From 941eba546d85eb551a3af3fce48a8cd5e0fb40fc Mon Sep 17 00:00:00 2001 From: nima1024m <114405577+nima1024m@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:40:49 +0330 Subject: [PATCH] feat(clients): restore traffic usage progress bars on Clients page (#5150) Bring back the v2.9.x traffic column UX: used amount, color-coded progress bar, limit/infinity label, and hover popover with upload/download/remaining breakdown. Adds a shared ClientTrafficCell component, traffic display helpers, and unit tests. --- .../components/clients/ClientTrafficCell.css | 90 +++++++++++++++++++ .../components/clients/ClientTrafficCell.tsx | 85 ++++++++++++++++++ frontend/src/lib/clients/traffic-display.ts | 64 +++++++++++++ frontend/src/pages/clients/ClientsPage.tsx | 31 ++++--- .../src/test/client-traffic-display.test.ts | 55 ++++++++++++ 5 files changed, 314 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/clients/ClientTrafficCell.css create mode 100644 frontend/src/components/clients/ClientTrafficCell.tsx create mode 100644 frontend/src/lib/clients/traffic-display.ts create mode 100644 frontend/src/test/client-traffic-display.test.ts diff --git a/frontend/src/components/clients/ClientTrafficCell.css b/frontend/src/components/clients/ClientTrafficCell.css new file mode 100644 index 000000000..d3d9e9cf0 --- /dev/null +++ b/frontend/src/components/clients/ClientTrafficCell.css @@ -0,0 +1,90 @@ +.client-traffic-cell { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 2px 10px; + border-radius: 999px; + background: var(--ant-color-fill-quaternary); +} + +.client-traffic-cell.is-compact { + gap: 6px; + padding: 2px 8px; + margin-top: 6px; +} + +.client-traffic-cell-used, +.client-traffic-cell-limit { + flex: 0 0 72px; + min-width: 72px; + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.client-traffic-cell.is-compact .client-traffic-cell-used { + flex-basis: 64px; + min-width: 64px; + font-size: 11px; +} + +.client-traffic-cell-used { + text-align: end; + color: var(--ant-color-text); +} + +.client-traffic-cell-limit { + text-align: start; + color: var(--ant-color-text-secondary); +} + +.client-traffic-cell-bar { + flex: 1 1 60px; + min-width: 48px; +} + +.client-traffic-cell-bar.ant-progress { + margin: 0; + line-height: 1; +} + +.client-traffic-cell-bar .ant-progress-outer, +.client-traffic-cell-bar .ant-progress-inner { + display: block; +} + +.client-traffic-cell-bar .ant-progress-inner { + background: var(--ant-color-fill-secondary); +} + +.client-traffic-cell.is-unlimited .client-traffic-cell-bar .ant-progress-inner .ant-progress-bg { + background-color: color-mix(in srgb, #722ed1 35%, transparent); + border: 1px solid color-mix(in srgb, #722ed1 55%, transparent); +} + +.client-traffic-cell-infinity { + display: inline-flex; + align-items: center; + justify-content: flex-start; + color: var(--ant-color-purple); + font-size: 14px; + line-height: 1; +} + +.client-traffic-popover table { + border-collapse: collapse; + width: 100%; + font-variant-numeric: tabular-nums; +} + +.client-traffic-popover td { + padding: 2px 6px; + white-space: nowrap; +} + +.client-traffic-popover td:first-child { + color: var(--ant-color-text-secondary); +} diff --git a/frontend/src/components/clients/ClientTrafficCell.tsx b/frontend/src/components/clients/ClientTrafficCell.tsx new file mode 100644 index 000000000..6df6ef09c --- /dev/null +++ b/frontend/src/components/clients/ClientTrafficCell.tsx @@ -0,0 +1,85 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Popover, Progress } from 'antd'; + +import InfinityIcon from '@/components/ui/InfinityIcon'; +import { useTheme } from '@/hooks/useTheme'; +import { computeTrafficDisplay } from '@/lib/clients/traffic-display'; +import { SizeFormatter } from '@/utils'; +import './ClientTrafficCell.css'; + +export interface ClientTrafficCellProps { + up?: number; + down?: number; + total?: number; + enabled?: boolean; + trafficDiff?: number; + compact?: boolean; +} + +export default function ClientTrafficCell({ + up = 0, + down = 0, + total = 0, + enabled = true, + trafficDiff = 0, + compact = false, +}: ClientTrafficCellProps) { + const { t } = useTranslation(); + const { isDark } = useTheme(); + + const display = useMemo( + () => computeTrafficDisplay({ up, down, total, enabled, trafficDiff }, isDark), + [up, down, total, enabled, trafficDiff, isDark], + ); + + const popover = ( + + + + + + + + + {!display.isUnlimited && ( + + + + + )} + +
{SizeFormatter.sizeFormat(up)}{SizeFormatter.sizeFormat(down)}
{t('remained')}{SizeFormatter.sizeFormat(display.remaining)}
+ ); + + const rootClass = [ + 'client-traffic-cell', + compact ? 'is-compact' : '', + display.isUnlimited ? 'is-unlimited' : '', + ].filter(Boolean).join(' '); + + return ( + +
+ {SizeFormatter.sizeFormat(display.used)} + + + {display.isUnlimited ? ( + + + + ) : ( + SizeFormatter.sizeFormat(total) + )} + +
+
+ ); +} diff --git a/frontend/src/lib/clients/traffic-display.ts b/frontend/src/lib/clients/traffic-display.ts new file mode 100644 index 000000000..7e8ca0185 --- /dev/null +++ b/frontend/src/lib/clients/traffic-display.ts @@ -0,0 +1,64 @@ +import { ColorUtils } from '@/utils'; + +export interface TrafficDisplayInput { + up: number; + down: number; + total: number; + enabled: boolean; + trafficDiff: number; +} + +export interface TrafficDisplay { + used: number; + remaining: number; + percent: number; + isUnlimited: boolean; + isDepleted: boolean; + strokeColor: string; + status: 'normal' | 'exception' | undefined; +} + +const DISABLED_STROKE = { + light: '#bcbcbc', + dark: 'rgb(72, 84, 105)', +} as const; + +const UNLIMITED_STROKE = '#722ed1'; + +export function computeTrafficDisplay( + input: TrafficDisplayInput, + isDark: boolean, +): TrafficDisplay { + const up = input.up || 0; + const down = input.down || 0; + const used = up + down; + const total = input.total || 0; + const isUnlimited = total <= 0; + + let percent = 100; + if (!isUnlimited) { + percent = Math.min(100, Math.max(0, (used / total) * 100)); + } + + const isDepleted = !isUnlimited && used >= total; + const remaining = isUnlimited ? 0 : Math.max(0, total - used); + + let strokeColor: string; + if (!input.enabled) { + strokeColor = isDark ? DISABLED_STROKE.dark : DISABLED_STROKE.light; + } else if (isUnlimited) { + strokeColor = UNLIMITED_STROKE; + } else { + strokeColor = ColorUtils.clientUsageColor({ up, down, total }, input.trafficDiff); + } + + return { + used, + remaining, + percent, + isUnlimited, + isDepleted, + strokeColor, + status: isDepleted && input.enabled ? 'exception' : undefined, + }; +} diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index eecfd8289..8c17b73d1 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -52,6 +52,7 @@ import { useWebSocket } from '@/hooks/useWebSocket'; import { useClients } from '@/hooks/useClients'; import { useDatepicker } from '@/hooks/useDatepicker'; import type { ClientRecord, InboundOption } from '@/hooks/useClients'; +import ClientTrafficCell from '@/components/clients/ClientTrafficCell'; import AppSidebar from '@/layouts/AppSidebar'; import { IntlUtil, SizeFormatter } from '@/utils'; import { setMessageInstance } from '@/utils/messageBus'; @@ -343,15 +344,6 @@ export default function ClientsPage() { // order, so we just hand it through. const sortedClients = filteredClients; - function trafficLabel(row: ClientRecord) { - const t0 = row.traffic; - if (!t0) return '-'; - const used = (t0.up || 0) + (t0.down || 0); - const total = row.totalGB || 0; - if (total <= 0) return `${SizeFormatter.sizeFormat(used)} / ∞`; - return `${SizeFormatter.sizeFormat(used)} / ${SizeFormatter.sizeFormat(total)}`; - } - function remainingLabel(row: ClientRecord) { const total = row.totalGB || 0; if (total <= 0) return '∞'; @@ -726,7 +718,16 @@ export default function ClientsPage() { { title: t('pages.clients.traffic'), key: 'traffic', - render: (_v, record) => trafficLabel(record), + width: 240, + render: (_v, record) => ( + + ), }, { title: t('pages.clients.remaining'), @@ -744,7 +745,7 @@ export default function ClientsPage() { ), }, // eslint-disable-next-line react-hooks/exhaustive-deps - ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups, datepicker]); + ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups, datepicker, trafficDiff]); const tablePagination = { current: currentPage, @@ -1186,6 +1187,14 @@ export default function ClientsPage() { + ); })} diff --git a/frontend/src/test/client-traffic-display.test.ts b/frontend/src/test/client-traffic-display.test.ts new file mode 100644 index 000000000..e70240c8e --- /dev/null +++ b/frontend/src/test/client-traffic-display.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; + +import { computeTrafficDisplay } from '@/lib/clients/traffic-display'; + +describe('computeTrafficDisplay', () => { + const gb = 1024 * 1024 * 1024; + + it('returns 50% for half-used limited quota', () => { + const d = computeTrafficDisplay( + { up: 0.25 * gb, down: 0.25 * gb, total: gb, enabled: true, trafficDiff: 0 }, + false, + ); + expect(d.percent).toBe(50); + expect(d.isUnlimited).toBe(false); + expect(d.remaining).toBe(0.5 * gb); + }); + + it('returns 100% bar for unlimited clients', () => { + const d = computeTrafficDisplay( + { up: 5 * gb, down: 2 * gb, total: 0, enabled: true, trafficDiff: 0 }, + false, + ); + expect(d.percent).toBe(100); + expect(d.isUnlimited).toBe(true); + expect(d.strokeColor).toBe('#722ed1'); + }); + + it('marks depleted clients with exception status', () => { + const d = computeTrafficDisplay( + { up: gb, down: 0, total: gb, enabled: true, trafficDiff: 0 }, + false, + ); + expect(d.isDepleted).toBe(true); + expect(d.status).toBe('exception'); + expect(d.percent).toBe(100); + }); + + it('uses gray stroke when client is disabled', () => { + const d = computeTrafficDisplay( + { up: 0.5 * gb, down: 0, total: gb, enabled: false, trafficDiff: 0 }, + false, + ); + expect(d.strokeColor).toBe('#bcbcbc'); + expect(d.status).toBeUndefined(); + }); + + it('uses warning color near traffic limit', () => { + const diff = 0.1 * gb; + const d = computeTrafficDisplay( + { up: 0.95 * gb, down: 0, total: gb, enabled: true, trafficDiff: diff }, + false, + ); + expect(d.strokeColor).toBe('#faad14'); + }); +});