diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 3f2aac3f3..36cd2432a 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index f67d3279a..39613d366 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index de45f17a9..23c88cd70 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -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", diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts index f32533fc1..5d3d5e927 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerKubernetesModel.ts @@ -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`); diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts index cd2b33510..4d7909ca3 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerTrueNASModel.ts @@ -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; diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts index 8091e15a2..40aef9500 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerVmwareModel.ts @@ -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' diff --git a/frontend-modern/src/components/shared/DetailSectionTable.tsx b/frontend-modern/src/components/shared/DetailSectionTable.tsx index e34a6eb91..d7878d420 100644 --- a/frontend-modern/src/components/shared/DetailSectionTable.tsx +++ b/frontend-modern/src/components/shared/DetailSectionTable.tsx @@ -6,6 +6,9 @@ import type { DetailSection, DetailValueTone } from './detailSectionModel'; export { compactDetailRows, compactDetailSections, + formatDetailBytesValue, + formatDetailCountValue, + formatDetailIntegerValue, makeDetailRow, type DetailRow, type DetailSection, diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index df2b7771b..78679b685 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -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'); diff --git a/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx b/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx index 4c7a4ecda..c3b6e639a 100644 --- a/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/DetailSectionTable.test.tsx @@ -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(() => ( ): 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}`; +}