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 碼",