feat: add an inline deploy-progress style for the stack detail (#1355)

* feat: add an inline deploy-progress style for the stack detail

Deploy progress gains a presentation choice under Settings > Appearance >
Display: Modal (the default centered overlay) or Inline. In Inline style a
compact status band on the stack detail shows the running operation, its
elapsed time, the live phase, the latest output line, and the post-update
health gate result. A "View output" button opens the full log modal on
demand, a dismiss control clears the band, and the band auto-clears a few
seconds after a clean completion.

The live progress socket is lifted to an always-mounted owner so the band
streams without the modal; the default Modal style is unchanged. Operations
carry their node so a band never bleeds onto a same-named stack on another
node.

The stack detail's redundant "CONTAINERS" section heading is removed; the
band reserves that vertical space.

* fix: keep inline deploy progress reachable off the stack detail

Review of the inline presentation found a gap: a failed operation, an App
Store install, or navigating away leaves the inline session with no visible
surface, since the band only renders on the operation's own stack detail.
Restore the minimized pill as the inline fallback, shown only when the band
is not covering the session, so there is always a click-through to the log
without ever overlapping the band. Closing the modal for a failed op now
ends the session (the band has stepped aside) instead of only hiding it.

Also document the unsupported mid-operation style switch, and refresh the
deploy-progress, settings, appearance, and app-store docs for the renamed
"Deploy progress" setting and the Modal/Inline choice.
This commit is contained in:
Anso
2026-06-11 10:33:57 -04:00
committed by GitHub
parent 38aabe7064
commit e20f1fe415
30 changed files with 1258 additions and 73 deletions
@@ -39,6 +39,7 @@ import { useIsMobile } from '@/hooks/use-is-mobile';
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
import { MobileStackDetail } from './MobileStackDetail';
import { RecoveryChip } from './RecoveryChip';
import { StackOperationBanner } from './StackOperationBanner';
import { retryHandlerFor } from './recovery-retry';
import type { NotificationItem } from '../dashboard/types';
import type { Node } from '@/context/NodeContext';
@@ -192,6 +193,10 @@ export interface EditorViewProps {
onRefreshState: () => void;
onDismissRecovery: () => void;
// Session start (ms) of the active deploy-feedback op, or null when none, for
// the inline progress banner's elapsed readout.
panelStartedAt: number | null;
// 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
@@ -252,6 +257,7 @@ export function EditorView(props: EditorViewProps) {
recoveryResult,
onRefreshState,
onDismissRecovery,
panelStartedAt,
} = props;
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
@@ -357,6 +363,12 @@ export function EditorView(props: EditorViewProps) {
)}
</div>
</CardHeader>
<StackOperationBanner
stackName={stackName}
activeNode={activeNode}
panelStartedAt={panelStartedAt}
variant="band"
/>
<CardContent className="p-4 pt-2">
<ContainersHealth
safeContainers={safeContainers}
@@ -14,6 +14,10 @@ vi.mock('./editor-view-blocks', () => ({
}));
vi.mock('../StackAnatomyPanel', () => ({ default: () => <div>compose-pane</div> }));
vi.mock('../ErrorBoundary', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}</> }));
// The inline operation banner pulls in the deploy-feedback context; this suite
// covers the segmented-control behavior, so stub it (it renders nothing in the
// default Modal style anyway).
vi.mock('./StackOperationBanner', () => ({ StackOperationBanner: () => null }));
function makeProps(over: Partial<EditorViewProps> = {}): EditorViewProps {
return {
@@ -5,6 +5,7 @@ import ErrorBoundary from '../ErrorBoundary';
import StackAnatomyPanel from '../StackAnatomyPanel';
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
import { RecoveryPanel } from './RecoveryPanel';
import { StackOperationBanner } from './StackOperationBanner';
import { retryHandlerFor } from './recovery-retry';
import type { EditorViewProps } from './EditorView';
@@ -60,6 +61,7 @@ export function MobileStackDetail(props: EditorViewProps) {
recoveryResult,
onRefreshState,
onDismissRecovery,
panelStartedAt,
} = props;
const [segment, setSegment] = useState<Segment>('logs');
@@ -109,6 +111,14 @@ export function MobileStackDetail(props: EditorViewProps) {
/>
</div>
<StackOperationBanner
stackName={stackName}
activeNode={activeNode}
panelStartedAt={panelStartedAt}
variant="card"
className="mx-4 mt-3 shrink-0"
/>
{recoveryResult && loadingAction == null && (
<div className="shrink-0 px-4 pt-3">
<RecoveryPanel
@@ -0,0 +1,198 @@
import { useEffect, useState } from 'react';
import { Terminal as TerminalIcon, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '../ui/button';
import { useDeployFeedback, VERB_LABELS } from '@/context/DeployFeedbackContext';
import { useDeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
import { formatElapsed } from './recovery-format';
import { classifyOperationPhase } from './operation-phase';
import type { Node } from '@/context/NodeContext';
// How long the banner lingers on a clean completion before clearing itself.
const AUTO_DISMISS_MS = 4000;
interface StackOperationBannerProps {
stackName: string;
activeNode: Node | null;
panelStartedAt: number | null;
// 'band' is the desktop treatment (a full-bleed section between the header
// and the container list); 'card' is the mobile treatment (a standalone
// card with a status rail). The inner content is identical.
variant: 'band' | 'card';
className?: string;
}
// Inline progress for the active deploy/update on the viewed stack, shown only
// in Inline progress style (the modal is the surface otherwise). Reads the
// deploy-feedback session directly: operation, elapsed, the live phase and
// latest output line, the post-update health gate, and a "View output" button
// that opens the full log modal. A dismiss button clears it without the modal.
export function StackOperationBanner({ stackName, activeNode, panelStartedAt, variant, className }: StackOperationBannerProps) {
const { panelState, healthGate, logRows, minimized, setMinimized, setBannerActive, onPanelClose } = useDeployFeedback();
const [style] = useDeployFeedbackStyle();
const { action, status, nodeId, progressUnavailable } = panelState;
// A failed gate routes into the recovery surface (RecoveryChip/Panel), so the
// banner steps aside for it rather than double-reporting the failure.
const active =
style === 'inline' &&
panelState.isOpen &&
panelState.stackName === stackName &&
nodeId === (activeNode?.id ?? null) &&
status !== 'failed' &&
healthGate?.status !== 'failed';
const gateObserving = healthGate?.status === 'observing';
const succeeded = status === 'succeeded';
// Operation finished (succeeded and any gate has settled past observing):
// freeze the elapsed here and switch to past tense.
const done = succeeded && !gateObserving;
// Fully done with a clean result (no gate, or a passed gate): auto-dismiss.
const fullyDone = done && (healthGate == null || healthGate.status === 'passed');
// Tell the portal this session is covered by the banner so its fallback pill
// stays hidden here; when the banner is not active (off the stack detail, or
// a failed op it steps aside for) the pill takes over as the surface.
useEffect(() => {
setBannerActive(active);
return () => setBannerActive(false);
}, [active, setBannerActive]);
// Freeze the elapsed at completion so the timer stops at the final duration.
const [frozenElapsed, setFrozenElapsed] = useState<string | null>(null);
useEffect(() => {
if (!active) {
setFrozenElapsed(null);
return;
}
if (done && frozenElapsed === null && panelStartedAt != null) {
setFrozenElapsed(formatElapsed(Date.now() - panelStartedAt));
}
}, [active, done, frozenElapsed, panelStartedAt]);
// Tick each second while running so elapsed and the gate's observing count
// stay live; stop once done (the elapsed is frozen from then on).
const [, tick] = useState(0);
useEffect(() => {
if (!active || done) return;
const id = setInterval(() => tick((n) => n + 1), 1000);
return () => clearInterval(id);
}, [active, done]);
// Auto-dismiss a few seconds after a clean completion. `minimized` false
// means the user has the full-log modal open over the banner: hold off then
// (neither surface auto-closes in Inline style, by design, so a log is not
// yanked mid-read) and arm the timer once they close it back to the banner.
useEffect(() => {
if (!active || !fullyDone || !minimized) return;
const timer = setTimeout(() => onPanelClose(), AUTO_DISMISS_MS);
return () => clearTimeout(timer);
}, [active, fullyDone, minimized, onPanelClose]);
if (!active) {
// The desktop band keeps its vertical slot reserved when idle so the
// container cards sit at the same height whether or not an operation is
// running, holding the space the removed section title used to occupy.
// Mobile reserves nothing (its card slot is conditional).
return variant === 'band' ? <div aria-hidden className="h-12" /> : null;
}
const verb = done ? VERB_LABELS[action].past : VERB_LABELS[action].present;
const elapsed = frozenElapsed ?? (panelStartedAt != null ? formatElapsed(Date.now() - panelStartedAt) : null);
let statusText: string | null = null;
let detailLine: string | null = null;
if (progressUnavailable && (status === 'preparing' || status === 'streaming')) {
statusText = 'Live progress unavailable';
detailLine = 'The operation continues running in the background.';
} else if (gateObserving) {
statusText = 'Verifying health';
const gateElapsed = healthGate?.startedAt ? Math.max(0, Math.floor((Date.now() - healthGate.startedAt) / 1000)) : 0;
detailLine = healthGate?.windowSeconds ? `${gateElapsed}s of ${healthGate.windowSeconds}s` : `${gateElapsed}s`;
} else if (healthGate?.status === 'passed') {
statusText = 'Health gate passed';
} else if (healthGate?.status === 'unknown') {
statusText = 'Health check unknown';
detailLine = healthGate.reason ?? null;
} else if (!succeeded) {
statusText = classifyOperationPhase(logRows, action);
detailLine = logRows.length > 0 ? logRows[logRows.length - 1].message : null;
}
const successDot = done || healthGate?.status === 'passed';
const inner = (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="flex items-center gap-1.5 text-sm">
<span
aria-hidden
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
successDot ? 'bg-success' : 'bg-brand animate-[pulse_2.4s_ease-in-out_infinite]',
)}
/>
<span className="font-medium text-foreground">{verb}</span>
{elapsed && (
<>
<span className="text-stat-subtitle">·</span>
<span className="font-mono text-[11px] tabular-nums text-stat-subtitle">{elapsed}</span>
</>
)}
{statusText && (
<>
<span className="text-stat-subtitle">·</span>
<span className="truncate text-xs text-muted-foreground">{statusText}</span>
</>
)}
</p>
{detailLine && (
<p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground" title={detailLine}>
{detailLine}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setMinimized(false)}
>
<TerminalIcon className="h-3.5 w-3.5" />
View output
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={onPanelClose}
title="Dismiss"
aria-label="Dismiss progress"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
const commonProps = {
'data-testid': 'stack-operation-banner',
role: 'status' as const,
'aria-label': `${verb} ${stackName}`,
};
if (variant === 'band') {
return (
<div {...commonProps} className={cn('border-y border-hairline bg-band px-4 py-2.5', className)}>
{inner}
</div>
);
}
return (
<div {...commonProps} className={cn('relative overflow-hidden rounded-xl border border-muted bg-card p-3', className)}>
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-[3px]', successDot ? 'bg-success/70' : 'bg-brand/70')} />
<div className="pl-2">{inner}</div>
</div>
);
}
@@ -0,0 +1,264 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StackOperationBanner } from '../StackOperationBanner';
import type { DeployPanelState, HealthGateUiState } from '@/context/DeployFeedbackContext';
import type { ParsedLogRow, LogStage } from '@/components/log-rendering/composeLogParser';
import type { Node } from '@/context/NodeContext';
const setMinimized = vi.fn();
const onPanelClose = vi.fn();
let mockPanelState: DeployPanelState;
let mockHealthGate: HealthGateUiState | null;
let mockLogRows: ParsedLogRow[];
let mockStyle: 'modal' | 'inline';
let mockMinimized: boolean;
vi.mock('@/context/DeployFeedbackContext', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/DeployFeedbackContext')>();
return {
...actual,
useDeployFeedback: () => ({
panelState: mockPanelState,
healthGate: mockHealthGate,
logRows: mockLogRows,
setMinimized,
setBannerActive: vi.fn(),
onPanelClose,
// Unused by the banner but part of the context shape.
runWithLog: vi.fn(),
minimized: mockMinimized,
bannerActive: false,
lastOutputAt: 0,
onTerminalReady: vi.fn(),
onTerminalError: vi.fn(),
onMessage: vi.fn(),
}),
};
});
vi.mock('@/hooks/use-deploy-feedback-style', () => ({
useDeployFeedbackStyle: () => [mockStyle, vi.fn()],
}));
function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
return {
isOpen: true,
stackName: 'web',
nodeId: null,
action: 'update',
status: 'streaming',
progressUnavailable: false,
deploySessionId: '',
sessionId: 1,
...over,
};
}
const row = (message: string, stage: LogStage = 'LOG'): ParsedLogRow => ({
id: `r-${message}`, timestamp: '', stage, level: 'info', message, raw: message,
});
const node = (id: number) => ({ id } as Node);
beforeEach(() => {
setMinimized.mockClear();
onPanelClose.mockClear();
mockStyle = 'inline';
mockMinimized = true; // inline sessions default to the banner (modal hidden)
mockPanelState = panel();
mockHealthGate = null;
mockLogRows = [];
});
const passedGate = (): HealthGateUiState => ({
stackName: 'web', gateId: 'g', trigger: 'update', status: 'passed', reason: null, windowSeconds: 90, startedAt: Date.now() - 90_000,
});
function renderBanner(activeNode: Node | null = null, panelStartedAt: number | null = Date.now() - 12_000) {
return render(
<StackOperationBanner stackName="web" activeNode={activeNode} panelStartedAt={panelStartedAt} variant="band" />,
);
}
describe('StackOperationBanner', () => {
it('renders for the matching stack and node in inline style while in flight', () => {
renderBanner();
expect(screen.getByTestId('stack-operation-banner')).toBeInTheDocument();
expect(screen.getByText('Updating')).toBeInTheDocument();
});
it('renders nothing in modal style', () => {
mockStyle = 'modal';
renderBanner();
expect(screen.queryByTestId('stack-operation-banner')).toBeNull();
});
it('renders nothing for a different stack or a node mismatch', () => {
mockPanelState = panel({ stackName: 'api' });
const { unmount } = renderBanner();
expect(screen.queryByTestId('stack-operation-banner')).toBeNull();
unmount();
mockPanelState = panel({ nodeId: 2 });
renderBanner(node(5));
expect(screen.queryByTestId('stack-operation-banner')).toBeNull();
});
it('renders when the panel node matches the active node', () => {
mockPanelState = panel({ nodeId: 5 });
renderBanner(node(5));
expect(screen.getByTestId('stack-operation-banner')).toBeInTheDocument();
});
it('renders nothing when the operation or the gate failed (recovery takes over)', () => {
mockPanelState = panel({ status: 'failed' });
const { unmount } = renderBanner();
expect(screen.queryByTestId('stack-operation-banner')).toBeNull();
unmount();
mockPanelState = panel({ status: 'succeeded' });
mockHealthGate = { stackName: 'web', gateId: 'g', trigger: 'update', status: 'failed', reason: 'exited', windowSeconds: 90, startedAt: Date.now() };
renderBanner();
expect(screen.queryByTestId('stack-operation-banner')).toBeNull();
});
it('shows the live phase and latest output line while streaming', () => {
mockLogRows = [row('=== Pulling latest images ==='), row('web-1 Pulling fs layer 41%', 'PULL')];
renderBanner();
expect(screen.getByText('Pulling images')).toBeInTheDocument();
expect(screen.getByText('web-1 Pulling fs layer 41%')).toBeInTheDocument();
});
it('shows past tense once succeeded with no pending gate', () => {
mockPanelState = panel({ status: 'succeeded' });
renderBanner();
expect(screen.getByText('Updated')).toBeInTheDocument();
});
it('shows the observing health gate and keeps present tense', () => {
mockPanelState = panel({ status: 'succeeded' });
mockHealthGate = { stackName: 'web', gateId: 'g', trigger: 'update', status: 'observing', reason: null, windowSeconds: 90, startedAt: Date.now() - 12_000 };
renderBanner();
expect(screen.getByText('Updating')).toBeInTheDocument();
expect(screen.getByText('Verifying health')).toBeInTheDocument();
expect(screen.getByText(/\d+s of 90s/)).toBeInTheDocument();
});
it('shows the passed health gate', () => {
mockPanelState = panel({ status: 'succeeded' });
mockHealthGate = { stackName: 'web', gateId: 'g', trigger: 'update', status: 'passed', reason: null, windowSeconds: 90, startedAt: Date.now() - 90_000 };
renderBanner();
expect(screen.getByText('Health gate passed')).toBeInTheDocument();
expect(screen.getByText('Updated')).toBeInTheDocument();
});
it('shows the unknown health gate state with its reason', () => {
mockPanelState = panel({ status: 'succeeded' });
mockHealthGate = { stackName: 'web', gateId: 'g', trigger: 'update', status: 'unknown', reason: 'no healthcheck defined', windowSeconds: 90, startedAt: Date.now() };
renderBanner();
expect(screen.getByText('Health check unknown')).toBeInTheDocument();
expect(screen.getByText('no healthcheck defined')).toBeInTheDocument();
});
it('freezes the elapsed once the operation is done', () => {
vi.useFakeTimers();
try {
mockMinimized = false; // modal-open path, so auto-dismiss does not interfere
const start = Date.now() - 5000;
mockPanelState = panel({ status: 'streaming' });
const view = render(<StackOperationBanner stackName="web" activeNode={null} panelStartedAt={start} variant="band" />);
act(() => { vi.advanceTimersByTime(3000); }); // ~8s elapsed, still streaming
// Complete the operation (succeeded, no gate) → elapsed should freeze.
mockPanelState = panel({ status: 'succeeded' });
view.rerender(<StackOperationBanner stackName="web" activeNode={null} panelStartedAt={start} variant="band" />);
const frozenText = screen.getByTestId('stack-operation-banner').textContent;
expect(frozenText).toMatch(/8s/);
// Advancing time and re-rendering must not move the elapsed readout.
act(() => { vi.advanceTimersByTime(10000); });
view.rerender(<StackOperationBanner stackName="web" activeNode={null} panelStartedAt={start} variant="band" />);
expect(screen.getByTestId('stack-operation-banner').textContent).toBe(frozenText);
} finally {
vi.useRealTimers();
}
});
it('shows the live-progress-unavailable fallback', () => {
mockPanelState = panel({ status: 'streaming', progressUnavailable: true });
renderBanner();
expect(screen.getByText('Live progress unavailable')).toBeInTheDocument();
expect(screen.getByText(/continues running in the background/i)).toBeInTheDocument();
});
it('View output un-minimizes the modal', () => {
renderBanner();
fireEvent.click(screen.getByRole('button', { name: /view output/i }));
expect(setMinimized).toHaveBeenCalledWith(false);
});
it('Dismiss clears the session without opening the modal', () => {
renderBanner();
fireEvent.click(screen.getByRole('button', { name: /dismiss progress/i }));
expect(onPanelClose).toHaveBeenCalledTimes(1);
expect(setMinimized).not.toHaveBeenCalled();
});
it('renders the card variant on mobile', () => {
render(<StackOperationBanner stackName="web" activeNode={null} panelStartedAt={Date.now()} variant="card" />);
expect(screen.getByTestId('stack-operation-banner')).toBeInTheDocument();
});
it('reserves a spacer slot when idle in the band variant, but nothing in the card variant', () => {
mockStyle = 'modal'; // inactive
const band = render(<StackOperationBanner stackName="web" activeNode={null} panelStartedAt={null} variant="band" />);
expect(screen.queryByTestId('stack-operation-banner')).toBeNull();
expect(band.container.firstChild).not.toBeNull();
band.unmount();
const card = render(<StackOperationBanner stackName="web" activeNode={null} panelStartedAt={null} variant="card" />);
expect(card.container.firstChild).toBeNull();
});
it('auto-dismisses a few seconds after a clean completion', () => {
vi.useFakeTimers();
try {
mockPanelState = panel({ status: 'succeeded' });
mockHealthGate = passedGate();
renderBanner();
expect(screen.getByText('Updated')).toBeInTheDocument();
expect(onPanelClose).not.toHaveBeenCalled();
act(() => { vi.advanceTimersByTime(4000); });
expect(onPanelClose).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it('does not auto-dismiss while the modal is open over the banner', () => {
vi.useFakeTimers();
try {
mockMinimized = false; // modal open over the banner
mockPanelState = panel({ status: 'succeeded' });
mockHealthGate = passedGate();
renderBanner();
act(() => { vi.advanceTimersByTime(8000); });
expect(onPanelClose).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('does not auto-dismiss while the health gate is still observing', () => {
vi.useFakeTimers();
try {
mockPanelState = panel({ status: 'succeeded' });
mockHealthGate = { stackName: 'web', gateId: 'g', trigger: 'update', status: 'observing', reason: null, windowSeconds: 90, startedAt: Date.now() };
renderBanner();
act(() => { vi.advanceTimersByTime(8000); });
expect(onPanelClose).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { classifyOperationPhase } from '../operation-phase';
import type { ParsedLogRow, LogStage } from '@/components/log-rendering/composeLogParser';
const row = (message: string, stage: LogStage = 'LOG'): ParsedLogRow => ({
id: message, timestamp: '', stage, level: 'info', message, raw: message,
});
describe('classifyOperationPhase', () => {
it('returns null with no rows or no phase markers', () => {
expect(classifyOperationPhase([], 'update')).toBeNull();
expect(classifyOperationPhase([row('Attaching to web-1')], 'update')).toBeNull();
});
it('classifies the update phase banners', () => {
expect(classifyOperationPhase([row('=== Pulling latest images ===')], 'update')).toBe('Pulling images');
expect(classifyOperationPhase([row('=== Recreating containers ===')], 'update')).toBe('Recreating containers');
expect(classifyOperationPhase([row('=== Pruned dangling images (120MB) ===')], 'update')).toBe('Pruning images');
expect(classifyOperationPhase([row('=== Backup created for atomic update ===')], 'update')).toBe('Preparing');
});
it('classifies docker compose stages', () => {
expect(classifyOperationPhase([row('[+] Pulling 2/3', 'PULL')], 'deploy')).toBe('Pulling images');
expect(classifyOperationPhase([row('[+] Starting 1/1', 'START')], 'deploy')).toBe('Starting containers');
});
it('treats compose v2 per-layer pull progress as pulling', () => {
expect(classifyOperationPhase([row('doplarr Pulling')], 'deploy')).toBe('Pulling images');
expect(classifyOperationPhase([row('45e54b3153b9 Downloading 4.194MB')], 'deploy')).toBe('Pulling images');
expect(classifyOperationPhase([row('45e54b3153b9 Extracting')], 'update')).toBe('Pulling images');
});
it('uses action-aware wording for the create stage', () => {
expect(classifyOperationPhase([row('[+] Creating 1/1', 'CREATE')], 'update')).toBe('Recreating containers');
expect(classifyOperationPhase([row('[+] Creating 1/1', 'CREATE')], 'deploy')).toBe('Creating containers');
expect(classifyOperationPhase([row('[+] Creating 1/1', 'CREATE')], 'install')).toBe('Creating containers');
});
it('returns the latest phase (newest-first wins)', () => {
const rows = [row('[+] Pulling', 'PULL'), row('[+] Creating', 'CREATE'), row('[+] Starting', 'START')];
expect(classifyOperationPhase(rows, 'deploy')).toBe('Starting containers');
});
});
@@ -328,18 +328,17 @@ export function ContainersHealth({
serviceAction,
}: ContainersHealthProps) {
return (
<div className="mt-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-muted-foreground">CONTAINERS</h4>
{containerStatsError && safeContainers.length > 0 && (
<div>
{containerStatsError && safeContainers.length > 0 && (
<div className="mb-3 flex items-center justify-end">
<span
className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground bg-warning/10 border border-warning/30 rounded-md px-2 py-0.5"
title={containerStatsError}
>
Stats unavailable
</span>
)}
</div>
</div>
)}
{safeContainers.length === 0 ? (
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
) : (
@@ -6,7 +6,7 @@ import type { useStackListState } from './useStackListState';
import type { useViewNavigationState } from './useViewNavigationState';
import type { OverlayState } from './useOverlayState';
import type { Node } from '@/context/NodeContext';
import type { ActionVerb } from '@/context/DeployFeedbackContext';
import type { RunWithLogParams } from '@/context/DeployFeedbackContext';
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
import type { NotificationItem } from '../../dashboard/types';
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
@@ -101,7 +101,7 @@ interface UseStackActionsOptions {
setActiveNode: (node: Node) => void;
nodes: Node[];
runWithLog: (
params: { stackName: string; action: ActionVerb },
params: RunWithLogParams,
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>,
) => Promise<RunResult>;
// Last live output line for a stack, but only while a deploy-feedback session
@@ -730,7 +730,7 @@ export function useStackActions(options: UseStackActionsOptions) {
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
stackListState.setStackAction(stackFile, 'deploy');
try {
await runWithLog({ stackName, action: 'deploy' }, (started, ds) =>
await runWithLog({ stackName, action: 'deploy', nodeId: activeNode?.id ?? null }, (started, ds) =>
runDeploy(stackName, stackFile, false, started, ds),
);
} finally {
@@ -765,7 +765,7 @@ export function useStackActions(options: UseStackActionsOptions) {
} else {
stackListState.setStackAction(existingFile, 'deploy');
try {
await runWithLog({ stackName, action: 'deploy' }, (started, ds) =>
await runWithLog({ stackName, action: 'deploy', nodeId: activeNode?.id ?? null }, (started, ds) =>
runDeploy(stackName, existingFile, true, started, ds),
);
} finally {
@@ -896,7 +896,7 @@ export function useStackActions(options: UseStackActionsOptions) {
stackListState.setStackAction(stackFile, action);
stackListState.setOptimisticStatus(stackFile, optimisticStatus);
try {
await runWithLog({ stackName, action }, async (started, ds) => {
await runWithLog({ stackName, action, nodeId: activeNode?.id ?? null }, async (started, ds) => {
await started;
try {
const url = ignorePolicy
@@ -0,0 +1,46 @@
import type { ParsedLogRow } from '@/components/log-rendering/composeLogParser';
import type { ActionVerb } from '@/context/DeployFeedbackContext';
// Classify the current operation phase from streamed compose output, returning a
// display label or null before any phase marker. The backend emits explicit
// `=== ... ===` phase banners during update (pull / recreate / prune), and docker
// compose emits `[+] Pulling/Creating/Starting` lines that the log parser tags as
// PULL/CREATE/START. Scanning newest-first returns the latest recognized phase,
// since phases run in sequence. Labels are action-aware: "Recreating containers"
// is update wording; deploy/install show "Creating containers".
export function classifyOperationPhase(rows: ParsedLogRow[], action: ActionVerb): string | null {
for (let i = rows.length - 1; i >= 0; i--) {
const { message, stage } = rows[i];
if (message.includes('Pruned dangling images') || message.includes('Pruning')) {
return 'Pruning images';
}
if (message.includes('Recreating containers')) {
return 'Recreating containers';
}
if (stage === 'START') {
return 'Starting containers';
}
if (stage === 'CREATE') {
return action === 'update' ? 'Recreating containers' : 'Creating containers';
}
// The update banner and the parser's `[+] Pulling` tag cover the headline,
// but compose v2's per-layer progress (`<service> Pulling`, `Downloading`,
// `Extracting`, ...) arrives as plain lines; match them so the phase reads
// "Pulling images" throughout the download rather than lagging behind.
if (
message.includes('Pulling latest images') ||
message.includes('Pulling from') ||
stage === 'PULL' ||
/\b(Pulling|Downloading|Extracting|Verifying Checksum|Pull complete|Download complete|Pulled)\b/.test(message)
) {
return 'Pulling images';
}
if (stage === 'BUILD') {
return 'Building images';
}
if (message.includes('Backup created for atomic') || message.includes('Cleaning up existing containers')) {
return 'Preparing';
}
}
return null;
}