mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user