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