diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index f4924e3f57..14d1f78030 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -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
diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md
index 67ab5be1db..eba799c144 100644
--- a/docs/release-control/v6/internal/subsystems/unified-resources.md
+++ b/docs/release-control/v6/internal/subsystems/unified-resources.md
@@ -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
diff --git a/frontend-modern/src/features/dashboardOverview/TrendCharts.tsx b/frontend-modern/src/features/dashboardOverview/TrendCharts.tsx
index b19e573601..581465d664 100644
--- a/frontend-modern/src/features/dashboardOverview/TrendCharts.tsx
+++ b/frontend-modern/src/features/dashboardOverview/TrendCharts.tsx
@@ -124,6 +124,11 @@ export function TrendCharts(props: TrendChartsProps) {
{getDashboardTrendErrorState().text}
+
+
+ {props.trends.infrastructure.emptyMessage}
+
+
diff --git a/frontend-modern/src/features/dashboardOverview/__tests__/TrendCharts.test.tsx b/frontend-modern/src/features/dashboardOverview/__tests__/TrendCharts.test.tsx
index 7975662123..d4eb044350 100644
--- a/frontend-modern/src/features/dashboardOverview/__tests__/TrendCharts.test.tsx
+++ b/frontend-modern/src/features/dashboardOverview/__tests__/TrendCharts.test.tsx
@@ -20,7 +20,7 @@ function makeOverview(overrides: Partial = {}): DashboardOver
function makeTrends(overrides: Partial = {}): 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('1h');
+ render(() => (
+
+ ));
+
+ expect(screen.getByText('Waiting for first sample')).toBeTruthy();
+ });
});
diff --git a/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts b/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts
index c78e6d3dc1..8ac4b60f51 100644
--- a/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts
+++ b/frontend-modern/src/hooks/__tests__/useDashboardTrends.test.ts
@@ -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');
+ });
+});
diff --git a/frontend-modern/src/hooks/useDashboardTrends.ts b/frontend-modern/src/hooks/useDashboardTrends.ts
index 749fdf2f40..02633739a2 100644
--- a/frontend-modern/src/hooks/useDashboardTrends.ts
+++ b/frontend-modern/src/hooks/useDashboardTrends.ts
@@ -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;
memory: Map;
+ 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(),
memory: new Map(),
+ 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,
-): 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 {
+ const infrastructureResources = resources.filter((resource) => isInfrastructure(resource));
+ if (infrastructureResources.length === 0) {
+ return {
+ cpu: new Map(),
+ memory: new Map(),
+ 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();
+ const memory = new Map();
+ 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 {
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(cpuEntries),
- memory: new Map(memoryEntries),
- },
+ infrastructure,
storage: {
capacity: storageCapacity,
},
@@ -277,21 +308,10 @@ export function useDashboardTrends(
const trendRequest = createMemo(() => {
const currentOverview = overview();
const currentResources = resources();
- const metricsTargetById = new Map();
- 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);
diff --git a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx
index 1da832d44f..916f77b6bf 100644
--- a/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx
+++ b/frontend-modern/src/pages/__tests__/DashboardPage.test.tsx
@@ -83,6 +83,7 @@ vi.mock('@/hooks/useDashboardTrends', () => ({
infrastructure: {
cpu: new Map(),
memory: new Map(),
+ emptyMessage: null,
},
storage: {
capacity: null,