mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Use canonical history catalogs in storage drawers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
+46
@@ -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
|
||||
|
||||
+64
@@ -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.
|
||||
|
||||
+66
-6
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user