mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-11 15:16:07 +00:00
feat(clients): toolbar sort selector + preserve updated_at on unchanged rows
Frontend
- New Sort dropdown in the clients toolbar covering oldest/newest,
recently updated, recently online, email A↔Z, most traffic, highest
remaining, expiring soonest. Default is Oldest first.
- Strip per-column sorter arrows from the Table — all sorting now flows
through the single dropdown, so the column headers stop competing
with it.
- Empty state: TeamOutlined icon, t('noData'), text-secondary color
(matching the inbound/node polish).
Backend
- sortClients: add createdAt, updatedAt and lastOnline cases (with id
tie-break for stable ordering when timestamps collide).
- Fix Recently updated: SyncInbound was calling tx.Save on every client
in the inbound, and GORM's autoUpdateTime tag stamped updated_at to
time.Now() each time — so editing one client bumped ALL of them.
After the Save, restore each row's preserved updated_at via
UpdateColumn (skips hooks). The actually-edited client gets its
fresh stamp from the explicit UpdateColumn at the end of Update().
- Fix periodic updated_at churn: adjustTraffics unconditionally set
c["updated_at"] = now() for every client in any inbound that had a
delayed-start expiry, every traffic-stats pass. Turn that into a
backfill (only when the key is missing), matching the created_at
treatment one line above.
This commit is contained in:
+33
-3
@@ -242,12 +242,19 @@ func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.
|
||||
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
|
||||
row.CreatedAt = incoming.CreatedAt
|
||||
}
|
||||
if incoming.UpdatedAt > row.UpdatedAt {
|
||||
row.UpdatedAt = incoming.UpdatedAt
|
||||
preservedUpdatedAt := row.UpdatedAt
|
||||
if incoming.UpdatedAt > preservedUpdatedAt {
|
||||
preservedUpdatedAt = incoming.UpdatedAt
|
||||
}
|
||||
row.UpdatedAt = preservedUpdatedAt
|
||||
if err := tx.Save(row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.ClientRecord{}).
|
||||
Where("id = ?", row.Id).
|
||||
UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
link := model.ClientInbound{
|
||||
@@ -648,7 +655,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
|
||||
|
||||
if err := database.GetDB().Model(&model.ClientRecord{}).
|
||||
Where("id = ?", id).
|
||||
Update("updated_at", updated.UpdatedAt).Error; err != nil {
|
||||
UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
return needRestart, nil
|
||||
@@ -1343,6 +1350,29 @@ func sortClients(rows []ClientWithAttachments, sortKey, order string) {
|
||||
eb = b.ExpiryTime
|
||||
}
|
||||
return ea < eb
|
||||
case "createdAt":
|
||||
if a.CreatedAt == b.CreatedAt {
|
||||
return a.Id < b.Id
|
||||
}
|
||||
return a.CreatedAt < b.CreatedAt
|
||||
case "updatedAt":
|
||||
if a.UpdatedAt == b.UpdatedAt {
|
||||
return a.Id < b.Id
|
||||
}
|
||||
return a.UpdatedAt < b.UpdatedAt
|
||||
case "lastOnline":
|
||||
la := int64(0)
|
||||
if a.Traffic != nil {
|
||||
la = a.Traffic.LastOnline
|
||||
}
|
||||
lb := int64(0)
|
||||
if b.Traffic != nil {
|
||||
lb = b.Traffic.LastOnline
|
||||
}
|
||||
if la == lb {
|
||||
return a.Id < b.Id
|
||||
}
|
||||
return la < lb
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1745,11 +1745,12 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
|
||||
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
|
||||
if _, ok := c["updated_at"]; !ok {
|
||||
c["updated_at"] = time.Now().Unix() * 1000
|
||||
}
|
||||
newClients = append(newClients, any(c))
|
||||
}
|
||||
settings["clients"] = newClients
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"reset": "Reset",
|
||||
"noData": "No data.",
|
||||
"noData": "Nothing here yet",
|
||||
"copySuccess": "Copied successfully",
|
||||
"sure": "Sure",
|
||||
"encryption": "Encryption",
|
||||
@@ -461,6 +461,15 @@
|
||||
"searchPlaceholder": "Search email, comment, sub ID, UUID, password, auth…",
|
||||
"filterTitle": "Filter clients",
|
||||
"clearAllFilters": "Clear all",
|
||||
"sortOldest": "Oldest first",
|
||||
"sortNewest": "Newest first",
|
||||
"sortRecentlyUpdated": "Recently updated",
|
||||
"sortRecentlyOnline": "Recently online",
|
||||
"sortEmailAZ": "Email A→Z",
|
||||
"sortEmailZA": "Email Z→A",
|
||||
"sortMostTraffic": "Most traffic",
|
||||
"sortHighestRemaining": "Highest remaining",
|
||||
"sortExpiringSoonest": "Expiring soonest",
|
||||
"has": "Has",
|
||||
"hasNot": "Doesn't have",
|
||||
"title": "Clients",
|
||||
@@ -496,7 +505,6 @@
|
||||
"resetAllTraffics": "Reset all client traffic",
|
||||
"resetAllTrafficsTitle": "Reset all client traffic?",
|
||||
"resetAllTrafficsContent": "Every client's up/down counter drops to zero. Quotas and expiry are not affected. This cannot be undone.",
|
||||
"empty": "No clients yet — add one to get started.",
|
||||
"deleteConfirmTitle": "Delete client {email}?",
|
||||
"deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.",
|
||||
"deleteSelected": "Delete ({count})",
|
||||
|
||||
Reference in New Issue
Block a user