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

fix(frontend): stabilize speed tags on inbound and client pages (#5930)

* fix(frontend): add shared stable speed-tag style

Give live up/down rate tags a fixed width, centered layout, nowrap,
and tabular numerals so digit/unit changes cannot reflow the Speed column.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>

* fix(frontend): stabilize InboundSpeedTag and ClientSpeedTag layout

Apply the shared speed-tag class/style to both live rate tags and lock
the behavior with a focused component test for small and large rates.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>

* fix(frontend): align speed columns with stable tag width

Widen inbound/client Speed columns to match the fixed tag and apply the
same stable style to idle dash cells so active/idle swaps do not jitter.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>

* fix(frontend): scope stable speed tags to table cells and fit content

---------

Co-authored-by: x06579 <x06579@ai-dashboard>
Co-authored-by: Sisyphus <[email protected]>
H-TTTTT 8 часов назад
Родитель
Сommit
f2b17397f4

+ 8 - 2
frontend/src/components/clients/ClientSpeedTag.tsx

@@ -2,6 +2,7 @@ import { Tag } from 'antd';
 
 import { SizeFormatter } from '@/utils';
 import type { ClientSpeedEntry } from '@/hooks/useClients';
+import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
 
 export type { ClientSpeedEntry };
 
@@ -11,11 +12,16 @@ export function isActiveSpeed(speed?: ClientSpeedEntry): speed is ClientSpeedEnt
 
 interface ClientSpeedTagProps {
   speed: ClientSpeedEntry;
+  tableCell?: boolean;
 }
 
-export function ClientSpeedTag({ speed }: ClientSpeedTagProps) {
+export function ClientSpeedTag({ speed, tableCell = false }: ClientSpeedTagProps) {
   return (
-    <Tag color="blue">
+    <Tag
+      color="blue"
+      className={tableCell ? SPEED_TAG_CLASS_NAME : undefined}
+      style={tableCell ? SPEED_TAG_STYLE : undefined}
+    >
       ↑ {SizeFormatter.speedFormat(speed.up)}
       {' / '}
       ↓ {SizeFormatter.speedFormat(speed.down)}

+ 21 - 0
frontend/src/components/utility/speedTagStyle.ts

@@ -0,0 +1,21 @@
+import type { CSSProperties } from 'react';
+
+export const SPEED_TAG_CLASS_NAME = 'table-speed-tag' as const;
+export const SPEED_TAG_WIDTH = 200 as const;
+export const SPEED_TABLE_CELL_INLINE_PADDING = 8 as const;
+
+export const SPEED_TAG_STYLE = {
+  width: SPEED_TAG_WIDTH,
+  display: 'inline-flex',
+  alignItems: 'center',
+  justifyContent: 'center',
+  textAlign: 'center',
+  whiteSpace: 'nowrap',
+  fontVariantNumeric: 'tabular-nums',
+  marginInlineEnd: 0,
+  boxSizing: 'border-box',
+  overflow: 'hidden',
+  textOverflow: 'ellipsis',
+} as const satisfies CSSProperties;
+
+export const SPEED_COLUMN_WIDTH = SPEED_TAG_WIDTH + SPEED_TABLE_CELL_INLINE_PADDING * 2;

+ 6 - 3
frontend/src/pages/clients/ClientsPage.tsx

@@ -67,6 +67,7 @@ import AppSidebar from '@/layouts/AppSidebar';
 import { IntlUtil, SizeFormatter } from '@/utils';
 import { setMessageInstance } from '@/utils/messageBus';
 import { LazyMount } from '@/components/utility';
+import { SPEED_COLUMN_WIDTH, SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
 const ClientFormModal = lazy(() => import('./ClientFormModal'));
 const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
 const ClientQrModal = lazy(() => import('./ClientQrModal'));
@@ -908,12 +909,14 @@ export default function ClientsPage() {
     {
       title: t('pages.clients.speed'),
       key: 'speed',
-      width: 110,
+      width: SPEED_COLUMN_WIDTH,
       align: 'center',
       render: (_v, record) => {
         const speed = clientSpeed[record.email];
-        if (!isActiveSpeed(speed)) return <Tag color="default">—</Tag>;
-        return <ClientSpeedTag speed={speed} />;
+        if (!isActiveSpeed(speed)) {
+          return <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}>—</Tag>;
+        }
+        return <ClientSpeedTag speed={speed} tableCell />;
       },
     },
     {

+ 8 - 2
frontend/src/pages/inbounds/list/InboundSpeedTag.tsx

@@ -1,6 +1,7 @@
 import { Tag, Tooltip } from 'antd';
 
 import { SizeFormatter } from '@/utils';
+import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
 
 import type { InboundSpeedEntry } from './types';
 
@@ -12,12 +13,17 @@ export function isActiveSpeed(speed?: InboundSpeedEntry): speed is InboundSpeedE
 interface InboundSpeedTagProps {
   speed: InboundSpeedEntry;
   withTooltip?: boolean;
+  tableCell?: boolean;
 }
 
 // Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
-export function InboundSpeedTag({ speed, withTooltip = false }: InboundSpeedTagProps) {
+export function InboundSpeedTag({ speed, withTooltip = false, tableCell = false }: InboundSpeedTagProps) {
   const tag = (
-    <Tag color="blue">
+    <Tag
+      color="blue"
+      className={tableCell ? SPEED_TAG_CLASS_NAME : undefined}
+      style={tableCell ? SPEED_TAG_STYLE : undefined}
+    >
       ↑ {SizeFormatter.speedFormat(speed.up)}
       {' / '}
       ↓ {SizeFormatter.speedFormat(speed.down)}

+ 4 - 3
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -10,6 +10,7 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery';
 import { coerceInboundJsonField } from '@/models/dbinbound';
 
 import { RowActionsCell } from './RowActions';
+import { SPEED_COLUMN_WIDTH, SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
 import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
 import {
   readStreamHints,
@@ -324,14 +325,14 @@ export function useInboundColumns({
         title: t('pages.inbounds.speed'),
         key: 'speed',
         align: 'center',
-        width: 110,
+        width: SPEED_COLUMN_WIDTH,
         sorter: (a, b) => speedTotal(a) - speedTotal(b),
         render: (_, record) => {
           const speed = inboundSpeed[record.id];
           if (!isActiveSpeed(speed)) {
-            return <Tag color='default'>—</Tag>;
+            return <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}>—</Tag>;
           }
-          return <InboundSpeedTag speed={speed} withTooltip />;
+          return <InboundSpeedTag speed={speed} withTooltip tableCell />;
         },
       },
       {

+ 84 - 0
frontend/src/test/speed-tag-stable.test.tsx

@@ -0,0 +1,84 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { ClientSpeedTag } from '@/components/clients/ClientSpeedTag';
+import {
+  SPEED_COLUMN_WIDTH,
+  SPEED_TABLE_CELL_INLINE_PADDING,
+  SPEED_TAG_CLASS_NAME,
+  SPEED_TAG_STYLE,
+  SPEED_TAG_WIDTH,
+} from '@/components/utility/speedTagStyle';
+import { InboundSpeedTag } from '@/pages/inbounds/list/InboundSpeedTag';
+import { SizeFormatter } from '@/utils';
+import '@/styles/utils.css';
+
+const SMALL_RATE = { up: 512, down: 1023 } as const;
+const LARGE_RATE = { up: 12_340_000, down: 99_990_000 } as const;
+const FORMATTER_BOUNDARY_RATE = {
+  up: SizeFormatter.ONE_GB - 1,
+  down: SizeFormatter.ONE_GB - 1,
+} as const;
+const FORMATTER_BOUNDARY_NATURAL_WIDTH = 192.765625;
+
+function firstTag(): HTMLElement {
+  const tag = document.querySelector('.ant-tag');
+  if (!(tag instanceof HTMLElement)) {
+    throw new Error('expected an Ant Design Tag in the document');
+  }
+  return tag;
+}
+
+function expectFluidTag(tag: HTMLElement) {
+  expect(tag.classList.contains(SPEED_TAG_CLASS_NAME)).toBe(false);
+  expect(tag.style.width).toBe('');
+}
+
+function expectStableTableTag(tag: HTMLElement) {
+  const style = getComputedStyle(tag);
+  expect(tag.classList.contains(SPEED_TAG_CLASS_NAME)).toBe(true);
+  expect(style.width).toBe(`${SPEED_TAG_WIDTH}px`);
+  expect(style.display).toBe(SPEED_TAG_STYLE.display);
+  expect(style.justifyContent).toBe(SPEED_TAG_STYLE.justifyContent);
+  expect(style.alignItems).toBe(SPEED_TAG_STYLE.alignItems);
+  expect(style.textAlign).toBe(SPEED_TAG_STYLE.textAlign);
+  expect(style.whiteSpace).toBe(SPEED_TAG_STYLE.whiteSpace);
+  expect(style.fontVariantNumeric).toBe(SPEED_TAG_STYLE.fontVariantNumeric);
+  expect(style.overflow).toBe('hidden');
+  expect(style.textOverflow).toBe('ellipsis');
+}
+
+describe('stable table speed tags (issue #5912)', () => {
+  it('scopes ClientSpeedTag stable sizing to table cells', () => {
+    const { rerender } = render(<ClientSpeedTag speed={SMALL_RATE} />);
+    expectFluidTag(firstTag());
+
+    rerender(<ClientSpeedTag speed={LARGE_RATE} tableCell />);
+    expectStableTableTag(firstTag());
+  });
+
+  it('scopes InboundSpeedTag stable sizing to table cells', () => {
+    const { rerender } = render(<InboundSpeedTag speed={SMALL_RATE} />);
+    expectFluidTag(firstTag());
+
+    rerender(<InboundSpeedTag speed={LARGE_RATE} withTooltip tableCell />);
+    expectStableTableTag(firstTag());
+  });
+
+  it('fits the widest formatter rollover and includes small-cell padding', () => {
+    render(
+      <>
+        <ClientSpeedTag speed={FORMATTER_BOUNDARY_RATE} tableCell />
+        <InboundSpeedTag speed={FORMATTER_BOUNDARY_RATE} tableCell />
+      </>,
+    );
+    const tags = Array.from(document.querySelectorAll<HTMLElement>('.ant-tag'));
+    expect(tags).toHaveLength(2);
+    expect(tags[0]?.textContent).toBe('↑ 1024.00 MB/s / ↓ 1024.00 MB/s');
+    expect(tags[1]?.textContent).toBe(tags[0]?.textContent);
+    for (const tag of tags) expectStableTableTag(tag);
+
+    expect(SPEED_TAG_WIDTH).toBeGreaterThan(FORMATTER_BOUNDARY_NATURAL_WIDTH);
+    expect(SPEED_COLUMN_WIDTH).toBe(SPEED_TAG_WIDTH + SPEED_TABLE_CELL_INLINE_PADDING * 2);
+  });
+});