feat: detect stalled stack updates and add in-app recovery actions (#1347)

* feat: detect stalled stack updates and add in-app recovery actions

Add a backend idle-output backstop that stops a deploy/update compose step
that has gone silent (SENCHO_COMPOSE_STALL_TIMEOUT_MS, default 10m), so a
hung image pull surfaces a fast failure instead of spinning indefinitely.

Surface failed, timed-out, and stalled operations with recovery actions on
the stack page: a desktop chip plus popover menu and an inline mobile card
offering retry, restart, roll back (when a backup exists), refresh state,
and copy diagnostics, all gated by deploy permission. The streaming
deploy/update progress modal is now on by default and warns when output
goes quiet. Container state is refreshed after a failed or stalled
operation, and the UI never sits in an indefinite spinner.

* fix: harden rollback against policy-blocked file mutation and refine recovery

Address review findings on the stalled-update recovery work:

- The rollback route restored backup files before running the policy gate, so
  a policy-blocked rollback could leave the on-disk config rolled back while the
  deployed containers were unchanged. Snapshot the current files first and
  revert them when the gate blocks; if that revert itself fails, escalate it on
  the persistent alert feed since the 409 is already sent.
- Refresh container state after a successful manual rollback (rollback
  redeploys), without mis-recording a refetch failure as a rollback failure.
- Suppress the stalled-output warning once live progress is unavailable.

* test: mock snapshotStackFiles in the atomic-deploy rollback route tests

The rollback route now snapshots stack files before restoring a backup, so its
FileSystemService mock needs snapshotStackFiles. Without it the mocked call
threw and the route returned 500, failing the success-path rollback assertions.
This commit is contained in:
Anso
2026-06-10 10:12:24 -04:00
committed by GitHub
parent a3033a848e
commit d369b03a38
31 changed files with 1580 additions and 76 deletions
@@ -3,6 +3,7 @@ import {
Loader2,
CheckCircle2,
AlertCircle,
AlertTriangle,
X,
Minimize2,
Terminal as TerminalIcon,
@@ -16,6 +17,11 @@ import { useDeployFeedback, VERB_LABELS } from '@/context/DeployFeedbackContext'
const AUTO_CLOSE_SECONDS = 4;
// Warn that an in-flight operation has gone quiet after this much silence. The
// backend idle-output timeout terminates a truly hung step later (default
// ~10min); this earlier heads-up keeps the modal from looking falsely busy.
const STALL_WARN_MS = 75_000;
interface DeployFeedbackModalProps {
isMinimized: boolean;
onMinimize: () => void;
@@ -31,7 +37,7 @@ function formatElapsed(seconds: number): string {
}
export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) {
const { panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback();
const { panelState, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback();
const [showRaw, setShowRaw] = useState(false);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
@@ -152,6 +158,15 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
const isDialogOpen = isOpen && !isMinimized;
const verbLabel = VERB_LABELS[action];
// Re-evaluated each second by the elapsed-time interval's re-render. Warns
// while the operation is still streaming but has produced no output for a
// while, including the case where no first line ever arrived. Suppressed once
// the progress stream is unavailable: no more output can arrive, so the modal
// already shows "Live progress unavailable" and a stall warning would be noise.
const lastLine = logRows.length > 0 ? logRows[logRows.length - 1].message : null;
const secondsSinceOutput = lastOutputAt > 0 ? Math.floor((Date.now() - lastOutputAt) / 1000) : 0;
const stalled = status === 'streaming' && !progressUnavailable && lastOutputAt > 0 && Date.now() - lastOutputAt > STALL_WARN_MS;
return (
<Modal
open={isDialogOpen}
@@ -214,6 +229,28 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
</div>
</div>
{/* Stalled-output warning: in-flight but quiet */}
{stalled && (
<div
data-testid="deploy-feedback-stalled"
className="flex items-start gap-2 px-4 py-2 border-b border-warning/30 bg-warning/5 shrink-0"
>
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0 text-warning" />
<div className="min-w-0 text-xs text-warning">
<p>
{lastLine
? `No new output for ${formatElapsed(secondsSinceOutput)}. The operation may be stalled.`
: 'No output received yet. The operation may be stalled.'}
</p>
{lastLine && (
<p className="mt-0.5 truncate font-mono text-[11px] text-warning/80" title={lastLine}>
{lastLine}
</p>
)}
</div>
</div>
)}
{/* Body */}
<div
ref={scrollRef}
+30 -1
View File
@@ -52,7 +52,19 @@ import type { SectionId } from './settings/types';
export default function EditorLayout() {
const { isAdmin, can } = useAuth();
const { status: trivy } = useTrivyStatus();
const { runWithLog, panelState } = useDeployFeedback();
const { runWithLog, panelState, logRows } = 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.
// Guards on both the streaming status and an exact stack-name match, so
// neither another stack's output nor a finished session's stale line leaks in.
const getLastDeployOutputLine = useCallback(
(forStack: string): string | undefined =>
panelState.status === 'streaming' && panelState.stackName === forStack
? logRows.at(-1)?.message
: undefined,
[panelState.status, panelState.stackName, logRows],
);
const editorState = useEditorViewState();
const {
@@ -101,6 +113,9 @@ export default function EditorLayout() {
isCollapsed, toggleCollapse,
remoteSearchLoading,
remoteSearchFailedNodes,
lastActionResult,
clearActionRecords,
dismissActionResult,
} = stackListState;
const { nodes, activeNode, setActiveNode } = useNodes();
@@ -176,6 +191,7 @@ export default function EditorLayout() {
setActiveNode,
nodes,
runWithLog,
getLastDeployOutputLine,
diffPreviewEnabled,
});
@@ -368,6 +384,16 @@ export default function EditorLayout() {
setGitSourceOpen={setGitSourceOpen}
setCopiedDigest={setCopiedDigest}
requestDeleteStack={stackActions.requestDeleteStack}
recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined}
onRefreshState={async () => {
if (!selectedFile) return;
const name = selectedFile.replace(/\.(yml|yaml)$/, '');
const ok = await stackActions.refreshSelectedContainers(name, selectedFile);
await refreshStacks(true);
if (ok) toast.success('Refreshed container state.');
else toast.error('Could not refresh container state.');
}}
onDismissRecovery={() => { if (selectedFile) dismissActionResult(selectedFile); }}
onMobileBack={goToMobileList}
/>
);
@@ -429,6 +455,9 @@ export default function EditorLayout() {
pendingStackLoadRef.current = null;
stackActions.resetEditorState();
// Stack filenames can repeat across nodes; drop the previous node's failure
// records so a stale recovery panel cannot surface on the new node.
clearActionRecords();
if (pendingStack) {
void stackActions.loadFile(pendingStack);
@@ -38,6 +38,8 @@ import { StackFileExplorer } from '@/components/files/StackFileExplorer';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
import { MobileStackDetail } from './MobileStackDetail';
import { RecoveryChip } from './RecoveryChip';
import { retryHandlerFor } from './recovery-retry';
import type { NotificationItem } from '../dashboard/types';
import type { Node } from '@/context/NodeContext';
import type { useAuth } from '@/context/AuthContext';
@@ -62,6 +64,32 @@ export type StackAction =
| 'delete'
| 'rollback';
/**
* Stack operations the recovery panel can offer safe next steps for. A failed
* stop/start/delete is not "recoverable" through retry/restart/rollback, so it
* never produces a record; narrowing the type keeps the panel's retry routing
* exhaustive.
*/
export type RecoverableAction = Extract<StackAction, 'deploy' | 'update' | 'restart' | 'rollback'>;
/**
* 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
* stalls. Cleared when the same stack's next operation succeeds or is dismissed,
* and on active-node change (the keyed stack filename can repeat across nodes).
*/
export interface StackActionResult {
action: RecoverableAction;
rolledBack: boolean;
errorMessage?: string;
startedAt: number;
endedAt: number;
// Last live output line captured only when a matching deploy-feedback
// session was streaming this stack at failure time; omitted otherwise so a
// line from another stack/session never leaks into diagnostics.
lastOutputLine?: string;
}
export interface ContainerStatsEntry {
cpu: string;
ram: string;
@@ -143,6 +171,13 @@ export interface EditorViewProps {
// Composed action: wraps setStackToDelete + setDeleteDialogOpen
requestDeleteStack: () => void;
// Recovery surface for a failed/stalled operation on this stack (undefined
// when the last op succeeded or none has run). onRefreshState re-syncs
// container state; onDismissRecovery drops the record.
recoveryResult?: StackActionResult;
onRefreshState: () => void;
onDismissRecovery: () => void;
// Mobile-only: back affordance in the detail header returns to the stack list.
onMobileBack?: () => void;
// Mobile-only: notifications + more-menu cluster for the detail header right
@@ -200,6 +235,9 @@ export function EditorView(props: EditorViewProps) {
setGitSourceOpen,
setCopiedDigest,
requestDeleteStack,
recoveryResult,
onRefreshState,
onDismissRecovery,
} = props;
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
@@ -262,28 +300,48 @@ export function EditorView(props: EditorViewProps) {
{/* Command Center Card (identity + health strip) */}
<Card className="rounded-xl border-muted bg-card shrink-0">
<CardHeader className="p-4 pb-2">
<StackIdentityHeader
stackName={stackName}
activeNode={activeNode}
safeContainers={safeContainers}
isRunning={isRunning}
copiedDigest={copiedDigest}
setCopiedDigest={setCopiedDigest}
copiedDigestTimerRef={copiedDigestTimerRef}
can={can}
isAdmin={isAdmin}
trivy={trivy}
backupInfo={backupInfo}
loadingAction={loadingAction}
stackMisconfigScanning={stackMisconfigScanning}
deployStack={deployStack}
restartStack={restartStack}
stopStack={stopStack}
updateStack={updateStack}
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<StackIdentityHeader
stackName={stackName}
activeNode={activeNode}
safeContainers={safeContainers}
isRunning={isRunning}
copiedDigest={copiedDigest}
setCopiedDigest={setCopiedDigest}
copiedDigestTimerRef={copiedDigestTimerRef}
can={can}
isAdmin={isAdmin}
trivy={trivy}
backupInfo={backupInfo}
loadingAction={loadingAction}
stackMisconfigScanning={stackMisconfigScanning}
deployStack={deployStack}
restartStack={restartStack}
stopStack={stopStack}
updateStack={updateStack}
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
/>
</div>
{recoveryResult && loadingAction == null && (
<div className="shrink-0">
<RecoveryChip
stackName={stackName}
result={recoveryResult}
activeNode={activeNode}
backupInfo={backupInfo}
canDeploy={can('stack:deploy', 'stack', stackName)}
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
onRestart={restartStack}
onRollback={rollbackStack}
onRefreshState={onRefreshState}
onDismiss={onDismissRecovery}
/>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-4 pt-2">
<ContainersHealth
@@ -4,6 +4,8 @@ import { cn } from '@/lib/utils';
import ErrorBoundary from '../ErrorBoundary';
import StackAnatomyPanel from '../StackAnatomyPanel';
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
import { RecoveryPanel } from './RecoveryPanel';
import { retryHandlerFor } from './recovery-retry';
import type { EditorViewProps } from './EditorView';
const SEGMENTS = [
@@ -55,6 +57,9 @@ export function MobileStackDetail(props: EditorViewProps) {
requestDeleteStack,
onMobileBack,
headerActions,
recoveryResult,
onRefreshState,
onDismissRecovery,
} = props;
const [segment, setSegment] = useState<Segment>('logs');
@@ -104,6 +109,23 @@ export function MobileStackDetail(props: EditorViewProps) {
/>
</div>
{recoveryResult && loadingAction == null && (
<div className="shrink-0 px-4 pt-3">
<RecoveryPanel
stackName={stackName}
result={recoveryResult}
activeNode={activeNode}
backupInfo={backupInfo}
canDeploy={can('stack:deploy', 'stack', stackName)}
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
onRestart={restartStack}
onRollback={rollbackStack}
onRefreshState={onRefreshState}
onDismiss={onDismissRecovery}
/>
</div>
)}
{/* Segmented control: Health · Logs · Compose */}
<div className="shrink-0 px-4 pt-3">
<div
@@ -0,0 +1,95 @@
import { useState } from 'react';
import { RotateCcw, RotateCw, Undo2, RefreshCw, Copy, Check } from 'lucide-react';
import { Button } from '../ui/button';
import { toast } from '@/components/ui/toast-store';
import { copyToClipboard } from '@/lib/clipboard';
import type { Node } from '@/context/NodeContext';
import type { StackActionResult } from './EditorView';
import { buildDiagnostics } from './recovery-format';
interface RecoveryActionsProps {
stackName: string;
result: StackActionResult;
activeNode: Node | null;
backupInfo: { exists: boolean; timestamp: number | null };
canDeploy: boolean;
onRetry: (e: React.MouseEvent) => void;
onRestart: (e: React.MouseEvent) => void;
onRollback: () => void;
onRefreshState: () => void;
// 'inline' wraps the actions in a row (mobile card); 'list' stacks them as
// full-width menu rows (desktop chip popover).
variant?: 'inline' | 'list';
}
// 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({
stackName,
result,
activeNode,
backupInfo,
canDeploy,
onRetry,
onRestart,
onRollback,
onRefreshState,
variant = 'inline',
}: RecoveryActionsProps) {
const [copied, setCopied] = useState(false);
const verb = result.action;
const showRestart = canDeploy && result.action !== 'restart';
const showRollback = canDeploy && backupInfo.exists && result.action !== 'rollback';
const list = variant === 'list';
const handleCopy = async () => {
try {
await copyToClipboard(buildDiagnostics(stackName, result, activeNode, backupInfo));
setCopied(true);
toast.success('Troubleshooting details copied.');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Could not copy troubleshooting details.');
}
};
const container = list ? 'flex flex-col' : 'flex flex-wrap items-center gap-1.5';
const secondary = list
? 'h-8 w-full justify-start gap-2 px-2 text-xs text-muted-foreground hover:text-foreground'
: 'h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground';
const primary = list
? 'h-8 w-full justify-start gap-2 px-2 text-xs text-foreground'
: 'h-7 gap-1.5 text-xs';
return (
<div className={container}>
{canDeploy && (
<Button variant={list ? 'ghost' : 'outline'} size="sm" className={primary} onClick={onRetry}>
<RotateCcw className="h-3.5 w-3.5" />
Retry {verb}
</Button>
)}
{showRestart && (
<Button variant="ghost" size="sm" className={secondary} onClick={onRestart}>
<RotateCw className="h-3.5 w-3.5" />
Restart
</Button>
)}
{showRollback && (
<Button variant="ghost" size="sm" className={secondary} onClick={() => onRollback()}>
<Undo2 className="h-3.5 w-3.5" />
Roll back
</Button>
)}
<Button variant="ghost" size="sm" className={secondary} onClick={onRefreshState}>
<RefreshCw className="h-3.5 w-3.5" />
Refresh
</Button>
<Button variant="ghost" size="sm" className={secondary} onClick={() => void handleCopy()}>
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
Copy details
</Button>
</div>
);
}
@@ -0,0 +1,96 @@
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 { capitalize, formatElapsed } from './recovery-format';
import type { Node } from '@/context/NodeContext';
import type { StackActionResult } from './EditorView';
interface RecoveryChipProps {
stackName: string;
result: StackActionResult;
activeNode: Node | null;
backupInfo: { exists: boolean; timestamp: number | null };
canDeploy: boolean;
onRetry: (e: React.MouseEvent) => void;
onRestart: (e: React.MouseEvent) => void;
onRollback: () => void;
onRefreshState: () => void;
onDismiss: () => void;
}
// Desktop recovery surface: a compact status chip that opens a popover with the
// error and recovery actions, so a failed operation stays discoverable without a
// banner taking permanent space in the detail. The mobile detail uses the inline
// RecoveryPanel instead.
export function RecoveryChip({
stackName,
result,
activeNode,
backupInfo,
canDeploy,
onRetry,
onRestart,
onRollback,
onRefreshState,
onDismiss,
}: RecoveryChipProps) {
const [open, setOpen] = useState(false);
const elapsed = formatElapsed(result.endedAt - result.startedAt);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-testid="recovery-chip"
className="inline-flex items-center gap-1.5 rounded-md border border-destructive/30 bg-destructive/5 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
>
<AlertTriangle className="h-3.5 w-3.5 shrink-0" strokeWidth={1.8} />
<span className="font-medium">{capitalize(result.action)} failed</span>
<ChevronDown className="h-3 w-3 opacity-70" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-64 overflow-hidden p-0" data-testid="recovery-panel">
<div className="px-3 pb-2 pt-2.5">
<p className="text-sm font-medium text-foreground">
{capitalize(result.action)} failed
<span className="ml-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle tabular-nums">
{elapsed}
</span>
</p>
<p className="mt-0.5 break-words text-xs text-muted-foreground">
{result.errorMessage ?? 'The operation did not complete.'}
{result.rolledBack && ' · rolled back to previous version'}
</p>
</div>
<div className="border-t border-glass-border p-1">
<RecoveryActions
variant="list"
stackName={stackName}
result={result}
activeNode={activeNode}
backupInfo={backupInfo}
canDeploy={canDeploy}
onRetry={onRetry}
onRestart={onRestart}
onRollback={onRollback}
onRefreshState={onRefreshState}
/>
</div>
<div className="border-t border-glass-border p-1">
<Button
variant="ghost"
size="sm"
className="h-8 w-full justify-start gap-2 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => { onDismiss(); setOpen(false); }}
>
<X className="h-3.5 w-3.5" />
Dismiss
</Button>
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,92 @@
import { AlertTriangle, X } from 'lucide-react';
import { Button } from '../ui/button';
import { RecoveryActions } from './RecoveryActions';
import { capitalize, formatElapsed } from './recovery-format';
import type { Node } from '@/context/NodeContext';
import type { StackActionResult } from './EditorView';
interface RecoveryPanelProps {
stackName: string;
result: StackActionResult;
activeNode: Node | null;
backupInfo: { exists: boolean; timestamp: number | null };
// Mirrors the existing header-action gate (can('stack:deploy', 'stack', name)).
canDeploy: boolean;
onRetry: (e: React.MouseEvent) => void;
onRestart: (e: React.MouseEvent) => void;
onRollback: () => void;
onRefreshState: () => void;
onDismiss: () => void;
}
// Inline recovery card for the mobile stack detail, shown after an
// 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.
export function RecoveryPanel({
stackName,
result,
activeNode,
backupInfo,
canDeploy,
onRetry,
onRestart,
onRollback,
onRefreshState,
onDismiss,
}: RecoveryPanelProps) {
const elapsed = formatElapsed(result.endedAt - result.startedAt);
return (
<div
data-testid="recovery-panel"
role="region"
aria-label={`${capitalize(result.action)} failed, recovery actions`}
className="relative overflow-hidden rounded-xl border border-muted bg-card p-3"
>
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-destructive/70" />
<div className="flex items-start justify-between gap-3 pl-2">
<div className="flex min-w-0 items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" strokeWidth={1.8} />
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">
{capitalize(result.action)} failed
<span className="ml-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle tabular-nums">
{elapsed}
</span>
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground" title={result.errorMessage}>
{result.errorMessage ?? 'The operation did not complete.'}
{result.rolledBack && ' · rolled back to previous version'}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
onClick={onDismiss}
title="Dismiss"
aria-label="Dismiss recovery panel"
>
<X className="h-3.5 w-3.5" />
</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>
</div>
);
}
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { RecoveryChip } from '../RecoveryChip';
import type { StackActionResult } from '../EditorView';
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const baseResult: StackActionResult = {
action: 'update',
rolledBack: false,
errorMessage: 'pull failed: connection reset',
startedAt: 1000,
endedAt: 4000,
};
function setup(over: Partial<Parameters<typeof RecoveryChip>[0]> = {}) {
const props = {
stackName: 'web',
result: baseResult,
activeNode: null,
backupInfo: { exists: false, timestamp: null },
canDeploy: true,
onRetry: vi.fn(),
onRestart: vi.fn(),
onRollback: vi.fn(),
onRefreshState: vi.fn(),
onDismiss: vi.fn(),
...over,
};
render(<RecoveryChip {...props} />);
return props;
}
describe('RecoveryChip', () => {
beforeEach(() => vi.clearAllMocks());
it('renders a chip with the failed action and keeps actions hidden until opened', () => {
setup();
expect(screen.getByTestId('recovery-chip')).toBeInTheDocument();
expect(screen.getByText(/Update failed/)).toBeInTheDocument();
expect(screen.queryByText('Retry update')).not.toBeInTheDocument();
});
it('reveals the recovery actions when the chip is clicked', () => {
const props = setup();
fireEvent.click(screen.getByTestId('recovery-chip'));
expect(screen.getByText('Retry update')).toBeInTheDocument();
expect(screen.getByText('Refresh')).toBeInTheDocument();
fireEvent.click(screen.getByText('Retry update'));
expect(props.onRetry).toHaveBeenCalledTimes(1);
});
it('dismisses from inside the popover', () => {
const props = setup();
fireEvent.click(screen.getByTestId('recovery-chip'));
fireEvent.click(screen.getByText('Dismiss'));
expect(props.onDismiss).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { RecoveryPanel } from '../RecoveryPanel';
import type { StackActionResult } from '../EditorView';
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { copyToClipboard } from '@/lib/clipboard';
const baseResult: StackActionResult = {
action: 'update',
rolledBack: false,
errorMessage: 'pull failed: connection reset',
startedAt: 1000,
endedAt: 4000,
};
function setup(over: Partial<Parameters<typeof RecoveryPanel>[0]> = {}) {
const props = {
stackName: 'web',
result: baseResult,
activeNode: null,
backupInfo: { exists: false, timestamp: null },
canDeploy: true,
onRetry: vi.fn(),
onRestart: vi.fn(),
onRollback: vi.fn(),
onRefreshState: vi.fn(),
onDismiss: vi.fn(),
...over,
};
render(<RecoveryPanel {...props} />);
return props;
}
describe('RecoveryPanel', () => {
beforeEach(() => vi.clearAllMocks());
it('shows the failed action title and error message', () => {
setup();
expect(screen.getByText(/Update failed/)).toBeInTheDocument();
expect(screen.getByText(/pull failed: connection reset/)).toBeInTheDocument();
});
it('calls onRetry from the retry button', () => {
const props = setup();
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 } });
expect(screen.queryByText('Retry update')).not.toBeInTheDocument();
expect(screen.queryByText('Restart')).not.toBeInTheDocument();
expect(screen.queryByText('Roll back')).not.toBeInTheDocument();
// Read-level actions remain available.
expect(screen.getByText('Refresh')).toBeInTheDocument();
expect(screen.getByText('Copy details')).toBeInTheDocument();
});
it('offers rollback only when a backup exists', () => {
setup({ backupInfo: { exists: false, timestamp: null } });
expect(screen.queryByText('Roll back')).not.toBeInTheDocument();
setup({ backupInfo: { exists: true, timestamp: 123 } });
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' } });
expect(screen.getByText('Retry restart')).toBeInTheDocument();
expect(screen.queryByText('Restart')).not.toBeInTheDocument();
});
it('copies session-safe diagnostics including stack and error', () => {
setup({ result: { ...baseResult, lastOutputLine: 'pulling app ...' } });
fireEvent.click(screen.getByText('Copy details'));
expect(copyToClipboard).toHaveBeenCalledTimes(1);
const blob = vi.mocked(copyToClipboard).mock.calls[0][0];
expect(blob).toContain('Stack: web');
expect(blob).toContain('pull failed: connection reset');
expect(blob).toContain('Last output: pulling app ...');
});
it('wires refresh and dismiss callbacks', () => {
const props = setup();
fireEvent.click(screen.getByText('Refresh'));
fireEvent.click(screen.getByLabelText('Dismiss recovery panel'));
expect(props.onRefreshState).toHaveBeenCalledTimes(1);
expect(props.onDismiss).toHaveBeenCalledTimes(1);
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { renderHook, act } from '@testing-library/react';
import { useStackActions } from './useStackActions';
import type { useEditorViewState } from './useEditorViewState';
import type { useStackListState } from './useStackListState';
@@ -62,6 +62,12 @@ function makeStackListState(over: Partial<StackListState> = {}): StackListState
isStackBusy: vi.fn().mockReturnValue(false),
refreshStacks: vi.fn(),
setSearchQuery: vi.fn(),
fetchImageUpdates: vi.fn(),
lastActionResult: {},
recordActionFailure: vi.fn(),
recordActionSuccess: vi.fn(),
clearActionRecords: vi.fn(),
dismissActionResult: vi.fn(),
};
return { ...base, ...over } as unknown as StackListState;
}
@@ -85,9 +91,14 @@ function makeOverlay(over: Partial<OverlayState> = {}): OverlayState {
const runWithLog: Parameters<typeof useStackActions>[0]['runWithLog'] = async (_p, run) =>
run(Promise.resolve(), 'test-session');
function setup(over: { editorState?: Partial<EditorState>; overlay?: Partial<OverlayState> } = {}) {
function setup(over: {
editorState?: Partial<EditorState>;
overlay?: Partial<OverlayState>;
stackList?: Partial<StackListState>;
getLastDeployOutputLine?: (stackName: string) => string | undefined;
} = {}) {
const editorState = makeEditorState(over.editorState);
const stackListState = makeStackListState();
const stackListState = makeStackListState(over.stackList);
const navState = { setActiveView: vi.fn() } as unknown as NavState;
const overlayState = makeOverlay(over.overlay);
@@ -101,6 +112,7 @@ function setup(over: { editorState?: Partial<EditorState>; overlay?: Partial<Ove
setActiveNode: vi.fn(),
nodes: [],
runWithLog,
getLastDeployOutputLine: over.getLastDeployOutputLine ?? (() => undefined),
diffPreviewEnabled: false,
}),
);
@@ -146,6 +158,7 @@ describe('useStackActions.saveFile', () => {
setActiveNode: vi.fn(),
nodes: [],
runWithLog,
getLastDeployOutputLine: () => undefined,
diffPreviewEnabled: false,
}),
);
@@ -351,3 +364,153 @@ describe('useStackActions.attemptLeaveEditor (mobile back / nav guard)', () => {
expect(overlayState.setPendingLeaveAction).toHaveBeenCalledWith(null);
});
});
describe('useStackActions recovery records', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
});
// Route every apiFetch by URL so the failure paths (which also refetch
// /containers) get sensible responses.
function routeApi(updateStatus: number, body = '{"error":"boom"}') {
vi.mocked(apiFetch).mockImplementation((url: string) => {
const u = String(url);
if (u.includes('/update') || u.includes('/deploy') || u.includes('/restart')) {
return Promise.resolve(new Response(body, { status: updateStatus }));
}
if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 }));
if (u.includes('/backup')) return Promise.resolve(new Response('{"exists":false,"timestamp":null}', { status: 200 }));
return Promise.resolve(new Response('[]', { status: 200 }));
});
}
it('records a failure and refetches containers when an update fails', async () => {
routeApi(500);
const { result, stackListState, editorState } = setup();
await act(async () => { await result.current.updateStack(); });
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
'web.yml',
expect.objectContaining({ action: 'update', errorMessage: 'boom', rolledBack: false }),
);
expect(editorState.setContainers).toHaveBeenCalled();
expect(stackListState.recordActionSuccess).not.toHaveBeenCalled();
});
it('clears the record on a successful update', async () => {
routeApi(200, '');
const { result, stackListState } = setup();
await act(async () => { await result.current.updateStack(); });
expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml');
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
});
it('does not record a failure for a stack-op-in-progress 409', async () => {
const inProgress = JSON.stringify({
code: 'stack_op_in_progress',
inProgress: { action: 'update', startedAt: 1, user: 'someone' },
});
vi.mocked(apiFetch).mockResolvedValue(new Response(inProgress, { status: 409 }));
const { result, stackListState } = setup();
await act(async () => { await result.current.updateStack(); });
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
});
it('stores the deploy-feedback last line only for the matching stack', async () => {
routeApi(500);
const getLastDeployOutputLine = (stackName: string) =>
stackName === 'web' ? 'pulling app ...' : undefined;
const { result, stackListState } = setup({ getLastDeployOutputLine });
await act(async () => { await result.current.updateStack(); });
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
'web.yml',
expect.objectContaining({ lastOutputLine: 'pulling app ...' }),
);
});
it('does not record a recovery panel for a failed stop (not recoverable)', async () => {
routeApi(500);
const { result, stackListState } = setup();
await act(async () => { await result.current.stopStack(); });
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
});
it('records a deploy failure and carries the rolledBack flag', async () => {
vi.mocked(apiFetch).mockImplementation((url: string) => {
const u = String(url);
if (u.includes('/deploy')) {
return Promise.resolve(new Response('{"error":"crash","rolledBack":true}', { status: 500 }));
}
if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 }));
return Promise.resolve(new Response('[]', { status: 200 }));
});
const { result, stackListState } = setup();
await act(async () => {
await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent);
});
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
'web.yml',
expect.objectContaining({ action: 'deploy', rolledBack: true, errorMessage: 'crash' }),
);
});
it('records a rollback failure', async () => {
vi.mocked(apiFetch).mockImplementation((url: string) => {
const u = String(url);
if (u.includes('/rollback')) return Promise.resolve(new Response('{"error":"no backup"}', { status: 500 }));
if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 }));
return Promise.resolve(new Response('[]', { status: 200 }));
});
const { result, stackListState } = setup();
await act(async () => { await result.current.rollbackStack(); });
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
'web.yml',
expect.objectContaining({ action: 'rollback', rolledBack: false, errorMessage: 'no backup' }),
);
});
it('does not record a failure when only the post-rollback refetch fails', async () => {
let rolledBack = false;
vi.mocked(apiFetch).mockImplementation((url: string) => {
const u = String(url);
if (u.includes('/rollback')) { rolledBack = true; return Promise.resolve(new Response(null, { status: 200 })); }
// After a successful rollback, the cosmetic content refetch throws.
if (rolledBack && u.endsWith('/stacks/web.yml')) return Promise.reject(new Error('network blip'));
return Promise.resolve(new Response('[]', { status: 200 }));
});
const { result, stackListState } = setup();
await act(async () => { await result.current.rollbackStack(); });
expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml');
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
});
it('refreshes containers after a successful rollback (rollback redeploys)', async () => {
vi.mocked(apiFetch).mockImplementation((url: string) => {
const u = String(url);
if (u.includes('/rollback')) return Promise.resolve(new Response(null, { status: 200 }));
if (u.includes('/containers')) {
return Promise.resolve(new Response('[{"Id":"c1","Names":["/web"],"State":"running"}]', { status: 200 }));
}
return Promise.resolve(new Response('', { status: 200 }));
});
const { result, stackListState, editorState } = setup();
await act(async () => { await result.current.rollbackStack(); });
expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml');
expect(editorState.setContainers).toHaveBeenCalledWith(
expect.arrayContaining([expect.objectContaining({ Id: 'c1' })]),
);
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
});
it('does not record a rollback failure when the post-rollback container refresh fails', async () => {
vi.mocked(apiFetch).mockImplementation((url: string) => {
const u = String(url);
if (u.includes('/rollback')) return Promise.resolve(new Response(null, { status: 200 }));
if (u.includes('/containers')) return Promise.reject(new Error('network blip'));
return Promise.resolve(new Response('', { status: 200 }));
});
const { result, stackListState } = setup();
await act(async () => { await result.current.rollbackStack(); });
expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml');
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
});
});
@@ -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 } from '../EditorView';
import type { StackAction, RecoverableAction } from '../EditorView';
import type { NotificationItem } from '../../dashboard/types';
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
@@ -62,6 +62,9 @@ interface UseStackActionsOptions {
params: { stackName: string; action: ActionVerb },
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>,
) => Promise<RunResult>;
// Last live output line for a stack, but only while a deploy-feedback session
// is streaming that exact stack; used to enrich failure diagnostics safely.
getLastDeployOutputLine: (stackName: string) => string | undefined;
diffPreviewEnabled: boolean;
}
@@ -128,6 +131,7 @@ export function useStackActions(options: UseStackActionsOptions) {
setActiveNode,
nodes,
runWithLog,
getLastDeployOutputLine,
diffPreviewEnabled,
} = options;
@@ -194,6 +198,55 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setIsEditing(false);
};
// Re-sync the open stack's container list. Used after both successful and
// failed/stalled operations so the detail never shows containers that no
// longer reflect reality. Returns true only when the live list was fetched;
// false on a non-applicable stack, a non-ok response, or a network error, so
// callers (e.g. the recovery panel's Refresh) can report the real outcome.
const refreshSelectedContainers = async (stackName: string, stackFile: string): Promise<boolean> => {
if (stackListState.selectedFile !== stackFile) return false;
try {
const res = await apiFetch(`/stacks/${stackName}/containers`);
if (!res.ok) return false;
const conts = await res.json();
editorState.setContainers(Array.isArray(conts) ? conts : []);
return true;
} catch {
// Non-critical when called from an action's failure path: refreshStacks(true)
// in the caller's finally still reconciles the sidebar status.
return false;
}
};
// Stack operations whose failure produces a recovery panel. A failed
// stop/start/delete is not recoverable through retry/restart/rollback.
const RECOVERABLE_ACTIONS: readonly StackAction[] = ['deploy', 'update', 'restart', 'rollback'];
const isRecoverableAction = (action: StackAction): action is RecoverableAction =>
RECOVERABLE_ACTIONS.includes(action);
// Store a terminal failure so the in-detail recovery panel can offer next
// steps. Non-recoverable actions are skipped. Snapshots the last output line
// only when the deploy-feedback panel is streaming this stack at failure time
// (see getLastDeployOutputLine); undefined otherwise.
const recordActionFailureFor = (
stackFile: string,
stackName: string,
action: StackAction,
startedAt: number,
errorMessage: string | undefined,
rolledBack: boolean,
) => {
if (!isRecoverableAction(action)) return;
stackListState.recordActionFailure(stackFile, {
action,
errorMessage,
rolledBack,
startedAt,
endedAt: Date.now(),
lastOutputLine: getLastDeployOutputLine(stackName),
});
};
const refreshGitSourcePending = async () => {
try {
const res = await apiFetch('/git-sources');
@@ -538,6 +591,7 @@ export function useStackActions(options: UseStackActionsOptions) {
deploySessionId?: string,
): Promise<RunResult> => {
const previousStatus = stackListState.stackStatuses[stackFile];
const startedAt = Date.now();
stackListState.setOptimisticStatus(stackFile, 'running');
try {
const path = ignorePolicy
@@ -571,17 +625,14 @@ export function useStackActions(options: UseStackActionsOptions) {
toast.success(
ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!',
);
if (stackListState.selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
editorState.setContainers(Array.isArray(conts) ? conts : []);
}
await refreshSelectedContainers(stackName, stackFile);
try {
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
} catch {
/* ignore */
}
stackListState.recordActionSuccess(stackFile);
return { ok: true };
} catch (error) {
console.error('Failed to deploy:', error);
@@ -594,6 +645,8 @@ export function useStackActions(options: UseStackActionsOptions) {
? `${errorMessage} - automatically rolled back to previous version.`
: errorMessage,
);
recordActionFailureFor(stackFile, stackName, 'deploy', startedAt, errorMessage, deployError.rolledBack === true);
await refreshSelectedContainers(stackName, stackFile);
return { ok: false, errorMessage, rolledBack: deployError.rolledBack };
}
};
@@ -660,6 +713,7 @@ export function useStackActions(options: UseStackActionsOptions) {
return;
const stackFile = stackListState.selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
const startedAt = Date.now();
stackListState.setStackAction(stackFile, 'rollback');
stackListState.setOptimisticStatus(stackFile, 'running');
try {
@@ -686,15 +740,26 @@ export function useStackActions(options: UseStackActionsOptions) {
}
overlayState.setPolicyBlock(null);
toast.success('Stack rolled back successfully.');
const contentRes = await apiFetch(`/stacks/${stackFile}`);
const text = await contentRes.text();
editorState.setContent(text || '');
editorState.setOriginalContent(text || '');
const backupRes = await apiFetch(`/stacks/${stackFile}/backup`);
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
stackListState.recordActionSuccess(stackFile);
// The rollback already succeeded; a failure of the cosmetic refetches below
// (containers redeployed by the rollback, restored compose content, backup
// info) must not be mis-recorded as a rollback failure.
try {
await refreshSelectedContainers(stackName, stackFile);
const contentRes = await apiFetch(`/stacks/${stackFile}`);
const text = await contentRes.text();
editorState.setContent(text || '');
editorState.setOriginalContent(text || '');
const backupRes = await apiFetch(`/stacks/${stackFile}/backup`);
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
} catch (refetchError) {
console.error('Post-rollback refetch failed:', refetchError);
}
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : 'Rollback failed';
toast.error(msg);
recordActionFailureFor(stackFile, stackName, 'rollback', startedAt, msg, false);
await refreshSelectedContainers(stackName, stackFile);
} finally {
stackListState.clearStackAction(stackFile);
stackListState.refreshStacks(true);
@@ -756,6 +821,7 @@ export function useStackActions(options: UseStackActionsOptions) {
if (stackListState.isStackBusy(stackFile)) return;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
const previousStatus = stackListState.stackStatuses[stackFile];
const startedAt = Date.now();
stackListState.setStackAction(stackFile, action);
stackListState.setOptimisticStatus(stackFile, optimisticStatus);
try {
@@ -785,6 +851,8 @@ export function useStackActions(options: UseStackActionsOptions) {
}
}
const actionError = parseStackActionError(errText, `${action} failed`);
recordActionFailureFor(stackFile, stackName, action, startedAt, actionError.message, actionError.rolledBack === true);
await refreshSelectedContainers(stackName, stackFile);
return {
ok: false as const,
errorMessage: actionError.message,
@@ -794,14 +862,14 @@ export function useStackActions(options: UseStackActionsOptions) {
overlayState.setPolicyBlock(null);
toast.success(successMessage);
if (action === 'update') stackListState.fetchImageUpdates();
if (stackListState.selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
editorState.setContainers(Array.isArray(conts) ? conts : []);
}
await refreshSelectedContainers(stackName, stackFile);
stackListState.recordActionSuccess(stackFile);
return { ok: true as const };
} catch (err) {
return { ok: false as const, errorMessage: (err as Error).message || `${action} failed` };
const message = (err as Error).message || `${action} failed`;
recordActionFailureFor(stackFile, stackName, action, startedAt, message, false);
await refreshSelectedContainers(stackName, stackFile);
return { ok: false as const, errorMessage: message };
}
});
} catch (error) {
@@ -949,6 +1017,7 @@ export function useStackActions(options: UseStackActionsOptions) {
) => {
if (stackListState.isStackBusy(stackFile)) return;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
const startedAt = Date.now();
stackListState.setStackAction(stackFile, action);
if (action === 'stop') {
@@ -978,11 +1047,7 @@ export function useStackActions(options: UseStackActionsOptions) {
throw parseStackActionError(errText, `${action} failed`);
}
toast.success(`Stack ${action}ed successfully!`);
if (stackListState.selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
editorState.setContainers(Array.isArray(conts) ? conts : []);
}
await refreshSelectedContainers(stackName, stackFile);
if (action === 'update') stackListState.fetchImageUpdates();
if (action === 'deploy') {
try {
@@ -992,6 +1057,7 @@ export function useStackActions(options: UseStackActionsOptions) {
/* ignore */
}
}
stackListState.recordActionSuccess(stackFile);
} catch (error) {
console.error(`Failed to ${action}:`, error);
const actionError = error as StackActionError;
@@ -1001,6 +1067,8 @@ export function useStackActions(options: UseStackActionsOptions) {
? `${msg} - automatically rolled back to previous version.`
: msg,
);
recordActionFailureFor(stackFile, stackName, action, startedAt, msg, actionError.rolledBack === true);
await refreshSelectedContainers(stackName, stackFile);
} finally {
stackListState.clearStackAction(stackFile);
stackListState.refreshStacks(true);
@@ -1065,6 +1133,7 @@ export function useStackActions(options: UseStackActionsOptions) {
getStackMenuVisibility,
openStackApp,
resetEditorState,
refreshSelectedContainers,
refreshGitSourcePending,
loadFile,
loadFileOnNode,
@@ -9,7 +9,7 @@ import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackAction
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
import type { StackAction, ContainerInfo } from '../EditorView';
import type { StackAction, StackActionResult, ContainerInfo } from '../EditorView';
import type { Label as StackLabel } from '../../label-types';
import type { FilterChip } from '../../sidebar/sidebar-types';
import type { StackRowStatus } from '../../sidebar/stack-status-utils';
@@ -38,6 +38,12 @@ export function useStackListState() {
const [stackActions, setStackActions] = useState<Record<string, StackAction>>({});
const stackActionsRef = useRef<Record<string, StackAction>>({});
// Per-stack terminal failure records driving the in-detail recovery panel.
// In-memory only. Node scoping is enforced by the caller, which clears these
// on active-node change (see EditorLayout's node-switch effect) so a repeated
// stack filename cannot carry a failure across nodes.
const [lastActionResult, setLastActionResult] = useState<Record<string, StackActionResult>>({});
const [isScanning, setIsScanning] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
@@ -85,6 +91,27 @@ export function useStackListState() {
setStackStatuses(prev => ({ ...prev, [stackFile]: status }));
};
// Recovery record lifecycle. recordActionFailure stores a terminal failure;
// recordActionSuccess / dismissActionResult drop it; clearActionRecords wipes
// all (node switch). The recovery panel itself renders only when the stack is
// not mid-operation, so a stale record never shows during a retry.
const clearStackResult = useCallback((stackFile: string) => {
setLastActionResult(prev => {
if (!(stackFile in prev)) return prev;
const next = { ...prev };
delete next[stackFile];
return next;
});
}, []);
const recordActionFailure = useCallback((stackFile: string, result: StackActionResult) => {
setLastActionResult(prev => ({ ...prev, [stackFile]: result }));
}, []);
const recordActionSuccess = clearStackResult;
const dismissActionResult = clearStackResult;
const clearActionRecords = useCallback(() => {
setLastActionResult({});
}, []);
const refreshLabels = useCallback(async () => {
try {
const [labelsRes, assignmentsRes] = await Promise.all([
@@ -333,6 +360,8 @@ export function useStackListState() {
remoteResults,
setStackAction, clearStackAction, isStackBusy,
setOptimisticStatus,
lastActionResult,
recordActionFailure, recordActionSuccess, clearActionRecords, dismissActionResult,
refreshLabels,
refreshStacks,
handleScanStacks,
@@ -0,0 +1,39 @@
import type { Node } from '@/context/NodeContext';
import type { StackActionResult } from './EditorView';
export function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function formatElapsed(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000));
if (seconds >= 60) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
return `${seconds}s`;
}
// Plain-text troubleshooting blob for the recovery panel's "Copy details". The
// last output line is included only when the record carries one (session-safe).
export function buildDiagnostics(
stackName: string,
result: StackActionResult,
activeNode: Node | null,
backupInfo: { exists: boolean; timestamp: number | null },
): string {
const lines = [
`Stack: ${stackName}`,
`Node: ${activeNode?.name ?? 'local'}${activeNode?.id != null ? ` (id ${activeNode.id})` : ''}`,
`Action: ${result.action}`,
`Outcome: failed${result.rolledBack ? ' (rolled back to previous version)' : ''}`,
`Elapsed: ${formatElapsed(result.endedAt - result.startedAt)}`,
`Error: ${result.errorMessage ?? 'unknown'}`,
`Backup: ${backupInfo.exists
? `available${backupInfo.timestamp ? ` (${new Date(backupInfo.timestamp).toISOString()})` : ''}`
: 'none'}`,
];
if (result.lastOutputLine) lines.push(`Last output: ${result.lastOutputLine}`);
return lines.join('\n');
}
@@ -0,0 +1,29 @@
import type { RecoverableAction } from './EditorView';
// The stack action handlers the recovery panel routes "Retry" to. Shared by
// both detail surfaces (desktop EditorView and MobileStackDetail) so the
// action-to-handler mapping lives in one place.
export interface RetryHandlers {
deployStack: (e: React.MouseEvent) => void;
restartStack: (e: React.MouseEvent) => void;
updateStack: (e?: React.MouseEvent) => void;
rollbackStack: () => void;
}
// Resolve the retry handler for a recoverable action. Rollback takes no event,
// so it is wrapped to match the panel's onRetry signature.
export function retryHandlerFor(
action: RecoverableAction,
handlers: RetryHandlers,
): (e: React.MouseEvent) => void {
switch (action) {
case 'deploy':
return handlers.deployStack;
case 'restart':
return handlers.restartStack;
case 'rollback':
return () => handlers.rollbackStack();
case 'update':
return handlers.updateStack;
}
}
@@ -0,0 +1,97 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { DeployFeedbackProvider, useDeployFeedback } from '@/context/DeployFeedbackContext';
import { DeployFeedbackModal } from '../DeployFeedbackModal';
// 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 }));
// The real Terminal mounts xterm + a WebSocket; mock it to a no-op that signals
// the stream connected on mount so the panel reaches the 'streaming' state.
vi.mock('@/components/Terminal', () => {
const MockTerminal = ({ onReady, onError }: { onReady?: () => void; onError?: () => void }) => {
React.useEffect(() => {
onReady?.();
if (ctl.drop) onError?.();
}, [onReady, onError]);
return null;
};
return { default: MockTerminal };
});
// 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;
function Driver() {
const { runWithLog } = useDeployFeedback();
React.useEffect(() => {
void runWithLog({ stackName: 'web', action: 'update' }, async (started) => {
await started;
return new Promise<{ ok: boolean; errorMessage?: string }>((res) => { resolveRun = res; });
});
}, [runWithLog]);
return null;
}
async function renderStreaming() {
await act(async () => {
render(
<DeployFeedbackProvider>
<Driver />
<DeployFeedbackModal isMinimized={false} onMinimize={() => {}} />
</DeployFeedbackProvider>,
);
// The mocked Terminal calls onReady on mount; flush the 50ms handshake.
await vi.advanceTimersByTimeAsync(60);
});
}
describe('DeployFeedbackModal stalled-output warning', () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
resolveRun = null;
ctl.drop = false;
});
afterEach(() => {
vi.useRealTimers();
});
it('warns after the stall threshold when streaming produces no output', async () => {
await renderStreaming();
// Well under the threshold: no warning yet.
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
expect(screen.queryByTestId('deploy-feedback-stalled')).toBeNull();
// Past the threshold with zero output: the warning appears.
await act(async () => { await vi.advanceTimersByTimeAsync(80_000); });
expect(screen.getByTestId('deploy-feedback-stalled')).toBeInTheDocument();
expect(screen.getByText(/No output received yet/)).toBeInTheDocument();
});
it('clears the stall warning once the operation fails', async () => {
await renderStreaming();
await act(async () => { await vi.advanceTimersByTimeAsync(80_000); });
expect(screen.getByTestId('deploy-feedback-stalled')).toBeInTheDocument();
// The operation finishes as a failure; status leaves 'streaming' so the
// stall warning must clear rather than sit next to the failed state.
await act(async () => {
resolveRun?.({ ok: false, errorMessage: 'boom' });
await Promise.resolve();
});
expect(screen.queryByTestId('deploy-feedback-stalled')).toBeNull();
});
it('suppresses the stall warning when the progress stream is unavailable', async () => {
ctl.drop = true; // the stream connects then immediately drops mid-operation
await renderStreaming();
// Past the threshold, but with the stream gone the warning would be noise.
await act(async () => { await vi.advanceTimersByTimeAsync(80_000); });
expect(screen.queryByTestId('deploy-feedback-stalled')).toBeNull();
});
});
@@ -188,7 +188,7 @@ export function AppearanceSection() {
<SettingsField
label="Deploy progress modal"
helper="Stream live output for deploy, restart, update, install, and Git operations."
helper="Stream live output for deploy, restart, update, install, and Git operations, with a warning when an operation goes quiet. On by default; turn it off to run operations without the panel."
>
<div className="flex items-center gap-2">
<Checkbox
+13 -1
View File
@@ -53,6 +53,14 @@ interface DeployFeedbackContextValue {
) => Promise<RunResult>;
panelState: DeployPanelState;
logRows: ParsedLogRow[];
/**
* Epoch ms of the most recent activity for the current session: stamped when
* the deploy starts, again when the stream connects, and on every output
* chunk. The modal compares it against now to warn that an in-flight
* operation has gone quiet (a possible stall), covering the no-first-line
* case because it is seeded at start rather than at first output.
*/
lastOutputAt: number;
onTerminalReady: () => void;
onTerminalError: () => void;
onMessage: (text: string) => void;
@@ -92,6 +100,7 @@ const DeployFeedbackContext = createContext<DeployFeedbackContextValue | undefin
export function DeployFeedbackProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [panelState, setPanelState] = useState<DeployPanelState>(DEFAULT_PANEL_STATE);
const [logRows, setLogRows] = useState<ParsedLogRow[]>([]);
const [lastOutputAt, setLastOutputAt] = useState<number>(0);
// Idempotent resolver for the current session's deployStarted gate. Set at the
// start of each runWithLog call; called by onTerminalReady (stream connected),
@@ -116,6 +125,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
const onTerminalReady = useCallback(() => {
streamReadyRef.current = true;
setLastOutputAt(Date.now());
setPanelState((prev) => (prev.status === 'preparing' ? { ...prev, status: 'streaming' } : prev));
// Give the connectTerminal handshake a beat to register on the backend
// before the deploy POST fires, so the first lines are not missed.
@@ -134,6 +144,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
const onMessage = useCallback((text: string) => {
const newRows = parseLogChunk(text, idCounterRef.current);
idCounterRef.current += newRows.length;
setLastOutputAt(Date.now());
setLogRows((prev) => {
const combined = prev.length > 0 && prev[0].id === TRUNCATION_ROW_ID
? [...prev.slice(1), ...newRows]
@@ -182,6 +193,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
// idCounterRef is intentionally not reset; keys must remain globally unique across sessions.
setLogRows([]);
setLastOutputAt(Date.now());
setPanelState({
isOpen: true,
@@ -236,7 +248,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
return (
<DeployFeedbackContext.Provider
value={{ runWithLog, panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
value={{ runWithLog, panelState, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
>
{children}
</DeployFeedbackContext.Provider>
@@ -0,0 +1,42 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDeployFeedbackEnabled, DEPLOY_FEEDBACK_KEY } from '../use-deploy-feedback-enabled';
describe('useDeployFeedbackEnabled (opt-out default)', () => {
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
it('defaults to enabled when no value is stored', () => {
const { result } = renderHook(() => useDeployFeedbackEnabled());
expect(result.current[0]).toBe(true);
});
it('is disabled only when explicitly set to false', () => {
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
const { result } = renderHook(() => useDeployFeedbackEnabled());
expect(result.current[0]).toBe(false);
});
it('treats any non-false value as enabled', () => {
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'true');
const { result } = renderHook(() => useDeployFeedbackEnabled());
expect(result.current[0]).toBe(true);
});
it('re-enables when a storage event clears the key (newValue null)', () => {
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
const { result } = renderHook(() => useDeployFeedbackEnabled());
expect(result.current[0]).toBe(false);
act(() => {
window.dispatchEvent(new StorageEvent('storage', { key: DEPLOY_FEEDBACK_KEY, newValue: null }));
});
expect(result.current[0]).toBe(true);
});
it('setEnabled(false) persists false and disables', () => {
const { result } = renderHook(() => useDeployFeedbackEnabled());
act(() => result.current[1](false));
expect(result.current[0]).toBe(false);
expect(localStorage.getItem(DEPLOY_FEEDBACK_KEY)).toBe('false');
});
});
@@ -3,12 +3,15 @@ import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
export const DEPLOY_FEEDBACK_KEY = 'sencho.deploy-feedback.enabled';
// Default on (opt-out): the live deploy/update output panel shows unless the
// user has explicitly turned it off, so update progress and the stalled-output
// warning are visible without a setting hunt. Only a stored 'false' disables it.
function readStored(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window === 'undefined') return true;
try {
return window.localStorage.getItem(DEPLOY_FEEDBACK_KEY) === 'true';
return window.localStorage.getItem(DEPLOY_FEEDBACK_KEY) !== 'false';
} catch {
return false;
return true;
}
}
@@ -26,7 +29,9 @@ export function useDeployFeedbackEnabled(): [boolean, (next: boolean) => void] {
useEffect(() => {
function onStorage(event: StorageEvent) {
if (event.key !== DEPLOY_FEEDBACK_KEY) return;
setEnabledState(event.newValue === 'true');
// Opt-out: anything other than an explicit 'false' (including a
// cleared key) means enabled.
setEnabledState(event.newValue !== 'false');
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);