Browse Source

Created / Updated fields for clients (#3384)

* feat(backend): add created_at/updated_at to clients and maintain on create/update
backfill existing clients and set updated_at on mutations

* feat(frontend): carry created_at/updated_at in client models and round-trip via JSON

* feat(frontend): display Created and Updated columns in client table with proper date formatting

* i18n: add pages.inbounds.createdAt/updatedAt across all locales

* Update inbound.go

Remove duplicate code
Ali Golzar 1 week ago
parent
commit
2198397197

+ 2 - 0
database/model/model.go

@@ -104,4 +104,6 @@ type Client struct {
 	SubID      string `json:"subId" form:"subId"`
 	Comment    string `json:"comment" form:"comment"`
 	Reset      int    `json:"reset" form:"reset"`
+	CreatedAt  int64  `json:"created_at,omitempty"`
+	UpdatedAt  int64  `json:"updated_at,omitempty"`
 }

+ 32 - 4
web/assets/js/model/inbound.js

@@ -1817,7 +1817,9 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass {
         tgId = '',
         subId = RandomUtil.randomLowerAndNum(16),
         comment = '',
-        reset = 0
+        reset = 0,
+        created_at = undefined,
+        updated_at = undefined
     ) {
         super();
         this.id = id;
@@ -1831,6 +1833,8 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass {
         this.subId = subId;
         this.comment = comment;
         this.reset = reset;
+        this.created_at = created_at;
+        this.updated_at = updated_at;
     }
 
     static fromJson(json = {}) {
@@ -1846,6 +1850,8 @@ Inbound.VmessSettings.VMESS = class extends XrayCommonClass {
             json.subId,
             json.comment,
             json.reset,
+            json.created_at,
+            json.updated_at,
         );
     }
     get _expiryTime() {
@@ -1926,7 +1932,9 @@ Inbound.VLESSSettings.VLESS = class extends XrayCommonClass {
         tgId = '',
         subId = RandomUtil.randomLowerAndNum(16),
         comment = '',
-        reset = 0
+        reset = 0,
+        created_at = undefined,
+        updated_at = undefined
     ) {
         super();
         this.id = id;
@@ -1940,6 +1948,8 @@ Inbound.VLESSSettings.VLESS = class extends XrayCommonClass {
         this.subId = subId;
         this.comment = comment;
         this.reset = reset;
+        this.created_at = created_at;
+        this.updated_at = updated_at;
     }
 
     static fromJson(json = {}) {
@@ -1955,6 +1965,8 @@ Inbound.VLESSSettings.VLESS = class extends XrayCommonClass {
             json.subId,
             json.comment,
             json.reset,
+            json.created_at,
+            json.updated_at,
         );
     }
 
@@ -2065,7 +2077,9 @@ Inbound.TrojanSettings.Trojan = class extends XrayCommonClass {
         tgId = '',
         subId = RandomUtil.randomLowerAndNum(16),
         comment = '',
-        reset = 0
+        reset = 0,
+        created_at = undefined,
+        updated_at = undefined
     ) {
         super();
         this.password = password;
@@ -2078,6 +2092,8 @@ Inbound.TrojanSettings.Trojan = class extends XrayCommonClass {
         this.subId = subId;
         this.comment = comment;
         this.reset = reset;
+        this.created_at = created_at;
+        this.updated_at = updated_at;
     }
 
     toJson() {
@@ -2092,6 +2108,8 @@ Inbound.TrojanSettings.Trojan = class extends XrayCommonClass {
             subId: this.subId,
             comment: this.comment,
             reset: this.reset,
+            created_at: this.created_at,
+            updated_at: this.updated_at,
         };
     }
 
@@ -2107,6 +2125,8 @@ Inbound.TrojanSettings.Trojan = class extends XrayCommonClass {
             json.subId,
             json.comment,
             json.reset,
+            json.created_at,
+            json.updated_at,
         );
     }
 
@@ -2226,7 +2246,9 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends XrayCommonClass {
         tgId = '',
         subId = RandomUtil.randomLowerAndNum(16),
         comment = '',
-        reset = 0
+        reset = 0,
+        created_at = undefined,
+        updated_at = undefined
     ) {
         super();
         this.method = method;
@@ -2240,6 +2262,8 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends XrayCommonClass {
         this.subId = subId;
         this.comment = comment;
         this.reset = reset;
+        this.created_at = created_at;
+        this.updated_at = updated_at;
     }
 
     toJson() {
@@ -2255,6 +2279,8 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends XrayCommonClass {
             subId: this.subId,
             comment: this.comment,
             reset: this.reset,
+            created_at: this.created_at,
+            updated_at: this.updated_at,
         };
     }
 
@@ -2271,6 +2297,8 @@ Inbound.ShadowsocksSettings.Shadowsocks = class extends XrayCommonClass {
             json.subId,
             json.comment,
             json.reset,
+            json.created_at,
+            json.updated_at,
         );
     }
 

+ 26 - 0
web/html/component/aClientTable.html

@@ -278,4 +278,30 @@
     </a-badge>
   </a-popover>
 </template>
+<template slot="createdAt" slot-scope="text, client, index">
+  <template v-if="client.created_at">
+    <template v-if="app.datepicker === 'gregorian'">
+      [[ DateUtil.formatMillis(client.created_at) ]]
+    </template>
+    <template v-else>
+      [[ DateUtil.convertToJalalian(moment(client.created_at)) ]]
+    </template>
+  </template>
+  <template v-else>
+    -
+  </template>
+</template>
+<template slot="updatedAt" slot-scope="text, client, index">
+  <template v-if="client.updated_at">
+    <template v-if="app.datepicker === 'gregorian'">
+      [[ DateUtil.formatMillis(client.updated_at) ]]
+    </template>
+    <template v-else>
+      [[ DateUtil.convertToJalalian(moment(client.updated_at)) ]]
+    </template>
+  </template>
+  <template v-else>
+    -
+  </template>
+</template>
 {{end}}

+ 2 - 0
web/html/inbounds.html

@@ -760,6 +760,8 @@
         { title: '{{ i18n "pages.inbounds.client" }}', width: 80, scopedSlots: { customRender: 'client' } },
         { title: '{{ i18n "pages.inbounds.traffic" }}', width: 80, align: 'center', scopedSlots: { customRender: 'traffic' } },
         { title: '{{ i18n "pages.inbounds.expireDate" }}', width: 80, align: 'center', scopedSlots: { customRender: 'expiryTime' } },
+        { title: '{{ i18n "pages.inbounds.createdAt" }}', width: 90, align: 'center', scopedSlots: { customRender: 'createdAt' } },
+        { title: '{{ i18n "pages.inbounds.updatedAt" }}', width: 90, align: 'center', scopedSlots: { customRender: 'updatedAt' } },
     ];
 
     const innerMobileColumns = [

+ 117 - 0
web/service/inbound.go

@@ -175,6 +175,30 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 		return inbound, false, err
 	}
 
+	// Ensure created_at and updated_at on clients in settings
+	if len(clients) > 0 {
+		var settings map[string]any
+		if err2 := json.Unmarshal([]byte(inbound.Settings), &settings); err2 == nil && settings != nil {
+			now := time.Now().Unix() * 1000
+			updatedClients := make([]model.Client, 0, len(clients))
+			for _, c := range clients {
+				if c.CreatedAt == 0 {
+					c.CreatedAt = now
+				}
+				c.UpdatedAt = now
+				updatedClients = append(updatedClients, c)
+			}
+			settings["clients"] = updatedClients
+			if bs, err3 := json.MarshalIndent(settings, "", "  "); err3 == nil {
+				inbound.Settings = string(bs)
+			} else {
+				logger.Debug("Unable to marshal inbound settings with timestamps:", err3)
+			}
+		} else if err2 != nil {
+			logger.Debug("Unable to parse inbound settings for timestamps:", err2)
+		}
+	}
+
 	// Secure client ID
 	for _, client := range clients {
 		switch inbound.Protocol {
@@ -320,6 +344,53 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 		return inbound, false, err
 	}
 
+	// Ensure created_at and updated_at exist in inbound.Settings clients
+	{
+		var oldSettings map[string]any
+		_ = json.Unmarshal([]byte(oldInbound.Settings), &oldSettings)
+		emailToCreated := map[string]int64{}
+		if oldSettings != nil {
+			if oc, ok := oldSettings["clients"].([]any); ok {
+				for _, it := range oc {
+					if m, ok2 := it.(map[string]any); ok2 {
+						if email, ok3 := m["email"].(string); ok3 {
+							switch v := m["created_at"].(type) {
+							case float64:
+								emailToCreated[email] = int64(v)
+							case int64:
+								emailToCreated[email] = v
+							}
+						}
+					}
+				}
+			}
+		}
+		var newSettings map[string]any
+		if err2 := json.Unmarshal([]byte(inbound.Settings), &newSettings); err2 == nil && newSettings != nil {
+			now := time.Now().Unix() * 1000
+			if nSlice, ok := newSettings["clients"].([]any); ok {
+				for i := range nSlice {
+					if m, ok2 := nSlice[i].(map[string]any); ok2 {
+						email, _ := m["email"].(string)
+						if _, ok3 := m["created_at"]; !ok3 {
+							if v, ok4 := emailToCreated[email]; ok4 && v > 0 {
+								m["created_at"] = v
+							} else {
+								m["created_at"] = now
+							}
+						}
+						m["updated_at"] = now
+						nSlice[i] = m
+					}
+				}
+				newSettings["clients"] = nSlice
+				if bs, err3 := json.MarshalIndent(newSettings, "", "  "); err3 == nil {
+					inbound.Settings = string(bs)
+				}
+			}
+		}
+	}
+
 	oldInbound.Up = inbound.Up
 	oldInbound.Down = inbound.Down
 	oldInbound.Total = inbound.Total
@@ -422,6 +493,17 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
 	}
 
 	interfaceClients := settings["clients"].([]any)
+	// Add timestamps for new clients being appended
+	nowTs := time.Now().Unix() * 1000
+	for i := range interfaceClients {
+		if cm, ok := interfaceClients[i].(map[string]any); ok {
+			if _, ok2 := cm["created_at"]; !ok2 {
+				cm["created_at"] = nowTs
+			}
+			cm["updated_at"] = nowTs
+			interfaceClients[i] = cm
+		}
+	}
 	existEmail, err := s.checkEmailsExistForClients(clients)
 	if err != nil {
 		return false, err
@@ -672,6 +754,25 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
 		return false, err
 	}
 	settingsClients := oldSettings["clients"].([]any)
+	// Preserve created_at and set updated_at for the replacing client
+	var preservedCreated any
+	if clientIndex >= 0 && clientIndex < len(settingsClients) {
+		if oldMap, ok := settingsClients[clientIndex].(map[string]any); ok {
+			if v, ok2 := oldMap["created_at"]; ok2 {
+				preservedCreated = v
+			}
+		}
+	}
+	if len(interfaceClients) > 0 {
+		if newMap, ok := interfaceClients[0].(map[string]any); ok {
+			if preservedCreated == nil {
+				preservedCreated = time.Now().Unix() * 1000
+			}
+			newMap["created_at"] = preservedCreated
+			newMap["updated_at"] = time.Now().Unix() * 1000
+			interfaceClients[0] = newMap
+		}
+	}
 	settingsClients[clientIndex] = interfaceClients[0]
 	oldSettings["clients"] = settingsClients
 
@@ -909,10 +1010,16 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
 							oldExpiryTime := c["expiryTime"].(float64)
 							newExpiryTime := (time.Now().Unix() * 1000) - int64(oldExpiryTime)
 							c["expiryTime"] = newExpiryTime
+							c["updated_at"] = time.Now().Unix() * 1000
 							dbClientTraffics[traffic_index].ExpiryTime = newExpiryTime
 							break
 						}
 					}
+					// Backfill created_at and updated_at
+					if _, ok := c["created_at"]; !ok {
+						c["created_at"] = time.Now().Unix() * 1000
+					}
+					c["updated_at"] = time.Now().Unix() * 1000
 					newClients = append(newClients, any(c))
 				}
 				settings["clients"] = newClients
