Stabilize infrastructure resource hydration

This commit is contained in:
rcourtman
2026-05-14 10:28:16 +01:00
parent 127389cd6f
commit c8a52377a0
13 changed files with 252 additions and 35 deletions
@@ -556,6 +556,11 @@ profile and assignment columns, but embedded table framing must route through
come from the explicit nested `fleet` objects on `/api/connections`; Settings
surfaces may rank or compact those signals, but they must not reconstruct
them from table copy, status badge labels, or provider-local error strings.
The connections ledger is a retained-value settings query: polling or manual
reloads must preserve the last fulfilled connected-systems rows while the
next `/api/connections` request is in flight, and must not route the
Infrastructure settings table through app-level Suspense or a blank loading
replacement.
The lifecycle-owned command-policy projection must preserve desired server
policy and applied agent report truth as separate facts. Desired disabled
with applied enabled, and desired enabled with applied disabled, are both
@@ -350,6 +350,13 @@ the canonical monitored-system blocked payload.
1. Add or change payload fields through handler + contract tests together
2. Update frontend API types in lockstep with backend contract changes.
Websocket-backed API consumers such as `frontend-modern/src/components/Settings/useAPITokenManagerState.ts` and `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` may read runtime context only through `frontend-modern/src/contexts/appRuntime.ts`; they must not import `frontend-modern/src/App.tsx`, because payload ownership remains in the API contract rather than the root shell.
2a. Route settings infrastructure connected-system ledgers through
`/api/connections` and `frontend-modern/src/components/Settings/useConnectionsLedger.ts`
together. The frontend ledger may retain the last fulfilled connection
snapshot while polling or manual reload is in flight, but that retention is
only a fetch lifecycle rule; it must not synthesize rows, downgrade backend
fleet state, or replace the shared connection projection with page-local
placeholders.
3. Add dedicated contract tests for new stable payloads
3a. Route diagnostics payload fields and user-facing diagnostics copy through
`internal/api/diagnostics.go`,
@@ -639,6 +639,10 @@ prompt explain the same operator-facing priority.
backend-authored cluster members, the table primitive must render those
nodes as child composition beneath the cluster row rather than flattening
them back into peer top-level systems or hiding them entirely.
The same table shell must keep fulfilled rows visible across polling and
manual reloads by using a retained-value query boundary, not app-level
Suspense or a blank loading replacement, so configured infrastructure does
not disappear while the next `/api/connections` request is in flight.
The systems table and setup summary must count the same visible posture
highlights they render, not hidden raw fleet signals. Passive attached-agent
config or rollout handshakes whose only cause is a missing comparable
@@ -1732,6 +1736,11 @@ owns infrastructure route/deep-link synchronization. Future feature
surfaces under `frontend-modern/src/features/` should follow that same pattern
instead of letting page files accumulate route sync, filter, and modal
orchestration inline.
The infrastructure feature state owner may opt into websocket-first unified
resource hydration only when it also schedules canonical REST revalidation
after the first-paint settle window; shared route composition must not re-route
the table through a blocking resource fetch just to confirm infrastructure that
the realtime store has already reported.
Infrastructure summary and detail surfaces now also use the shared normalized
identity lookup helper from `frontend-modern/src/utils/resourceIdentity.ts`
so dotted hostnames and alias variants stay consistent between the shared
@@ -683,6 +683,13 @@ REST fetch. Route-owned trend loading must also key off stable target identity
and selected range only; reconciled resource snapshots that do not change the
effective target set must not trigger duplicate infrastructure or storage chart
requests.
Infrastructure route first paint belongs to that same freshness discipline:
when websocket `state.resources` already carries the connected-system snapshot,
the route must keep that model visible immediately and revalidate richer REST
details only after the first-paint settle window instead of making the operator
wait on a blocking resource fetch before Pulse admits what is connected, or
forcing a second resource-shape transition while the summary/table shell is
still mounting.
That same protected metrics-store boundary now also owns selected-series batch
queries. Compact route consumers that request only CPU/memory or only storage
`used`/`avail` capacity must keep that metric-type filter all the way through
@@ -370,6 +370,11 @@ bypass the API fail-closed execution gate.
recovery consumers must also wait for the first canonical REST snapshot
instead of painting thinner websocket transport rows first and then
rehydrating into a richer canonical shape a moment later.
Websocket-first unified-resource hydration is an explicit consumer opt-in,
not the storage/recovery default. Infrastructure may use that opt-in for
connected-system continuity only with delayed canonical REST revalidation;
storage and recovery routes must continue to require canonical REST first
unless their own contract is updated with equivalent shape-stability proof.
Shared chart transports in `internal/api/router.go` must follow the same
rule in mock mode: `/api/storage-charts` and adjacent infrastructure chart
payloads must read through `GetUnifiedReadStateOrSnapshot()` so storage and
@@ -305,8 +305,13 @@ AI-only summary payloads, or page-local heuristics.
refreshes. For default
`initialHydration: 'immediate'` consumers, that same path must not paint the
thinner websocket transport before the first canonical REST snapshot exists;
only explicit `prefer-ws` consumers may render directly from the realtime
transport before canonical hydrate completes.
only explicit websocket-first consumers may render directly from the realtime
transport before canonical hydrate completes. Operator surfaces that must
preserve already-known infrastructure continuity after login, such as the
Infrastructure page, must use websocket-first hydration with stale-cache
REST revalidation after the first-paint settle window so the page can paint
from live state immediately without forcing a second resource-shape
transition while summary and table surfaces are still mounting.
Canonical cluster membership in that shared path must come only from
explicit cluster identity such as Kubernetes context or platform cluster
labels; standalone resource names must never be repurposed as synthetic
@@ -33,6 +33,7 @@ import infrastructureOperationsModelSource from '../infrastructureOperationsMode
import infrastructureSourceManagerSource from '../InfrastructureSourceManager.tsx?raw';
import infrastructureSourcePickerSource from '../InfrastructureSourcePicker.tsx?raw';
import infrastructureWorkspaceModelSource from '../infrastructureWorkspaceModel.ts?raw';
import useConnectionsLedgerSource from '../useConnectionsLedger.ts?raw';
import agentProfileSettingsSource from '../agentProfileSettings.ts?raw';
import connectionsTableSource from '../ConnectionsTable.tsx?raw';
import monitoredSystemImpactPreviewSource from '../MonitoredSystemImpactPreview.tsx?raw';
@@ -591,6 +592,9 @@ describe('settings architecture guardrails', () => {
expect(settingsHeaderMetaSource).toContain(
"description: 'Configure the public URL, CORS, embedding, and webhook network boundaries.'",
);
expect(useConnectionsLedgerSource).toContain('createNonSuspendingQuery');
expect(useConnectionsLedgerSource).toContain('pollMs: POLL_INTERVAL_MS');
expect(useConnectionsLedgerSource).not.toContain('createResource');
});
it('keeps the detect-first editor and inline credential bodies on the shared editor model', () => {
@@ -1,13 +1,26 @@
import { renderHook, waitFor } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ConnectionsAPI, type Connection, type ConnectionSystem } from '@/api/connections';
import { resetCreateNonSuspendingQueryCacheForTest } from '@/hooks/createNonSuspendingQuery';
import { useConnectionsLedger } from '../useConnectionsLedger';
import useConnectionsLedgerSource from '../useConnectionsLedger.ts?raw';
describe('useConnectionsLedger', () => {
beforeEach(() => {
resetCreateNonSuspendingQueryCacheForTest();
});
afterEach(() => {
resetCreateNonSuspendingQueryCacheForTest();
vi.restoreAllMocks();
});
it('keeps connection-ledger refreshes out of app-level Suspense', () => {
expect(useConnectionsLedgerSource).toContain('createNonSuspendingQuery');
expect(useConnectionsLedgerSource).toContain('pollMs: POLL_INTERVAL_MS');
expect(useConnectionsLedgerSource).not.toContain('createResource');
});
it('renders standalone agent rows with compact host identity and endpoint context', async () => {
const connections: Connection[] = [
{
@@ -126,6 +139,58 @@ describe('useConnectionsLedger', () => {
expect(result.rows()[119]).toBe(firstRows[119]);
});
it('retains the fulfilled ledger while a reload is in flight', async () => {
const firstConnection: Connection = {
id: 'agent:tower',
type: 'agent',
name: 'Tower',
address: 'Tower',
state: 'active',
stateReason: '',
enabled: true,
surfaces: ['host'],
scope: { host: true },
lastSeen: '2026-04-23T12:00:00Z',
lastError: null,
source: 'agent',
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
};
const nextConnection: Connection = {
...firstConnection,
id: 'agent:pi',
name: 'pi',
address: 'pi',
};
let resolveReload:
| ((value: { connections: Connection[]; systems: ConnectionSystem[] }) => void)
| undefined;
vi.spyOn(ConnectionsAPI, 'list')
.mockResolvedValueOnce({ connections: [firstConnection], systems: [] })
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveReload = resolve;
}),
);
const { result } = renderHook(() => useConnectionsLedger());
await waitFor(() => expect(result.rows()).toHaveLength(1));
const firstRows = result.rows();
expect(firstRows[0]?.name).toBe('Tower');
result.reload();
await waitFor(() => expect(result.loading()).toBe(true));
expect(result.rows()).toBe(firstRows);
expect(result.rows()[0]?.name).toBe('Tower');
resolveReload?.({ connections: [nextConnection], systems: [] });
await waitFor(() => expect(result.loading()).toBe(false));
await waitFor(() => expect(result.rows()[0]?.name).toBe('pi'));
});
it('prioritizes explicit rollout drift, credential, and command-policy posture', async () => {
const connections: Connection[] = [
{
@@ -1,4 +1,5 @@
import { createEffect, createMemo, createResource, onCleanup } from 'solid-js';
import { createMemo } from 'solid-js';
import { createNonSuspendingQuery } from '@/hooks/createNonSuspendingQuery';
import { formatConnectionErrorMessage } from '@/utils/connectionErrorPresentation';
import {
ConnectionsAPI,
@@ -24,6 +25,7 @@ import {
export { surfaceLabel };
const POLL_INTERVAL_MS = 15000;
const CONNECTIONS_LEDGER_QUERY_KEY = 'settings-infrastructure-connections';
export const CONNECTION_TYPE_LABELS: Record<ConnectionType, string> = {
pve: 'Proxmox VE',
@@ -443,6 +445,11 @@ interface ConnectionsLedgerSnapshot {
systems: ConnectionSystem[];
}
const EMPTY_CONNECTIONS_LEDGER_SNAPSHOT: ConnectionsLedgerSnapshot = {
connections: [],
systems: [],
};
export const useConnectionsLedger = (): ConnectionsLedger => {
const rowCache = new Map<string, CachedInfrastructureSystemRow>();
@@ -474,30 +481,21 @@ export const useConnectionsLedger = (): ConnectionsLedger => {
}
};
const [resource, { refetch }] = createResource<ConnectionsLedgerSnapshot>(
async () => {
const ledgerSnapshot = createNonSuspendingQuery<ConnectionsLedgerSnapshot, string>({
source: () => CONNECTIONS_LEDGER_QUERY_KEY,
fetcher: async () => {
const response = await ConnectionsAPI.list();
return {
connections: response.connections ?? [],
systems: response.systems ?? [],
};
},
{
initialValue: {
connections: [],
systems: [],
},
},
);
createEffect(() => {
const handle = window.setInterval(() => {
void refetch();
}, POLL_INTERVAL_MS);
onCleanup(() => window.clearInterval(handle));
initialValue: EMPTY_CONNECTIONS_LEDGER_SNAPSHOT,
cacheKey: (key) => key,
pollMs: POLL_INTERVAL_MS,
});
const snapshot = () => resource() ?? { connections: [], systems: [] };
const snapshot = () => ledgerSnapshot.value() ?? EMPTY_CONNECTIONS_LEDGER_SNAPSHOT;
const connections = () => snapshot().connections ?? [];
const rows = createMemo<InfrastructureSystemRow[]>(() => {
const allConnections = connections();
@@ -546,9 +544,9 @@ export const useConnectionsLedger = (): ConnectionsLedger => {
rows,
findById,
reload: () => {
void refetch();
void ledgerSnapshot.refetch();
},
loading: () => resource.loading,
error: () => resource.error,
loading: ledgerSnapshot.loading,
error: ledgerSnapshot.error,
};
};
@@ -21,6 +21,7 @@ describe('InfrastructurePageSurface guardrails', () => {
expect(infrastructurePageStateSource).toContain('useInfrastructurePageRouteState');
expect(infrastructurePageStateSource).toContain('buildInfrastructurePageFilterDerivation');
expect(infrastructurePageStateSource).toContain("initialHydration: 'prefer-ws-then-rest'");
expect(infrastructurePageStateSource).not.toContain('useLocation(');
expect(infrastructurePageStateSource).not.toContain('useNavigate(');
expect(infrastructurePageStateSource).not.toContain('parseInfrastructureLinkSearch(');
@@ -29,7 +29,9 @@ type DeployCluster = {
};
export function useInfrastructurePageState() {
const { resources, loading, error, refetch } = useUnifiedResources();
const { resources, loading, error, refetch } = useUnifiedResources({
initialHydration: 'prefer-ws-then-rest',
});
const kioskMode = useKioskMode();
const { isMobile } = useBreakpoint();
@@ -337,6 +337,67 @@ describe('useUnifiedResources', () => {
dispose();
});
it('uses an already-available websocket snapshot before the fallback REST timer for prefer-ws screens', async () => {
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({
query: '',
cacheKey: 'all-resources',
initialHydration: 'prefer-ws',
});
});
await waitForResourceCount(() => result!.resources().length);
expect(result!.resources()[0]?.id).toBe(wsResource.id);
expect(result!.loading()).toBe(false);
expect(apiFetchMock).not.toHaveBeenCalled();
dispose();
});
it('paints from websocket before revalidating prefer-ws-then-rest screens in the background', async () => {
let resolveFetch:
| ((value: { ok: true; json: () => Promise<{ data: Array<typeof v2Resource> }> }) => void)
| undefined;
apiFetchMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveFetch = resolve;
}),
);
let dispose = () => {};
let result: ReturnType<UseUnifiedResourcesModule['useUnifiedResources']> | undefined;
createRoot((d) => {
dispose = d;
result = useUnifiedResources({
initialHydration: 'prefer-ws-then-rest',
});
});
await waitForResourceCount(() => result!.resources().length);
await flushAsync();
expect(result!.resources()[0]?.id).toBe(wsResource.id);
expect(result!.loading()).toBe(false);
expect(apiFetchMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(15_250);
await flushAsync();
expect(apiFetchMock).toHaveBeenCalledTimes(1);
resolveFetch?.({
ok: true,
json: async () => ({ data: [{ ...v2Resource, name: 'node-rest' }] }),
});
await waitForValue(() => result!.resources()[0]?.displayName, 'node-rest');
expect(result!.loading()).toBe(false);
dispose();
});
it('waits for the first canonical REST snapshot before painting immediate hydration screens', async () => {
let resolveFetch:
| ((value: { ok: true; json: () => Promise<{ data: Array<typeof v2Resource> }> }) => void)
@@ -46,6 +46,8 @@ const UNIFIED_RESOURCES_CACHE_MAX_AGE_MS = 15_000;
const UNIFIED_RESOURCES_WS_DEBOUNCE_MS = 800;
const UNIFIED_RESOURCES_WS_MIN_REFETCH_INTERVAL_MS = 2_500;
const UNIFIED_RESOURCES_WS_INITIAL_HYDRATION_WAIT_MS = 1_200;
const UNIFIED_RESOURCES_WS_CANONICAL_REVALIDATE_DELAY_MS =
UNIFIED_RESOURCES_CACHE_MAX_AGE_MS + 250;
type APIMetricValue = {
value?: number;
@@ -1029,10 +1031,12 @@ export const getCachedUnifiedResources = (options?: {
return getUnifiedResourcesCacheEntry(scopedCacheKey).resources;
};
type UnifiedResourcesInitialHydration = 'immediate' | 'prefer-ws' | 'prefer-ws-then-rest';
type UseUnifiedResourcesOptions = {
query?: string;
cacheKey?: string;
initialHydration?: 'immediate' | 'prefer-ws';
initialHydration?: UnifiedResourcesInitialHydration;
enabled?: Accessor<boolean>;
};
@@ -1044,7 +1048,10 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
const typeFilter = parseUnifiedResourcesTypeFilter(query);
const supportsCanonicalWsHydration = query === '' || typeFilter !== null;
const prefersWsInitialHydration =
initialHydration === 'prefer-ws' && supportsCanonicalWsHydration;
(initialHydration === 'prefer-ws' || initialHydration === 'prefer-ws-then-rest') &&
supportsCanonicalWsHydration;
const revalidatesRestAfterWsInitialHydration =
initialHydration === 'prefer-ws-then-rest' && supportsCanonicalWsHydration;
const [orgScope, setOrgScope] = createSignal(normalizeOrgScope(getOrgID()));
const resolveScopedCacheKey = () => buildScopedUnifiedResourcesCacheKey(cacheKey, orgScope());
let cacheEntry = seedUnifiedResourcesCacheFromAllResources(
@@ -1066,6 +1073,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
const wsStore = getGlobalWebSocketStore();
let refreshHandle: ReturnType<typeof setTimeout> | undefined;
let initialHydrationHandle: ReturnType<typeof setTimeout> | undefined;
let canonicalRevalidationHandle: ReturnType<typeof setTimeout> | undefined;
let inFlightRefetch: Promise<Resource[]> | null = null;
let wsInitialized = false;
let lastWsUpdateToken = '';
@@ -1087,6 +1095,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
const runRefetch = async (options?: {
force?: boolean;
source?: 'initial' | 'ws' | 'manual';
background?: boolean;
}) => {
if (!enabled()) {
return resources as unknown as Resource[];
@@ -1097,13 +1106,14 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
const force = options?.force === true;
const source = options?.source ?? 'manual';
const background = options?.background === true;
if (!force && source === 'ws' && shouldThrottleWsRefetch(cacheEntry)) {
return resources as unknown as Resource[];
}
const shouldForceNetwork = force || source === 'ws';
const shouldShowLoading = force || !cacheEntry.hasSnapshot;
const shouldShowLoading = !background && (force || !cacheEntry.hasSnapshot);
if (shouldShowLoading) {
setLoading(true);
}
@@ -1126,7 +1136,9 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
});
return fetched;
} catch (err) {
setError(err);
if (!background) {
setError(err);
}
throw err;
} finally {
inFlightRefetch = null;
@@ -1168,11 +1180,18 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
}
};
const clearCanonicalRevalidationTimeout = () => {
if (canonicalRevalidationHandle !== undefined) {
clearTimeout(canonicalRevalidationHandle);
canonicalRevalidationHandle = undefined;
}
};
const shouldPreferWsInitialHydration = () =>
prefersWsInitialHydration &&
!cacheEntry.hasSnapshot &&
!wsStore.initialDataReceived() &&
(!Array.isArray(wsStore.state.resources) || wsStore.state.resources.length === 0);
prefersWsInitialHydration && !cacheEntry.hasSnapshot;
const hasWsInitialHydrationSnapshot = () =>
wsStore.connected() && wsStore.initialDataReceived() && Array.isArray(wsStore.state.resources);
const scheduleInitialHydrationFallback = () => {
if (initialHydrationHandle !== undefined) {
@@ -1207,11 +1226,29 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
}, delay);
};
const scheduleCanonicalRevalidation = () => {
if (canonicalRevalidationHandle !== undefined) {
return;
}
// Let websocket-first routes finish their initial summary/table mount
// before asking REST to enrich the thinner realtime snapshot.
canonicalRevalidationHandle = setTimeout(() => {
canonicalRevalidationHandle = undefined;
if (!enabled()) {
return;
}
void runRefetch({ source: 'initial', background: true }).catch((err) => {
logger.debug('[useUnifiedResources] Background canonical revalidation failed', err);
});
}, UNIFIED_RESOURCES_WS_CANONICAL_REVALIDATE_DELAY_MS);
};
createEffect(() => {
orgScope();
if (!enabled()) {
clearInitialHydrationTimeout();
clearCanonicalRevalidationTimeout();
clearRefreshTimeout();
setLoading(false);
return;
@@ -1223,7 +1260,9 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
}
if (shouldPreferWsInitialHydration()) {
scheduleInitialHydrationFallback();
if (!hasWsInitialHydrationSnapshot()) {
scheduleInitialHydrationFallback();
}
return;
}
@@ -1258,11 +1297,13 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
const wsResources = Array.isArray(wsStore.state.resources) ? wsStore.state.resources : [];
// For normal page loads, keep the first paint on the canonical REST contract.
// Only `prefer-ws` consumers are allowed to render directly from the thinner
// realtime transport before a canonical snapshot exists.
// Only explicit websocket-first consumers are allowed to render directly
// from the thinner realtime transport before a canonical snapshot exists.
if (!cacheEntry.hasSnapshot && !prefersWsInitialHydration) {
return;
}
const shouldRevalidateCanonicalSnapshot =
revalidatesRestAfterWsInitialHydration && !cacheEntry.hasSnapshot;
const allResourcesEntry = getUnifiedResourcesCacheEntry(
buildScopedUnifiedResourcesCacheKey(ALL_RESOURCES_CACHE_KEY, currentOrgScope),
);
@@ -1296,6 +1337,10 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
setError(undefined);
setLoading(false);
});
if (shouldRevalidateCanonicalSnapshot) {
scheduleCanonicalRevalidation();
}
});
createEffect(() => {
@@ -1305,6 +1350,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
wsInitialized = false;
lastWsUpdateToken = '';
clearInitialHydrationTimeout();
clearCanonicalRevalidationTimeout();
clearRefreshTimeout();
return;
}
@@ -1357,6 +1403,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
wsInitialized = false;
lastWsUpdateToken = '';
clearInitialHydrationTimeout();
clearCanonicalRevalidationTimeout();
const scopedResources = cacheEntry.resources;
const scopedPolicyPosture = cacheEntry.policyPosture;
@@ -1379,6 +1426,7 @@ export function useUnifiedResources(options?: UseUnifiedResourcesOptions) {
onCleanup(() => {
unsubscribeOrgSwitch();
clearInitialHydrationTimeout();
clearCanonicalRevalidationTimeout();
clearRefreshTimeout();
});