mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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.
|
||||
|
||||
<Note>
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/deploy-progress/setting-toggle.png" alt="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." />
|
||||
<img src="/images/deploy-progress/setting-toggle.png" alt="Settings Appearance Display panel with the deploy progress toggle." />
|
||||
</Frame>
|
||||
|
||||
## Using the modal
|
||||
@@ -88,6 +93,17 @@ Minimizing, closing, or navigating away never cancels the operation. The progres
|
||||
<img src="/images/deploy-progress/pill.png" alt="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'" />
|
||||
</Frame>
|
||||
|
||||
## 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
|
||||
<Accordion title="An update stalled or appears stuck">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="I turned the setting off but the modal still appears">
|
||||
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.
|
||||
<Accordion title="I changed the deploy progress setting but it did not take effect">
|
||||
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.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/settings/appearance-density.png" alt="Appearance settings showing the Density selector, Deploy progress modal, and Diff preview toggles" />
|
||||
<img src="/images/settings/appearance-density.png" alt="Appearance settings showing the Density selector, the Deploy progress toggle, and the Diff preview toggle" />
|
||||
</Frame>
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
}, 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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
@@ -281,7 +297,12 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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' ? (
|
||||
<div
|
||||
className="border-t border-glass-border shrink-0"
|
||||
style={{ height: showRaw ? '200px' : 0, overflow: 'hidden' }}
|
||||
@@ -293,6 +314,13 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
onMessage={onMessage}
|
||||
/>
|
||||
</div>
|
||||
) : showRaw ? (
|
||||
<div className="border-t border-glass-border shrink-0 h-[200px] overflow-auto bg-well">
|
||||
<pre className="px-3 py-2 font-mono text-[11px] leading-[1.4] text-foreground/80 whitespace-pre-wrap break-words">
|
||||
{logRows.map((r) => r.raw).join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-t border-glass-border shrink-0">
|
||||
@@ -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}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Close
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<DeployFeedbackModal
|
||||
isMinimized={isMinimized}
|
||||
onMinimize={() => 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. */}
|
||||
<DeployFeedbackPill
|
||||
isVisible={panelState.isOpen && isMinimized}
|
||||
onExpand={() => 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 && (
|
||||
<div aria-hidden className="h-0 overflow-hidden">
|
||||
<TerminalComponent
|
||||
deploySessionId={panelState.deploySessionId}
|
||||
onReady={onTerminalReady}
|
||||
onError={onTerminalError}
|
||||
onMessage={onMessage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
)}
|
||||
{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;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DeployFeedbackModal } from '../DeployFeedbackModal';
|
||||
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
|
||||
|
||||
const onPanelClose = vi.fn();
|
||||
const onMinimize = vi.fn();
|
||||
let mockStyle: 'modal' | 'inline';
|
||||
let mockPanelState: DeployPanelState;
|
||||
|
||||
vi.mock('@/context/DeployFeedbackContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/DeployFeedbackContext')>();
|
||||
return {
|
||||
...actual,
|
||||
useDeployFeedback: () => ({
|
||||
panelState: mockPanelState,
|
||||
healthGate: null,
|
||||
logRows: [],
|
||||
lastOutputAt: 0,
|
||||
onTerminalReady: vi.fn(),
|
||||
onTerminalError: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
onPanelClose,
|
||||
runWithLog: vi.fn(),
|
||||
minimized: false,
|
||||
setMinimized: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@/hooks/use-deploy-feedback-style', () => ({
|
||||
useDeployFeedbackStyle: () => [mockStyle, vi.fn()],
|
||||
}));
|
||||
vi.mock('@/components/Terminal', () => ({ default: () => <div data-testid="modal-terminal" /> }));
|
||||
|
||||
function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
|
||||
return {
|
||||
isOpen: true, stackName: 'web', nodeId: null, action: 'update', status: 'streaming',
|
||||
progressUnavailable: false, deploySessionId: 'abc', sessionId: 1, ...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onPanelClose.mockClear();
|
||||
onMinimize.mockClear();
|
||||
mockStyle = 'inline';
|
||||
mockPanelState = panel();
|
||||
});
|
||||
|
||||
function clickClose() {
|
||||
// Both the header icon and the footer button are named "Close" and route
|
||||
// through the same handler; either click exercises it.
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]);
|
||||
}
|
||||
|
||||
describe('DeployFeedbackModal Inline vs Modal style', () => {
|
||||
it('inline style: closing only hides the modal (keeps the session for the banner)', () => {
|
||||
mockStyle = 'inline';
|
||||
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
|
||||
clickClose();
|
||||
expect(onMinimize).toHaveBeenCalledTimes(1);
|
||||
expect(onPanelClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('inline style: closing a failed op ends the session (the banner has stepped aside)', () => {
|
||||
mockStyle = 'inline';
|
||||
mockPanelState = panel({ status: 'failed' });
|
||||
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
|
||||
clickClose();
|
||||
expect(onPanelClose).toHaveBeenCalledTimes(1);
|
||||
expect(onMinimize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('modal style: closing ends the session', () => {
|
||||
mockStyle = 'modal';
|
||||
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
|
||||
clickClose();
|
||||
expect(onPanelClose).toHaveBeenCalledTimes(1);
|
||||
expect(onMinimize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('modal style owns the live terminal; inline style renders no terminal (single socket)', () => {
|
||||
mockStyle = 'modal';
|
||||
const view = render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
|
||||
expect(screen.getByTestId('modal-terminal')).toBeInTheDocument();
|
||||
view.unmount();
|
||||
|
||||
mockStyle = 'inline';
|
||||
render(<DeployFeedbackModal isMinimized={false} onMinimize={onMinimize} />);
|
||||
expect(screen.queryByTestId('modal-terminal')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ let runOuter: Promise<unknown> | null = null;
|
||||
function Driver() {
|
||||
const { runWithLog } = useDeployFeedback();
|
||||
React.useEffect(() => {
|
||||
runOuter = runWithLog({ stackName: 'web', action: 'update' }, async (started) => {
|
||||
runOuter = runWithLog({ stackName: 'web', action: 'update', nodeId: null }, async (started) => {
|
||||
await started;
|
||||
return new Promise<{ ok: boolean; errorMessage?: string; healthGateId?: string | null }>((res) => { resolveRun = res; });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { DeployFeedbackPortal } from '../DeployFeedbackPortal';
|
||||
import type { DeployPanelState } from '@/context/DeployFeedbackContext';
|
||||
|
||||
let mockPanelState: DeployPanelState;
|
||||
let mockStyle: 'modal' | 'inline';
|
||||
let mockBannerActive: boolean;
|
||||
|
||||
vi.mock('@/context/DeployFeedbackContext', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/DeployFeedbackContext')>();
|
||||
return {
|
||||
...actual,
|
||||
useDeployFeedback: () => ({
|
||||
panelState: mockPanelState,
|
||||
minimized: true,
|
||||
setMinimized: vi.fn(),
|
||||
bannerActive: mockBannerActive,
|
||||
setBannerActive: vi.fn(),
|
||||
onTerminalReady: vi.fn(),
|
||||
onTerminalError: vi.fn(),
|
||||
onMessage: vi.fn(),
|
||||
healthGate: null,
|
||||
logRows: [],
|
||||
lastOutputAt: 0,
|
||||
runWithLog: vi.fn(),
|
||||
onPanelClose: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@/hooks/use-deploy-feedback-style', () => ({
|
||||
useDeployFeedbackStyle: () => [mockStyle, vi.fn()],
|
||||
}));
|
||||
vi.mock('../DeployFeedbackModal', () => ({ DeployFeedbackModal: () => <div data-testid="modal-marker" /> }));
|
||||
vi.mock('../DeployFeedbackPill', () => ({
|
||||
DeployFeedbackPill: ({ isVisible }: { isVisible: boolean }) => (isVisible ? <div data-testid="pill-marker" /> : null),
|
||||
}));
|
||||
vi.mock('../Terminal', () => ({ default: () => <div data-testid="portal-terminal" /> }));
|
||||
|
||||
function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
|
||||
return {
|
||||
isOpen: false, stackName: '', nodeId: null, action: 'deploy', status: 'preparing',
|
||||
progressUnavailable: false, deploySessionId: '', sessionId: 0, ...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockStyle = 'inline';
|
||||
mockBannerActive = false;
|
||||
mockPanelState = panel();
|
||||
});
|
||||
|
||||
// Exactly one terminal owns the per-session socket: the portal mounts it in
|
||||
// Inline style (the modal stays closed there), and the modal owns it in Modal
|
||||
// style. This pins the portal's half of that invariant.
|
||||
describe('DeployFeedbackPortal', () => {
|
||||
it('mounts the progress terminal in inline style while a session is open', () => {
|
||||
mockStyle = 'inline';
|
||||
mockPanelState = panel({ isOpen: true });
|
||||
render(<DeployFeedbackPortal />);
|
||||
expect(screen.getByTestId('portal-terminal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not mount the portal terminal in modal style (the modal owns it)', () => {
|
||||
mockStyle = 'modal';
|
||||
mockPanelState = panel({ isOpen: true });
|
||||
render(<DeployFeedbackPortal />);
|
||||
expect(screen.queryByTestId('portal-terminal')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not mount the portal terminal when no session is open', () => {
|
||||
mockStyle = 'inline';
|
||||
mockPanelState = panel({ isOpen: false });
|
||||
render(<DeployFeedbackPortal />);
|
||||
expect(screen.queryByTestId('portal-terminal')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the minimize pill in modal style when open and minimized', () => {
|
||||
mockStyle = 'modal';
|
||||
mockPanelState = panel({ isOpen: true });
|
||||
render(<DeployFeedbackPortal />);
|
||||
expect(screen.getByTestId('pill-marker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the pill in inline style while the banner is covering the session', () => {
|
||||
mockStyle = 'inline';
|
||||
mockBannerActive = true;
|
||||
mockPanelState = panel({ isOpen: true });
|
||||
render(<DeployFeedbackPortal />);
|
||||
expect(screen.queryByTestId('pill-marker')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the fallback pill in inline style when the banner is not active', () => {
|
||||
mockStyle = 'inline';
|
||||
mockBannerActive = false; // App Store, off-detail, or a failed op
|
||||
mockPanelState = panel({ isOpen: true });
|
||||
render(<DeployFeedbackPortal />);
|
||||
expect(screen.getByTestId('pill-marker')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { useDensity } from '@/hooks/use-density';
|
||||
import type { Density } from '@/hooks/use-density';
|
||||
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { useDeployFeedbackStyle, type DeployFeedbackStyle } from '@/hooks/use-deploy-feedback-style';
|
||||
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
||||
import { useTheme, THEME_MODE_OPTIONS, ACCENTS, CONTRAST, BORDER_BOOST, GLOW, TYPE_SCALE } from '@/hooks/use-theme';
|
||||
import { AccentPicker } from '@/components/theme/AccentPicker';
|
||||
@@ -25,11 +26,17 @@ const DENSITY_DESCRIPTIONS: Record<Density, string> = {
|
||||
compact: 'Tighter rows and tiles. Fits more on screen for dense dashboards.',
|
||||
};
|
||||
|
||||
const DEPLOY_STYLE_OPTIONS: { value: DeployFeedbackStyle; label: string }[] = [
|
||||
{ value: 'modal', label: 'Modal' },
|
||||
{ value: 'inline', label: 'Inline' },
|
||||
];
|
||||
|
||||
const fmtSigned = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(2)}`;
|
||||
|
||||
export function AppearanceSection() {
|
||||
const [density, setDensity] = useDensity();
|
||||
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
|
||||
const [feedbackStyle, setFeedbackStyle] = useDeployFeedbackStyle();
|
||||
const [diffPreviewEnabled, setDiffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
||||
const {
|
||||
theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale,
|
||||
@@ -187,8 +194,8 @@ export function AppearanceSection() {
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Deploy progress modal"
|
||||
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."
|
||||
label="Deploy progress"
|
||||
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 it."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
@@ -205,6 +212,20 @@ export function AppearanceSection() {
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
{isEnabled && (
|
||||
<SettingsField
|
||||
label="Progress style"
|
||||
helper="Modal opens a centered overlay. Inline shows a quiet status on the stack detail with the full log a click away under View output."
|
||||
>
|
||||
<SegmentedControl
|
||||
value={feedbackStyle}
|
||||
options={DEPLOY_STYLE_OPTIONS}
|
||||
onChange={setFeedbackStyle}
|
||||
ariaLabel="Deploy progress style"
|
||||
/>
|
||||
</SettingsField>
|
||||
)}
|
||||
|
||||
<SettingsField
|
||||
label="Diff preview before save"
|
||||
helper="Show a side-by-side diff of compose and env edits before they reach disk."
|
||||
|
||||
@@ -7,6 +7,7 @@ function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
|
||||
return {
|
||||
isOpen: false,
|
||||
stackName: '',
|
||||
nodeId: null,
|
||||
action: 'deploy',
|
||||
status: 'preparing',
|
||||
progressUnavailable: false,
|
||||
|
||||
@@ -19,9 +19,9 @@ function notif(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
||||
};
|
||||
}
|
||||
|
||||
const IDLE_PANEL: DeployPanelState = { isOpen: false, stackName: '', action: 'deploy', status: 'preparing', progressUnavailable: false, deploySessionId: '', sessionId: 0 };
|
||||
const STREAMING_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'streaming', progressUnavailable: false, deploySessionId: '', sessionId: 1 };
|
||||
const SUCCEEDED_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'succeeded', progressUnavailable: false, deploySessionId: '', sessionId: 1 };
|
||||
const IDLE_PANEL: DeployPanelState = { isOpen: false, stackName: '', nodeId: null, action: 'deploy', status: 'preparing', progressUnavailable: false, deploySessionId: '', sessionId: 0 };
|
||||
const STREAMING_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', nodeId: null, action: 'deploy', status: 'streaming', progressUnavailable: false, deploySessionId: '', sessionId: 1 };
|
||||
const SUCCEEDED_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', nodeId: null, action: 'deploy', status: 'succeeded', progressUnavailable: false, deploySessionId: '', sessionId: 1 };
|
||||
|
||||
function inputs(overrides: Partial<Parameters<typeof deriveSummary>[0]> = {}) {
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,9 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/context/DeployFeedbackContext', () => ({
|
||||
useDeployFeedback: () => ({ runWithLog: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ activeNode: null }),
|
||||
}));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog';
|
||||
import { GitSourceFields, type ApplyMode } from './GitSourceFields';
|
||||
@@ -74,6 +75,7 @@ export function GitSourcePanel({
|
||||
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
|
||||
|
||||
const { runWithLog } = useDeployFeedback();
|
||||
const { activeNode } = useNodes();
|
||||
const applyMode = deriveApplyMode(source, applyModeOverride);
|
||||
|
||||
const resetToUnlinked = useCallback(() => {
|
||||
@@ -258,7 +260,7 @@ export function GitSourcePanel({
|
||||
};
|
||||
|
||||
if (deploy) {
|
||||
await runWithLog({ stackName, action: 'deploy' }, runApply);
|
||||
await runWithLog({ stackName, action: 'deploy', nodeId: activeNode?.id ?? null }, runApply);
|
||||
} else {
|
||||
await runApply(Promise.resolve());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useRef, useCallback, useEff
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { type ParsedLogRow, parseLogChunk } from '../components/log-rendering/composeLogParser';
|
||||
import { useDeployFeedbackEnabled } from '../hooks/use-deploy-feedback-enabled';
|
||||
import { readDeployFeedbackStyle } from '../hooks/use-deploy-feedback-style';
|
||||
|
||||
export type ActionVerb = 'deploy' | 'update' | 'down' | 'restart' | 'stop' | 'install';
|
||||
|
||||
@@ -18,6 +19,12 @@ export const VERB_LABELS: Record<ActionVerb, { present: string; past: string }>
|
||||
export interface DeployPanelState {
|
||||
isOpen: boolean;
|
||||
stackName: string;
|
||||
/**
|
||||
* Node the operation runs on (null = local), captured at runWithLog start.
|
||||
* The inline banner filters on it so an operation never bleeds onto a
|
||||
* same-named stack after an active-node switch.
|
||||
*/
|
||||
nodeId: number | null;
|
||||
action: ActionVerb;
|
||||
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
|
||||
errorMessage?: string;
|
||||
@@ -66,12 +73,38 @@ export interface HealthGateUiState {
|
||||
|
||||
const GATE_POLL_INTERVAL_MS = 4_000;
|
||||
|
||||
/** Parameters identifying the operation a runWithLog call drives. */
|
||||
export interface RunWithLogParams {
|
||||
stackName: string;
|
||||
action: ActionVerb;
|
||||
/** Node the operation runs on (null = local), for node-scoped surfaces. */
|
||||
nodeId: number | null;
|
||||
}
|
||||
|
||||
interface DeployFeedbackContextValue {
|
||||
runWithLog: (
|
||||
params: { stackName: string; action: ActionVerb },
|
||||
params: RunWithLogParams,
|
||||
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>
|
||||
) => Promise<RunResult>;
|
||||
panelState: DeployPanelState;
|
||||
/**
|
||||
* Whether the modal surface is hidden for the current session. In Modal style
|
||||
* a session starts visible; in Inline style it starts hidden (the banner is
|
||||
* the surface) and the banner's "View output" sets this false to summon the
|
||||
* modal. Lifted here so the in-page banner and the portal share one source.
|
||||
*/
|
||||
minimized: boolean;
|
||||
setMinimized: (next: boolean) => void;
|
||||
/**
|
||||
* Whether the in-page banner is currently showing this session (Inline style,
|
||||
* on the operation's own stack detail). The portal reads it to decide whether
|
||||
* the minimized pill is needed as the fallback surface: in Inline style the
|
||||
* pill shows only when the banner is not covering the session (App Store,
|
||||
* after navigating away, or a failed op the banner steps aside for), so the
|
||||
* two never overlap. The banner owns this flag.
|
||||
*/
|
||||
bannerActive: boolean;
|
||||
setBannerActive: (next: boolean) => void;
|
||||
/** Gate state for the current session, or null when no gate was started. */
|
||||
healthGate: HealthGateUiState | null;
|
||||
logRows: ParsedLogRow[];
|
||||
@@ -92,6 +125,7 @@ interface DeployFeedbackContextValue {
|
||||
const DEFAULT_PANEL_STATE: DeployPanelState = {
|
||||
isOpen: false,
|
||||
stackName: '',
|
||||
nodeId: null,
|
||||
action: 'deploy',
|
||||
status: 'preparing',
|
||||
progressUnavailable: false,
|
||||
@@ -160,6 +194,15 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
|
||||
const [isEnabled] = useDeployFeedbackEnabled();
|
||||
|
||||
// Whether the modal surface is hidden for the current session. Set per style
|
||||
// at session start (see runWithLog); the banner toggles it via setMinimized.
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
|
||||
// Whether the in-page banner is currently the visible surface (Inline style,
|
||||
// on the operation's stack detail). Owned by the banner; the portal reads it
|
||||
// to gate the fallback pill so the two never overlap.
|
||||
const [bannerActive, setBannerActive] = useState(false);
|
||||
|
||||
const onTerminalReady = useCallback(() => {
|
||||
streamReadyRef.current = true;
|
||||
setLastOutputAt(Date.now());
|
||||
@@ -204,6 +247,8 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
stopGatePolling();
|
||||
setHealthGate(null);
|
||||
setPanelState(DEFAULT_PANEL_STATE);
|
||||
setMinimized(false);
|
||||
setBannerActive(false);
|
||||
setLogRows([]);
|
||||
}, [stopGatePolling]);
|
||||
|
||||
@@ -280,7 +325,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
|
||||
const runWithLog = useCallback(
|
||||
async (
|
||||
params: { stackName: string; action: ActionVerb },
|
||||
params: RunWithLogParams,
|
||||
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>
|
||||
): Promise<RunResult> => {
|
||||
// Unique, unguessable per-deploy id correlating the progress socket with
|
||||
@@ -297,12 +342,23 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
return run(Promise.resolve(), deploySessionId);
|
||||
}
|
||||
|
||||
// Read the persisted style synchronously so this deploy uses the style in
|
||||
// effect when it starts. The surfaces read the style reactively as they
|
||||
// render, so flipping the Modal/Inline setting during an in-flight
|
||||
// operation is unsupported: it would hand the single progress socket
|
||||
// between the modal and the portal mid-stream and lose the gap. Change
|
||||
// the style between operations.
|
||||
const inlineStyle = readDeployFeedbackStyle() === 'inline';
|
||||
|
||||
// Cancel any existing session before starting a new one.
|
||||
sessionIdRef.current += 1;
|
||||
const mySession = sessionIdRef.current;
|
||||
streamReadyRef.current = false;
|
||||
stopGatePolling();
|
||||
setHealthGate(null);
|
||||
// Inline style starts with the modal hidden (the banner is the surface);
|
||||
// modal style starts visible.
|
||||
setMinimized(inlineStyle);
|
||||
|
||||
// idCounterRef is intentionally not reset; keys must remain globally unique across sessions.
|
||||
setLogRows([]);
|
||||
@@ -311,6 +367,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
setPanelState({
|
||||
isOpen: true,
|
||||
stackName: params.stackName,
|
||||
nodeId: params.nodeId,
|
||||
action: params.action,
|
||||
status: 'preparing',
|
||||
progressUnavailable: false,
|
||||
@@ -318,6 +375,10 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
sessionId: mySession,
|
||||
});
|
||||
|
||||
// Gate the deploy on the progress stream connecting (the modal owns it in
|
||||
// Modal style, the portal's hidden stream in Inline style), so the first
|
||||
// output lines are not missed. The timer is the last-resort release if it
|
||||
// neither connects nor errors.
|
||||
const deployStarted = new Promise<void>((resolve) => {
|
||||
let done = false;
|
||||
const settle = () => {
|
||||
@@ -330,7 +391,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
// by which point `timer` is assigned, so the forward reference is safe.
|
||||
const timer = setTimeout(() => {
|
||||
// The stream neither connected nor errored within the window. Mark live
|
||||
// output unavailable (so the modal stops showing "Connecting...") and
|
||||
// output unavailable (so the surface stops showing "Connecting...") and
|
||||
// release the deploy so a silent stall never blocks it.
|
||||
setPanelState((prev) => (prev.isOpen ? { ...prev, progressUnavailable: true } : prev));
|
||||
settle();
|
||||
@@ -364,7 +425,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
|
||||
return (
|
||||
<DeployFeedbackContext.Provider
|
||||
value={{ runWithLog, panelState, healthGate, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
|
||||
value={{ runWithLog, panelState, minimized, setMinimized, bannerActive, setBannerActive, healthGate, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
|
||||
>
|
||||
{children}
|
||||
</DeployFeedbackContext.Provider>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { renderHook, act } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { DeployFeedbackProvider, useDeployFeedback } from '../DeployFeedbackContext';
|
||||
import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { DEPLOY_FEEDBACK_STYLE_KEY } from '@/hooks/use-deploy-feedback-style';
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <DeployFeedbackProvider>{children}</DeployFeedbackProvider>;
|
||||
@@ -22,7 +23,7 @@ describe('DeployFeedbackContext', () => {
|
||||
let deployRan = false;
|
||||
let outer: Promise<unknown> | undefined;
|
||||
await act(async () => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy', nodeId: null }, async (started) => {
|
||||
await started;
|
||||
deployRan = true;
|
||||
return { ok: true };
|
||||
@@ -52,7 +53,7 @@ describe('DeployFeedbackContext', () => {
|
||||
let runCount = 0;
|
||||
let outer: Promise<unknown> | undefined;
|
||||
await act(async () => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy', nodeId: null }, async (started) => {
|
||||
await started;
|
||||
runCount += 1;
|
||||
return { ok: true };
|
||||
@@ -84,7 +85,7 @@ describe('DeployFeedbackContext', () => {
|
||||
let runCount = 0;
|
||||
let outer: Promise<unknown> | undefined;
|
||||
await act(async () => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy', nodeId: null }, async (started) => {
|
||||
await started;
|
||||
runCount += 1;
|
||||
return { ok: true };
|
||||
@@ -128,7 +129,7 @@ describe('DeployFeedbackContext', () => {
|
||||
|
||||
let deployRan = false;
|
||||
await act(async () => {
|
||||
await result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
|
||||
await result.current.runWithLog({ stackName: 'web', action: 'deploy', nodeId: null }, async (started) => {
|
||||
await started;
|
||||
deployRan = true;
|
||||
return { ok: true };
|
||||
@@ -151,4 +152,88 @@ describe('DeployFeedbackContext', () => {
|
||||
expect(result.current.logRows[0].id).toBe('row-truncated');
|
||||
expect(result.current.logRows[result.current.logRows.length - 1].message).toContain('line 5999');
|
||||
});
|
||||
|
||||
it('stores the operation node id on the panel state', async () => {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_STYLE_KEY, 'inline');
|
||||
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||
|
||||
let outer: Promise<unknown> | undefined;
|
||||
await act(async () => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'update', nodeId: 7 }, async (s) => { await s; return { ok: true }; });
|
||||
await Promise.resolve();
|
||||
});
|
||||
// nodeId is stamped synchronously when the panel opens, before the gate.
|
||||
expect(result.current.panelState.nodeId).toBe(7);
|
||||
|
||||
await act(async () => { result.current.onTerminalReady(); await outer; });
|
||||
});
|
||||
|
||||
it('minimized is false before any session', () => {
|
||||
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||
expect(result.current.minimized).toBe(false);
|
||||
});
|
||||
|
||||
it('inline style starts the session minimized (banner is the surface)', async () => {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_STYLE_KEY, 'inline');
|
||||
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||
|
||||
let outer: Promise<unknown> | undefined;
|
||||
await act(async () => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'update', nodeId: null }, async (started) => {
|
||||
await started;
|
||||
return { ok: true };
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.minimized).toBe(true);
|
||||
expect(result.current.panelState.isOpen).toBe(true);
|
||||
|
||||
await act(async () => { result.current.onTerminalReady(); await outer; });
|
||||
});
|
||||
|
||||
it('modal style starts not minimized and gates the deploy on the stream', async () => {
|
||||
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||
|
||||
let deployRan = false;
|
||||
let outer: Promise<unknown> | undefined;
|
||||
await act(async () => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy', nodeId: 3 }, async (started) => {
|
||||
await started;
|
||||
deployRan = true;
|
||||
return { ok: true };
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.minimized).toBe(false);
|
||||
expect(result.current.panelState.nodeId).toBe(3);
|
||||
expect(deployRan).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
result.current.onTerminalReady();
|
||||
await outer;
|
||||
});
|
||||
expect(deployRan).toBe(true);
|
||||
});
|
||||
|
||||
it('setMinimized toggles, and onPanelClose resets it', async () => {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_STYLE_KEY, 'inline');
|
||||
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||
|
||||
let outer: Promise<unknown> | undefined;
|
||||
await act(async () => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'update', nodeId: null }, async (s) => { await s; return { ok: true }; });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.minimized).toBe(true);
|
||||
|
||||
act(() => result.current.setMinimized(false));
|
||||
expect(result.current.minimized).toBe(false);
|
||||
|
||||
await act(async () => { result.current.onTerminalReady(); await outer; });
|
||||
|
||||
act(() => result.current.onPanelClose());
|
||||
expect(result.current.minimized).toBe(false);
|
||||
expect(result.current.panelState.isOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDeployFeedbackStyle, DEPLOY_FEEDBACK_STYLE_KEY } from '../use-deploy-feedback-style';
|
||||
|
||||
describe('useDeployFeedbackStyle (modal default)', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('defaults to modal when no value is stored', () => {
|
||||
const { result } = renderHook(() => useDeployFeedbackStyle());
|
||||
expect(result.current[0]).toBe('modal');
|
||||
});
|
||||
|
||||
it('reads inline only when explicitly set to inline', () => {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_STYLE_KEY, 'inline');
|
||||
const { result } = renderHook(() => useDeployFeedbackStyle());
|
||||
expect(result.current[0]).toBe('inline');
|
||||
});
|
||||
|
||||
it('treats any unknown value as modal', () => {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_STYLE_KEY, 'something-else');
|
||||
const { result } = renderHook(() => useDeployFeedbackStyle());
|
||||
expect(result.current[0]).toBe('modal');
|
||||
});
|
||||
|
||||
it('setStyle persists and switches', () => {
|
||||
const { result } = renderHook(() => useDeployFeedbackStyle());
|
||||
act(() => result.current[1]('inline'));
|
||||
expect(result.current[0]).toBe('inline');
|
||||
expect(localStorage.getItem(DEPLOY_FEEDBACK_STYLE_KEY)).toBe('inline');
|
||||
act(() => result.current[1]('modal'));
|
||||
expect(result.current[0]).toBe('modal');
|
||||
});
|
||||
|
||||
it('reacts to a storage event from another tab', () => {
|
||||
const { result } = renderHook(() => useDeployFeedbackStyle());
|
||||
expect(result.current[0]).toBe('modal');
|
||||
act(() => {
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: DEPLOY_FEEDBACK_STYLE_KEY, newValue: 'inline' }));
|
||||
});
|
||||
expect(result.current[0]).toBe('inline');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
|
||||
export const DEPLOY_FEEDBACK_STYLE_KEY = 'sencho.deploy-feedback.style';
|
||||
|
||||
export type DeployFeedbackStyle = 'modal' | 'inline';
|
||||
|
||||
// How the live deploy/update progress surfaces while it streams. 'modal' (the
|
||||
// default) is the centered overlay; 'inline' is the quiet in-page banner on the
|
||||
// stack detail with the full log behind its "View output" button. Only an
|
||||
// explicit 'inline' selects the banner; anything else stays on the modal, so a
|
||||
// missing or unknown value is safe.
|
||||
// Read the persisted style synchronously. Exported so non-React callers (the
|
||||
// deploy-feedback context at deploy time) can read the current value directly
|
||||
// rather than depend on a reactive snapshot that an event might not have
|
||||
// refreshed yet.
|
||||
export function readDeployFeedbackStyle(): DeployFeedbackStyle {
|
||||
if (typeof window === 'undefined') return 'modal';
|
||||
try {
|
||||
return window.localStorage.getItem(DEPLOY_FEEDBACK_STYLE_KEY) === 'inline' ? 'inline' : 'modal';
|
||||
} catch {
|
||||
// localStorage unavailable (private mode, quota): fall back to the modal default.
|
||||
return 'modal';
|
||||
}
|
||||
}
|
||||
|
||||
export function useDeployFeedbackStyle(): [DeployFeedbackStyle, (next: DeployFeedbackStyle) => void] {
|
||||
const [style, setStyleState] = useState<DeployFeedbackStyle>(readDeployFeedbackStyle);
|
||||
|
||||
useEffect(() => {
|
||||
function onSettingsChanged() {
|
||||
setStyleState(readDeployFeedbackStyle());
|
||||
}
|
||||
window.addEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, onSettingsChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.key !== DEPLOY_FEEDBACK_STYLE_KEY) return;
|
||||
setStyleState(event.newValue === 'inline' ? 'inline' : 'modal');
|
||||
}
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const setStyle = useCallback((next: DeployFeedbackStyle) => {
|
||||
try {
|
||||
window.localStorage.setItem(DEPLOY_FEEDBACK_STYLE_KEY, next);
|
||||
} catch {
|
||||
// ignore; localStorage may be unavailable (private mode, quota)
|
||||
}
|
||||
setStyleState(next);
|
||||
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED));
|
||||
}, []);
|
||||
|
||||
return [style, setStyle];
|
||||
}
|
||||
Reference in New Issue
Block a user