mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-29 06:27:14 +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,
|
type BulkDetachResult,
|
||||||
} from '@/schemas/client';
|
} from '@/schemas/client';
|
||||||
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
import { DefaultsPayloadSchema } from '@/schemas/defaults';
|
||||||
|
import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
|
||||||
|
|
||||||
// One row sent to POST /clients/:email/externalLinks.
|
// One row sent to POST /clients/:email/externalLinks.
|
||||||
export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
|
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: [],
|
total: 0, active: 0, online: [], depleted: [], expiring: [], deactive: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ClientSpeedEntry {
|
||||||
|
up: number;
|
||||||
|
down: number;
|
||||||
|
}
|
||||||
|
|
||||||
type ClientStatRow = ClientTraffic & { email?: string };
|
type ClientStatRow = ClientTraffic & { email?: string };
|
||||||
|
|
||||||
// Mirror of the server's buildClientsSummary (web/service/client.go). The
|
// 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
|
// back to the server-computed summary until the first event lands, and keeps
|
||||||
// the server's authoritative total for the headline count.
|
// the server's authoritative total for the headline count.
|
||||||
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
|
const [allClientStats, setAllClientStats] = useState<ClientStatRow[]>([]);
|
||||||
|
const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
|
||||||
const summary = useMemo<ClientsSummary>(() => {
|
const summary = useMemo<ClientsSummary>(() => {
|
||||||
const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
|
const serverSummary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
|
||||||
if (allClientStats.length === 0) return serverSummary;
|
if (allClientStats.length === 0) return serverSummary;
|
||||||
@@ -543,10 +550,24 @@ export function useClients() {
|
|||||||
|
|
||||||
const applyTrafficEvent = useCallback((payload: unknown) => {
|
const applyTrafficEvent = useCallback((payload: unknown) => {
|
||||||
if (!payload || typeof payload !== 'object') return;
|
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)) {
|
if (Array.isArray(p.onlineClients)) {
|
||||||
queryClient.setQueryData(keys.clients.onlines(), 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]);
|
}, [queryClient]);
|
||||||
|
|
||||||
const applyClientStatsEvent = useCallback((payload: unknown) => {
|
const applyClientStatsEvent = useCallback((payload: unknown) => {
|
||||||
@@ -629,6 +650,7 @@ export function useClients() {
|
|||||||
exportClients,
|
exportClients,
|
||||||
importClients,
|
importClients,
|
||||||
setEnable,
|
setEnable,
|
||||||
|
clientSpeed,
|
||||||
applyTrafficEvent,
|
applyTrafficEvent,
|
||||||
applyClientStatsEvent,
|
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);
|
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 {
|
.card-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
|||||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||||
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
|
||||||
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
|
import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
|
||||||
|
import ClientSpeedTag, { isActiveSpeed } from '@/components/clients/ClientSpeedTag';
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
import { IntlUtil, SizeFormatter } from '@/utils';
|
import { IntlUtil, SizeFormatter } from '@/utils';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
@@ -209,6 +210,7 @@ export default function ClientsPage() {
|
|||||||
tgBotEnable, expireDiff, trafficDiff, pageSize,
|
tgBotEnable, expireDiff, trafficDiff, pageSize,
|
||||||
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
|
create, update, remove, bulkDelete, bulkAdjust, bulkEnable, bulkDisable, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
|
||||||
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
|
resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
|
||||||
|
clientSpeed,
|
||||||
applyTrafficEvent, applyClientStatsEvent,
|
applyTrafficEvent, applyClientStatsEvent,
|
||||||
refresh,
|
refresh,
|
||||||
hydrate,
|
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'),
|
title: t('pages.clients.remaining'),
|
||||||
key: 'remaining',
|
key: 'remaining',
|
||||||
@@ -919,7 +932,7 @@ export default function ClientsPage() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 = {
|
const tablePagination = {
|
||||||
current: currentPage,
|
current: currentPage,
|
||||||
@@ -1428,6 +1441,15 @@ export default function ClientsPage() {
|
|||||||
enabled={row.enable}
|
enabled={row.enable}
|
||||||
trafficDiff={trafficDiff}
|
trafficDiff={trafficDiff}
|
||||||
/>
|
/>
|
||||||
|
{(() => {
|
||||||
|
const speed = clientSpeed[row.email];
|
||||||
|
if (!isActiveSpeed(speed)) return null;
|
||||||
|
return (
|
||||||
|
<div className="client-card-speed">
|
||||||
|
<ClientSpeedTag speed={speed} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@
|
|||||||
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
|
import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
|
||||||
|
|
||||||
import type { InboundSpeedEntry } from './list/types';
|
import type { InboundSpeedEntry } from './list/types';
|
||||||
|
import { TRAFFIC_POLL_INTERVAL_S } from '@/lib/traffic/poll-interval';
|
||||||
|
|
||||||
export interface SubSettings {
|
export interface SubSettings {
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
@@ -28,10 +29,6 @@ export interface SubSettings {
|
|||||||
|
|
||||||
type DBInboundInstance = InstanceType<typeof DBInbound>;
|
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
|
// 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
|
// 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
|
// poll. Cache the last speed map across mounts (module scope) and reseed from it
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "مثلاً customer-a",
|
"groupPlaceholder": "مثلاً customer-a",
|
||||||
"comment": "ملاحظة",
|
"comment": "ملاحظة",
|
||||||
"traffic": "حركة المرور",
|
"traffic": "حركة المرور",
|
||||||
|
"speed": "السرعة",
|
||||||
"offline": "غير متصل",
|
"offline": "غير متصل",
|
||||||
"addClient": "إضافة عميل",
|
"addClient": "إضافة عميل",
|
||||||
"qrCode": "رمز QR",
|
"qrCode": "رمز QR",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "e.g. customer-a",
|
"groupPlaceholder": "e.g. customer-a",
|
||||||
"comment": "Comment",
|
"comment": "Comment",
|
||||||
"traffic": "Traffic",
|
"traffic": "Traffic",
|
||||||
|
"speed": "Speed",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"addClient": "Add Client",
|
"addClient": "Add Client",
|
||||||
"qrCode": "QR Code",
|
"qrCode": "QR Code",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "p. ej. customer-a",
|
"groupPlaceholder": "p. ej. customer-a",
|
||||||
"comment": "Comentario",
|
"comment": "Comentario",
|
||||||
"traffic": "Tráfico",
|
"traffic": "Tráfico",
|
||||||
|
"speed": "Velocidad",
|
||||||
"offline": "Sin conexión",
|
"offline": "Sin conexión",
|
||||||
"addClient": "Añadir cliente",
|
"addClient": "Añadir cliente",
|
||||||
"qrCode": "Código QR",
|
"qrCode": "Código QR",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "مثلاً customer-a",
|
"groupPlaceholder": "مثلاً customer-a",
|
||||||
"comment": "توضیحات",
|
"comment": "توضیحات",
|
||||||
"traffic": "ترافیک",
|
"traffic": "ترافیک",
|
||||||
|
"speed": "سرعت",
|
||||||
"offline": "آفلاین",
|
"offline": "آفلاین",
|
||||||
"addClient": "افزودن کلاینت",
|
"addClient": "افزودن کلاینت",
|
||||||
"qrCode": "کد QR",
|
"qrCode": "کد QR",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "mis. customer-a",
|
"groupPlaceholder": "mis. customer-a",
|
||||||
"comment": "Komentar",
|
"comment": "Komentar",
|
||||||
"traffic": "Lalu lintas",
|
"traffic": "Lalu lintas",
|
||||||
|
"speed": "Kecepatan",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"addClient": "Tambah klien",
|
"addClient": "Tambah klien",
|
||||||
"qrCode": "Kode QR",
|
"qrCode": "Kode QR",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "例: customer-a",
|
"groupPlaceholder": "例: customer-a",
|
||||||
"comment": "コメント",
|
"comment": "コメント",
|
||||||
"traffic": "トラフィック",
|
"traffic": "トラフィック",
|
||||||
|
"speed": "速度",
|
||||||
"offline": "オフライン",
|
"offline": "オフライン",
|
||||||
"addClient": "クライアントを追加",
|
"addClient": "クライアントを追加",
|
||||||
"qrCode": "QR コード",
|
"qrCode": "QR コード",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "ex.: customer-a",
|
"groupPlaceholder": "ex.: customer-a",
|
||||||
"comment": "Comentário",
|
"comment": "Comentário",
|
||||||
"traffic": "Tráfego",
|
"traffic": "Tráfego",
|
||||||
|
"speed": "Velocidade",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"addClient": "Adicionar cliente",
|
"addClient": "Adicionar cliente",
|
||||||
"qrCode": "Código QR",
|
"qrCode": "Código QR",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "например, customer-a",
|
"groupPlaceholder": "например, customer-a",
|
||||||
"comment": "Комментарий",
|
"comment": "Комментарий",
|
||||||
"traffic": "Трафик",
|
"traffic": "Трафик",
|
||||||
|
"speed": "Скорость",
|
||||||
"offline": "Не в сети",
|
"offline": "Не в сети",
|
||||||
"addClient": "Добавить клиента",
|
"addClient": "Добавить клиента",
|
||||||
"qrCode": "QR-код",
|
"qrCode": "QR-код",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "örn. customer-a",
|
"groupPlaceholder": "örn. customer-a",
|
||||||
"comment": "Yorum",
|
"comment": "Yorum",
|
||||||
"traffic": "Trafik",
|
"traffic": "Trafik",
|
||||||
|
"speed": "Hız",
|
||||||
"offline": "Çevrimdışı",
|
"offline": "Çevrimdışı",
|
||||||
"addClient": "Kullanıcı Ekle",
|
"addClient": "Kullanıcı Ekle",
|
||||||
"qrCode": "QR Kodu",
|
"qrCode": "QR Kodu",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "напр. customer-a",
|
"groupPlaceholder": "напр. customer-a",
|
||||||
"comment": "Коментар",
|
"comment": "Коментар",
|
||||||
"traffic": "Трафік",
|
"traffic": "Трафік",
|
||||||
|
"speed": "Швидкість",
|
||||||
"offline": "Не в мережі",
|
"offline": "Не в мережі",
|
||||||
"addClient": "Додати клієнта",
|
"addClient": "Додати клієнта",
|
||||||
"qrCode": "QR-код",
|
"qrCode": "QR-код",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "ví dụ customer-a",
|
"groupPlaceholder": "ví dụ customer-a",
|
||||||
"comment": "Ghi chú",
|
"comment": "Ghi chú",
|
||||||
"traffic": "Lưu lượng",
|
"traffic": "Lưu lượng",
|
||||||
|
"speed": "Tốc độ",
|
||||||
"offline": "Ngoại tuyến",
|
"offline": "Ngoại tuyến",
|
||||||
"addClient": "Thêm khách hàng",
|
"addClient": "Thêm khách hàng",
|
||||||
"qrCode": "Mã QR",
|
"qrCode": "Mã QR",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "如 customer-a",
|
"groupPlaceholder": "如 customer-a",
|
||||||
"comment": "备注",
|
"comment": "备注",
|
||||||
"traffic": "流量",
|
"traffic": "流量",
|
||||||
|
"speed": "速度",
|
||||||
"offline": "离线",
|
"offline": "离线",
|
||||||
"addClient": "添加客户端",
|
"addClient": "添加客户端",
|
||||||
"qrCode": "二维码",
|
"qrCode": "二维码",
|
||||||
|
|||||||
@@ -809,6 +809,7 @@
|
|||||||
"groupPlaceholder": "如 customer-a",
|
"groupPlaceholder": "如 customer-a",
|
||||||
"comment": "備註",
|
"comment": "備註",
|
||||||
"traffic": "流量",
|
"traffic": "流量",
|
||||||
|
"speed": "速度",
|
||||||
"offline": "離線",
|
"offline": "離線",
|
||||||
"addClient": "新增客戶端",
|
"addClient": "新增客戶端",
|
||||||
"qrCode": "QR 碼",
|
"qrCode": "QR 碼",
|
||||||
|
|||||||
Reference in New Issue
Block a user