ソースを参照

feat(ui): client-realtime-speed (#5687)

* refactor(inbounds): extract TRAFFIC_POLL_INTERVAL_S to shared util

* feat(clients): derive per-client live speed from traffic WebSocket deltas

* feat(clients): render speed column and mobile card line

* i18n(clients): add pages.clients.speed key to all 13 locales
Nikan Zeyaei 18 時間 前
コミット
b177e30714

+ 26 - 0
frontend/src/components/clients/ClientSpeedTag.tsx

@@ -0,0 +1,26 @@
+import { Tag } from 'antd';
+
+import { SizeFormatter } from '@/utils';
+import type { ClientSpeedEntry } from '@/hooks/useClients';
+
+export type { ClientSpeedEntry };
+
+export function isActiveSpeed(speed?: ClientSpeedEntry): speed is ClientSpeedEntry {
+  return !!speed && (speed.up > 0 || speed.down > 0);
+}
+
+interface ClientSpeedTagProps {
+  speed: ClientSpeedEntry;
+}
+
+export function ClientSpeedTag({ speed }: ClientSpeedTagProps) {
+  return (
+    <Tag color="blue">
+      ↑ {SizeFormatter.speedFormat(speed.up)}
+      {' / '}
+      ↓ {SizeFormatter.speedFormat(speed.down)}
+    </Tag>
+  );
+}
+
+export default ClientSpeedTag;

+ 23 - 1
frontend/src/hooks/useClients.ts

@@ -32,6 +32,7 @@ import {
   type BulkDetachResult,
 } from '@/schemas/client';
 import { DefaultsPayloadSchema } from '@/schemas/defaults';
+import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
 
 // One row sent to POST /clients/:email/externalLinks.
 export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
@@ -75,6 +76,11 @@ const DEFAULT_SUMMARY: ClientsSummary = {
   total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
 };
 
+export interface ClientSpeedEntry {
+  up: number;
+  down: number;
+}
+
 type ClientStatRow = ClientTraffic & { email?: string };
 
 // Mirror of the server's buildClientsSummary (web/service/client.go). The
@@ -264,6 +270,7 @@ export function useClients() {
   // back to the server-computed summary until the first event lands, and keeps
   // the server's authoritative total for the headline count.
   const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
+  const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
   const summary = useMemo<ClientsSummary>(() => {
     const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
     if (allClientStats.length === 0) return serverSummary;
@@ -543,10 +550,24 @@ export function useClients() {
 
   const applyTrafficEvent = useCallback((payload: unknown) => {
     if (!payload || typeof payload !== 'object') return;
-    const p = payload as { onlineClients?: string[] };
+    const p = payload as {
+      onlineClients?: string[];
+      clientTraffics?: { email: string; up: number; down: number }[];
+    };
     if (Array.isArray(p.onlineClients)) {
       queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
     }
+    if (Array.isArray(p.clientTraffics)) {
+      const next: Record<string, ClientSpeedEntry> = {};
+      for (const ct of p.clientTraffics) {
+        if (!ct || !ct.email) continue;
+        next[ct.email] = {
+          up: (ct.up || 0) / TRAFFIC_POLL_INTERVAL_S,
+          down: (ct.down || 0) / TRAFFIC_POLL_INTERVAL_S,
+        };
+      }
+      setClientSpeed(next);
+    }
   }, [queryClient]);
 
   const applyClientStatsEvent = useCallback((payload: unknown) => {
@@ -629,6 +650,7 @@ export function useClients() {
     exportClients,
     importClients,
     setEnable,
+    clientSpeed,
     applyTrafficEvent,
     applyClientStatsEvent,
   };

+ 1 - 0
frontend/src/lib/traffic/poll-interval.ts

@@ -0,0 +1 @@
+export const TRAFFIC_POLL_INTERVAL_S = 5;

+ 6 - 0
frontend/src/pages/clients/ClientsPage.css

@@ -162,6 +162,12 @@
   background: color-mix(in srgb, var(--ant-color-primary) 6%, transparent);
 }
 
+.client-card-speed {
+  margin-top: 6px;
+  font-size: 12px;
+  color: var(--ant-color-text-secondary);
+}
+
 .card-head {
   display: flex;
   align-items: center;

+ 23 - 1
frontend/src/pages/clients/ClientsPage.tsx

@@ -61,6 +61,7 @@ import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
 import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
+import ClientSpeedTag, { isActiveSpeed } from '@/components/clients/ClientSpeedTag';
 import AppSidebar from '@/layouts/AppSidebar';
 import { IntlUtil, SizeFormatter } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
@@ -209,6 +210,7 @@ export default function ClientsPage() {
     tgBotEnable, expireDiff, trafficDiff, pageSize,
     create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
+    clientSpeed,
     applyTrafficEvent, applyClientStatsEvent,
     refresh,
     hydrate,
@@ -902,6 +904,17 @@ export default function ClientsPage() {
         />
       ),
     },
+    {
+      title: t('pages.clients.speed'),
+      key: 'speed',
+      width: 110,
+      align: 'center',
+      render: (_v, record) => {
+        const speed = clientSpeed[record.email];
+        if (!isActiveSpeed(speed)) return <Tag color="default">—</Tag>;
+        return <ClientSpeedTag speed={speed} />;
+      },
+    },
     {
       title: t('pages.clients.remaining'),
       key: 'remaining',
@@ -919,7 +932,7 @@ export default function ClientsPage() {
       ),
     },
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups, datepicker, trafficDiff]);
+  ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups, datepicker, trafficDiff, clientSpeed]);
 
   const tablePagination = {
     current: currentPage,
@@ -1428,6 +1441,15 @@ export default function ClientsPage() {
                                     enabled={row.enable}
                                     trafficDiff={trafficDiff}
                                   />
+                                  {(() => {
+                                    const speed = clientSpeed[row.email];
+                                    if (!isActiveSpeed(speed)) return null;
+                                    return (
+                                      <div className="client-card-speed">
+                                        <ClientSpeedTag speed={speed} />
+                                      </div>
+                                    );
+                                  })()}
                                 </div>
                               );
                             })}

+ 1 - 4
frontend/src/pages/inbounds/useInbounds.ts

@@ -13,6 +13,7 @@ import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@
 import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
 
 import type { InboundSpeedEntry } from './list/types';
+import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
 
 export interface SubSettings {
   enable: boolean;
@@ -28,10 +29,6 @@ export interface SubSettings {
 
 type DBInboundInstance = InstanceType<typeof DBInbound>;
 
-// Server-side traffic polling interval in seconds. XrayTrafficJob broadcasts
-// deltas accumulated over this window, so dividing by it yields bytes/sec.
-const TRAFFIC_POLL_INTERVAL_S = 5;
-
 // Speed is delta-derived, so it can't be recomputed until the first poll after
 // mount; navigating away and back would otherwise blank the column for up to one
 // poll. Cache the last speed map across mounts (module scope) and reseed from it

+ 1 - 0
internal/web/translation/ar-EG.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "مثلاً customer-a",
       "comment": "ملاحظة",
       "traffic": "حركة المرور",
+      "speed": "السرعة",
       "offline": "غير متصل",
       "addClient": "إضافة عميل",
       "qrCode": "رمز QR",

+ 1 - 0
internal/web/translation/en-US.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "e.g. customer-a",
       "comment": "Comment",
       "traffic": "Traffic",
+      "speed": "Speed",
       "offline": "Offline",
       "addClient": "Add Client",
       "qrCode": "QR Code",

+ 1 - 0
internal/web/translation/es-ES.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "p. ej. customer-a",
       "comment": "Comentario",
       "traffic": "Tráfico",
+      "speed": "Velocidad",
       "offline": "Sin conexión",
       "addClient": "Añadir cliente",
       "qrCode": "Código QR",

+ 1 - 0
internal/web/translation/fa-IR.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "مثلاً customer-a",
       "comment": "توضیحات",
       "traffic": "ترافیک",
+      "speed": "سرعت",
       "offline": "آفلاین",
       "addClient": "افزودن کلاینت",
       "qrCode": "کد QR",

+ 1 - 0
internal/web/translation/id-ID.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "mis. customer-a",
       "comment": "Komentar",
       "traffic": "Lalu lintas",
+      "speed": "Kecepatan",
       "offline": "Offline",
       "addClient": "Tambah klien",
       "qrCode": "Kode QR",

+ 1 - 0
internal/web/translation/ja-JP.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "例: customer-a",
       "comment": "コメント",
       "traffic": "トラフィック",
+      "speed": "速度",
       "offline": "オフライン",
       "addClient": "クライアントを追加",
       "qrCode": "QR コード",

+ 1 - 0
internal/web/translation/pt-BR.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "ex.: customer-a",
       "comment": "Comentário",
       "traffic": "Tráfego",
+      "speed": "Velocidade",
       "offline": "Offline",
       "addClient": "Adicionar cliente",
       "qrCode": "Código QR",

+ 1 - 0
internal/web/translation/ru-RU.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "например, customer-a",
       "comment": "Комментарий",
       "traffic": "Трафик",
+      "speed": "Скорость",
       "offline": "Не в сети",
       "addClient": "Добавить клиента",
       "qrCode": "QR-код",

+ 1 - 0
internal/web/translation/tr-TR.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "örn. customer-a",
       "comment": "Yorum",
       "traffic": "Trafik",
+      "speed": "Hız",
       "offline": "Çevrimdışı",
       "addClient": "Kullanıcı Ekle",
       "qrCode": "QR Kodu",

+ 1 - 0
internal/web/translation/uk-UA.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "напр. customer-a",
       "comment": "Коментар",
       "traffic": "Трафік",
+      "speed": "Швидкість",
       "offline": "Не в мережі",
       "addClient": "Додати клієнта",
       "qrCode": "QR-код",

+ 1 - 0
internal/web/translation/vi-VN.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "ví dụ customer-a",
       "comment": "Ghi chú",
       "traffic": "Lưu lượng",
+      "speed": "Tốc độ",
       "offline": "Ngoại tuyến",
       "addClient": "Thêm khách hàng",
       "qrCode": "Mã QR",

+ 1 - 0
internal/web/translation/zh-CN.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "如 customer-a",
       "comment": "备注",
       "traffic": "流量",
+      "speed": "速度",
       "offline": "离线",
       "addClient": "添加客户端",
       "qrCode": "二维码",

+ 1 - 0
internal/web/translation/zh-TW.json

@@ -809,6 +809,7 @@
       "groupPlaceholder": "如 customer-a",
       "comment": "備註",
       "traffic": "流量",
+      "speed": "速度",
       "offline": "離線",
       "addClient": "新增客戶端",
       "qrCode": "QR 碼",