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
This commit is contained in:
Nikan Zeyaei
2026-07-05 02:27:03 +03:30
committed by GitHub
parent e11e587c60
commit b177e30714
19 changed files with 93 additions and 6 deletions
+23 -1
View File
@@ -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,
};