diff --git a/docs/features/app-store.mdx b/docs/features/app-store.mdx index 07eab102..72ea1e44 100644 --- a/docs/features/app-store.mdx +++ b/docs/features/app-store.mdx @@ -117,7 +117,7 @@ Clicking **Deploy** runs the following sequence: ## Watching the deploy -Sencho can stream the live `docker compose` output for every template install. Open **Settings › Appearance › Display** and enable **Deploy progress modal**; from then on, every App Store deploy opens a structured log modal you can minimise to a pill that follows you across navigation. See [Deploy Progress](/features/deploy-progress) for the full reference. +Sencho can stream the live `docker compose` output for every template install. Open **Settings › Appearance › Display** and enable **Deploy progress**; from then on, every App Store deploy streams its output. In Modal style it opens a structured log modal you can minimise to a pill that follows you across navigation; in Inline style it shows as that pill, which opens the same log on click. See [Deploy Progress](/features/deploy-progress) for the full reference. Deploying a template requires the `stack:create` permission on the target node, which **admin** and **node-admin** roles hold by default. The Deploy button stays disabled when your account does not have it. diff --git a/docs/features/appearance.mdx b/docs/features/appearance.mdx index b6a82a24..d7c71b6a 100644 --- a/docs/features/appearance.mdx +++ b/docs/features/appearance.mdx @@ -49,5 +49,5 @@ Hero headings and featured names always render in Instrument Serif so the editor The **Display** group holds the remaining per-browser preferences: - **Density** switches between **Comfortable** (roomy rows, the default) and **Compact** (tighter rows and tiles that fit more on screen for dense dashboards). -- **Deploy progress modal** toggles the live streaming output window for deploy, restart, update, install, and Git operations. +- **Deploy progress** toggles live output streaming for deploy, restart, update, install, and Git operations, and **Progress style** chooses Modal or Inline presentation. - **Diff preview before save** toggles a side-by-side diff of compose and env edits before they are written to disk. diff --git a/docs/features/deploy-progress.mdx b/docs/features/deploy-progress.mdx index e3f127f3..255a6a9d 100644 --- a/docs/features/deploy-progress.mdx +++ b/docs/features/deploy-progress.mdx @@ -5,12 +5,17 @@ description: Stream live output from stack deploy, install, update, restart, and When you trigger a stack action that runs through `docker compose` (Deploy, Update, Install from the App Store, Apply with deploy from a Git Source), a progress modal opens and streams the output in real time. Each line is parsed into a timestamped row with a stage badge so you can track the deployment lifecycle as it runs. The modal can be minimized to a small pill that follows you across navigation, so you can leave the App Store mid-install and still see the status from any screen. If an operation goes quiet or fails, Sencho keeps the state observable: the modal warns when output has stopped, and the stack page surfaces recovery actions you can take without leaving Sencho. -## Showing or hiding deploy progress +## Showing, hiding, and styling deploy progress -The modal is **on by default**. To run operations without it, open **Settings > Appearance > Display** and turn off **Deploy progress modal**. The choice is saved to the current browser only and synced across tabs in the same browser without a reload. +Deploy progress is **on by default**. To run operations without it, open **Settings > Appearance > Display** and turn off **Deploy progress**. While it is on, **Progress style** chooses how it appears: + +- **Modal** (the default): a centered overlay that opens automatically and streams the full structured log. +- **Inline**: a quiet status band on the stack detail itself, with the full log a click away under **View output**. + +The choices are saved to the current browser only and synced across tabs in the same browser without a reload. - Settings · Personal · Appearance panel with the Deploy progress modal field and its helper text describing live output streaming for deploy, restart, update, install, and Git operations. + Settings Appearance Display panel with the deploy progress toggle. ## Using the modal @@ -88,6 +93,17 @@ Minimizing, closing, or navigating away never cancels the operation. The progres Minimized deploy progress pill anchored at the bottom center of a stack editor view, showing the brand-colored pulsing dot and the text 'Updating docs-demo' +## Inline style + +With **Progress style** set to **Inline**, the modal does not open on its own. Instead, the stack detail shows a compact status band for the running operation, between the action buttons and the container list (a status card on a phone). The band shows: + +- the operation and its elapsed time, +- the current phase while images pull and containers recreate, +- the latest output line, +- the post-update [health gate](/features/health-gated-updates) result once the operation succeeds. + +A **View output** button opens the full log modal on demand, and a dismiss control clears the band. When the operation finishes cleanly, the elapsed time freezes at its final duration and the band clears itself a few seconds later. Closing the opened log modal in this style only hides it again; the band stays until the operation is done or you dismiss it. If the live stream cannot attach or drops, the band shows the same **Live progress unavailable** state and the operation keeps running in the background. + ## Recovery actions When a deploy or update fails, times out, or its outcome is ambiguous, the stack page offers safe next steps so you can fix the stack in place. On desktop a small **Update failed** chip appears in the stack card; click it to open a menu of recovery actions. On a phone the same actions show as an inline card on the stack detail. Either way it shows the failed action, the error, and how long the operation ran, and it works whether or not the progress modal is enabled. @@ -135,7 +151,7 @@ The HTTP API also exposes a `down` action (compose-level teardown) that streams If a pull or recreate produces no output for a while, the modal shows a stalled-output warning so you know the operation has gone quiet. The operation keeps running. If a step is genuinely hung, Sencho stops it after a longer idle window (10 minutes by default) and the stack page offers recovery actions where you can retry, restart, roll back when a backup exists, refresh the container state, or copy troubleshooting details. To change how long Sencho waits before treating a silent step as stalled, set `SENCHO_COMPOSE_STALL_TIMEOUT_MS`; raise it on slow links or for heavy local image builds. - - The setting is stored in `localStorage` under `sencho.deploy-feedback.enabled` and applies to the current tab without a reload. The modal is on by default, so only an explicit off choice hides it. Other tabs in the same browser pick up the change through a `storage` event. If a tab still does not honour the setting, refresh that tab. The setting does not sync across browsers or devices; each one carries its own choice. + + The settings live in `localStorage`: the on/off toggle under `sencho.deploy-feedback.enabled` and the Modal/Inline choice under `sencho.deploy-feedback.style`. Both apply to the current tab without a reload, and other tabs in the same browser pick up the change through a `storage` event. Deploy progress defaults to on in Modal style, so only an explicit choice changes it. If a tab still does not honour the setting, refresh that tab. The settings do not sync across browsers or devices; each one carries its own choice. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index e07f3fc5..29109a1c 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -99,7 +99,7 @@ See [Two-Factor Authentication](/features/two-factor-authentication) for the enr Control how dense and how interactive the workspace feels. Each browser remembers its own choices so a compact laptop setup does not force the same rhythm on a larger desktop. - Appearance settings showing the Density selector, Deploy progress modal, and Diff preview toggles + Appearance settings showing the Density selector, the Deploy progress toggle, and the Diff preview toggle ### Density @@ -111,14 +111,14 @@ Control how dense and how interactive the workspace feels. Each browser remember Density affects the dashboard stack table, the resource gauge strip, the Settings Hub sidebar, the Schedules and Audit Log tables, and every other data table in Sencho. Typography, color, and layout structure stay the same; only vertical padding compresses. -### Deploy progress modal +### Deploy progress -Sencho streams live output in a modal whenever you deploy, restart, update, install, or run a Git operation. The modal closes automatically on success or stays open on failure so you can read the error output, and it warns when an operation goes quiet. It is on by default; turn it off to run operations without the panel. +Sencho streams live output whenever you deploy, restart, update, install, or run a Git operation. It is on by default; turn it off to run operations without it. When it is on, **Progress style** chooses how it appears: **Modal** (a centered overlay that closes automatically on success or stays open on failure) or **Inline** (a quiet status band on the stack detail). See [Deploy Progress](/features/deploy-progress) for the full reference. | Value | Behavior | |-------|----------| -| **Enabled** (default) | A progress modal opens for every long-running operation | -| **Disabled** | Operations run without the panel; results surface via toast notifications, and a failed operation still shows recovery actions on the stack page | +| **Enabled** (default) | A progress surface (the Modal overlay or the Inline band, per Progress style) shows for every long-running operation | +| **Disabled** | Operations run without it; results surface via toast notifications, and a failed operation still shows recovery actions on the stack page | ### Diff preview before save diff --git a/e2e/deploy-log-panel.spec.ts b/e2e/deploy-log-panel.spec.ts index f5e35bb0..c6a2511a 100644 --- a/e2e/deploy-log-panel.spec.ts +++ b/e2e/deploy-log-panel.spec.ts @@ -14,6 +14,7 @@ import { loginAs, waitForStacksLoaded } from './helpers'; const HAPPY_STACK = 'e2e-deploy-log-test'; const FAIL_STACK = 'e2e-deploy-log-fail-test'; const DEPLOY_FEEDBACK_KEY = 'sencho.deploy-feedback.enabled'; +const DEPLOY_FEEDBACK_STYLE_KEY = 'sencho.deploy-feedback.style'; const HAPPY_COMPOSE = `services: web: @@ -77,6 +78,21 @@ async function enableDeployFeedback(page: Page): Promise { }, DEPLOY_FEEDBACK_KEY); } +/** + * Select the progress presentation style (Modal or Inline). addInitScript keeps + * it set across the reload setupDeployStack performs; the evaluate updates any + * already-mounted tree. + */ +async function setDeployStyle(page: Page, style: 'modal' | 'inline'): Promise { + await page.addInitScript(([key, value]) => { + window.localStorage.setItem(key, value); + }, [DEPLOY_FEEDBACK_STYLE_KEY, style] as const); + await page.evaluate(([key, value]) => { + window.localStorage.setItem(key, value); + window.dispatchEvent(new CustomEvent('sencho-settings-changed')); + }, [DEPLOY_FEEDBACK_STYLE_KEY, style] as const); +} + /** * Shrink the post-deploy health gate observation window so success-path tests * can watch the full deploy, verify, succeed sequence without waiting out the @@ -384,3 +400,72 @@ test.describe('Deploy feedback modal', () => { await setHealthGateWindow(page, 90).catch(() => {}); }); }); + +test.describe('Inline deploy progress', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page); + await waitForStacksLoaded(page); + }); + + test.afterEach(async ({ page }) => { + await deleteStackViaApi(page, HAPPY_STACK); + await disableDeployFeedback(page); + await page.evaluate((key) => window.localStorage.removeItem(key), DEPLOY_FEEDBACK_STYLE_KEY).catch(() => {}); + await setHealthGateWindow(page, 90).catch(() => {}); + }); + + test('inline style shows the in-page banner instead of the modal, and View output opens it', async ({ page }) => { + test.setTimeout(120_000); + + await enableDeployFeedback(page); + await setDeployStyle(page, 'inline'); + await setHealthGateWindow(page, 15); + await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE); + await syncDeployFeedbackState(page); + + await page.getByTestId('stack-deploy-button').click(); + + const banner = page.getByTestId('stack-operation-banner'); + await expect(banner).toBeVisible({ timeout: 10_000 }); + + // The modal must not auto-open in Inline style. + await expect(page.locator('[data-testid="deploy-feedback-modal"]')).toBeHidden(); + + // View output summons the full modal on demand. + await banner.getByRole('button', { name: /view output/i }).click(); + await expect(page.locator('[data-testid="deploy-feedback-modal"]')).toBeVisible({ timeout: 5_000 }); + }); + + test('inline banner is visible on the mobile detail', async ({ page }) => { + test.setTimeout(120_000); + + await enableDeployFeedback(page); + await setDeployStyle(page, 'inline'); + await setHealthGateWindow(page, 15); + await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE); + await syncDeployFeedbackState(page); + + // Resize below md so the detail re-renders as the mobile segmented layout + // with the same selected stack. + await page.setViewportSize({ width: 390, height: 844 }); + + await page.getByTestId('stack-deploy-button').click(); + await expect(page.getByTestId('stack-operation-banner')).toBeVisible({ timeout: 10_000 }); + }); + + test('modal style shows no inline banner', async ({ page }) => { + test.setTimeout(120_000); + + await enableDeployFeedback(page); + await setDeployStyle(page, 'modal'); + await setHealthGateWindow(page, 15); + await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE); + await syncDeployFeedbackState(page); + + await page.getByTestId('stack-deploy-button').click(); + await page.mouse.move(0, 0); + + await expect(page.locator('[data-testid="deploy-feedback-modal"]')).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId('stack-operation-banner')).toHaveCount(0); + }); +}); diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 1200a3a5..d83670c2 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -198,7 +198,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { }); try { - const result = await runWithLog({ stackName: stackName.trim(), action: 'install' }, async (started, ds) => { + const result = await runWithLog({ stackName: stackName.trim(), action: 'install', nodeId: activeNode?.id ?? null }, async (started, ds) => { await started; const res = await apiFetch('/templates/deploy', withDeploySession(ds, { method: 'POST', diff --git a/frontend/src/components/DeployFeedbackModal.tsx b/frontend/src/components/DeployFeedbackModal.tsx index 572383f4..49754f00 100644 --- a/frontend/src/components/DeployFeedbackModal.tsx +++ b/frontend/src/components/DeployFeedbackModal.tsx @@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button'; import { StructuredLogRow } from '@/components/log-rendering/StructuredLogRow'; import TerminalComponent from '@/components/Terminal'; import { useDeployFeedback, VERB_LABELS, type HealthGateUiState } from '@/context/DeployFeedbackContext'; +import { useDeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style'; const AUTO_CLOSE_SECONDS = 4; @@ -40,6 +41,7 @@ function formatElapsed(seconds: number): string { export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) { const { panelState, healthGate, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback(); + const [style] = useDeployFeedbackStyle(); const [showRaw, setShowRaw] = useState(false); const [elapsedSeconds, setElapsedSeconds] = useState(0); @@ -116,8 +118,11 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM // or success whose gate passed. An observing gate suspends the countdown; a // failed/unknown gate keeps the modal open until the user closes it. const gateHoldsOpen = healthGate !== null && healthGate.status !== 'passed'; + // Inline style never auto-closes the modal: the banner owns the lifecycle and + // a manually opened log should not be yanked mid-read. + const canAutoClose = status === 'succeeded' && isOpen && !gateHoldsOpen && style === 'modal'; useEffect(() => { - if (status === 'succeeded' && isOpen && !gateHoldsOpen) { + if (canAutoClose) { // eslint-disable-next-line react-hooks/set-state-in-effect startCountdown(); } else { @@ -127,7 +132,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM return () => { clearCountdownInterval(); }; - }, [status, isOpen, gateHoldsOpen, startCountdown, clearCountdownInterval]); + }, [canAutoClose, startCountdown, clearCountdownInterval]); useEffect(() => { if (!userScrolledUp && scrollRef.current) { @@ -149,16 +154,27 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM const handleMouseLeave = useCallback(() => { autoCloseHoveredRef.current = false; - if (status === 'succeeded' && isOpen && !gateHoldsOpen) { + if (canAutoClose) { startCountdown(); } - }, [status, isOpen, gateHoldsOpen, startCountdown]); + }, [canAutoClose, startCountdown]); + + // In Inline style, closing only hides the modal while the banner is still the + // surface (running or cleanly done). When the op or its gate failed the banner + // steps aside for the recovery surface, so there is nothing to return to: + // closing ends the session (and clears the fallback pill), as Modal style + // always does. + const inlineFailed = status === 'failed' || healthGate?.status === 'failed'; + const closeSurface = useCallback(() => { + if (style === 'inline' && !inlineFailed) onMinimize(); + else onPanelClose(); + }, [style, inlineFailed, onMinimize, onPanelClose]); const handleOpenChange = useCallback( (open: boolean) => { - if (!open) onPanelClose(); + if (!open) closeSurface(); }, - [onPanelClose] + [closeSurface] ); const isDialogOpen = isOpen && !isMinimized; @@ -229,7 +245,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM variant="ghost" size="icon" className="h-7 w-7 hover:bg-destructive/10 hover:text-destructive" - onClick={onPanelClose} + onClick={closeSurface} title="Close" > @@ -281,18 +297,30 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM )} - {/* always mounted so onMessage feeds structured rows even before user toggles raw view */} -
- -
+ {/* Modal style owns the live terminal here, always mounted so onMessage + feeds structured rows even before the user toggles raw view. In Inline + style the portal owns the single socket, so this modal (opened on + demand) renders a text view of the captured lines instead, avoiding a + second socket for the same session. */} + {style === 'modal' ? ( +
+ +
+ ) : showRaw ? ( +
+
+              {logRows.map((r) => r.raw).join('\n')}
+            
+
+ ) : null} {/* Footer */}
@@ -320,7 +348,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM variant="ghost" size="sm" className="h-7 gap-1.5 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive" - onClick={onPanelClose} + onClick={closeSurface} > Close diff --git a/frontend/src/components/DeployFeedbackPortal.tsx b/frontend/src/components/DeployFeedbackPortal.tsx index 40533774..f8b1cc54 100644 --- a/frontend/src/components/DeployFeedbackPortal.tsx +++ b/frontend/src/components/DeployFeedbackPortal.tsx @@ -1,29 +1,43 @@ -import { useEffect, useState } from 'react'; import { useDeployFeedback } from '@/context/DeployFeedbackContext'; +import { useDeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style'; +import TerminalComponent from './Terminal'; import { DeployFeedbackModal } from './DeployFeedbackModal'; import { DeployFeedbackPill } from './DeployFeedbackPill'; export function DeployFeedbackPortal() { - const [isMinimized, setIsMinimized] = useState(false); - const { panelState } = useDeployFeedback(); - - useEffect(() => { - if (!panelState.isOpen) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsMinimized(false); - } - }, [panelState.isOpen]); + const { panelState, minimized, setMinimized, bannerActive, onTerminalReady, onTerminalError, onMessage } = useDeployFeedback(); + const [style] = useDeployFeedbackStyle(); return ( <> setIsMinimized(true)} + isMinimized={minimized} + onMinimize={() => setMinimized(true)} /> + {/* The pill is the minimized surface. In Modal style it shows whenever the + modal is minimized. In Inline style the in-page banner is the surface, + so the pill only fills in when the banner is not covering the session: + an App Store install, after navigating away from the stack, or a failed + op the banner steps aside for. This keeps a click-through to the log in + every case without ever overlapping the banner. */} setIsMinimized(false)} + isVisible={panelState.isOpen && minimized && (style === 'modal' || !bannerActive)} + onExpand={() => setMinimized(false)} /> + {/* Inline style streams without the modal: the modal owns the terminal in + Modal style, but in Inline style the modal stays closed, so mount the + single progress terminal here (hidden) to feed logRows to the banner. + Exactly one terminal owns the per-session socket at a time. */} + {style === 'inline' && panelState.isOpen && ( +
+ +
+ )} ); } diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 9dd54a76..9700cd3c 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -426,6 +426,7 @@ export default function EditorLayout() { else toast.error('Could not refresh container state.'); }} onDismissRecovery={() => { if (selectedFile) dismissActionResult(selectedFile); }} + panelStartedAt={panelStartedAt} onMobileBack={goToMobileList} /> ); diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 12e390b2..95cccff0 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -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(null); @@ -357,6 +363,12 @@ export function EditorView(props: EditorViewProps) { )}
+ ({ })); vi.mock('../StackAnatomyPanel', () => ({ default: () =>
compose-pane
})); 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 { return { diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index ff5b5be4..1cf9e1cd 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -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('logs'); @@ -109,6 +111,14 @@ export function MobileStackDetail(props: EditorViewProps) { /> + + {recoveryResult && loadingAction == null && (