Przeglądaj źródła

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.
MHSanaei 7 godzin temu
rodzic
commit
7680e27d1d

+ 2 - 2
frontend/src/pages/clients/ClientsPage.css

@@ -178,7 +178,7 @@
 .card-empty {
   text-align: center;
   padding: 40px 16px;
-  opacity: 0.55;
+  color: var(--ant-color-text-secondary);
   display: flex;
   flex-direction: column;
   align-items: center;
@@ -188,5 +188,5 @@
 .clients-empty {
   padding: 32px 0;
   text-align: center;
-  opacity: 0.55;
+  color: var(--ant-color-text-secondary);
 }

+ 164 - 144
frontend/src/pages/clients/ClientsPage.tsx

@@ -14,6 +14,7 @@ import {
   Pagination,
   Popover,
   Row,
+  Select,
   Space,
   Spin,
   Statistic,
@@ -36,8 +37,8 @@ import {
   RestOutlined,
   RetweetOutlined,
   SearchOutlined,
+  SortAscendingOutlined,
   TeamOutlined,
-  UserOutlined,
   UsergroupAddOutlined,
 } from '@ant-design/icons';
 
@@ -108,6 +109,25 @@ function gbToBytes(gb: number | undefined): number {
   return Math.round(gb * 1024 * 1024 * 1024);
 }
 
+const SORT_OPTIONS: { value: string; column: string; order: 'ascend' | 'descend'; labelKey: string }[] = [
+  { value: 'createdAt:ascend',    column: 'createdAt',  order: 'ascend',   labelKey: 'pages.clients.sortOldest' },
+  { value: 'createdAt:descend',   column: 'createdAt',  order: 'descend',  labelKey: 'pages.clients.sortNewest' },
+  { value: 'updatedAt:descend',   column: 'updatedAt',  order: 'descend',  labelKey: 'pages.clients.sortRecentlyUpdated' },
+  { value: 'lastOnline:descend',  column: 'lastOnline', order: 'descend',  labelKey: 'pages.clients.sortRecentlyOnline' },
+  { value: 'email:ascend',        column: 'email',      order: 'ascend',   labelKey: 'pages.clients.sortEmailAZ' },
+  { value: 'email:descend',       column: 'email',      order: 'descend',  labelKey: 'pages.clients.sortEmailZA' },
+  { value: 'traffic:descend',     column: 'traffic',    order: 'descend',  labelKey: 'pages.clients.sortMostTraffic' },
+  { value: 'remaining:descend',   column: 'remaining',  order: 'descend',  labelKey: 'pages.clients.sortHighestRemaining' },
+  { value: 'expiryTime:ascend',   column: 'expiryTime', order: 'ascend',   labelKey: 'pages.clients.sortExpiringSoonest' },
+];
+
+const DEFAULT_SORT = SORT_OPTIONS[0];
+
+function sortValueFor(column: string | null, order: 'ascend' | 'descend' | null): string {
+  if (!column || !order) return DEFAULT_SORT.value;
+  return `${column}:${order}`;
+}
+
 export default function ClientsPage() {
   const { t } = useTranslation();
   const { isDark, isUltra, antdThemeConfig } = useTheme();
@@ -152,8 +172,8 @@ export default function ClientsPage() {
   const [filters, setFilters] = useState<ClientFilters>(initial.filters);
   const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
 
-  const [sortColumn, setSortColumn] = useState<string | null>(null);
-  const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(null);
+  const [sortColumn, setSortColumn] = useState<string | null>(DEFAULT_SORT.column);
+  const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>(DEFAULT_SORT.order);
   const [currentPage, setCurrentPage] = useState(1);
   const [tablePageSize, setTablePageSize] = useState(25);
   // debouncedSearch lags behind the input so we don't spam the server on every
@@ -475,151 +495,139 @@ export default function ClientsPage() {
     return classes.join(' ');
   }, [isDark, isUltra]);
 
-  const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag, _filters, sorter) => {
+  const onTableChange: NonNullable<TableProps<ClientRecord>['onChange']> = (pag) => {
     if (pag?.current) setCurrentPage(pag.current);
     if (pag?.pageSize) setTablePageSize(pag.pageSize);
-    const s = Array.isArray(sorter) ? sorter[0] : sorter;
-    setSortColumn((s?.columnKey as string) || (s?.field as string) || null);
-    setSortOrder((s?.order as 'ascend' | 'descend' | null) || null);
   };
 
-  const columns = useMemo<ColumnsType<ClientRecord>>(() => {
-    function sortableCol<T extends ColumnsType<ClientRecord>[number]>(col: T, key: string): T {
-      return {
-        ...col,
-        sorter: true,
-        showSorterTooltip: false,
-        sortOrder: sortColumn === key ? sortOrder : null,
-        sortDirections: ['ascend', 'descend'],
-      };
-    }
-    return [
-      {
-        title: t('pages.clients.actions'),
-        key: 'actions',
-        width: 200,
-        render: (_v, record) => (
-          <Space size={4}>
-            <Tooltip title={t('pages.clients.qrCode')}>
-              <Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
-            </Tooltip>
-            <Tooltip title={t('pages.clients.moreInformation')}>
-              <Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
-            </Tooltip>
-            <Tooltip title={t('pages.inbounds.resetTraffic')}>
-              <Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
-            </Tooltip>
-            <Tooltip title={t('edit')}>
-              <Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
-            </Tooltip>
-            <Tooltip title={t('delete')}>
-              <Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
-            </Tooltip>
-          </Space>
-        ),
-      },
-      sortableCol({
-        title: t('pages.clients.enabled'), key: 'enable', width: 80,
-        render: (_v, record) => (
-          <Switch
-            checked={!!record.enable}
-            size="small"
-            loading={togglingEmail === record.email}
-            onChange={(next) => onToggleEnable(record, next)}
-          />
-        ),
-      }, 'enable'),
-      {
-        title: t('pages.clients.online'),
-        key: 'online',
-        width: 90,
-        render: (_v, record) => {
-          const bucket = clientBucket(record);
-          if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
-          if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
-          if (!record.enable) return <Tag>{t('disabled')}</Tag>;
-          if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
-          return <Tag>{t('pages.clients.offline')}</Tag>;
-        },
+  const columns = useMemo<ColumnsType<ClientRecord>>(() => [
+    {
+      title: t('pages.clients.actions'),
+      key: 'actions',
+      width: 200,
+      render: (_v, record) => (
+        <Space size={4}>
+          <Tooltip title={t('pages.clients.qrCode')}>
+            <Button size="small" type="text" icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
+          </Tooltip>
+          <Tooltip title={t('pages.clients.moreInformation')}>
+            <Button size="small" type="text" icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
+          </Tooltip>
+          <Tooltip title={t('pages.inbounds.resetTraffic')}>
+            <Button size="small" type="text" icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
+          </Tooltip>
+          <Tooltip title={t('edit')}>
+            <Button size="small" type="text" icon={<EditOutlined />} onClick={() => onEdit(record)} />
+          </Tooltip>
+          <Tooltip title={t('delete')}>
+            <Button size="small" type="text" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
+          </Tooltip>
+        </Space>
+      ),
+    },
+    {
+      title: t('pages.clients.enabled'),
+      key: 'enable',
+      width: 80,
+      render: (_v, record) => (
+        <Switch
+          checked={!!record.enable}
+          size="small"
+          loading={togglingEmail === record.email}
+          onChange={(next) => onToggleEnable(record, next)}
+        />
+      ),
+    },
+    {
+      title: t('pages.clients.online'),
+      key: 'online',
+      width: 90,
+      render: (_v, record) => {
+        const bucket = clientBucket(record);
+        if (bucket === 'depleted') return <Tag color="red">{t('depleted')}</Tag>;
+        if (record.enable && isOnline(record.email)) return <Tag color="green">{t('pages.clients.online')}</Tag>;
+        if (!record.enable) return <Tag>{t('disabled')}</Tag>;
+        if (bucket === 'expiring') return <Tag color="orange">{t('depletingSoon')}</Tag>;
+        return <Tag>{t('pages.clients.offline')}</Tag>;
       },
-      sortableCol({
-        title: t('pages.clients.client'),
-        key: 'email',
-        render: (_v, record) => (
-          <div className="email-cell">
-            <span className="email">{record.email}</span>
-            {record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
-            {record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
-          </div>
-        ),
-      }, 'email'),
-      sortableCol({
-        title: t('pages.clients.attachedInbounds'),
-        key: 'inboundIds',
-        width: 170,
-        render: (_v, record) => {
-          const ids = record.inboundIds || [];
-          if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
-          const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
-          const overflow = ids.slice(INBOUND_CHIP_LIMIT);
-          const chip = (id: number, compact: boolean) => {
-            const ib = inboundsById[id];
-            const proto = (ib?.protocol || '').toLowerCase();
-            const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
-            const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
-            return (
-              <Tooltip key={id} title={inboundLabel(id)}>
-                <Tag color={color} style={{ margin: 2 }}>
-                  {compact ? compactLabel : inboundLabel(id)}
-                </Tag>
-              </Tooltip>
-            );
-          };
+    },
+    {
+      title: t('pages.clients.client'),
+      key: 'email',
+      render: (_v, record) => (
+        <div className="email-cell">
+          <span className="email">{record.email}</span>
+          {record.subId && <span className="sub" title={record.subId}>{record.subId}</span>}
+          {record.comment && <span className="sub" title={record.comment}>{record.comment}</span>}
+        </div>
+      ),
+    },
+    {
+      title: t('pages.clients.attachedInbounds'),
+      key: 'inboundIds',
+      width: 170,
+      render: (_v, record) => {
+        const ids = record.inboundIds || [];
+        if (ids.length === 0) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
+        const visible = ids.slice(0, INBOUND_CHIP_LIMIT);
+        const overflow = ids.slice(INBOUND_CHIP_LIMIT);
+        const chip = (id: number, compact: boolean) => {
+          const ib = inboundsById[id];
+          const proto = (ib?.protocol || '').toLowerCase();
+          const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
+          const compactLabel = ib ? `${ib.protocol}:${ib.port}` : `#${id}`;
           return (
-            <>
-              {visible.map((id) => chip(id, true))}
-              {overflow.length > 0 && (
-                <Popover
-                  trigger="click"
-                  placement="bottomRight"
-                  content={
-                    <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
-                      {overflow.map((id) => chip(id, false))}
-                    </div>
-                  }
-                >
-                  <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
-                    +{overflow.length}
-                  </Tag>
-                </Popover>
-              )}
-            </>
+            <Tooltip key={id} title={inboundLabel(id)}>
+              <Tag color={color} style={{ margin: 2 }}>
+                {compact ? compactLabel : inboundLabel(id)}
+              </Tag>
+            </Tooltip>
           );
-        },
-      }, 'inboundIds'),
-      sortableCol({
-        title: t('pages.clients.traffic'),
-        key: 'traffic',
-        render: (_v, record) => trafficLabel(record),
-      }, 'traffic'),
-      sortableCol({
-        title: t('pages.clients.remaining'),
-        key: 'remaining',
-        width: 130,
-        render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
-      }, 'remaining'),
-      sortableCol({
-        title: t('pages.clients.duration'),
-        key: 'expiryTime',
-        render: (_v, record) => (
-          <Tooltip title={expiryLabel(record)}>
-            <Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
-          </Tooltip>
-        ),
-      }, 'expiryTime'),
-    ];
+        };
+        return (
+          <>
+            {visible.map((id) => chip(id, true))}
+            {overflow.length > 0 && (
+              <Popover
+                trigger="click"
+                placement="bottomRight"
+                content={
+                  <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
+                    {overflow.map((id) => chip(id, false))}
+                  </div>
+                }
+              >
+                <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
+                  +{overflow.length}
+                </Tag>
+              </Popover>
+            )}
+          </>
+        );
+      },
+    },
+    {
+      title: t('pages.clients.traffic'),
+      key: 'traffic',
+      render: (_v, record) => trafficLabel(record),
+    },
+    {
+      title: t('pages.clients.remaining'),
+      key: 'remaining',
+      width: 130,
+      render: (_v, record) => <Tag color={remainingColor(record)}>{remainingLabel(record)}</Tag>,
+    },
+    {
+      title: t('pages.clients.duration'),
+      key: 'expiryTime',
+      render: (_v, record) => (
+        <Tooltip title={expiryLabel(record)}>
+          <Tag color={expiryColor(record)}>{record.expiryTime ? expiryRelative(record) : '∞'}</Tag>
+        </Tooltip>
+      ),
+    },
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [t, togglingEmail, sortColumn, sortOrder, clientBucket, isOnline, inboundsById]);
+  ], [t, togglingEmail, clientBucket, isOnline, inboundsById]);
 
   const tablePagination = {
     current: currentPage,
@@ -777,6 +785,18 @@ export default function ClientsPage() {
                             {!isMobile && t('filter')}
                           </Button>
                         </Badge>
+                        <Select
+                          value={sortValueFor(sortColumn, sortOrder)}
+                          size={isMobile ? 'small' : 'middle'}
+                          suffixIcon={<SortAscendingOutlined />}
+                          style={{ minWidth: isMobile ? 130 : 200 }}
+                          onChange={(value) => {
+                            const opt = SORT_OPTIONS.find((o) => o.value === value);
+                            setSortColumn(opt?.column ?? null);
+                            setSortOrder(opt?.order ?? null);
+                          }}
+                          options={SORT_OPTIONS.map((o) => ({ value: o.value, label: t(o.labelKey) }))}
+                        />
                         {activeCount > 0 && (
                           <Button
                             size={isMobile ? 'small' : 'middle'}
@@ -862,8 +882,8 @@ export default function ClientsPage() {
                           locale={{
                             emptyText: (
                               <div className="clients-empty">
-                                <UserOutlined style={{ fontSize: 32, marginBottom: 8 }} />
-                                <div>{t('pages.clients.empty')}</div>
+                                <TeamOutlined style={{ fontSize: 32, marginBottom: 8 }} />
+                                <div>{t('noData')}</div>
                               </div>
                             ),
                           }}
@@ -887,8 +907,8 @@ export default function ClientsPage() {
                             )}
                             {filteredClients.length === 0 && (
                               <div className="card-empty">
-                                <UserOutlined style={{ fontSize: 28, opacity: 0.5 }} />
-                                <div>{t('pages.clients.empty')}</div>
+                                <TeamOutlined style={{ fontSize: 28, opacity: 0.5 }} />
+                                <div>{t('noData')}</div>
                               </div>
                             )}
                             {filteredClients.length > 0 && (

+ 33 - 3
web/service/client.go

@@ -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
 	}

+ 3 - 2
web/service/inbound.go

@@ -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

+ 10 - 2
web/translation/en-US.json

@@ -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})",