Enable sorting for workload IDs
Some checks are pending
Build and Test / Backend tests (rest-0) (push) Blocked by required conditions
Build and Test / Secret Scan (push) Waiting to run
Build and Test / Detect changed areas (push) Waiting to run
Build and Test / Frontend (push) Blocked by required conditions
Build and Test / Backend tests (api) (push) Blocked by required conditions
Build and Test / Backend tests (rest-1) (push) Blocked by required conditions
Build and Test / Script smoke tests & backend build (push) Blocked by required conditions
Build and Test / Benchmarks (push) Blocked by required conditions
Canonical Governance / governance (push) Waiting to run
Public docs / check (push) Waiting to run
Core E2E Tests / Validate E2E tier selection (push) Waiting to run
Core E2E Tests / Playwright Core E2E (shard 1/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 2/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 3/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 4/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 5/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 6/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 7/8) (push) Blocked by required conditions
Core E2E Tests / Playwright Core E2E (shard 8/8) (push) Blocked by required conditions
Core E2E Tests / Agent registration lifecycle (push) Waiting to run
Core E2E Tests / E2E verdict (push) Blocked by required conditions
Unified Agent Native Verification / macOS ARM64 (push) Waiting to run
Unified Agent Native Verification / macOS Intel (push) Waiting to run
Unified Agent Native Verification / FreeBSD cross-build contract (push) Waiting to run
Unified Agent Native Verification / Linux ARM64 (push) Waiting to run
Unified Agent Native Verification / Linux x64 (push) Waiting to run
Unified Agent Native Verification / Windows x64 (push) Waiting to run

This commit is contained in:
rcourtman 2026-08-26 10:22:00 +01:00
parent 3b866458f6
commit ad4c82c34e
9 changed files with 135 additions and 36 deletions

View file

@ -2486,6 +2486,12 @@ the canonical owners for desktop and mobile workload column sizing. Global CSS
must not reintroduce competing `.workload-table [data-workload-col=…]` width
rules or `min-width: max-content` fallbacks that can blow the table out
horizontally on Firefox or other desktop browsers.
The mixed Workloads `Info`/compact `ID` column must also remain sortable by the
exact scalar rendered for each workload kind. `guestRowModel.tsx` owns that
projection and `workloadSelectors.ts` owns its natural ordering, including
numeric VM IDs, text fallbacks for images and namespaces, and empty values
remaining last. Header code must consume the column model's `sortKey` instead
of adding a second display-specific sorting path.
The same `internal/api/router.go` payload boundary also keeps the
will_fix_later remind-at deadline scoped to a single optional pointer
(`*time.Time`) per finding on both API write paths, so adding the

View file

