Fix dashboard trend sparkline data path

This commit is contained in:
rcourtman 2026-03-24 11:54:46 +00:00
parent 2a8c315e20
commit a6a438c5ce
7 changed files with 148 additions and 83 deletions

View file

@ -244,6 +244,11 @@ must treat missing infrastructure history as a governed empty state rather than
as a silent blank sparkline box. Error copy and empty-history copy belong to
the feature shell, while the data path and chart-shaping logic must stay in the
owned hook/model layers that feed it.
That shell must also stay passive with respect to data ownership: dashboard
trend cards may render the summary-range controls and operator-facing empty or
error copy, but they must not reintroduce route-local metrics-history fetch
loops for CPU and memory sparklines when the canonical infrastructure summary
surface already owns the chart contract.
The shared density map now follows that same owner split.
`frontend-modern/src/components/shared/DensityMap.tsx` stays the render shell,
`frontend-modern/src/components/shared/useDensityMapState.ts` owns hover

View file

@ -270,6 +270,12 @@ series matching logic instead of issuing bespoke per-resource
keeps dashboard summary sparklines aligned with canonical resource identity
matching, agent-facet fallback behavior, and first-sample empty-state semantics
already owned by the infrastructure summary surface.
The same contract now also treats dashboard top-card sparkline hydration as a
summary projection concern, not a per-resource history concern: top-CPU and
top-memory selections may still come from dashboard overview ranking, but the
series attached to those selections must be resolved through the same
resource-to-chart matching path used by infrastructure summary cards so agent
fallback and canonical identity aliases do not drift between the two surfaces.
The backend AI and Patrol context renderers now derive their canonical change
kind, source type, source adapter, actor, reason, and related-resource
fragments from `internal/unifiedresources/change_presentation.go`, so the

View file

@ -124,6 +124,11 @@ export function TrendCharts(props: TrendChartsProps) {
{getDashboardTrendErrorState().text}
</div>
</Show>
<Show when={!props.trends.error && props.trends.infrastructure.emptyMessage}>
<div class="mb-2 rounded border border-border bg-surface px-2 py-1.5 text-[11px] text-muted">
{props.trends.infrastructure.emptyMessage}
</div>
</Show>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<Card padding="none" tone="default" class="px-4 py-3 border-border-subtle">

View file

@ -20,7 +20,7 @@ function makeOverview(overrides: Partial<DashboardOverview> = {}): DashboardOver
function makeTrends(overrides: Partial<DashboardTrends> = {}): DashboardTrends {
return {
infrastructure: { cpu: new Map(), memory: new Map() },
infrastructure: { cpu: new Map(), memory: new Map(), emptyMessage: null },
storage: { capacity: null },
loading: false,
error: null,
@ -56,4 +56,24 @@ describe('TrendCharts', () => {
expect(screen.getByText('Unable to load trends')).toBeTruthy();
});
it('renders the infrastructure empty-state message when the charts have no renderable history', () => {
const [range, setRange] = createSignal<HistoryTimeRange>('1h');
render(() => (
<TrendCharts
trends={makeTrends({
infrastructure: {
cpu: new Map(),
memory: new Map(),
emptyMessage: 'Waiting for first sample',
},
})}
overview={makeOverview()}
trendRange={range}
setTrendRange={setRange}
/>
));
expect(screen.getByText('Waiting for first sample')).toBeTruthy();
});
});

View file

@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import useDashboardTrendsSource from '@/hooks/useDashboardTrends.ts?raw';
import { computeTrendDelta, extractTrendData, type TrendPoint } from '@/hooks/useDashboardTrends';
function createPoints(values: number[]): TrendPoint[] {
@ -78,3 +79,12 @@ describe('extractTrendData', () => {
expect(trend.delta ?? 0).toBeGreaterThan(0);
});
});
describe('useDashboardTrends infrastructure routing', () => {
it('routes dashboard infrastructure sparklines through the infrastructure summary chart cache', () => {
expect(useDashboardTrendsSource).toContain('fetchInfrastructureSummaryAndCache');
expect(useDashboardTrendsSource).toContain("caller: 'useDashboardTrends'");
expect(useDashboardTrendsSource).not.toContain('request.cpu.map(async');
expect(useDashboardTrendsSource).not.toContain('request.memory.map(async');
});
});

View file

