diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 6ef081c68..aad133b10 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 981caf957..c8a37df35 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx index d44dcf2cf..6d72634a8 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.machine-history.test.tsx @@ -15,6 +15,7 @@ vi.mock('@/components/Workloads/GuestDrawerHistory', () => ({ target: { resourceType: string; resourceId: string } | null; range: string; currentMetrics?: Record; + groups?: Array<{ id: string; series: Array<{ metric: string }> }>; }) => (
({ 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(() => ( + + )); + + 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(() => ( + + )); + + 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 diff --git a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts index 6d5e3b8f8..69c864fe9 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts +++ b/frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts @@ -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. diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts index eb92353c8..19364a220 100644 --- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts +++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts @@ -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 => { + 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),