diff --git a/frontend/src/components/clients/ClientSpeedTag.tsx b/frontend/src/components/clients/ClientSpeedTag.tsx new file mode 100644 index 000000000..3c7413b5c --- /dev/null +++ b/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 ( + + ↑ {SizeFormatter.speedFormat(speed.up)} + {' / '} + ↓ {SizeFormatter.speedFormat(speed.down)} + + ); +} + +export default ClientSpeedTag; diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 70a653cf2..b2f3bd2ae 100644 --- a/frontend/src/hooks/useClients.ts +++ b/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([]); + const [clientSpeed, setClientSpeed] = useState>({}); const summary = useMemo(() => { 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 = {}; + 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, }; diff --git a/frontend/src/lib/traffic/poll-interval.ts b/frontend/src/lib/traffic/poll-interval.ts new file mode 100644 index 000000000..e216c9351 --- /dev/null +++ b/frontend/src/lib/traffic/poll-interval.ts @@ -0,0 +1 @@ +export const TRAFFIC_POLL_INTERVAL_S = 5; diff --git a/frontend/src/pages/clients/ClientsPage.css b/frontend/src/pages/clients/ClientsPage.css index a412ebeee..d3c2e2fd7 100644 --- a/frontend/src/pages/clients/ClientsPage.css +++ b/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; diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index f89e63085..4a54f8209 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/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 ; + return ; + }, + }, { 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 ( +
+ +
+ ); + })()} ); })} diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts index f37a68bb8..f5c464ec5 100644 --- a/frontend/src/pages/inbounds/useInbounds.ts +++ b/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; -// 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 diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 2d127e99c..b43de03a2 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -809,6 +809,7 @@ "groupPlaceholder": "مثلاً customer-a", "comment": "ملاحظة", "traffic": "حركة المرور", + "speed": "السرعة", "offline": "غير متصل", "addClient": "إضافة عميل", "qrCode": "رمز QR", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 9a649d9d1..477fabb1d 100644 --- a/internal/web/translation/en-US.json +++ b/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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 96ba2c378..ef82c941e 100644 --- a/internal/web/translation/es-ES.json +++ b/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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 8fcdec9d9..db57d9a88 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -809,6 +809,7 @@ "groupPlaceholder": "مثلاً customer-a", "comment": "توضیحات", "traffic": "ترافیک", + "speed": "سرعت", "offline": "آفلاین", "addClient": "افزودن کلاینت", "qrCode": "کد QR", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 206aa07ea..b44f9d81e 100644 --- a/internal/web/translation/id-ID.json +++ b/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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 68d1ad8e0..d1a20c886 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -809,6 +809,7 @@ "groupPlaceholder": "例: customer-a", "comment": "コメント", "traffic": "トラフィック", + "speed": "速度", "offline": "オフライン", "addClient": "クライアントを追加", "qrCode": "QR コード", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 92f7e6247..f7e521415 100644 --- a/internal/web/translation/pt-BR.json +++ b/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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 2d2a9cec7..81ad4f56b 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -809,6 +809,7 @@ "groupPlaceholder": "например, customer-a", "comment": "Комментарий", "traffic": "Трафик", + "speed": "Скорость", "offline": "Не в сети", "addClient": "Добавить клиента", "qrCode": "QR-код", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index d9c73e491..4128f2e83 100644 --- a/internal/web/translation/tr-TR.json +++ b/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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index a08e6c664..77c46f80f 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -809,6 +809,7 @@ "groupPlaceholder": "напр. customer-a", "comment": "Коментар", "traffic": "Трафік", + "speed": "Швидкість", "offline": "Не в мережі", "addClient": "Додати клієнта", "qrCode": "QR-код", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 82f320e7a..052f79908 100644 --- a/internal/web/translation/vi-VN.json +++ b/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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index a00a25cfb..1b1f453cb 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -809,6 +809,7 @@ "groupPlaceholder": "如 customer-a", "comment": "备注", "traffic": "流量", + "speed": "速度", "offline": "离线", "addClient": "添加客户端", "qrCode": "二维码", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index dee558fb9..13f0ab95d 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -809,6 +809,7 @@ "groupPlaceholder": "如 customer-a", "comment": "備註", "traffic": "流量", + "speed": "速度", "offline": "離線", "addClient": "新增客戶端", "qrCode": "QR 碼",