From 9ea67c6a6dcb31740a6fb53eb4afb6b13276c708 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 13 Jun 2026 11:32:50 +0100 Subject: [PATCH] Converge platform table value primitives --- .../subsystems/frontend-primitives.md | 27 ++-- .../internal/subsystems/unified-resources.md | 19 ++- .../scripts/canonical-platform-audit.mjs | 3 - .../scripts/shared-template-registry.json | 132 ++++++++++++++++- .../SharedPrimitives.guardrails.test.ts | 136 ++++++++++++++++++ .../kubernetes/KubernetesClustersTable.tsx | 40 +++--- .../KubernetesClustersTable.test.tsx | 48 +++++++ .../__tests__/sharedPlatformPage.test.ts | 32 +++++ .../platformPage/sharedPlatformPage.tsx | 35 +++++ .../truenas/TrueNASProtectionTable.tsx | 6 +- .../truenas/TrueNASStorageTopologyTable.tsx | 37 ++--- .../features/truenas/TrueNASSystemsTable.tsx | 9 +- .../src/types/__tests__/resource.test.ts | 30 ++++ 13 files changed, 490 insertions(+), 64 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 11edd35ea..78f59233d 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2613,25 +2613,34 @@ Platform table byte-size formatting follows the same rule. `formatPlatformTableBytesValue` owns the repeated positive-byte formatting plus canonical empty-cell marker behavior for dense platform table cells. Docker / Podman native table helpers, Kubernetes node capacity cells, and TrueNAS -system/VM byte cells must compose that helper instead of declaring local -`formatBytes` wrappers or reimplementing byte-unit precision in table files. +system, VM, storage-topology, and protection byte cells must compose that +helper instead of declaring local `formatBytes` wrappers, importing the generic +formatter in table files, or reimplementing byte-unit precision there. 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 optional numeric table cells. Docker / Podman native count helpers, Kubernetes optional count cells, Docker Swarm service desired/running counts, Kubernetes -Deployment replica counts, and Proxmox Mail Gateway count columns must compose -that primitive instead of declaring local `numberValue`, `numericValue`, -`replicaCount`, or `countCell` helpers. If a scheduler or service-domain count -is intentionally zero-defaulted, the consuming table owns that field/default -choice and still renders through `PlatformTableNumberValue`. +Deployment replica counts, Proxmox Mail Gateway count columns, and TrueNAS +system share/service and storage-topology disk count cells must compose that +primitive instead of declaring local `numberValue`, `numericValue`, +`replicaCount`, `countCell`, `diskCountLabel`, or cell-level `tabular-nums` +variants. If a scheduler, service-domain, or inventory count is intentionally +zero-defaulted, the consuming table owns that field/default choice and still +renders through `PlatformTableNumberValue`. +`PlatformTableCountRatioValue` owns the companion healthy/total or ready/total +count-ratio skeleton: numerator, slash, muted denominator, tabular styling, and +empty marker behavior. Kubernetes cluster child counts compose that primitive +instead of keeping a table-local `childCountCell` renderer; the table owns only +which current/total values and warning tone apply. One-decimal percent and positive Celsius cells are also shared platform-table value primitives. `PlatformTablePercentValue` owns percent formatting, tabular-number styling, and empty markers, while `PlatformTableTemperatureValue` owns finite positive Celsius validation, one-decimal `°C` formatting, tabular-number styling, and empty markers. Docker -/ Podman host and TrueNAS system tables must compose those primitives instead -of carrying local `formatPercent` or `formatTemperature` helpers. +/ Podman host and TrueNAS system/storage-topology tables must compose those +primitives instead of carrying local `formatPercent`, `formatTemperature`, or +temperature label helpers. Platform table metric fallback rendering is also shared. `PlatformTableMetricFallback` owns the centered muted empty marker used in metric bar cells plus optional caller-owned fallback label/title text, and diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index d920f18dd..cbc004285 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -249,20 +249,25 @@ consumers own whether a capacity, memory, disk, or requested-size field is the right source-specific value, while dense platform table cells must use `formatPlatformTableBytesValue` for positive byte formatting and unknown / non-positive empty-cell markers instead of carrying table-local `formatBytes` -wrappers. +wrappers or direct generic byte-formatter imports. 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 count, and whether a domain-specific -formatter is needed, while dense platform table cells must use -`PlatformTableNumberValue` for finite-number validation, tabular styling, and -unknown empty-cell markers instead of carrying local `numberValue`, -`numericValue`, `replicaCount`, or `countCell` helpers. +zero-default an absent scheduler/service/inventory count, whether a +domain-specific formatter is needed, and which current/total fields belong in a +grouped health ratio, while dense platform table cells must use +`PlatformTableNumberValue` for scalar count validation, tabular styling, and +unknown empty-cell markers, and `PlatformTableCountRatioValue` for +healthy/total or ready/total count-ratio skeletons. Tables must not carry local +`numberValue`, `numericValue`, `replicaCount`, `countCell`, `childCountCell`, +`diskCountLabel`, or cell-level `tabular-nums` variants for grouped +child/share/service/storage counts. Percent and temperature table cells follow the same split: unified-resource consumers own which usage percentage or temperature source is meaningful for the row, while dense platform table rendering must use `PlatformTablePercentValue` and `PlatformTableTemperatureValue` for one-decimal formatting, positive Celsius validation, tabular styling, and empty markers -instead of local `formatPercent` or `formatTemperature` helpers. +instead of local `formatPercent`, `formatTemperature`, or temperature label +helpers. Metric bar fallbacks follow that split as well: unified-resource consumers own which CPU or memory value is selected, plus any source-specific fallback reason such as outdated standalone agent telemetry, while platform tables must use diff --git a/frontend-modern/scripts/canonical-platform-audit.mjs b/frontend-modern/scripts/canonical-platform-audit.mjs index 7df197033..120933ab4 100644 --- a/frontend-modern/scripts/canonical-platform-audit.mjs +++ b/frontend-modern/scripts/canonical-platform-audit.mjs @@ -1371,9 +1371,6 @@ const HELPER_RULES = [ // The kind-based wrappers internally delegate to the align-based // helpers; that's the one legitimate call site. 'src/features/platformPage/sharedPlatformPage.tsx', - // KubernetesClustersTable is pending migration by another agent. - // Remove this entry once that work lands. - 'src/features/kubernetes/KubernetesClustersTable.tsx', ]), }, ]; diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index f32e64447..cfb9e84a4 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -1347,6 +1347,8 @@ "requiredConsumers": [ { "path": "src/features/docker/DockerNativeTableShared.tsx" }, { "path": "src/features/kubernetes/KubernetesNodesTable.tsx" }, + { "path": "src/features/truenas/TrueNASProtectionTable.tsx" }, + { "path": "src/features/truenas/TrueNASStorageTopologyTable.tsx" }, { "path": "src/features/truenas/TrueNASSystemsTable.tsx" }, { "path": "src/features/truenas/TrueNASVirtualMachinesTable.tsx" } ], @@ -1359,6 +1361,18 @@ "path": "src/features/kubernetes/KubernetesNodesTable.tsx", "patterns": ["const formatBytes", "value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)"] }, + { + "path": "src/features/truenas/TrueNASProtectionTable.tsx", + "patterns": ["formatBytes(point.sizeBytes)"] + }, + { + "path": "src/features/truenas/TrueNASStorageTopologyTable.tsx", + "patterns": [ + "formatBytes(size)", + "formatBytes(row.resource.disk.used)", + "formatBytes(row.resource.disk.total)" + ] + }, { "path": "src/features/truenas/TrueNASSystemsTable.tsx", "patterns": ["const formatBytes", "value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)"] @@ -1391,7 +1405,9 @@ { "path": "src/features/kubernetes/KubernetesDeploymentsTable.tsx" }, { "path": "src/features/kubernetes/KubernetesEventsTable.tsx" }, { "path": "src/features/kubernetes/KubernetesPodsTable.tsx" }, - { "path": "src/features/proxmox/ProxmoxMailGatewayTable.tsx" } + { "path": "src/features/proxmox/ProxmoxMailGatewayTable.tsx" }, + { "path": "src/features/truenas/TrueNASStorageTopologyTable.tsx" }, + { "path": "src/features/truenas/TrueNASSystemsTable.tsx" } ], "forbiddenPatterns": [ { @@ -1446,6 +1462,35 @@ { "path": "src/features/proxmox/ProxmoxMailGatewayTable.tsx", "patterns": ["const countCell", "value.toLocaleString() : '—'"] + }, + { + "path": "src/features/truenas/TrueNASSystemsTable.tsx", + "patterns": ["hidden text-base-content tabular-nums lg:table-cell"] + }, + { + "path": "src/features/truenas/TrueNASStorageTopologyTable.tsx", + "patterns": ["const diskCountLabel"] + } + ], + "proof": [ + "src/components/shared/SharedPrimitives.guardrails.test.ts", + "src/features/platformPage/__tests__/sharedPlatformPage.test.ts", + "scripts/shared-template-audit.mjs" + ] + }, + { + "id": "platform-table-count-ratio-value", + "category": "platform-table-text-value", + "summary": "Platform tables must use the shared count-ratio primitive for healthy/total or ready/total count cells instead of recreating local numerator/slash/denominator markup.", + "canonical": { + "path": "src/features/platformPage/sharedPlatformPage.tsx", + "export": "PlatformTableCountRatioValue" + }, + "requiredConsumers": [{ "path": "src/features/kubernetes/KubernetesClustersTable.tsx" }], + "forbiddenPatterns": [ + { + "path": "src/features/kubernetes/KubernetesClustersTable.tsx", + "patterns": ["const childCountCell", "/{count.total}"] } ], "proof": [ @@ -1488,6 +1533,7 @@ }, "requiredConsumers": [ { "path": "src/features/docker/DockerHostsTable.tsx" }, + { "path": "src/features/truenas/TrueNASStorageTopologyTable.tsx" }, { "path": "src/features/truenas/TrueNASSystemsTable.tsx" } ], "forbiddenPatterns": [ @@ -1498,6 +1544,10 @@ "{celsius.toFixed(1)}°C" ] }, + { + "path": "src/features/truenas/TrueNASStorageTopologyTable.tsx", + "patterns": ["const temperatureLabel", "${Math.round(value)}C"] + }, { "path": "src/features/truenas/TrueNASSystemsTable.tsx", "patterns": [ @@ -2108,6 +2158,26 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "platform-table-local-format-bytes-import", + "category": "platform-table-text-value", + "summary": "Platform feature tables must not import the generic byte formatter directly for dense table byte cells.", + "canonical": { + "path": "src/features/platformPage/sharedPlatformPage.tsx", + "export": "formatPlatformTableBytesValue" + }, + "scopes": ["src/features/truenas"], + "extensions": [".tsx"], + "allPatterns": ["import { formatBytes } from '@/utils/format';", "formatBytes("], + "legacyReason": "Retired migration debt. Dense platform table byte cells must use formatPlatformTableBytesValue so positive-byte formatting and empty markers 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-number-value-helper", "category": "platform-table-text-value", @@ -2188,6 +2258,46 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "platform-table-local-count-ratio-helper", + "category": "platform-table-text-value", + "summary": "Platform tables must not recreate local healthy/total count-ratio helpers.", + "canonical": { + "path": "src/features/platformPage/sharedPlatformPage.tsx", + "export": "PlatformTableCountRatioValue" + }, + "scopes": ["src/features/kubernetes"], + "extensions": [".tsx"], + "allPatterns": ["const childCountCell", "/{count.total}"], + "legacyReason": "Retired migration debt. Healthy/total and ready/total table count cells must compose PlatformTableCountRatioValue so ratio markup, tabular styling, muted denominator styling, and empty markers 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", + "summary": "TrueNAS table scalar value cells must not put tabular-number styling on table cells instead of shared value primitives.", + "canonical": { + "path": "src/features/platformPage/sharedPlatformPage.tsx", + "export": "PlatformTableNumberValue" + }, + "scopes": ["src/features/truenas"], + "extensions": [".tsx"], + "allPatterns": ["TrueNAS", "hidden text-base-content tabular-nums lg:table-cell"], + "legacyReason": "Retired migration debt. TrueNAS share/service and adjacent scalar value cells must render through shared platform value primitives so finite-number checks, tabular styling, and empty markers 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-percent-value-helper", "category": "platform-table-text-value", @@ -2234,6 +2344,26 @@ "scripts/shared-template-audit.mjs" ] }, + { + "id": "platform-table-local-temperature-label-helper", + "category": "platform-table-text-value", + "summary": "Platform tables must not recreate local temperatureLabel helpers for positive Celsius cells.", + "canonical": { + "path": "src/features/platformPage/sharedPlatformPage.tsx", + "export": "PlatformTableTemperatureValue" + }, + "scopes": ["src/features/truenas"], + "extensions": [".tsx"], + "allPatterns": ["const temperatureLabel", "${Math.round(value)}C"], + "legacyReason": "Retired migration debt. Positive Celsius table cells must compose PlatformTableTemperatureValue so validity, formatting, tabular styling, and empty markers 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-metric-fallback-helper", "category": "platform-table-metric", diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 9d8fbca7f..0ad79215a 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -3689,9 +3689,14 @@ describe('shared primitive guardrails', () => { const localHelperGuard = registry.patternGuards?.find( (guard) => guard.id === 'platform-table-local-bytes-helper', ); + const localFormatBytesImportGuard = registry.patternGuards?.find( + (guard) => guard.id === 'platform-table-local-format-bytes-import', + ); const platformByteValueConsumers: Array<[string, string]> = [ ['src/features/docker/DockerNativeTableShared.tsx', dockerNativeTableSharedSource], ['src/features/kubernetes/KubernetesNodesTable.tsx', kubernetesNodesTableSource], + ['src/features/truenas/TrueNASProtectionTable.tsx', truenasProtectionTableSource], + ['src/features/truenas/TrueNASStorageTopologyTable.tsx', truenasStorageTopologyTableSource], ['src/features/truenas/TrueNASSystemsTable.tsx', truenasSystemsTableSource], ['src/features/truenas/TrueNASVirtualMachinesTable.tsx', truenasVirtualMachinesTableSource], ]; @@ -3717,6 +3722,18 @@ describe('shared primitive guardrails', () => { path: 'src/features/kubernetes/KubernetesNodesTable.tsx', patterns: localFormatBytesHelperPatterns, }, + { + path: 'src/features/truenas/TrueNASProtectionTable.tsx', + patterns: ['formatBytes(point.sizeBytes)'], + }, + { + path: 'src/features/truenas/TrueNASStorageTopologyTable.tsx', + patterns: [ + 'formatBytes(size)', + 'formatBytes(row.resource.disk.used)', + 'formatBytes(row.resource.disk.total)', + ], + }, { path: 'src/features/truenas/TrueNASSystemsTable.tsx', patterns: localFormatBytesHelperPatterns, @@ -3738,6 +3755,17 @@ describe('shared primitive guardrails', () => { ]); expect(localHelperGuard?.allowedPaths ?? []).toHaveLength(0); expect(localHelperGuard?.ignoredPaths ?? []).toHaveLength(0); + expect(localFormatBytesImportGuard?.canonical?.path).toBe( + 'src/features/platformPage/sharedPlatformPage.tsx', + ); + expect(localFormatBytesImportGuard?.canonical?.export).toBe('formatPlatformTableBytesValue'); + expect(localFormatBytesImportGuard?.allPatterns).toEqual([ + "import { formatBytes } from '@/utils/format';", + 'formatBytes(', + ]); + expect(localFormatBytesImportGuard?.scopes).toEqual(['src/features/truenas']); + expect(localFormatBytesImportGuard?.allowedPaths ?? []).toHaveLength(0); + expect(localFormatBytesImportGuard?.ignoredPaths ?? []).toHaveLength(0); expect(sharedPlatformPageSource).toContain('export const formatPlatformTableBytesValue'); expect(sharedPlatformPageSource).toContain('formatBytes(bytes)'); @@ -3787,6 +3815,9 @@ describe('shared primitive guardrails', () => { const localSwarmCountGuard = registry.patternGuards?.find( (guard) => guard.id === 'platform-table-local-swarm-count-spans', ); + const localTrueNASCountGuard = registry.patternGuards?.find( + (guard) => guard.id === 'platform-table-local-truenas-count-cell-class', + ); const platformNumberValueConsumers: Array<[string, string]> = [ ['src/features/docker/DockerNativeTableShared.tsx', dockerNativeTableSharedSource], ['src/features/docker/DockerServicesTable.tsx', dockerServicesTableSource], @@ -3797,6 +3828,8 @@ describe('shared primitive guardrails', () => { ['src/features/kubernetes/KubernetesEventsTable.tsx', kubernetesEventsTableSource], ['src/features/kubernetes/KubernetesPodsTable.tsx', kubernetesPodsTableSource], ['src/features/proxmox/ProxmoxMailGatewayTable.tsx', proxmoxMailGatewayTableSource], + ['src/features/truenas/TrueNASStorageTopologyTable.tsx', truenasStorageTopologyTableSource], + ['src/features/truenas/TrueNASSystemsTable.tsx', truenasSystemsTableSource], ]; const platformNumberValueConsumerPaths = platformNumberValueConsumers.map(([path]) => path); const optionalNumberMarkupPattern = @@ -3849,6 +3882,14 @@ describe('shared primitive guardrails', () => { path: 'src/features/proxmox/ProxmoxMailGatewayTable.tsx', patterns: ['const countCell', "value.toLocaleString() : '—'"], }, + { + path: 'src/features/truenas/TrueNASSystemsTable.tsx', + patterns: ['hidden text-base-content tabular-nums lg:table-cell'], + }, + { + path: 'src/features/truenas/TrueNASStorageTopologyTable.tsx', + patterns: ['const diskCountLabel'], + }, ]); expect(localNumberGuard?.canonical?.path).toBe( 'src/features/platformPage/sharedPlatformPage.tsx', @@ -3898,6 +3939,17 @@ describe('shared primitive guardrails', () => { expect(localSwarmCountGuard?.scopes).toEqual(['src/components/Docker']); expect(localSwarmCountGuard?.allowedPaths ?? []).toHaveLength(0); expect(localSwarmCountGuard?.ignoredPaths ?? []).toHaveLength(0); + expect(localTrueNASCountGuard?.canonical?.path).toBe( + 'src/features/platformPage/sharedPlatformPage.tsx', + ); + expect(localTrueNASCountGuard?.canonical?.export).toBe('PlatformTableNumberValue'); + expect(localTrueNASCountGuard?.allPatterns).toEqual([ + 'TrueNAS', + 'hidden text-base-content tabular-nums lg:table-cell', + ]); + expect(localTrueNASCountGuard?.scopes).toEqual(['src/features/truenas']); + expect(localTrueNASCountGuard?.allowedPaths ?? []).toHaveLength(0); + expect(localTrueNASCountGuard?.ignoredPaths ?? []).toHaveLength(0); expect(sharedPlatformPageSource).toContain('export function PlatformTableNumberValue'); expect(sharedPlatformPageSource).toContain('Number.isFinite(value)'); @@ -3914,6 +3966,71 @@ describe('shared primitive guardrails', () => { expect(proxmoxMailGatewayTableSource).toContain('format={formatLocaleCount}'); }); + it('keeps platform table count ratios on the shared primitive', () => { + 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-count-ratio-value', + ); + const localCountRatioGuard = registry.patternGuards?.find( + (guard) => guard.id === 'platform-table-local-count-ratio-helper', + ); + const platformCountRatioConsumers: Array<[string, string]> = [ + ['src/features/kubernetes/KubernetesClustersTable.tsx', kubernetesClustersTableSource], + ]; + const platformCountRatioConsumerPaths = platformCountRatioConsumers.map(([path]) => path); + + expect(registeredRule?.canonical?.path).toBe( + 'src/features/platformPage/sharedPlatformPage.tsx', + ); + expect(registeredRule?.canonical?.export).toBe('PlatformTableCountRatioValue'); + expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual( + platformCountRatioConsumerPaths, + ); + expect(registeredRule?.forbiddenPatterns).toEqual([ + { + path: 'src/features/kubernetes/KubernetesClustersTable.tsx', + patterns: ['const childCountCell', '/{count.total}'], + }, + ]); + expect(localCountRatioGuard?.canonical?.path).toBe( + 'src/features/platformPage/sharedPlatformPage.tsx', + ); + expect(localCountRatioGuard?.canonical?.export).toBe('PlatformTableCountRatioValue'); + expect(localCountRatioGuard?.allPatterns).toEqual([ + 'const childCountCell', + '/{count.total}', + ]); + expect(localCountRatioGuard?.scopes).toEqual(['src/features/kubernetes']); + expect(localCountRatioGuard?.allowedPaths ?? []).toHaveLength(0); + expect(localCountRatioGuard?.ignoredPaths ?? []).toHaveLength(0); + + expect(sharedPlatformPageSource).toContain('export function PlatformTableCountRatioValue'); + + for (const [path, source] of platformCountRatioConsumers) { + expect(source).toContain('PlatformTableCountRatioValue'); + const forbiddenPatterns = + registeredRule?.forbiddenPatterns?.find((entry) => entry.path === path)?.patterns ?? []; + for (const pattern of forbiddenPatterns) { + expect(source).not.toContain(pattern); + } + } + }); + it('keeps platform table scalar unit values on shared primitives', () => { const registry = JSON.parse(sharedTemplateRegistrySource) as { rules?: Array<{ @@ -3943,11 +4060,15 @@ describe('shared primitive guardrails', () => { const localTemperatureGuard = registry.patternGuards?.find( (guard) => guard.id === 'platform-table-local-temperature-value-helper', ); + const localTemperatureLabelGuard = registry.patternGuards?.find( + (guard) => guard.id === 'platform-table-local-temperature-label-helper', + ); const percentConsumers: Array<[string, string]> = [ ['src/features/truenas/TrueNASSystemsTable.tsx', truenasSystemsTableSource], ]; const temperatureConsumers: Array<[string, string]> = [ ['src/features/docker/DockerHostsTable.tsx', dockerHostsTableSource], + ['src/features/truenas/TrueNASStorageTopologyTable.tsx', truenasStorageTopologyTableSource], ['src/features/truenas/TrueNASSystemsTable.tsx', truenasSystemsTableSource], ]; const localPercentPatterns = [ @@ -3983,6 +4104,10 @@ describe('shared primitive guardrails', () => { path: 'src/features/docker/DockerHostsTable.tsx', patterns: localTemperaturePatterns, }, + { + path: 'src/features/truenas/TrueNASStorageTopologyTable.tsx', + patterns: ['const temperatureLabel', '${Math.round(value)}C'], + }, { path: 'src/features/truenas/TrueNASSystemsTable.tsx', patterns: localTemperaturePatterns, @@ -4005,6 +4130,17 @@ describe('shared primitive guardrails', () => { expect(localTemperatureGuard?.scopes).toEqual(['src/features/docker', 'src/features/truenas']); expect(localTemperatureGuard?.allowedPaths ?? []).toHaveLength(0); expect(localTemperatureGuard?.ignoredPaths ?? []).toHaveLength(0); + expect(localTemperatureLabelGuard?.canonical?.path).toBe( + 'src/features/platformPage/sharedPlatformPage.tsx', + ); + expect(localTemperatureLabelGuard?.canonical?.export).toBe('PlatformTableTemperatureValue'); + expect(localTemperatureLabelGuard?.allPatterns).toEqual([ + 'const temperatureLabel', + '${Math.round(value)}C', + ]); + expect(localTemperatureLabelGuard?.scopes).toEqual(['src/features/truenas']); + expect(localTemperatureLabelGuard?.allowedPaths ?? []).toHaveLength(0); + expect(localTemperatureLabelGuard?.ignoredPaths ?? []).toHaveLength(0); expect(sharedPlatformPageSource).toContain('export function PlatformTablePercentValue'); expect(sharedPlatformPageSource).toContain('export function PlatformTableTemperatureValue'); diff --git a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx index 72590a3c2..8718f927e 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesClustersTable.tsx @@ -8,6 +8,7 @@ import { getSimpleStatusIndicator } from '@/utils/status'; import { asTrimmedString } from '@/utils/stringUtils'; import { PLATFORM_HEALTH_FILTER_OPTIONS, + PlatformTableCountRatioValue, PlatformTableMetricFallback, PlatformTableToolbar, PlatformTableEmptyState, @@ -30,7 +31,6 @@ import { buildKubernetesClusterChildCounts, emptyKubernetesClusterChildCounts, filterKubernetesResources, - type KubernetesClusterChildCount, type KubernetesResourceStatusFilter, } from './kubernetesPageModel'; @@ -44,18 +44,6 @@ import { // CPU/Memory utilisation. It reuses the same shared primitives every // other platform-page table uses. -// v5-style healthy/total fraction: the healthy share turns amber as soon as -// any child needs attention, so a cluster with a NotReady node reads "2/3" -// instead of a flat "3". -const childCountCell = (count: KubernetesClusterChildCount) => ( - <> - 0 ? 'text-amber-700 dark:text-amber-300' : ''}> - {count.total - count.attention} - - /{count.total} - -); - export const KubernetesClustersTable: Component<{ clusters: Resource[]; // All Kubernetes-tagged resources from the same query, so the table can @@ -231,19 +219,33 @@ export const KubernetesClustersTable: Component<{ - {childCountCell(counts().nodes)} + 0 ? 'warning' : undefined} + /> { expect(screen.queryByText('Pending uninstall')).toBeNull(); }); + it('renders child counts through the shared count-ratio primitive', () => { + const cluster = makeClusterResource(); + + const { container } = render(() => ( + } + emptyTitle="No clusters" + emptyDescription="No clusters" + showToolbar={false} + /> + )); + + const row = container.querySelector('[data-kubernetes-cluster-row="cluster:prod-west"]'); + expect(row?.textContent).toContain('1/2'); + expect(row?.querySelector('.text-amber-700')?.textContent).toBe('1'); + expect(row?.querySelectorAll('.tabular-nums').length).toBeGreaterThanOrEqual(2); + }); + it('badges clusters whose agent is pending uninstall', () => { const cluster = makeClusterResource({ kubernetes: { diff --git a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts index 691a9a54a..42d86d9a5 100644 --- a/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts +++ b/frontend-modern/src/features/platformPage/__tests__/sharedPlatformPage.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { createRoot, createSignal } from 'solid-js'; import type { Resource } from '@/types/resource'; import { + PlatformTableCountRatioValue, PlatformTableMetricFallback, PlatformTableNumberValue, PlatformTablePercentValue, @@ -333,6 +334,37 @@ describe('PlatformTableNumberValue', () => { }); }); +describe('PlatformTableCountRatioValue', () => { + it('renders count ratios through shared tabular number styling', () => { + const { container } = render(() => + PlatformTableCountRatioValue({ + current: 2, + total: 3, + currentTone: 'warning', + suffix: 'ready', + }), + ); + + expect(container.textContent).toBe('2/3 ready'); + expect(container.querySelectorAll('.tabular-nums')).toHaveLength(2); + expect(screen.getByText('2').parentElement?.classList.contains('text-amber-700')).toBe(true); + expect(screen.getByText('/').classList.contains('text-muted')).toBe(true); + expect(screen.getByText('3').parentElement?.classList.contains('text-muted')).toBe(true); + }); + + it('uses the shared empty marker when neither side is finite', () => { + render(() => + PlatformTableCountRatioValue({ + current: undefined, + total: Number.NaN, + emptyText: '-', + }), + ); + + expect(screen.getByText('-')).toBeInTheDocument(); + }); +}); + describe('PlatformTablePercentValue', () => { it('renders finite percentages with one decimal place and shared tabular styling', () => { const { container } = render(() => PlatformTablePercentValue({ value: 42.345 })); diff --git a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx index 8b9a4ab0e..d8f518aec 100644 --- a/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx +++ b/frontend-modern/src/features/platformPage/sharedPlatformPage.tsx @@ -221,6 +221,41 @@ export function PlatformTableNumberValue(props: { return {label()}; } +export function PlatformTableCountRatioValue(props: { + current: number | undefined; + total: number | undefined; + currentTone?: 'warning'; + emptyText?: string; + suffix?: string; +}) { + const hasAnyValue = () => + [props.current, props.total].some( + (value) => typeof value === 'number' && Number.isFinite(value), + ); + const currentClass = () => + props.currentTone === 'warning' ? 'text-amber-700 dark:text-amber-300' : ''; + + return ( + } + > + + + + + / + + + + + {(suffix) => {suffix()}} + + + + ); +} + const formatOneDecimalPercent = (value: number): string => `${value.toFixed(1)}%`; const formatOneDecimalCelsius = (value: number): string => `${value.toFixed(1)}°C`; diff --git a/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx b/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx index c521b1982..0a22da569 100644 --- a/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx +++ b/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx @@ -2,7 +2,6 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js'; import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow'; import { StatusDot } from '@/components/shared/StatusDot'; import { TableCell, TableHead, TableRow } from '@/components/shared/Table'; -import { formatBytes } from '@/utils/format'; import { getRecoveryOutcomeBadgeClass, getRecoveryOutcomeLabel, @@ -16,6 +15,7 @@ import { PlatformTableLoadingState, PlatformTableToolbar, createPlatformTableFilterState, + formatPlatformTableBytesValue, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, type PlatformTableFilterOption, @@ -174,7 +174,7 @@ const formatPointTime = (point: RecoveryPoint): string => { }; const sizeLabel = (point: RecoveryPoint): string => - typeof point.sizeBytes === 'number' && point.sizeBytes > 0 ? formatBytes(point.sizeBytes) : '-'; + formatPlatformTableBytesValue(point.sizeBytes ?? undefined, '-'); type ProtectionDetailTone = DetailValueTone; type ProtectionDetailSection = DetailSection; @@ -483,7 +483,7 @@ export const TrueNASProtectionTable: Component<{ {formatPointTime(point)} diff --git a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx index 6f8656cb7..94d3252a5 100644 --- a/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx +++ b/frontend-modern/src/features/truenas/TrueNASStorageTopologyTable.tsx @@ -3,14 +3,16 @@ import { StatusDot } from '@/components/shared/StatusDot'; import { ResponsiveMetricCell } from '@/components/shared/responsive'; import { TableCell, TableHead, TableRow } from '@/components/shared/Table'; import { filterChipStatusDot } from '@/components/shared/FilterBar'; -import { formatBytes } from '@/utils/format'; import { getSimpleStatusIndicator } from '@/utils/status'; import { asTrimmedString } from '@/utils/stringUtils'; import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { PlatformTableEmptyState, + PlatformTableNumberValue, + PlatformTableTemperatureValue, PlatformTableToolbar, createPlatformTableFilterState, + formatPlatformTableBytesValue, formatPlatformTableTitleCaseValue, getPlatformTableCellClassForKind, getPlatformTableHeadClassForKind, @@ -88,12 +90,12 @@ const capacityPercent = (resource: Resource): number | undefined => { const diskSizeLabel = (row: TrueNASStorageTopologyRow): string => { const size = row.resource.physicalDisk?.sizeBytes; - return typeof size === 'number' && size > 0 ? formatBytes(size) : '-'; + return formatPlatformTableBytesValue(size, '-'); }; const capacitySublabel = (row: TrueNASStorageTopologyRow): string | undefined => { if (typeof row.resource.disk?.used === 'number' && typeof row.resource.disk?.total === 'number') { - return `${formatBytes(row.resource.disk.used)} / ${formatBytes(row.resource.disk.total)}`; + return `${formatPlatformTableBytesValue(row.resource.disk.used, '-')} / ${formatPlatformTableBytesValue(row.resource.disk.total, '-')}`; } return undefined; }; @@ -160,17 +162,6 @@ const RiskPill: Component<{ row: TrueNASStorageTopologyRow }> = (props) => ( ); -const temperatureLabel = (row: TrueNASStorageTopologyRow): string => { - if (row.kind !== 'disk') return '-'; - const value = row.resource.physicalDisk?.temperature; - return typeof value === 'number' && Number.isFinite(value) ? `${Math.round(value)}C` : '-'; -}; - -const diskCountLabel = (row: TrueNASStorageTopologyRow): string => { - if (row.kind === 'pool') return String(row.counts.disks); - return '-'; -}; - export const getTrueNASStorageTopologyIndentClass = (depth: number): string => { if (depth <= 0) return ''; if (depth === 1) return 'pl-5 sm:pl-7'; @@ -328,14 +319,24 @@ export const TrueNASStorageTopologyTable: Component<{ diff --git a/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx b/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx index a76c0c9fc..2615e05d7 100644 --- a/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx +++ b/frontend-modern/src/features/truenas/TrueNASSystemsTable.tsx @@ -9,6 +9,7 @@ import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys'; import { PLATFORM_HEALTH_FILTER_OPTIONS, PlatformTableMetricFallback, + PlatformTableNumberValue, PlatformTablePercentValue, PlatformTableTemperatureValue, PlatformTableToolbar, @@ -322,9 +323,9 @@ export const TrueNASSystemsTable: Component<{ { ]); }); + it('keeps Kubernetes native API sections in the governed platform projection', () => { + expect(SOURCE_PLATFORM_CANONICAL_PROJECTIONS.kubernetes).toEqual([ + 'k8s-cluster', + 'k8s-node', + 'pod', + 'k8s-deployment', + 'k8s-replicaset', + 'k8s-statefulset', + 'k8s-daemonset', + 'k8s-service', + 'k8s-job', + 'k8s-cronjob', + 'k8s-ingress', + 'k8s-endpoint-slice', + 'k8s-network-policy', + 'k8s-persistent-volume', + 'k8s-persistent-volume-claim', + 'k8s-storage-class', + 'k8s-namespace', + 'k8s-configmap', + 'k8s-secret', + 'k8s-serviceaccount', + 'k8s-resource-quota', + 'k8s-limit-range', + 'k8s-pod-disruption-budget', + 'k8s-horizontal-pod-autoscaler', + 'k8s-event', + ]); + }); + describe('isInfrastructure', () => { const infrastructureTypes: ResourceType[] = [ 'agent',