mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-10 21:30:59 +00:00
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 <clio-agent@sisyphuslabs.ai> * 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 <clio-agent@sisyphuslabs.ai> * 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 <clio-agent@sisyphuslabs.ai> * fix(frontend): scope stable speed tags to table cells and fit content --------- Co-authored-by: x06579 <x06579@ai-dashboard> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -2,6 +2,7 @@ import { Tag } from 'antd';
|
|||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
import { SizeFormatter } from '@/utils';
|
||||||
import type { ClientSpeedEntry } from '@/hooks/useClients';
|
import type { ClientSpeedEntry } from '@/hooks/useClients';
|
||||||
|
import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
|
||||||
|
|
||||||
export type { ClientSpeedEntry };
|
export type { ClientSpeedEntry };
|
||||||
|
|
||||||
@@ -11,11 +12,16 @@ export function isActiveSpeed(speed?: ClientSpeedEntry): speed is ClientSpeedEnt
|
|||||||
|
|
||||||
interface ClientSpeedTagProps {
|
interface ClientSpeedTagProps {
|
||||||
speed: ClientSpeedEntry;
|
speed: ClientSpeedEntry;
|
||||||
|
tableCell?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientSpeedTag({ speed }: ClientSpeedTagProps) {
|
export function ClientSpeedTag({ speed, tableCell = false }: ClientSpeedTagProps) {
|
||||||
return (
|
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.up)}
|
||||||
{' / '}
|
{' / '}
|
||||||
↓ {SizeFormatter.speedFormat(speed.down)}
|
↓ {SizeFormatter.speedFormat(speed.down)}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -67,6 +67,7 @@ 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';
|
||||||
import { LazyMount } from '@/components/utility';
|
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 ClientFormModal = lazy(() => import('./ClientFormModal'));
|
||||||
const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
|
const ClientInfoModal = lazy(() => import('./ClientInfoModal'));
|
||||||
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
const ClientQrModal = lazy(() => import('./ClientQrModal'));
|
||||||
@@ -908,12 +909,14 @@ export default function ClientsPage() {
|
|||||||
{
|
{
|
||||||
title: t('pages.clients.speed'),
|
title: t('pages.clients.speed'),
|
||||||
key: 'speed',
|
key: 'speed',
|
||||||
width: 110,
|
width: SPEED_COLUMN_WIDTH,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_v, record) => {
|
render: (_v, record) => {
|
||||||
const speed = clientSpeed[record.email];
|
const speed = clientSpeed[record.email];
|
||||||
if (!isActiveSpeed(speed)) return <Tag color="default">—</Tag>;
|
if (!isActiveSpeed(speed)) {
|
||||||
return <ClientSpeedTag speed={speed} />;
|
return <Tag color="default" className={SPEED_TAG_CLASS_NAME} style={SPEED_TAG_STYLE}>—</Tag>;
|
||||||
|
}
|
||||||
|
return <ClientSpeedTag speed={speed} tableCell />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Tag, Tooltip } from 'antd';
|
import { Tag, Tooltip } from 'antd';
|
||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
import { SizeFormatter } from '@/utils';
|
||||||
|
import { SPEED_TAG_CLASS_NAME, SPEED_TAG_STYLE } from '@/components/utility/speedTagStyle';
|
||||||
|
|
||||||
import type { InboundSpeedEntry } from './types';
|
import type { InboundSpeedEntry } from './types';
|
||||||
|
|
||||||
@@ -12,12 +13,17 @@ export function isActiveSpeed(speed?: InboundSpeedEntry): speed is InboundSpeedE
|
|||||||
interface InboundSpeedTagProps {
|
interface InboundSpeedTagProps {
|
||||||
speed: InboundSpeedEntry;
|
speed: InboundSpeedEntry;
|
||||||
withTooltip?: boolean;
|
withTooltip?: boolean;
|
||||||
|
tableCell?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
|
// 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 = (
|
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.up)}
|
||||||
{' / '}
|
{' / '}
|
||||||
↓ {SizeFormatter.speedFormat(speed.down)}
|
↓ {SizeFormatter.speedFormat(speed.down)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
|||||||
import { coerceInboundJsonField } from '@/models/dbinbound';
|
import { coerceInboundJsonField } from '@/models/dbinbound';
|
||||||
|
|
||||||
import { RowActionsCell } from './RowActions';
|
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 { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
|
||||||
import {
|
import {
|
||||||
readStreamHints,
|
readStreamHints,
|
||||||
@@ -324,14 +325,14 @@ export function useInboundColumns({
|
|||||||
title: t('pages.inbounds.speed'),
|
title: t('pages.inbounds.speed'),
|
||||||
key: 'speed',
|
key: 'speed',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 110,
|
width: SPEED_COLUMN_WIDTH,
|
||||||
sorter: (a, b) => speedTotal(a) - speedTotal(b),
|
sorter: (a, b) => speedTotal(a) - speedTotal(b),
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
const speed = inboundSpeed[record.id];
|
const speed = inboundSpeed[record.id];
|
||||||
if (!isActiveSpeed(speed)) {
|
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 />;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user