@ -1,15 +1,19 @@
{
"version": 1,
"base_sha": "af370a8dda01852586e6462b3208a7d822ee1261",
"verified_at": "2026-08-26T09:08:45Z",
"base_sha": "3b866458f665ff7be80e611cc60286d622a66e92",
"verified_at": "2026-08-26T09:20:54Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestRow.tsx",
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx"
"frontend-modern/src/components/Workloads/guestRowModel.tsx",
"frontend-modern/src/components/Workloads/useGuestRowState.ts",
"frontend-modern/src/components/Workloads/workloadSelectors.ts",
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestRow.tsx": "c1886d219bb44a9560a93e3227e022e370d0df7e3e396fa6e69ac4d982f275e2",
"frontend-modern/src/components/Workloads/WorkloadTableHeader.tsx": "9ed1b7fe10646efa7ec366a2ceca0c69fe30e77aff9334d2440ca6ede3f65fb8"
"frontend-modern/src/components/Workloads/guestRowModel.tsx": "e5bdf0f4bcb070227c7c40d512863eaff1bcb80db53c19d59a9b9085b648d344",
"frontend-modern/src/components/Workloads/useGuestRowState.ts": "591c98dbdb3213636ae5465f65fff4df03882ef428b57589c33eec36dc22fb85",
"frontend-modern/src/components/Workloads/workloadSelectors.ts": "98fe0f042d5e114f5b3b99080cc894e7da99d490f208ce6cd94867aa5a8aaff3",
"frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "085dbf2b61d3cd87aec04b54aa3fcc15f0a12d3d70c4014830c45a807788cb79"
},
"routes": [
"/proxmox/overview"
@ -26,16 +30,15 @@
],
"states": [
"loaded grouped guest table with running and stopped VM and LXC rows",
"wide table with shared Info/ID, Net I/O, and Disk I/O columns visible",
"narrow table with the responsive Info/ID column visible and I/O columns omitted",
"active descending Net I/O sort indicator"
"wide table with the shared Info/ID column visible",
"narrow table with the responsive Info/ID column visible",
"active ascending and descending ID sort indicators"
],
"interactions": [
"reloaded the Proxmox overview after the final source change",
"confirmed Type and the shared Info/ID header and cells use balanced centered alignment at desktop and narrow widths",
"confirmed Net I/O and Disk I/O headers stay centered over their paired directional readings",
"sorted by ID and Net I/O and confirmed the sort indicator preserves the corrected alignment",
"confirmed table and scroll-shell width parity at 1920px and 768px without unintended horizontal overflow",
"confirmed no browser console warnings or errors and restored the viewport override"
"loaded the Proxmox overview from the final source at desktop and narrow widths",
"clicked the shared ID header to sort ascending and confirmed the first group ordered IDs from 663 upward",
"clicked the shared ID header again to sort descending and confirmed the first group ordered IDs from 680 downward",
"confirmed the ID sort indicator and centered header-cell alignment remain coherent at 1920px and 768px",
"confirmed the responsive table remained readable without clipping the changed header and restored the viewport override"
]
}

View file

