mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 08:34:22 +00:00
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.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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 = (
|
||||
<table className="client-traffic-popover">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>↑</td>
|
||||
<td>{SizeFormatter.sizeFormat(up)}</td>
|
||||
<td>↓</td>
|
||||
<td>{SizeFormatter.sizeFormat(down)}</td>
|
||||
</tr>
|
||||
{!display.isUnlimited && (
|
||||
<tr>
|
||||
<td colSpan={2}>{t('remained')}</td>
|
||||
<td colSpan={2}>{SizeFormatter.sizeFormat(display.remaining)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
const rootClass = [
|
||||
'client-traffic-cell',
|
||||
compact ? 'is-compact' : '',
|
||||
display.isUnlimited ? 'is-unlimited' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<Popover content={popover} trigger={['hover', 'click']} placement="top">
|
||||
<div className={rootClass}>
|
||||
<span className="client-traffic-cell-used">{SizeFormatter.sizeFormat(display.used)}</span>
|
||||
<Progress
|
||||
className="client-traffic-cell-bar"
|
||||
percent={display.percent}
|
||||
showInfo={false}
|
||||
strokeColor={display.strokeColor}
|
||||
status={display.status}
|
||||
size={compact ? 'small' : 'default'}
|
||||
/>
|
||||
<span className="client-traffic-cell-limit">
|
||||
{display.isUnlimited ? (
|
||||
<span className="client-traffic-cell-infinity" aria-label={t('subscription.unlimited')}>
|
||||
<InfinityIcon />
|
||||
</span>
|
||||
) : (
|
||||
SizeFormatter.sizeFormat(total)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) => (
|
||||
<ClientTrafficCell
|
||||
up={record.traffic?.up}
|
||||
down={record.traffic?.down}
|
||||
total={record.totalGB}
|
||||
enabled={record.enable}
|
||||
trafficDiff={trafficDiff}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
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() {
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<ClientTrafficCell
|
||||
compact
|
||||
up={row.traffic?.up}
|
||||
down={row.traffic?.down}
|
||||
total={row.totalGB}
|
||||
enabled={row.enable}
|
||||
trafficDiff={trafficDiff}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user