diff --git a/frontend/src/components/clients/ClientSpeedTag.tsx b/frontend/src/components/clients/ClientSpeedTag.tsx index 3c7413b5c..1278a61fa 100644 --- a/frontend/src/components/clients/ClientSpeedTag.tsx +++ b/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 ( - + ↑ {SizeFormatter.speedFormat(speed.up)} {' / '} ↓ {SizeFormatter.speedFormat(speed.down)} diff --git a/frontend/src/components/utility/speedTagStyle.ts b/frontend/src/components/utility/speedTagStyle.ts new file mode 100644 index 000000000..eeb719447 --- /dev/null +++ b/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; diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index 141e5a1b1..75b6baac4 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/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 ; - return ; + if (!isActiveSpeed(speed)) { + return ; + } + return ; }, }, { diff --git a/frontend/src/pages/inbounds/list/InboundSpeedTag.tsx b/frontend/src/pages/inbounds/list/InboundSpeedTag.tsx index 36c60265b..f94afd0f0 100644 --- a/frontend/src/pages/inbounds/list/InboundSpeedTag.tsx +++ b/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 = ( - + ↑ {SizeFormatter.speedFormat(speed.up)} {' / '} ↓ {SizeFormatter.speedFormat(speed.down)} diff --git a/frontend/src/pages/inbounds/list/useInboundColumns.tsx b/frontend/src/pages/inbounds/list/useInboundColumns.tsx index dec375b7b..43fd3b94f 100644 --- a/frontend/src/pages/inbounds/list/useInboundColumns.tsx +++ b/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 ; + return ; } - return ; + return ; }, }, { diff --git a/frontend/src/test/speed-tag-stable.test.tsx b/frontend/src/test/speed-tag-stable.test.tsx new file mode 100644 index 000000000..66a5d47f5 --- /dev/null +++ b/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(); + expectFluidTag(firstTag()); + + rerender(); + expectStableTableTag(firstTag()); + }); + + it('scopes InboundSpeedTag stable sizing to table cells', () => { + const { rerender } = render(); + expectFluidTag(firstTag()); + + rerender(); + expectStableTableTag(firstTag()); + }); + + it('fits the widest formatter rollover and includes small-cell padding', () => { + render( + <> + + + , + ); + const tags = Array.from(document.querySelectorAll('.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); + }); +});