mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +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:
@@ -4,6 +4,8 @@ import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CircleHelp,
|
||||
HeartPulse,
|
||||
X,
|
||||
Minimize2,
|
||||
Terminal as TerminalIcon,
|
||||
@@ -13,7 +15,7 @@ import { DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { StructuredLogRow } from '@/components/log-rendering/StructuredLogRow';
|
||||
import TerminalComponent from '@/components/Terminal';
|
||||
import { useDeployFeedback, VERB_LABELS } from '@/context/DeployFeedbackContext';
|
||||
import { useDeployFeedback, VERB_LABELS, type HealthGateUiState } from '@/context/DeployFeedbackContext';
|
||||
|
||||
const AUTO_CLOSE_SECONDS = 4;
|
||||
|
||||
@@ -37,7 +39,7 @@ function formatElapsed(seconds: number): string {
|
||||
}
|
||||
|
||||
export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) {
|
||||
const { panelState, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback();
|
||||
const { panelState, healthGate, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback();
|
||||
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
@@ -110,8 +112,12 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
}, 1000);
|
||||
}, [clearCountdownInterval, onPanelClose]);
|
||||
|
||||
// Auto-close only when there is nothing left to watch: success with no gate,
|
||||
// or success whose gate passed. An observing gate suspends the countdown; a
|
||||
// failed/unknown gate keeps the modal open until the user closes it.
|
||||
const gateHoldsOpen = healthGate !== null && healthGate.status !== 'passed';
|
||||
useEffect(() => {
|
||||
if (status === 'succeeded' && isOpen) {
|
||||
if (status === 'succeeded' && isOpen && !gateHoldsOpen) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
startCountdown();
|
||||
} else {
|
||||
@@ -121,7 +127,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
return () => {
|
||||
clearCountdownInterval();
|
||||
};
|
||||
}, [status, isOpen, startCountdown, clearCountdownInterval]);
|
||||
}, [status, isOpen, gateHoldsOpen, startCountdown, clearCountdownInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userScrolledUp && scrollRef.current) {
|
||||
@@ -143,10 +149,10 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
autoCloseHoveredRef.current = false;
|
||||
if (status === 'succeeded' && isOpen) {
|
||||
if (status === 'succeeded' && isOpen && !gateHoldsOpen) {
|
||||
startCountdown();
|
||||
}
|
||||
}, [status, isOpen, startCountdown]);
|
||||
}, [status, isOpen, gateHoldsOpen, startCountdown]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -207,6 +213,8 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
rowCount={logRows.length}
|
||||
errorMessage={errorMessage}
|
||||
countdown={countdown}
|
||||
showCountdown={!gateHoldsOpen}
|
||||
gateStatus={healthGate?.status ?? null}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -229,6 +237,11 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Post-update health gate status */}
|
||||
{status === 'succeeded' && healthGate && (
|
||||
<HealthGateBanner gate={healthGate} />
|
||||
)}
|
||||
|
||||
{/* Stalled-output warning: in-flight but quiet */}
|
||||
{stalled && (
|
||||
<div
|
||||
@@ -319,15 +332,65 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
);
|
||||
}
|
||||
|
||||
// Re-renders every second via the elapsed-time interval, so the observing
|
||||
// elapsed count stays live without its own timer.
|
||||
function HealthGateBanner({ gate }: { gate: HealthGateUiState }) {
|
||||
const elapsed = gate.startedAt ? Math.max(0, Math.floor((Date.now() - gate.startedAt) / 1000)) : 0;
|
||||
const windowLabel = gate.windowSeconds ? ` of ${gate.windowSeconds}s` : '';
|
||||
|
||||
if (gate.status === 'observing') {
|
||||
return (
|
||||
<div data-testid="health-gate-banner" data-status="observing" className="flex items-start gap-2 px-4 py-2 border-b border-glass-border bg-card/40 shrink-0">
|
||||
<HeartPulse className="h-3.5 w-3.5 mt-0.5 shrink-0 text-brand" />
|
||||
<p className="min-w-0 text-xs text-muted-foreground">
|
||||
Health gate: observing containers ({elapsed}s{windowLabel}). Closing this panel does not stop the observation.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (gate.status === 'passed') {
|
||||
return (
|
||||
<div data-testid="health-gate-banner" data-status="passed" className="flex items-start gap-2 px-4 py-2 border-b border-success/30 bg-success/5 shrink-0">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mt-0.5 shrink-0 text-success" />
|
||||
<p className="min-w-0 text-xs text-success">
|
||||
Health gate passed: containers stayed healthy through the observation window.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (gate.status === 'failed') {
|
||||
return (
|
||||
<div data-testid="health-gate-banner" data-status="failed" className="flex items-start gap-2 px-4 py-2 border-b border-destructive/30 bg-destructive/5 shrink-0">
|
||||
<AlertCircle className="h-3.5 w-3.5 mt-0.5 shrink-0 text-destructive" />
|
||||
<p className="min-w-0 text-xs text-destructive">
|
||||
Health gate failed{gate.reason ? `: ${gate.reason}` : ''}. Rollback options are available on the stack.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div data-testid="health-gate-banner" data-status="unknown" className="flex items-start gap-2 px-4 py-2 border-b border-glass-border bg-card/40 shrink-0">
|
||||
<CircleHelp className="h-3.5 w-3.5 mt-0.5 shrink-0 text-muted-foreground" />
|
||||
<p className="min-w-0 text-xs text-muted-foreground">
|
||||
Health gate result is unknown{gate.reason ? `: ${gate.reason}` : ''}. Check the stack's containers directly.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
|
||||
progressUnavailable: boolean;
|
||||
rowCount: number;
|
||||
errorMessage?: string;
|
||||
countdown: number;
|
||||
/** False while a health gate is observing or terminal-unhealthy (no auto-close). */
|
||||
showCountdown: boolean;
|
||||
/** Active health gate status, or null when no gate was started. */
|
||||
gateStatus: 'observing' | 'passed' | 'failed' | 'unknown' | null;
|
||||
}
|
||||
|
||||
function StatusIndicator({ status, progressUnavailable, rowCount, errorMessage, countdown }: StatusIndicatorProps) {
|
||||
function StatusIndicator({ status, progressUnavailable, rowCount, errorMessage, countdown, showCountdown, gateStatus }: StatusIndicatorProps) {
|
||||
// While the deploy is still in flight (preparing/streaming) but the progress
|
||||
// socket is gone, the deploy keeps running server-side with no live output.
|
||||
if (progressUnavailable && (status === 'preparing' || status === 'streaming')) {
|
||||
@@ -358,11 +421,38 @@ function StatusIndicator({ status, progressUnavailable, rowCount, errorMessage,
|
||||
}
|
||||
|
||||
if (status === 'succeeded') {
|
||||
// The compose operation finished, but while a health gate is active the
|
||||
// verdict is not "succeeded" yet: withhold the green state until the gate
|
||||
// passes, and report a failed/unknown gate as the headline result.
|
||||
if (gateStatus === 'observing') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-brand" />
|
||||
<span>Verifying health</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (gateStatus === 'failed') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-destructive">
|
||||
<AlertCircle className="h-3 w-3 text-destructive" />
|
||||
<span>Health gate failed</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (gateStatus === 'unknown') {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CircleHelp className="h-3 w-3" />
|
||||
<span>Health unknown</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-success">
|
||||
<CheckCircle2 className="h-3 w-3 text-success" />
|
||||
<span>Succeeded</span>
|
||||
<span className="text-muted-foreground">closes in {countdown}s</span>
|
||||
{showCountdown && <span className="text-muted-foreground">closes in {countdown}s</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ import type { SectionId } from './settings/types';
|
||||
export default function EditorLayout() {
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { status: trivy } = useTrivyStatus();
|
||||
const { runWithLog, panelState, logRows } = useDeployFeedback();
|
||||
const { runWithLog, panelState, logRows, healthGate } = useDeployFeedback();
|
||||
|
||||
// The last live output line captured for a stack while its deploy-feedback
|
||||
// session is still streaming, used to enrich a failure record's diagnostics.
|
||||
@@ -118,7 +118,7 @@ export default function EditorLayout() {
|
||||
dismissActionResult,
|
||||
} = stackListState;
|
||||
|
||||
const { nodes, activeNode, setActiveNode } = useNodes();
|
||||
const { nodes, activeNode, setActiveNode, hasCapability } = useNodes();
|
||||
|
||||
// Mirror activeNode.id in a ref so async handlers (e.g. CreateStackDialog's
|
||||
// post-create handoff) can detect a node switch that happened mid-flight.
|
||||
@@ -193,11 +193,43 @@ export default function EditorLayout() {
|
||||
runWithLog,
|
||||
getLastDeployOutputLine,
|
||||
diffPreviewEnabled,
|
||||
hasUpdateGuard: hasCapability('update-guard'),
|
||||
});
|
||||
|
||||
// Wire the ref now that stackActions is available
|
||||
resetEditorStateRef.current = stackActions.resetEditorState;
|
||||
|
||||
// A failed health gate routes into the existing recovery affordance: record
|
||||
// a failure for the stack so RecoveryChip/RecoveryPanel offer the same
|
||||
// explicit, user-confirmed rollback as any failed operation. Keyed by gate
|
||||
// id so one verdict records exactly once.
|
||||
const handledGateRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!healthGate || healthGate.status !== 'failed' || handledGateRef.current === healthGate.gateId) return;
|
||||
const stackFile = stackListState.files.find(
|
||||
f => f.replace(/\.(yml|yaml)$/, '') === healthGate.stackName,
|
||||
);
|
||||
if (!stackFile) {
|
||||
// Do not mark handled: the files list may be mid-refresh, and the
|
||||
// effect's files dependency retries once it lands.
|
||||
console.warn('[HealthGate] no stack file matches failed gate for', healthGate.stackName);
|
||||
return;
|
||||
}
|
||||
handledGateRef.current = healthGate.gateId;
|
||||
stackListState.recordActionFailure(stackFile, {
|
||||
action: healthGate.trigger,
|
||||
rolledBack: false,
|
||||
errorMessage: `Health gate failed: ${healthGate.reason ?? 'containers did not stay healthy after the update'}`,
|
||||
startedAt: healthGate.startedAt ?? Date.now(),
|
||||
endedAt: Date.now(),
|
||||
failure: {
|
||||
reason: 'healthcheck_failed',
|
||||
label: 'Health gate failed',
|
||||
suggestion: 'Check the container logs; roll back if the previous version was healthy.',
|
||||
},
|
||||
});
|
||||
}, [healthGate, stackListState.files, stackListState.recordActionFailure]);
|
||||
|
||||
const buildMenuCtx = useSidebarContextMenu({
|
||||
stackListState,
|
||||
navState,
|
||||
|
||||
@@ -72,6 +72,17 @@ export type StackAction =
|
||||
*/
|
||||
export type RecoverableAction = Extract<StackAction, 'deploy' | 'update' | 'restart' | 'rollback'>;
|
||||
|
||||
/**
|
||||
* Server-side classification of a failed deploy/update: a cause headline and a
|
||||
* suggested next step. `reason` stays a plain string here; the backend owns the
|
||||
* category vocabulary and the UI only displays it.
|
||||
*/
|
||||
export interface FailureClassification {
|
||||
reason: string;
|
||||
label: string;
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal record of a failed stack operation, kept in memory per stack so the
|
||||
* recovery panel can offer safe next steps after an update/deploy fails or
|
||||
@@ -88,6 +99,9 @@ export interface StackActionResult {
|
||||
// session was streaming this stack at failure time; omitted otherwise so a
|
||||
// line from another stack/session never leaks into diagnostics.
|
||||
lastOutputLine?: string;
|
||||
// Classified cause + suggested next action from the failed response body,
|
||||
// when the backend (or the unreachable-node fallback) provided one.
|
||||
failure?: FailureClassification;
|
||||
}
|
||||
|
||||
export interface ContainerStatsEntry {
|
||||
|
||||
@@ -22,6 +22,18 @@ interface RecoveryActionsProps {
|
||||
variant?: 'inline' | 'list';
|
||||
}
|
||||
|
||||
// The classified cause + suggested next step, rendered by both recovery
|
||||
// surfaces above their action sets. Nothing renders without a classification.
|
||||
export function RecoveryClassification({ result }: { result: StackActionResult }) {
|
||||
if (!result.failure) return null;
|
||||
return (
|
||||
<div data-testid="recovery-classification">
|
||||
<p className="text-xs font-medium text-foreground">{result.failure.label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{result.failure.suggestion}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The recovery action set shared by the mobile inline panel and the desktop
|
||||
// chip popover, so retry/restart/rollback/refresh/copy have one implementation.
|
||||
export function RecoveryActions({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { AlertTriangle, ChevronDown, X } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '../ui/button';
|
||||
import { RecoveryActions } from './RecoveryActions';
|
||||
import { RecoveryActions, RecoveryClassification } from './RecoveryActions';
|
||||
import { capitalize, formatElapsed } from './recovery-format';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { StackActionResult } from './EditorView';
|
||||
@@ -64,6 +64,9 @@ export function RecoveryChip({
|
||||
{result.errorMessage ?? 'The operation did not complete.'}
|
||||
{result.rolledBack && ' · rolled back to previous version'}
|
||||
</p>
|
||||
<div className="mt-1.5">
|
||||
<RecoveryClassification result={result} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-glass-border p-1">
|
||||
<RecoveryActions
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, ChevronDown, X } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '../ui/button';
|
||||
import { RecoveryActions } from './RecoveryActions';
|
||||
import { RecoveryActions, RecoveryClassification } from './RecoveryActions';
|
||||
import { capitalize, formatElapsed } from './recovery-format';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { StackActionResult } from './EditorView';
|
||||
@@ -23,7 +25,9 @@ interface RecoveryPanelProps {
|
||||
// update/deploy/restart/rollback fails or stalls. Styled as a quiet card with a
|
||||
// thin destructive rail (the toast accent language) so it blends with the
|
||||
// surrounding detail rather than shouting; the desktop surface uses RecoveryChip
|
||||
// instead. The full failure output stays in the deploy modal.
|
||||
// instead. The error and the classified cause stay visible on the card; the
|
||||
// actions collapse behind one Take action menu so the card stays small on a
|
||||
// phone. The full failure output stays in the deploy modal.
|
||||
export function RecoveryPanel({
|
||||
stackName,
|
||||
result,
|
||||
@@ -36,6 +40,7 @@ export function RecoveryPanel({
|
||||
onRefreshState,
|
||||
onDismiss,
|
||||
}: RecoveryPanelProps) {
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const elapsed = formatElapsed(result.endedAt - result.startedAt);
|
||||
|
||||
return (
|
||||
@@ -60,6 +65,9 @@ export function RecoveryPanel({
|
||||
{result.errorMessage ?? 'The operation did not complete.'}
|
||||
{result.rolledBack && ' · rolled back to previous version'}
|
||||
</p>
|
||||
<div className="mt-1.5">
|
||||
<RecoveryClassification result={result} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -74,18 +82,29 @@ export function RecoveryPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 pl-2">
|
||||
<RecoveryActions
|
||||
stackName={stackName}
|
||||
result={result}
|
||||
activeNode={activeNode}
|
||||
backupInfo={backupInfo}
|
||||
canDeploy={canDeploy}
|
||||
onRetry={onRetry}
|
||||
onRestart={onRestart}
|
||||
onRollback={onRollback}
|
||||
onRefreshState={onRefreshState}
|
||||
/>
|
||||
<div className="mt-2.5 flex justify-end pl-2">
|
||||
<Popover open={actionsOpen} onOpenChange={setActionsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs">
|
||||
Take action
|
||||
<ChevronDown className="h-3 w-3 opacity-70" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-60 p-1" data-testid="recovery-actions-menu">
|
||||
<RecoveryActions
|
||||
variant="list"
|
||||
stackName={stackName}
|
||||
result={result}
|
||||
activeNode={activeNode}
|
||||
backupInfo={backupInfo}
|
||||
canDeploy={canDeploy}
|
||||
onRetry={onRetry}
|
||||
onRestart={onRestart}
|
||||
onRollback={onRollback}
|
||||
onRefreshState={onRefreshState}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react';
|
||||
import BashExecModal from '../BashExecModal';
|
||||
import LazyBoundary from '../LazyBoundary';
|
||||
import { PolicyBlockDialog } from '../stack/PolicyBlockDialog';
|
||||
import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog';
|
||||
import { DeleteStackDialog } from './DeleteStackDialog';
|
||||
import { UnsavedChangesDialog } from './UnsavedChangesDialog';
|
||||
import { StackAlertSheet } from '../StackAlertSheet';
|
||||
@@ -55,6 +56,7 @@ export function ShellOverlays({
|
||||
logViewerOpen, logContainer,
|
||||
stackMonitor, closeStackMonitor,
|
||||
policyBlock, setPolicyBlock, policyBypassing,
|
||||
updateReadiness, setUpdateReadiness,
|
||||
stackMisconfigScanId, setStackMisconfigScanId,
|
||||
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
|
||||
} = overlayState;
|
||||
@@ -102,6 +104,14 @@ export function ShellOverlays({
|
||||
initialTab={stackMonitor?.tab ?? 'alerts'}
|
||||
/>
|
||||
|
||||
{/* Pre-update readiness check */}
|
||||
<UpdateReadinessDialog
|
||||
open={updateReadiness !== null}
|
||||
stackName={updateReadiness?.stackName ?? ''}
|
||||
onCancel={() => setUpdateReadiness(null)}
|
||||
onProceed={() => updateReadiness?.proceed()}
|
||||
/>
|
||||
|
||||
{/* Pre-deploy policy block */}
|
||||
<PolicyBlockDialog
|
||||
open={policyBlock !== null}
|
||||
|
||||
@@ -34,23 +34,37 @@ function setup(over: Partial<Parameters<typeof RecoveryPanel>[0]> = {}) {
|
||||
return props;
|
||||
}
|
||||
|
||||
// The actions live behind the Take action menu so the mobile card stays small.
|
||||
function openActions() {
|
||||
fireEvent.click(screen.getByText('Take action'));
|
||||
}
|
||||
|
||||
describe('RecoveryPanel', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('shows the failed action title and error message', () => {
|
||||
it('shows the failed action title and error message on the card', () => {
|
||||
setup();
|
||||
expect(screen.getByText(/Update failed/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pull failed: connection reset/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRetry from the retry button', () => {
|
||||
it('keeps the actions collapsed behind the Take action menu', () => {
|
||||
setup();
|
||||
expect(screen.queryByText('Retry update')).not.toBeInTheDocument();
|
||||
openActions();
|
||||
expect(screen.getByText('Retry update')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRetry from the retry action', () => {
|
||||
const props = setup();
|
||||
openActions();
|
||||
fireEvent.click(screen.getByText('Retry update'));
|
||||
expect(props.onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides retry/restart/rollback without the deploy permission', () => {
|
||||
setup({ canDeploy: false, backupInfo: { exists: true, timestamp: 1 } });
|
||||
openActions();
|
||||
expect(screen.queryByText('Retry update')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Restart')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Roll back')).not.toBeInTheDocument();
|
||||
@@ -61,19 +75,23 @@ describe('RecoveryPanel', () => {
|
||||
|
||||
it('offers rollback only when a backup exists', () => {
|
||||
setup({ backupInfo: { exists: false, timestamp: null } });
|
||||
openActions();
|
||||
expect(screen.queryByText('Roll back')).not.toBeInTheDocument();
|
||||
setup({ backupInfo: { exists: true, timestamp: 123 } });
|
||||
fireEvent.click(screen.getAllByText('Take action')[1]);
|
||||
expect(screen.getByText('Roll back')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show a redundant restart button when the failed action was a restart', () => {
|
||||
setup({ result: { ...baseResult, action: 'restart' } });
|
||||
openActions();
|
||||
expect(screen.getByText('Retry restart')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Restart')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Restart', { exact: true })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies session-safe diagnostics including stack and error', () => {
|
||||
setup({ result: { ...baseResult, lastOutputLine: 'pulling app ...' } });
|
||||
openActions();
|
||||
fireEvent.click(screen.getByText('Copy details'));
|
||||
expect(copyToClipboard).toHaveBeenCalledTimes(1);
|
||||
const blob = vi.mocked(copyToClipboard).mock.calls[0][0];
|
||||
@@ -82,8 +100,47 @@ describe('RecoveryPanel', () => {
|
||||
expect(blob).toContain('Last output: pulling app ...');
|
||||
});
|
||||
|
||||
it('renders the failure classification on the card without opening the menu', () => {
|
||||
setup({
|
||||
result: {
|
||||
...baseResult,
|
||||
failure: {
|
||||
reason: 'port_conflict',
|
||||
label: 'Host port conflict',
|
||||
suggestion: 'Free the conflicting host port, then retry.',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('Host port conflict')).toBeInTheDocument();
|
||||
expect(screen.getByText('Free the conflicting host port, then retry.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no classification block without a failure field', () => {
|
||||
setup();
|
||||
expect(screen.queryByTestId('recovery-classification')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('includes the classification in copied diagnostics', () => {
|
||||
setup({
|
||||
result: {
|
||||
...baseResult,
|
||||
failure: {
|
||||
reason: 'env_missing',
|
||||
label: 'Missing environment variable',
|
||||
suggestion: 'Define the missing variable, then retry.',
|
||||
},
|
||||
},
|
||||
});
|
||||
openActions();
|
||||
fireEvent.click(screen.getByText('Copy details'));
|
||||
const blob = vi.mocked(copyToClipboard).mock.calls[0][0];
|
||||
expect(blob).toContain('Classified: Missing environment variable');
|
||||
expect(blob).toContain('Suggestion: Define the missing variable, then retry.');
|
||||
});
|
||||
|
||||
it('wires refresh and dismiss callbacks', () => {
|
||||
const props = setup();
|
||||
openActions();
|
||||
fireEvent.click(screen.getByText('Refresh'));
|
||||
fireEvent.click(screen.getByLabelText('Dismiss recovery panel'));
|
||||
expect(props.onRefreshState).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -88,6 +88,14 @@ export function useOverlayState() {
|
||||
const [policyBlock, setPolicyBlock] = useState<PolicyBlock | null>(null);
|
||||
const [policyBypassing, setPolicyBypassing] = useState(false);
|
||||
|
||||
// Pre-update readiness dialog. `proceed` runs the actual update when the
|
||||
// user confirms; opened by useStackActions.requestStackUpdate.
|
||||
const [updateReadiness, setUpdateReadiness] = useState<{
|
||||
stackName: string;
|
||||
stackFile: string;
|
||||
proceed: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const [stackMisconfigScanId, setStackMisconfigScanId] = useState<number | null>(null);
|
||||
|
||||
const [diffPreview, setDiffPreview] = useState<DiffPreview | null>(null);
|
||||
@@ -103,6 +111,7 @@ export function useOverlayState() {
|
||||
logViewerOpen, logContainer, openLogViewer, closeLogViewer,
|
||||
stackMonitor, openAlertSheet, openAutoHeal, closeStackMonitor,
|
||||
policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing,
|
||||
updateReadiness, setUpdateReadiness,
|
||||
stackMisconfigScanId, setStackMisconfigScanId,
|
||||
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
|
||||
} as const;
|
||||
|
||||
@@ -83,6 +83,8 @@ function makeOverlay(over: Partial<OverlayState> = {}): OverlayState {
|
||||
policyBlock: null,
|
||||
setPolicyBlock: vi.fn(),
|
||||
setPolicyBypassing: vi.fn(),
|
||||
updateReadiness: null,
|
||||
setUpdateReadiness: vi.fn(),
|
||||
setDiffPreview: vi.fn(),
|
||||
...over,
|
||||
} as unknown as OverlayState;
|
||||
@@ -96,6 +98,7 @@ function setup(over: {
|
||||
overlay?: Partial<OverlayState>;
|
||||
stackList?: Partial<StackListState>;
|
||||
getLastDeployOutputLine?: (stackName: string) => string | undefined;
|
||||
hasUpdateGuard?: boolean;
|
||||
} = {}) {
|
||||
const editorState = makeEditorState(over.editorState);
|
||||
const stackListState = makeStackListState(over.stackList);
|
||||
@@ -114,6 +117,7 @@ function setup(over: {
|
||||
runWithLog,
|
||||
getLastDeployOutputLine: over.getLastDeployOutputLine ?? (() => undefined),
|
||||
diffPreviewEnabled: false,
|
||||
hasUpdateGuard: over.hasUpdateGuard ?? false,
|
||||
}),
|
||||
);
|
||||
return { result, editorState, stackListState, overlayState };
|
||||
@@ -365,6 +369,75 @@ describe('useStackActions.attemptLeaveEditor (mobile back / nav guard)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStackActions update readiness routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
});
|
||||
|
||||
function routeUpdateOk() {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/update')) return Promise.resolve(new Response('', { status: 200 }));
|
||||
return Promise.resolve(new Response('[]', { status: 200 }));
|
||||
});
|
||||
}
|
||||
|
||||
it('opens the readiness dialog instead of posting when the node has update-guard', async () => {
|
||||
routeUpdateOk();
|
||||
const { result, overlayState } = setup({ hasUpdateGuard: true });
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(overlayState.setUpdateReadiness).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stackName: 'web', stackFile: 'web.yml' }),
|
||||
);
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes a sidebar/context-menu update through the readiness dialog too', async () => {
|
||||
routeUpdateOk();
|
||||
const { result, overlayState } = setup({ hasUpdateGuard: true });
|
||||
await act(async () => { await result.current.executeStackActionByFile('web.yml', 'update', 'update'); });
|
||||
expect(overlayState.setUpdateReadiness).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stackName: 'web', stackFile: 'web.yml' }),
|
||||
);
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs the shared update executor when the dialog proceeds', async () => {
|
||||
routeUpdateOk();
|
||||
const { result, overlayState, stackListState } = setup({ hasUpdateGuard: true });
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
const pending = vi.mocked(overlayState.setUpdateReadiness).mock.calls[0][0] as
|
||||
{ stackName: string; stackFile: string; proceed: () => void };
|
||||
expect(pending).not.toBeNull();
|
||||
await act(async () => { pending.proceed(); });
|
||||
expect(overlayState.setUpdateReadiness).toHaveBeenLastCalledWith(null);
|
||||
const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0]));
|
||||
expect(urls).toContain('/stacks/web/update');
|
||||
expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml');
|
||||
});
|
||||
|
||||
it('does nothing while the stack is busy, with or without the dialog', async () => {
|
||||
routeUpdateOk();
|
||||
const { result, overlayState } = setup({
|
||||
hasUpdateGuard: true,
|
||||
stackList: { isStackBusy: vi.fn().mockReturnValue(true) as never },
|
||||
});
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(overlayState.setUpdateReadiness).not.toHaveBeenCalled();
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates directly without the capability, from both entry points', async () => {
|
||||
routeUpdateOk();
|
||||
const { result, overlayState } = setup();
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
await act(async () => { await result.current.executeStackActionByFile('web.yml', 'update', 'update'); });
|
||||
expect(overlayState.setUpdateReadiness).not.toHaveBeenCalled();
|
||||
const updatePosts = vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]) === '/stacks/web/update');
|
||||
expect(updatePosts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStackActions recovery records', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
@@ -453,6 +526,67 @@ describe('useStackActions recovery records', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the server failure classification into the recovery record', async () => {
|
||||
const body = JSON.stringify({
|
||||
error: 'port is already allocated',
|
||||
rolledBack: false,
|
||||
failure: { reason: 'port_conflict', label: 'Host port conflict', suggestion: 'Free the port, then retry.' },
|
||||
});
|
||||
routeApi(500, body);
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
|
||||
'web.yml',
|
||||
expect.objectContaining({
|
||||
failure: { reason: 'port_conflict', label: 'Host port conflict', suggestion: 'Free the port, then retry.' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores a malformed failure field in the response body', async () => {
|
||||
routeApi(500, JSON.stringify({ error: 'boom', failure: { reason: 42 } }));
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
|
||||
'web.yml',
|
||||
expect.objectContaining({ failure: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('synthesizes a node_unreachable classification for a gateway 502 with no body', async () => {
|
||||
routeApi(502, 'Bad Gateway');
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
|
||||
'web.yml',
|
||||
expect.objectContaining({
|
||||
failure: expect.objectContaining({ reason: 'node_unreachable' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mislabel an unrelated JSON 503 as node_unreachable', async () => {
|
||||
routeApi(503, JSON.stringify({ error: 'maintenance window' }));
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
|
||||
'web.yml',
|
||||
expect.objectContaining({ failure: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('synthesizes node_unreachable for a docker_unavailable 503 without a classified body', async () => {
|
||||
routeApi(503, JSON.stringify({ error: 'daemon gone', code: 'docker_unavailable' }));
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
|
||||
'web.yml',
|
||||
expect.objectContaining({
|
||||
failure: expect.objectContaining({ reason: 'node_unreachable' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('records a rollback failure', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { useViewNavigationState } from './useViewNavigationState';
|
||||
import type { OverlayState } from './useOverlayState';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { ActionVerb } from '@/context/DeployFeedbackContext';
|
||||
import type { StackAction, RecoverableAction } from '../EditorView';
|
||||
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
|
||||
import type { NotificationItem } from '../../dashboard/types';
|
||||
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
|
||||
|
||||
@@ -15,15 +15,57 @@ interface RunResult {
|
||||
ok: boolean;
|
||||
errorMessage?: string;
|
||||
rolledBack?: boolean;
|
||||
/** Health gate run id from the success body, when the backend started one. */
|
||||
healthGateId?: string | null;
|
||||
}
|
||||
|
||||
/** healthGateId from a success body, or null when absent or unreadable. */
|
||||
const parseHealthGateId = async (response: Response): Promise<string | null> => {
|
||||
try {
|
||||
const body: unknown = await response.json();
|
||||
if (isRecord(body) && typeof body.healthGateId === 'string') return body.healthGateId;
|
||||
} catch (e) {
|
||||
// A success body should always parse; the warn surfaces a future
|
||||
// double-read bug instead of silently disabling the gate UI.
|
||||
console.warn('[HealthGate] could not read the success body:', e);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Sentinel stored in overlayState.pendingUnsavedLoad to mark that the pending
|
||||
// confirmation is a node switch (not a stack load). When the user confirms the
|
||||
// discard, discardAndLoadPending calls setActiveNode(targetNode) and skips the
|
||||
// stack-load branch.
|
||||
export const NODE_SWITCH_PENDING_TOKEN = '__node-switch-pending__';
|
||||
|
||||
type StackActionError = Error & { rolledBack?: boolean };
|
||||
type StackActionError = Error & { rolledBack?: boolean; failure?: FailureClassification };
|
||||
|
||||
// Fallback classification when the response never reached a Sencho backend
|
||||
// (proxy 502/504 for a dead remote, or a 503 with no classified body).
|
||||
const NODE_UNREACHABLE_FAILURE: FailureClassification = {
|
||||
reason: 'node_unreachable',
|
||||
label: 'Node or Docker unreachable',
|
||||
suggestion: 'Check that the node is online and Docker is running, then retry.',
|
||||
};
|
||||
|
||||
const UNREACHABLE_STATUSES: ReadonlySet<number> = new Set([502, 503, 504]);
|
||||
|
||||
const parseFailureClassification = (value: unknown): FailureClassification | undefined => {
|
||||
if (
|
||||
isRecord(value) &&
|
||||
typeof value.reason === 'string' &&
|
||||
typeof value.label === 'string' && value.label.trim() &&
|
||||
typeof value.suggestion === 'string' && value.suggestion.trim()
|
||||
) {
|
||||
return { reason: value.reason, label: value.label, suggestion: value.suggestion };
|
||||
}
|
||||
if (value !== undefined) {
|
||||
// Likely hub/node version skew or a mangled proxy body; the raw error
|
||||
// message still renders, only the classification panel is degraded.
|
||||
console.warn('Unrecognized failure classification shape in error response:', value);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type StackOpAction = 'deploy' | 'down' | 'restart' | 'stop' | 'start' | 'update';
|
||||
|
||||
@@ -66,6 +108,10 @@ interface UseStackActionsOptions {
|
||||
// is streaming that exact stack; used to enrich failure diagnostics safely.
|
||||
getLastDeployOutputLine: (stackName: string) => string | undefined;
|
||||
diffPreviewEnabled: boolean;
|
||||
// Active node advertises the update-guard capability, so manual updates show
|
||||
// the pre-update readiness dialog. Defaults to false: without the
|
||||
// capability, updates run directly with no dialog.
|
||||
hasUpdateGuard?: boolean;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -100,24 +146,40 @@ const stackOpInProgressMessage = (stackName: string, info: StackOpInProgressInfo
|
||||
return `${stackName} is already ${verb}${actor}.`;
|
||||
};
|
||||
|
||||
const parseStackActionError = (rawBody: string, fallback: string): StackActionError => {
|
||||
const parseStackActionError = (rawBody: string, fallback: string, status?: number): StackActionError => {
|
||||
let message = rawBody || fallback;
|
||||
let rolledBack = false;
|
||||
let failure: FailureClassification | undefined;
|
||||
let parsedCode: string | undefined;
|
||||
let bodyWasJson = false;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(rawBody);
|
||||
bodyWasJson = true;
|
||||
if (isRecord(parsed)) {
|
||||
if (typeof parsed.error === 'string' && parsed.error.trim()) {
|
||||
message = parsed.error;
|
||||
}
|
||||
rolledBack = parsed.rolledBack === true;
|
||||
failure = parseFailureClassification(parsed.failure);
|
||||
if (typeof parsed.code === 'string') parsedCode = parsed.code;
|
||||
}
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
|
||||
// A gateway-style status with no classified body means the request likely
|
||||
// never reached the owning node's backend; surface that as the cause. A 503
|
||||
// qualifies only when it is body-less (proxy generated) or the backend's own
|
||||
// docker_unavailable shape, so an unrelated future 503 is not mislabeled.
|
||||
if (!failure && status !== undefined && UNREACHABLE_STATUSES.has(status)) {
|
||||
const qualifies = status !== 503 || !bodyWasJson || parsedCode === 'docker_unavailable';
|
||||
if (qualifies) failure = { ...NODE_UNREACHABLE_FAILURE };
|
||||
}
|
||||
|
||||
const error = new Error(message) as StackActionError;
|
||||
error.rolledBack = rolledBack;
|
||||
error.failure = failure;
|
||||
return error;
|
||||
};
|
||||
|
||||
@@ -133,6 +195,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
runWithLog,
|
||||
getLastDeployOutputLine,
|
||||
diffPreviewEnabled,
|
||||
hasUpdateGuard = false,
|
||||
} = options;
|
||||
|
||||
const pendingStackLoadRef = useRef<string | null>(null);
|
||||
@@ -235,6 +298,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
startedAt: number,
|
||||
errorMessage: string | undefined,
|
||||
rolledBack: boolean,
|
||||
failure?: FailureClassification,
|
||||
) => {
|
||||
if (!isRecoverableAction(action)) return;
|
||||
stackListState.recordActionFailure(stackFile, {
|
||||
@@ -244,6 +308,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
lastOutputLine: getLastDeployOutputLine(stackName),
|
||||
failure,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -619,12 +684,17 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
return { ok: false, errorMessage: message };
|
||||
}
|
||||
}
|
||||
throw parseStackActionError(rawBody, 'Deploy failed');
|
||||
throw parseStackActionError(rawBody, 'Deploy failed', response.status);
|
||||
}
|
||||
overlayState.setPolicyBlock(null);
|
||||
toast.success(
|
||||
ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!',
|
||||
);
|
||||
const healthGateId = await parseHealthGateId(response);
|
||||
// With a health gate observing, the operation finishing is not the
|
||||
// final verdict yet; soften the toast so success is not claimed twice.
|
||||
if (healthGateId) {
|
||||
toast.info(ignorePolicy ? 'Stack deployed (policy bypassed). Verifying health...' : 'Stack deployed. Verifying health...');
|
||||
} else {
|
||||
toast.success(ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!');
|
||||
}
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
@@ -633,7 +703,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
/* ignore */
|
||||
}
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return { ok: true };
|
||||
return { ok: true, healthGateId };
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy:', error);
|
||||
if (previousStatus !== undefined)
|
||||
@@ -645,7 +715,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
? `${errorMessage} - automatically rolled back to previous version.`
|
||||
: errorMessage,
|
||||
);
|
||||
recordActionFailureFor(stackFile, stackName, 'deploy', startedAt, errorMessage, deployError.rolledBack === true);
|
||||
recordActionFailureFor(stackFile, stackName, 'deploy', startedAt, errorMessage, deployError.rolledBack === true, deployError.failure);
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
return { ok: false, errorMessage, rolledBack: deployError.rolledBack };
|
||||
}
|
||||
@@ -736,7 +806,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw parseStackActionError(rawBody, 'Rollback failed');
|
||||
throw parseStackActionError(rawBody, 'Rollback failed', res.status);
|
||||
}
|
||||
overlayState.setPolicyBlock(null);
|
||||
toast.success('Stack rolled back successfully.');
|
||||
@@ -758,7 +828,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Rollback failed';
|
||||
toast.error(msg);
|
||||
recordActionFailureFor(stackFile, stackName, 'rollback', startedAt, msg, false);
|
||||
recordActionFailureFor(stackFile, stackName, 'rollback', startedAt, msg, false,
|
||||
error instanceof Error ? (error as StackActionError).failure : undefined);
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
} finally {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
@@ -850,8 +921,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const actionError = parseStackActionError(errText, `${action} failed`);
|
||||
recordActionFailureFor(stackFile, stackName, action, startedAt, actionError.message, actionError.rolledBack === true);
|
||||
const actionError = parseStackActionError(errText, `${action} failed`, response.status);
|
||||
recordActionFailureFor(stackFile, stackName, action, startedAt, actionError.message, actionError.rolledBack === true, actionError.failure);
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
return {
|
||||
ok: false as const,
|
||||
@@ -860,11 +931,18 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
};
|
||||
}
|
||||
overlayState.setPolicyBlock(null);
|
||||
toast.success(successMessage);
|
||||
const healthGateId = await parseHealthGateId(response);
|
||||
// With a health gate observing, the operation finishing is not the
|
||||
// final verdict yet; soften the toast so success is not claimed twice.
|
||||
if (healthGateId && action === 'update') {
|
||||
toast.info('Stack updated. Verifying health...');
|
||||
} else {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
if (action === 'update') stackListState.fetchImageUpdates();
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return { ok: true as const };
|
||||
return { ok: true as const, healthGateId };
|
||||
} catch (err) {
|
||||
const message = (err as Error).message || `${action} failed`;
|
||||
recordActionFailureFor(stackFile, stackName, action, startedAt, message, false);
|
||||
@@ -923,11 +1001,33 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
}
|
||||
};
|
||||
|
||||
// Single entry point for every manual update trigger (toolbar, sidebar
|
||||
// context menu, recovery retry). With the update-guard capability it opens
|
||||
// the readiness dialog first; the dialog's proceed runs the same
|
||||
// runWithLog-backed executor either way, so there is exactly one update path.
|
||||
const requestStackUpdate = async (stackFile: string): Promise<void> => {
|
||||
if (stackListState.isStackBusy(stackFile)) return;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
const run = () => runStackAction(stackFile, 'update', 'update', 'running', 'Stack updated successfully!');
|
||||
if (hasUpdateGuard) {
|
||||
overlayState.setUpdateReadiness({
|
||||
stackName,
|
||||
stackFile,
|
||||
proceed: () => {
|
||||
overlayState.setUpdateReadiness(null);
|
||||
void run();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await run();
|
||||
};
|
||||
|
||||
const updateStack = async (e?: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
if (!stackListState.selectedFile) return;
|
||||
await runStackAction(stackListState.selectedFile, 'update', 'update', 'running', 'Stack updated successfully!');
|
||||
await requestStackUpdate(stackListState.selectedFile);
|
||||
};
|
||||
|
||||
const deleteStack = async (pruneVolumes: boolean) => {
|
||||
@@ -1016,13 +1116,20 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
endpoint: string,
|
||||
) => {
|
||||
if (stackListState.isStackBusy(stackFile)) return;
|
||||
// Updates route through the shared update path so the sidebar gets the
|
||||
// readiness dialog, the deploy-feedback modal, and the same failure
|
||||
// handling as the toolbar.
|
||||
if (action === 'update') {
|
||||
await requestStackUpdate(stackFile);
|
||||
return;
|
||||
}
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
const startedAt = Date.now();
|
||||
stackListState.setStackAction(stackFile, action);
|
||||
|
||||
if (action === 'stop') {
|
||||
stackListState.setOptimisticStatus(stackFile, 'exited');
|
||||
} else if (action === 'deploy' || action === 'restart' || action === 'update') {
|
||||
} else if (action === 'deploy' || action === 'restart') {
|
||||
stackListState.setOptimisticStatus(stackFile, 'running');
|
||||
}
|
||||
|
||||
@@ -1036,19 +1143,18 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
toast.error(stackOpInProgressMessage(stackName, inProgress));
|
||||
return;
|
||||
}
|
||||
if (action === 'deploy' || action === 'update') {
|
||||
if (action === 'deploy') {
|
||||
const blockedBy = tryOpenPolicyBlock(errText, stackName, stackFile, action);
|
||||
if (blockedBy) {
|
||||
toast.error(`${action === 'update' ? 'Update' : 'Deploy'} blocked by policy "${blockedBy}"`);
|
||||
toast.error(`Deploy blocked by policy "${blockedBy}"`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw parseStackActionError(errText, `${action} failed`);
|
||||
throw parseStackActionError(errText, `${action} failed`, response.status);
|
||||
}
|
||||
toast.success(`Stack ${action}ed successfully!`);
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
if (action === 'update') stackListState.fetchImageUpdates();
|
||||
if (action === 'deploy') {
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
@@ -1067,7 +1173,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
? `${msg} - automatically rolled back to previous version.`
|
||||
: msg,
|
||||
);
|
||||
recordActionFailureFor(stackFile, stackName, action, startedAt, msg, actionError.rolledBack === true);
|
||||
recordActionFailureFor(stackFile, stackName, action, startedAt, msg, actionError.rolledBack === true, actionError.failure);
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
} finally {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
@@ -1154,6 +1260,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
restartStack,
|
||||
serviceAction,
|
||||
updateStack,
|
||||
requestStackUpdate,
|
||||
deleteStack,
|
||||
attemptLeaveEditor,
|
||||
cancelPendingUnsavedLoad,
|
||||
|
||||
@@ -34,6 +34,10 @@ export function buildDiagnostics(
|
||||
? `available${backupInfo.timestamp ? ` (${new Date(backupInfo.timestamp).toISOString()})` : ''}`
|
||||
: 'none'}`,
|
||||
];
|
||||
if (result.failure) {
|
||||
lines.push(`Classified: ${result.failure.label}`);
|
||||
lines.push(`Suggestion: ${result.failure.suggestion}`);
|
||||
}
|
||||
if (result.lastOutputLine) lines.push(`Last output: ${result.lastOutputLine}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { render, screen, act } from '@testing-library/react';
|
||||
import { DeployFeedbackProvider, useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||
import { DeployFeedbackModal } from '../DeployFeedbackModal';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
// Lets a test simulate a mid-stream drop (onReady then onError) so the panel
|
||||
// reaches 'streaming' with progressUnavailable set.
|
||||
const ctl = vi.hoisted(() => ({ drop: false }));
|
||||
@@ -23,14 +26,16 @@ vi.mock('@/components/Terminal', () => {
|
||||
|
||||
// Resolver for the in-flight operation, assigned inside the run callback (async,
|
||||
// after render) so the test can leave it pending or settle it on demand.
|
||||
let resolveRun: ((r: { ok: boolean; errorMessage?: string }) => void) | null = null;
|
||||
let resolveRun: ((r: { ok: boolean; errorMessage?: string; healthGateId?: string | null }) => void) | null = null;
|
||||
// The runWithLog promise itself, so a test can await full result propagation.
|
||||
let runOuter: Promise<unknown> | null = null;
|
||||
|
||||
function Driver() {
|
||||
const { runWithLog } = useDeployFeedback();
|
||||
React.useEffect(() => {
|
||||
void runWithLog({ stackName: 'web', action: 'update' }, async (started) => {
|
||||
runOuter = runWithLog({ stackName: 'web', action: 'update' }, async (started) => {
|
||||
await started;
|
||||
return new Promise<{ ok: boolean; errorMessage?: string }>((res) => { resolveRun = res; });
|
||||
return new Promise<{ ok: boolean; errorMessage?: string; healthGateId?: string | null }>((res) => { resolveRun = res; });
|
||||
});
|
||||
}, [runWithLog]);
|
||||
return null;
|
||||
@@ -49,6 +54,193 @@ async function renderStreaming() {
|
||||
});
|
||||
}
|
||||
|
||||
type GateStatus = 'observing' | 'passed' | 'failed' | 'unknown';
|
||||
|
||||
function routeGateApi(responses: Array<{ id: string; status: GateStatus; reason?: string | null }>) {
|
||||
let call = 0;
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
if (!String(url).includes('/health-gate')) {
|
||||
return Promise.resolve(new Response('{}', { status: 200 }));
|
||||
}
|
||||
const r = responses[Math.min(call, responses.length - 1)];
|
||||
call += 1;
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
stack: 'web', id: r.id, status: r.status, trigger: 'update',
|
||||
reason: r.reason ?? null, windowSeconds: 90, startedAt: Date.now(), endedAt: null, containers: [],
|
||||
}), { status: 200 }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('DeployFeedbackModal health gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
resolveRun = null;
|
||||
ctl.drop = false;
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function succeedWithGate(gateId: string | null) {
|
||||
await renderStreaming();
|
||||
// The Terminal onReady effect flushes at the end of renderStreaming's act,
|
||||
// scheduling the 50ms handshake timer after that act's advance already
|
||||
// ran; fire it here so the run reaches its resolver.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(60); });
|
||||
expect(resolveRun).not.toBeNull();
|
||||
await act(async () => {
|
||||
resolveRun?.({ ok: true, healthGateId: gateId });
|
||||
await runOuter;
|
||||
});
|
||||
}
|
||||
|
||||
it('shows the observing banner and suspends auto-close while the gate observes', async () => {
|
||||
routeGateApi([{ id: 'gate-1', status: 'observing' }]);
|
||||
await succeedWithGate('gate-1');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'observing');
|
||||
expect(screen.queryByText(/closes in/)).toBeNull();
|
||||
// The verdict is withheld while observing: no green Succeeded yet.
|
||||
expect(screen.queryByText('Succeeded')).toBeNull();
|
||||
expect(screen.getByText('Verifying health')).toBeInTheDocument();
|
||||
// Far past the normal 4s auto-close: the modal must still be open.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
|
||||
expect(screen.getByTestId('deploy-feedback-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resumes the auto-close countdown once the gate passes', async () => {
|
||||
routeGateApi([{ id: 'gate-1', status: 'observing' }, { id: 'gate-1', status: 'passed' }]);
|
||||
await succeedWithGate('gate-1');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'observing');
|
||||
// The next 4s poll returns passed; the countdown then runs to auto-close.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(4_100); });
|
||||
expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'passed');
|
||||
expect(screen.getByText(/closes in/)).toBeInTheDocument();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
|
||||
// onPanelClose resets the panel (Radix may keep the dialog DOM mounted
|
||||
// briefly under fake timers, so assert on the reset, not the unmount).
|
||||
expect(screen.queryByText('Succeeded')).toBeNull();
|
||||
expect(screen.queryByTestId('health-gate-banner')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the modal open and shows the reason when the gate fails', async () => {
|
||||
routeGateApi([{ id: 'gate-1', status: 'failed', reason: 'container web-app-1 exited during observation' }]);
|
||||
await succeedWithGate('gate-1');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'failed');
|
||||
expect(screen.getByText(/exited during observation/)).toBeInTheDocument();
|
||||
// The headline indicator reports the gate verdict, never a green Succeeded.
|
||||
expect(screen.queryByText('Succeeded')).toBeNull();
|
||||
expect(screen.getAllByText(/Health gate failed/).length).toBeGreaterThan(0);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(20_000); });
|
||||
expect(screen.getByTestId('deploy-feedback-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives up with an unknown verdict after repeated poll failures', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
if (String(url).includes('/health-gate')) {
|
||||
return Promise.resolve(new Response('{"error":"boom"}', { status: 500 }));
|
||||
}
|
||||
return Promise.resolve(new Response('{}', { status: 200 }));
|
||||
});
|
||||
await succeedWithGate('gate-1');
|
||||
// Four strikes at the 4s poll cadence flip the gate to a client-side
|
||||
// unknown and stop the interval (gateHoldsOpen keeps the modal up).
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(17_000); });
|
||||
expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'unknown');
|
||||
expect(screen.getByText(/could not be retrieved/)).toBeInTheDocument();
|
||||
const callsAfterGiveUp = vi.mocked(apiFetch).mock.calls.length;
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(20_000); });
|
||||
expect(vi.mocked(apiFetch).mock.calls.length).toBe(callsAfterGiveUp);
|
||||
});
|
||||
|
||||
it('ignores a report for a different gate id', async () => {
|
||||
routeGateApi([{ id: 'some-other-gate', status: 'failed', reason: 'stale' }]);
|
||||
await succeedWithGate('gate-1');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
// The mismatched report never replaces the optimistic observing state.
|
||||
expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'observing');
|
||||
});
|
||||
|
||||
it('polls single-flight and latches the terminal verdict against a late response', async () => {
|
||||
// Hold each health-gate response open so we can release it deliberately and
|
||||
// count how many requests overlap.
|
||||
const release: Array<(body: object) => void> = [];
|
||||
let healthGateCalls = 0;
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
if (!String(url).includes('/health-gate')) {
|
||||
return Promise.resolve(new Response('{}', { status: 200 }));
|
||||
}
|
||||
healthGateCalls += 1;
|
||||
return new Promise<Response>((resolve) => {
|
||||
release.push((body) => resolve(new Response(JSON.stringify(body), { status: 200 })));
|
||||
});
|
||||
});
|
||||
|
||||
await succeedWithGate('gate-1');
|
||||
// The first poll is still pending; advancing several intervals must not
|
||||
// start a second one (single-flight), so no two responses can race.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(12_000); });
|
||||
expect(healthGateCalls).toBe(1);
|
||||
|
||||
// Release the first poll as a terminal passed; the latch stops the interval.
|
||||
await act(async () => {
|
||||
release[0]({
|
||||
stack: 'web', id: 'gate-1', status: 'passed', trigger: 'update',
|
||||
reason: null, windowSeconds: 90, startedAt: Date.now(), endedAt: null, containers: [],
|
||||
});
|
||||
});
|
||||
expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'passed');
|
||||
|
||||
// No further polls after a terminal verdict: a stale observing response can
|
||||
// never arrive to roll the UI back.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(12_000); });
|
||||
expect(healthGateCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('does not poll the gate or show the panel when deploy feedback is disabled', async () => {
|
||||
// Turning off the deploy progress panel opts out of the live gate UI: there
|
||||
// is no surface to render it on. The backend gate still runs and records
|
||||
// timeline events; only the in-browser verifying/recovery view is skipped.
|
||||
localStorage.setItem('sencho.deploy-feedback.enabled', 'false');
|
||||
await act(async () => {
|
||||
render(
|
||||
<DeployFeedbackProvider>
|
||||
<Driver />
|
||||
<DeployFeedbackModal isMinimized={false} onMinimize={() => {}} />
|
||||
</DeployFeedbackProvider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
expect(resolveRun).not.toBeNull();
|
||||
resolveRun?.({ ok: true, healthGateId: 'gate-1' });
|
||||
await runOuter;
|
||||
});
|
||||
expect(screen.queryByTestId('deploy-feedback-modal')).toBeNull();
|
||||
expect(screen.queryByTestId('health-gate-banner')).toBeNull();
|
||||
// No poll is ever issued for the gate even though the backend returned an id.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(12_000); });
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders no gate banner and auto-closes normally without a healthGateId', async () => {
|
||||
await succeedWithGate(null);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(screen.getByText('Succeeded')).toBeInTheDocument();
|
||||
expect(screen.getByText(/closes in/)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('health-gate-banner')).toBeNull();
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
|
||||
// onPanelClose resets the panel (Radix may keep the dialog DOM mounted
|
||||
// briefly under fake timers, so assert on the reset, not the unmount).
|
||||
expect(screen.queryByText('Succeeded')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeployFeedbackModal stalled-output warning', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -29,7 +29,7 @@ function SectionSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
type HostAlertFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'global_crash'>;
|
||||
type HostAlertFields = Pick<PatchableSettings, 'host_cpu_limit' | 'host_ram_limit' | 'host_disk_limit' | 'host_alert_suppression_mins' | 'global_crash' | 'health_gate_enabled' | 'health_gate_window_seconds'>;
|
||||
|
||||
const DEFAULT_HOST_ALERTS: HostAlertFields = {
|
||||
host_cpu_limit: DEFAULT_SETTINGS.host_cpu_limit,
|
||||
@@ -37,6 +37,8 @@ const DEFAULT_HOST_ALERTS: HostAlertFields = {
|
||||
host_disk_limit: DEFAULT_SETTINGS.host_disk_limit,
|
||||
host_alert_suppression_mins: DEFAULT_SETTINGS.host_alert_suppression_mins,
|
||||
global_crash: DEFAULT_SETTINGS.global_crash,
|
||||
health_gate_enabled: DEFAULT_SETTINGS.health_gate_enabled,
|
||||
health_gate_window_seconds: DEFAULT_SETTINGS.health_gate_window_seconds,
|
||||
};
|
||||
|
||||
export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
|
||||
@@ -56,6 +58,8 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
|
||||
if (settings.host_disk_limit !== baseline.host_disk_limit) n++;
|
||||
if (settings.host_alert_suppression_mins !== baseline.host_alert_suppression_mins) n++;
|
||||
if (settings.global_crash !== baseline.global_crash) n++;
|
||||
if (settings.health_gate_enabled !== baseline.health_gate_enabled) n++;
|
||||
if (settings.health_gate_window_seconds !== baseline.health_gate_window_seconds) n++;
|
||||
return n;
|
||||
}, [settings]);
|
||||
|
||||
@@ -89,6 +93,8 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
|
||||
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
|
||||
host_alert_suppression_mins: nodeData.host_alert_suppression_mins ?? DEFAULT_SETTINGS.host_alert_suppression_mins,
|
||||
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
||||
health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled,
|
||||
health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds,
|
||||
};
|
||||
setSettings(safe);
|
||||
serverSettingsRef.current = { ...safe };
|
||||
@@ -197,6 +203,30 @@ export function HostAlertsSection({ onDirtyChange }: HostAlertsSectionProps) {
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Update health gate">
|
||||
<SettingsField
|
||||
label="Observe health after updates"
|
||||
helper="After a stack deploy or update succeeds, watch its containers for the observation window and record a passed or failed verdict on the stack timeline. Observational only: nothing is restarted or rolled back automatically. On by default."
|
||||
>
|
||||
<TogglePill
|
||||
checked={settings.health_gate_enabled === '1'}
|
||||
onChange={(next) => onSettingChange('health_gate_enabled', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField
|
||||
label="Observation window"
|
||||
helper="How long to watch containers before declaring the update healthy. Raise it for stacks that take a while to settle. Default 90 seconds."
|
||||
>
|
||||
<NumberChip
|
||||
value={settings.health_gate_window_seconds || '90'}
|
||||
onChange={(v) => onSettingChange('health_gate_window_seconds', v)}
|
||||
suffix="s"
|
||||
min={15}
|
||||
max={600}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
{!readOnly && (
|
||||
<SettingsPrimaryButton onClick={saveSettings} disabled={isSaving || !hasChanges}>
|
||||
|
||||
@@ -60,14 +60,16 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('split section save payloads', () => {
|
||||
it('HostAlertsSection patches only host alert keys', async () => {
|
||||
it('HostAlertsSection patches only host alert and health gate keys', async () => {
|
||||
render(<HostAlertsSection />);
|
||||
const save = await screen.findByRole('button', { name: /save alerts/i });
|
||||
fireEvent.click(screen.getByRole('switch')); // global_crash
|
||||
fireEvent.click(screen.getAllByRole('switch')[0]); // global_crash
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
|
||||
expect(patchedKeys()).toEqual([
|
||||
'global_crash',
|
||||
'health_gate_enabled',
|
||||
'health_gate_window_seconds',
|
||||
'host_alert_suppression_mins',
|
||||
'host_cpu_limit',
|
||||
'host_disk_limit',
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface PatchableSettings {
|
||||
prune_on_update?: '0' | '1';
|
||||
reclaim_hero?: '0' | '1';
|
||||
snapshot_documentation?: '0' | '1';
|
||||
health_gate_enabled?: '0' | '1';
|
||||
health_gate_window_seconds?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
@@ -34,6 +36,8 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
prune_on_update: '1',
|
||||
reclaim_hero: '1',
|
||||
snapshot_documentation: '0',
|
||||
health_gate_enabled: '1',
|
||||
health_gate_window_seconds: '90',
|
||||
};
|
||||
|
||||
export type SectionId =
|
||||
|
||||
@@ -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