@ -4,9 +4,16 @@ import {
type AggregatedMetricPoint,
type HistoryTimeRange,
type ResourceType as HistoryResourceType,
type TimeRange,
} from '@/api/charts';
import {
buildInfrastructureEmptyHistoryLabel,
buildInfrastructureSummarySeries,
} from '@/components/Infrastructure/infrastructureSummaryModel';
import type { DashboardOverview } from '@/hooks/useDashboardOverview';
import { isStorage, type Resource, type ResourceMetricsTarget } from '@/types/resource';
import { hasAgentFacet } from '@/utils/agentResources';
import { fetchInfrastructureSummaryAndCache } from '@/utils/infrastructureSummaryCache';
import { isInfrastructure, isStorage, type Resource } from '@/types/resource';
export type TrendPoint = {
timestamp: number;
@ -23,6 +30,7 @@ export interface DashboardTrends {
infrastructure: {
cpu: Map<string, TrendData>;
memory: Map<string, TrendData>;
emptyMessage: string | null;
};
storage: {
capacity: TrendData | null;
@ -31,16 +39,8 @@ export interface DashboardTrends {
error: string | null;
}
interface HistoryTarget {
id: string;
resourceType: HistoryResourceType;
/** The unified resource ID used as the map key (may differ from the API resource ID). */
originalId: string;
}
interface DashboardTrendRequest {
cpu: HistoryTarget[];
memory: HistoryTarget[];
infrastructure: string[];
storage: string[];
infrastructureRange: HistoryTimeRange;
}
@ -68,6 +68,7 @@ function createEmptyTrendSnapshot(): DashboardTrendSnapshot {
infrastructure: {
cpu: new Map<string, TrendData>(),
memory: new Map<string, TrendData>(),
emptyMessage: null,
},
storage: {
capacity: null,
@ -100,20 +101,78 @@ function dedupeValues(values: string[]): string[] {
return Array.from(new Set(values));
}
function buildHistoryTargets(
ids: string[],
metricsTargetById: Map<string, ResourceMetricsTarget>,
): HistoryTarget[] {
const uniqueIds = dedupeValues(ids);
const targets: HistoryTarget[] = [];
function toInfrastructureSummaryRange(range: HistoryTimeRange): TimeRange {
switch (range) {
case '1h':
return '1h';
case '6h':
case '12h':
return '12h';
case '24h':
return '24h';
case '7d':
return '7d';
case '30d':
case '90d':
return '30d';
default:
return '1h';
}
}
for (const id of uniqueIds) {
const mt = metricsTargetById.get(id);
if (!mt) continue;
targets.push({ id: mt.resourceId, resourceType: mt.resourceType, originalId: id });
async function fetchInfrastructureTrendSnapshot(
resources: Resource[],
range: HistoryTimeRange,
): Promise<DashboardTrends['infrastructure']> {
const infrastructureResources = resources.filter((resource) => isInfrastructure(resource));
if (infrastructureResources.length === 0) {
return {
cpu: new Map<string, TrendData>(),
memory: new Map<string, TrendData>(),
emptyMessage: null,
};
}
return targets;
const agentFacetResources = resources.filter(
(resource) =>
(resource.type === 'agent' ||
resource.type === 'pbs' ||
resource.type === 'pmg' ||
resource.type === 'truenas') &&
hasAgentFacet(resource),
);
const { map, oldestDataTimestamp } = await fetchInfrastructureSummaryAndCache(
toInfrastructureSummaryRange(range),
{ caller: 'useDashboardTrends' },
);
const summarySeries = buildInfrastructureSummarySeries(
infrastructureResources,
map,
agentFacetResources,
);
const cpu = new Map<string, TrendData>();
const memory = new Map<string, TrendData>();
let hasRenderableHistory = false;
for (const series of summarySeries) {
const cpuTrend = extractTrendData(series.cpu);
const memoryTrend = extractTrendData(series.memory);
if (cpuTrend.points.length >= 2 || memoryTrend.points.length >= 2) {
hasRenderableHistory = true;
}
cpu.set(series.id, cpuTrend);
memory.set(series.id, memoryTrend);
}
return {
cpu,
memory,
emptyMessage: hasRenderableHistory
? null
: buildInfrastructureEmptyHistoryLabel(oldestDataTimestamp === null),
};
}
export function aggregateStoragePoints(allSeries: TrendPoint[][]): TrendPoint[] {
@ -191,6 +250,7 @@ export function extractTrendData(points: Array<{ timestamp: number; value: numbe
async function fetchDashboardTrendSnapshot(
request: DashboardTrendRequest,
resources: Resource[],
): Promise<DashboardTrendSnapshot> {
let firstError: string | null = null;
const captureError = (error: unknown) => {
@ -199,41 +259,15 @@ async function fetchDashboardTrendSnapshot(
}
};
const [cpuEntries, memoryEntries, storageSeries] = await Promise.all([
Promise.all(
request.cpu.map(async (target) => {
try {
const points = await fetchMetricPoints(
target.resourceType,
target.id,
'cpu',
request.infrastructureRange,
SPARKLINE_POINTS,
);
return [target.originalId, extractTrendData(points)] as const;
} catch (error) {
captureError(error);
return [target.originalId, createEmptyTrendData()] as const;
}
}),
),
Promise.all(
request.memory.map(async (target) => {
try {
const points = await fetchMetricPoints(
target.resourceType,
target.id,
'memory',
request.infrastructureRange,
SPARKLINE_POINTS,
);
return [target.originalId, extractTrendData(points)] as const;
} catch (error) {
captureError(error);
return [target.originalId, createEmptyTrendData()] as const;
}
}),
),
const [infrastructure, storageSeries] = await Promise.all([
(async () => {
try {
return await fetchInfrastructureTrendSnapshot(resources, request.infrastructureRange);
} catch (error) {
captureError(error);
return createEmptyTrendSnapshot().infrastructure;
}
})(),
Promise.all(
request.storage.map(async (resourceId) => {
try {
@ -256,10 +290,7 @@ async function fetchDashboardTrendSnapshot(
request.storage.length > 0 ? extractTrendData(aggregateStoragePoints(storageSeries)) : null;
return {
infrastructure: {
cpu: new Map<string, TrendData>(cpuEntries),
memory: new Map<string, TrendData>(memoryEntries),
},
infrastructure,
storage: {
capacity: storageCapacity,
},
@ -277,21 +308,10 @@ export function useDashboardTrends(
const trendRequest = createMemo<DashboardTrendRequest>(() => {
const currentOverview = overview();
const currentResources = resources();
const metricsTargetById = new Map<string, ResourceMetricsTarget>();
for (const resource of currentResources) {
if (resource.metricsTarget) {
metricsTargetById.set(resource.id, resource.metricsTarget);
}
}
const cpuTargets = buildHistoryTargets(
currentOverview.infrastructure.topCPU.map((item) => item.id),
metricsTargetById,
);
const memoryTargets = buildHistoryTargets(
currentOverview.infrastructure.topMemory.map((item) => item.id),
metricsTargetById,
);
const infrastructureTargets = dedupeValues([
...currentOverview.infrastructure.topCPU.map((item) => item.id),
...currentOverview.infrastructure.topMemory.map((item) => item.id),
]).sort();
const storageTargets = dedupeValues(
currentResources
.filter((resource) => isStorage(resource))
@ -302,8 +322,7 @@ export function useDashboardTrends(
).sort();
return {
cpu: cpuTargets,
memory: memoryTargets,
infrastructure: infrastructureTargets,
storage: storageTargets,
infrastructureRange: infrastructureRange ? infrastructureRange() : INFRASTRUCTURE_RANGE,
};
@ -314,8 +333,7 @@ export function useDashboardTrends(
const requestKey = createMemo(() => {
const req = trendRequest();
const stableReq = {
cpu: [...req.cpu].sort((a, b) => a.id.localeCompare(b.id)),
memory: [...req.memory].sort((a, b) => a.id.localeCompare(b.id)),
infrastructure: [...req.infrastructure].sort(),
storage: [...req.storage].sort(),
infrastructureRange: req.infrastructureRange,
};
@ -335,15 +353,15 @@ export function useDashboardTrends(
createEffect(() => {
requestKey(); // track dependency — re-run effect when key changes
const request = trendRequest();
const currentResources = resources();
// Skip empty requests (no targets yet).
if (request.cpu.length === 0 && request.memory.length === 0 && request.storage.length === 0)
return;
if (request.infrastructure.length === 0 && request.storage.length === 0) return;
const requestId = ++latestRequestId;
setTrendLoading(true);
fetchDashboardTrendSnapshot(request)
fetchDashboardTrendSnapshot(request, currentResources)
.then((result) => {
if (requestId !== latestRequestId) return; // stale
setSnapshot(result);

View file

@ -83,6 +83,7 @@ vi.mock('@/hooks/useDashboardTrends', () => ({
infrastructure: {
cpu: new Map(),
memory: new Map(),
emptyMessage: null,
},
storage: {
capacity: null,