mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-05 20:34:20 +00:00
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:
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const TRAFFIC_POLL_INTERVAL_S = 5;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -809,6 +809,7 @@
|
||||
"groupPlaceholder": "مثلاً customer-a",
|
||||
"comment": "ملاحظة",
|
||||
"traffic": "حركة المرور",
|
||||
"speed": "السرعة",
|
||||
"offline": "غير متصل",
|
||||
"addClient": "إضافة عميل",
|
||||
"qrCode": "رمز QR",
|
||||
|
||||
@@ -809,6 +809,7 @@
|
||||
"groupPlaceholder": "e.g. customer-a",
|
||||
"comment": "Comment",
|
||||
"traffic": "Traffic",
|
||||
"speed": "Speed",
|
||||
"offline": "Offline",
|
||||
"addClient": "Add Client",
|
||||
"qrCode": "QR Code",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -809,6 +809,7 @@
|
||||
"groupPlaceholder": "مثلاً customer-a",
|
||||
"comment": "توضیحات",
|
||||
"traffic": "ترافیک",
|
||||
"speed": "سرعت",
|
||||
"offline": "آفلاین",
|
||||
"addClient": "افزودن کلاینت",
|
||||
"qrCode": "کد QR",
|
||||
|
||||
@@ -809,6 +809,7 @@
|
||||
"groupPlaceholder": "mis. customer-a",
|
||||
"comment": "Komentar",
|
||||
"traffic": "Lalu lintas",
|
||||
"speed": "Kecepatan",
|
||||
"offline": "Offline",
|
||||
"addClient": "Tambah klien",
|
||||
"qrCode": "Kode QR",
|
||||
|
||||
@@ -809,6 +809,7 @@
|
||||
"groupPlaceholder": "例: customer-a",
|
||||
"comment": "コメント",
|
||||
"traffic": "トラフィック",
|
||||
"speed": "速度",
|
||||
"offline": "オフライン",
|
||||
"addClient": "クライアントを追加",
|
||||
"qrCode": "QR コード",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -809,6 +809,7 @@
|
||||
"groupPlaceholder": "например, customer-a",
|
||||
"comment": "Комментарий",
|
||||
"traffic": "Трафик",
|
||||
"speed": "Скорость",
|
||||
"offline": "Не в сети",
|
||||
"addClient": "Добавить клиента",
|
||||
"qrCode": "QR-код",
|
||||
|
||||
@@ -809,6 +809,7 @@
|
||||
"groupPlaceholder": "örn. customer-a",
|
||||
"comment": "Yorum",
|
||||
"traffic": "Trafik",
|
||||
"speed": "Hız",
|
||||
"offline": "Çevrimdışı",
|
||||
"addClient": "Kullanıcı Ekle",
|
||||
"qrCode": "QR Kodu",
|
||||
|
||||
@@ -809,6 +809,7 @@
|
||||
"groupPlaceholder": "напр. customer-a",
|
||||
"comment": "Коментар",
|
||||
"traffic": "Трафік",
|
||||
"speed": "Швидкість",
|
||||
"offline": "Не в мережі",
|
||||
"addClient": "Додати клієнта",
|
||||
"qrCode": "QR-код",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -809,6 +809,7 @@
|
||||
"groupPlaceholder": "如 customer-a",
|
||||
"comment": "备注",
|
||||
"traffic": "流量",
|
||||
"speed": "速度",
|
||||
"offline": "离线",
|
||||
"addClient": "添加客户端",
|
||||
"qrCode": "二维码",
|
||||
|
||||
@@ -809,6 +809,7 @@
|
||||
"groupPlaceholder": "如 customer-a",
|
||||
"comment": "備註",
|
||||
"traffic": "流量",
|
||||
"speed": "速度",
|
||||
"offline": "離線",
|
||||
"addClient": "新增客戶端",
|
||||
"qrCode": "QR 碼",
|
||||
|
||||
Reference in New Issue
Block a user