Просмотр исходного кода

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.
nima1024m 1 день назад
Родитель
Сommit
941eba546d

+ 90 - 0
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);
+}

+ 85 - 0
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 = (
+    <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>
+  );
+}

+ 64 - 0
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,
+  };
+}

+ 20 - 11
frontend/src/pages/clients/ClientsPage.tsx

@@ -52,6 +52,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
 import { useClients } from '@/hooks/useClients';
 import { useClients } from '@/hooks/useClients';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
 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';
@@ -343,15 +344,6 @@ export default function ClientsPage() {
   // order, so we just hand it through.
   // order, so we just hand it through.
   const sortedClients = filteredClients;
   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) {
   function remainingLabel(row: ClientRecord) {
     const total = row.totalGB || 0;
     const total = row.totalGB || 0;
     if (total <= 0) return '∞';
     if (total <= 0) return '∞';
@@ -726,7 +718,16 @@ export default function ClientsPage() {
     {
     {
       title: t('pages.clients.traffic'),
       title: t('pages.clients.traffic'),
       key: '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'),
       title: t('pages.clients.remaining'),
@@ -744,7 +745,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]);
+  ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups, datepicker, trafficDiff]);
 
 
   const tablePagination = {
   const tablePagination = {
     current: currentPage,
     current: currentPage,
@@ -1186,6 +1187,14 @@ export default function ClientsPage() {
                                       </Dropdown>
                                       </Dropdown>
                                     </div>
                                     </div>
                                   </div>
                                   </div>
+                                  <ClientTrafficCell
+                                    compact
+                                    up={row.traffic?.up}
+                                    down={row.traffic?.down}
+                                    total={row.totalGB}
+                                    enabled={row.enable}
+                                    trafficDiff={trafficDiff}
+                                  />
                                 </div>
                                 </div>
                               );
                               );
                             })}
                             })}

+ 55 - 0
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');
+  });
+});