@@ -1274,6 +1381,7 @@ func (s *InboundService) SetClientTelegramUserID(trafficId int, tgId int64) (boo
 		c := clients[client_index].(map[string]any)
 		if c["email"] == clientEmail {
 			c["tgId"] = tgId
+			c["updated_at"] = time.Now().Unix() * 1000
 			newClients = append(newClients, any(c))
 		}
 	}
@@ -1360,6 +1468,7 @@ func (s *InboundService) ToggleClientEnableByEmail(clientEmail string) (bool, bo
 		c := clients[client_index].(map[string]any)
 		if c["email"] == clientEmail {
 			c["enable"] = !clientOldEnabled
+			c["updated_at"] = time.Now().Unix() * 1000
 			newClients = append(newClients, any(c))
 		}
 	}
@@ -1423,6 +1532,7 @@ func (s *InboundService) ResetClientIpLimitByEmail(clientEmail string, count int
 		c := clients[client_index].(map[string]any)
 		if c["email"] == clientEmail {
 			c["limitIp"] = count
+			c["updated_at"] = time.Now().Unix() * 1000
 			newClients = append(newClients, any(c))
 		}
 	}
@@ -1481,6 +1591,7 @@ func (s *InboundService) ResetClientExpiryTimeByEmail(clientEmail string, expiry
 		c := clients[client_index].(map[string]any)
 		if c["email"] == clientEmail {
 			c["expiryTime"] = expiry_time
+			c["updated_at"] = time.Now().Unix() * 1000
 			newClients = append(newClients, any(c))
 		}
 	}
