Use canonical history catalogs in storage drawers

This commit is contained in:
rcourtman
2026-08-26 12:28:43 +01:00
parent 5d46e7d0e7
commit 08b8144fcf
5 changed files with 194 additions and 22 deletions
@@ -6406,7 +6406,11 @@ source-aware subset of that catalog: utilization, network, and thermals remain,
while host disk throughput is omitted because the PVE node API does not expose
that stream. The drawer must not leave an unsupported disk-I/O chart in a
permanent `Collecting history` state. Non-host workloads retain the default
workload history groups unchanged.
workload history groups unchanged. Storage metrics targets select a dedicated
capacity catalog backed by the canonical `usage` series, while physical-disk
targets select only the canonical busy, read, write, and SMART-temperature
series. Neither target may inherit guest CPU, memory, or network charts that
its backend history model does not record.
Rendered table proof belongs in
`frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx`;
drawer grouping and fallback proof belongs in
+13 -15
View File
@@ -1,15 +1,15 @@
{
"version": 1,
"base_sha": "18b74c0adb07749cdd00caf9fc01ec09849029cd",
"verified_at": "2026-08-26T11:10:47Z",
"base_sha": "5d46e7d0e76ddd069a12f62fb670fd3ec0356e9e",
"verified_at": "2026-08-26T11:27:33Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/docker/dockerHostDrawerModel.ts"
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts"
],
"content_sha256": {
"frontend-modern/src/features/docker/dockerHostDrawerModel.ts": "af67dd2e6ae26a4c220ba77342842a5782a52dae77d664b5b56d37e996abdb79"
"frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts": "6767cf06b2f3c42931deba2b9620858210d8c7ce55d261b042e752b6680a4223"
},
"routes": ["/docker/overview", "/proxmox/overview"],
"routes": ["/truenas/storage"],
"viewports": [
{
"width": 1230,
@@ -21,17 +21,15 @@
}
],
"states": [
"Docker host History at 24 hours and 7 days with utilization, network I/O, disk I/O, and thermals populated",
"Docker app-container History at 24 hours and 7 days with utilization, network I/O, and disk I/O populated",
"Proxmox guest History with utilization, network I/O, and disk I/O populated",
"Proxmox node History at 24 hours and 7 days with utilization, network I/O, disk I/O, and thermals populated",
"narrow Docker host History stacked without document-level horizontal overflow"
"TrueNAS pool History with only the canonical capacity series populated",
"TrueNAS dataset History with only the canonical capacity series populated",
"TrueNAS physical-disk History at 24 hours and 7 days with activity, disk I/O, and temperature populated",
"narrow TrueNAS physical-disk History with the same populated chart catalog"
],
"interactions": [
"opened Edge Apps 01 from the Docker hosts table and selected History; confirmed four chart groups and zero Collecting history placeholders",
"opened nextcloud from the Docker containers table and selected History; confirmed three chart groups and zero Collecting history placeholders",
"switched Docker host, Docker container, and Proxmox node History selectors to 7 days; every expected group remained populated",
"opened checkout-web-265 and Analytics A on the Proxmox overview; guest and node History drawers rendered their full chart catalogs",
"rechecked Edge Apps 01 at 390 by 844; confirmed four stacked groups, no horizontal overflow, and row focus return after collapse"
"opened archive and archive/cold from the TrueNAS storage table; each drawer rendered one populated Capacity chart and no workload-only charts",
"opened physical disk sdc and selected History; confirmed Activity, Disk I/O, and Temperature charts with zero Collecting history placeholders",
"switched the physical-disk History selector to 7 days; all three groups remained populated",
"rechecked sdc at 390 by 844; the responsive table and populated disk history remained usable"
]
}
@@ -15,6 +15,7 @@ vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
target: { resourceType: string; resourceId: string } | null;
range: string;
currentMetrics?: Record<string, number | undefined>;
groups?: Array<{ id: string; series: Array<{ metric: string }> }>;
}) => (
<div
data-testid="machine-history"
@@ -23,6 +24,10 @@ vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({
data-range={props.range}
data-cpu={props.currentMetrics?.cpu}
data-netin={props.currentMetrics?.netin}
data-groups={props.groups?.map((group) => group.id).join(',')}
data-metrics={props.groups
?.flatMap((group) => group.series.map((series) => series.metric))
.join(',')}
/>
),
GuestDrawerHistoryRangeSelect: (props: {
@@ -123,6 +128,47 @@ describe('ResourceDetailDrawer machine metrics history', () => {
expect(screen.queryByRole('tab', { name: 'History' })).not.toBeInTheDocument();
});
it('renders storage history with the capacity catalog instead of workload metrics', async () => {
render(() => (
<ResourceDetailDrawer
resource={resource({
id: 'storage-pool-tank',
type: 'storage',
metricsTarget: { resourceType: 'storage', resourceId: 'pool:tank' },
disk: { current: 0, used: 30, total: 100 },
})}
/>
));
await fireEvent.click(screen.getByRole('tab', { name: 'History' }));
const history = screen.getByTestId('machine-history');
expect(history).toHaveAttribute('data-resource-type', 'storage');
expect(history).toHaveAttribute('data-groups', 'capacity');
expect(history).toHaveAttribute('data-metrics', 'usage');
});
it('renders physical-disk history with only canonical disk metrics', async () => {
render(() => (
<ResourceDetailDrawer
resource={resource({
id: 'disk-sdc',
type: 'storage',
metricsTarget: { resourceType: 'disk', resourceId: 'WD-WX12A3456' },
disk: { current: 42 },
temperature: 39,
})}
/>
));
await fireEvent.click(screen.getByRole('tab', { name: 'History' }));
const history = screen.getByTestId('machine-history');
expect(history).toHaveAttribute('data-resource-type', 'disk');
expect(history).toHaveAttribute('data-groups', 'disk-activity,disk-io,disk-thermal');
expect(history).toHaveAttribute('data-metrics', 'disk,diskread,diskwrite,smart_temp');
});
it('adds a first-class discovery tab for Pulse Agent machines', async () => {
syncAIRuntimeSettings({ discovery_enabled: true } as Parameters<
typeof syncAIRuntimeSettings
@@ -135,11 +135,13 @@ describe('getResourceMetricsHistoryCurrentMetrics branch coverage', () => {
cpu: 12.5,
memory: 30, // (30 / 100) * 100
disk: 40, // (80 / 200) * 100
usage: 40,
netin: 1000,
netout: 2000,
diskread: 3000,
diskwrite: 4000,
temperature: undefined,
smart_temp: undefined,
gpu: undefined,
gpu_memory: undefined,
gpu_temperature: undefined,
@@ -154,11 +156,13 @@ describe('getResourceMetricsHistoryCurrentMetrics branch coverage', () => {
cpu: undefined,
memory: undefined,
disk: undefined,
usage: undefined,
netin: undefined,
netout: undefined,
diskread: undefined,
diskwrite: undefined,
temperature: undefined,
smart_temp: undefined,
gpu: undefined,
gpu_memory: undefined,
gpu_temperature: undefined,
@@ -222,11 +226,13 @@ describe('getResourceMetricsHistoryCurrentMetrics branch coverage', () => {
cpu: undefined,
memory: undefined,
disk: undefined,
usage: undefined,
netin: undefined,
netout: undefined,
diskread: undefined,
diskwrite: undefined,
temperature: undefined,
smart_temp: undefined,
gpu: undefined,
gpu_memory: undefined,
gpu_temperature: undefined,
@@ -296,6 +302,64 @@ describe('getResourceMetricsHistoryCurrentMetrics branch coverage', () => {
]);
});
it('uses only canonical capacity history for storage targets', () => {
const resource = baseResource({
type: 'storage',
metricsTarget: { resourceType: 'storage', resourceId: 'pool:tank' },
disk: { current: 0, used: 30, total: 100 },
});
expect(getResourceMetricsHistoryGroups(resource)).toStrictEqual([
{
id: 'capacity',
label: 'Capacity',
unit: '%',
series: [{ metric: 'usage', label: 'Used', unit: '%', color: '#22c55e' }],
},
]);
expect(getResourceMetricsHistoryCurrentMetrics(resource).usage).toBe(30);
});
it('uses canonical activity, throughput, and SMART temperature history for disk targets', () => {
const resource = baseResource({
type: 'storage',
metricsTarget: { resourceType: 'disk', resourceId: 'SERIAL-1' },
disk: { current: 17 },
diskIO: { readRate: 1024, writeRate: 2048 },
temperature: 41,
});
expect(getResourceMetricsHistoryGroups(resource).map((group) => group.id)).toEqual([
'disk-activity',
'disk-io',
'disk-thermal',
]);
expect(
getResourceMetricsHistoryGroups(resource).flatMap((group) =>
group.series.map((series) => series.metric),
),
).toEqual(['disk', 'diskread', 'diskwrite', 'smart_temp']);
expect(getResourceMetricsHistoryCurrentMetrics(resource)).toMatchObject({
disk: 17,
diskread: 1024,
diskwrite: 2048,
smart_temp: 41,
});
});
it('uses the source-aware host subset for API-only Proxmox node targets', () => {
const resource = baseResource({
type: 'agent',
metricsTarget: { resourceType: 'node', resourceId: 'pve1-node1' },
});
expect(getResourceMetricsHistoryGroups(resource).map((group) => group.id)).toEqual([
'utilization',
'network',
'thermals',
]);
});
it('emits the memory ternary FALSE arm as undefined when memory is explicitly absent but disk is present', () => {
// Mixes a present disk (TRUE arm) with an absent memory (FALSE arm) to prove
// the two ternaries are evaluated independently.
@@ -68,13 +68,67 @@ const GPU_HISTORY_GROUPS: GuestDrawerHistoryGroupConfig[] = [
},
];
const NODE_METRICS_HISTORY_GROUPS = HOST_METRICS_HISTORY_GROUPS.filter(
(group) => group.id !== 'disk-io',
);
const STORAGE_METRICS_HISTORY_GROUPS: GuestDrawerHistoryGroupConfig[] = [
{
id: 'capacity',
label: 'Capacity',
unit: '%',
series: [{ metric: 'usage', label: 'Used', unit: '%', color: '#22c55e' }],
},
];
const DISK_METRICS_HISTORY_GROUPS: GuestDrawerHistoryGroupConfig[] = [
{
id: 'disk-activity',
label: 'Activity',
unit: '%',
series: [{ metric: 'disk', label: 'Busy', unit: '%', color: '#8b5cf6' }],
},
{
id: 'disk-io',
label: 'Disk I/O',
unit: 'B/s',
series: [
{ metric: 'diskread', label: 'Read', unit: 'B/s', color: '#3b82f6' },
{ metric: 'diskwrite', label: 'Write', unit: 'B/s', color: '#f59e0b' },
],
},
{
id: 'disk-thermal',
label: 'Temperature',
unit: 'C',
series: [{ metric: 'smart_temp', label: 'Disk', unit: 'C', color: '#ef4444' }],
},
];
const getBaseMetricsHistoryGroups = (
resourceType: MetricsHistoryResourceType | undefined,
): GuestDrawerHistoryGroupConfig[] => {
switch (resourceType) {
case 'agent':
return HOST_METRICS_HISTORY_GROUPS;
case 'node':
return NODE_METRICS_HISTORY_GROUPS;
case 'storage':
return STORAGE_METRICS_HISTORY_GROUPS;
case 'disk':
return DISK_METRICS_HISTORY_GROUPS;
default:
return GUEST_DRAWER_HISTORY_GROUPS;
}
};
export const getResourceMetricsHistoryGroups = (
resource: Resource,
): GuestDrawerHistoryGroupConfig[] => {
const target = getResourceMetricsHistoryTarget(resource);
const baseGroups =
target?.resourceType === 'agent' ? HOST_METRICS_HISTORY_GROUPS : GUEST_DRAWER_HISTORY_GROUPS;
return (resource.agent?.sensors?.gpu?.length ?? 0) > 0
const baseGroups = getBaseMetricsHistoryGroups(target?.resourceType);
const supportsGPUHistory = target?.resourceType === 'agent' || target?.resourceType === 'node';
return supportsGPUHistory && (resource.agent?.sensors?.gpu?.length ?? 0) > 0
? [...baseGroups, ...GPU_HISTORY_GROUPS]
: baseGroups;
};
@@ -101,7 +155,9 @@ export const getResourceMetricsHistoryTarget = (
// Any resource that resolves a metrics history target can chart history —
// the backend stores and serves series for every type resolveMetricsTarget
// hands out (agent, vm, system-container, app-container, pod, disk, ceph).
// hands out. Each target type selects the catalog that matches the metrics
// actually persisted for it; storage and physical disks must not inherit the
// CPU/network workload catalog merely because they use the shared renderer.
// Gating on type === 'agent' silently hid history for Docker containers
// even though the store records their CPU/memory/disk/IO samples.
export const resourceSupportsMetricsHistory = (resource: Resource): boolean =>
@@ -110,15 +166,19 @@ export const resourceSupportsMetricsHistory = (resource: Resource): boolean =>
export const getResourceMetricsHistoryCurrentMetrics = (
resource: Resource,
): Record<string, number | undefined> => {
const diskPercent = resource.disk ? finiteMetric(getDiskPercent(resource)) : undefined;
const temperature = finiteMetric(resource.temperature);
return {
cpu: finiteMetric(resource.cpu?.current),
memory: resource.memory ? finiteMetric(getMemoryPercent(resource)) : undefined,
disk: resource.disk ? finiteMetric(getDiskPercent(resource)) : undefined,
disk: diskPercent,
usage: diskPercent,
netin: finiteMetric(resource.network?.rxBytes),
netout: finiteMetric(resource.network?.txBytes),
diskread: finiteMetric(resource.diskIO?.readRate),
diskwrite: finiteMetric(resource.diskIO?.writeRate),
temperature: finiteMetric(resource.temperature),
temperature,
smart_temp: temperature,
gpu: getResourceGPUUtilizationPercent(resource),
gpu_memory: getResourceGPUMemoryPercent(resource),
gpu_temperature: getResourceGPUTemperatureCelsius(resource),