mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
feat: health-gated updates and rollback readiness (#1354)
* feat: classify stack deploy and update failures with suggested next actions Failed deploy and update responses now carry a failure classification (cause category, headline, and suggested next step) derived from the compose error output. The recovery panel and chip render the classification and include it in copied diagnostics, and gateway-style failures surface as a node-unreachable cause. * feat: add update and rollback readiness reports for stacks Before a manual update, Sencho now shows an advisory readiness verdict computed from the stored preflight result, open drift findings, live container health, the pending image change, the rollback backup slot, and node disk headroom. The Stack Dossier gains a rollback readiness section that states what a rollback can restore and explicitly discloses that volume and bind-mounted data are not covered. Toolbar and sidebar updates now share one update path, and admins can create a fleet snapshot from the readiness dialog before updating. Nodes that do not advertise the capability keep the direct update flow. * feat: observe stack health after updates with a post-deploy health gate After a deploy or update succeeds, Sencho now watches the stack for a configurable observation window and records a passed, failed, or unknown verdict: containers must stay running, healthchecks must report healthy, and restart loops or disappearing containers fail the gate. The deploy panel shows the observation live and holds off auto-closing until the verdict lands, a failed gate surfaces the existing recovery actions including rollback, and the stack timeline records update started and gate verdict events. Scheduled, webhook, bulk, and git-source updates are gated the same way; rollbacks and installs are deliberately not. The gate is observational only and can be tuned or disabled per node under host alert settings. * docs: document health-gated updates and rollback readiness New operator page covering the update readiness dialog, the post-update health gate and its settings, the rollback readiness disclosure, and classified failures, with cross-links from the atomic deployments and deploy progress pages. The API reference gains the readiness and health-gate endpoints, the healthGateId success field, and the failure classification schema on deploy and update error responses. * feat: withhold the success verdict while the health gate observes An update used to show a green Succeeded that a failed health gate then contradicted moments later. The deploy modal now reports Verifying health while the gate observes, shows success only when the gate passes, and makes a failed or unknown gate the headline result; success toasts soften to a verifying message while a gate runs. The mobile recovery card groups its actions behind one bottom-right Take action menu so it stays compact on a phone, with the classified cause still visible on the card. A successful image update now also counts as the last known-good marker in rollback readiness, and the docs gain screenshots of the readiness dialog, gate states, dossier section, and settings. * fix: harden log format strings and the env existence path check Log calls that interpolated the stack name into the console format string now use constant format strings with placeholder arguments, and envExists validates path containment inline at its filesystem access, matching the established patterns used elsewhere in the same files. * test: adapt deploy modal success specs to the post-deploy health gate The deploy feedback modal now withholds its success verdict while the health gate observes the new containers, showing "Verifying health" until the gate passes. The two success-path E2E tests waited for "Succeeded" within the gate's 90s default window and timed out. Shorten the observation window to the 15s minimum for these tests via the settings API, assert the verify-then-succeed sequence the modal actually renders, and restore the default window afterward so the test value does not leak into later runs. * fix: serialize health gate polling and harden gate observation Address race conditions in the post-update health gate found in review. Backend: the gate poller used setInterval, so a Docker observe slower than the 5s tick could overlap the next poll and corrupt the restart and missing-container accounting, and a wedged socket could leave a poll pending forever. Polling is now single-flight: each cycle self-schedules the next only after it settles, and the observe is bounded by an 8s timeout so a hung probe counts as a poll error and resolves the gate unknown after three in a row. Frontend: the gate poller could overlap requests, letting a slow earlier "observing" response overwrite an already-applied terminal verdict. It is now single-flight with a terminal latch, so a late response can never roll the UI back from passed or failed. Also reject a non-digit nodeId on the snapshot coverage route instead of letting parseInt coerce it, document that turning off the deploy progress panel opts out of the live gate UI while the gate still runs server-side, and add gate-coverage tests for the webhook, git source, and auto-update apply paths plus the new single-flight, observe-timeout, and recovery cases.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, CircleHelp, Database, X, type LucideIcon } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
// Mirrors the backend payload shape (the frontend never imports backend).
|
||||
type RollbackItemState = 'ready' | 'missing' | 'unknown' | 'not_covered';
|
||||
type RollbackOverall = 'ready' | 'partial' | 'not_ready';
|
||||
|
||||
interface RollbackReadinessItem {
|
||||
id: string;
|
||||
state: RollbackItemState;
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface RollbackReadinessReport {
|
||||
stack: string;
|
||||
computedAt: number;
|
||||
overall: RollbackOverall;
|
||||
items: RollbackReadinessItem[];
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle';
|
||||
|
||||
const OVERALL_META: Record<RollbackOverall, { label: string; tone: string }> = {
|
||||
ready: { label: 'ready', tone: 'border-success/40 bg-success/[0.06] text-success' },
|
||||
partial: { label: 'partial', tone: 'border-warning/40 bg-warning/[0.06] text-warning' },
|
||||
not_ready: { label: 'not ready', tone: 'border-destructive/40 bg-destructive/[0.06] text-destructive' },
|
||||
};
|
||||
|
||||
const STATE_META: Record<RollbackItemState, { icon: LucideIcon; tone: string }> = {
|
||||
ready: { icon: Check, tone: 'text-success' },
|
||||
missing: { icon: X, tone: 'text-destructive' },
|
||||
unknown: { icon: CircleHelp, tone: 'text-stat-subtitle' },
|
||||
not_covered: { icon: Database, tone: 'text-warning' },
|
||||
};
|
||||
|
||||
/**
|
||||
* "Would the existing rollback actually save me?" disclosure for the Stack
|
||||
* Dossier. Read-only; renders nothing while loading, on error, or when the
|
||||
* active node does not advertise the update-guard capability.
|
||||
*/
|
||||
export function RollbackReadinessSection({ stackName }: { stackName: string }) {
|
||||
const { activeNode, hasCapability } = useNodes();
|
||||
const nodeId = activeNode?.id;
|
||||
const enabled = hasCapability('update-guard');
|
||||
const [report, setReport] = useState<RollbackReadinessReport | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setReport(null);
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/rollback-readiness`);
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
console.warn('[RollbackReadiness] unavailable for %s:', stackName, res.status);
|
||||
return;
|
||||
}
|
||||
setReport(await res.json() as RollbackReadinessReport);
|
||||
} catch (e) {
|
||||
// Render-nothing on failure: the dossier remains fully usable without
|
||||
// this section. The warn keeps the cause findable in the console.
|
||||
if (!cancelled) console.warn('[RollbackReadiness] unavailable for %s:', stackName, e);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => { cancelled = true; };
|
||||
}, [stackName, nodeId, enabled]);
|
||||
|
||||
if (!enabled || !report) return null;
|
||||
|
||||
const overall = OVERALL_META[report.overall] ?? OVERALL_META.partial;
|
||||
|
||||
return (
|
||||
<section data-testid="dossier-rollback-readiness">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className={LABEL_CLASS}>rollback readiness</span>
|
||||
<span
|
||||
data-testid="rollback-overall"
|
||||
data-overall={report.overall}
|
||||
className={cn('rounded-md border px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide', overall.tone)}
|
||||
>
|
||||
{overall.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{report.items.map(item => {
|
||||
const meta = STATE_META[item.state] ?? STATE_META.unknown;
|
||||
const StateIcon = meta.icon;
|
||||
return (
|
||||
<div key={item.id} className="border-t border-muted py-2 first:border-t-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<StateIcon className={cn('h-3.5 w-3.5 shrink-0', meta.tone)} strokeWidth={1.5} />
|
||||
<span className="text-[12px] font-medium text-foreground/90">{item.label}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[12px] leading-relaxed text-foreground/80">{item.detail}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2, AlertCircle,
|
||||
TriangleAlert, CircleCheck,
|
||||
TriangleAlert, CircleCheck, HeartPulse, HeartCrack,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -36,6 +36,9 @@ const CATEGORY_ICON: Record<string, LucideIcon> = {
|
||||
image_update_applied: ArrowUp,
|
||||
drift_detected: TriangleAlert,
|
||||
drift_resolved: CircleCheck,
|
||||
update_started: ArrowUp,
|
||||
health_gate_passed: HeartPulse,
|
||||
health_gate_failed: HeartCrack,
|
||||
};
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
@@ -10,7 +10,9 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 } }) }));
|
||||
// hasCapability false keeps the rollback readiness section (tested in its own
|
||||
// file) out of these dossier-focused tests.
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 1 }, hasCapability: () => false }) }));
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('@/lib/download', () => ({ downloadTextFile: vi.fn() }));
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@/lib/dossierMarkdown';
|
||||
import type { AnatomyMarkdownInput } from '@/lib/anatomyMarkdown';
|
||||
import { computeDocDrift, type DocDriftFinding } from '@/lib/docDrift';
|
||||
import { RollbackReadinessSection } from './RollbackReadinessSection';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
interface StackDossierPanelProps {
|
||||
@@ -357,6 +358,8 @@ export default function StackDossierPanel({ stackName, anatomy, canEdit }: Stack
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<RollbackReadinessSection stackName={stackName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Check, CircleHelp, Info, ShieldAlert, TriangleAlert, Camera, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
|
||||
// Mirrors the backend payload shape (the frontend never imports backend).
|
||||
type ReadinessVerdict = 'ready' | 'ready_with_warnings' | 'review_required' | 'blocked' | 'unknown';
|
||||
type SignalStatus = 'ok' | 'warning' | 'attention' | 'blocked' | 'unknown';
|
||||
|
||||
interface ReadinessSignal {
|
||||
id: string;
|
||||
status: SignalStatus;
|
||||
title: string;
|
||||
detail: string;
|
||||
affectsVerdict: boolean;
|
||||
}
|
||||
|
||||
interface UpdateReadinessReport {
|
||||
stack: string;
|
||||
computedAt: number;
|
||||
verdict: ReadinessVerdict;
|
||||
signals: ReadinessSignal[];
|
||||
}
|
||||
|
||||
const FETCH_TIMEOUT_MS = 4_000;
|
||||
|
||||
const VERDICT_META: Record<ReadinessVerdict, { label: string; icon: LucideIcon; tone: string; line: string }> = {
|
||||
ready: {
|
||||
label: 'ready',
|
||||
icon: Check,
|
||||
tone: 'border-success/40 bg-success/[0.06] text-success',
|
||||
line: 'Nothing stands out; the update can proceed.',
|
||||
},
|
||||
ready_with_warnings: {
|
||||
label: 'ready with warnings',
|
||||
icon: Info,
|
||||
tone: 'border-info/40 bg-info/[0.06] text-info',
|
||||
line: 'The update can proceed; review the warnings below first.',
|
||||
},
|
||||
review_required: {
|
||||
label: 'review required',
|
||||
icon: TriangleAlert,
|
||||
tone: 'border-warning/40 bg-warning/[0.06] text-warning',
|
||||
line: 'Something needs a look before this update.',
|
||||
},
|
||||
blocked: {
|
||||
label: 'blocked',
|
||||
icon: ShieldAlert,
|
||||
tone: 'border-destructive/40 bg-destructive/[0.06] text-destructive',
|
||||
line: 'A blocker was found. Proceeding is likely to fail or be stopped by policy.',
|
||||
},
|
||||
unknown: {
|
||||
label: 'unknown',
|
||||
icon: CircleHelp,
|
||||
tone: 'border-muted bg-card/40 text-stat-subtitle',
|
||||
line: 'Readiness could not be fully verified; proceed with care.',
|
||||
},
|
||||
};
|
||||
|
||||
// The `?? unknown` fallbacks at the lookup sites are forward-compat guards: a
|
||||
// newer backend may send statuses this build does not know.
|
||||
const SIGNAL_META: Record<SignalStatus, { icon: LucideIcon; tone: string }> = {
|
||||
ok: { icon: Check, tone: 'text-success' },
|
||||
warning: { icon: Info, tone: 'text-info' },
|
||||
attention: { icon: TriangleAlert, tone: 'text-warning' },
|
||||
blocked: { icon: ShieldAlert, tone: 'text-destructive' },
|
||||
unknown: { icon: CircleHelp, tone: 'text-stat-subtitle' },
|
||||
};
|
||||
|
||||
const UNKNOWN_FALLBACK = (detail: string): UpdateReadinessReport => ({
|
||||
stack: '',
|
||||
computedAt: Date.now(),
|
||||
verdict: 'unknown',
|
||||
signals: [{
|
||||
id: 'readiness',
|
||||
status: 'unknown',
|
||||
title: 'Readiness check',
|
||||
detail,
|
||||
affectsVerdict: true,
|
||||
}],
|
||||
});
|
||||
|
||||
interface UpdateReadinessDialogProps {
|
||||
open: boolean;
|
||||
stackName: string;
|
||||
onCancel: () => void;
|
||||
/** Caller closes the dialog and starts the update. */
|
||||
onProceed: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-update readiness check. Advisory only: every verdict, including
|
||||
* blocked and unknown, keeps Proceed enabled; the scan-policy gate remains
|
||||
* the single hard block. A slow or failed readiness fetch degrades to an
|
||||
* unknown verdict so this dialog can never strand the update path.
|
||||
*/
|
||||
export function UpdateReadinessDialog({ open, stackName, onCancel, onProceed }: UpdateReadinessDialogProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const { isAdmin } = useAuth();
|
||||
const nodeId = activeNode?.id ?? null;
|
||||
|
||||
const [report, setReport] = useState<UpdateReadinessReport | null>(null);
|
||||
const [snapshotAt, setSnapshotAt] = useState<number | null>(null);
|
||||
const [snapshotKnown, setSnapshotKnown] = useState(false);
|
||||
const [snapshotFirst, setSnapshotFirst] = useState(false);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setReport(null);
|
||||
setSnapshotAt(null);
|
||||
setSnapshotKnown(false);
|
||||
setSnapshotFirst(false);
|
||||
setWorking(false);
|
||||
return;
|
||||
}
|
||||
setReport(null);
|
||||
const controller = new AbortController();
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, FETCH_TIMEOUT_MS);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/update-readiness`, { signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
const unreachable = res.status === 502 || res.status === 503 || res.status === 504;
|
||||
setReport(UNKNOWN_FALLBACK(unreachable
|
||||
? 'The node may be unreachable; readiness could not be verified.'
|
||||
: 'The readiness check failed; readiness could not be verified.'));
|
||||
return;
|
||||
}
|
||||
setReport(await res.json() as UpdateReadinessReport);
|
||||
} catch {
|
||||
// The cleanup abort (dialog closed) must not write a stale fake
|
||||
// verdict; only the 4s timer earns the timed-out wording.
|
||||
if (controller.signal.aborted && !timedOut) return;
|
||||
setReport(UNKNOWN_FALLBACK(timedOut
|
||||
? 'The readiness check did not respond in time.'
|
||||
: 'The readiness check could not be reached.'));
|
||||
}
|
||||
};
|
||||
void load();
|
||||
|
||||
// Snapshot coverage lives only in the hub database; merged client-side.
|
||||
const loadCoverage = async () => {
|
||||
if (!isAdmin || nodeId === null) return;
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/fleet/snapshots/coverage?nodeId=${nodeId}&stackName=${encodeURIComponent(stackName)}`,
|
||||
{ localOnly: true, signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.warn('[UpdateReadiness] snapshot coverage unavailable:', res.status);
|
||||
return;
|
||||
}
|
||||
const body = await res.json() as { latestAt: number | null };
|
||||
setSnapshotAt(body.latestAt);
|
||||
setSnapshotKnown(true);
|
||||
} catch (e) {
|
||||
// Coverage is supplemental; the dialog renders without the row.
|
||||
if (!controller.signal.aborted) {
|
||||
console.warn('[UpdateReadiness] snapshot coverage unavailable:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
void loadCoverage();
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, stackName, nodeId, isAdmin]);
|
||||
|
||||
const proceed = async () => {
|
||||
if (snapshotFirst) {
|
||||
setWorking(true);
|
||||
try {
|
||||
const res = await apiFetch('/fleet/snapshots', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({ description: `Pre-update snapshot: ${stackName}` }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let serverError = '';
|
||||
try {
|
||||
serverError = ((await res.json()) as { error?: string }).error ?? '';
|
||||
} catch { /* non-JSON body */ }
|
||||
toast.error(`The pre-update snapshot failed; the update was not started.${serverError ? ` ${serverError}` : ''}`);
|
||||
return;
|
||||
}
|
||||
toast.success('Fleet snapshot created.');
|
||||
} catch (e) {
|
||||
console.error('[UpdateReadiness] pre-update snapshot failed:', e);
|
||||
toast.error('The pre-update snapshot failed; the update was not started.');
|
||||
return;
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
onProceed();
|
||||
};
|
||||
|
||||
const verdict = report ? VERDICT_META[report.verdict] ?? VERDICT_META.unknown : null;
|
||||
const VerdictIcon = verdict?.icon;
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={(next) => { if (!next && !working) onCancel(); }} size="lg">
|
||||
<ModalHeader
|
||||
kicker={`${stackName.toUpperCase()} · UPDATE READINESS`}
|
||||
title="Ready to update?"
|
||||
description="A pre-update check of this stack's preflight, drift, containers, backup, and pending image change."
|
||||
/>
|
||||
<ModalBody>
|
||||
{!report || !verdict || !VerdictIcon ? (
|
||||
<div className="py-4 font-mono text-[11px] text-stat-subtitle">Checking readiness…</div>
|
||||
) : (
|
||||
<>
|
||||
<div data-testid="readiness-verdict" data-verdict={report.verdict} className={cn('rounded-lg border px-3 py-2.5', verdict.tone)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<VerdictIcon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-mono text-[11px] uppercase tracking-wide">{verdict.label}</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-[11px] leading-relaxed text-foreground/80">{verdict.line}</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-muted bg-card/40 px-3 py-1">
|
||||
{report.signals.map(signal => {
|
||||
const meta = SIGNAL_META[signal.status] ?? SIGNAL_META.unknown;
|
||||
const SignalIcon = meta.icon;
|
||||
return (
|
||||
<div key={signal.id} className="border-t border-muted py-2 first:border-t-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<SignalIcon className={cn('h-3.5 w-3.5 shrink-0', meta.tone)} strokeWidth={1.5} />
|
||||
<span className="text-[12px] font-medium text-foreground/90">{signal.title}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 pl-5 text-[12px] leading-relaxed text-foreground/80">{signal.detail}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{snapshotKnown && (
|
||||
<div className="border-t border-muted py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Camera className="h-3.5 w-3.5 shrink-0 text-stat-subtitle" strokeWidth={1.5} />
|
||||
<span className="text-[12px] font-medium text-foreground/90">Fleet snapshot</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] leading-relaxed text-foreground/80">
|
||||
{snapshotAt
|
||||
? `The most recent fleet snapshot covering this stack was taken ${formatTimeAgo(snapshotAt)}.`
|
||||
: 'No fleet snapshot covers this stack yet.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<label className="flex items-center gap-2 text-[12px] text-foreground/80">
|
||||
<Checkbox
|
||||
checked={snapshotFirst}
|
||||
onCheckedChange={(checked) => setSnapshotFirst(checked === true)}
|
||||
aria-label="Create a fleet snapshot before updating"
|
||||
/>
|
||||
Create a fleet snapshot before updating
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter
|
||||
secondary={
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={working}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
<Button
|
||||
size="sm"
|
||||
autoFocus
|
||||
disabled={working}
|
||||
data-testid="readiness-proceed"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void proceed();
|
||||
}}
|
||||
>
|
||||
{working ? 'Creating snapshot…' : 'Update now'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { RollbackReadinessSection } from '../RollbackReadinessSection';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
|
||||
const nodesState = {
|
||||
activeNode: { id: 1, type: 'local', name: 'local' },
|
||||
hasCapability: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => nodesState,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
type Overall = 'ready' | 'partial' | 'not_ready';
|
||||
|
||||
const report = (overall: Overall) => ({
|
||||
stack: 'web',
|
||||
computedAt: Date.now(),
|
||||
overall,
|
||||
items: [
|
||||
{ id: 'compose_source', state: 'ready', label: 'Previous compose file', detail: 'A backup is available to restore.' },
|
||||
{ id: 'volume_data', state: 'not_covered', label: 'Application data', detail: 'Named volumes and bind-mounted data are not included in file backups.' },
|
||||
],
|
||||
});
|
||||
|
||||
describe('RollbackReadinessSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
nodesState.hasCapability.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it.each(['ready', 'partial', 'not_ready'] as const)('renders the %s overall chip', async (overall) => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response(JSON.stringify(report(overall)), { status: 200 }));
|
||||
render(<RollbackReadinessSection stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByTestId('rollback-overall')).toHaveAttribute('data-overall', overall));
|
||||
});
|
||||
|
||||
it('always shows the application-data non-coverage disclosure', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response(JSON.stringify(report('ready')), { status: 200 }));
|
||||
render(<RollbackReadinessSection stackName="web" />);
|
||||
await waitFor(() => expect(screen.getByText('Application data')).toBeInTheDocument());
|
||||
expect(screen.getByText(/not included in file backups/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing without the update-guard capability and never fetches', () => {
|
||||
nodesState.hasCapability.mockReturnValue(false);
|
||||
const { container } = render(<RollbackReadinessSection stackName="web" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders nothing when the fetch fails', async () => {
|
||||
vi.mocked(apiFetch).mockRejectedValue(new Error('down'));
|
||||
const { container } = render(<RollbackReadinessSection stackName="web" />);
|
||||
await waitFor(() => expect(apiFetch).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { UpdateReadinessDialog } from '../UpdateReadinessDialog';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
|
||||
const nodesState = { activeNode: { id: 1, type: 'local', name: 'local' } };
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => nodesState,
|
||||
}));
|
||||
|
||||
const authState = { isAdmin: true };
|
||||
vi.mock('@/context/AuthContext', () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
type Verdict = 'ready' | 'ready_with_warnings' | 'review_required' | 'blocked' | 'unknown';
|
||||
|
||||
const report = (verdict: Verdict) => ({
|
||||
stack: 'web',
|
||||
computedAt: Date.now(),
|
||||
verdict,
|
||||
signals: [
|
||||
{ id: 'preflight', status: 'ok', title: 'Compose Doctor', detail: 'The last preflight passed.', affectsVerdict: true },
|
||||
],
|
||||
});
|
||||
|
||||
function routeApi(over: {
|
||||
readiness?: () => Promise<Response>;
|
||||
coverage?: () => Promise<Response>;
|
||||
snapshot?: () => Promise<Response>;
|
||||
} = {}) {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string, options?: { method?: string }) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/update-readiness')) {
|
||||
return (over.readiness ?? (() => Promise.resolve(new Response(JSON.stringify(report('ready')), { status: 200 }))))();
|
||||
}
|
||||
if (u.includes('/snapshots/coverage')) {
|
||||
return (over.coverage ?? (() => Promise.resolve(new Response(JSON.stringify({ latestAt: null }), { status: 200 }))))();
|
||||
}
|
||||
if (u.includes('/fleet/snapshots') && options?.method === 'POST') {
|
||||
return (over.snapshot ?? (() => Promise.resolve(new Response('{}', { status: 200 }))))();
|
||||
}
|
||||
return Promise.resolve(new Response('{}', { status: 200 }));
|
||||
});
|
||||
}
|
||||
|
||||
function setup(props: Partial<Parameters<typeof UpdateReadinessDialog>[0]> = {}) {
|
||||
const base = {
|
||||
open: true,
|
||||
stackName: 'web',
|
||||
onCancel: vi.fn(),
|
||||
onProceed: vi.fn(),
|
||||
...props,
|
||||
};
|
||||
render(<UpdateReadinessDialog {...base} />);
|
||||
return base;
|
||||
}
|
||||
|
||||
describe('UpdateReadinessDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.clearAllMocks();
|
||||
authState.isAdmin = true;
|
||||
});
|
||||
|
||||
const verdictCases: Array<{ verdict: Verdict; label: string }> = [
|
||||
{ verdict: 'ready', label: 'ready' },
|
||||
{ verdict: 'ready_with_warnings', label: 'ready with warnings' },
|
||||
{ verdict: 'review_required', label: 'review required' },
|
||||
{ verdict: 'blocked', label: 'blocked' },
|
||||
{ verdict: 'unknown', label: 'unknown' },
|
||||
];
|
||||
|
||||
it.each(verdictCases)('renders the $verdict verdict with Proceed enabled', async ({ verdict, label }) => {
|
||||
routeApi({ readiness: () => Promise.resolve(new Response(JSON.stringify(report(verdict)), { status: 200 })) });
|
||||
setup();
|
||||
await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toHaveAttribute('data-verdict', verdict));
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('readiness-proceed')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('degrades to unknown on a plain network failure, and stays non-blocking', async () => {
|
||||
routeApi({ readiness: () => Promise.reject(new Error('connection refused')) });
|
||||
const props = setup();
|
||||
await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toHaveAttribute('data-verdict', 'unknown'));
|
||||
expect(screen.getByText(/could not be reached/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('readiness-proceed'));
|
||||
await waitFor(() => expect(props.onProceed).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('reports a timed-out readiness check as unknown after the 4s timer aborts it', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string, options?: RequestInit) => {
|
||||
if (String(url).includes('/update-readiness')) {
|
||||
return new Promise((_, reject) => {
|
||||
options?.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')));
|
||||
});
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ latestAt: null }), { status: 200 }));
|
||||
});
|
||||
render(<UpdateReadinessDialog open stackName="web" onCancel={vi.fn()} onProceed={vi.fn()} />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(4_100); });
|
||||
expect(screen.getByTestId('readiness-verdict')).toHaveAttribute('data-verdict', 'unknown');
|
||||
expect(screen.getByText(/did not respond in time/)).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not paint a stale fallback verdict after closing mid-fetch and reopening', async () => {
|
||||
let call = 0;
|
||||
vi.mocked(apiFetch).mockImplementation((url: string, options?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/update-readiness')) {
|
||||
call += 1;
|
||||
if (call === 1) {
|
||||
// Hangs until the cleanup abort rejects it.
|
||||
return new Promise((_, reject) => {
|
||||
options?.signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('Aborted', 'AbortError')));
|
||||
});
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify(report('ready')), { status: 200 }));
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify({ latestAt: null }), { status: 200 }));
|
||||
});
|
||||
|
||||
const props = { stackName: 'web', onCancel: vi.fn(), onProceed: vi.fn() };
|
||||
const { rerender } = render(<UpdateReadinessDialog open {...props} />);
|
||||
rerender(<UpdateReadinessDialog open={false} {...props} />);
|
||||
rerender(<UpdateReadinessDialog open {...props} />);
|
||||
await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toHaveAttribute('data-verdict', 'ready'));
|
||||
});
|
||||
|
||||
it('marks a remote 502 as unreachable and unknown', async () => {
|
||||
routeApi({ readiness: () => Promise.resolve(new Response('Bad Gateway', { status: 502 })) });
|
||||
setup();
|
||||
await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toHaveAttribute('data-verdict', 'unknown'));
|
||||
expect(screen.getByText(/may be unreachable/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes onProceed without a snapshot when the checkbox is unchecked', async () => {
|
||||
routeApi();
|
||||
const props = setup();
|
||||
await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId('readiness-proceed'));
|
||||
await waitFor(() => expect(props.onProceed).toHaveBeenCalledTimes(1));
|
||||
const snapshotPosts = vi.mocked(apiFetch).mock.calls.filter(
|
||||
c => String(c[0]) === '/fleet/snapshots' && (c[1] as { method?: string } | undefined)?.method === 'POST',
|
||||
);
|
||||
expect(snapshotPosts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('creates a snapshot before proceeding when checked', async () => {
|
||||
routeApi();
|
||||
const props = setup();
|
||||
await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByLabelText('Create a fleet snapshot before updating'));
|
||||
fireEvent.click(screen.getByTestId('readiness-proceed'));
|
||||
await waitFor(() => expect(props.onProceed).toHaveBeenCalledTimes(1));
|
||||
const snapshotPosts = vi.mocked(apiFetch).mock.calls.filter(
|
||||
c => String(c[0]) === '/fleet/snapshots' && (c[1] as { method?: string } | undefined)?.method === 'POST',
|
||||
);
|
||||
expect(snapshotPosts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('halts the update when the pre-update snapshot fails', async () => {
|
||||
routeApi({ snapshot: () => Promise.resolve(new Response('{"error":"boom"}', { status: 500 })) });
|
||||
const props = setup();
|
||||
await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByLabelText('Create a fleet snapshot before updating'));
|
||||
fireEvent.click(screen.getByTestId('readiness-proceed'));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(props.onProceed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides the snapshot checkbox and coverage row from non-admins', async () => {
|
||||
authState.isAdmin = false;
|
||||
routeApi();
|
||||
setup();
|
||||
await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument());
|
||||
expect(screen.queryByLabelText('Create a fleet snapshot before updating')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Fleet snapshot')).not.toBeInTheDocument();
|
||||
const coverageCalls = vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]).includes('/snapshots/coverage'));
|
||||
expect(coverageCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows the hub snapshot coverage row for admins', async () => {
|
||||
routeApi({ coverage: () => Promise.resolve(new Response(JSON.stringify({ latestAt: Date.now() - 60_000 }), { status: 200 })) });
|
||||
setup();
|
||||
await waitFor(() => expect(screen.getByText('Fleet snapshot')).toBeInTheDocument());
|
||||
expect(screen.getByText(/most recent fleet snapshot/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('wires Cancel', async () => {
|
||||
routeApi();
|
||||
const props = setup();
|
||||
await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Cancel'));
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user