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}
+ />
- {childCountCell(counts().pods)}
+ 0 ? 'warning' : undefined}
+ />
- {childCountCell(counts().deployments)}
+ 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)}
{sizeLabel(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<{
- {diskCountLabel(row)}
+
- {temperatureLabel(row)}
+
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<{
- {c().shares}
+
- {c().services}
+
{
]);
});
+ 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',