feat(deploy-logs): opt-in deploy progress modal with structured log rows (#779)

* feat(notifications): dispatch deploy_failure alert on stack action errors

* feat(terminal): add onReady and onMessage callback props

* feat(deploy-logs): add DeployLogContext with runWithLog API

* feat(deploy-logs): add DeployLogPanel bottom drawer with resize and minimize

* feat(deploy-logs): wire DeployLogContext to App and EditorLayout action runners

* test(deploy-logs): add E2E test for deploy log panel open, failure, and minimize

* docs(deploy-logs): add user-facing and internal architecture docs

* feat(deploy-logs): redesign as opt-in modal with structured log rows

Replace the full-width bottom drawer (DeployLogPanel) with a centered
modal that streams structured log output for deploy, stop, restart,
update, install, and Git apply operations. The modal is disabled by
default; users opt in from Settings -> Appearance.

Core changes:
- New DeployFeedbackContext with runWithLog() API: if opt-in is off,
  silently bypasses the UI so all call sites degrade to the existing
  toast behavior without code changes.
- composeLogParser.ts: pure parser that strips ANSI escapes and
  classifies compose output into stage badges (PULL, BUILD, CREATE,
  START, STOP, DOWN, WARN, ERR, LOG). 15 unit tests.
- StructuredLogRow.tsx: memoized row with timestamp, stage badge, and
  message. Error rows get a rose left rail; warn rows get a tinted bg.
- DeployFeedbackModal: Dialog-based, max-w-640px/max-h-70vh, elapsed
  timer, auto-close 4s on success (hover cancels), persistent on
  failure. Raw xterm output collapsible in footer.
- DeployFeedbackPill: minimized state anchored top-right, survives
  navigation, click restores modal.
- Wires App Store install (action: install), Git apply (action: deploy),
  and Git pull (action: update) in addition to the existing EditorLayout
  actions.
- Fixes Terminal.tsx WS URL in generic mode (was connecting to root path
  not proxied by Vite; now uses /ws).
- Settings: adds "Show deploy progress modal" checkbox to Appearance.
- Docs: renames deploy-logs.mdx to deploy-progress.mdx; updates
  internal architecture doc.

* fix(deploy-logs): connect Terminal in generic mode and move pill to bottom-center

Terminal was passed stackName which routes it to the stack logs WS
(container stdout). In that mode onReady is never called, so the
deployStarted gate never resolves and the compose command never runs.
Remove stackName so Terminal uses generic WS mode, which calls onReady
on open and streams compose output.

Also reposition the minimized pill from top-right to bottom-center
(fixed bottom-6 left-1/2 -translate-x-1/2) per UX feedback.

* docs(deploy-logs): update pill position to bottom center

* test(deploy-logs): rewrite E2E spec for deploy feedback modal

The old spec targeted the removed bottom-drawer DeployLogPanel and used
the wrong field name when calling POST /api/stacks (sent 'name' but the
endpoint reads 'stackName'), causing every test to fail with a 400 before
any UI assertions ran.

Fixes:
- POST /api/stacks body now uses 'stackName' matching the API contract
- All locators updated to target the new DeployFeedbackModal and
  DeployFeedbackPill components (data-testid attributes added)
- Added enableDeployFeedback helper to opt-in via localStorage before
  each test that expects the modal (feature is off by default)
- Added opt-in OFF test to confirm the modal is suppressed when disabled
- Minimize/expand test now asserts the pill appears and contains the
  stack name before clicking to restore the modal

* test(deploy-logs): fix compose file write endpoint in E2E helper

createStackViaApi was calling PUT /api/stacks/:name/files/docker-compose.yml
which does not exist. The correct endpoint is PUT /api/stacks/:name with
{ content } in the body.

* test(deploy-logs): use addInitScript to persist opt-in across reloads

The opt-in flag was set via page.evaluate before setupDeployStack, which
calls page.reload() and loginAs (a second navigation). Although localStorage
should persist across same-origin reloads, the React tree was reading
'false' on remount in CI. Switching to addInitScript guarantees the
localStorage value is set before any page script on every navigation, so
useDeployFeedbackEnabled's useState initializer always sees the right
value when React mounts.

* test(deploy-logs): verify localStorage and re-dispatch event before deploy

Adds syncDeployFeedbackState() called right before each deploy click in
the ON tests. It both verifies localStorage is set (failing the test
loudly with a clear message if not) and re-dispatches the
SENCHO_SETTINGS_CHANGED event to defeat any stale React state after
navigation. If the modal still does not appear with the assertion green,
the issue is downstream of localStorage and we have a clear signal.

* test(deploy-logs): wait for React re-render after dispatching opt-in event

After syncDeployFeedbackState dispatches SENCHO_SETTINGS_CHANGED, React
schedules the state update but does not flush it synchronously. The
click that follows can fire against the stale closure where isEnabled is
still false, so runWithLog takes its early-return path and the modal
never opens. A 200ms wait is enough to let React commit the new state
before the next interaction.

* test(deploy-logs): wait for stack file fetch before clicking deploy

deployStack() in EditorLayout returns early at 'if (!selectedFile)'
without calling runWithLog. selectedFile is set inside loadFile() after
GET /api/stacks/:name resolves. The previous setup clicked the stack in
the sidebar and immediately asked the test to click Deploy, racing the
fetch. CI backend logs confirmed no deploy POST ever fired for the ON
tests, while the OFF test passed only because it asserts non-existence.

Now setup awaits both the stack click and the file response together,
then verifies the action bar's deploy button is visible before returning.

* test(deploy-logs): wait for network idle and capture browser logs

Adds a networkidle wait plus a 500ms settle after the stack click so
React commits selectedFile and any follow-up env/container/backup
fetches drain before the deploy click. Also mirrors browser console
errors and pageerrors into the Playwright output so the next failure
ships with the React stack trace instead of just a 'modal not visible'
message.

* test(deploy-logs): temporary debug logging in runWithLog

Adds a console.log at the entry of runWithLog so we can see in CI logs
whether it is being called and what isEnabled value the closure has.
Also widens the test's console capture to include these debug lines.

This is diagnostic only and will be removed once the root cause of the
modal-not-opening-in-CI failure is identified.

* test(deploy-logs): debug log at deployStack entry to trace click path

Adds console.log at the first line of deployStack handler so we can
confirm in CI whether the click is reaching it at all and what
selectedFile/isStackBusy resolve to. Combined with the existing
runWithLog debug logs, this isolates whether the modal failure is in
deployStack guarding out, runWithLog early-returning, or something
else entirely.

* test(deploy-logs): drop filter, log every browser console msg

The previous filter only emitted error/warning plus the deploy-feedback
substring. The deploy-feedback debug logs never appeared, so we don't
yet know whether the log itself is firing. Remove the filter so the
full console stream shows up in CI.

* test(deploy-logs): app-level console log to verify capture pipeline

If even an unconditional log at App component render time does not
appear in CI browser logs, then the console capture listener is broken
or the dispatched logs are being filtered upstream of Playwright. This
isolates whether the issue is in the production code or the test
harness.

* test(deploy-logs): use testid locator for stack action button

Replaces the regex-based getByRole locator (/Deploy|Start/i) with
getByTestId('stack-deploy-button'). The regex matched something other
than the actual deploy button: backend logs proved no deploy POST ever
fired, and instrumentation confirmed neither deployStack nor runWithLog
ran on click despite the test claiming success.

Adds data-testid='stack-deploy-button' to both the Restart and Start
button branches in EditorLayout's action bar so the same locator works
whether the stack is running or not.

Also drops the temporary debug console.log entries in deployStack,
runWithLog, and App, and restores the test's console listener filter
to only emit error and warning messages.

* test(deploy-logs): park cursor in corner so auto-close countdown fires

After clicking the deploy button, the cursor lands inside the centered
modal. The modal pauses its 4s auto-close countdown on hover, so the
HAPPY test was waiting for a close that never happened. page.mouse.move
to (0,0) parks the cursor outside the modal before the success banner
appears, letting the countdown complete.

* test(deploy-logs): drop redundant loginAs after page.reload

page.reload preserves auth cookies, so the page lands back on the
dashboard without needing a fresh login. The loginAs call after reload
was racing on isLoginPage(): a transient login-page state during page
load made loginAs commit to filling #username, then the dashboard
committed and #username never came back. Playwright's auto-wait then
hung the fill until the test's 120s timeout, which also dragged later
stacks.spec tests down with collateral timeouts.

waitForStacksLoaded is enough to confirm we're on the dashboard with
the sidebar populated before clicking the new stack.

* test(e2e): make loginAs race-safe when login page is a false positive

isLoginPage() reports the page as a login screen if the Login button
locator reports visible at the moment of the check. Under CI load (more
real container deploys from the deploy-log-panel suite), the auth
context can render the login form for one paint, then redirect to the
dashboard. The original code committed to filling #username and hung
until the test timeout when the field was no longer there.

Now the login branch waits up to 2s for #username to actually appear
before filling. If it never appears, we fall through to the dashboard
check instead of hanging.
This commit is contained in:
Anso
2026-04-26 00:43:54 -04:00
committed by GitHub
parent 6986b927e3
commit dd9d33813b
25 changed files with 1641 additions and 93 deletions
@@ -0,0 +1,232 @@
/**
* Integration tests verifying that deploy_failure notifications are dispatched
* when stack action routes encounter errors.
*
* Covers: deploy, down, restart, stop, update
*
* ComposeService and DockerController are mocked so no real Docker daemon is
* required. NotificationService.dispatchAlert is spied on to assert dispatch.
*/
import { describe, it, expect, beforeAll, afterAll, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
// ── Hoisted mocks (must come before importing the app) ──────────────────────
const {
mockDeployStack,
mockRunCommand,
mockUpdateStack,
mockGetContainersByStack,
mockRestartContainer,
mockStopContainer,
} = vi.hoisted(() => ({
mockDeployStack: vi.fn(),
mockRunCommand: vi.fn(),
mockUpdateStack: vi.fn(),
mockGetContainersByStack: vi.fn(),
mockRestartContainer: vi.fn(),
mockStopContainer: vi.fn(),
}));
vi.mock('../services/ComposeService', async () => {
const actual = await vi.importActual<typeof import('../services/ComposeService')>(
'../services/ComposeService',
);
return {
...actual,
ComposeService: {
...actual.ComposeService,
getInstance: () => ({
deployStack: mockDeployStack,
runCommand: mockRunCommand,
updateStack: mockUpdateStack,
}),
},
};
});
vi.mock('../services/DockerController', async () => {
const actual = await vi.importActual<typeof import('../services/DockerController')>(
'../services/DockerController',
);
return {
...actual,
default: {
...actual.default,
getInstance: () => ({
getContainersByStack: mockGetContainersByStack,
restartContainer: mockRestartContainer,
stopContainer: mockStopContainer,
}),
},
};
});
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
getStacks: vi.fn().mockResolvedValue([]),
getBaseDir: () => '/tmp/compose',
readComposeFile: vi.fn().mockResolvedValue(''),
}),
},
}));
// ── Setup ───────────────────────────────────────────────────────────────────
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let dispatchAlertSpy: ReturnType<typeof vi.spyOn>;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
const { NotificationService } = await import('../services/NotificationService');
dispatchAlertSpy = vi
.spyOn(NotificationService.getInstance(), 'dispatchAlert')
.mockResolvedValue(undefined);
});
afterAll(() => {
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
beforeEach(() => {
mockDeployStack.mockReset();
mockRunCommand.mockReset();
mockUpdateStack.mockReset();
mockGetContainersByStack.mockReset();
mockRestartContainer.mockReset();
mockStopContainer.mockReset();
dispatchAlertSpy.mockClear();
});
// ── Tests ───────────────────────────────────────────────────────────────────
describe('deploy_failure notification on /deploy error', () => {
it('dispatches deploy_failure alert with correct stackName when deployStack throws', async () => {
mockDeployStack.mockRejectedValue(new Error('image pull failed'));
const res = await request(app)
.post('/api/stacks/myapp/deploy')
.set('Cookie', authCookie);
expect(res.status).toBe(500);
await new Promise(resolve => setImmediate(resolve));
expect(dispatchAlertSpy).toHaveBeenCalledWith(
'error',
'deploy_failure',
expect.stringContaining('image pull failed'),
{ stackName: 'myapp' },
);
});
it('includes the error message in the dispatched alert', async () => {
mockDeployStack.mockRejectedValue(new Error('network timeout'));
await request(app)
.post('/api/stacks/webapp/deploy')
.set('Cookie', authCookie);
await new Promise(resolve => setImmediate(resolve));
const call = dispatchAlertSpy.mock.calls[0];
expect(call[0]).toBe('error');
expect(call[1]).toBe('deploy_failure');
expect(call[2]).toContain('network timeout');
expect(call[3]).toEqual({ stackName: 'webapp' });
});
});
describe('deploy_failure notification on /down error', () => {
it('dispatches deploy_failure alert when runCommand (down) throws', async () => {
mockRunCommand.mockRejectedValue(new Error('container removal error'));
const res = await request(app)
.post('/api/stacks/myapp/down')
.set('Cookie', authCookie);
expect(res.status).toBe(500);
await new Promise(resolve => setImmediate(resolve));
expect(dispatchAlertSpy).toHaveBeenCalledWith(
'error',
'deploy_failure',
expect.any(String),
{ stackName: 'myapp' },
);
});
});
describe('deploy_failure notification on /restart error', () => {
it('dispatches deploy_failure alert when restartContainer throws', async () => {
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
mockRestartContainer.mockRejectedValue(new Error('restart daemon error'));
const res = await request(app)
.post('/api/stacks/myapp/restart')
.set('Cookie', authCookie);
expect(res.status).toBe(500);
await new Promise(resolve => setImmediate(resolve));
expect(dispatchAlertSpy).toHaveBeenCalledWith(
'error',
'deploy_failure',
expect.stringContaining('restart daemon error'),
{ stackName: 'myapp' },
);
});
});
describe('deploy_failure notification on /stop error', () => {
it('dispatches deploy_failure alert when stopContainer throws', async () => {
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
mockStopContainer.mockRejectedValue(new Error('stop daemon error'));
const res = await request(app)
.post('/api/stacks/myapp/stop')
.set('Cookie', authCookie);
expect(res.status).toBe(500);
await new Promise(resolve => setImmediate(resolve));
expect(dispatchAlertSpy).toHaveBeenCalledWith(
'error',
'deploy_failure',
expect.stringContaining('stop daemon error'),
{ stackName: 'myapp' },
);
});
});
describe('deploy_failure notification on /update error', () => {
it('dispatches deploy_failure alert with correct stackName when updateStack throws', async () => {
mockUpdateStack.mockRejectedValue(new Error('image not found'));
const res = await request(app)
.post('/api/stacks/myapp/update')
.set('Cookie', authCookie);
expect(res.status).toBe(500);
await new Promise(resolve => setImmediate(resolve));
expect(dispatchAlertSpy).toHaveBeenCalledWith(
'error',
'deploy_failure',
expect.stringContaining('image not found'),
{ stackName: 'myapp' },
);
});
});
+15 -3
View File
@@ -22,6 +22,13 @@ import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
import { getTerminalWs } from '../websocket/generic';
function notifyActionFailure(action: string, stackName: string, error: unknown): void {
const message = getErrorMessage(error, `Failed to ${action} stack`);
NotificationService.getInstance()
.dispatchAlert('error', 'deploy_failure', message, { stackName })
.catch(err => console.error(`[Stacks] Failed to dispatch failure notification for ${stackName}:`, err));
}
async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
const fsService = FileSystemService.getInstance(nodeId);
const stackDir = path.join(fsService.getBaseDir(), stackName);
@@ -596,6 +603,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
if (rolledBack) console.warn(`[Stacks] Deploy failed, rolled back: ${stackName}`);
const message = getErrorMessage(error, 'Failed to deploy stack');
notifyActionFailure('deploy', stackName, error);
res.status(500).json({ error: message, rolledBack });
}
});
@@ -611,8 +619,9 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => {
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Down completed: ${stackName}`);
res.json({ status: 'Command started' });
} catch (error) {
} catch (error: unknown) {
console.error(`[Stacks] Down failed: ${stackName}`, error);
notifyActionFailure('down', stackName, error);
res.status(500).json({ error: 'Failed to start command' });
}
});
@@ -638,6 +647,7 @@ stacksRouter.post('/:stackName/restart', async (req: Request, res: Response) =>
} catch (error: unknown) {
console.error(`[Stacks] Restart failed: ${stackName}`, error);
const message = getErrorMessage(error, 'Failed to restart containers');
notifyActionFailure('restart', stackName, error);
res.status(500).json({ error: message });
}
});
@@ -663,6 +673,7 @@ stacksRouter.post('/:stackName/stop', async (req: Request, res: Response) => {
} catch (error: unknown) {
console.error(`[Stacks] Stop failed: ${stackName}`, error);
const message = getErrorMessage(error, 'Failed to stop containers');
notifyActionFailure('stop', stackName, error);
res.status(500).json({ error: message });
}
});
@@ -786,11 +797,12 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
);
} catch (error) {
} catch (error: unknown) {
console.error(`[Stacks] Update failed: ${stackName}`, error);
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${stackName}`);
res.status(500).json({ error: 'Failed to update', rolledBack });
notifyActionFailure('update', stackName, error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to update'), rolledBack });
}
});
+1
View File
@@ -99,6 +99,7 @@
"features/sidebar",
"features/stack-management",
"features/editor",
"features/deploy-progress",
"features/resources",
"features/app-store",
"features/global-observability",
+79
View File
@@ -0,0 +1,79 @@
---
title: Deploy Progress Modal
description: Stream live output from stack deploy, install, update, restart, and stop operations in a structured log view.
---
When you trigger a stack action (Deploy, Install, Update, Stop, Restart, or Down), an optional progress modal opens and streams structured output from `docker compose` in real time. Each line is parsed into a timestamped row with a stage badge (PULL, BUILD, CREATE, START, STOP, etc.) so you can track the deployment lifecycle as it runs.
## Enabling deploy progress
The modal is opt-in. Go to **Settings > Appearance** and enable **Show deploy progress modal**. The setting is saved per browser and takes effect immediately without a reload.
## Using the modal
The modal opens automatically when you trigger an action. It stays centered on screen and does not block access to the rest of the UI.
### What the modal shows
- A **header** with the action name, stack name, and elapsed time
- A **status indicator** showing the current state (Connecting, streaming line count, Succeeded, or the error message)
- A **structured log body** with one row per output line, each tagged with a stage badge and timestamp
- A **footer** with a "Raw output" toggle and minimize/close controls
### Stage badges
Each log row carries a badge indicating what phase of the operation it came from:
| Badge | Meaning |
|-------|---------|
| PULL | Image layer being fetched from a registry |
| BUILD | Image build step running |
| CREATE | Container being created |
| START | Container starting |
| STOP | Container stopping |
| DOWN | Container or network being removed |
| WARN | Warning from Docker or compose |
| ERR | Error line |
| LOG | General output |
Error rows have a rose left border and a tinted background so they stand out without requiring a full scan of the log.
### Raw output toggle
Click **Raw output** in the footer to reveal the raw terminal view alongside the structured rows. This is useful when you want to see the full unprocessed Docker output, including progress bars and multi-line build steps that the parser collapses to single rows.
### Auto-close on success
When an action completes successfully, the modal closes automatically after a few seconds. Hover over the modal to pause the timer. Click the close button to dismiss it immediately.
### On failure
If the action fails, the modal stays open and highlights the error row in the log. The status indicator shows the error message. The modal remains open until you close it manually.
### Minimize to pill
Click **Minimize** to collapse the modal to a small status pill anchored at the bottom center of the screen. The pill shows the action and stack name with a color-coded status dot (cyan pulse for in-progress, green for success, rose for error). Click the pill to expand the modal back.
The pill survives navigation, so you can leave the App Store page mid-install and still see the status from any screen.
## Supported entry points
The progress modal fires for the following actions:
- Stack **Deploy**, **Update**, **Restart**, **Stop**, and **Down** from the stack editor
- **Install** from the App Store
- **Apply** from a Git Source panel (when the apply includes a deploy)
## Troubleshooting
<Accordion title="The modal shows 'Connecting...' and no rows appear">
Check that the WebSocket connection to Sencho is not blocked by an upstream proxy or firewall. Sencho uses a WebSocket on the same port and path as the main API. If you are running behind nginx or a reverse proxy, ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection "upgrade";` are configured. See the [configuration guide](/getting-started/configuration) for a complete nginx example.
</Accordion>
<Accordion title="The structured rows are empty but raw output shows content">
Some compose wrappers or custom Docker plugins emit non-standard output. The parser falls back to `LOG` rows for lines it cannot classify. If the structured view is empty, open Raw output to see the full stream.
</Accordion>
<Accordion title="I toggled the setting but nothing changed">
The setting is read from localStorage and updates in the current tab without a reload. If the change did not take effect, try refreshing the page.
</Accordion>
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

+279
View File
@@ -0,0 +1,279 @@
/**
* Deploy feedback modal E2E tests.
*
* The modal is opt-in (default off). Each test that expects the modal must
* enable the setting via localStorage before triggering an action.
*
* These tests require a running Docker daemon because they exercise real
* docker compose operations. Timeouts are generous to allow for image pulls
* on a cold cache.
*/
import { test, expect, type Page } from '@playwright/test';
import { loginAs } 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 HAPPY_COMPOSE = `services:
web:
image: nginx:alpine
`;
const FAIL_COMPOSE = `services:
web:
image: nginnnnx:notexist
`;
async function waitForStacksLoaded(page: Page): Promise<void> {
await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 });
}
async function createStackViaApi(page: Page, name: string, composeContent: string): Promise<void> {
await page.evaluate(
async ({ stackName, content }: { stackName: string; content: string }) => {
const createRes = await fetch('/api/stacks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stackName }),
});
if (!createRes.ok && createRes.status !== 409) {
throw new Error(`Failed to create stack: ${createRes.status}`);
}
const writeRes = await fetch(`/api/stacks/${stackName}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!writeRes.ok) {
throw new Error(`Failed to write compose file: ${writeRes.status}`);
}
},
{ stackName: name, content: composeContent },
);
}
async function deleteStackViaApi(page: Page, name: string): Promise<void> {
await page.evaluate(async (stackName: string) => {
await fetch(`/api/stacks/${stackName}`, {
method: 'DELETE',
credentials: 'include',
}).catch(() => {});
}, name);
}
/**
* Set the opt-in flag so it survives every page navigation and reload in the
* test. addInitScript runs before any page script on each load, so the React
* tree's useState initializer always reads the current value on mount. The
* page.evaluate call covers any state already mounted on the current page.
*/
async function enableDeployFeedback(page: Page): Promise<void> {
await page.addInitScript((key: string) => {
window.localStorage.setItem(key, 'true');
}, DEPLOY_FEEDBACK_KEY);
await page.evaluate((key: string) => {
window.localStorage.setItem(key, 'true');
window.dispatchEvent(new CustomEvent('SENCHO_SETTINGS_CHANGED'));
}, DEPLOY_FEEDBACK_KEY);
}
/**
* Force the React hook to re-read localStorage and update its state. Used
* right before a deploy click to defeat any stale-state edge cases after
* navigation.
*/
async function syncDeployFeedbackState(page: Page): Promise<void> {
const stored = await page.evaluate((key: string) => {
window.dispatchEvent(new CustomEvent('SENCHO_SETTINGS_CHANGED'));
return window.localStorage.getItem(key);
}, DEPLOY_FEEDBACK_KEY);
expect(stored, 'deploy feedback opt-in flag missing in localStorage').toBe('true');
// Allow React to process the state update from the dispatched event before
// the next user interaction. The click fires synchronously, but React
// re-renders in a microtask; without this wait the click handler can run
// against the stale closure where isEnabled was still false.
await page.waitForTimeout(200);
}
async function disableDeployFeedback(page: Page): Promise<void> {
await page.evaluate((key: string) => {
window.localStorage.removeItem(key);
window.dispatchEvent(new CustomEvent('SENCHO_SETTINGS_CHANGED'));
}, DEPLOY_FEEDBACK_KEY);
}
/**
* Delete any leftover stack, create a fresh one, reload so the sidebar picks
* it up, and click it to open the editor. Returns with the stack selected and
* the Deploy button ready.
*
* The stack click and the file fetch that follows are awaited together so
* the editor's selectedFile state is committed before the test continues.
* Without this, deployStack() returns early at the !selectedFile guard and
* never calls runWithLog, leaving the modal closed.
*/
async function setupDeployStack(page: Page, name: string, composeContent: string): Promise<void> {
await deleteStackViaApi(page, name);
await page.waitForTimeout(300);
await createStackViaApi(page, name, composeContent);
// Reload preserves auth cookies, so the page lands back on the dashboard
// without needing a fresh login. Calling loginAs() here was racing the
// login-page detection during the brief render where the auth context
// was still loading, then waiting forever on a #username field that
// never re-appeared once the dashboard committed.
await page.reload();
await waitForStacksLoaded(page);
await Promise.all([
page.waitForResponse(
(res) =>
res.url().endsWith(`/api/stacks/${name}`) &&
res.request().method() === 'GET' &&
res.ok(),
{ timeout: 10_000 },
),
page.getByText(name, { exact: true }).first().click(),
]);
// Wait for the editor's action bar to render with the deploy/start button.
await expect(page.getByTestId('stack-deploy-button')).toBeVisible({ timeout: 10_000 });
// Settle remaining state updates and any follow-up fetches the editor
// triggers (env file, container list, backup info). Without this the click
// can race with React committing selectedFile, leaving deployStack to bail
// at its !selectedFile guard.
await page.waitForLoadState('networkidle', { timeout: 10_000 });
await page.waitForTimeout(500);
}
test.describe('Deploy feedback modal', () => {
test.beforeEach(async ({ page }, testInfo) => {
// Surface React errors and network failures from the browser so test
// failures arrive with diagnostic context instead of just "modal not
// visible". Skip noisy info/log/debug messages.
page.on('console', (msg) => {
if (msg.type() === 'error' || msg.type() === 'warning') {
// eslint-disable-next-line no-console
console.log(`[browser ${msg.type()}] [${testInfo.title}] ${msg.text()}`);
}
});
page.on('pageerror', (err) => {
// eslint-disable-next-line no-console
console.log(`[browser pageerror] [${testInfo.title}] ${err.message}`);
});
await loginAs(page);
await waitForStacksLoaded(page);
});
test('no modal appears when opt-in is off', async ({ page }) => {
test.setTimeout(60_000);
await disableDeployFeedback(page);
await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE);
await page.getByTestId('stack-deploy-button').click();
// Modal must not appear when opt-in is disabled
await expect(page.locator('[data-testid="deploy-feedback-modal"]')).not.toBeVisible({
timeout: 5_000,
});
});
test('modal opens, streams output, and auto-closes on success', async ({ page }) => {
test.setTimeout(120_000);
await enableDeployFeedback(page);
await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE);
await syncDeployFeedbackState(page);
await page.getByTestId('stack-deploy-button').click();
// Park the cursor in a corner so the modal's onMouseEnter never fires.
// The modal pauses its 4s auto-close countdown on hover; without this
// move, the cursor lands inside the centered modal after click and the
// countdown never fires.
await page.mouse.move(0, 0);
const modal = page.locator('[data-testid="deploy-feedback-modal"]');
await expect(modal).toBeVisible({ timeout: 10_000 });
// Initial status: either "Connecting..." or already streaming rows
const connectingText = page.getByText(/Connecting\.\.\./i);
const streamingIndicator = page.getByText(/\d+ lines?/i);
await Promise.race([
connectingText.waitFor({ state: 'visible', timeout: 10_000 }),
streamingIndicator.waitFor({ state: 'visible', timeout: 10_000 }),
]);
// Wait for the operation to succeed
await expect(page.getByText('Succeeded')).toBeVisible({ timeout: 90_000 });
// Modal auto-closes AUTO_CLOSE_SECONDS (4s) after success; allow up to 15s total
await expect(modal).toBeHidden({ timeout: 15_000 });
});
test('modal stays open with error indicator on failure', async ({ page }) => {
test.setTimeout(120_000);
await enableDeployFeedback(page);
await setupDeployStack(page, FAIL_STACK, FAIL_COMPOSE);
await syncDeployFeedbackState(page);
await page.getByTestId('stack-deploy-button').click();
const modal = page.locator('[data-testid="deploy-feedback-modal"]');
await expect(modal).toBeVisible({ timeout: 10_000 });
// Docker fails to pull the nonexistent image; give it up to 90s to attempt and fail
await expect(
page.getByText(/failed|error|not found|unable to find/i).first(),
).toBeVisible({ timeout: 90_000 });
// Modal must remain open on failure
await page.waitForTimeout(8_000);
await expect(modal).toBeVisible();
});
test('modal can be minimized to a pill and expanded back', async ({ page }) => {
test.setTimeout(120_000);
await enableDeployFeedback(page);
await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE);
await syncDeployFeedbackState(page);
await page.getByTestId('stack-deploy-button').click();
// Move cursor away from modal so the auto-close countdown is not paused
// by hover, in case the deploy completes before we click Minimize.
await page.mouse.move(0, 0);
const modal = page.locator('[data-testid="deploy-feedback-modal"]');
await expect(modal).toBeVisible({ timeout: 10_000 });
// Click the minimize button in the header (first occurrence)
const minimizeBtn = page.getByRole('button', { name: 'Minimize' }).first();
await expect(minimizeBtn).toBeVisible({ timeout: 5_000 });
await minimizeBtn.click();
// Modal dialog closes; pill appears at bottom center
await expect(modal).toBeHidden({ timeout: 5_000 });
const pill = page.locator('[data-testid="deploy-feedback-pill"]');
await expect(pill).toBeVisible({ timeout: 5_000 });
// Pill contains the stack name
await expect(pill).toContainText(HAPPY_STACK);
// Click the pill to restore the modal
await pill.click();
await expect(modal).toBeVisible({ timeout: 5_000 });
});
test.afterEach(async ({ page }) => {
await deleteStackViaApi(page, HAPPY_STACK);
await deleteStackViaApi(page, FAIL_STACK);
await disableDeployFeedback(page);
});
});
+13 -1
View File
@@ -72,12 +72,24 @@ export async function loginAs(page: Page, username = TEST_USERNAME, password = T
// ── Login screen ─────────────────────────────────────────────────────────
if (await isLoginPage(page)) {
await page.locator('#username').fill(username);
// Confirm the username field is actually visible before filling. If
// isLoginPage was a transient false positive (e.g. login form briefly
// rendered before auth check redirected to dashboard), the fill would
// hang forever waiting for #username to come back.
const usernameField = page.locator('#username');
const usernameVisible = await usernameField
.waitFor({ state: 'visible', timeout: 2_000 })
.then(() => true)
.catch(() => false);
if (usernameVisible) {
await usernameField.fill(username);
await page.locator('#password').fill(password);
await page.locator('button:has-text("Login"), button:has-text("Sign in")').first().click();
await expect(page.locator(DASHBOARD_INDICATOR)).toBeVisible({ timeout: 10_000 });
return;
}
// Fall through to the dashboard check below.
}
// ── Already on the dashboard ──────────────────────────────────────────────
if (await isDashboard(page)) {
+5
View File
@@ -5,6 +5,8 @@ import { Login } from './components/Login';
import { Setup } from './components/Setup';
import EditorLayout from './components/EditorLayout';
import { MfaChallenge } from './components/MfaChallenge';
import { DeployFeedbackProvider } from './context/DeployFeedbackContext';
import { DeployFeedbackPortal } from './components/DeployFeedbackPortal';
function AppContent() {
const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth();
@@ -43,7 +45,10 @@ import { ToastContainer } from './components/ui/toast';
function App() {
return (
<AuthProvider>
<DeployFeedbackProvider>
<AppContent />
<DeployFeedbackPortal />
</DeployFeedbackProvider>
<ToastContainer />
</AuthProvider>
);
+11 -4
View File
@@ -11,6 +11,7 @@ import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from '
import { toast } from '@/components/ui/toast-store';
import { cn } from '@/lib/utils';
import { apiFetch } from '@/lib/api';
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
@@ -37,6 +38,7 @@ interface AppStoreViewProps {
export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
const { can } = useAuth();
const { activeNode } = useNodes();
const { runWithLog } = useDeployFeedback();
const [templates, setTemplates] = useState<Template[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
@@ -189,6 +191,8 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
});
try {
const result = await runWithLog({ stackName: stackName.trim(), action: 'install' }, async (started) => {
await started;
const res = await apiFetch('/templates/deploy', {
method: 'POST',
body: JSON.stringify({
@@ -199,13 +203,16 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to deploy template');
if (!res.ok) return { ok: false, errorMessage: data.error || 'Failed to deploy template' };
return { ok: true };
});
if (result.ok) {
toast.success(`${selectedTemplate?.title} deployed successfully!`);
setIsSheetOpen(false);
onDeploySuccess(stackName.trim());
} catch (err) {
toast.error((err as Error).message || 'Deployment failed');
} else {
toast.error(result.errorMessage || 'Deployment failed');
}
} finally {
setIsDeploying(false);
}
@@ -0,0 +1,350 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import {
Loader2,
CheckCircle2,
AlertCircle,
X,
Minimize2,
Terminal as TerminalIcon,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { StructuredLogRow } from '@/components/log-rendering/StructuredLogRow';
import TerminalComponent from '@/components/Terminal';
import { useDeployFeedback, VERB_LABELS } from '@/context/DeployFeedbackContext';
const AUTO_CLOSE_SECONDS = 4;
interface DeployFeedbackModalProps {
isMinimized: boolean;
onMinimize: () => void;
}
function formatElapsed(seconds: number): string {
if (seconds >= 60) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
return `${seconds}s`;
}
export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) {
const { panelState, logRows, onTerminalReady, onMessage, onPanelClose } = useDeployFeedback();
const [showRaw, setShowRaw] = useState(false);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const [countdown, setCountdown] = useState(AUTO_CLOSE_SECONDS);
const [userScrolledUp, setUserScrolledUp] = useState(false);
const startTimeRef = useRef<number>(0);
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const autoCloseHoveredRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null);
const { isOpen, stackName, action, status, errorMessage } = panelState;
useEffect(() => {
if (isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setShowRaw(false);
// eslint-disable-next-line react-hooks/set-state-in-effect
setElapsedSeconds(0);
// eslint-disable-next-line react-hooks/set-state-in-effect
setCountdown(AUTO_CLOSE_SECONDS);
// eslint-disable-next-line react-hooks/set-state-in-effect
setUserScrolledUp(false);
startTimeRef.current = 0;
}
}, [isOpen]);
useEffect(() => {
if (isOpen && status !== 'preparing') {
if (startTimeRef.current === 0) startTimeRef.current = Date.now();
if (elapsedIntervalRef.current !== null) return;
elapsedIntervalRef.current = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
} else {
if (elapsedIntervalRef.current !== null) {
clearInterval(elapsedIntervalRef.current);
elapsedIntervalRef.current = null;
}
}
return () => {
if (elapsedIntervalRef.current !== null) {
clearInterval(elapsedIntervalRef.current);
elapsedIntervalRef.current = null;
}
};
}, [isOpen, status]);
const clearCountdownInterval = useCallback(() => {
if (countdownIntervalRef.current !== null) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
}, []);
const startCountdown = useCallback(() => {
clearCountdownInterval();
setCountdown(AUTO_CLOSE_SECONDS);
let remaining = AUTO_CLOSE_SECONDS;
countdownIntervalRef.current = setInterval(() => {
remaining -= 1;
setCountdown(remaining);
if (remaining <= 0) {
clearCountdownInterval();
if (!autoCloseHoveredRef.current) {
onPanelClose();
}
}
}, 1000);
}, [clearCountdownInterval, onPanelClose]);
useEffect(() => {
if (status === 'succeeded' && isOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect
startCountdown();
} else {
clearCountdownInterval();
}
return () => {
clearCountdownInterval();
};
}, [status, isOpen, startCountdown, clearCountdownInterval]);
useEffect(() => {
if (!userScrolledUp && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logRows.length, userScrolledUp]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
setUserScrolledUp(distanceFromBottom > 32);
}, []);
const handleMouseEnter = useCallback(() => {
autoCloseHoveredRef.current = true;
clearCountdownInterval();
}, [clearCountdownInterval]);
const handleMouseLeave = useCallback(() => {
autoCloseHoveredRef.current = false;
if (status === 'succeeded' && isOpen) {
startCountdown();
}
}, [status, isOpen, startCountdown]);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) onPanelClose();
},
[onPanelClose]
);
const isDialogOpen = isOpen && !isMinimized;
const verbLabel = VERB_LABELS[action];
return (
<Dialog open={isDialogOpen} onOpenChange={handleOpenChange}>
<DialogContent
showClose={false}
data-testid="deploy-feedback-modal"
className="max-w-[640px] p-0 gap-0 max-h-[70vh] flex flex-col"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<DialogTitle className="sr-only">
{verbLabel.present} {stackName}
</DialogTitle>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-glass-border shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium text-foreground shrink-0">
{verbLabel.present}
</span>
<span className="text-sm font-mono text-foreground/80 truncate max-w-[200px]">
{stackName}
</span>
{status !== 'preparing' && elapsedSeconds > 0 && (
<span className="text-xs font-mono text-muted-foreground shrink-0">
{formatElapsed(elapsedSeconds)}
</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<StatusIndicator
status={status}
rowCount={logRows.length}
errorMessage={errorMessage}
countdown={countdown}
/>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onMinimize}
title="Minimize"
>
<Minimize2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:bg-destructive/10 hover:text-destructive"
onClick={onPanelClose}
title="Close"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Body */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto min-h-0"
onScroll={handleScroll}
>
{logRows.length === 0 ? (
<EmptyBody status={status} />
) : (
<div className="py-1">
{logRows.map((row) => (
<StructuredLogRow key={row.id} row={row} />
))}
</div>
)}
</div>
{/* always mounted so onMessage feeds structured rows even before user toggles raw view */}
<div
className="border-t border-glass-border shrink-0"
style={{ height: showRaw ? '200px' : 0, overflow: 'hidden' }}
>
<TerminalComponent
onReady={onTerminalReady}
onMessage={onMessage}
/>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 border-t border-glass-border shrink-0">
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowRaw((prev) => !prev)}
>
<TerminalIcon className="h-3.5 w-3.5" />
{showRaw ? 'Hide raw' : 'Raw output'}
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={onMinimize}
>
<Minimize2 className="h-3.5 w-3.5" />
Minimize
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onPanelClose}
>
<X className="h-3.5 w-3.5" />
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
interface StatusIndicatorProps {
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
rowCount: number;
errorMessage?: string;
countdown: number;
}
function StatusIndicator({ status, rowCount, errorMessage, countdown }: StatusIndicatorProps) {
if (status === 'preparing') {
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span>Connecting...</span>
</div>
);
}
if (status === 'streaming') {
return (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin text-brand" />
<span>{rowCount} {rowCount === 1 ? 'line' : 'lines'}</span>
</div>
);
}
if (status === 'succeeded') {
return (
<div className="flex items-center gap-1.5 text-xs text-success">
<CheckCircle2 className="h-3 w-3 text-success" />
<span>Succeeded</span>
<span className="text-muted-foreground">closes in {countdown}s</span>
</div>
);
}
// failed
return (
<div className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="h-3 w-3 text-destructive" />
<span
className="max-w-[200px] truncate"
title={errorMessage}
>
{errorMessage ?? 'Failed'}
</span>
</div>
);
}
interface EmptyBodyProps {
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
}
function EmptyBody({ status }: EmptyBodyProps) {
if (status === 'preparing') {
return (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm">Connecting to log stream...</span>
</div>
);
}
return (
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground">
Waiting for output...
</div>
);
}
@@ -0,0 +1,54 @@
import { memo, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { useDeployFeedback, VERB_LABELS } from '@/context/DeployFeedbackContext';
interface DeployFeedbackPillProps {
isVisible: boolean;
onExpand: () => void;
}
function DeployFeedbackPillBase({ isVisible, onExpand }: DeployFeedbackPillProps) {
const { panelState } = useDeployFeedback();
const { stackName, action, status } = panelState;
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') onExpand();
}, [onExpand]);
if (!isVisible) return null;
const verbLabel = VERB_LABELS[action];
const dotClass = cn(
'h-2 w-2 rounded-full shrink-0',
(status === 'preparing' || status === 'streaming') && 'bg-brand animate-pulse',
status === 'succeeded' && 'bg-success',
status === 'failed' && 'bg-destructive',
);
const textClass = cn(
'text-xs',
(status === 'preparing' || status === 'streaming') && 'text-foreground',
status === 'succeeded' && 'text-success',
status === 'failed' && 'text-destructive',
);
return (
<div
role="button"
tabIndex={0}
data-testid="deploy-feedback-pill"
onClick={onExpand}
onKeyDown={handleKeyDown}
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 w-[280px] bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-glass-border rounded-full px-3 py-1.5 shadow-lg cursor-pointer flex items-center gap-2"
>
<span className={dotClass} />
<span className={cn(textClass, 'flex items-center gap-1 min-w-0 flex-1 overflow-hidden')}>
<span className="shrink-0">{verbLabel.present}</span>
<span className="font-mono truncate">{stackName}</span>
</span>
</div>
);
}
export const DeployFeedbackPill = memo(DeployFeedbackPillBase);
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
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]);
return (
<>
<DeployFeedbackModal
isMinimized={isMinimized}
onMinimize={() => setIsMinimized(true)}
/>
<DeployFeedbackPill
isVisible={panelState.isOpen && isMinimized}
onExpand={() => setIsMinimized(false)}
/>
</>
);
}
+37 -20
View File
@@ -65,6 +65,7 @@ import { useNodes } from '@/context/NodeContext';
import type { Node } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
import { StackSidebar } from '@/components/sidebar/StackSidebar';
@@ -180,6 +181,7 @@ export default function EditorLayout() {
const { isAdmin, can } = useAuth();
const { isPaid, license } = useLicense();
const { status: trivy } = useTrivyStatus();
const { runWithLog } = useDeployFeedback();
const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false);
const [stackMisconfigScanId, setStackMisconfigScanId] = useState<number | null>(null);
const [policyBlock, setPolicyBlock] = useState<{ stackName: string; payload: PolicyBlockPayload } | null>(null);
@@ -1329,13 +1331,19 @@ export default function EditorLayout() {
}
};
const runDeploy = async (stackName: string, stackFile: string, ignorePolicy: boolean): Promise<void> => {
const runDeploy = async (
stackName: string,
stackFile: string,
ignorePolicy: boolean,
started?: Promise<void>,
): Promise<{ ok: boolean; errorMessage?: string }> => {
const previousStatus = stackStatuses[stackFile];
setOptimisticStatus(stackFile, 'running');
try {
const path = ignorePolicy
? `/stacks/${stackName}/deploy?ignorePolicy=true`
: `/stacks/${stackName}/deploy`;
if (started) await started;
const response = await apiFetch(path, { method: 'POST' });
if (!response.ok) {
const rawBody = await response.text();
@@ -1346,7 +1354,7 @@ export default function EditorLayout() {
setPolicyBlock({ stackName, payload: parsed });
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
toast.error(`Deploy blocked by policy "${parsed.policy.name}"`);
return;
return { ok: false, errorMessage: `Deploy blocked by policy "${parsed.policy.name}"` };
}
}
throw new Error(rawBody || 'Deploy failed');
@@ -1364,11 +1372,13 @@ export default function EditorLayout() {
if (backupRes.ok) setBackupInfo(await backupRes.json());
} catch { /* ignore */ }
}
return { ok: true };
} catch (error) {
console.error('Failed to deploy:', error);
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
const msg = (error as Error).message || 'Failed to deploy stack';
toast.error(isPaid ? `${msg} - automatically rolled back to previous version.` : msg);
const errorMessage = (error as Error).message || 'Failed to deploy stack';
toast.error(isPaid ? `${errorMessage} - automatically rolled back to previous version.` : errorMessage);
return { ok: false, errorMessage };
}
};
@@ -1380,7 +1390,9 @@ export default function EditorLayout() {
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
setStackAction(stackFile, 'deploy');
try {
await runDeploy(stackName, stackFile, false);
await runWithLog({ stackName, action: 'deploy' }, (started) =>
runDeploy(stackName, stackFile, false, started)
);
} finally {
clearStackAction(stackFile);
refreshStacks(true);
@@ -1396,7 +1408,9 @@ export default function EditorLayout() {
setPolicyBypassing(true);
setStackAction(existingFile, 'deploy');
try {
await runDeploy(policyBlock.stackName, existingFile, true);
await runWithLog({ stackName: policyBlock.stackName, action: 'deploy' }, (started) =>
runDeploy(policyBlock.stackName, existingFile, true, started)
);
} finally {
setPolicyBypassing(false);
clearStackAction(existingFile);
@@ -1414,20 +1428,21 @@ export default function EditorLayout() {
const previousStatus = stackStatuses[stackFile];
setOptimisticStatus(stackFile, 'exited');
try {
const response = await apiFetch(`/stacks/${stackName}/stop`, {
method: 'POST',
});
await runWithLog({ stackName, action: 'stop' }, async (started) => {
await started;
const response = await apiFetch(`/stacks/${stackName}/stop`, { method: 'POST' });
if (!response.ok) {
const errText = await response.text();
throw new Error(errText || 'Stop failed');
}
toast.success('Stack stopped successfully!');
// Refresh containers after stop
if (selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
}
return { ok: true };
});
} catch (error) {
console.error('Failed to stop:', error);
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
@@ -1448,20 +1463,21 @@ export default function EditorLayout() {
const previousStatus = stackStatuses[stackFile];
setOptimisticStatus(stackFile, 'running');
try {
const response = await apiFetch(`/stacks/${stackName}/restart`, {
method: 'POST',
});
await runWithLog({ stackName, action: 'restart' }, async (started) => {
await started;
const response = await apiFetch(`/stacks/${stackName}/restart`, { method: 'POST' });
if (!response.ok) {
const errText = await response.text();
throw new Error(errText || 'Restart failed');
}
toast.success('Stack restarted successfully!');
// Refresh containers after restart
if (selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
}
return { ok: true };
});
} catch (error) {
console.error('Failed to restart:', error);
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
@@ -1503,21 +1519,22 @@ export default function EditorLayout() {
const previousStatus = stackStatuses[stackFile];
setOptimisticStatus(stackFile, 'running');
try {
const response = await apiFetch(`/stacks/${stackName}/update`, {
method: 'POST',
});
await runWithLog({ stackName, action: 'update' }, async (started) => {
await started;
const response = await apiFetch(`/stacks/${stackName}/update`, { method: 'POST' });
if (!response.ok) {
const errText = await response.text();
throw new Error(errText || 'Update failed');
}
toast.success('Stack updated successfully!');
fetchImageUpdates();
// Refresh containers after update
if (selectedFile === stackFile) {
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await containersRes.json();
setContainers(Array.isArray(conts) ? conts : []);
}
return { ok: true };
});
} catch (error) {
console.error('Failed to update:', error);
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
@@ -2407,12 +2424,12 @@ export default function EditorLayout() {
{can('stack:deploy', 'stack', stackName) && (
<div className="flex items-center gap-2 flex-wrap">
{isRunning ? (
<Button type="button" size="sm" className="rounded-lg bg-brand text-brand-foreground hover:bg-brand/90" onClick={restartStack} disabled={loadingAction !== null}>
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg bg-brand text-brand-foreground hover:bg-brand/90" onClick={restartStack} disabled={loadingAction !== null}>
<RotateCw className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'restart' ? 'Restarting...' : 'Restart'}
</Button>
) : (
<Button type="button" size="sm" className="rounded-lg bg-brand text-brand-foreground hover:bg-brand/90" onClick={deployStack} disabled={loadingAction !== null}>
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg bg-brand text-brand-foreground hover:bg-brand/90" onClick={deployStack} disabled={loadingAction !== null}>
<Play className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'deploy' ? 'Starting...' : 'Start'}
</Button>
+7 -3
View File
@@ -10,9 +10,11 @@ import '@xterm/xterm/css/xterm.css';
interface TerminalComponentProps {
stackName?: string;
onReady?: () => void;
onMessage?: (text: string) => void;
}
export default function TerminalComponent({ stackName }: TerminalComponentProps) {
export default function TerminalComponent({ stackName, onReady, onMessage }: TerminalComponentProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const terminalInstance = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
@@ -131,7 +133,7 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps)
// Otherwise, fall back to the generic terminal WebSocket
const wsUrl = cleanStackName
? `${wsProtocol}//${window.location.host}/api/stacks/${cleanStackName}/logs${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`
: `${wsProtocol}//${window.location.host}`;
: `${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@@ -141,6 +143,7 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps)
if (!cleanStackName) {
// Generic terminal mode - send connect action
ws.send(JSON.stringify({ action: 'connectTerminal' }));
onReady?.();
}
// For stack logs mode, the server starts streaming automatically on connection
}
@@ -149,6 +152,7 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps)
ws.onmessage = (event) => {
if (mounted && terminalInstance.current) {
const text = typeof event.data === 'string' ? event.data : event.data.toString();
onMessage?.(text);
terminalInstance.current.write(text.replace(/\r?\n/g, '\r\n'));
}
};
@@ -208,7 +212,7 @@ export default function TerminalComponent({ stackName }: TerminalComponentProps)
searchAddonRef.current = null;
serializeAddonRef.current = null;
};
}, [stackName]);
}, [stackName, onReady, onMessage]);
const handleDownload = () => {
if (!serializeAddonRef.current) return;
@@ -0,0 +1,52 @@
import { memo } from 'react';
import { cn } from '@/lib/utils';
import type { ParsedLogRow, LogStage } from './composeLogParser';
interface StructuredLogRowProps {
row: ParsedLogRow;
}
const BADGE_CLASSES: Record<LogStage, string> = {
PULL: 'bg-blue-500/10 text-blue-400',
BUILD: 'bg-violet-500/10 text-violet-400',
CREATE: 'bg-brand/10 text-brand',
START: 'bg-success/10 text-success',
STOP: 'bg-warning/10 text-warning',
DOWN: 'bg-muted/60 text-muted-foreground',
WARN: 'bg-warning/10 text-warning',
ERR: 'bg-destructive/10 text-destructive',
LOG: 'bg-muted/30 text-muted-foreground/70',
};
function StructuredLogRowBase({ row }: StructuredLogRowProps) {
const isError = row.level === 'error';
const isWarn = row.level === 'warn';
return (
<div
className={cn(
'flex items-start gap-2 px-3 py-0.5 text-xs font-mono relative overflow-hidden',
isError && 'bg-destructive/5 before:absolute before:left-0 before:top-0 before:bottom-0 before:w-0.5 before:bg-destructive/60',
isWarn && 'bg-warning/5',
)}
>
<span className="text-[10px] text-muted-foreground/60 shrink-0 tabular-nums w-[88px] pt-[1px]">
{row.timestamp.substring(11, 19)}
</span>
<span
className={cn(
'text-[10px] uppercase tracking-widest shrink-0 w-[52px] text-center rounded px-1 pt-[1px]',
BADGE_CLASSES[row.stage],
)}
>
{row.stage}
</span>
<span className={cn('flex-1 break-all leading-4', isError ? 'text-destructive/90' : 'text-foreground/90')}>
{row.message}
</span>
</div>
);
}
export const StructuredLogRow = memo(StructuredLogRowBase);
export default StructuredLogRow;
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest';
import { parseLogChunk } from '../composeLogParser';
describe('parseLogChunk', () => {
it('detects PULL stage', () => {
const [row] = parseLogChunk('[+] Pulling fs layer', 0);
expect(row.stage).toBe('PULL');
expect(row.level).toBe('info');
});
it('detects BUILD stage', () => {
const [row] = parseLogChunk('[+] Building 0.0s (3/7)', 0);
expect(row.stage).toBe('BUILD');
expect(row.level).toBe('info');
});
it('detects CREATE stage', () => {
const [row] = parseLogChunk('[+] Creating myapp-web-1', 0);
expect(row.stage).toBe('CREATE');
expect(row.level).toBe('info');
});
it('detects START stage', () => {
const [row] = parseLogChunk('[+] Starting myapp-web-1', 0);
expect(row.stage).toBe('START');
expect(row.level).toBe('info');
});
it('detects STOP stage', () => {
const [row] = parseLogChunk('[+] Stopping myapp-web-1', 0);
expect(row.stage).toBe('STOP');
expect(row.level).toBe('info');
});
it('detects DOWN stage', () => {
const [row] = parseLogChunk('[+] Removing myapp-web-1', 0);
expect(row.stage).toBe('DOWN');
expect(row.level).toBe('info');
});
it('detects WARN stage', () => {
const [row] = parseLogChunk('WARN[0000] some warning message', 0);
expect(row.stage).toBe('WARN');
expect(row.level).toBe('warn');
});
it('detects ERR stage', () => {
const [row] = parseLogChunk('Error response from daemon: No such container', 0);
expect(row.stage).toBe('ERR');
expect(row.level).toBe('error');
});
it('falls through to LOG for unrecognized lines', () => {
const [row] = parseLogChunk('Step 3/7 : RUN npm install', 0);
expect(row.stage).toBe('LOG');
expect(row.level).toBe('info');
});
it('strips ANSI escapes from message but keeps raw intact', () => {
const input = '\x1b[32m[+] Starting myapp\x1b[0m';
const [row] = parseLogChunk(input, 0);
expect(row.stage).toBe('START');
expect(row.message).not.toContain('\x1b');
expect(row.raw).toBe(input.trim());
expect(row.raw).toContain('\x1b');
});
it('returns 3 rows for a 3-line chunk', () => {
const rows = parseLogChunk('line1\nline2\nline3', 0);
expect(rows).toHaveLength(3);
});
it('skips empty lines', () => {
const rows = parseLogChunk('line1\n\n\nline2', 0);
expect(rows).toHaveLength(2);
});
it('applies idOffset to row ids', () => {
const [row] = parseLogChunk('line', 5);
expect(row.id).toBe('row-5');
});
it('handles CRLF line endings', () => {
const rows = parseLogChunk('line1\r\nline2', 0);
expect(rows).toHaveLength(2);
});
it('produces non-colliding IDs across two calls when caller advances offset', () => {
const first = parseLogChunk('line-a\nline-b', 0);
expect(first).toHaveLength(2);
expect(first[0].id).toBe('row-0');
expect(first[1].id).toBe('row-1');
const second = parseLogChunk('line-c', first.length);
expect(second).toHaveLength(1);
expect(second[0].id).toBe('row-2');
});
});
@@ -0,0 +1,80 @@
export type LogStage = 'PULL' | 'BUILD' | 'CREATE' | 'START' | 'STOP' | 'DOWN' | 'WARN' | 'ERR' | 'LOG';
export type LogLevel = 'info' | 'warn' | 'error';
export interface ParsedLogRow {
id: string;
timestamp: string;
stage: LogStage;
level: LogLevel;
message: string;
raw: string;
}
// eslint-disable-next-line no-control-regex
const ANSI_CSI = /\x1b\[[0-9;]*[A-Za-z]/g;
// eslint-disable-next-line no-control-regex
const ANSI_OSC = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
function stripAnsi(text: string): string {
return text.replace(ANSI_CSI, '').replace(ANSI_OSC, '');
}
function classify(line: string): { stage: LogStage; level: LogLevel } {
if (line.includes('[+] Pulling') || line.includes('Pulling from')) {
return { stage: 'PULL', level: 'info' };
}
if (line.includes('[+] Building')) {
return { stage: 'BUILD', level: 'info' };
}
if (line.includes('[+] Creating')) {
return { stage: 'CREATE', level: 'info' };
}
if (line.includes('[+] Starting')) {
return { stage: 'START', level: 'info' };
}
if (line.includes('[+] Stopping') || line.includes('[+] Stopped')) {
return { stage: 'STOP', level: 'info' };
}
if (line.includes('[+] Removing') || line.includes('[+] Removed')) {
return { stage: 'DOWN', level: 'info' };
}
if (line.startsWith('WARN[')) {
return { stage: 'WARN', level: 'warn' };
}
if (
line.includes('Error response from daemon') ||
/^error/i.test(line)
) {
return { stage: 'ERR', level: 'error' };
}
return { stage: 'LOG', level: 'info' };
}
export function parseLogChunk(chunk: string, idOffset: number): ParsedLogRow[] {
const timestamp = new Date().toISOString();
const rows: ParsedLogRow[] = [];
const lines = chunk.split(/\r?\n/);
let index = 0;
for (const line of lines) {
const raw = line.trim();
if (raw === '') continue;
const message = stripAnsi(raw);
const { stage, level } = classify(message);
rows.push({
id: `row-${idOffset + index}`,
timestamp,
stage,
level,
message,
raw,
});
index++;
}
return rows;
}
@@ -1,7 +1,9 @@
import { Label } from '@/components/ui/label';
import { Combobox } from '@/components/ui/combobox';
import { Checkbox } from '@/components/ui/checkbox';
import { useDensity } from '@/hooks/use-density';
import type { Density } from '@/hooks/use-density';
import { useDeployFeedbackEnabled } from '@/hooks/use-deploy-feedback-enabled';
const DENSITY_OPTIONS: { value: Density; label: string }[] = [
{ value: 'comfortable', label: 'Comfortable' },
@@ -15,6 +17,7 @@ const DENSITY_DESCRIPTIONS: Record<Density, string> = {
export function AppearanceSection() {
const [density, setDensity] = useDensity();
const [isEnabled, setEnabled] = useDeployFeedbackEnabled();
return (
<div className="space-y-6">
@@ -34,6 +37,21 @@ export function AppearanceSection() {
</p>
</div>
</div>
<div className="space-y-4 bg-glass border border-glass-border p-4 rounded-lg">
<div className="flex items-start gap-3">
<Checkbox
id="deploy-feedback"
checked={isEnabled}
onCheckedChange={(v) => setEnabled(v === true)}
/>
<label htmlFor="deploy-feedback">
<p className="text-sm font-medium cursor-pointer">Show deploy progress modal</p>
<p className="text-xs text-stat-subtitle mt-0.5">
Stream live output for deploy, restart, update, install, and Git operations.
</p>
</label>
</div>
</div>
<p className="text-xs text-stat-subtitle">
Preference is saved to this browser only. Each device you use remembers its own choice.
</p>
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { apiFetch } from '@/lib/api';
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
import { toast } from '@/components/ui/toast-store';
import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog';
import { GitSourceFields, type ApplyMode } from './GitSourceFields';
@@ -82,6 +83,7 @@ export function GitSourcePanel({
const [diffOpen, setDiffOpen] = useState(false);
const [removeConfirmOpen, setRemoveConfirmOpen] = useState(false);
const { runWithLog } = useDeployFeedback();
const applyMode = deriveApplyMode(source, applyModeOverride);
const load = useCallback(async () => {
@@ -228,6 +230,8 @@ export function GitSourcePanel({
setApplying(true);
const loadingId = toast.loading(deploy ? 'Applying and deploying...' : 'Applying changes...');
try {
const runApply = async (started: Promise<void>) => {
if (deploy) await started;
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/apply`, {
method: 'POST',
body: JSON.stringify({ commitSha, deploy }),
@@ -236,19 +240,31 @@ export function GitSourcePanel({
const data: { applied: boolean; deployed: boolean; deployError?: string } = await res.json();
if (data.deployError) {
toast.warning(`Applied, but deploy failed: ${data.deployError}`);
} else if (deploy && data.deployed) {
toast.success('Changes applied and deployed.');
} else {
toast.success(data.deployed ? 'Applied and deployed.' : 'Applied successfully.');
toast.success('Changes applied.');
}
setDiffOpen(false);
setPull(null);
await load();
onSourceChanged?.();
return { ok: true };
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Apply failed.');
const msg = (err as { error?: string }).error || 'Failed to apply changes.';
toast.error(msg);
return { ok: false, errorMessage: msg };
}
} catch (e) {
toast.error((e as Error)?.message || 'Network error.');
};
if (deploy) {
await runWithLog({ stackName, action: 'deploy' }, runApply);
} else {
await runApply(Promise.resolve());
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
toast.dismiss(loadingId);
setApplying(false);
@@ -0,0 +1,157 @@
import React, { createContext, useContext, useState, useRef, useCallback } from 'react';
import { type ParsedLogRow, parseLogChunk } from '../components/log-rendering/composeLogParser';
import { useDeployFeedbackEnabled } from '../hooks/use-deploy-feedback-enabled';
export type ActionVerb = 'deploy' | 'update' | 'down' | 'restart' | 'stop' | 'install';
// eslint-disable-next-line react-refresh/only-export-components
export const VERB_LABELS: Record<ActionVerb, { present: string; past: string }> = {
deploy: { present: 'Deploying', past: 'Deployed' },
update: { present: 'Updating', past: 'Updated' },
down: { present: 'Stopping', past: 'Stopped' },
restart: { present: 'Restarting', past: 'Restarted' },
stop: { present: 'Stopping', past: 'Stopped' },
install: { present: 'Installing', past: 'Installed' },
};
export interface DeployPanelState {
isOpen: boolean;
stackName: string;
action: ActionVerb;
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
errorMessage?: string;
}
interface RunResult {
ok: boolean;
errorMessage?: string;
}
interface DeployFeedbackContextValue {
runWithLog: (
params: { stackName: string; action: ActionVerb },
run: (deployStarted: Promise<void>) => Promise<RunResult>
) => Promise<RunResult>;
panelState: DeployPanelState;
logRows: ParsedLogRow[];
onTerminalReady: () => void;
onMessage: (text: string) => void;
onPanelClose: () => void;
}
const DEFAULT_PANEL_STATE: DeployPanelState = {
isOpen: false,
stackName: '',
action: 'deploy',
status: 'preparing',
};
const DeployFeedbackContext = createContext<DeployFeedbackContextValue | undefined>(undefined);
export function DeployFeedbackProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [panelState, setPanelState] = useState<DeployPanelState>(DEFAULT_PANEL_STATE);
const [logRows, setLogRows] = useState<ParsedLogRow[]>([]);
// Holds the resolver for the current session's deployStarted promise.
// Updated at the start of each runWithLog call; not state because
// changing it must not trigger a re-render.
const readyResolverRef = useRef<(() => void) | null>(null);
// Tracks whether a session is still active so a cancelled session
// cannot mutate state for the new session that replaced it.
const sessionIdRef = useRef(0);
// Monotonically increasing counter for unique log row IDs.
// Never reset between deploys so React keys remain globally unique.
const idCounterRef = useRef(0);
const [isEnabled] = useDeployFeedbackEnabled();
const onTerminalReady = useCallback(() => {
setPanelState((prev) => ({ ...prev, status: 'streaming' }));
if (readyResolverRef.current !== null) {
readyResolverRef.current();
readyResolverRef.current = null;
}
}, []);
const onMessage = useCallback((text: string) => {
const newRows = parseLogChunk(text, idCounterRef.current);
idCounterRef.current += newRows.length;
setLogRows((prev) => [...prev, ...newRows]);
}, []);
const onPanelClose = useCallback(() => {
sessionIdRef.current += 1;
readyResolverRef.current = null;
setPanelState(DEFAULT_PANEL_STATE);
setLogRows([]);
}, []);
const runWithLog = useCallback(
async (
params: { stackName: string; action: ActionVerb },
run: (deployStarted: Promise<void>) => Promise<RunResult>
): Promise<RunResult> => {
if (!isEnabled) {
return run(Promise.resolve());
}
// Cancel any existing session before starting a new one.
sessionIdRef.current += 1;
const mySession = sessionIdRef.current;
// idCounterRef is intentionally not reset; keys must remain globally unique across sessions.
setLogRows([]);
setPanelState({
isOpen: true,
stackName: params.stackName,
action: params.action,
status: 'preparing',
});
const deployStarted = new Promise<void>((resolve) => {
readyResolverRef.current = () => {
setTimeout(resolve, 50);
};
});
let result: RunResult;
try {
result = await run(deployStarted);
} catch (err) {
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
result = { ok: false, errorMessage: message };
}
if (sessionIdRef.current === mySession) {
setPanelState((prev) => ({
...prev,
status: result.ok ? 'succeeded' : 'failed',
errorMessage: result.ok ? undefined : result.errorMessage,
}));
}
return result;
},
[isEnabled]
);
return (
<DeployFeedbackContext.Provider
value={{ runWithLog, panelState, logRows, onTerminalReady, onMessage, onPanelClose }}
>
{children}
</DeployFeedbackContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useDeployFeedback(): DeployFeedbackContextValue {
const context = useContext(DeployFeedbackContext);
if (context === undefined) {
throw new Error('useDeployFeedback must be used within a DeployFeedbackProvider');
}
return context;
}
@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from 'react';
export const DEPLOY_FEEDBACK_KEY = 'sencho.deploy-feedback.enabled';
function readStored(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem(DEPLOY_FEEDBACK_KEY) === 'true';
} catch {
return false;
}
}
export function useDeployFeedbackEnabled(): [boolean, (next: boolean) => void] {
const [enabled, setEnabledState] = useState<boolean>(readStored);
useEffect(() => {
function onSettingsChanged() {
setEnabledState(readStored());
}
window.addEventListener('SENCHO_SETTINGS_CHANGED', onSettingsChanged);
return () => window.removeEventListener('SENCHO_SETTINGS_CHANGED', onSettingsChanged);
}, []);
useEffect(() => {
function onStorage(event: StorageEvent) {
if (event.key !== DEPLOY_FEEDBACK_KEY) return;
setEnabledState(event.newValue === 'true');
}
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const setEnabled = useCallback((next: boolean) => {
try {
window.localStorage.setItem(DEPLOY_FEEDBACK_KEY, next ? 'true' : 'false');
} catch {
// ignore; localStorage may be unavailable (private mode, quota)
}
setEnabledState(next);
window.dispatchEvent(new CustomEvent('SENCHO_SETTINGS_CHANGED'));
}, []);
return [enabled, setEnabled];
}
@@ -0,0 +1 @@
export { useDeployFeedback } from '../context/DeployFeedbackContext';