Converge resource detail numeric formatting

This commit is contained in:
rcourtman
2026-06-13 14:46:06 +01:00
parent 1aec698bc7
commit 9712150c1e
10 changed files with 395 additions and 105 deletions
@@ -553,6 +553,11 @@ not a replacement status card, CTA band, or page-local nested card.
classes, table rendering, and inline close-action chrome must come from
`detailSectionModel.ts`, `DetailSectionTable`, and `InlineDetailPanel`
instead of local `DetailField` grids or provider-named reusable primitives.
Resource-detail drawer byte labels, integer labels, and count pluralization
are part of that same primitive family: provider drawer models choose the
fields and domain labels, but numeric detail values must route through
`formatDetailBytesValue`, `formatDetailIntegerValue`, and
`formatDetailCountValue` in `detailSectionModel.ts`.
Framed product table surfaces must consume the shared `TableCard` frame and
`TableCardHeader` title/action band instead of composing page-local `Card`
border, background, overflow, or table-title chrome. Feature owners may own
@@ -363,6 +363,10 @@ compaction, table rendering, value-tone classes, and inline close-action chrome
must compose the frontend-primitives-owned `DetailSectionTable`,
`InlineDetailPanel`, and `detailSectionModel.ts` primitives instead of
recreating local `DetailField` grids or provider-named neutral detail tables.
Provider detail drawer models also own which byte, count, and integer fields
are meaningful, but the formatting and pluralization of those repeated numeric
detail values must use the shared `detailSectionModel.ts` helpers rather than
provider-local byte scaling, integer, or count helpers.
The split also applies to web-interface launch affordances. Unified-resource
tables own whether a row has a saved, inferred, or source-native web-interface
URL and how that URL is derived, but the visible launch affordance belongs on
@@ -162,6 +162,48 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "resource-detail-numeric-value-formatting",
"category": "row-detail",
"summary": "Resource detail drawer models must use the shared detail-section numeric formatting helpers for byte scaling, integer labels, and count pluralization instead of recreating provider-local byte/count helpers.",
"canonical": {
"path": "src/components/shared/detailSectionModel.ts",
"export": "formatDetailBytesValue"
},
"requiredConsumers": [
{ "path": "src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts" },
{ "path": "src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts" },
{ "path": "src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts" }
],
"forbiddenPatterns": [
{
"path": "src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts",
"patterns": ["const formatBytes", "const units = ['B', 'KB', 'MB', 'GB', 'TB']"]
},
{
"path": "src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts",
"patterns": [
"const formatInteger",
"const formatBytes",
"const formatCount",
"new Intl.NumberFormat().format"
]
},
{
"path": "src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts",
"patterns": [
"const formatCount",
"const formatCapacityBytes",
"const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']"
]
}
],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/__tests__/DetailSectionTable.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "table-card-frame-shell",
"category": "table-card",
@@ -3946,6 +3988,92 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "resource-detail-local-byte-format-helper",
"category": "row-detail",
"summary": "Resource detail drawer models must not recreate local byte-scaling helpers.",
"canonical": {
"path": "src/components/shared/detailSectionModel.ts",
"export": "formatDetailBytesValue"
},
"scopes": [
"src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts",
"src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts"
],
"extensions": [".ts"],
"allPatterns": ["const formatBytes", "const units = ['B'"],
"legacyReason": "Retired migration debt. Detail drawer byte labels must use formatDetailBytesValue so byte precision, zero handling, and unit scaling stay shared.",
"allowedPaths": [],
"ignoredPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/__tests__/DetailSectionTable.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "resource-detail-local-capacity-byte-format-helper",
"category": "row-detail",
"summary": "Resource detail drawer models must not recreate compact local capacity byte helpers.",
"canonical": {
"path": "src/components/shared/detailSectionModel.ts",
"export": "formatDetailBytesValue"
},
"scopes": ["src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts"],
"extensions": [".ts"],
"allPatterns": ["const formatCapacityBytes", "const units = ['B'"],
"legacyReason": "Retired migration debt. Compact detail drawer capacity labels must use formatDetailBytesValue with compact precision instead of local byte scaling.",
"allowedPaths": [],
"ignoredPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/__tests__/DetailSectionTable.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "resource-detail-local-count-format-helper",
"category": "row-detail",
"summary": "Resource detail drawer models must not recreate local count pluralization helpers.",
"canonical": {
"path": "src/components/shared/detailSectionModel.ts",
"export": "formatDetailCountValue"
},
"scopes": [
"src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts",
"src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts"
],
"extensions": [".ts"],
"allPatterns": ["const formatCount"],
"legacyReason": "Retired migration debt. Detail drawer count labels must use formatDetailCountValue so integer formatting and pluralization stay shared.",
"allowedPaths": [],
"ignoredPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/__tests__/DetailSectionTable.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "resource-detail-local-integer-format-helper",
"category": "row-detail",
"summary": "Resource detail drawer models must not recreate local integer formatters.",
"canonical": {
"path": "src/components/shared/detailSectionModel.ts",
"export": "formatDetailIntegerValue"
},
"scopes": ["src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts"],
"extensions": [".ts"],
"allPatterns": ["const formatInteger", "new Intl.NumberFormat().format"],
"legacyReason": "Retired migration debt. Detail drawer integer labels must use formatDetailIntegerValue so count-like numeric rows share locale formatting.",
"allowedPaths": [],
"ignoredPaths": [],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/__tests__/DetailSectionTable.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "button-compact-settings-action-local-shell",
"category": "action-button",
@@ -2,6 +2,7 @@ import type { Resource, ResourceKubernetesPodContainerStatus } from '@/types/res
import {
compactDetailRows as compactRows,
compactDetailSections as compactSections,
formatDetailBytesValue,
makeDetailRow as makeRow,
type DetailSection,
} from '@/components/shared/detailSectionModel';
@@ -13,24 +14,12 @@ const asString = (value?: string | null): string | null => {
return trimmed ? trimmed : null;
};
const formatBytes = (bytes?: number): string | null => {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return null;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let scaled = bytes;
let unitIndex = 0;
while (scaled >= 1024 && unitIndex < units.length - 1) {
scaled /= 1024;
unitIndex += 1;
}
return `${scaled.toFixed(scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2)} ${units[unitIndex]}`;
};
const formatNodeBudget = (cores?: number, memoryBytes?: number, pods?: number): string | null => {
const parts: string[] = [];
if (typeof cores === 'number' && Number.isFinite(cores) && cores > 0) {
parts.push(`${cores} cores`);
}
const memory = formatBytes(memoryBytes);
const memory = formatDetailBytesValue(memoryBytes);
if (memory) parts.push(memory);
if (typeof pods === 'number' && Number.isFinite(pods) && pods > 0) {
parts.push(`${pods} pods`);
@@ -12,6 +12,9 @@ import type {
import {
compactDetailRows,
compactDetailSections,
formatDetailBytesValue,
formatDetailCountValue,
formatDetailIntegerValue,
makeDetailRow,
type DetailRow,
type DetailSection,
@@ -32,11 +35,6 @@ const asString = (value?: string | null): string | null => {
const asPositiveNumber = (value?: number): number | null =>
typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
const formatInteger = (value?: number): string | null => {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return new Intl.NumberFormat().format(Math.round(value));
};
const normalizeDelimitedLabel = (value?: string): string | null => {
const trimmed = asString(value);
if (!trimmed) return null;
@@ -47,19 +45,6 @@ const normalizeDelimitedLabel = (value?: string): string | null => {
.join(' ');
};
const formatBytes = (bytes?: number): string | null => {
const value = asPositiveNumber(bytes);
if (!value) return null;
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let scaled = value;
let unitIndex = 0;
while (scaled >= 1024 && unitIndex < units.length - 1) {
scaled /= 1024;
unitIndex += 1;
}
return `${scaled.toFixed(scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2)} ${units[unitIndex]}`;
};
const formatPercent = (percent?: number): string | null => {
if (typeof percent !== 'number' || !Number.isFinite(percent)) return null;
return `${percent.toFixed(percent >= 10 ? 1 : 2)}%`;
@@ -81,9 +66,6 @@ const formatDurationSeconds = (seconds?: number): string | null => {
return minutes > 0 ? `${minutes}m` : '<1m';
};
const formatCount = (value: number, singular: string, plural = `${singular}s`): string =>
`${new Intl.NumberFormat().format(value)} ${value === 1 ? singular : plural}`;
const summarizeList = (
values: string[],
visibleCount = 3,
@@ -196,7 +178,10 @@ const buildTrueNASSystemSections = (
]);
const serviceRows = compactRows([
row('Services', services.length > 0 ? formatCount(services.length, 'service') : null),
row(
'Services',
services.length > 0 ? formatDetailCountValue(services.length, 'service') : null,
),
...(['running', 'attention', 'stopped', 'disabled'] as TrueNASServiceStatus[]).map((status) =>
row(
serviceStatusLabel(status),
@@ -241,8 +226,8 @@ const storageStateTone = (
};
const storageUsageLabel = (resource: Resource): string | null => {
const used = formatBytes(resource.disk?.used);
const total = formatBytes(resource.disk?.total);
const used = formatDetailBytesValue(resource.disk?.used);
const total = formatDetailBytesValue(resource.disk?.total);
if (used && total) return `${used} / ${total}`;
return formatPercent(resource.disk?.current);
};
@@ -275,11 +260,11 @@ const buildTrueNASStorageSections = (
const capacityRows = compactRows([
row('Usage', storageUsageLabel(resource)),
row('Used', formatBytes(resource.disk?.used)),
row('Total', formatBytes(resource.disk?.total)),
row('Used', formatDetailBytesValue(resource.disk?.used)),
row('Total', formatDetailBytesValue(resource.disk?.total)),
row('Percent', formatPercent(resource.disk?.current)),
row('Children', formatInteger(resource.childCount)),
row('Consumers', formatInteger(storage.consumerCount)),
row('Children', formatDetailIntegerValue(resource.childCount)),
row('Consumers', formatDetailIntegerValue(storage.consumerCount)),
]);
const healthRows = compactRows([
@@ -329,7 +314,7 @@ const diskTypeLabel = (value?: string): string | null => {
const formatDiskHours = (hours?: number): string | null => {
const value = asPositiveNumber(hours);
if (!value) return null;
return `${formatInteger(value) ?? value.toFixed(0)}h`;
return `${formatDetailIntegerValue(value) ?? value.toFixed(0)}h`;
};
const buildTrueNASDiskSections = (
@@ -345,7 +330,7 @@ const buildTrueNASDiskSections = (
row('Serial', asString(disk.serial)),
row('WWN', asString(disk.wwn)),
row('Type', diskTypeLabel(disk.diskType)),
row('Size', formatBytes(disk.sizeBytes)),
row('Size', formatDetailBytesValue(disk.sizeBytes)),
]);
const healthRows = compactRows([
@@ -357,7 +342,7 @@ const buildTrueNASDiskSections = (
'Wearout',
disk.wearout === undefined || disk.wearout < 0 ? null : formatPercent(disk.wearout),
),
row('RPM', formatInteger(disk.rpm)),
row('RPM', formatDetailIntegerValue(disk.rpm)),
row('Role', normalizeDelimitedLabel(disk.storageRole)),
row('Group', asString(disk.storageGroup) ?? asString(resource.parentName)),
row('State', normalizeDelimitedLabel(disk.storageState)),
@@ -366,23 +351,23 @@ const buildTrueNASDiskSections = (
const smartRows = compactRows([
row('Power on', formatDiskHours(disk.smart?.powerOnHours)),
row('Power cycles', formatInteger(disk.smart?.powerCycles)),
row('Reallocated', formatInteger(disk.smart?.reallocatedSectors), {
row('Power cycles', formatDetailIntegerValue(disk.smart?.powerCycles)),
row('Reallocated', formatDetailIntegerValue(disk.smart?.reallocatedSectors), {
tone: disk.smart?.reallocatedSectors ? 'warning' : 'default',
}),
row('Pending sectors', formatInteger(disk.smart?.pendingSectors), {
row('Pending sectors', formatDetailIntegerValue(disk.smart?.pendingSectors), {
tone: disk.smart?.pendingSectors ? 'warning' : 'default',
}),
row('Offline uncorrectable', formatInteger(disk.smart?.offlineUncorrectable), {
row('Offline uncorrectable', formatDetailIntegerValue(disk.smart?.offlineUncorrectable), {
tone: disk.smart?.offlineUncorrectable ? 'warning' : 'default',
}),
row('CRC errors', formatInteger(disk.smart?.udmaCrcErrors), {
row('CRC errors', formatDetailIntegerValue(disk.smart?.udmaCrcErrors), {
tone: disk.smart?.udmaCrcErrors ? 'warning' : 'default',
}),
row('Media errors', formatInteger(disk.smart?.mediaErrors), {
row('Media errors', formatDetailIntegerValue(disk.smart?.mediaErrors), {
tone: disk.smart?.mediaErrors ? 'warning' : 'default',
}),
row('Unsafe shutdowns', formatInteger(disk.smart?.unsafeShutdowns)),
row('Unsafe shutdowns', formatDetailIntegerValue(disk.smart?.unsafeShutdowns)),
row('Available spare', formatPercent(disk.smart?.availableSpare)),
row('Percentage used', formatPercent(disk.smart?.percentageUsed)),
]);
@@ -456,7 +441,7 @@ const buildTrueNASAppSections = (
tone: app.state?.toLowerCase() === 'running' ? 'success' : 'warning',
}),
row('Version', asString(app.humanVersion) ?? asString(app.version)),
row('Containers', formatInteger(containerCount)),
row('Containers', formatDetailIntegerValue(containerCount)),
row('Custom app', yesNoValue(app.customApp)),
row(
'App updates',
@@ -499,12 +484,12 @@ const buildTrueNASAppSections = (
const formatVMCpu = (vm: ResourceTrueNASVMMeta): string | null => {
const vcpus = asPositiveNumber(vm.vcpus);
if (vcpus) return formatCount(vcpus, 'vCPU', 'vCPU');
if (vcpus) return formatDetailCountValue(vcpus, 'vCPU', 'vCPU');
const cores = asPositiveNumber(vm.cores);
const threads = asPositiveNumber(vm.threads);
if (cores && threads) return `${cores} cores x ${threads} threads`;
if (cores) return formatCount(cores, 'core');
if (threads) return formatCount(threads, 'thread');
if (cores) return formatDetailCountValue(cores, 'core');
if (threads) return formatDetailCountValue(threads, 'thread');
return null;
};
@@ -512,8 +497,8 @@ const formatVMTopology = (vm: ResourceTrueNASVMMeta): string | null => {
const cores = asPositiveNumber(vm.cores);
const threads = asPositiveNumber(vm.threads);
if (cores && threads) return `${cores} cores x ${threads} threads`;
if (cores) return formatCount(cores, 'core');
if (threads) return formatCount(threads, 'thread');
if (cores) return formatDetailCountValue(cores, 'core');
if (threads) return formatDetailCountValue(threads, 'thread');
return null;
};
@@ -531,8 +516,8 @@ const buildTrueNASVMSections = (
row('Domain state', sameState ? null : domainState),
row('vCPU', formatVMCpu(vm)),
row('Topology', formatVMTopology(vm)),
row('Memory', formatBytes(vm.memoryBytes)),
row('Minimum memory', formatBytes(vm.minMemoryBytes)),
row('Memory', formatDetailBytesValue(vm.memoryBytes)),
row('Minimum memory', formatDetailBytesValue(vm.minMemoryBytes)),
row('CPU mode', normalizeDelimitedLabel(vm.cpuMode)),
row('CPU model', asString(vm.cpuModel)),
]);
@@ -540,18 +525,18 @@ const buildTrueNASVMSections = (
const runtimeRows = compactRows([
row('Bootloader', asString(vm.bootloader)),
row('Machine', machine),
row('Process ID', formatInteger(vm.pid)),
row('Process ID', formatDetailIntegerValue(vm.pid)),
row('UUID', asString(vm.uuid)),
]);
const deviceRows = compactRows([
row('Total', formatInteger(vm.deviceCount)),
row('Disks', formatInteger(vm.diskCount)),
row('NICs', formatInteger(vm.nicCount)),
row('Displays', formatInteger(vm.displayCount)),
row('CD-ROMs', formatInteger(vm.cdromCount)),
row('USB', formatInteger(vm.usbCount)),
row('PCI', formatInteger(vm.pciCount)),
row('Total', formatDetailIntegerValue(vm.deviceCount)),
row('Disks', formatDetailIntegerValue(vm.diskCount)),
row('NICs', formatDetailIntegerValue(vm.nicCount)),
row('Displays', formatDetailIntegerValue(vm.displayCount)),
row('CD-ROMs', formatDetailIntegerValue(vm.cdromCount)),
row('USB', formatDetailIntegerValue(vm.usbCount)),
row('PCI', formatDetailIntegerValue(vm.pciCount)),
]);
const flagRows = compactRows([
@@ -674,8 +659,8 @@ export const buildTrueNASDetailsSummary = (resource: Resource): string | null =>
const summary = [
normalizeDelimitedLabel(vm.state ?? vm.domainState),
formatVMCpu(vm),
formatBytes(vm.memoryBytes),
deviceCount ? formatCount(deviceCount, 'device') : null,
formatDetailBytesValue(vm.memoryBytes),
deviceCount ? formatDetailCountValue(deviceCount, 'device') : null,
].filter((value): value is string => Boolean(value));
return summary.length > 0 ? summary.join(', ') : null;
}
@@ -686,9 +671,9 @@ export const buildTrueNASDetailsSummary = (resource: Resource): string | null =>
const updateCount = [app.upgradeAvailable, app.imageUpdatesAvailable].filter(Boolean).length;
const summary = [
normalizeDelimitedLabel(app.state),
formatCount(app.containerCount ?? app.containers?.length ?? 0, 'container'),
formatCount(portCount, 'port'),
updateCount > 0 ? formatCount(updateCount, 'update') : null,
formatDetailCountValue(app.containerCount ?? app.containers?.length ?? 0, 'container'),
formatDetailCountValue(portCount, 'port'),
updateCount > 0 ? formatDetailCountValue(updateCount, 'update') : null,
].filter((value): value is string => Boolean(value));
return summary.length > 0 ? summary.join(', ') : null;
}
@@ -709,7 +694,7 @@ export const buildTrueNASDetailsSummary = (resource: Resource): string | null =>
const summary = [
diskTypeLabel(disk.diskType),
normalizeDelimitedLabel(disk.health),
formatBytes(disk.sizeBytes),
formatDetailBytesValue(disk.sizeBytes),
formatTemperature(disk.temperature),
].filter((value): value is string => Boolean(value));
return summary.length > 0 ? summary.join(', ') : null;
@@ -721,7 +706,7 @@ export const buildTrueNASDetailsSummary = (resource: Resource): string | null =>
const summary = [
asString(truenas.version),
formatDurationSeconds(truenas.uptimeSeconds ?? resource.uptime),
serviceCount !== undefined ? formatCount(serviceCount, 'service') : null,
serviceCount !== undefined ? formatDetailCountValue(serviceCount, 'service') : null,
asString(truenas.storageRiskSummary) ?? asString(truenas.protectionSummary),
].filter((value): value is string => Boolean(value));
return summary.length > 0 ? summary.join(', ') : null;
@@ -8,6 +8,10 @@ import type {
ResourceVMwareVirtualDisk,
} from '@/types/resource';
import { formatVmwareClusterServices } from '@/utils/vmwareDisplay';
import {
formatDetailBytesValue,
formatDetailCountValue,
} from '@/components/shared/detailSectionModel';
export type ResourceDetailDrawerVMwareRowTone = 'default' | 'accent' | 'warning';
@@ -34,9 +38,6 @@ export type ResourceDetailDrawerVMwareSection = {
const asTrimmedString = (value?: string | null): string => (value || '').trim();
const formatCount = (count: number, label: string): string =>
`${count} ${label}${count === 1 ? '' : 's'}`;
const summarizeList = (values: string[] | undefined): string =>
(values ?? []).map(asTrimmedString).filter(Boolean).join(', ');
@@ -74,22 +75,15 @@ const formatEnumLabel = (value?: string | null): string => {
.join(' ');
};
const formatCapacityBytes = (value?: number): string => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const precision = unitIndex === 0 || size >= 10 ? 0 : 1;
return `${size.toFixed(precision)} ${units[unitIndex]}`;
};
const formatMiB = (value?: number): string => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return '';
return formatCapacityBytes(value * 1024 * 1024).replace(/\.0 ([A-Z]+)/, ' $1');
return (
formatDetailBytesValue(value * 1024 * 1024, {
allowZero: true,
precision: 'compact',
trimWhole: true,
}) ?? ''
);
};
const countSnapshotTree = (snapshots?: ResourceVMwareSnapshot[]): number =>
@@ -233,8 +227,7 @@ const hardwareRows = (vmware?: ResourceVMwareMeta): ResourceDetailDrawerVMwareRo
if (!vmware?.hardware) return [];
const hardware = vmware.hardware;
const upgradeStatus = asTrimmedString(hardware.upgradeStatus).toUpperCase();
const upgradeAttention =
Boolean(upgradeStatus) && !['NONE', 'OK'].includes(upgradeStatus);
const upgradeAttention = Boolean(upgradeStatus) && !['NONE', 'OK'].includes(upgradeStatus);
return filterNonEmptyRows([
{
label: 'Guest OS',
@@ -296,8 +289,7 @@ const toolsSummary = (tools?: ResourceVMwareTools): string => {
const toolsRows = (tools?: ResourceVMwareTools): ResourceDetailDrawerVMwareRow[] => {
if (!tools) return [];
const versionStatus = asTrimmedString(tools.versionStatus).toUpperCase();
const versionAttention =
Boolean(versionStatus) && !['CURRENT', 'OK'].includes(versionStatus);
const versionAttention = Boolean(versionStatus) && !['CURRENT', 'OK'].includes(versionStatus);
// Drawer surfaces what an operator scans for: is Tools running, is its
// version current, has the guest asked for a reboot, did the last install
// error. Install metadata (install type, upgrade policy, auto-update
@@ -359,7 +351,11 @@ const formatVirtualDiskAddress = (disk: ResourceVMwareVirtualDisk): string => {
const virtualDiskValue = (disk: ResourceVMwareVirtualDisk): string => {
const parts = [
formatVirtualDiskAddress(disk),
formatCapacityBytes(disk.capacityBytes),
formatDetailBytesValue(disk.capacityBytes, {
allowZero: true,
precision: 'compact',
trimWhole: true,
}),
asTrimmedString(disk.datastoreName),
asTrimmedString(disk.backingType),
asTrimmedString(disk.vmdkFile),
@@ -399,7 +395,7 @@ const vmwareEntityLabel = (entityType?: string): string => {
const buildSignalValue = (count: number | undefined, label: string, summary?: string): string => {
const parts: string[] = [];
if (typeof count === 'number') {
parts.push(formatCount(Math.max(0, count), label));
parts.push(formatDetailCountValue(Math.max(0, count), label));
}
const trimmedSummary = asTrimmedString(summary);
if (trimmedSummary) {
@@ -438,21 +434,21 @@ export const buildVMwareDetailsSummary = (
? Math.max(0, vmware.snapshotCount)
: countSnapshotTree(vmware.snapshotTree);
if (resourceType === 'vm' && snapshotCount > 0) {
parts.push(formatCount(snapshotCount, 'snapshot'));
parts.push(formatDetailCountValue(snapshotCount, 'snapshot'));
}
const networkAdapterCount = vmware.networkAdapters?.length ?? 0;
if (resourceType === 'vm' && networkAdapterCount > 0) {
parts.push(formatCount(networkAdapterCount, 'vNIC'));
parts.push(formatDetailCountValue(networkAdapterCount, 'vNIC'));
}
const virtualDiskCount = vmware.virtualDisks?.length ?? 0;
if (resourceType === 'vm' && virtualDiskCount > 0) {
parts.push(formatCount(virtualDiskCount, 'disk'));
parts.push(formatDetailCountValue(virtualDiskCount, 'disk'));
}
if (resourceType === 'network') {
const hostCount = vmware.networkHostNames?.length ?? vmware.networkHostIds?.length ?? 0;
const vmCount = vmware.networkVmNames?.length ?? vmware.networkVmIds?.length ?? 0;
if (hostCount > 0) parts.push(formatCount(hostCount, 'host'));
if (vmCount > 0) parts.push(formatCount(vmCount, 'VM'));
if (hostCount > 0) parts.push(formatDetailCountValue(hostCount, 'host'));
if (vmCount > 0) parts.push(formatDetailCountValue(vmCount, 'VM'));
}
const hardware = resourceType === 'vm' ? hardwareSummary(vmware.hardware) : '';
if (hardware) {
@@ -463,10 +459,10 @@ export const buildVMwareDetailsSummary = (
parts.push(tools);
}
if ((vmware.activeAlarmCount ?? 0) > 0) {
parts.push(formatCount(vmware.activeAlarmCount ?? 0, 'alarm'));
parts.push(formatDetailCountValue(vmware.activeAlarmCount ?? 0, 'alarm'));
}
if ((vmware.recentTaskCount ?? 0) > 0) {
parts.push(formatCount(vmware.recentTaskCount ?? 0, 'task'));
parts.push(formatDetailCountValue(vmware.recentTaskCount ?? 0, 'task'));
}
return parts.join(' · ');
@@ -616,7 +612,7 @@ export const buildVMwareDetailSections = (
label: 'Snapshots',
value:
resourceType === 'vm' || typeof vmware.snapshotCount === 'number'
? formatCount(
? formatDetailCountValue(
Math.max(
0,
typeof vmware.snapshotCount === 'number'
@@ -6,6 +6,9 @@ import type { DetailSection, DetailValueTone } from './detailSectionModel';
export {
compactDetailRows,
compactDetailSections,
formatDetailBytesValue,
formatDetailCountValue,
formatDetailIntegerValue,
makeDetailRow,
type DetailRow,
type DetailSection,
@@ -208,6 +208,7 @@ import resourceDetailDrawerSource from '@/components/Infrastructure/ResourceDeta
import resourceDetailDrawerDebugTabSource from '@/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx?raw';
import resourceDetailDrawerKubernetesModelSource from '@/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts?raw';
import resourceDetailDrawerTrueNASModelSource from '@/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts?raw';
import resourceDetailDrawerVmwareModelSource from '@/components/Infrastructure/resourceDetailDrawerVmwareModel.ts?raw';
import aiSettingsDialogsSource from '@/components/Settings/AISettingsDialogs.tsx?raw';
import aiProviderConfigurationSectionSource from '@/components/Settings/AIProviderConfigurationSection.tsx?raw';
import agentProfilesPanelSource from '@/components/Settings/AgentProfilesPanel.tsx?raw';
@@ -5391,6 +5392,7 @@ describe('shared primitive guardrails', () => {
canonical?: { path?: string; export?: string };
allPatterns?: string[];
allowedPaths?: string[];
ignoredPaths?: string[];
scopes?: string[];
}>;
};
@@ -5403,6 +5405,21 @@ describe('shared primitive guardrails', () => {
const providerNamedGuard = registry.patternGuards?.find(
(guard) => guard.id === 'inline-detail-provider-named-primitive-import',
);
const numericFormatRule = registry.rules?.find(
(rule) => rule.id === 'resource-detail-numeric-value-formatting',
);
const localByteFormatGuard = registry.patternGuards?.find(
(guard) => guard.id === 'resource-detail-local-byte-format-helper',
);
const localCapacityByteFormatGuard = registry.patternGuards?.find(
(guard) => guard.id === 'resource-detail-local-capacity-byte-format-helper',
);
const localCountFormatGuard = registry.patternGuards?.find(
(guard) => guard.id === 'resource-detail-local-count-format-helper',
);
const localIntegerFormatGuard = registry.patternGuards?.find(
(guard) => guard.id === 'resource-detail-local-integer-format-helper',
);
expect(registeredRule?.canonical?.path).toBe('src/components/shared/DetailSectionTable.tsx');
expect(registeredRule?.canonical?.export).toBe('DetailSection');
@@ -5444,8 +5461,78 @@ describe('shared primitive guardrails', () => {
expect(providerNamedGuard?.allPatterns).toEqual(['TrueNASDetailTable']);
expect(providerNamedGuard?.allowedPaths ?? []).toHaveLength(0);
expect(numericFormatRule?.canonical?.path).toBe('src/components/shared/detailSectionModel.ts');
expect(numericFormatRule?.canonical?.export).toBe('formatDetailBytesValue');
expect(numericFormatRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
'src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts',
'src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts',
'src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts',
]);
expect(numericFormatRule?.forbiddenPatterns).toEqual([
{
path: 'src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts',
patterns: ['const formatBytes', "const units = ['B', 'KB', 'MB', 'GB', 'TB']"],
},
{
path: 'src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts',
patterns: [
'const formatInteger',
'const formatBytes',
'const formatCount',
'new Intl.NumberFormat().format',
],
},
{
path: 'src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts',
patterns: [
'const formatCount',
'const formatCapacityBytes',
"const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']",
],
},
]);
expect(localByteFormatGuard?.canonical?.export).toBe('formatDetailBytesValue');
expect(localByteFormatGuard?.scopes).toEqual([
'src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts',
'src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts',
]);
expect(localByteFormatGuard?.allPatterns).toEqual(['const formatBytes', "const units = ['B'"]);
expect(localByteFormatGuard?.allowedPaths ?? []).toHaveLength(0);
expect(localByteFormatGuard?.ignoredPaths ?? []).toHaveLength(0);
expect(localCapacityByteFormatGuard?.canonical?.export).toBe('formatDetailBytesValue');
expect(localCapacityByteFormatGuard?.scopes).toEqual([
'src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts',
]);
expect(localCapacityByteFormatGuard?.allPatterns).toEqual([
'const formatCapacityBytes',
"const units = ['B'",
]);
expect(localCapacityByteFormatGuard?.allowedPaths ?? []).toHaveLength(0);
expect(localCapacityByteFormatGuard?.ignoredPaths ?? []).toHaveLength(0);
expect(localCountFormatGuard?.canonical?.export).toBe('formatDetailCountValue');
expect(localCountFormatGuard?.scopes).toEqual([
'src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts',
'src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts',
]);
expect(localCountFormatGuard?.allPatterns).toEqual(['const formatCount']);
expect(localCountFormatGuard?.allowedPaths ?? []).toHaveLength(0);
expect(localCountFormatGuard?.ignoredPaths ?? []).toHaveLength(0);
expect(localIntegerFormatGuard?.canonical?.export).toBe('formatDetailIntegerValue');
expect(localIntegerFormatGuard?.scopes).toEqual([
'src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts',
]);
expect(localIntegerFormatGuard?.allPatterns).toEqual([
'const formatInteger',
'new Intl.NumberFormat().format',
]);
expect(localIntegerFormatGuard?.allowedPaths ?? []).toHaveLength(0);
expect(localIntegerFormatGuard?.ignoredPaths ?? []).toHaveLength(0);
expect(detailSectionModelSource).toContain('export type DetailSection');
expect(detailSectionModelSource).toContain('makeDetailRow');
expect(detailSectionModelSource).toContain('formatDetailBytesValue');
expect(detailSectionModelSource).toContain('formatDetailCountValue');
expect(detailSectionModelSource).toContain('formatDetailIntegerValue');
expect(detailSectionTableSource).toContain('DetailSectionTable');
expect(detailSectionTableSource).toContain('InlineDetailPanel');
expect(detailSectionTableSource).toContain('variant="outline"');
@@ -5459,6 +5546,21 @@ describe('shared primitive guardrails', () => {
expect(source).not.toContain('trueNASDetailTableModel');
expect(source).not.toContain('makeTrueNASDetailRow');
}
for (const source of [
resourceDetailDrawerKubernetesModelSource,
resourceDetailDrawerTrueNASModelSource,
resourceDetailDrawerVmwareModelSource,
]) {
expect(source).toContain('formatDetailBytesValue');
expect(source).not.toContain('const formatBytes');
expect(source).not.toContain('const formatCapacityBytes');
}
expect(resourceDetailDrawerTrueNASModelSource).toContain('formatDetailIntegerValue');
expect(resourceDetailDrawerTrueNASModelSource).toContain('formatDetailCountValue');
expect(resourceDetailDrawerVmwareModelSource).toContain('formatDetailCountValue');
expect(resourceDetailDrawerTrueNASModelSource).not.toContain('const formatInteger');
expect(resourceDetailDrawerTrueNASModelSource).not.toContain('const formatCount');
expect(resourceDetailDrawerVmwareModelSource).not.toContain('const formatCount');
expect(resourceDetailDrawerOverviewTabSource).toContain('DetailSectionTable');
expect(resourceDetailDrawerOverviewTabSource).not.toContain('TrueNASDetailSectionTable');
@@ -5,6 +5,9 @@ import {
InlineDetailPanel,
compactDetailRows,
compactDetailSections,
formatDetailBytesValue,
formatDetailCountValue,
formatDetailIntegerValue,
makeDetailRow,
} from '../DetailSectionTable';
import detailSectionTableSource from '../DetailSectionTable.tsx?raw';
@@ -35,6 +38,24 @@ describe('DetailSectionTable', () => {
).toEqual([{ label: 'Host', rows: [{ label: 'Name', value: 'tower' }] }]);
});
it('keeps detail numeric formatting in the shared model', () => {
expect(formatDetailBytesValue(undefined)).toBeNull();
expect(formatDetailBytesValue(0)).toBeNull();
expect(formatDetailBytesValue(0, { allowZero: true })).toBe('0 B');
expect(formatDetailBytesValue(8 * 1024 ** 3)).toBe('8.00 GB');
expect(
formatDetailBytesValue(8 * 1024 ** 3, {
allowZero: true,
precision: 'compact',
trimWhole: true,
}),
).toBe('8 GB');
expect(formatDetailIntegerValue(1234.6)).toBe(new Intl.NumberFormat().format(1235));
expect(formatDetailCountValue(1, 'disk')).toBe('1 disk');
expect(formatDetailCountValue(2, 'vCPU', 'vCPU')).toBe('2 vCPU');
expect(formatDetailCountValue(undefined, 'disk')).toBeNull();
});
it('renders section tables with shared value tone classes', () => {
render(() => (
<DetailSectionTable
@@ -1,3 +1,5 @@
import { formatBytes } from '@/utils/format';
export type DetailValueTone = 'default' | 'accent' | 'success' | 'warning' | 'danger' | 'muted';
export type DetailRow = {
@@ -29,3 +31,58 @@ export const compactDetailSections = (sections: Array<DetailSection | null>): De
sections.filter((section): section is DetailSection =>
Boolean(section && section.rows.length > 0),
);
export type DetailBytesFormatOptions = {
allowZero?: boolean;
precision?: 'auto' | 'compact';
trimWhole?: boolean;
};
const isFiniteNumber = (value: number | undefined): value is number =>
typeof value === 'number' && Number.isFinite(value);
const formatCompactBytes = (bytes: number): string => {
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let scaled = bytes;
let unitIndex = 0;
while (scaled >= 1024 && unitIndex < units.length - 1) {
scaled /= 1024;
unitIndex += 1;
}
const precision = unitIndex === 0 || scaled >= 10 ? 0 : 1;
return `${scaled.toFixed(precision)} ${units[unitIndex]}`;
};
const trimWholeUnit = (value: string): string => value.replace(/\.0+ ([A-Z]+)/, ' $1');
export const formatDetailBytesValue = (
bytes: number | undefined,
options: DetailBytesFormatOptions = {},
): string | null => {
if (!isFiniteNumber(bytes) || bytes < 0) return null;
if (bytes === 0 && options.allowZero !== true) return null;
const formatted =
options.precision === 'compact' ? formatCompactBytes(bytes) : formatBytes(bytes);
return options.trimWhole ? trimWholeUnit(formatted) : formatted;
};
export const formatDetailIntegerValue = (value: number | undefined): string | null => {
if (!isFiniteNumber(value)) return null;
return new Intl.NumberFormat().format(Math.round(value));
};
export function formatDetailCountValue(value: number, singular: string, plural?: string): string;
export function formatDetailCountValue(
value: number | undefined,
singular: string,
plural?: string,
): string | null;
export function formatDetailCountValue(
value: number | undefined,
singular: string,
plural = `${singular}s`,
): string | null {
if (!isFiniteNumber(value)) return null;
return `${formatDetailIntegerValue(value)} ${value === 1 ? singular : plural}`;
}