@ -1104,6 +1104,11 @@ describe('GUEST_COLUMNS', () => {
expect(columnIds.slice(dockerTailStart)).toEqual(['netIo', 'diskIo', 'update']);
});
it('sorts the mixed Info/ID column by its rendered value', () => {
expect(GUEST_COLUMNS.find((column) => column.id === 'info')?.sortKey).toBe('info');
expect(GUEST_COLUMNS.find((column) => column.id === 'vmid')?.sortKey).toBe('vmid');
});
it('marks toggleable columns correctly', () => {
const toggleable = GUEST_COLUMNS.filter((c) => c.toggleable);
const toggleableIds = toggleable.map((c) => c.id);

View file

@ -60,6 +60,7 @@ import guestRowCellsSource from '../GuestRowCells.tsx?raw';
import guestDrawerStateSource from '../useGuestDrawerState.ts?raw';
import guestRowModelSource from '../guestRowModel.tsx?raw';
import guestRowStateSource from '../useGuestRowState.ts?raw';
import workloadSelectorsSource from '../workloadSelectors.ts?raw';
import workloadTopologySource from '../workloadTopology.ts?raw';
import {
filterWorkloads,
@ -1652,6 +1653,11 @@ describe('Workloads performance contract', () => {
expect(workloadTableHeaderSource).toContain('TableHead');
expect(workloadTableHeaderSource).toContain('col.sortKey as WorkloadSortKey');
expect(workloadTableHeaderSource).toContain('getTableSortIndicator');
expect(guestRowModelSource).toContain(
"id: 'info', label: 'Info', width: '100px', sortKey: 'info'",
);
expect(workloadSelectorsSource).toContain("sortKey === 'info'");
expect(workloadSelectorsSource).toContain('getWorkloadInfoValue');
expect(workloadTableHeaderSource).toContain('style={getGuestColumnStyle(');
expect(workloadTableHeaderSource).toContain('props.workloadTableLayoutMode()');
expect(workloadTableHeaderSource).toContain('props.workloadTableVisibleColumnIds()');

View file

@ -640,6 +640,64 @@ describe('workloadSelectors', () => {
expect([...guests].sort(cpuAsc!).map((g) => g.id)).toEqual(['id-1', 'id-2', 'id-3']);
expect(createWorkloadSortComparator('', 'asc')).toBeNull();
});
it('sorts the mixed Info/ID projection naturally and keeps empty values last', () => {
const guests = [
makeGuest(1, {
id: 'vm-10',
name: 'vm-10',
type: 'vm',
workloadType: 'vm',
displayId: '10',
}),
makeGuest(2, {
id: 'container-nginx',
name: 'container-nginx',
type: 'app-container',
workloadType: 'app-container',
image: 'docker.io/library/nginx:2',
}),
makeGuest(3, {
id: 'vm-2',
name: 'vm-2',
type: 'vm',
workloadType: 'vm',
displayId: '2',
}),
makeGuest(4, {
id: 'pod-production',
name: 'pod-production',
type: 'pod',
workloadType: 'pod',
namespace: 'production',
}),
makeGuest(5, {
id: 'pod-empty',
name: 'pod-empty',
type: 'pod',
workloadType: 'pod',
namespace: '',
}),
];
const infoAsc = createWorkloadSortComparator('info', 'asc')!;
const infoDesc = createWorkloadSortComparator('info', 'desc')!;
expect([...guests].sort(infoAsc).map((guest) => guest.id)).toEqual([
'vm-2',
'vm-10',
'container-nginx',
'pod-production',
'pod-empty',
]);
expect([...guests].sort(infoDesc).map((guest) => guest.id)).toEqual([
'pod-production',
'container-nginx',
'vm-10',
'vm-2',
'pod-empty',
]);
});
});
describe('groupWorkloads', () => {

View file

@ -8,6 +8,8 @@ import {
PLATFORM_TABLE_PHONE_IDENTITY_WIDTH_PERCENT,
} from '@/features/platformPage/sharedPlatformPage';
import type { WorkloadGuest, WorkloadType, ViewMode } from '@/types/workloads';
import { getShortImageName } from '@/utils/format';
import { resolveWorkloadType } from '@/utils/workloads';
import { createVisibleCanonicalTypeColumn } from '@/utils/typeColumnDefinition';
import type {
WorkloadMetricHistoryReader,
@ -184,6 +186,27 @@ export const getOutlierEmphasis = (value: number, stats: IODistributionStats): I
return { className: 'text-muted', showOutlierHint: false };
};
export const getWorkloadDisplayId = (guest: WorkloadGuest): string => {
const provided = guest.displayId?.trim();
if (provided) return provided;
if (typeof guest.vmid === 'number' && guest.vmid > 0) return String(guest.vmid);
return '';
};
// The mixed Info column projects one scalar from each workload kind. Sorting
// must use this same projection so the header never orders rows by a hidden or
// provider-specific field that differs from the rendered value.
export const getWorkloadInfoValue = (guest: WorkloadGuest): string => {
const type = resolveWorkloadType(guest);
if (type === 'vm' || type === 'system-container') return getWorkloadDisplayId(guest);
if (type === 'app-container') {
const image = guest.image?.trim() ?? '';
return image ? getShortImageName(image) : '';
}
if (type === 'pod') return guest.namespace?.trim() ?? '';
return '';
};
export const GUEST_COLUMNS: ColumnDef[] = [
{
id: 'name',
@ -213,7 +236,7 @@ export const GUEST_COLUMNS: ColumnDef[] = [
kind: 'text',
},
createVisibleCanonicalTypeColumn(),
{ id: 'info', label: 'Info', width: '100px', kind: 'numeric-value' },
{ id: 'info', label: 'Info', width: '100px', sortKey: 'info', kind: 'numeric-value' },
{ id: 'vmid', label: 'ID', width: '45px', sortKey: 'vmid', kind: 'numeric-value' },
{ id: 'cpu', label: 'CPU', width: '140px', sortKey: 'cpu', kind: 'metric-bar' },
{ id: 'memory', label: 'Mem', width: '140px', sortKey: 'memory', kind: 'metric-bar' },

View file

@ -7,7 +7,7 @@ import type { Container } from '@/types/api';
import type { DisplayMetricType } from '@/utils/metricThresholds';
import { buildMetricKey } from '@/utils/metricsKeys';
import { getGuestHealthIndicator, isGuestRunning } from '@/utils/status';
import { getShortImageName, formatBytes } from '@/utils/format';
import { formatBytes } from '@/utils/format';
import { getContainerRuntimeBadgeForRuntime } from '@/utils/resourceBadgePresentation';
import {
getCanonicalWorkloadId,
@ -31,6 +31,8 @@ import {
EMPTY_IO_EMPHASIS,
GROUPED_FIRST_CELL_INDENT,
getOutlierEmphasis,
getWorkloadDisplayId,
getWorkloadInfoValue,
type GuestRowProps,
} from './guestRowModel';
@ -76,14 +78,7 @@ export function useGuestRowState(props: GuestRowProps) {
const customUrl = createMemo(() => props.customUrl?.trim() ?? '');
const displayId = createMemo(() => {
const provided = props.guest.displayId?.trim();
if (provided) return provided;
if (typeof props.guest.vmid === 'number' && props.guest.vmid > 0) {
return String(props.guest.vmid);
}
return '';
});
const displayId = createMemo(() => getWorkloadDisplayId(props.guest));
const ipAddresses = createMemo(() => props.guest.ipAddresses ?? []);
const networkInterfaces = createMemo(() => props.guest.networkInterfaces ?? []);
@ -107,13 +102,7 @@ export function useGuestRowState(props: GuestRowProps) {
return type === 'vm' || type === 'system-container';
});
const infoValue = createMemo(() => {
const type = workloadType();
if (type === 'vm' || type === 'system-container') return displayId();
if (type === 'app-container') return dockerImage() ? getShortImageName(dockerImage()) : '';
if (type === 'pod') return namespace();
return '';
});
const infoValue = createMemo(() => getWorkloadInfoValue(props.guest));
const infoTooltip = createMemo(() => {
if (workloadType() === 'app-container') return dockerImage();

View file

@ -1,6 +1,6 @@
import type { Node } from '@/types/api';
import type { WorkloadGuest, ViewMode, WorkloadType } from '@/types/workloads';
import type { WorkloadIOEmphasis } from './guestRowModel';
import { getWorkloadInfoValue, type WorkloadIOEmphasis } from './guestRowModel';
import { computeIOScale } from '@/components/Infrastructure/infrastructureSelectors';
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
import { parseFilterStack, evaluateFilterStack, splitSearchExclusions } from '@/utils/searchQuery';
@ -307,12 +307,19 @@ export const createWorkloadSortComparator = (
if (nameA !== nameB) return nameA < nameB ? -1 : 1;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
};
const naturalTextCollator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
return (a: WorkloadGuest, b: WorkloadGuest): number => {
let aVal: SortValue = null;
let bVal: SortValue = null;
if (sortKey === 'cpu') {
if (sortKey === 'info') {
aVal = getWorkloadInfoValue(a);
bVal = getWorkloadInfoValue(b);
} else if (sortKey === 'cpu') {
aVal = getWorkloadCPUPercent(a.cpu) ?? 0;
bVal = getWorkloadCPUPercent(b.cpu) ?? 0;
} else if (sortKey === 'memory') {
@ -349,7 +356,9 @@ export const createWorkloadSortComparator = (
const bStr = String(bVal).toLowerCase();
if (aStr === bStr) return tiebreak(a, b);
const comparison = aStr < bStr ? -1 : 1;
const comparison =
sortKey === 'info' ? naturalTextCollator.compare(aStr, bStr) : aStr < bStr ? -1 : 1;
if (comparison === 0) return tiebreak(a, b);
return sortDirection === 'asc' ? comparison : -comparison;
};
};

View file

@ -8,7 +8,7 @@ export type WorkloadsStatusMode = 'all' | 'running' | 'degraded' | 'stopped';
export type WorkloadsGroupingMode = 'grouped' | 'flat';
export type WorkloadsMetricDisplayMode = 'bars' | 'sparklines';
export type WorkloadsMemoryDisplayBasis = 'guest' | 'host';
export type WorkloadsSortKey = keyof WorkloadGuest | 'diskIo' | 'netIo';
export type WorkloadsSortKey = keyof WorkloadGuest | 'diskIo' | 'info' | 'netIo';
export interface WorkloadsStatusOption {
value: WorkloadsStatusMode;