@@ -1542,6 +1653,7 @@ func (s *InboundService) ResetClientTrafficLimitByEmail(clientEmail string, tota
 		c := clients[client_index].(map[string]any)
 		if c["email"] == clientEmail {
 			c["totalGB"] = totalGB * 1024 * 1024 * 1024
+			c["updated_at"] = time.Now().Unix() * 1000
 			newClients = append(newClients, any(c))
 		}
 	}
@@ -1962,6 +2074,11 @@ func (s *InboundService) MigrationRequirements() {
 						c["flow"] = ""
 					}
 				}
+				// Backfill created_at and updated_at
+				if _, ok := c["created_at"]; !ok {
+					c["created_at"] = time.Now().Unix() * 1000
+				}
+				c["updated_at"] = time.Now().Unix() * 1000
 				newClients = append(newClients, any(c))
 			}
 			settings["clients"] = newClients

+ 2 - 0
web/translation/translate.ar_EG.toml

@@ -165,6 +165,8 @@
 "details" = "تفاصيل"
 "transportConfig" = "نقل"
 "expireDate" = "المدة"
+"createdAt" = "تاريخ الإنشاء"
+"updatedAt" = "تاريخ التحديث"
 "resetTraffic" = "إعادة ضبط الترافيك"
 "addInbound" = "أضف إدخال"
 "generalActions" = "إجراءات عامة"

