mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Converge resource detail numeric formatting
This commit is contained in:
@@ -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
-13
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user