mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-25 12:52:25 +00:00
Split infrastructure summary runtime owners
This commit is contained in:
@@ -113,20 +113,26 @@ regression protection.
|
||||
90. `frontend-modern/src/components/Infrastructure/__tests__/UnifiedResourceTable.performance.contract.test.tsx`
|
||||
91. `frontend-modern/src/components/Dashboard/useDashboardWorkloadViewportSync.ts`
|
||||
92. `frontend-modern/src/components/Dashboard/__tests__/useDashboardWorkloadViewportSync.test.tsx`
|
||||
93. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
|
||||
94. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
|
||||
95. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `unified-resources`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
3. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
4. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` shared with `unified-resources`: the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
5. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` shared with `unified-resources`: the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
6. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` shared with `unified-resources`: the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
7. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` shared with `unified-resources`: the unified resource table is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
8. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` shared with `unified-resources`: unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
9. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `unified-resources`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
10. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `unified-resources`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
11. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
|
||||
2. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx` shared with `unified-resources`: the infrastructure summary surface is both a canonical unified-resource consumer and a fleet-scale summary chart hot-path boundary.
|
||||
3. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts` shared with `unified-resources`: infrastructure summary chart matching and series derivation are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary.
|
||||
4. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `unified-resources`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
5. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `unified-resources`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
6. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` shared with `unified-resources`: the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
7. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` shared with `unified-resources`: the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
8. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` shared with `unified-resources`: the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
9. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` shared with `unified-resources`: the unified resource table is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
10. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` shared with `unified-resources`: unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
11. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` shared with `unified-resources`: infrastructure summary chart polling, cache hydration, and summary-state orchestration are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary.
|
||||
12. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `unified-resources`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
13. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `unified-resources`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
14. `internal/api/slo.go` shared with `api-contracts`: the SLO endpoint is both an API contract surface and a protected performance hot-path boundary.
|
||||
|
||||
## Extension Points
|
||||
|
||||
@@ -320,6 +326,12 @@ while viewport-sync and selected-row reveal behavior now live in
|
||||
so future hot-path table-state changes must not fold selector and scroll
|
||||
coordination back into one mixed owner or the render shell.
|
||||
That hot-path contract now includes policy badge rendering on resource rows.
|
||||
The infrastructure summary hot path is now explicit shared ownership too:
|
||||
`InfrastructureSummary.tsx` stays a render shell,
|
||||
`useInfrastructureSummaryState.ts` owns chart polling and cache lifecycle, and
|
||||
`infrastructureSummaryModel.ts` owns chart matching plus summary-series
|
||||
derivation. Future summary-chart work must not put polling, cache hydration,
|
||||
and series math back into the shell.
|
||||
It now also includes the compact resource-facet summary chips rendered next
|
||||
to policy metadata, and those chips must stay within the same bounded
|
||||
windowing and mounted-row budget proved by
|
||||
|
||||
@@ -65,6 +65,22 @@
|
||||
"unified-resources"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx",
|
||||
"rationale": "the infrastructure summary surface is both a canonical unified-resource consumer and a fleet-scale summary chart hot-path boundary",
|
||||
"subsystems": [
|
||||
"performance-and-scalability",
|
||||
"unified-resources"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts",
|
||||
"rationale": "infrastructure summary chart matching and series derivation are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary",
|
||||
"subsystems": [
|
||||
"performance-and-scalability",
|
||||
"unified-resources"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
||||
"rationale": "resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary",
|
||||
@@ -121,6 +137,14 @@
|
||||
"unified-resources"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts",
|
||||
"rationale": "infrastructure summary chart polling, cache hydration, and summary-state orchestration are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary",
|
||||
"subsystems": [
|
||||
"performance-and-scalability",
|
||||
"unified-resources"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts",
|
||||
"rationale": "unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary",
|
||||
@@ -2663,6 +2687,8 @@
|
||||
"frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx",
|
||||
"frontend-modern/src/components/Dashboard/workloadTopology.ts",
|
||||
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
||||
"frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx",
|
||||
@@ -2670,6 +2696,7 @@
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts",
|
||||
"internal/api/router_bench_test.go",
|
||||
@@ -2784,6 +2811,8 @@
|
||||
"frontend-modern/src/components/Dashboard/WorkloadTableHeader.tsx",
|
||||
"frontend-modern/src/components/Dashboard/workloadTopology.ts",
|
||||
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
||||
"frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx",
|
||||
@@ -2791,6 +2820,7 @@
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts"
|
||||
],
|
||||
@@ -3164,6 +3194,8 @@
|
||||
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
|
||||
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
||||
"frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
@@ -3176,6 +3208,7 @@
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts",
|
||||
@@ -3251,6 +3284,8 @@
|
||||
"frontend-modern/src/components/Discovery/DiscoveryTab.tsx",
|
||||
"frontend-modern/src/components/Discovery/useDiscoveryTabState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts",
|
||||
"frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawer.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
@@ -3263,6 +3298,7 @@
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDerivedState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerDockerActionsState.ts",
|
||||
"frontend-modern/src/components/Infrastructure/useResourceDetailDrawerHistoryState.ts",
|
||||
|
||||
@@ -64,20 +64,26 @@ cross-source deduplication.
|
||||
42. `frontend-modern/src/features/infrastructure/InfrastructurePageSurface.tsx`
|
||||
43. `frontend-modern/src/features/infrastructure/useInfrastructurePageRouteState.ts`
|
||||
44. `frontend-modern/src/features/infrastructure/useInfrastructurePageState.ts`
|
||||
45. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
|
||||
46. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`
|
||||
47. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
|
||||
|
||||
## Shared Boundaries
|
||||
|
||||
1. `frontend-modern/src/components/Infrastructure/infrastructureSelectors.ts` shared with `performance-and-scalability`: the infrastructure selector pipeline is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
2. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `performance-and-scalability`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
3. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `performance-and-scalability`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
4. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` shared with `performance-and-scalability`: the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
5. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` shared with `performance-and-scalability`: the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
6. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` shared with `performance-and-scalability`: the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
7. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` shared with `performance-and-scalability`: the unified resource table is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
8. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` shared with `performance-and-scalability`: unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
9. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `performance-and-scalability`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
10. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `performance-and-scalability`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
11. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
|
||||
2. `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx` shared with `performance-and-scalability`: the infrastructure summary surface is both a canonical unified-resource consumer and a fleet-scale summary chart hot-path boundary.
|
||||
3. `frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts` shared with `performance-and-scalability`: infrastructure summary chart matching and series derivation are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary.
|
||||
4. `frontend-modern/src/components/Infrastructure/resourceDetailMappers.ts` shared with `performance-and-scalability`: resource detail mappers are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
5. `frontend-modern/src/components/Infrastructure/UnifiedResourceHostTableCard.tsx` shared with `performance-and-scalability`: the unified resource host table card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
6. `frontend-modern/src/components/Infrastructure/UnifiedResourcePBSTableSection.tsx` shared with `performance-and-scalability`: the unified resource PBS section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
7. `frontend-modern/src/components/Infrastructure/UnifiedResourcePMGTableSection.tsx` shared with `performance-and-scalability`: the unified resource PMG section is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
8. `frontend-modern/src/components/Infrastructure/UnifiedResourceServiceInfrastructureCard.tsx` shared with `performance-and-scalability`: the unified resource service infrastructure card is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
9. `frontend-modern/src/components/Infrastructure/UnifiedResourceTable.tsx` shared with `performance-and-scalability`: the unified resource table is both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
10. `frontend-modern/src/components/Infrastructure/unifiedResourceTableModel.ts` shared with `performance-and-scalability`: unified resource service row shaping and I/O emphasis are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
11. `frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts` shared with `performance-and-scalability`: infrastructure summary chart polling, cache hydration, and summary-state orchestration are both a canonical unified-resource consumer surface and a fleet-scale summary chart hot-path boundary.
|
||||
12. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableState.ts` shared with `performance-and-scalability`: unified resource table state, grouping, and windowing are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
13. `frontend-modern/src/components/Infrastructure/useUnifiedResourceTableViewportSync.ts` shared with `performance-and-scalability`: unified resource table viewport sync and selected-row reveal are both a canonical unified-resource consumer surface and a fleet-scale performance hot-path boundary.
|
||||
14. `internal/api/resources.go` shared with `api-contracts`: the unified resource endpoint is both a backend payload contract surface and a unified-resource runtime boundary.
|
||||
|
||||
## Extension Points
|
||||
|
||||
@@ -159,6 +165,12 @@ canonical drawer derivation layer,
|
||||
`useResourceDetailDrawerDockerActionsState.ts` owns Docker action runtime, and
|
||||
the overview/debug render-heavy surfaces live in dedicated drawer-local owners
|
||||
instead of staying inline in the shell.
|
||||
The infrastructure summary surface now follows the same shell/runtime/model
|
||||
shape: `InfrastructureSummary.tsx` is the render shell,
|
||||
`useInfrastructureSummaryState.ts` owns chart polling, cache hydration,
|
||||
org-scope lifecycle, and focused-summary state, and
|
||||
`infrastructureSummaryModel.ts` owns chart matching plus summary-series
|
||||
derivation.
|
||||
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
|
||||
@@ -306,6 +318,14 @@ while Docker update mutations route through
|
||||
and `frontend-modern/src/components/Infrastructure/useResourceDetailDrawerState.ts`
|
||||
stays the composition owner, so unified-resource history, investigation, and
|
||||
drawer-local action runtime no longer accumulate inline beside the model layer.
|
||||
The infrastructure summary path now routes chart polling, cache hydration, and
|
||||
org-scope lifecycle through
|
||||
`frontend-modern/src/components/Infrastructure/useInfrastructureSummaryState.ts`,
|
||||
while chart matching and summary-series derivation route through
|
||||
`frontend-modern/src/components/Infrastructure/infrastructureSummaryModel.ts`
|
||||
and `frontend-modern/src/components/Infrastructure/InfrastructureSummary.tsx`
|
||||
stays the render shell, so summary charts are no longer an unowned mixed
|
||||
resource consumer surface.
|
||||
The shared `ResourceFacetSummary` consumer now omits capability and
|
||||
relationship badges from the default table/detail surface entirely, while the
|
||||
backend contract keeps capability and relationship data on the owned resource
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createSignal } from 'solid-js';
|
||||
import { render, waitFor, cleanup } from '@solidjs/testing-library';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { InfrastructureSummary, __resetInMemoryChartCacheForTests } from './InfrastructureSummary';
|
||||
import { InfrastructureSummary } from './InfrastructureSummary';
|
||||
import { __resetInMemoryChartCacheForTests } from './useInfrastructureSummaryState';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { TimeRange } from '@/api/charts';
|
||||
import { __resetInfrastructureSummaryFetchesForTests } from '@/utils/infrastructureSummaryCache';
|
||||
|
||||
@@ -1,559 +1,21 @@
|
||||
import { Component, Show, createMemo, createEffect, createSignal, onCleanup } from 'solid-js';
|
||||
import type { Component } from 'solid-js';
|
||||
import { Show } from 'solid-js';
|
||||
import { Card } from '@/components/shared/Card';
|
||||
import { InteractiveSparkline } from '@/components/shared/InteractiveSparkline';
|
||||
import { DensityMap } from '@/components/shared/DensityMap';
|
||||
import { SummaryPanel } from '@/components/shared/SummaryPanel';
|
||||
import { SummaryMetricCard } from '@/components/shared/SummaryMetricCard';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getDiskPercent } from '@/types/resource';
|
||||
import type { MetricPoint, ChartData, TimeRange } from '@/api/charts';
|
||||
import { useResources } from '@/hooks/useResources';
|
||||
import {
|
||||
fetchInfrastructureSummaryAndCache,
|
||||
readInfrastructureSummaryCache,
|
||||
} from '@/utils/infrastructureSummaryCache';
|
||||
import {
|
||||
getActionableAgentIdFromResource,
|
||||
getMetricsChartKeyCandidatesFromResource,
|
||||
getPlatformAgentRecord,
|
||||
getPlatformDataRecord,
|
||||
hasAgentFacet,
|
||||
} from '@/utils/agentResources';
|
||||
import {
|
||||
getPreferredResourceDisplayName,
|
||||
getPreferredResourceHostname,
|
||||
getResourceIdentityAliases,
|
||||
getNormalizedIdentityLookupVariants,
|
||||
} from '@/utils/resourceIdentity';
|
||||
import { formatThroughputRate } from '@/utils/throughputPresentation';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import { getOrgID } from '@/utils/apiClient';
|
||||
import { normalizeOrgScope } from '@/utils/orgScope';
|
||||
import { eventBus } from '@/stores/events';
|
||||
import { getChartSeriesColor } from '@/utils/chartSeriesPresentation';
|
||||
|
||||
const getNormalizedResourceIdentifiers = (resource: Resource): Set<string> =>
|
||||
new Set<string>([
|
||||
...getNormalizedIdentityLookupVariants(resource.id),
|
||||
...getNormalizedIdentityLookupVariants(resource.platformId),
|
||||
...getNormalizedIdentityLookupVariants(getPreferredResourceDisplayName(resource)),
|
||||
...getNormalizedIdentityLookupVariants(getPreferredResourceHostname(resource)),
|
||||
...getResourceIdentityAliases(resource).flatMap((value) =>
|
||||
getNormalizedIdentityLookupVariants(value),
|
||||
),
|
||||
]);
|
||||
|
||||
const getLinkedNodeIdFromResource = (resource: Resource): string | null =>
|
||||
asTrimmedString(getPlatformDataRecord(resource)?.linkedNodeId) ||
|
||||
asTrimmedString(getPlatformAgentRecord(resource)?.linkedNodeId) ||
|
||||
null;
|
||||
|
||||
// Combine a resource's net in/out into a single throughput series.
|
||||
// Buckets points into 30-second windows and sums rates from both directions.
|
||||
function combineResourceThroughputSeries(
|
||||
inSeries: MetricPoint[],
|
||||
outSeries: MetricPoint[],
|
||||
): MetricPoint[] {
|
||||
const bucketSize = 30_000; // 30 seconds
|
||||
const buckets = new Map<number, number>();
|
||||
for (const p of inSeries) {
|
||||
const bucket = Math.round(p.timestamp / bucketSize) * bucketSize;
|
||||
buckets.set(bucket, (buckets.get(bucket) || 0) + p.value);
|
||||
}
|
||||
for (const p of outSeries) {
|
||||
const bucket = Math.round(p.timestamp / bucketSize) * bucketSize;
|
||||
buckets.set(bucket, (buckets.get(bucket) || 0) + p.value);
|
||||
}
|
||||
|
||||
return Array.from(buckets.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([timestamp, value]) => ({ timestamp, value }));
|
||||
}
|
||||
|
||||
interface InfrastructureSummaryProps {
|
||||
resources: Resource[];
|
||||
timeRange?: TimeRange;
|
||||
hoveredResourceId?: string | null;
|
||||
focusedResourceId?: string | null;
|
||||
onTimeRangeChange?: (range: TimeRange) => void;
|
||||
}
|
||||
|
||||
// In-memory full-resolution cache keyed by "org::range".
|
||||
// Survives component unmount/remount (page navigation) without the
|
||||
// downsampling artifacts that localStorage cache introduces.
|
||||
// Capped at MAX_IN_MEMORY_ENTRIES to prevent unbounded growth.
|
||||
const MAX_IN_MEMORY_INFRA_ENTRIES = 20;
|
||||
const inMemoryChartCache = new Map<string, Map<string, ChartData>>();
|
||||
|
||||
function inMemoryCacheKey(range: TimeRange): string {
|
||||
return `${normalizeOrgScope(getOrgID())}::${range}`;
|
||||
}
|
||||
|
||||
/** @internal Test-only reset for in-memory chart cache. */
|
||||
export function __resetInMemoryChartCacheForTests(): void {
|
||||
inMemoryChartCache.clear();
|
||||
}
|
||||
|
||||
// Clear in-memory cache on org switch to prevent cross-org data leakage.
|
||||
const unsubscribeInfraOrgSwitch = eventBus.on('org_switched', () => {
|
||||
inMemoryChartCache.clear();
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
unsubscribeInfraOrgSwitch();
|
||||
});
|
||||
}
|
||||
import type { InfrastructureSummaryProps } from './infrastructureSummaryModel';
|
||||
import { useInfrastructureSummaryState } from './useInfrastructureSummaryState';
|
||||
|
||||
export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (props) => {
|
||||
// Chart data keyed by resource identifier (node name, resource id, etc.)
|
||||
const [chartMap, setChartMap] = createSignal<Map<string, ChartData>>(new Map());
|
||||
const [chartRange, setChartRange] = createSignal<TimeRange | null>(null);
|
||||
const [loadedRange, setLoadedRange] = createSignal<TimeRange | null>(null);
|
||||
const [oldestDataTimestamp, setOldestDataTimestamp] = createSignal<number | null>(null);
|
||||
const [fetchFailed, setFetchFailed] = createSignal(false);
|
||||
const selectedRange = createMemo<TimeRange>(() => props.timeRange || '1h');
|
||||
const hasCurrentRangeCharts = createMemo(() => chartRange() === selectedRange());
|
||||
const isCurrentRangeLoaded = createMemo(() => loadedRange() === selectedRange());
|
||||
const state = useInfrastructureSummaryState(props);
|
||||
|
||||
const { workloads, resources } = useResources();
|
||||
const agentResources = createMemo(() =>
|
||||
resources().filter(
|
||||
(resource) =>
|
||||
(resource.type === 'agent' ||
|
||||
resource.type === 'pbs' ||
|
||||
resource.type === 'pmg' ||
|
||||
resource.type === 'truenas') &&
|
||||
hasAgentFacet(resource),
|
||||
),
|
||||
);
|
||||
|
||||
// Track org switches so the effect re-runs when the org changes.
|
||||
const [orgVersion, setOrgVersion] = createSignal(0);
|
||||
const unsubscribeOrgSwitch = eventBus.on('org_switched', () => {
|
||||
setOrgVersion((v) => v + 1);
|
||||
});
|
||||
|
||||
// Fetch charts data directly — no dependency on dashboard sparkline store
|
||||
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let activeFetchController: AbortController | null = null;
|
||||
let activeFetchRequest = 0;
|
||||
let activeScopeKey: string | null = null;
|
||||
|
||||
const awaitAbortable = <T,>(promise: Promise<T>, signal: AbortSignal): Promise<T> => {
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCharts = async (options?: { prioritize?: boolean }) => {
|
||||
if (props.resources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prioritize = options?.prioritize === true;
|
||||
if (activeFetchController && !prioritize) {
|
||||
// Keep the current request; next timer tick will retry if needed.
|
||||
return;
|
||||
}
|
||||
if (activeFetchController && prioritize) {
|
||||
activeFetchController.abort();
|
||||
}
|
||||
|
||||
const requestedRange = selectedRange();
|
||||
const controller = new AbortController();
|
||||
const requestId = ++activeFetchRequest;
|
||||
activeFetchController = controller;
|
||||
|
||||
try {
|
||||
const fetched = await awaitAbortable(
|
||||
fetchInfrastructureSummaryAndCache(requestedRange, { caller: 'InfrastructureSummary' }),
|
||||
controller.signal,
|
||||
);
|
||||
if (requestId !== activeFetchRequest) {
|
||||
return;
|
||||
}
|
||||
const map = fetched.map;
|
||||
|
||||
// If the backend returns an empty payload transiently, keep the last
|
||||
// good map to avoid flashing the "no history / static" fallbacks.
|
||||
const currentMapMatchesRequestedRange = chartRange() === requestedRange;
|
||||
if (map.size > 0 || chartMap().size === 0 || !currentMapMatchesRequestedRange) {
|
||||
const cacheKey = inMemoryCacheKey(requestedRange);
|
||||
if (
|
||||
!inMemoryChartCache.has(cacheKey) &&
|
||||
inMemoryChartCache.size >= MAX_IN_MEMORY_INFRA_ENTRIES
|
||||
) {
|
||||
const oldest = inMemoryChartCache.keys().next().value;
|
||||
if (oldest !== undefined) inMemoryChartCache.delete(oldest);
|
||||
}
|
||||
inMemoryChartCache.set(cacheKey, map);
|
||||
setChartMap(map);
|
||||
setChartRange(requestedRange);
|
||||
}
|
||||
setOldestDataTimestamp(fetched.oldestDataTimestamp);
|
||||
setFetchFailed(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
if (requestId === activeFetchRequest) {
|
||||
setFetchFailed(true);
|
||||
// Fall back to localStorage cache on fetch failure.
|
||||
if (chartRange() !== requestedRange) {
|
||||
const cached = readInfrastructureSummaryCache(requestedRange);
|
||||
if (cached) {
|
||||
setChartMap(cached.map);
|
||||
setChartRange(requestedRange);
|
||||
setOldestDataTimestamp(cached.oldestDataTimestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (activeFetchController === controller) {
|
||||
activeFetchController = null;
|
||||
}
|
||||
if (requestId === activeFetchRequest) {
|
||||
setLoadedRange(requestedRange);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
// Start polling when there are resources to show. Crucially, do NOT tear down
|
||||
// and recreate the interval on every props update, or we end up refetching
|
||||
// charts at the websocket update cadence (causing visible UI flashes).
|
||||
const hasResources = props.resources.length > 0;
|
||||
// Read orgVersion to subscribe to org switches.
|
||||
const currentOrg = orgVersion();
|
||||
if (!hasResources) {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
if (activeFetchController) {
|
||||
activeFetchController.abort();
|
||||
activeFetchController = null;
|
||||
}
|
||||
activeScopeKey = null;
|
||||
setChartMap(new Map());
|
||||
setChartRange(null);
|
||||
setLoadedRange(null);
|
||||
setOldestDataTimestamp(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!refreshTimer) {
|
||||
refreshTimer = setInterval(() => void fetchCharts(), 30_000);
|
||||
}
|
||||
|
||||
const nextRange = selectedRange();
|
||||
const nextScopeKey = `${currentOrg}::${nextRange}`;
|
||||
if (activeScopeKey !== nextScopeKey) {
|
||||
activeScopeKey = nextScopeKey;
|
||||
|
||||
// Hydrate from in-memory cache (full-resolution, no curve shift).
|
||||
// Only falls back to skeleton if this range was never fetched in this session.
|
||||
const memCached = inMemoryChartCache.get(inMemoryCacheKey(nextRange));
|
||||
if (memCached && memCached.size > 0) {
|
||||
setChartMap(memCached);
|
||||
setChartRange(nextRange);
|
||||
setLoadedRange(nextRange);
|
||||
const cached = readInfrastructureSummaryCache(nextRange);
|
||||
setOldestDataTimestamp(cached?.oldestDataTimestamp ?? null);
|
||||
} else {
|
||||
setChartMap(new Map());
|
||||
setChartRange(null);
|
||||
setLoadedRange(null);
|
||||
setOldestDataTimestamp(null);
|
||||
}
|
||||
setFetchFailed(false);
|
||||
void fetchCharts({ prioritize: true });
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (activeFetchController) {
|
||||
activeFetchController.abort();
|
||||
activeFetchController = null;
|
||||
}
|
||||
unsubscribeOrgSwitch();
|
||||
});
|
||||
|
||||
// Match a unified resource to its chart data.
|
||||
// Chart data is keyed by backend composite IDs (e.g. "cluster-pve01" or "instance-pve01")
|
||||
// but unified resources have hashed IDs. We reconstruct the composite key or use suffix matching.
|
||||
const findChartData = (resource: Resource): ChartData | undefined => {
|
||||
if (!hasCurrentRangeCharts()) return undefined;
|
||||
const map = chartMap();
|
||||
if (map.size === 0) return undefined;
|
||||
|
||||
// 1. Agent ID match from unified platform data (most reliable for agent resources).
|
||||
for (const key of getMetricsChartKeyCandidatesFromResource(resource)) {
|
||||
const match = map.get(key);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
// 2. Direct matches (works for agent resources where IDs may align)
|
||||
// Reconstruct composite key for clustered Proxmox nodes: "clusterName-nodeName"
|
||||
if (resource.clusterId && resource.platformId) {
|
||||
const clusterKey = `${resource.clusterId}-${resource.platformId}`;
|
||||
const clusterMatch = map.get(clusterKey);
|
||||
if (clusterMatch) return clusterMatch;
|
||||
}
|
||||
|
||||
// 3. Suffix match for standalone Proxmox nodes: key ends with "-{nodeName}"
|
||||
// Handles cases where the instance name prefix is unknown to the frontend
|
||||
const nameToMatch =
|
||||
getPreferredResourceHostname(resource) || resource.platformId || resource.name;
|
||||
if (nameToMatch) {
|
||||
const suffix = `-${nameToMatch}`;
|
||||
for (const [key, data] of map) {
|
||||
if (key.endsWith(suffix)) return data;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Find chart data from a linked agent when the primary chart data
|
||||
// (typically from nodeData) doesn't include agent-specific metrics like
|
||||
// netin/netout/diskread/diskwrite.
|
||||
// Agent resources have internal IDs that match agentData chart keys, and
|
||||
// platformData.linkedNodeId + identity.hostname fields that let us correlate
|
||||
// with infrastructure resources.
|
||||
const findAgentChartData = (resource: Resource): ChartData | undefined => {
|
||||
if (!hasCurrentRangeCharts()) return undefined;
|
||||
const map = chartMap();
|
||||
if (map.size === 0) return undefined;
|
||||
|
||||
const directAgentCandidates: string[] = [];
|
||||
const actionableAgentId = getActionableAgentIdFromResource(resource);
|
||||
if (actionableAgentId) {
|
||||
directAgentCandidates.push(actionableAgentId);
|
||||
}
|
||||
if (resource.platformType === 'agent') {
|
||||
const discoveryResourceId = asTrimmedString(resource.discoveryTarget?.resourceId);
|
||||
const discoveryHostId = asTrimmedString(resource.discoveryTarget?.agentId);
|
||||
if (discoveryResourceId) directAgentCandidates.push(discoveryResourceId);
|
||||
if (discoveryHostId) directAgentCandidates.push(discoveryHostId);
|
||||
}
|
||||
|
||||
for (const key of Array.from(new Set(directAgentCandidates))) {
|
||||
const direct = map.get(key);
|
||||
if (direct) return direct;
|
||||
}
|
||||
|
||||
const agentFacetResources = agentResources();
|
||||
if (!agentFacetResources || agentFacetResources.length === 0) return undefined;
|
||||
|
||||
const nodeRefCandidates = new Set<string>(
|
||||
[resource.id, resource.platformId, getPreferredResourceHostname(resource)]
|
||||
.map((value) => value?.trim().toLowerCase())
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
const resourceNameCandidates = getNormalizedResourceIdentifiers(resource);
|
||||
|
||||
// Find an agent resource that matches this infrastructure resource
|
||||
// by linked node ID, hostname, or name
|
||||
for (const agentResource of agentFacetResources) {
|
||||
const linkedNodeId = getLinkedNodeIdFromResource(agentResource);
|
||||
const normalizedLinkedNodeId = linkedNodeId?.toLowerCase();
|
||||
|
||||
// Match by linked node: agent is linked to a PVE node matching this resource
|
||||
const linkedMatch = normalizedLinkedNodeId
|
||||
? nodeRefCandidates.has(normalizedLinkedNodeId)
|
||||
: false;
|
||||
// Match by hostname: agent hostname matches this resource
|
||||
const agentResourceNames = Array.from(getNormalizedResourceIdentifiers(agentResource));
|
||||
const hostnameMatch = agentResourceNames.some((candidate) =>
|
||||
resourceNameCandidates.has(candidate),
|
||||
);
|
||||
|
||||
if (linkedMatch || hostnameMatch) {
|
||||
for (const key of getMetricsChartKeyCandidatesFromResource(agentResource)) {
|
||||
const agentData = map.get(key);
|
||||
if (agentData) return agentData;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Build sparkline series for all resources
|
||||
const resourceSeries = createMemo(() => {
|
||||
void chartMap(); // reactive dependency
|
||||
return props.resources.map((resource, i) => {
|
||||
const primaryData = findChartData(resource);
|
||||
const agentData = findAgentChartData(resource);
|
||||
const seriesId = resource.id || resource.platformId || resource.name || `resource-${i}`;
|
||||
|
||||
const metricSeries = (metric: keyof ChartData): MetricPoint[] => {
|
||||
const primary = primaryData?.[metric];
|
||||
if (primary && primary.length > 0) return primary;
|
||||
const fallback = agentData?.[metric];
|
||||
if (fallback && fallback.length > 0) return fallback;
|
||||
return [];
|
||||
};
|
||||
|
||||
return {
|
||||
key: seriesId,
|
||||
id: seriesId,
|
||||
cpu: metricSeries('cpu'),
|
||||
memory: metricSeries('memory'),
|
||||
disk: metricSeries('disk'),
|
||||
netin: metricSeries('netin'),
|
||||
netout: metricSeries('netout'),
|
||||
network: combineResourceThroughputSeries(metricSeries('netin'), metricSeries('netout')),
|
||||
diskio: combineResourceThroughputSeries(
|
||||
metricSeries('diskread'),
|
||||
metricSeries('diskwrite'),
|
||||
),
|
||||
color: getChartSeriesColor(i),
|
||||
name: getPreferredResourceDisplayName(resource),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// When a resource drawer is open, filter sparklines to show only that resource
|
||||
const displaySeries = createMemo(() => {
|
||||
const focused = props.focusedResourceId;
|
||||
const all = resourceSeries();
|
||||
if (!focused) return all;
|
||||
const match = all.find((s) => s.id === focused);
|
||||
return match ? [match] : all;
|
||||
});
|
||||
|
||||
const focusedResourceName = createMemo(() => {
|
||||
const focused = props.focusedResourceId;
|
||||
if (!focused) return null;
|
||||
const match = resourceSeries().find((s) => s.id === focused);
|
||||
return match?.name ?? null;
|
||||
});
|
||||
|
||||
const singleDisplayedOnlineResource = createMemo(() => {
|
||||
if (displaySeries().length !== 1 || props.resources.length !== 1) return null;
|
||||
const [resource] = props.resources;
|
||||
if (!resource) return null;
|
||||
return resource.status?.toLowerCase() === 'online' ? resource : null;
|
||||
});
|
||||
|
||||
const isAwaitingFirstSample = createMemo(() => {
|
||||
const resource = singleDisplayedOnlineResource();
|
||||
if (!resource || !isCurrentRangeLoaded() || fetchFailed()) return false;
|
||||
|
||||
const oldest = oldestDataTimestamp();
|
||||
if (oldest === null) return true;
|
||||
return resource.lastSeen >= oldest;
|
||||
});
|
||||
|
||||
const emptyHistoryLabel = createMemo(() =>
|
||||
isAwaitingFirstSample() ? 'Waiting for first sample' : 'No history yet',
|
||||
);
|
||||
|
||||
const hasData = (metric: 'cpu' | 'memory' | 'disk') =>
|
||||
displaySeries().some((s) => s[metric].length >= 1);
|
||||
|
||||
const networkSeries = createMemo(() =>
|
||||
displaySeries().map((s) => ({
|
||||
id: s.id,
|
||||
data: s.network,
|
||||
color: s.color,
|
||||
name: s.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const hasNetData = () => displaySeries().some((s) => s.network.length >= 1);
|
||||
|
||||
const diskioSeries = createMemo(() =>
|
||||
displaySeries().map((s) => ({
|
||||
id: s.id,
|
||||
data: s.diskio,
|
||||
color: s.color,
|
||||
name: s.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const hasDiskIOData = () => displaySeries().some((s) => s.diskio.length >= 1);
|
||||
|
||||
const avgDiskCapacity = createMemo(() => {
|
||||
const diskResources = props.resources.filter(
|
||||
(resource) => resource.disk && resource.disk.total,
|
||||
);
|
||||
if (diskResources.length === 0) return null;
|
||||
const avg =
|
||||
diskResources.reduce((sum, resource) => sum + getDiskPercent(resource), 0) /
|
||||
diskResources.length;
|
||||
return Math.round(avg);
|
||||
});
|
||||
|
||||
// Keep the network card visible when we have capability but limited history.
|
||||
const hasNetworkCapability = createMemo(() =>
|
||||
props.resources.some((resource) => {
|
||||
if (resource.type === 'docker-host' || hasAgentFacet(resource)) return true;
|
||||
|
||||
// If current-rate metrics are present, treat as network-capable.
|
||||
const rx = resource.network?.rxBytes ?? 0;
|
||||
const tx = resource.network?.txBytes ?? 0;
|
||||
return rx > 0 || tx > 0;
|
||||
}),
|
||||
);
|
||||
|
||||
const shouldShowNetworkCard = createMemo(() => hasNetData() || hasNetworkCapability());
|
||||
|
||||
const seriesFor = (metric: 'cpu' | 'memory' | 'disk') =>
|
||||
displaySeries().map((s) => ({ id: s.id, data: s[metric], color: s.color, name: s.name }));
|
||||
const rangeLabel = () => props.timeRange || '1h';
|
||||
|
||||
const workloadStats = createMemo(() => {
|
||||
const all = workloads();
|
||||
let running = 0;
|
||||
let stopped = 0;
|
||||
let vms = 0;
|
||||
let containers = 0;
|
||||
for (const w of all) {
|
||||
if (w.status === 'running' || w.status === 'online') {
|
||||
running++;
|
||||
} else {
|
||||
stopped++;
|
||||
}
|
||||
if (w.type === 'vm') {
|
||||
vms++;
|
||||
} else {
|
||||
containers++;
|
||||
}
|
||||
}
|
||||
return { total: all.length, running, stopped, vms, containers };
|
||||
});
|
||||
|
||||
const resourceCounts = createMemo(() => {
|
||||
const total = props.resources.length;
|
||||
const online = props.resources.filter((resource) => resource.status === 'online').length;
|
||||
const offline = total - online;
|
||||
return { total, online, offline };
|
||||
});
|
||||
|
||||
const emptyMsg = () => (fetchFailed() ? 'Trend data unavailable' : emptyHistoryLabel());
|
||||
|
||||
const focusedLabel = () => {
|
||||
const name = focusedResourceName();
|
||||
const name = state.focusedResourceName();
|
||||
if (!name) return undefined;
|
||||
return <span class="text-xs text-muted ml-1.5 truncate">— {name}</span>;
|
||||
};
|
||||
@@ -566,31 +28,31 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
|
||||
headerLeft={
|
||||
<>
|
||||
<span class="font-medium text-base-content">
|
||||
{resourceCounts().total} {resourceCounts().total === 1 ? 'resource' : 'resources'}
|
||||
{state.resourceCounts().total}{' '}
|
||||
{state.resourceCounts().total === 1 ? 'resource' : 'resources'}
|
||||
</span>
|
||||
<Show when={resourceCounts().online > 0}>
|
||||
<Show when={state.resourceCounts().online > 0}>
|
||||
<span class="text-emerald-600 dark:text-emerald-400">
|
||||
{resourceCounts().online} online
|
||||
{state.resourceCounts().online} online
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={resourceCounts().offline > 0}>
|
||||
<span class="text-muted">{resourceCounts().offline} offline</span>
|
||||
<Show when={state.resourceCounts().offline > 0}>
|
||||
<span class="text-muted">{state.resourceCounts().offline} offline</span>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
timeRange={selectedRange()}
|
||||
timeRange={state.selectedRange()}
|
||||
onTimeRangeChange={props.onTimeRangeChange}
|
||||
>
|
||||
{/* CPU Card */}
|
||||
<SummaryMetricCard
|
||||
label="CPU"
|
||||
secondaryLabel={focusedLabel()}
|
||||
loaded={isCurrentRangeLoaded()}
|
||||
hasData={hasData('cpu')}
|
||||
emptyMessage={emptyMsg()}
|
||||
loaded={state.isCurrentRangeLoaded()}
|
||||
hasData={state.hasData('cpu')}
|
||||
emptyMessage={state.emptyMessage()}
|
||||
>
|
||||
<InteractiveSparkline
|
||||
series={seriesFor('cpu')}
|
||||
series={state.seriesFor('cpu')}
|
||||
rangeLabel={rangeLabel()}
|
||||
timeRange={props.timeRange}
|
||||
yMode="percent"
|
||||
@@ -599,16 +61,15 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
{/* Memory Card */}
|
||||
<SummaryMetricCard
|
||||
label="Memory"
|
||||
secondaryLabel={focusedLabel()}
|
||||
loaded={isCurrentRangeLoaded()}
|
||||
hasData={hasData('memory')}
|
||||
emptyMessage={emptyMsg()}
|
||||
loaded={state.isCurrentRangeLoaded()}
|
||||
hasData={state.hasData('memory')}
|
||||
emptyMessage={state.emptyMessage()}
|
||||
>
|
||||
<InteractiveSparkline
|
||||
series={seriesFor('memory')}
|
||||
series={state.seriesFor('memory')}
|
||||
rangeLabel={rangeLabel()}
|
||||
timeRange={props.timeRange}
|
||||
yMode="percent"
|
||||
@@ -617,34 +78,32 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
{/* Disk I/O Card */}
|
||||
<SummaryMetricCard
|
||||
label="Disk I/O"
|
||||
secondaryLabel={
|
||||
<>
|
||||
{focusedLabel()}
|
||||
<Show when={!focusedResourceName() && avgDiskCapacity() !== null}>
|
||||
<Show when={!state.focusedResourceName() && state.avgDiskCapacity() !== null}>
|
||||
<span class="text-[10px] text-muted ml-auto shrink-0">
|
||||
Capacity: {avgDiskCapacity()}%
|
||||
Capacity: {state.avgDiskCapacity()}%
|
||||
</span>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
loaded={isCurrentRangeLoaded()}
|
||||
hasData={hasDiskIOData()}
|
||||
emptyMessage={emptyMsg()}
|
||||
loaded={state.isCurrentRangeLoaded()}
|
||||
hasData={state.hasDiskIOData()}
|
||||
emptyMessage={state.emptyMessage()}
|
||||
>
|
||||
<DensityMap
|
||||
series={diskioSeries()}
|
||||
series={state.diskioSeries()}
|
||||
rangeLabel={rangeLabel()}
|
||||
timeRange={props.timeRange}
|
||||
formatValue={formatThroughputRate}
|
||||
/>
|
||||
</SummaryMetricCard>
|
||||
|
||||
{/* 4th Card: Network or Workloads fallback */}
|
||||
<Show
|
||||
when={shouldShowNetworkCard()}
|
||||
when={state.shouldShowNetworkCard()}
|
||||
fallback={
|
||||
<Card padding="sm" class="h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
@@ -667,26 +126,33 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-xl sm:text-2xl font-bold text-base-content">
|
||||
{workloadStats().running}
|
||||
{state.workloadStats().running}
|
||||
<span class="text-sm font-normal text-muted ml-1">running</span>
|
||||
</div>
|
||||
<Show
|
||||
when={workloadStats().total > 0}
|
||||
when={state.workloadStats().total > 0}
|
||||
fallback={<div class="text-[10px] text-muted mt-1">No workloads detected</div>}
|
||||
>
|
||||
<div class="text-[10px] text-muted mt-1">
|
||||
<Show when={workloadStats().vms > 0}>
|
||||
<span>{workloadStats().vms} VMs</span>
|
||||
<Show when={state.workloadStats().vms > 0}>
|
||||
<span>{state.workloadStats().vms} VMs</span>
|
||||
</Show>
|
||||
<Show when={workloadStats().vms > 0 && workloadStats().containers > 0}>
|
||||
<Show
|
||||
when={
|
||||
state.workloadStats().vms > 0 &&
|
||||
state.workloadStats().containers > 0
|
||||
}
|
||||
>
|
||||
<span class="mx-0.5">·</span>
|
||||
</Show>
|
||||
<Show when={workloadStats().containers > 0}>
|
||||
<span>{workloadStats().containers} containers</span>
|
||||
<Show when={state.workloadStats().containers > 0}>
|
||||
<span>{state.workloadStats().containers} containers</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={workloadStats().stopped > 0}>
|
||||
<div class="text-[10px] text-muted">{workloadStats().stopped} stopped</div>
|
||||
<Show when={state.workloadStats().stopped > 0}>
|
||||
<div class="text-[10px] text-muted">
|
||||
{state.workloadStats().stopped} stopped
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -696,12 +162,12 @@ export const InfrastructureSummary: Component<InfrastructureSummaryProps> = (pro
|
||||
<SummaryMetricCard
|
||||
label="Network"
|
||||
secondaryLabel={focusedLabel()}
|
||||
loaded={isCurrentRangeLoaded()}
|
||||
hasData={hasNetData()}
|
||||
emptyMessage={emptyHistoryLabel()}
|
||||
loaded={state.isCurrentRangeLoaded()}
|
||||
hasData={state.hasNetData()}
|
||||
emptyMessage={state.emptyHistoryLabel()}
|
||||
>
|
||||
<DensityMap
|
||||
series={networkSeries()}
|
||||
series={state.networkSeries()}
|
||||
rangeLabel={rangeLabel()}
|
||||
timeRange={props.timeRange}
|
||||
formatValue={formatThroughputRate}
|
||||
|
||||
+22
@@ -9,6 +9,9 @@ import unifiedResourceTableSource from '@/components/Infrastructure/UnifiedResou
|
||||
import unifiedResourceTableStateSource from '@/components/Infrastructure/useUnifiedResourceTableState.ts?raw';
|
||||
import unifiedResourceTableViewportSyncSource from '@/components/Infrastructure/useUnifiedResourceTableViewportSync.ts?raw';
|
||||
import unifiedResourceTableModelSource from '@/components/Infrastructure/unifiedResourceTableModel.ts?raw';
|
||||
import infrastructureSummarySource from '@/components/Infrastructure/InfrastructureSummary.tsx?raw';
|
||||
import infrastructureSummaryStateSource from '@/components/Infrastructure/useInfrastructureSummaryState.ts?raw';
|
||||
import infrastructureSummaryModelSource from '@/components/Infrastructure/infrastructureSummaryModel.ts?raw';
|
||||
import {
|
||||
buildStatusOptions,
|
||||
filterResources,
|
||||
@@ -200,6 +203,25 @@ describe('UnifiedResourceTable performance contract', () => {
|
||||
expect(unifiedResourceTableModelSource).toContain('export const getOutlierEmphasis');
|
||||
});
|
||||
|
||||
it('keeps infrastructure summary fetch runtime out of the render shell', () => {
|
||||
expect(infrastructureSummarySource).toContain('useInfrastructureSummaryState');
|
||||
expect(infrastructureSummarySource).not.toContain('fetchInfrastructureSummaryAndCache');
|
||||
expect(infrastructureSummarySource).not.toContain('readInfrastructureSummaryCache');
|
||||
expect(infrastructureSummarySource).not.toContain('setInterval(');
|
||||
expect(infrastructureSummarySource).not.toContain('AbortController');
|
||||
expect(infrastructureSummaryStateSource).toContain('fetchInfrastructureSummaryAndCache');
|
||||
expect(infrastructureSummaryStateSource).toContain('readInfrastructureSummaryCache');
|
||||
expect(infrastructureSummaryStateSource).toContain('setInterval(');
|
||||
expect(infrastructureSummaryStateSource).toContain('AbortController');
|
||||
expect(infrastructureSummaryStateSource).toContain("eventBus.on('org_switched'");
|
||||
expect(infrastructureSummaryModelSource).toContain(
|
||||
'export function buildInfrastructureSummarySeries',
|
||||
);
|
||||
expect(infrastructureSummaryModelSource).toContain(
|
||||
'export function combineResourceThroughputSeries',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps source filtering on the shared canonical source-platform helper', () => {
|
||||
const resources = [
|
||||
makeResource(0, { platformData: { sources: ['proxmox'] } }),
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { ChartData, MetricPoint, TimeRange } from '@/api/charts';
|
||||
import {
|
||||
getActionableAgentIdFromResource,
|
||||
getMetricsChartKeyCandidatesFromResource,
|
||||
getPlatformAgentRecord,
|
||||
getPlatformDataRecord,
|
||||
hasAgentFacet,
|
||||
} from '@/utils/agentResources';
|
||||
import {
|
||||
getPreferredResourceDisplayName,
|
||||
getPreferredResourceHostname,
|
||||
getResourceIdentityAliases,
|
||||
getNormalizedIdentityLookupVariants,
|
||||
} from '@/utils/resourceIdentity';
|
||||
import { asTrimmedString } from '@/utils/stringUtils';
|
||||
import { getChartSeriesColor } from '@/utils/chartSeriesPresentation';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import { getDiskPercent } from '@/types/resource';
|
||||
|
||||
export interface InfrastructureSummaryProps {
|
||||
resources: Resource[];
|
||||
timeRange?: TimeRange;
|
||||
hoveredResourceId?: string | null;
|
||||
focusedResourceId?: string | null;
|
||||
onTimeRangeChange?: (range: TimeRange) => void;
|
||||
}
|
||||
|
||||
export interface InfrastructureSummarySeries {
|
||||
key: string;
|
||||
id: string;
|
||||
cpu: MetricPoint[];
|
||||
memory: MetricPoint[];
|
||||
disk: MetricPoint[];
|
||||
netin: MetricPoint[];
|
||||
netout: MetricPoint[];
|
||||
network: MetricPoint[];
|
||||
diskio: MetricPoint[];
|
||||
color: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type InfrastructureSummarySparkMetric = 'cpu' | 'memory' | 'disk';
|
||||
|
||||
export interface InfrastructureWorkloadStats {
|
||||
total: number;
|
||||
running: number;
|
||||
stopped: number;
|
||||
vms: number;
|
||||
containers: number;
|
||||
}
|
||||
|
||||
export interface InfrastructureResourceCounts {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
}
|
||||
|
||||
const getNormalizedResourceIdentifiers = (resource: Resource): Set<string> =>
|
||||
new Set<string>([
|
||||
...getNormalizedIdentityLookupVariants(resource.id),
|
||||
...getNormalizedIdentityLookupVariants(resource.platformId),
|
||||
...getNormalizedIdentityLookupVariants(getPreferredResourceDisplayName(resource)),
|
||||
...getNormalizedIdentityLookupVariants(getPreferredResourceHostname(resource)),
|
||||
...getResourceIdentityAliases(resource).flatMap((value) =>
|
||||
getNormalizedIdentityLookupVariants(value),
|
||||
),
|
||||
]);
|
||||
|
||||
const getLinkedNodeIdFromResource = (resource: Resource): string | null =>
|
||||
asTrimmedString(getPlatformDataRecord(resource)?.linkedNodeId) ||
|
||||
asTrimmedString(getPlatformAgentRecord(resource)?.linkedNodeId) ||
|
||||
null;
|
||||
|
||||
// Combine a resource's net in/out into a single throughput series.
|
||||
// Buckets points into 30-second windows and sums rates from both directions.
|
||||
export function combineResourceThroughputSeries(
|
||||
inSeries: MetricPoint[],
|
||||
outSeries: MetricPoint[],
|
||||
): MetricPoint[] {
|
||||
const bucketSize = 30_000; // 30 seconds
|
||||
const buckets = new Map<number, number>();
|
||||
for (const point of inSeries) {
|
||||
const bucket = Math.round(point.timestamp / bucketSize) * bucketSize;
|
||||
buckets.set(bucket, (buckets.get(bucket) || 0) + point.value);
|
||||
}
|
||||
for (const point of outSeries) {
|
||||
const bucket = Math.round(point.timestamp / bucketSize) * bucketSize;
|
||||
buckets.set(bucket, (buckets.get(bucket) || 0) + point.value);
|
||||
}
|
||||
|
||||
return Array.from(buckets.entries())
|
||||
.sort((left, right) => left[0] - right[0])
|
||||
.map(([timestamp, value]) => ({ timestamp, value }));
|
||||
}
|
||||
|
||||
export function findInfrastructureChartData(
|
||||
resource: Resource,
|
||||
chartMap: Map<string, ChartData>,
|
||||
): ChartData | undefined {
|
||||
if (chartMap.size === 0) return undefined;
|
||||
|
||||
for (const key of getMetricsChartKeyCandidatesFromResource(resource)) {
|
||||
const match = chartMap.get(key);
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
if (resource.clusterId && resource.platformId) {
|
||||
const clusterKey = `${resource.clusterId}-${resource.platformId}`;
|
||||
const clusterMatch = chartMap.get(clusterKey);
|
||||
if (clusterMatch) return clusterMatch;
|
||||
}
|
||||
|
||||
const nameToMatch =
|
||||
getPreferredResourceHostname(resource) || resource.platformId || resource.name;
|
||||
if (nameToMatch) {
|
||||
const suffix = `-${nameToMatch}`;
|
||||
for (const [key, data] of chartMap) {
|
||||
if (key.endsWith(suffix)) return data;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function findInfrastructureAgentChartData(
|
||||
resource: Resource,
|
||||
chartMap: Map<string, ChartData>,
|
||||
agentFacetResources: Resource[],
|
||||
): ChartData | undefined {
|
||||
if (chartMap.size === 0) return undefined;
|
||||
|
||||
const directAgentCandidates: string[] = [];
|
||||
const actionableAgentId = getActionableAgentIdFromResource(resource);
|
||||
if (actionableAgentId) {
|
||||
directAgentCandidates.push(actionableAgentId);
|
||||
}
|
||||
if (resource.platformType === 'agent') {
|
||||
const discoveryResourceId = asTrimmedString(resource.discoveryTarget?.resourceId);
|
||||
const discoveryHostId = asTrimmedString(resource.discoveryTarget?.agentId);
|
||||
if (discoveryResourceId) directAgentCandidates.push(discoveryResourceId);
|
||||
if (discoveryHostId) directAgentCandidates.push(discoveryHostId);
|
||||
}
|
||||
|
||||
for (const key of Array.from(new Set(directAgentCandidates))) {
|
||||
const direct = chartMap.get(key);
|
||||
if (direct) return direct;
|
||||
}
|
||||
|
||||
if (agentFacetResources.length === 0) return undefined;
|
||||
|
||||
const nodeRefCandidates = new Set<string>(
|
||||
[resource.id, resource.platformId, getPreferredResourceHostname(resource)]
|
||||
.map((value) => value?.trim().toLowerCase())
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
const resourceNameCandidates = getNormalizedResourceIdentifiers(resource);
|
||||
|
||||
for (const agentResource of agentFacetResources) {
|
||||
const linkedNodeId = getLinkedNodeIdFromResource(agentResource);
|
||||
const normalizedLinkedNodeId = linkedNodeId?.toLowerCase();
|
||||
|
||||
const linkedMatch = normalizedLinkedNodeId
|
||||
? nodeRefCandidates.has(normalizedLinkedNodeId)
|
||||
: false;
|
||||
const agentResourceNames = Array.from(getNormalizedResourceIdentifiers(agentResource));
|
||||
const hostnameMatch = agentResourceNames.some((candidate) =>
|
||||
resourceNameCandidates.has(candidate),
|
||||
);
|
||||
|
||||
if (linkedMatch || hostnameMatch) {
|
||||
for (const key of getMetricsChartKeyCandidatesFromResource(agentResource)) {
|
||||
const agentData = chartMap.get(key);
|
||||
if (agentData) return agentData;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildInfrastructureSummarySeries(
|
||||
resources: Resource[],
|
||||
chartMap: Map<string, ChartData>,
|
||||
agentFacetResources: Resource[],
|
||||
): InfrastructureSummarySeries[] {
|
||||
return resources.map((resource, index) => {
|
||||
const primaryData = findInfrastructureChartData(resource, chartMap);
|
||||
const agentData = findInfrastructureAgentChartData(resource, chartMap, agentFacetResources);
|
||||
const seriesId = resource.id || resource.platformId || resource.name || `resource-${index}`;
|
||||
|
||||
const metricSeries = (metric: keyof ChartData): MetricPoint[] => {
|
||||
const primary = primaryData?.[metric];
|
||||
if (primary && primary.length > 0) return primary;
|
||||
const fallback = agentData?.[metric];
|
||||
if (fallback && fallback.length > 0) return fallback;
|
||||
return [];
|
||||
};
|
||||
|
||||
return {
|
||||
key: seriesId,
|
||||
id: seriesId,
|
||||
cpu: metricSeries('cpu'),
|
||||
memory: metricSeries('memory'),
|
||||
disk: metricSeries('disk'),
|
||||
netin: metricSeries('netin'),
|
||||
netout: metricSeries('netout'),
|
||||
network: combineResourceThroughputSeries(metricSeries('netin'), metricSeries('netout')),
|
||||
diskio: combineResourceThroughputSeries(
|
||||
metricSeries('diskread'),
|
||||
metricSeries('diskwrite'),
|
||||
),
|
||||
color: getChartSeriesColor(index),
|
||||
name: getPreferredResourceDisplayName(resource),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildInfrastructureWorkloadStats(
|
||||
workloads: Resource[],
|
||||
): InfrastructureWorkloadStats {
|
||||
let running = 0;
|
||||
let stopped = 0;
|
||||
let vms = 0;
|
||||
let containers = 0;
|
||||
for (const workload of workloads) {
|
||||
if (workload.status === 'running' || workload.status === 'online') {
|
||||
running++;
|
||||
} else {
|
||||
stopped++;
|
||||
}
|
||||
if (workload.type === 'vm') {
|
||||
vms++;
|
||||
} else {
|
||||
containers++;
|
||||
}
|
||||
}
|
||||
return { total: workloads.length, running, stopped, vms, containers };
|
||||
}
|
||||
|
||||
export function buildInfrastructureResourceCounts(
|
||||
resources: Resource[],
|
||||
): InfrastructureResourceCounts {
|
||||
const total = resources.length;
|
||||
const online = resources.filter((resource) => resource.status === 'online').length;
|
||||
return {
|
||||
total,
|
||||
online,
|
||||
offline: total - online,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAverageDiskCapacity(resources: Resource[]): number | null {
|
||||
const diskResources = resources.filter((resource) => resource.disk && resource.disk.total);
|
||||
if (diskResources.length === 0) return null;
|
||||
const average =
|
||||
diskResources.reduce((sum, resource) => sum + getDiskPercent(resource), 0) /
|
||||
diskResources.length;
|
||||
return Math.round(average);
|
||||
}
|
||||
|
||||
export function hasInfrastructureNetworkCapability(resources: Resource[]): boolean {
|
||||
return resources.some((resource) => {
|
||||
if (resource.type === 'docker-host' || hasAgentFacet(resource)) return true;
|
||||
const rx = resource.network?.rxBytes ?? 0;
|
||||
const tx = resource.network?.txBytes ?? 0;
|
||||
return rx > 0 || tx > 0;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
||||
import type { ChartData, TimeRange } from '@/api/charts';
|
||||
import { useResources } from '@/hooks/useResources';
|
||||
import {
|
||||
fetchInfrastructureSummaryAndCache,
|
||||
readInfrastructureSummaryCache,
|
||||
} from '@/utils/infrastructureSummaryCache';
|
||||
import { hasAgentFacet } from '@/utils/agentResources';
|
||||
import { getOrgID } from '@/utils/apiClient';
|
||||
import { normalizeOrgScope } from '@/utils/orgScope';
|
||||
import { eventBus } from '@/stores/events';
|
||||
import {
|
||||
type InfrastructureSummaryProps,
|
||||
type InfrastructureSummarySparkMetric,
|
||||
buildInfrastructureResourceCounts,
|
||||
buildInfrastructureSummarySeries,
|
||||
buildInfrastructureWorkloadStats,
|
||||
getAverageDiskCapacity,
|
||||
hasInfrastructureNetworkCapability,
|
||||
} from './infrastructureSummaryModel';
|
||||
|
||||
// In-memory full-resolution cache keyed by "org::range".
|
||||
// Survives component unmount/remount (page navigation) without the
|
||||
// downsampling artifacts that localStorage cache introduces.
|
||||
// Capped at MAX_IN_MEMORY_INFRA_ENTRIES to prevent unbounded growth.
|
||||
const MAX_IN_MEMORY_INFRA_ENTRIES = 20;
|
||||
const inMemoryChartCache = new Map<string, Map<string, ChartData>>();
|
||||
|
||||
function inMemoryCacheKey(range: TimeRange): string {
|
||||
return `${normalizeOrgScope(getOrgID())}::${range}`;
|
||||
}
|
||||
|
||||
/** @internal Test-only reset for in-memory chart cache. */
|
||||
export function __resetInMemoryChartCacheForTests(): void {
|
||||
inMemoryChartCache.clear();
|
||||
}
|
||||
|
||||
const unsubscribeInfraOrgSwitch = eventBus.on('org_switched', () => {
|
||||
inMemoryChartCache.clear();
|
||||
});
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.dispose(() => {
|
||||
unsubscribeInfraOrgSwitch();
|
||||
});
|
||||
}
|
||||
|
||||
export function useInfrastructureSummaryState(props: InfrastructureSummaryProps) {
|
||||
const [chartMap, setChartMap] = createSignal<Map<string, ChartData>>(new Map());
|
||||
const [chartRange, setChartRange] = createSignal<TimeRange | null>(null);
|
||||
const [loadedRange, setLoadedRange] = createSignal<TimeRange | null>(null);
|
||||
const [oldestDataTimestamp, setOldestDataTimestamp] = createSignal<number | null>(null);
|
||||
const [fetchFailed, setFetchFailed] = createSignal(false);
|
||||
const selectedRange = createMemo<TimeRange>(() => props.timeRange || '1h');
|
||||
const hasCurrentRangeCharts = createMemo(() => chartRange() === selectedRange());
|
||||
const isCurrentRangeLoaded = createMemo(() => loadedRange() === selectedRange());
|
||||
|
||||
const { workloads, resources } = useResources();
|
||||
const agentResources = createMemo(() =>
|
||||
resources().filter(
|
||||
(resource) =>
|
||||
(resource.type === 'agent' ||
|
||||
resource.type === 'pbs' ||
|
||||
resource.type === 'pmg' ||
|
||||
resource.type === 'truenas') &&
|
||||
hasAgentFacet(resource),
|
||||
),
|
||||
);
|
||||
|
||||
const [orgVersion, setOrgVersion] = createSignal(0);
|
||||
const unsubscribeOrgSwitch = eventBus.on('org_switched', () => {
|
||||
setOrgVersion((value) => value + 1);
|
||||
});
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
||||
let activeFetchController: AbortController | null = null;
|
||||
let activeFetchRequest = 0;
|
||||
let activeScopeKey: string | null = null;
|
||||
|
||||
const awaitAbortable = <T,>(promise: Promise<T>, signal: AbortSignal): Promise<T> => {
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(new DOMException('Aborted', 'AbortError'));
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCharts = async (options?: { prioritize?: boolean }) => {
|
||||
if (props.resources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prioritize = options?.prioritize === true;
|
||||
if (activeFetchController && !prioritize) {
|
||||
return;
|
||||
}
|
||||
if (activeFetchController && prioritize) {
|
||||
activeFetchController.abort();
|
||||
}
|
||||
|
||||
const requestedRange = selectedRange();
|
||||
const controller = new AbortController();
|
||||
const requestId = ++activeFetchRequest;
|
||||
activeFetchController = controller;
|
||||
|
||||
try {
|
||||
const fetched = await awaitAbortable(
|
||||
fetchInfrastructureSummaryAndCache(requestedRange, { caller: 'InfrastructureSummary' }),
|
||||
controller.signal,
|
||||
);
|
||||
if (requestId !== activeFetchRequest) {
|
||||
return;
|
||||
}
|
||||
const fetchedMap = fetched.map;
|
||||
const currentMapMatchesRequestedRange = chartRange() === requestedRange;
|
||||
if (fetchedMap.size > 0 || chartMap().size === 0 || !currentMapMatchesRequestedRange) {
|
||||
const cacheKey = inMemoryCacheKey(requestedRange);
|
||||
if (
|
||||
!inMemoryChartCache.has(cacheKey) &&
|
||||
inMemoryChartCache.size >= MAX_IN_MEMORY_INFRA_ENTRIES
|
||||
) {
|
||||
const oldest = inMemoryChartCache.keys().next().value;
|
||||
if (oldest !== undefined) inMemoryChartCache.delete(oldest);
|
||||
}
|
||||
inMemoryChartCache.set(cacheKey, fetchedMap);
|
||||
setChartMap(fetchedMap);
|
||||
setChartRange(requestedRange);
|
||||
}
|
||||
setOldestDataTimestamp(fetched.oldestDataTimestamp);
|
||||
setFetchFailed(false);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
if (requestId === activeFetchRequest) {
|
||||
setFetchFailed(true);
|
||||
if (chartRange() !== requestedRange) {
|
||||
const cached = readInfrastructureSummaryCache(requestedRange);
|
||||
if (cached) {
|
||||
setChartMap(cached.map);
|
||||
setChartRange(requestedRange);
|
||||
setOldestDataTimestamp(cached.oldestDataTimestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (activeFetchController === controller) {
|
||||
activeFetchController = null;
|
||||
}
|
||||
if (requestId === activeFetchRequest) {
|
||||
setLoadedRange(requestedRange);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const hasResources = props.resources.length > 0;
|
||||
const currentOrg = orgVersion();
|
||||
if (!hasResources) {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
if (activeFetchController) {
|
||||
activeFetchController.abort();
|
||||
activeFetchController = null;
|
||||
}
|
||||
activeScopeKey = null;
|
||||
setChartMap(new Map());
|
||||
setChartRange(null);
|
||||
setLoadedRange(null);
|
||||
setOldestDataTimestamp(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!refreshTimer) {
|
||||
refreshTimer = setInterval(() => void fetchCharts(), 30_000);
|
||||
}
|
||||
|
||||
const nextRange = selectedRange();
|
||||
const nextScopeKey = `${currentOrg}::${nextRange}`;
|
||||
if (activeScopeKey !== nextScopeKey) {
|
||||
activeScopeKey = nextScopeKey;
|
||||
const inMemoryCached = inMemoryChartCache.get(inMemoryCacheKey(nextRange));
|
||||
if (inMemoryCached && inMemoryCached.size > 0) {
|
||||
setChartMap(inMemoryCached);
|
||||
setChartRange(nextRange);
|
||||
setLoadedRange(nextRange);
|
||||
const cached = readInfrastructureSummaryCache(nextRange);
|
||||
setOldestDataTimestamp(cached?.oldestDataTimestamp ?? null);
|
||||
} else {
|
||||
setChartMap(new Map());
|
||||
setChartRange(null);
|
||||
setLoadedRange(null);
|
||||
setOldestDataTimestamp(null);
|
||||
}
|
||||
setFetchFailed(false);
|
||||
void fetchCharts({ prioritize: true });
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
if (activeFetchController) {
|
||||
activeFetchController.abort();
|
||||
activeFetchController = null;
|
||||
}
|
||||
unsubscribeOrgSwitch();
|
||||
});
|
||||
|
||||
const resourceSeries = createMemo(() =>
|
||||
hasCurrentRangeCharts()
|
||||
? buildInfrastructureSummarySeries(props.resources, chartMap(), agentResources())
|
||||
: buildInfrastructureSummarySeries(props.resources, new Map(), agentResources()),
|
||||
);
|
||||
|
||||
const displaySeries = createMemo(() => {
|
||||
const focused = props.focusedResourceId;
|
||||
const allSeries = resourceSeries();
|
||||
if (!focused) return allSeries;
|
||||
const match = allSeries.find((series) => series.id === focused);
|
||||
return match ? [match] : allSeries;
|
||||
});
|
||||
|
||||
const focusedResourceName = createMemo(() => {
|
||||
const focused = props.focusedResourceId;
|
||||
if (!focused) return null;
|
||||
const match = resourceSeries().find((series) => series.id === focused);
|
||||
return match?.name ?? null;
|
||||
});
|
||||
|
||||
const singleDisplayedOnlineResource = createMemo(() => {
|
||||
if (displaySeries().length !== 1 || props.resources.length !== 1) return null;
|
||||
const [resource] = props.resources;
|
||||
if (!resource) return null;
|
||||
return resource.status?.toLowerCase() === 'online' ? resource : null;
|
||||
});
|
||||
|
||||
const isAwaitingFirstSample = createMemo(() => {
|
||||
const resource = singleDisplayedOnlineResource();
|
||||
if (!resource || !isCurrentRangeLoaded() || fetchFailed()) return false;
|
||||
|
||||
const oldest = oldestDataTimestamp();
|
||||
if (oldest === null) return true;
|
||||
return resource.lastSeen >= oldest;
|
||||
});
|
||||
|
||||
const emptyHistoryLabel = createMemo(() =>
|
||||
isAwaitingFirstSample() ? 'Waiting for first sample' : 'No history yet',
|
||||
);
|
||||
|
||||
const hasData = (metric: InfrastructureSummarySparkMetric) =>
|
||||
displaySeries().some((series) => series[metric].length >= 1);
|
||||
|
||||
const networkSeries = createMemo(() =>
|
||||
displaySeries().map((series) => ({
|
||||
id: series.id,
|
||||
data: series.network,
|
||||
color: series.color,
|
||||
name: series.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const hasNetData = createMemo(() => displaySeries().some((series) => series.network.length >= 1));
|
||||
|
||||
const diskioSeries = createMemo(() =>
|
||||
displaySeries().map((series) => ({
|
||||
id: series.id,
|
||||
data: series.diskio,
|
||||
color: series.color,
|
||||
name: series.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const hasDiskIOData = createMemo(() =>
|
||||
displaySeries().some((series) => series.diskio.length >= 1),
|
||||
);
|
||||
|
||||
const avgDiskCapacity = createMemo(() => getAverageDiskCapacity(props.resources));
|
||||
const shouldShowNetworkCard = createMemo(
|
||||
() => hasNetData() || hasInfrastructureNetworkCapability(props.resources),
|
||||
);
|
||||
const workloadStats = createMemo(() => buildInfrastructureWorkloadStats(workloads()));
|
||||
const resourceCounts = createMemo(() => buildInfrastructureResourceCounts(props.resources));
|
||||
const emptyMessage = createMemo(() =>
|
||||
fetchFailed() ? 'Trend data unavailable' : emptyHistoryLabel(),
|
||||
);
|
||||
const seriesFor = (metric: InfrastructureSummarySparkMetric) =>
|
||||
displaySeries().map((series) => ({
|
||||
id: series.id,
|
||||
data: series[metric],
|
||||
color: series.color,
|
||||
name: series.name,
|
||||
}));
|
||||
|
||||
return {
|
||||
selectedRange,
|
||||
isCurrentRangeLoaded,
|
||||
emptyHistoryLabel,
|
||||
emptyMessage,
|
||||
focusedResourceName,
|
||||
resourceCounts,
|
||||
workloadStats,
|
||||
avgDiskCapacity,
|
||||
shouldShowNetworkCard,
|
||||
networkSeries,
|
||||
hasNetData,
|
||||
diskioSeries,
|
||||
hasDiskIOData,
|
||||
hasData,
|
||||
seriesFor,
|
||||
};
|
||||
}
|
||||
|
||||
export type InfrastructureSummaryState = ReturnType<typeof useInfrastructureSummaryState>;
|
||||
@@ -246,6 +246,8 @@ import resourceDetailDrawerShellSource from '@/components/Infrastructure/Resourc
|
||||
import resourceDetailDrawerOverviewSource from '@/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx?raw';
|
||||
import resourceDetailDrawerDebugSource from '@/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx?raw';
|
||||
import infrastructureSummarySource from '@/components/Infrastructure/InfrastructureSummary.tsx?raw';
|
||||
import infrastructureSummaryStateSource from '@/components/Infrastructure/useInfrastructureSummaryState.ts?raw';
|
||||
import infrastructureSummaryModelSource from '@/components/Infrastructure/infrastructureSummaryModel.ts?raw';
|
||||
import resourceDetailMappersSource from '@/components/Infrastructure/resourceDetailMappers.ts?raw';
|
||||
import resourceDetailDrawerHistoryStateSource from '@/components/Infrastructure/useResourceDetailDrawerHistoryState.ts?raw';
|
||||
import resourceDetailDrawerDerivedStateSource from '@/components/Infrastructure/useResourceDetailDrawerDerivedState.ts?raw';
|
||||
@@ -854,8 +856,12 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(useWorkloadsSource).not.toContain('const normalizeOrgScope =');
|
||||
expect(useUnifiedResourcesSource).toContain('normalizeOrgScope(getOrgID())');
|
||||
expect(useUnifiedResourcesSource).not.toContain('const normalizeOrgScope =');
|
||||
expect(infrastructureSummarySource).toContain('normalizeOrgScope(getOrgID())');
|
||||
expect(infrastructureSummarySource).not.toContain("getOrgID() || 'default'");
|
||||
expect(infrastructureSummarySource).toContain('useInfrastructureSummaryState');
|
||||
expect(infrastructureSummarySource).not.toContain('fetchInfrastructureSummaryAndCache');
|
||||
expect(infrastructureSummarySource).not.toContain('setInterval(');
|
||||
expect(infrastructureSummarySource).not.toContain('AbortController');
|
||||
expect(infrastructureSummaryStateSource).toContain('normalizeOrgScope(getOrgID())');
|
||||
expect(infrastructureSummaryStateSource).not.toContain("getOrgID() || 'default'");
|
||||
expect(storageSummarySource).toContain('normalizeOrgScope(getOrgID())');
|
||||
expect(storageSummarySource).not.toContain("getOrgID() || 'default'");
|
||||
expect(guestRowSource).toContain('useGuestRowState');
|
||||
@@ -2581,14 +2587,14 @@ describe('frontend resource type boundaries', () => {
|
||||
expect(aiChatSource).not.toContain('const normalizeMentionKeyPart =');
|
||||
expect(chatIdentifiersSource).toContain('normalizeChatMentionKeyPart');
|
||||
expect(chatIdentifiersSource).toContain('normalizeChatToolName');
|
||||
expect(infrastructureSummarySource).toContain('getNormalizedIdentityLookupVariants');
|
||||
expect(infrastructureSummaryModelSource).toContain('getNormalizedIdentityLookupVariants');
|
||||
expect(infrastructureSummaryTableSource).toContain('getNormalizedIdentityLookupVariants');
|
||||
expect(resourceIdentitySource).toContain('getNormalizedIdentityLookupVariants');
|
||||
expect(stringUtilsSource).toContain('export const asTrimmedString');
|
||||
expect(resourceIdentitySource).not.toContain(
|
||||
'const asTrimmedString = (value: unknown): string | undefined => {',
|
||||
);
|
||||
expect(infrastructureSummarySource).not.toContain(
|
||||
expect(infrastructureSummaryModelSource).not.toContain(
|
||||
'const asTrimmedString = (value: unknown): string | null => {',
|
||||
);
|
||||
expect(infrastructureSummaryTableSource).not.toContain(
|
||||
|
||||
Reference in New Issue
Block a user