+ 2 - 0
web/translation/translate.en_US.toml

@@ -165,6 +165,8 @@
 "details" = "Details"
 "transportConfig" = "Transport"
 "expireDate" = "Duration"
+"createdAt" = "Created"
+"updatedAt" = "Updated"
 "resetTraffic" = "Reset Traffic"
 "addInbound" = "Add Inbound"
 "generalActions" = "General Actions"

+ 2 - 0
web/translation/translate.es_ES.toml

@@ -165,6 +165,8 @@
 "details" = "Detalles"
 "transportConfig" = "Transporte"
 "expireDate" = "Fecha de Expiración"
+"createdAt" = "Creado"
+"updatedAt" = "Actualizado"
 "resetTraffic" = "Restablecer Tráfico"
 "addInbound" = "Agregar Entrada"
 "generalActions" = "Acciones Generales"

+ 2 - 0
web/translation/translate.fa_IR.toml

@@ -165,6 +165,8 @@
 "details" = "توضیحات"
 "transportConfig" = "نحوه اتصال"
 "expireDate" = "مدت زمان"
+"createdAt" = "ایجاد"
+"updatedAt" = "به‌روزرسانی"
 "resetTraffic" = "ریست ترافیک"
 "addInbound" = "افزودن ورودی"
 "generalActions" = "عملیات کلی"

+ 2 - 0
web/translation/translate.id_ID.toml

@@ -165,6 +165,8 @@
 "details" = "Rincian"
 "transportConfig" = "Transport"
 "expireDate" = "Durasi"
+"createdAt" = "Dibuat"
+"updatedAt" = "Diperbarui"
 "resetTraffic" = "Reset Traffic"
 "addInbound" = "Tambahkan Masuk"
 "generalActions" = "Tindakan Umum"

+ 2 - 0
web/translation/translate.ja_JP.toml

@@ -165,6 +165,8 @@
 "details" = "詳細情報"
 "transportConfig" = "トランスポート設定"
 "expireDate" = "有効期限"
+"createdAt" = "作成"
+"updatedAt" = "更新"
 "resetTraffic" = "トラフィックリセット"
 "addInbound" = "インバウンド追加"
 "generalActions" = "一般操作"

+ 2 - 0
web/translation/translate.pt_BR.toml

@@ -165,6 +165,8 @@
 "details" = "Detalhes"
 "transportConfig" = "Transporte"
 "expireDate" = "Duração"
+"createdAt" = "Criado"
+"updatedAt" = "Atualizado"
 "resetTraffic" = "Redefinir Tráfego"
 "addInbound" = "Adicionar Inbound"
 "generalActions" = "Ações Gerais"

+ 2 - 0
web/translation/translate.ru_RU.toml

@@ -165,6 +165,8 @@
 "details" = "Подробнее"
 "transportConfig" = "Транспорт"
 "expireDate" = "Дата окончания"
+"createdAt" = "Создано"
+"updatedAt" = "Обновлено"
 "resetTraffic" = "Сброс трафика"
 "addInbound" = "Создать инбаунд"
 "generalActions" = "Общие действия"

+ 2 - 0
web/translation/translate.tr_TR.toml

@@ -165,6 +165,8 @@
 "details" = "Detaylar"
 "transportConfig" = "Taşıma"
 "expireDate" = "Süre"
+"createdAt" = "Oluşturuldu"
+"updatedAt" = "Güncellendi"
 "resetTraffic" = "Trafiği Sıfırla"
 "addInbound" = "Gelen Ekle"
 "generalActions" = "Genel Eylemler"

+ 2 - 0
web/translation/translate.uk_UA.toml

@@ -165,6 +165,8 @@
 "details" = "Деталі"
 "transportConfig" = "Транспорт"
 "expireDate" = "Тривалість"
+"createdAt" = "Створено"
+"updatedAt" = "Оновлено"
 "resetTraffic" = "Скинути трафік"
 "addInbound" = "Додати вхідний"
 "generalActions" = "Загальні дії"

+ 2 - 0
web/translation/translate.vi_VN.toml

@@ -165,6 +165,8 @@
 "details" = "Chi tiết"
 "transportConfig" = "Giao vận"
 "expireDate" = "Ngày hết hạn"
+"createdAt" = "Tạo lúc"
+"updatedAt" = "Cập nhật"
 "resetTraffic" = "Đặt lại lưu lượng"
 "addInbound" = "Thêm điểm vào"
 "generalActions" = "Hành động chung"

+ 2 - 0
web/translation/translate.zh_CN.toml

@@ -165,6 +165,8 @@
 "details" = "详细信息"
 "transportConfig" = "传输配置"
 "expireDate" = "到期时间"
+"createdAt" = "创建时间"
+"updatedAt" = "更新时间"
 "resetTraffic" = "重置流量"
 "addInbound" = "添加入站"
 "generalActions" = "通用操作"

+ 2 - 0
web/translation/translate.zh_TW.toml

@@ -165,6 +165,8 @@
 "details" = "詳細資訊"
 "transportConfig" = "傳輸配置"
 "expireDate" = "到期時間"
+"createdAt" = "建立時間"
+"updatedAt" = "更新時間"
 "resetTraffic" = "重置流量"
 "addInbound" = "新增入站"
 "generalActions" = "通用操作"