Guard Proxmox Patrol coverage before hydration

This commit is contained in:
rcourtman 2026-06-30 16:56:35 +01:00
parent f0c81de00e
commit 4ac224084e
2 changed files with 33 additions and 10 deletions

View file

@ -84,6 +84,7 @@ const PROXMOX_WORKLOAD_STATUS_OPTIONS: readonly WorkloadsStatusOption[] = [
const ProxmoxIcon = getPlatformIcon('proxmox');
const proxmoxIcon = () => <ProxmoxIcon class="h-6 w-6 text-slate-400" />;
const EMPTY_PROXMOX_PAGE_MODEL = buildProxmoxPageModel([]);
interface MonitorPatrolContext {
latestRun: PatrolRunRecord | null;
@ -111,7 +112,9 @@ export function ProxmoxPageSurface() {
cacheKey: 'proxmox-workspace',
initialHydration: 'prefer-ws-then-rest',
});
const model = createMemo(() => buildProxmoxPageModel(resources()));
const model = createMemo(() =>
buildProxmoxPageModel(Array.isArray(resources()) ? resources() : []),
);
// Replication jobs come straight from /api/replication/jobs (they bypass
// the unified-resource pipeline), so the surface owns the fetch: the job
// count gates the Replication tab and the same data feeds the table.
@ -287,6 +290,7 @@ interface ProxmoxOverviewProps {
}
function ProxmoxOverview(props: ProxmoxOverviewProps) {
const currentModel = createMemo(() => props.model?.() ?? EMPTY_PROXMOX_PAGE_MODEL);
const workloadsState = useWorkloadsState({
vms: [],
containers: [],
@ -315,8 +319,8 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
);
const filteredNodes = createMemo(() =>
filterProxmoxNodesForSearch(
props.model().pveNodes,
props.model().guests,
currentModel().pveNodes,
currentModel().guests,
workloadsState.search(),
),
);
@ -327,7 +331,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
getMonitorContextPatrolProtectionPosture({
findingCount: activePatrolFindings().length,
latestRun: props.patrolContext()?.latestRun ?? null,
monitoredResourceCount: props.model().resources.length,
monitoredResourceCount: currentModel().resources.length,
patrolStatus: props.patrolContext()?.status ?? null,
pendingApprovalCount: aiIntelligenceStore.patrolPendingApprovalCount,
workTypeComposition: getPatrolWorkTypeComposition(activePatrolFindings()),
@ -399,7 +403,7 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
</Show>
<ProxmoxNodesTable
nodes={filteredNodes()}
guests={props.model().guests}
guests={currentModel().guests}
metricDisplayMode={props.metricDisplayMode}
metricHistoryRange={props.metricHistoryRange}
emptyIcon={<ProxmoxIcon class="h-6 w-6 text-slate-400" />}
@ -423,26 +427,26 @@ function ProxmoxOverview(props: ProxmoxOverviewProps) {
/>
{/* v5 dashboard closed with a running/stopped totals strip; the counts
are platform-wide, matching the status-chip vocabulary above. */}
<Show when={props.model().summary.guestCount > 0}>
<Show when={currentModel().summary.guestCount > 0}>
<div
class="flex items-center gap-2 rounded border border-border bg-surface-alt px-2 py-1 text-xs text-muted"
data-testid="proxmox-guest-totals"
>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="success" ariaHidden />
{props.model().summary.runningGuestCount} running
{currentModel().summary.runningGuestCount} running
</span>
<Show when={props.model().summary.degradedGuestCount > 0}>
<Show when={currentModel().summary.degradedGuestCount > 0}>
<span aria-hidden="true">|</span>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="warning" ariaHidden />
{props.model().summary.degradedGuestCount} attention
{currentModel().summary.degradedGuestCount} attention
</span>
</Show>
<span aria-hidden="true">|</span>
<span class="flex items-center gap-1.5">
<StatusDot size="xs" variant="danger" ariaHidden />
{props.model().summary.stoppedGuestCount} stopped
{currentModel().summary.stoppedGuestCount} stopped
</span>
</div>
</Show>

View file

@ -43,6 +43,15 @@ const setResources = (resources: Resource[]) => {
});
};
const setResourcesSnapshot = (resources: Resource[] | undefined, loading = false) => {
mockUseUnifiedResources.mockReturnValue({
resources: () => resources as Resource[],
loading: () => loading,
error: () => null,
refetch: vi.fn(),
});
};
vi.mock('@/hooks/useUnifiedResources', () => ({
useUnifiedResources: (...args: unknown[]) => mockUseUnifiedResources(...args),
}));
@ -266,6 +275,16 @@ describe('ProxmoxPageSurface contract', () => {
).not.toBeInTheDocument();
});
it('does not crash when Patrol state refreshes before Proxmox resources hydrate', async () => {
setResourcesSnapshot(undefined, true);
render(() => <ProxmoxPageSurface />);
expect(screen.getByTestId('platform-table-loading-state')).toBeInTheDocument();
await waitFor(() => expect(mockLoadPatrolFindings).toHaveBeenCalled());
expect(screen.queryByRole('list', { name: 'Proxmox Patrol coverage' })).not.toBeInTheDocument();
});
it('does not render monitor-context Patrol coverage when active Patrol work exists', () => {
mockPatrolState.findings = [{ status: 'active' }];
setResources([