mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(deploy-progress): decouple deploys from the live progress stream (#1246)
* fix(deploy-progress): decouple deploys from the live progress stream The deploy progress modal streamed compose output over a WebSocket, but the deploy itself was coupled to that socket in two ways that could break or silently abort a deploy: - The deploy request was gated on the progress socket connecting, so any upgrade failure (a reverse proxy blocking WebSocket upgrades, or the admin-only stream rejecting a scoped deployer) left the modal stuck on "Connecting..." and the deploy never fired. - The backend terminated the running compose process when that socket closed, so minimizing the modal, navigating away, or a network blip aborted an in-flight deploy. Make the progress socket output-only: the deploy is owned by its request and runs to completion (or the existing command timeout) regardless of the stream. The modal now degrades to a "Live progress unavailable" state and still reports success or failure from the request result. Connect failures, drops, and a connect timeout all release the deploy instead of blocking it. Also route progress output per deploy: the frontend sends a correlation id on both the connectTerminal message and the deploy request header, and the backend keys progress sockets by that id so concurrent deploys from different tabs or users no longer cross-stream each other's output. Cap the in-memory parsed log rows so a very long deploy cannot grow the modal's state unbounded. * fix(deploy-progress): generate the deploy session id with a CSPRNG The per-deploy correlation id keys which WebSocket receives a deploy's live output, so a guessable id lets one authenticated client register a victim's id and read its compose output. It was built from Math.random() plus a timestamp, which is not cryptographically secure. Generate it with crypto.getRandomValues (128 bits, hex). That is the one Crypto member available in insecure contexts, so it still works over LAN HTTP where crypto.randomUUID is unavailable. * fix(deploy-progress): stop headerless ops bleeding into a keyed progress modal Address review findings on the progress-stream routing: - Only an id-less connectTerminal registration may become the id-less fallback socket. Previously every connectTerminal (including keyed deploy modals) set the fallback, so a headerless operation (bulk update, rollback, or a legacy client) resolved via getTerminalWs() into another user's keyed deploy modal. Keyed sockets are now excluded from the fallback, and a socket that adopts a session id is removed from it. - The connect-timeout fallback now also flags the modal as "Live progress unavailable" instead of leaving it on "Connecting..." while the deploy runs. - Log only a short prefix of the deploy session id in developer diagnostics, not the full capability value.
This commit is contained in:
@@ -254,22 +254,21 @@ describe('ComposeService - runCommand', () => {
|
|||||||
await expectation;
|
await expectation;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('kills and rejects running commands when the WebSocket disconnects', async () => {
|
it('keeps the command running when the WebSocket disconnects (progress socket is output-only)', async () => {
|
||||||
const proc = createMockProcess();
|
const proc = createMockProcess();
|
||||||
mockSpawn.mockReturnValue(proc);
|
mockSpawn.mockReturnValue(proc);
|
||||||
const ws = createMockWs();
|
const ws = createMockWs();
|
||||||
|
|
||||||
const svc = ComposeService.getInstance(1);
|
const svc = ComposeService.getInstance(1);
|
||||||
const promise = svc.runCommand('my-stack', 'restart', ws);
|
const promise = svc.runCommand('my-stack', 'restart', ws);
|
||||||
const expectation = expect(promise).rejects.toThrow('client disconnected');
|
// The deploy is owned by its HTTP request; closing the progress socket
|
||||||
let settled = false;
|
// (panel minimized, navigated away, connection blip) must not abort it.
|
||||||
promise.finally(() => { settled = true; }).catch(() => undefined);
|
|
||||||
ws.emit('close');
|
ws.emit('close');
|
||||||
|
expect(proc.kill).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
|
// The command still completes on its own exit, not the socket close.
|
||||||
expect(settled).toBe(false);
|
proc.emit('close', 0);
|
||||||
proc.emit('close', null);
|
await expect(promise).resolves.toBeUndefined();
|
||||||
await expectation;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rewrites ENOMEM spawn failures as host out-of-memory', async () => {
|
it('rewrites ENOMEM spawn failures as host out-of-memory', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import WebSocket, { type WebSocketServer } from 'ws';
|
||||||
|
import { attachGenericConnectionHandlers, getTerminalWs } from '../websocket/generic';
|
||||||
|
|
||||||
|
class FakeWs extends EventEmitter {
|
||||||
|
readyState: number = WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate a client opening the generic /ws socket and registering for deploy
|
||||||
|
* output via the connectTerminal handshake. */
|
||||||
|
function connect(wss: EventEmitter, sessionId?: string): FakeWs {
|
||||||
|
const ws = new FakeWs();
|
||||||
|
wss.emit('connection', ws);
|
||||||
|
ws.emit('message', Buffer.from(JSON.stringify({ action: 'connectTerminal', ...(sessionId ? { sessionId } : {}) })));
|
||||||
|
return ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generic ws terminal registry', () => {
|
||||||
|
let wss: EventEmitter;
|
||||||
|
beforeEach(() => {
|
||||||
|
wss = new EventEmitter();
|
||||||
|
attachGenericConnectionHandlers(wss as unknown as WebSocketServer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes output to the socket matching the deploy session id', () => {
|
||||||
|
const a = connect(wss, 'sess-a');
|
||||||
|
const b = connect(wss, 'sess-b');
|
||||||
|
expect(getTerminalWs('sess-a')).toBe(a);
|
||||||
|
expect(getTerminalWs('sess-b')).toBe(b);
|
||||||
|
expect(getTerminalWs('not-a-session')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the most recent id-less socket', () => {
|
||||||
|
const a = connect(wss);
|
||||||
|
expect(getTerminalWs()).toBe(a);
|
||||||
|
const b = connect(wss);
|
||||||
|
expect(getTerminalWs()).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a session from the registry when its socket closes', () => {
|
||||||
|
const a = connect(wss, 'sess-x');
|
||||||
|
expect(getTerminalWs('sess-x')).toBe(a);
|
||||||
|
a.emit('close');
|
||||||
|
expect(getTerminalWs('sess-x')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a registered socket that is no longer open', () => {
|
||||||
|
const a = connect(wss, 'sess-y');
|
||||||
|
a.readyState = WebSocket.CLOSED;
|
||||||
|
expect(getTerminalWs('sess-y')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rebinds a socket to a new session id and drops the old mapping', () => {
|
||||||
|
const a = connect(wss, 'sess-1');
|
||||||
|
// Same socket re-registers under a new id (a second deploy in the same tab).
|
||||||
|
a.emit('message', Buffer.from(JSON.stringify({ action: 'connectTerminal', sessionId: 'sess-2' })));
|
||||||
|
expect(getTerminalWs('sess-1')).toBeUndefined();
|
||||||
|
expect(getTerminalWs('sess-2')).toBe(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the id-less fallback when its socket closes', () => {
|
||||||
|
const a = connect(wss);
|
||||||
|
expect(getTerminalWs()).toBe(a);
|
||||||
|
a.emit('close');
|
||||||
|
expect(getTerminalWs()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose a keyed socket as the id-less fallback', () => {
|
||||||
|
// A headerless operation (bulk / rollback / legacy) resolves via getTerminalWs()
|
||||||
|
// with no id; it must never reach a keyed deploy modal's socket.
|
||||||
|
const a = connect(wss, 'sess-keyed');
|
||||||
|
expect(getTerminalWs('sess-keyed')).toBe(a);
|
||||||
|
expect(getTerminalWs()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the id-less fallback when that socket later adopts a session id', () => {
|
||||||
|
const a = connect(wss);
|
||||||
|
expect(getTerminalWs()).toBe(a);
|
||||||
|
a.emit('message', Buffer.from(JSON.stringify({ action: 'connectTerminal', sessionId: 'sess-late' })));
|
||||||
|
expect(getTerminalWs()).toBeUndefined();
|
||||||
|
expect(getTerminalWs('sess-late')).toBe(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,7 @@ import { sendGitSourceError } from '../utils/gitSourceHttp';
|
|||||||
import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate';
|
import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate';
|
||||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||||
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
|
import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants';
|
||||||
import { getTerminalWs } from '../websocket/generic';
|
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
|
||||||
|
|
||||||
// Authenticated users with edit permission can write arbitrarily large compose
|
// Authenticated users with edit permission can write arbitrarily large compose
|
||||||
// files. Refuse to YAML.parse anything beyond this bound so a malformed (or
|
// files. Refuse to YAML.parse anything beyond this bound so a malformed (or
|
||||||
@@ -311,7 +311,7 @@ async function runStackBulkOp(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const atomic = effectiveTier(req) === 'paid';
|
const atomic = effectiveTier(req) === 'paid';
|
||||||
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(), atomic);
|
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
||||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||||
NotificationService.getInstance().broadcastEvent({
|
NotificationService.getInstance().broadcastEvent({
|
||||||
type: 'state-invalidate',
|
type: 'state-invalidate',
|
||||||
@@ -892,7 +892,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
|
|||||||
const debug = isDebugEnabled();
|
const debug = isDebugEnabled();
|
||||||
const atomic = effectiveTier(req) === 'paid';
|
const atomic = effectiveTier(req) === 'paid';
|
||||||
if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId });
|
if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId });
|
||||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic);
|
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
||||||
invalidateNodeCaches(req.nodeId);
|
invalidateNodeCaches(req.nodeId);
|
||||||
dlog(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`);
|
dlog(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`);
|
||||||
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
|
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
|
||||||
@@ -938,7 +938,7 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => {
|
|||||||
let ok = false;
|
let ok = false;
|
||||||
try {
|
try {
|
||||||
if (isDebugEnabled()) console.debug(`[Stacks:debug] Down starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId });
|
if (isDebugEnabled()) console.debug(`[Stacks:debug] Down starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId });
|
||||||
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs());
|
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs(req.get(DEPLOY_SESSION_HEADER)));
|
||||||
invalidateNodeCaches(req.nodeId);
|
invalidateNodeCaches(req.nodeId);
|
||||||
dlog(`[Stacks] Down completed: ${sanitizeForLog(stackName)}`);
|
dlog(`[Stacks] Down completed: ${sanitizeForLog(stackName)}`);
|
||||||
ok = true;
|
ok = true;
|
||||||
@@ -1152,7 +1152,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
|
|||||||
const debug = isDebugEnabled();
|
const debug = isDebugEnabled();
|
||||||
const atomic = effectiveTier(req) === 'paid';
|
const atomic = effectiveTier(req) === 'paid';
|
||||||
if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId });
|
if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId });
|
||||||
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(), atomic);
|
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
||||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||||
invalidateNodeCaches(req.nodeId);
|
invalidateNodeCaches(req.nodeId);
|
||||||
NotificationService.getInstance().broadcastEvent({
|
NotificationService.getInstance().broadcastEvent({
|
||||||
@@ -1209,7 +1209,7 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) =>
|
|||||||
dlog(`[Stacks] Rollback initiated: ${sanitizeForLog(stackName)}`);
|
dlog(`[Stacks] Rollback initiated: ${sanitizeForLog(stackName)}`);
|
||||||
await fsSvc.restoreStackFiles(stackName);
|
await fsSvc.restoreStackFiles(stackName);
|
||||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
||||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), false);
|
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), false);
|
||||||
invalidateNodeCaches(req.nodeId);
|
invalidateNodeCaches(req.nodeId);
|
||||||
dlog(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`);
|
dlog(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`);
|
||||||
res.json({ message: 'Stack rolled back successfully.' });
|
res.json({ message: 'Stack rolled back successfully.' });
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { isDebugEnabled } from '../utils/debug';
|
|||||||
import { getErrorMessage } from '../utils/errors';
|
import { getErrorMessage } from '../utils/errors';
|
||||||
import { runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate';
|
import { runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate';
|
||||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||||
import { getTerminalWs } from '../websocket/generic';
|
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
|
||||||
|
|
||||||
export const templatesRouter = Router();
|
export const templatesRouter = Router();
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const atomic = effectiveTier(req) === 'paid';
|
const atomic = effectiveTier(req) === 'paid';
|
||||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic);
|
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
|
||||||
invalidateNodeCaches(req.nodeId);
|
invalidateNodeCaches(req.nodeId);
|
||||||
console.log(`[Templates] Deploy completed: ${stackName}`);
|
console.log(`[Templates] Deploy completed: ${stackName}`);
|
||||||
res.json({ success: true, message: 'Template deployed successfully' });
|
res.json({ success: true, message: 'Template deployed successfully' });
|
||||||
|
|||||||
@@ -131,9 +131,6 @@ export class ComposeService {
|
|||||||
clearTimeout(forceKillTimeout);
|
clearTimeout(forceKillTimeout);
|
||||||
forceKillTimeout = null;
|
forceKillTimeout = null;
|
||||||
}
|
}
|
||||||
if (ws) {
|
|
||||||
ws.removeListener('close', onClientDisconnect);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const finish = (complete: () => void) => {
|
const finish = (complete: () => void) => {
|
||||||
@@ -161,21 +158,17 @@ export class ComposeService {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClientDisconnect = () => {
|
// The progress socket is output-only: a deploy/update/down is owned by the
|
||||||
const message = 'Command cancelled because the client disconnected';
|
// HTTP request that started it, so closing or losing the socket (the user
|
||||||
terminateChild(new Error(message));
|
// minimizes the panel, navigates away, or the connection blips) must not
|
||||||
};
|
// terminate the compose process. Termination is driven solely by the
|
||||||
|
// command timeout below.
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
const message = `Command timed out after ${Math.round(timeoutMs / 1000)}s`;
|
const message = `Command timed out after ${Math.round(timeoutMs / 1000)}s`;
|
||||||
sendOutput(`${message}\n`);
|
sendOutput(`${message}\n`);
|
||||||
terminateChild(new Error(message));
|
terminateChild(new Error(message));
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
if (ws) {
|
|
||||||
ws.once('close', onClientDisconnect);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onData = (data: Buffer) => {
|
const onData = (data: Buffer) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
errorLog += text;
|
errorLog += text;
|
||||||
|
|||||||
@@ -8,19 +8,28 @@ import { isDebugEnabled } from '../utils/debug';
|
|||||||
import { rejectUpgrade as reject } from './reject';
|
import { rejectUpgrade as reject } from './reject';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Module-scope singleton: the most recent WebSocket to send
|
* Header the deploy/update/down routes carry the per-deploy correlation id on,
|
||||||
* `{action: 'connectTerminal'}` receives streaming output from any subsequent
|
* mirroring the `sessionId` the frontend sends in `{action:'connectTerminal'}`.
|
||||||
* compose deploy/down/update. Routes that want to echo compose progress read
|
* Must stay in sync with `DEPLOY_SESSION_HEADER` in `frontend/src/lib/api.ts`.
|
||||||
* the current value via `getTerminalWs()`.
|
|
||||||
*
|
|
||||||
* Intentionally single-instance. If multiple clients connect, the last one
|
|
||||||
* wins. This matches pre-refactor behavior; race-hardening is a separate
|
|
||||||
* concern.
|
|
||||||
*/
|
*/
|
||||||
let terminalWs: WebSocket | undefined;
|
export const DEPLOY_SESSION_HEADER = 'x-deploy-session-id';
|
||||||
|
|
||||||
export function getTerminalWs(): WebSocket | undefined {
|
/**
|
||||||
return terminalWs;
|
* Registry of compose-progress sockets keyed by the per-deploy correlation id
|
||||||
|
* the frontend sends on `{action:'connectTerminal', sessionId}` and echoes on
|
||||||
|
* the deploy/update/down POST via {@link DEPLOY_SESSION_HEADER}. A route resolves
|
||||||
|
* the socket for its own deploy with `getTerminalWs(sessionId)`, so concurrent
|
||||||
|
* deploys from different tabs or users never cross-stream output to each other.
|
||||||
|
*
|
||||||
|
* `lastTerminalWs` is the fallback for callers that connect or stream without a
|
||||||
|
* session id (bulk operations, legacy clients): the most recent such socket wins.
|
||||||
|
*/
|
||||||
|
const terminalRegistry = new Map<string, WebSocket>();
|
||||||
|
let lastTerminalWs: WebSocket | undefined;
|
||||||
|
|
||||||
|
export function getTerminalWs(sessionId?: string): WebSocket | undefined {
|
||||||
|
const ws = sessionId ? terminalRegistry.get(sessionId) : lastTerminalWs;
|
||||||
|
return ws && ws.readyState === WebSocket.OPEN ? ws : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GenericContext {
|
interface GenericContext {
|
||||||
@@ -85,6 +94,7 @@ export function handleGenericWs(
|
|||||||
export function attachGenericConnectionHandlers(wss: WebSocketServer): void {
|
export function attachGenericConnectionHandlers(wss: WebSocketServer): void {
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws) => {
|
||||||
console.log('WebSocket connected');
|
console.log('WebSocket connected');
|
||||||
|
let registeredSessionId: string | undefined;
|
||||||
|
|
||||||
ws.on('message', (message) => {
|
ws.on('message', (message) => {
|
||||||
try {
|
try {
|
||||||
@@ -92,7 +102,24 @@ export function attachGenericConnectionHandlers(wss: WebSocketServer): void {
|
|||||||
if (!data.action) return;
|
if (!data.action) return;
|
||||||
|
|
||||||
if (data.action === 'connectTerminal') {
|
if (data.action === 'connectTerminal') {
|
||||||
terminalWs = ws;
|
const sessionId = typeof data.sessionId === 'string' && data.sessionId ? data.sessionId : undefined;
|
||||||
|
// Rebind this socket to the new id, dropping any prior mapping it held.
|
||||||
|
if (registeredSessionId && registeredSessionId !== sessionId && terminalRegistry.get(registeredSessionId) === ws) {
|
||||||
|
terminalRegistry.delete(registeredSessionId);
|
||||||
|
}
|
||||||
|
registeredSessionId = sessionId;
|
||||||
|
if (sessionId) {
|
||||||
|
terminalRegistry.set(sessionId, ws);
|
||||||
|
// A keyed socket must never be the id-less fallback, or a headerless
|
||||||
|
// operation (bulk / rollback / legacy) would stream into this user's
|
||||||
|
// keyed deploy modal.
|
||||||
|
if (lastTerminalWs === ws) lastTerminalWs = undefined;
|
||||||
|
} else {
|
||||||
|
lastTerminalWs = ws;
|
||||||
|
}
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.debug('[Deploy:diag] progress stream registered', { session: sessionId ? `${sessionId.slice(0, 8)}…` : '(none)' });
|
||||||
|
}
|
||||||
} else if (data.action === 'streamStats') {
|
} else if (data.action === 'streamStats') {
|
||||||
const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId();
|
const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId();
|
||||||
// When a WS is proxied from a gateway to this remote instance, the
|
// When a WS is proxied from a gateway to this remote instance, the
|
||||||
@@ -117,5 +144,12 @@ export function attachGenericConnectionHandlers(wss: WebSocketServer): void {
|
|||||||
// Malformed JSON - ignore silently
|
// Malformed JSON - ignore silently
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (registeredSessionId && terminalRegistry.get(registeredSessionId) === ws) {
|
||||||
|
terminalRegistry.delete(registeredSessionId);
|
||||||
|
}
|
||||||
|
if (lastTerminalWs === ws) lastTerminalWs = undefined;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ The modal opens automatically when you trigger an action. It floats centered in
|
|||||||
### What the modal shows
|
### What the modal shows
|
||||||
|
|
||||||
- **Header**: the action verb (Deploying, Updating, Installing, Restarting, Stopping), the stack name in monospace truncated at 200 px, and an elapsed-time chip that appears once the connection moves past the initial **Connecting...** state.
|
- **Header**: the action verb (Deploying, Updating, Installing, Restarting, Stopping), the stack name in monospace truncated at 200 px, and an elapsed-time chip that appears once the connection moves past the initial **Connecting...** state.
|
||||||
- **Status indicator** in the upper right: one of `Connecting...` while the stream attaches, a live `<n> lines` counter while output is flowing, `Succeeded · closes in <n>s` after a clean finish, or the failure message itself when the run errors out.
|
- **Status indicator** in the upper right: one of `Connecting...` while the stream attaches, a live `<n> lines` counter while output is flowing, `Succeeded · closes in <n>s` after a clean finish, or the failure message itself when the run errors out. If the live stream cannot attach or drops, the indicator switches to `Live progress unavailable` and the operation keeps running in the background.
|
||||||
- **Structured log body**: one row per output line. Each row carries a timestamp, a fixed-width stage badge, and the log message. Error rows have a destructive left border and tinted background so they stand out without scanning.
|
- **Structured log body**: one row per output line. Each row carries a timestamp, a fixed-width stage badge, and the log message. Error rows have a destructive left border and tinted background so they stand out without scanning.
|
||||||
- **Footer**: a `Raw output` / `Hide raw` toggle on the left, with **Minimize** and **Close** buttons on the right.
|
- **Footer**: a `Raw output` / `Hide raw` toggle on the left, with **Minimize** and **Close** buttons on the right.
|
||||||
|
|
||||||
@@ -76,6 +76,8 @@ Click **Minimize** to collapse the modal to a small status pill anchored at the
|
|||||||
|
|
||||||
The pill is mounted as a portal, so it persists across navigation: leave the App Store mid-install and the pill follows you to the dashboard, the editor, or any other view.
|
The pill is mounted as a portal, so it persists across navigation: leave the App Store mid-install and the pill follows you to the dashboard, the editor, or any other view.
|
||||||
|
|
||||||
|
Minimizing, closing, or navigating away never cancels the operation. The progress view only displays output; the deploy, update, or stop runs to completion on its own. If you dismiss the view while live output is flowing, the run still finishes in the background, and its success or failure lands in your notifications.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<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'" />
|
<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>
|
</Frame>
|
||||||
@@ -95,8 +97,11 @@ The HTTP API also exposes a `down` action (compose-level teardown) that streams
|
|||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
<AccordionGroup>
|
<AccordionGroup>
|
||||||
<Accordion title="The modal shows 'Connecting...' and no rows appear">
|
<Accordion title="The modal shows 'Live progress unavailable' or stays on 'Connecting...'">
|
||||||
The modal opens a WebSocket to the same host and port as the rest of the API and registers itself as the recipient of compose output. If the connection cannot upgrade, the status stays at **Connecting...** indefinitely. Check that an upstream proxy or firewall is not blocking WebSocket upgrades. For nginx, ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection "upgrade";` are configured. See the [configuration guide](/getting-started/configuration#reverse-proxy-setup) for a full nginx example.
|
The modal opens a WebSocket to the same host and port as the rest of the API to receive compose output. If that connection cannot upgrade, the modal switches to **Live progress unavailable** and the operation continues without live output, finishing in the background with its result in your notifications. To restore live streaming, check that an upstream proxy or firewall is not blocking WebSocket upgrades. For nginx, ensure `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection "upgrade";` are configured. See the [configuration guide](/getting-started/configuration#reverse-proxy-setup) for a full nginx example.
|
||||||
|
</Accordion>
|
||||||
|
<Accordion title="The modal said 'Live progress unavailable' but my stack still deployed">
|
||||||
|
That is expected. The deploy, update, or stop is driven by the action you triggered, not by the progress stream. The stream is a live view layered on top, so if it cannot connect or drops partway, the operation still runs to completion. Reopen the stack or check your notifications to confirm the final result.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
<Accordion title="Restart or Stop opens the modal but shows 0 lines">
|
<Accordion title="Restart or Stop opens the modal but shows 0 lines">
|
||||||
Restart and Stop hit the Docker Engine directly to act on the existing containers; they do not invoke `docker compose`, so there is no compose stream to render. The modal still opens to confirm the action and to surface a failure if one occurs. For a fully populated structured log, use **Deploy** or **Update** instead.
|
Restart and Stop hit the Docker Engine directly to act on the existing containers; they do not invoke `docker compose`, so there is no compose stream to render. The modal still opens to confirm the action and to surface a failure if one occurs. For a fully populated structured log, use **Deploy** or **Update** instead.
|
||||||
|
|||||||
@@ -266,6 +266,40 @@ test.describe('Deploy feedback modal', () => {
|
|||||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deploy continues to success after minimizing mid-deploy', 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();
|
||||||
|
|
||||||
|
const modal = page.locator('[data-testid="deploy-feedback-modal"]');
|
||||||
|
await expect(modal).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
// Wait until output is actually streaming so the minimize happens mid-deploy,
|
||||||
|
// not after a too-fast completion (which would pass vacuously).
|
||||||
|
await Promise.race([
|
||||||
|
page.getByText(/Connecting\.\.\./i).waitFor({ state: 'visible', timeout: 10_000 }),
|
||||||
|
page.getByText(/\d+ lines?/i).waitFor({ state: 'visible', timeout: 10_000 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Minimize while the deploy is in flight. The progress socket closes when
|
||||||
|
// the dialog unmounts; the deploy is owned by its HTTP request and must
|
||||||
|
// keep running. Before the output-only fix this aborted the deploy with a
|
||||||
|
// "client disconnected" failure.
|
||||||
|
await page.getByRole('button', { name: 'Minimize' }).first().click();
|
||||||
|
const pill = page.locator('[data-testid="deploy-feedback-pill"]');
|
||||||
|
await expect(pill).toBeVisible({ timeout: 5_000 });
|
||||||
|
|
||||||
|
// Re-expand and confirm the deploy reached success, never a disconnect failure.
|
||||||
|
await pill.click();
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||||
|
await expect(page.getByText('Succeeded')).toBeVisible({ timeout: 90_000 });
|
||||||
|
await expect(page.getByText(/client disconnected/i)).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test.afterEach(async ({ page }) => {
|
test.afterEach(async ({ page }) => {
|
||||||
await deleteStackViaApi(page, HAPPY_STACK);
|
await deleteStackViaApi(page, HAPPY_STACK);
|
||||||
await deleteStackViaApi(page, FAIL_STACK);
|
await deleteStackViaApi(page, FAIL_STACK);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from 'lucide-react';
|
import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from 'lucide-react';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch, withDeploySession } from '@/lib/api';
|
||||||
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
@@ -189,9 +189,9 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runWithLog({ stackName: stackName.trim(), action: 'install' }, async (started) => {
|
const result = await runWithLog({ stackName: stackName.trim(), action: 'install' }, async (started, ds) => {
|
||||||
await started;
|
await started;
|
||||||
const res = await apiFetch('/templates/deploy', {
|
const res = await apiFetch('/templates/deploy', withDeploySession(ds, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
stackName: stackName.trim(),
|
stackName: stackName.trim(),
|
||||||
@@ -199,7 +199,7 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) {
|
|||||||
envVars: finalEnvVars,
|
envVars: finalEnvVars,
|
||||||
skip_scan: !autoScan,
|
skip_scan: !autoScan,
|
||||||
}),
|
}),
|
||||||
});
|
}));
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) return { ok: false, errorMessage: data.error || 'Failed to deploy template' };
|
if (!res.ok) return { ok: false, errorMessage: data.error || 'Failed to deploy template' };
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ function formatElapsed(seconds: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) {
|
export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) {
|
||||||
const { panelState, logRows, onTerminalReady, onMessage, onPanelClose } = useDeployFeedback();
|
const { panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback();
|
||||||
const { isPaid } = useLicense();
|
const { isPaid } = useLicense();
|
||||||
|
|
||||||
const [showRaw, setShowRaw] = useState(false);
|
const [showRaw, setShowRaw] = useState(false);
|
||||||
@@ -46,7 +46,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
|||||||
const autoCloseHoveredRef = useRef(false);
|
const autoCloseHoveredRef = useRef(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { isOpen, stackName, action, status, errorMessage } = panelState;
|
const { isOpen, stackName, action, status, errorMessage, progressUnavailable } = panelState;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -190,6 +190,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
|||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<StatusIndicator
|
<StatusIndicator
|
||||||
status={status}
|
status={status}
|
||||||
|
progressUnavailable={progressUnavailable}
|
||||||
rowCount={logRows.length}
|
rowCount={logRows.length}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
countdown={countdown}
|
countdown={countdown}
|
||||||
@@ -232,7 +233,7 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
|||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
{logRows.length === 0 ? (
|
{logRows.length === 0 ? (
|
||||||
<EmptyBody status={status} />
|
<EmptyBody status={status} progressUnavailable={progressUnavailable} />
|
||||||
) : (
|
) : (
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{logRows.map((row) => (
|
{logRows.map((row) => (
|
||||||
@@ -248,7 +249,9 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
|||||||
style={{ height: showRaw ? '200px' : 0, overflow: 'hidden' }}
|
style={{ height: showRaw ? '200px' : 0, overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<TerminalComponent
|
<TerminalComponent
|
||||||
|
deploySessionId={panelState.deploySessionId}
|
||||||
onReady={onTerminalReady}
|
onReady={onTerminalReady}
|
||||||
|
onError={onTerminalError}
|
||||||
onMessage={onMessage}
|
onMessage={onMessage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,12 +296,24 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
|||||||
|
|
||||||
interface StatusIndicatorProps {
|
interface StatusIndicatorProps {
|
||||||
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
|
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
|
||||||
|
progressUnavailable: boolean;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
countdown: number;
|
countdown: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusIndicator({ status, rowCount, errorMessage, countdown }: StatusIndicatorProps) {
|
function StatusIndicator({ status, progressUnavailable, rowCount, errorMessage, countdown }: StatusIndicatorProps) {
|
||||||
|
// While the deploy is still in flight (preparing/streaming) but the progress
|
||||||
|
// socket is gone, the deploy keeps running server-side with no live output.
|
||||||
|
if (progressUnavailable && (status === 'preparing' || 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-muted-foreground" />
|
||||||
|
<span>Live progress unavailable</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'preparing') {
|
if (status === 'preparing') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
@@ -343,9 +358,18 @@ function StatusIndicator({ status, rowCount, errorMessage, countdown }: StatusIn
|
|||||||
|
|
||||||
interface EmptyBodyProps {
|
interface EmptyBodyProps {
|
||||||
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
|
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
|
||||||
|
progressUnavailable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmptyBody({ status }: EmptyBodyProps) {
|
function EmptyBody({ status, progressUnavailable }: EmptyBodyProps) {
|
||||||
|
if (progressUnavailable && (status === 'preparing' || status === 'streaming')) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-10 text-sm text-muted-foreground text-center px-4">
|
||||||
|
Live progress is unavailable for this deploy. It continues running in the background.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'preparing') {
|
if (status === 'preparing') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center gap-2 py-10 text-muted-foreground">
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import type { useStackListState } from './useStackListState';
|
|||||||
import type { useViewNavigationState } from './useViewNavigationState';
|
import type { useViewNavigationState } from './useViewNavigationState';
|
||||||
import type { OverlayState } from './useOverlayState';
|
import type { OverlayState } from './useOverlayState';
|
||||||
|
|
||||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
vi.mock('@/lib/api', () => ({
|
||||||
|
apiFetch: vi.fn(),
|
||||||
|
DEPLOY_SESSION_HEADER: 'x-deploy-session-id',
|
||||||
|
withDeploySession: (deploySessionId: string, options: RequestInit = {}) => ({
|
||||||
|
...options,
|
||||||
|
headers: { ...(options.headers as Record<string, string> | undefined), 'x-deploy-session-id': deploySessionId },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
vi.mock('@/components/ui/toast-store', () => ({
|
vi.mock('@/components/ui/toast-store', () => ({
|
||||||
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||||
}));
|
}));
|
||||||
@@ -72,7 +79,7 @@ function makeOverlay(): OverlayState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runWithLog: Parameters<typeof useStackActions>[0]['runWithLog'] = async (_p, run) =>
|
const runWithLog: Parameters<typeof useStackActions>[0]['runWithLog'] = async (_p, run) =>
|
||||||
run(Promise.resolve());
|
run(Promise.resolve(), 'test-session');
|
||||||
|
|
||||||
function setup(over: { editorState?: Partial<EditorState> } = {}) {
|
function setup(over: { editorState?: Partial<EditorState> } = {}) {
|
||||||
const editorState = makeEditorState(over.editorState);
|
const editorState = makeEditorState(over.editorState);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef, useCallback, useEffect } from 'react';
|
import { useRef, useCallback, useEffect } from 'react';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch, withDeploySession } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import type { useEditorViewState } from './useEditorViewState';
|
import type { useEditorViewState } from './useEditorViewState';
|
||||||
import type { useStackListState } from './useStackListState';
|
import type { useStackListState } from './useStackListState';
|
||||||
@@ -61,7 +61,7 @@ interface UseStackActionsOptions {
|
|||||||
isPaid: boolean;
|
isPaid: boolean;
|
||||||
runWithLog: (
|
runWithLog: (
|
||||||
params: { stackName: string; action: ActionVerb },
|
params: { stackName: string; action: ActionVerb },
|
||||||
run: (deployStarted: Promise<void>) => Promise<RunResult>,
|
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>,
|
||||||
) => Promise<RunResult>;
|
) => Promise<RunResult>;
|
||||||
diffPreviewEnabled: boolean;
|
diffPreviewEnabled: boolean;
|
||||||
}
|
}
|
||||||
@@ -511,6 +511,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
stackFile: string,
|
stackFile: string,
|
||||||
ignorePolicy: boolean,
|
ignorePolicy: boolean,
|
||||||
started?: Promise<void>,
|
started?: Promise<void>,
|
||||||
|
deploySessionId?: string,
|
||||||
): Promise<RunResult> => {
|
): Promise<RunResult> => {
|
||||||
const previousStatus = stackListState.stackStatuses[stackFile];
|
const previousStatus = stackListState.stackStatuses[stackFile];
|
||||||
stackListState.setOptimisticStatus(stackFile, 'running');
|
stackListState.setOptimisticStatus(stackFile, 'running');
|
||||||
@@ -519,7 +520,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
? `/stacks/${stackName}/deploy?ignorePolicy=true`
|
? `/stacks/${stackName}/deploy?ignorePolicy=true`
|
||||||
: `/stacks/${stackName}/deploy`;
|
: `/stacks/${stackName}/deploy`;
|
||||||
if (started) await started;
|
if (started) await started;
|
||||||
const response = await apiFetch(path, { method: 'POST' });
|
const response = await apiFetch(path, withDeploySession(deploySessionId ?? '', { method: 'POST' }));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const rawBody = await response.text();
|
const rawBody = await response.text();
|
||||||
if (response.status === 409) {
|
if (response.status === 409) {
|
||||||
@@ -598,8 +599,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||||
stackListState.setStackAction(stackFile, 'deploy');
|
stackListState.setStackAction(stackFile, 'deploy');
|
||||||
try {
|
try {
|
||||||
await runWithLog({ stackName, action: 'deploy' }, started =>
|
await runWithLog({ stackName, action: 'deploy' }, (started, ds) =>
|
||||||
runDeploy(stackName, stackFile, false, started),
|
runDeploy(stackName, stackFile, false, started, ds),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
stackListState.clearStackAction(stackFile);
|
stackListState.clearStackAction(stackFile);
|
||||||
@@ -624,8 +625,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
overlayState.setPolicyBypassing(true);
|
overlayState.setPolicyBypassing(true);
|
||||||
stackListState.setStackAction(existingFile, 'deploy');
|
stackListState.setStackAction(existingFile, 'deploy');
|
||||||
try {
|
try {
|
||||||
await runWithLog({ stackName, action: 'deploy' }, started =>
|
await runWithLog({ stackName, action: 'deploy' }, (started, ds) =>
|
||||||
runDeploy(stackName, existingFile, true, started),
|
runDeploy(stackName, existingFile, true, started, ds),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
overlayState.setPolicyBypassing(false);
|
overlayState.setPolicyBypassing(false);
|
||||||
@@ -719,10 +720,10 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
stackListState.setStackAction(stackFile, action);
|
stackListState.setStackAction(stackFile, action);
|
||||||
stackListState.setOptimisticStatus(stackFile, optimisticStatus);
|
stackListState.setOptimisticStatus(stackFile, optimisticStatus);
|
||||||
try {
|
try {
|
||||||
await runWithLog({ stackName, action }, async (started) => {
|
await runWithLog({ stackName, action }, async (started, ds) => {
|
||||||
await started;
|
await started;
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' });
|
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, withDeploySession(ds, { method: 'POST' }));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errText = await response.text();
|
const errText = await response.text();
|
||||||
if (response.status === 409) {
|
if (response.status === 409) {
|
||||||
|
|||||||
@@ -7,11 +7,17 @@ import { buildXtermTheme } from '@/lib/terminalTheme';
|
|||||||
|
|
||||||
interface TerminalComponentProps {
|
interface TerminalComponentProps {
|
||||||
stackName?: string;
|
stackName?: string;
|
||||||
|
/** Correlation id sent on the generic `connectTerminal` handshake so the
|
||||||
|
* backend streams the matching deploy's output to this socket. */
|
||||||
|
deploySessionId?: string;
|
||||||
onReady?: () => void;
|
onReady?: () => void;
|
||||||
|
/** Fired when the socket fails to connect or drops while still mounted, so the
|
||||||
|
* caller can stop waiting on a best-effort progress stream. */
|
||||||
|
onError?: () => void;
|
||||||
onMessage?: (text: string) => void;
|
onMessage?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TerminalComponent({ stackName, onReady, onMessage }: TerminalComponentProps) {
|
export default function TerminalComponent({ stackName, deploySessionId, onReady, onError, onMessage }: TerminalComponentProps) {
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
const terminalInstance = useRef<Terminal | null>(null);
|
const terminalInstance = useRef<Terminal | null>(null);
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
@@ -124,8 +130,9 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter
|
|||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
if (!cleanStackName) {
|
if (!cleanStackName) {
|
||||||
// Generic terminal mode - send connect action
|
// Generic terminal mode - send connect action with the deploy
|
||||||
ws.send(JSON.stringify({ action: 'connectTerminal' }));
|
// correlation id so the backend streams this deploy's output here.
|
||||||
|
ws.send(JSON.stringify({ action: 'connectTerminal', sessionId: deploySessionId }));
|
||||||
onReady?.();
|
onReady?.();
|
||||||
}
|
}
|
||||||
// For stack logs mode, the server starts streaming automatically on connection
|
// For stack logs mode, the server starts streaming automatically on connection
|
||||||
@@ -142,10 +149,21 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter
|
|||||||
|
|
||||||
ws.onerror = (err) => {
|
ws.onerror = (err) => {
|
||||||
console.error('WebSocket error:', err);
|
console.error('WebSocket error:', err);
|
||||||
|
if (mounted) onError?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
// Only surface unexpected closes. Intentional teardown sets mounted
|
||||||
|
// false before closing, so this skips minimize/navigation/unmount.
|
||||||
|
if (mounted) onError?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error initializing terminal:', err);
|
console.error('Error initializing terminal:', err);
|
||||||
|
// A synchronous setup failure (e.g. WebSocket construction blocked by
|
||||||
|
// CSP) gives no onerror/onclose, so release the deploy gate now instead
|
||||||
|
// of waiting out the connect timeout.
|
||||||
|
if (mounted) onError?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,7 +213,7 @@ export default function TerminalComponent({ stackName, onReady, onMessage }: Ter
|
|||||||
searchAddonRef.current = null;
|
searchAddonRef.current = null;
|
||||||
serializeAddonRef.current = null;
|
serializeAddonRef.current = null;
|
||||||
};
|
};
|
||||||
}, [stackName, onReady, onMessage]);
|
}, [stackName, deploySessionId, onReady, onError, onMessage]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
if (!serializeAddonRef.current) return;
|
if (!serializeAddonRef.current) return;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ function panel(over: Partial<DeployPanelState> = {}): DeployPanelState {
|
|||||||
stackName: '',
|
stackName: '',
|
||||||
action: 'deploy',
|
action: 'deploy',
|
||||||
status: 'preparing',
|
status: 'preparing',
|
||||||
|
progressUnavailable: false,
|
||||||
|
deploySessionId: '',
|
||||||
sessionId: 0,
|
sessionId: 0,
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ function notif(overrides: Partial<NotificationItem> = {}): NotificationItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const IDLE_PANEL: DeployPanelState = { isOpen: false, stackName: '', action: 'deploy', status: 'preparing', sessionId: 0 };
|
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', sessionId: 1 };
|
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', sessionId: 1 };
|
const SUCCEEDED_PANEL: DeployPanelState = { isOpen: true, stackName: 'api', action: 'deploy', status: 'succeeded', progressUnavailable: false, deploySessionId: '', sessionId: 1 };
|
||||||
|
|
||||||
function inputs(overrides: Partial<Parameters<typeof deriveSummary>[0]> = {}) {
|
function inputs(overrides: Partial<Parameters<typeof deriveSummary>[0]> = {}) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ export interface DeployPanelState {
|
|||||||
action: ActionVerb;
|
action: ActionVerb;
|
||||||
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
|
status: 'preparing' | 'streaming' | 'succeeded' | 'failed';
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
/**
|
||||||
|
* True once the progress socket has failed to connect or dropped. The deploy
|
||||||
|
* is owned by its HTTP request, so it still runs to completion; this only
|
||||||
|
* tells the UI that live output is no longer arriving.
|
||||||
|
*/
|
||||||
|
progressUnavailable: boolean;
|
||||||
|
/**
|
||||||
|
* Per-deploy correlation id sent to the backend on both the `connectTerminal`
|
||||||
|
* WebSocket message and the deploy POST header, so concurrent deploys never
|
||||||
|
* cross-stream output. Empty until the first runWithLog call.
|
||||||
|
*/
|
||||||
|
deploySessionId: string;
|
||||||
/**
|
/**
|
||||||
* Monotonic id incremented on every runWithLog call. Lets external
|
* Monotonic id incremented on every runWithLog call. Lets external
|
||||||
* consumers (e.g. the sidebar footer elapsed-time tracker) detect a new
|
* consumers (e.g. the sidebar footer elapsed-time tracker) detect a new
|
||||||
@@ -37,11 +49,12 @@ interface RunResult {
|
|||||||
interface DeployFeedbackContextValue {
|
interface DeployFeedbackContextValue {
|
||||||
runWithLog: (
|
runWithLog: (
|
||||||
params: { stackName: string; action: ActionVerb },
|
params: { stackName: string; action: ActionVerb },
|
||||||
run: (deployStarted: Promise<void>) => Promise<RunResult>
|
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>
|
||||||
) => Promise<RunResult>;
|
) => Promise<RunResult>;
|
||||||
panelState: DeployPanelState;
|
panelState: DeployPanelState;
|
||||||
logRows: ParsedLogRow[];
|
logRows: ParsedLogRow[];
|
||||||
onTerminalReady: () => void;
|
onTerminalReady: () => void;
|
||||||
|
onTerminalError: () => void;
|
||||||
onMessage: (text: string) => void;
|
onMessage: (text: string) => void;
|
||||||
onPanelClose: () => void;
|
onPanelClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -51,19 +64,45 @@ const DEFAULT_PANEL_STATE: DeployPanelState = {
|
|||||||
stackName: '',
|
stackName: '',
|
||||||
action: 'deploy',
|
action: 'deploy',
|
||||||
status: 'preparing',
|
status: 'preparing',
|
||||||
|
progressUnavailable: false,
|
||||||
|
deploySessionId: '',
|
||||||
sessionId: 0,
|
sessionId: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on the parsed rows kept in memory for one deploy. A verbose deploy
|
||||||
|
* (many services, many image layers) can emit thousands of lines; past this cap
|
||||||
|
* the oldest rows are dropped and a single sentinel row marks the truncation, so
|
||||||
|
* state and the rendered DOM stay bounded. The xterm raw view keeps its own
|
||||||
|
* 10k-line scrollback independently.
|
||||||
|
*/
|
||||||
|
const MAX_LOG_ROWS = 5000;
|
||||||
|
const TRUNCATION_ROW_ID = 'row-truncated';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-resort fallback: if the progress socket neither opens nor errors within
|
||||||
|
* this window, release the deploy anyway so a silently stalled connection never
|
||||||
|
* blocks the deploy itself. Connect failures normally resolve far sooner via
|
||||||
|
* onTerminalError.
|
||||||
|
*/
|
||||||
|
const PROGRESS_CONNECT_TIMEOUT_MS = 8000;
|
||||||
|
|
||||||
const DeployFeedbackContext = createContext<DeployFeedbackContextValue | undefined>(undefined);
|
const DeployFeedbackContext = createContext<DeployFeedbackContextValue | undefined>(undefined);
|
||||||
|
|
||||||
export function DeployFeedbackProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
export function DeployFeedbackProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||||
const [panelState, setPanelState] = useState<DeployPanelState>(DEFAULT_PANEL_STATE);
|
const [panelState, setPanelState] = useState<DeployPanelState>(DEFAULT_PANEL_STATE);
|
||||||
const [logRows, setLogRows] = useState<ParsedLogRow[]>([]);
|
const [logRows, setLogRows] = useState<ParsedLogRow[]>([]);
|
||||||
|
|
||||||
// Holds the resolver for the current session's deployStarted promise.
|
// Idempotent resolver for the current session's deployStarted gate. Set at the
|
||||||
// Updated at the start of each runWithLog call; not state because
|
// start of each runWithLog call; called by onTerminalReady (stream connected),
|
||||||
// changing it must not trigger a re-render.
|
// onTerminalError (stream failed/dropped), or the connect-timeout fallback.
|
||||||
const readyResolverRef = useRef<(() => void) | null>(null);
|
// Not state: changing it must not trigger a re-render.
|
||||||
|
const settleStartRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// Whether the progress stream has connected for the current session. Lets
|
||||||
|
// onTerminalError distinguish a connect failure (release the deploy gate) from
|
||||||
|
// a mid-stream drop (gate already released; just mark output unavailable).
|
||||||
|
const streamReadyRef = useRef(false);
|
||||||
|
|
||||||
// Tracks whether a session is still active so a cancelled session
|
// Tracks whether a session is still active so a cancelled session
|
||||||
// cannot mutate state for the new session that replaced it.
|
// cannot mutate state for the new session that replaced it.
|
||||||
@@ -76,22 +115,43 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
|||||||
const [isEnabled] = useDeployFeedbackEnabled();
|
const [isEnabled] = useDeployFeedbackEnabled();
|
||||||
|
|
||||||
const onTerminalReady = useCallback(() => {
|
const onTerminalReady = useCallback(() => {
|
||||||
setPanelState((prev) => ({ ...prev, status: 'streaming' }));
|
streamReadyRef.current = true;
|
||||||
if (readyResolverRef.current !== null) {
|
setPanelState((prev) => (prev.status === 'preparing' ? { ...prev, status: 'streaming' } : prev));
|
||||||
readyResolverRef.current();
|
// Give the connectTerminal handshake a beat to register on the backend
|
||||||
readyResolverRef.current = null;
|
// before the deploy POST fires, so the first lines are not missed.
|
||||||
}
|
setTimeout(() => settleStartRef.current?.(), 50);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTerminalError = useCallback(() => {
|
||||||
|
// The progress socket failed to connect or dropped mid-stream. The deploy is
|
||||||
|
// owned by its HTTP request, not this socket, so flag that live output is
|
||||||
|
// gone and, if the stream never connected, release the gate so the deploy
|
||||||
|
// still fires.
|
||||||
|
setPanelState((prev) => (prev.isOpen ? { ...prev, progressUnavailable: true } : prev));
|
||||||
|
if (!streamReadyRef.current) settleStartRef.current?.();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onMessage = useCallback((text: string) => {
|
const onMessage = useCallback((text: string) => {
|
||||||
const newRows = parseLogChunk(text, idCounterRef.current);
|
const newRows = parseLogChunk(text, idCounterRef.current);
|
||||||
idCounterRef.current += newRows.length;
|
idCounterRef.current += newRows.length;
|
||||||
setLogRows((prev) => [...prev, ...newRows]);
|
setLogRows((prev) => {
|
||||||
|
const combined = prev.length > 0 && prev[0].id === TRUNCATION_ROW_ID
|
||||||
|
? [...prev.slice(1), ...newRows]
|
||||||
|
: [...prev, ...newRows];
|
||||||
|
if (combined.length <= MAX_LOG_ROWS) return combined;
|
||||||
|
const kept = combined.slice(combined.length - MAX_LOG_ROWS);
|
||||||
|
return [
|
||||||
|
{ id: TRUNCATION_ROW_ID, timestamp: kept[0].timestamp, stage: 'LOG', level: 'info',
|
||||||
|
message: `... earlier output truncated (showing last ${MAX_LOG_ROWS} lines) ...`, raw: '' },
|
||||||
|
...kept,
|
||||||
|
];
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onPanelClose = useCallback(() => {
|
const onPanelClose = useCallback(() => {
|
||||||
sessionIdRef.current += 1;
|
sessionIdRef.current += 1;
|
||||||
readyResolverRef.current = null;
|
settleStartRef.current = null;
|
||||||
|
streamReadyRef.current = false;
|
||||||
setPanelState(DEFAULT_PANEL_STATE);
|
setPanelState(DEFAULT_PANEL_STATE);
|
||||||
setLogRows([]);
|
setLogRows([]);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -99,15 +159,26 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
|||||||
const runWithLog = useCallback(
|
const runWithLog = useCallback(
|
||||||
async (
|
async (
|
||||||
params: { stackName: string; action: ActionVerb },
|
params: { stackName: string; action: ActionVerb },
|
||||||
run: (deployStarted: Promise<void>) => Promise<RunResult>
|
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>
|
||||||
): Promise<RunResult> => {
|
): Promise<RunResult> => {
|
||||||
|
// Unique, unguessable per-deploy id correlating the progress socket with
|
||||||
|
// the deploy POST so concurrent deploys cannot read each other's output.
|
||||||
|
// Uses crypto.getRandomValues (the one Crypto member available in insecure
|
||||||
|
// contexts, so it works over LAN HTTP, unlike crypto.randomUUID). When the
|
||||||
|
// feature is disabled the id is never registered on the backend, so output
|
||||||
|
// simply streams nowhere.
|
||||||
|
const idBytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(idBytes);
|
||||||
|
const deploySessionId = Array.from(idBytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
return run(Promise.resolve());
|
return run(Promise.resolve(), deploySessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel any existing session before starting a new one.
|
// Cancel any existing session before starting a new one.
|
||||||
sessionIdRef.current += 1;
|
sessionIdRef.current += 1;
|
||||||
const mySession = sessionIdRef.current;
|
const mySession = sessionIdRef.current;
|
||||||
|
streamReadyRef.current = false;
|
||||||
|
|
||||||
// idCounterRef is intentionally not reset; keys must remain globally unique across sessions.
|
// idCounterRef is intentionally not reset; keys must remain globally unique across sessions.
|
||||||
setLogRows([]);
|
setLogRows([]);
|
||||||
@@ -117,18 +188,34 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
|||||||
stackName: params.stackName,
|
stackName: params.stackName,
|
||||||
action: params.action,
|
action: params.action,
|
||||||
status: 'preparing',
|
status: 'preparing',
|
||||||
|
progressUnavailable: false,
|
||||||
|
deploySessionId,
|
||||||
sessionId: mySession,
|
sessionId: mySession,
|
||||||
});
|
});
|
||||||
|
|
||||||
const deployStarted = new Promise<void>((resolve) => {
|
const deployStarted = new Promise<void>((resolve) => {
|
||||||
readyResolverRef.current = () => {
|
let done = false;
|
||||||
setTimeout(resolve, 50);
|
const settle = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
};
|
};
|
||||||
|
// settle only runs asynchronously (timer fire or onTerminalReady/Error),
|
||||||
|
// 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
|
||||||
|
// release the deploy so a silent stall never blocks it.
|
||||||
|
setPanelState((prev) => (prev.isOpen ? { ...prev, progressUnavailable: true } : prev));
|
||||||
|
settle();
|
||||||
|
}, PROGRESS_CONNECT_TIMEOUT_MS);
|
||||||
|
settleStartRef.current = settle;
|
||||||
});
|
});
|
||||||
|
|
||||||
let result: RunResult;
|
let result: RunResult;
|
||||||
try {
|
try {
|
||||||
result = await run(deployStarted);
|
result = await run(deployStarted, deploySessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
|
const message = err instanceof Error ? err.message : 'An unexpected error occurred';
|
||||||
result = { ok: false, errorMessage: message };
|
result = { ok: false, errorMessage: message };
|
||||||
@@ -149,7 +236,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DeployFeedbackContext.Provider
|
<DeployFeedbackContext.Provider
|
||||||
value={{ runWithLog, panelState, logRows, onTerminalReady, onMessage, onPanelClose }}
|
value={{ runWithLog, panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DeployFeedbackContext.Provider>
|
</DeployFeedbackContext.Provider>
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
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';
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: ReactNode }) {
|
||||||
|
return <DeployFeedbackProvider>{children}</DeployFeedbackProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DeployFeedbackContext', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'true');
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the deploy when the progress stream fails before connecting', 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' }, async (started) => {
|
||||||
|
await started;
|
||||||
|
deployRan = true;
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
// Let runWithLog install the start gate and let run() reach `await started`.
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The deploy must not have fired yet: it is gated on the progress stream.
|
||||||
|
expect(deployRan).toBe(false);
|
||||||
|
|
||||||
|
// A connect failure (e.g. the admin-only /ws gate rejecting a scoped deployer,
|
||||||
|
// or a reverse proxy blocking the upgrade) must release the gate, not hang.
|
||||||
|
await act(async () => {
|
||||||
|
result.current.onTerminalError();
|
||||||
|
await outer;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deployRan).toBe(true);
|
||||||
|
expect(result.current.panelState.progressUnavailable).toBe(true);
|
||||||
|
expect(result.current.panelState.status).toBe('succeeded');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks progress unavailable on a mid-stream drop without re-running or blocking the deploy', async () => {
|
||||||
|
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||||
|
|
||||||
|
let runCount = 0;
|
||||||
|
let outer: Promise<unknown> | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
|
||||||
|
await started;
|
||||||
|
runCount += 1;
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream connects -> gate releases (after the 50ms buffer) -> deploy runs once.
|
||||||
|
await act(async () => {
|
||||||
|
result.current.onTerminalReady();
|
||||||
|
await outer;
|
||||||
|
});
|
||||||
|
expect(runCount).toBe(1);
|
||||||
|
expect(result.current.panelState.status).toBe('succeeded');
|
||||||
|
|
||||||
|
// A late socket drop only flags unavailability; it must not re-settle or re-run.
|
||||||
|
act(() => {
|
||||||
|
result.current.onTerminalError();
|
||||||
|
});
|
||||||
|
expect(result.current.panelState.progressUnavailable).toBe(true);
|
||||||
|
expect(runCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('releases the deploy via the connect timeout when the stream never signals', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||||
|
|
||||||
|
let runCount = 0;
|
||||||
|
let outer: Promise<unknown> | undefined;
|
||||||
|
await act(async () => {
|
||||||
|
outer = result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
|
||||||
|
await started;
|
||||||
|
runCount += 1;
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runCount).toBe(0);
|
||||||
|
// Neither ready nor error fires; the 8s fallback must still release the deploy
|
||||||
|
// and flag live output unavailable so the modal stops showing "Connecting...".
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(8000);
|
||||||
|
await outer;
|
||||||
|
});
|
||||||
|
expect(runCount).toBe(1);
|
||||||
|
expect(result.current.panelState.progressUnavailable).toBe(true);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces the truncation sentinel instead of stacking it on repeated overflow', () => {
|
||||||
|
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.onMessage(Array.from({ length: 6000 }, (_, i) => `a ${i}`).join('\n'));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.onMessage('b 0\nb 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.logRows.length).toBe(5001);
|
||||||
|
expect(result.current.logRows.filter((r) => r.id === 'row-truncated').length).toBe(1);
|
||||||
|
expect(result.current.logRows[0].id).toBe('row-truncated');
|
||||||
|
expect(result.current.logRows[result.current.logRows.length - 1].message).toContain('b 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs immediately with no panel when the feature is disabled', async () => {
|
||||||
|
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
|
||||||
|
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||||
|
|
||||||
|
let deployRan = false;
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.runWithLog({ stackName: 'web', action: 'deploy' }, async (started) => {
|
||||||
|
await started;
|
||||||
|
deployRan = true;
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deployRan).toBe(true);
|
||||||
|
expect(result.current.panelState.isOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps log rows and marks the truncation point', () => {
|
||||||
|
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||||
|
|
||||||
|
const chunk = Array.from({ length: 6000 }, (_, i) => `line ${i}`).join('\n');
|
||||||
|
act(() => {
|
||||||
|
result.current.onMessage(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.logRows.length).toBe(5001);
|
||||||
|
expect(result.current.logRows[0].id).toBe('row-truncated');
|
||||||
|
expect(result.current.logRows[result.current.logRows.length - 1].message).toContain('line 5999');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
* when the caller supplied any `headers` value.
|
* when the caller supplied any `headers` value.
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { apiFetch } from '../api';
|
import { apiFetch, DEPLOY_SESSION_HEADER, withDeploySession } from '../api';
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@@ -74,3 +74,28 @@ describe('apiFetch header merge', () => {
|
|||||||
localStorage.removeItem('sencho-active-node');
|
localStorage.removeItem('sencho-active-node');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('withDeploySession', () => {
|
||||||
|
it('uses the canonical header name (kept in sync with the backend)', () => {
|
||||||
|
expect(DEPLOY_SESSION_HEADER).toBe('x-deploy-session-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags a bare POST with the session header', () => {
|
||||||
|
const opts = withDeploySession('sess-123', { method: 'POST' });
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
expect((opts.headers as Record<string, string>)[DEPLOY_SESSION_HEADER]).toBe('sess-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves caller body and headers without clobbering them', () => {
|
||||||
|
const opts = withDeploySession('sess-abc', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ a: 1 }),
|
||||||
|
headers: { 'X-Custom': 'keep' },
|
||||||
|
});
|
||||||
|
const headers = opts.headers as Record<string, string>;
|
||||||
|
expect(opts.method).toBe('POST');
|
||||||
|
expect(opts.body).toBe(JSON.stringify({ a: 1 }));
|
||||||
|
expect(headers['X-Custom']).toBe('keep');
|
||||||
|
expect(headers[DEPLOY_SESSION_HEADER]).toBe('sess-abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ export interface ApiFetchOptions extends RequestInit {
|
|||||||
localOnly?: boolean;
|
localOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Header carrying a deploy's progress-stream correlation id. Mirrors the
|
||||||
|
* `sessionId` the frontend sends in the `connectTerminal` WebSocket message so
|
||||||
|
* the backend streams compose output to the matching socket.
|
||||||
|
* Must stay in sync with `DEPLOY_SESSION_HEADER` in `backend/src/websocket/generic.ts`. */
|
||||||
|
export const DEPLOY_SESSION_HEADER = 'x-deploy-session-id';
|
||||||
|
|
||||||
|
/** Tag a deploy/update/down POST with its progress-stream session id, preserving
|
||||||
|
* any caller-supplied options and headers. */
|
||||||
|
export function withDeploySession(
|
||||||
|
deploySessionId: string,
|
||||||
|
options: ApiFetchOptions = {},
|
||||||
|
): ApiFetchOptions {
|
||||||
|
return {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(options.headers as Record<string, string> | undefined),
|
||||||
|
[DEPLOY_SESSION_HEADER]: deploySessionId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiFetch(
|
export async function apiFetch(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
options: ApiFetchOptions = {}
|
options: ApiFetchOptions = {}
|
||||||
|
|||||||
Reference in New Issue
Block a user