mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 18:44:53 +00:00
Fix dashboard trend sparkline data path
This commit is contained in:
parent
2a8c315e20
commit
a6a438c5ce
7 changed files with 148 additions and 83 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ vi.mock('@/hooks/useDashboardTrends', () => ({
|
|||
infrastructure: {
|
||||
cpu: new Map(),
|
||||
memory: new Map(),
|
||||
emptyMessage: null,
|
||||
},
|
||||
storage: {
|
||||
capacity: null,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue