diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 067d3c30a..3f2aac3f3 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2645,6 +2645,13 @@ empty-cell markers, and tabular-number styling for dense platform table cells. Proxmox replication last-duration cells and Standalone availability-check poll interval cells must compose that primitive instead of declaring local seconds/minutes helpers. +Responsive platform table width normalization is registry-backed too. +`getPlatformTableWeightedColumnWidthStyle` owns visible-column weight +normalization, zero-width fallback, and stable four-decimal percentage +formatting for dense platform table models. Docker / Podman container columns +and Proxmox host columns must keep their domain column IDs, layout breakpoints, +and weight maps local, but must call that shared helper instead of declaring +local `formatPercentage` / `toFixed(4)` width helpers. Platform table numeric fallback rendering is registry-backed too. `PlatformTableNumberValue` owns finite-number checking, tabular-number styling, custom empty-marker support, and caller-owned number formatting for dense diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 7e28fd538..f67d3279a 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -278,6 +278,12 @@ That boundary covers Proxmox replication last-sync duration cells and Standalone availability-check poll interval cells, and future duration-like platform table values must join the same shared primitive registry rule before adding table-local formatting. +Responsive table layout keeps the same split: unified-resource and +source-specific table models own which columns are visible at each breakpoint +and the relative weights for those columns, while dense platform table +rendering must use `getPlatformTableWeightedColumnWidthStyle` for +visible-column percentage normalization, zero-width fallback, and stable +percentage formatting instead of declaring local width-percent helpers. Optional numeric table cells follow the same split: unified-resource consumers own which count or replica field is meaningful, whether the domain should zero-default an absent scheduler/service/inventory count, whether a diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index 4a9d96923..de45f17a9 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -1559,6 +1559,34 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "platform-table-weighted-column-width-style", + "category": "platform-table-layout", + "summary": "Responsive platform table models must use the shared weighted column-width helper for visible-column percentage normalization instead of declaring local percentage-format helpers.", + "canonical": { + "path": "src/features/platformPage/sharedPlatformPage.tsx", + "export": "getPlatformTableWeightedColumnWidthStyle" + }, + "requiredConsumers": [ + { "path": "src/features/docker/dockerContainerTableModel.ts" }, + { "path": "src/features/proxmox/proxmoxHostTableModel.ts" } + ], + "forbiddenPatterns": [ + { + "path": "src/features/docker/dockerContainerTableModel.ts", + "patterns": ["const formatPercentage", "value.toFixed(4)"] + }, + { + "path": "src/features/proxmox/proxmoxHostTableModel.ts", + "patterns": ["const formatPercentage", "value.toFixed(4)"] + } + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/features/platformPage/__tests__/sharedPlatformPage.test.ts", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "platform-table-integer-value-format", "category": "platform-table-text-value", @@ -2793,6 +2821,33 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "platform-table-local-weighted-width-helper", + "category": "platform-table-layout", + "summary": "Responsive platform table models must not recreate local weighted-width percentage helpers.", + "canonical": { + "path": "src/features/platformPage/sharedPlatformPage.tsx", + "export": "getPlatformTableWeightedColumnWidthStyle" + }, + "scopes": [ + "src/features/docker/dockerContainerTableModel.ts", + "src/features/proxmox/proxmoxHostTableModel.ts" + ], + "extensions": [".ts"], + "allPatterns": [ + "const formatPercentage", + "value.toFixed(4)", + "return { width: formatPercentage(width) }" + ], + "legacyReason": "Retired migration debt. Responsive platform table width normalization must use getPlatformTableWeightedColumnWidthStyle so percentage rounding and zero-width fallback stay shared.", + "allowedPaths": [], + "ignoredPaths": [], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/features/platformPage/__tests__/sharedPlatformPage.test.ts", + "scripts/shared-template-audit.mjs" + ] + }, { "id": "platform-table-local-truenas-count-cell-class", "category": "platform-table-text-value", diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index b035a6c6d..df2b7771b 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -149,6 +149,7 @@ import workloadsTableSource from '@/components/Workloads/WorkloadsTable.tsx?raw' import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw'; import guestRowStateSource from '@/components/Workloads/useGuestRowState.ts?raw'; import workloadSelectionStateSource from '@/components/Workloads/useWorkloadSelectionState.ts?raw'; +import dockerContainerTableModelSource from '@/features/docker/dockerContainerTableModel.ts?raw'; import dockerContainersTableSource from '@/features/docker/DockerContainersTable.tsx?raw'; import dockerHostsTableSource from '@/features/docker/DockerHostsTable.tsx?raw'; import dockerNativeTableSharedSource from '@/features/docker/DockerNativeTableShared.tsx?raw'; @@ -4323,6 +4324,82 @@ describe('shared primitive guardrails', () => { } }); + it('keeps responsive platform table column widths on the shared weighted helper', () => { + const registry = JSON.parse(sharedTemplateRegistrySource) as { + rules?: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + requiredConsumers?: Array<{ path?: string }>; + forbiddenPatterns?: Array<{ path?: string; patterns?: string[] }>; + }>; + patternGuards?: Array<{ + id: string; + canonical?: { path?: string; export?: string }; + allPatterns?: string[]; + scopes?: string[]; + allowedPaths?: string[]; + ignoredPaths?: string[]; + }>; + }; + const registeredRule = registry.rules?.find( + (rule) => rule.id === 'platform-table-weighted-column-width-style', + ); + const localHelperGuard = registry.patternGuards?.find( + (guard) => guard.id === 'platform-table-local-weighted-width-helper', + ); + const platformWidthConsumers: Array<[string, string]> = [ + ['src/features/docker/dockerContainerTableModel.ts', dockerContainerTableModelSource], + ['src/features/proxmox/proxmoxHostTableModel.ts', proxmoxHostTableModelSource], + ]; + const platformWidthConsumerPaths = platformWidthConsumers.map(([path]) => path); + + expect(registeredRule?.canonical?.path).toBe( + 'src/features/platformPage/sharedPlatformPage.tsx', + ); + expect(registeredRule?.canonical?.export).toBe('getPlatformTableWeightedColumnWidthStyle'); + expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual( + platformWidthConsumerPaths, + ); + expect(registeredRule?.forbiddenPatterns).toEqual([ + { + path: 'src/features/docker/dockerContainerTableModel.ts', + patterns: ['const formatPercentage', 'value.toFixed(4)'], + }, + { + path: 'src/features/proxmox/proxmoxHostTableModel.ts', + patterns: ['const formatPercentage', 'value.toFixed(4)'], + }, + ]); + expect(localHelperGuard?.canonical?.path).toBe( + 'src/features/platformPage/sharedPlatformPage.tsx', + ); + expect(localHelperGuard?.canonical?.export).toBe('getPlatformTableWeightedColumnWidthStyle'); + expect(localHelperGuard?.allPatterns).toEqual([ + 'const formatPercentage', + 'value.toFixed(4)', + 'return { width: formatPercentage(width) }', + ]); + expect(localHelperGuard?.scopes).toEqual([ + 'src/features/docker/dockerContainerTableModel.ts', + 'src/features/proxmox/proxmoxHostTableModel.ts', + ]); + expect(localHelperGuard?.allowedPaths ?? []).toHaveLength(0); + expect(localHelperGuard?.ignoredPaths ?? []).toHaveLength(0); + + expect(sharedPlatformPageSource).toContain( + 'export const getPlatformTableWeightedColumnWidthStyle', + ); + + for (const [path, source] of platformWidthConsumers) { + expect(source).toContain('getPlatformTableWeightedColumnWidthStyle'); + const forbiddenPatterns = + registeredRule?.forbiddenPatterns?.find((entry) => entry.path === path)?.patterns ?? []; + for (const pattern of forbiddenPatterns) { + expect(source).not.toContain(pattern); + } + } + }); + it('keeps platform table number-value fallbacks on the shared primitive', () => { const registry = JSON.parse(sharedTemplateRegistrySource) as { rules?: Array<{ diff --git a/frontend-modern/src/features/docker/dockerContainerTableModel.ts b/frontend-modern/src/features/docker/dockerContainerTableModel.ts index 124759f52..d4e07a2e6 100644 --- a/frontend-modern/src/features/docker/dockerContainerTableModel.ts +++ b/frontend-modern/src/features/docker/dockerContainerTableModel.ts @@ -2,6 +2,7 @@ import type { JSX } from 'solid-js'; import type { WorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel'; import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment'; +import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage'; export type DockerContainerTableColumnId = | 'container' @@ -119,8 +120,6 @@ const DOCKER_CONTAINER_RESPONSIVE_WIDTHS: Record< }, }; -const formatPercentage = (value: number): string => `${Number(value.toFixed(4))}%`; - export const getDockerContainerVisibleColumnsForLayout = ( layoutMode: WorkloadTableLayoutMode, includeRuntime: boolean, @@ -148,11 +147,7 @@ export const getDockerContainerColumnWidthStyle = ( layoutMode === 'wide' ? DOCKER_CONTAINER_DESKTOP_WIDTHS : DOCKER_CONTAINER_RESPONSIVE_WIDTHS[layoutMode]; - const columnWeight = weights[columnId] ?? 0; - const totalWeight = visibleColumnIds.reduce((total, id) => total + (weights[id] ?? 0), 0); - const width = totalWeight > 0 ? (columnWeight / totalWeight) * 100 : 0; - - return { width: formatPercentage(width) }; + return getPlatformTableWeightedColumnWidthStyle(columnId, weights, visibleColumnIds); }; // The Docker container table already has a row detail drawer for forensic diff --git a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts index d16c7a983..b927e3f50 100644 --- a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts @@ -25,6 +25,7 @@ import { filterPlatformResources, formatPlatformTableTextValue, getPlatformTableFiniteMetric, + getPlatformTableWeightedColumnWidthStyle, summarizePlatformTableValues, type PlatformResourceStatusFilter, } from '../sharedPlatformPage'; @@ -419,6 +420,33 @@ describe('PlatformTableDurationValue', () => { }); }); +describe('getPlatformTableWeightedColumnWidthStyle', () => { + it('normalizes visible weighted table columns into stable width percentages', () => { + const weights = { + container: 32, + state: 14, + cpu: 18, + memory: 22, + updates: 14, + actions: 14, + }; + const visibleColumnIds = ['container', 'state', 'cpu', 'memory', 'updates', 'actions'] as const; + + expect( + getPlatformTableWeightedColumnWidthStyle('container', weights, visibleColumnIds), + ).toEqual({ + width: '28.0702%', + }); + expect(getPlatformTableWeightedColumnWidthStyle('memory', weights, visibleColumnIds)).toEqual({ + width: '19.2982%', + }); + }); + + it('returns a zero width when no visible weighted columns are present', () => { + expect(getPlatformTableWeightedColumnWidthStyle('node', {}, [])).toEqual({ width: '0%' }); + }); +}); + describe('formatPlatformTableIntegerValue', () => { it('formats rounded integer labels with canonical empty markers', () => { const expected = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(1235); diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index aab3e6791..150890624 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -358,6 +358,21 @@ export function PlatformTableDurationValue(props: { ); } +const formatPlatformTableWidthPercentage = (value: number): string => + `${Number(value.toFixed(4))}%`; + +export const getPlatformTableWeightedColumnWidthStyle = ( + columnId: ColumnId, + weights: Partial>, + visibleColumnIds: readonly ColumnId[], +): JSX.CSSProperties => { + const columnWeight = weights[columnId] ?? 0; + const totalWeight = visibleColumnIds.reduce((total, id) => total + (weights[id] ?? 0), 0); + const width = totalWeight > 0 ? (columnWeight / totalWeight) * 100 : 0; + + return { width: formatPlatformTableWidthPercentage(width) }; +}; + const platformTableIntegerFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0, }); diff --git a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts index 235734f2b..1af144585 100644 --- a/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxHostTableModel.ts @@ -2,6 +2,7 @@ import type { JSX } from 'solid-js'; import type { WorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel'; import type { PlatformTableColumnKind } from '@/features/platformPage/columnAlignment'; +import { getPlatformTableWeightedColumnWidthStyle } from '@/features/platformPage/sharedPlatformPage'; export type ProxmoxHostTableColumnId = | 'node' @@ -82,8 +83,6 @@ const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record< compact: HOST_COLUMN_DESKTOP_WIDTHS, }; -const formatPercentage = (value: number): string => `${Number(value.toFixed(4))}%`; - // Column order follows the canonical recommended ordering documented in // columnAlignment.ts: identity → context → bars (CPU/Memory/Disk // contiguous) → diagnostic (Temp) → time (Uptime) → inventory counts @@ -119,11 +118,7 @@ export const getProxmoxHostColumnWidthStyle = ( ): JSX.CSSProperties => { const weights = layoutMode === 'wide' ? HOST_COLUMN_DESKTOP_WIDTHS : HOST_COLUMN_RESPONSIVE_WEIGHTS[layoutMode]; - const columnWeight = weights[columnId] ?? 0; - const totalWeight = visibleColumnIds.reduce((total, id) => total + (weights[id] ?? 0), 0); - const width = totalWeight > 0 ? (columnWeight / totalWeight) * 100 : 0; - - return { width: formatPercentage(width) }; + return getPlatformTableWeightedColumnWidthStyle(columnId, weights, visibleColumnIds); }; // Only the `wide` layout (>= 1440px viewport) reserves a fixed 1240px floor so @@ -134,5 +129,4 @@ export const getProxmoxHostColumnWidthStyle = ( // fits the container exactly and the bars scale down gracefully instead. export const getProxmoxHostTableMinWidthClass = ( layoutMode: WorkloadTableLayoutMode, -): 'min-w-full' | 'min-w-[1240px]' => - layoutMode === 'wide' ? 'min-w-[1240px]' : 'min-w-full'; +): 'min-w-full' | 'min-w-[1240px]' => (layoutMode === 'wide' ? 'min-w-[1240px]' : 'min-w-full');