mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat: detect stalled stack updates and add in-app recovery actions (#1347)
* feat: detect stalled stack updates and add in-app recovery actions Add a backend idle-output backstop that stops a deploy/update compose step that has gone silent (SENCHO_COMPOSE_STALL_TIMEOUT_MS, default 10m), so a hung image pull surfaces a fast failure instead of spinning indefinitely. Surface failed, timed-out, and stalled operations with recovery actions on the stack page: a desktop chip plus popover menu and an inline mobile card offering retry, restart, roll back (when a backup exists), refresh state, and copy diagnostics, all gated by deploy permission. The streaming deploy/update progress modal is now on by default and warns when output goes quiet. Container state is refreshed after a failed or stalled operation, and the UI never sits in an indefinite spinner. * fix: harden rollback against policy-blocked file mutation and refine recovery Address review findings on the stalled-update recovery work: - The rollback route restored backup files before running the policy gate, so a policy-blocked rollback could leave the on-disk config rolled back while the deployed containers were unchanged. Snapshot the current files first and revert them when the gate blocks; if that revert itself fails, escalate it on the persistent alert feed since the 409 is already sent. - Refresh container state after a successful manual rollback (rollback redeploys), without mis-recording a refetch failure as a rollback failure. - Suppress the stalled-output warning once live progress is unavailable. * test: mock snapshotStackFiles in the atomic-deploy rollback route tests The rollback route now snapshots stack files before restoring a backup, so its FileSystemService mock needs snapshotStackFiles. Without it the mocked call threw and the route returned 500, failing the success-path rollback assertions.
This commit is contained in:
@@ -140,3 +140,11 @@ SENCHO_MESH_PROXY_TUNNEL_IDLE_MS=0
|
||||
# you track compose files in a legitimately large repository. Default
|
||||
# 104857600 (100 MB).
|
||||
GITSOURCE_MAX_CLONE_BYTES=104857600
|
||||
|
||||
# Idle-output backstop for deploy and update compose steps (pull/recreate). If a
|
||||
# step produces no output for this long while still running, Sencho treats it as
|
||||
# stalled and stops it, so a hung image pull surfaces a clear failure (and the
|
||||
# in-app recovery actions) instead of spinning. Conservative by default because a
|
||||
# working pull can be briefly silent while a large layer extracts; raise it on
|
||||
# slow links or heavy local builds. Default 600000 (10 minutes).
|
||||
# SENCHO_COMPOSE_STALL_TIMEOUT_MS=600000
|
||||
|
||||
@@ -17,10 +17,12 @@ const {
|
||||
mockDeployStack,
|
||||
mockGetBackupInfo,
|
||||
mockRestoreStackFiles,
|
||||
mockSnapshotStackFiles,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDeployStack: vi.fn(),
|
||||
mockGetBackupInfo: vi.fn(),
|
||||
mockRestoreStackFiles: vi.fn(),
|
||||
mockSnapshotStackFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/ComposeService', async () => {
|
||||
@@ -43,6 +45,7 @@ vi.mock('../services/FileSystemService', () => ({
|
||||
hasComposeFile: vi.fn().mockResolvedValue(true),
|
||||
getBackupInfo: mockGetBackupInfo,
|
||||
restoreStackFiles: mockRestoreStackFiles,
|
||||
snapshotStackFiles: mockSnapshotStackFiles,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -83,6 +86,7 @@ beforeEach(async () => {
|
||||
mockDeployStack.mockReset();
|
||||
mockGetBackupInfo.mockReset().mockResolvedValue({ exists: true, timestamp: Date.now() });
|
||||
mockRestoreStackFiles.mockReset().mockResolvedValue(undefined);
|
||||
mockSnapshotStackFiles.mockReset().mockResolvedValue(async () => {});
|
||||
const { StackOpLockService } = await import('../services/StackOpLockService');
|
||||
StackOpLockService.resetForTests();
|
||||
});
|
||||
|
||||
@@ -110,6 +110,7 @@ vi.mock('../services/LogFormatter', () => ({
|
||||
import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService';
|
||||
|
||||
const originalComposeTimeout = process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS;
|
||||
const originalStallTimeout = process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS;
|
||||
|
||||
/** Creates an EventEmitter that mimics a child_process spawn result */
|
||||
function createMockProcess() {
|
||||
@@ -169,6 +170,11 @@ afterEach(() => {
|
||||
} else {
|
||||
process.env.SENCHO_COMPOSE_COMMAND_TIMEOUT_MS = originalComposeTimeout;
|
||||
}
|
||||
if (originalStallTimeout === undefined) {
|
||||
delete process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = originalStallTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
// ── runCommand ─────────────────────────────────────────────────────────
|
||||
@@ -689,3 +695,122 @@ describe('ComposeService - downStack', () => {
|
||||
await expect(svc.downStack('my-stack')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── stall (idle-output) backstop ───────────────────────────────────────
|
||||
|
||||
describe('ComposeService - idle-output stall backstop', () => {
|
||||
it('terminates a silent update step and rejects with STACK_STALLED_OUTPUT', async () => {
|
||||
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
// The pull emits nothing; the idle backstop should fire after 1s.
|
||||
const result = svc.updateStack('my-stack').then(() => null, (e: Error) => e);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(proc.kill).toHaveBeenCalledWith('SIGTERM');
|
||||
proc.emit('close', null);
|
||||
const error = await result;
|
||||
expect(error).not.toBeNull();
|
||||
expect(error!.message).toContain('STACK_STALLED_OUTPUT');
|
||||
});
|
||||
|
||||
it('sends a stalled marker to the WebSocket before terminating', async () => {
|
||||
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
const ws = createMockWs();
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const result = svc.updateStack('my-stack', ws).then(() => null, (e: Error) => e);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
proc.emit('close', null);
|
||||
await result;
|
||||
|
||||
const sendCalls = ws.send.mock.calls.map(c => c[0] as string);
|
||||
expect(sendCalls.some(msg => msg.includes('appears stalled and was stopped'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not stall when output keeps arriving within the idle window', async () => {
|
||||
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
// deployStack spawns a single `up`; emit output every 600ms (< 1s window)
|
||||
// so the idle timer resets and never fires.
|
||||
const promise = svc.deployStack('my-stack');
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
proc.stdout.emit('data', Buffer.from('pulling layer a...'));
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
proc.stdout.emit('data', Buffer.from('pulling layer b...'));
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(proc.kill).not.toHaveBeenCalled();
|
||||
|
||||
proc.emit('close', 0);
|
||||
await vi.advanceTimersByTimeAsync(3100); // health probe
|
||||
await promise;
|
||||
expect(proc.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not arm the idle backstop for runCommand (down/restart/stop stay silent-safe)', async () => {
|
||||
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.runCommand('my-stack', 'restart');
|
||||
// A silent restart longer than the stall window must not be killed: the
|
||||
// idle backstop is only armed for deploy/update compose steps.
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
expect(proc.kill).not.toHaveBeenCalled();
|
||||
|
||||
proc.emit('close', 0);
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('falls back to the default stall window when the env value is invalid', async () => {
|
||||
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '0'; // invalid → default (10min)
|
||||
mockListContainers.mockResolvedValue([]);
|
||||
const proc = createMockProcess();
|
||||
mockSpawn.mockReturnValue(proc);
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const promise = svc.deployStack('my-stack');
|
||||
// Far below the 10-minute default: a '0' that leaked through would fire at 0ms.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(proc.kill).not.toHaveBeenCalled();
|
||||
|
||||
proc.emit('close', 0);
|
||||
await vi.advanceTimersByTimeAsync(3100); // health probe
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('preserves STACK_STALLED_OUTPUT through the atomic rollback wrapper', async () => {
|
||||
process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS = '1000';
|
||||
const pullProc = createMockProcess();
|
||||
let call = 0;
|
||||
// First spawn is the stalling pull; later spawns (the rollback restore's
|
||||
// `up`) close cleanly, proving the restore is not idle-timeout armed.
|
||||
mockSpawn.mockImplementation(() => {
|
||||
call += 1;
|
||||
if (call === 1) return pullProc;
|
||||
const p = createMockProcess();
|
||||
Promise.resolve().then(() => p.emit('close', 0));
|
||||
return p;
|
||||
});
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
const result = svc.updateStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
|
||||
await vi.advanceTimersByTimeAsync(1000); // idle backstop fires on the silent pull
|
||||
pullProc.emit('close', null);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const error = await result;
|
||||
expect(error).not.toBeNull();
|
||||
expect(error!.message).toContain('STACK_STALLED_OUTPUT');
|
||||
expect(getComposeRollbackInfo(error)).toMatchObject({ attempted: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,6 +228,58 @@ describe('FileSystemService backup location', () => {
|
||||
await expect(service.restoreStackFiles(stackName)).rejects.toThrow(/Rollback aborted/i);
|
||||
});
|
||||
|
||||
it('snapshotStackFiles reverts a restore so a policy-blocked rollback leaves current files in place', async () => {
|
||||
const stackName = 'revert';
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
// The rollback target (older config) is captured into the backup slot.
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: old\n', 'utf-8');
|
||||
|
||||
const service = FileSystemService.getInstance();
|
||||
await service.backupStackFiles(stackName);
|
||||
|
||||
// The current in-use config differs from the backup and adds a managed file
|
||||
// (.env) the backup does not have.
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: current\n', 'utf-8');
|
||||
await fsPromises.writeFile(path.join(stackDir, '.env'), 'TOKEN=current\n', 'utf-8');
|
||||
|
||||
// Snapshot current, then restore the backup, mirroring the rollback route.
|
||||
const revert = await service.snapshotStackFiles(stackName);
|
||||
await service.restoreStackFiles(stackName);
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('name: old\n');
|
||||
await expect(fsPromises.access(path.join(stackDir, '.env'))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
|
||||
// The policy gate blocks the restored target: revert must put the current
|
||||
// files back exactly (content restored, the removed .env recreated).
|
||||
await revert();
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('name: current\n');
|
||||
expect(await fsPromises.readFile(path.join(stackDir, '.env'), 'utf-8')).toBe('TOKEN=current\n');
|
||||
});
|
||||
|
||||
it('snapshotStackFiles revert removes a managed file the snapshot did not have', async () => {
|
||||
const stackName = 'revert-orphan';
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
// Backup target has compose + .env; current has only compose.
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: target\n', 'utf-8');
|
||||
await fsPromises.writeFile(path.join(stackDir, '.env'), 'TOKEN=target\n', 'utf-8');
|
||||
|
||||
const service = FileSystemService.getInstance();
|
||||
await service.backupStackFiles(stackName);
|
||||
|
||||
await fsPromises.rm(path.join(stackDir, '.env'));
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'name: current\n', 'utf-8');
|
||||
|
||||
const revert = await service.snapshotStackFiles(stackName);
|
||||
await service.restoreStackFiles(stackName); // brings back the .env from the backup
|
||||
await expect(fsPromises.access(path.join(stackDir, '.env'))).resolves.toBeUndefined();
|
||||
|
||||
await revert();
|
||||
// The current state had no .env, so revert must remove the restored one.
|
||||
await expect(fsPromises.access(path.join(stackDir, '.env'))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe('name: current\n');
|
||||
});
|
||||
|
||||
it('restoreStackFiles leaves non-managed files untouched', async () => {
|
||||
const stackName = 'userdata';
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
|
||||
@@ -12,6 +12,7 @@ import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
import { ComposeRollbackError } from '../services/ComposeService';
|
||||
import * as policyGate from '../helpers/policyGate';
|
||||
|
||||
// ── Hoisted mocks (must come before importing the app) ──────────────────────
|
||||
|
||||
@@ -26,6 +27,9 @@ const {
|
||||
mockIsTrivyAvailable,
|
||||
mockGetImageDigest,
|
||||
mockRunScanAndPersist,
|
||||
mockGetBackupInfo,
|
||||
mockRestoreStackFiles,
|
||||
mockSnapshotStackFiles,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDeployStack: vi.fn(),
|
||||
mockRunCommand: vi.fn(),
|
||||
@@ -37,6 +41,9 @@ const {
|
||||
mockIsTrivyAvailable: vi.fn(),
|
||||
mockGetImageDigest: vi.fn(),
|
||||
mockRunScanAndPersist: vi.fn(),
|
||||
mockGetBackupInfo: vi.fn(),
|
||||
mockRestoreStackFiles: vi.fn(),
|
||||
mockSnapshotStackFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/ComposeService', async () => {
|
||||
@@ -100,6 +107,9 @@ vi.mock('../services/FileSystemService', () => ({
|
||||
getBaseDir: () => '/tmp/compose',
|
||||
readComposeFile: vi.fn().mockResolvedValue(''),
|
||||
hasComposeFile: vi.fn().mockResolvedValue(true),
|
||||
getBackupInfo: mockGetBackupInfo,
|
||||
restoreStackFiles: mockRestoreStackFiles,
|
||||
snapshotStackFiles: mockSnapshotStackFiles,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
@@ -145,6 +155,12 @@ beforeEach(() => {
|
||||
critical_count: 0,
|
||||
high_count: 0,
|
||||
});
|
||||
mockGetBackupInfo.mockReset();
|
||||
mockRestoreStackFiles.mockReset();
|
||||
mockSnapshotStackFiles.mockReset();
|
||||
mockGetBackupInfo.mockResolvedValue({ exists: true, timestamp: 1 });
|
||||
mockRestoreStackFiles.mockResolvedValue(undefined);
|
||||
mockSnapshotStackFiles.mockResolvedValue(async () => {});
|
||||
dispatchAlertSpy.mockClear();
|
||||
});
|
||||
|
||||
@@ -354,3 +370,63 @@ describe('deploy_failure notification on /update error', () => {
|
||||
expect(mockUpdateStack.mock.calls[0][2]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rollback file-revert safety on a policy-blocked rollback', () => {
|
||||
it('does not deploy and alerts the operator when the post-block file revert fails', async () => {
|
||||
// The restored backup is blocked by policy after files were already restored.
|
||||
const gateSpy = vi
|
||||
.spyOn(policyGate, 'runPolicyGate')
|
||||
.mockImplementation(async (_req, res) => {
|
||||
res.status(409).json({ error: 'Rollback blocked by policy' });
|
||||
return false;
|
||||
});
|
||||
// The revert that should undo the restore itself fails (e.g. EACCES on a
|
||||
// chowned bind mount), leaving disk inconsistent with the deployed stack.
|
||||
mockSnapshotStackFiles.mockResolvedValue(async () => {
|
||||
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
||||
});
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/myapp/rollback')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
// The rollback must not have deployed the blocked target.
|
||||
expect(mockDeployStack).not.toHaveBeenCalled();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
// The revert failure is escalated on the persistent alert feed.
|
||||
expect(dispatchAlertSpy).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'deploy_failure',
|
||||
expect.stringContaining('EACCES'),
|
||||
{ stackName: 'myapp', actor: 'testadmin' },
|
||||
);
|
||||
} finally {
|
||||
gateSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reverts cleanly and stays quiet when the policy block revert succeeds', async () => {
|
||||
const revert = vi.fn().mockResolvedValue(undefined);
|
||||
const gateSpy = vi
|
||||
.spyOn(policyGate, 'runPolicyGate')
|
||||
.mockImplementation(async (_req, res) => {
|
||||
res.status(409).json({ error: 'Rollback blocked by policy' });
|
||||
return false;
|
||||
});
|
||||
mockSnapshotStackFiles.mockResolvedValue(revert);
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/stacks/myapp/rollback')
|
||||
.set('Cookie', authCookie);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(revert).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeployStack).not.toHaveBeenCalled();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(dispatchAlertSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
gateSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1421,8 +1421,24 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) =>
|
||||
return;
|
||||
}
|
||||
dlog(`[Stacks] Rollback initiated: ${sanitizeForLog(stackName)}`);
|
||||
// Snapshot the current files before restoring so a policy gate that blocks
|
||||
// the restored target can be undone: restoreStackFiles commits to disk, and
|
||||
// without this a blocked rollback would leave disk rolled back while the
|
||||
// deployed state is unchanged.
|
||||
const revertRestore = await fsSvc.snapshotStackFiles(stackName);
|
||||
await fsSvc.restoreStackFiles(stackName);
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return;
|
||||
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) {
|
||||
try {
|
||||
await revertRestore();
|
||||
} catch (revertError) {
|
||||
console.error('[Stacks] Failed to revert files after a policy-blocked rollback: %s', sanitizeForLog(stackName), revertError);
|
||||
// The 409 is already sent and the on-disk config now diverges from the
|
||||
// running stack; surface it on the persistent alert feed so the operator
|
||||
// can repair it rather than discovering it on the next deploy.
|
||||
notifyActionFailure('rollback', stackName, revertError, req.user?.username ?? 'system');
|
||||
}
|
||||
return;
|
||||
}
|
||||
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), false);
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
dlog(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`);
|
||||
|
||||
@@ -50,6 +50,22 @@ function getComposeCommandTimeoutMs(): number {
|
||||
return DEFAULT_COMPOSE_COMMAND_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
// Idle backstop for long-running pull/recreate steps: if the child emits no
|
||||
// output for this window while still running, the step is treated as stalled
|
||||
// and terminated, so a hung `docker compose pull` surfaces a fast failure
|
||||
// instead of spinning until the much longer command timeout above. Conservative
|
||||
// by default because a working pull can be briefly silent while a large layer
|
||||
// extracts; operators on slow links or heavy local builds can raise it.
|
||||
const DEFAULT_COMPOSE_STALL_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function getComposeStallTimeoutMs(): number {
|
||||
const configured = Number(process.env.SENCHO_COMPOSE_STALL_TIMEOUT_MS);
|
||||
if (Number.isFinite(configured) && configured > 0) {
|
||||
return configured;
|
||||
}
|
||||
return DEFAULT_COMPOSE_STALL_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* ComposeService - local docker compose CLI execution.
|
||||
*
|
||||
@@ -98,7 +114,11 @@ export class ComposeService {
|
||||
cwd: string,
|
||||
ws?: WebSocket,
|
||||
throwOnError = true,
|
||||
env?: Record<string, string | undefined>
|
||||
env?: Record<string, string | undefined>,
|
||||
// When set, terminate the child if it emits no output for this long while
|
||||
// still running (idle stall backstop). Appended last so the existing
|
||||
// registry-auth call sites that pass `env` are unaffected.
|
||||
idleTimeoutMs?: number
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
@@ -116,6 +136,7 @@ export class ComposeService {
|
||||
const timeoutMs = getComposeCommandTimeoutMs();
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let forceKillTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let idleTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const sendOutput = (text: string) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
@@ -132,6 +153,10 @@ export class ComposeService {
|
||||
clearTimeout(forceKillTimeout);
|
||||
forceKillTimeout = null;
|
||||
}
|
||||
if (idleTimeout) {
|
||||
clearTimeout(idleTimeout);
|
||||
idleTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (complete: () => void) => {
|
||||
@@ -159,21 +184,39 @@ export class ComposeService {
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
// Idle stall backstop. Armed once below and reset on every output chunk;
|
||||
// if it ever fires, the step has been silent for idleTimeoutMs while still
|
||||
// running, so terminate it. Never rearmed after a termination is pending or
|
||||
// the child has exited, so it cannot re-fire during the SIGTERM grace.
|
||||
const armIdleTimeout = () => {
|
||||
if (idleTimeoutMs === undefined) return;
|
||||
if (exited || settled || pendingTerminationError) return;
|
||||
if (idleTimeout) clearTimeout(idleTimeout);
|
||||
idleTimeout = setTimeout(() => {
|
||||
const seconds = Math.round(idleTimeoutMs / 1000);
|
||||
sendOutput(`=== No output for ${seconds}s; the operation appears stalled and was stopped ===\n`);
|
||||
terminateChild(new Error(`STACK_STALLED_OUTPUT: no output for ${seconds}s`));
|
||||
}, idleTimeoutMs);
|
||||
};
|
||||
|
||||
// The progress socket is output-only: a deploy/update/down is owned by the
|
||||
// HTTP request that started it, so closing or losing the socket (the user
|
||||
// minimizes the panel, navigates away, or the connection blips) must not
|
||||
// terminate the compose process. Termination is driven solely by the
|
||||
// command timeout below.
|
||||
// command timeout here and the optional idle stall backstop above.
|
||||
timeout = setTimeout(() => {
|
||||
const message = `Command timed out after ${Math.round(timeoutMs / 1000)}s`;
|
||||
sendOutput(`${message}\n`);
|
||||
terminateChild(new Error(message));
|
||||
}, timeoutMs);
|
||||
|
||||
armIdleTimeout();
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
errorLog += text;
|
||||
sendOutput(text);
|
||||
armIdleTimeout();
|
||||
};
|
||||
|
||||
child.stdout.on('data', onData);
|
||||
@@ -328,7 +371,7 @@ export class ComposeService {
|
||||
}
|
||||
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env);
|
||||
await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
}, sendOutput);
|
||||
|
||||
// Post-Deploy Health Probe
|
||||
@@ -505,10 +548,10 @@ export class ComposeService {
|
||||
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
sendOutput('=== Pulling latest images ===\n');
|
||||
await this.execute('docker', ['compose', 'pull'], stackDir, ws, true, env);
|
||||
await this.execute('docker', ['compose', 'pull'], stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
|
||||
sendOutput('=== Recreating containers ===\n');
|
||||
await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env);
|
||||
await this.execute('docker', await this.composeArgs(stackName, ['up', '-d', '--remove-orphans']), stackDir, ws, true, env, getComposeStallTimeoutMs());
|
||||
}, sendOutput);
|
||||
|
||||
// Post-Update Health Probe
|
||||
|
||||
@@ -874,6 +874,52 @@ export class FileSystemService {
|
||||
if (debug) console.debug(`[FileSystemService:debug] Restore completed in ${Date.now() - t0}ms`, { stackName, restored: items.filter(i => i !== '.timestamp').length, removedOrphans });
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the current managed stack files (PROTECTED_STACK_FILES) in memory and
|
||||
* return a function that puts them back, faithfully (writing the captured
|
||||
* contents and removing any managed file that did not exist when captured).
|
||||
*
|
||||
* Used by the rollback route to undo a restored backup when the policy gate
|
||||
* blocks before the deploy commits: restoreStackFiles has already overwritten
|
||||
* the on-disk files, so without this a blocked rollback would leave disk holding
|
||||
* the rolled-back configuration while the deployed containers are unchanged.
|
||||
*/
|
||||
async snapshotStackFiles(stackName: string): Promise<() => Promise<void>> {
|
||||
const stackDir = this.resolveStackDir(stackName);
|
||||
// Canonical js/path-injection barrier inline with the read/write sinks, the
|
||||
// same pattern restoreStackFiles uses: resolve against the base and confirm
|
||||
// containment so static analysis credits the barrier.
|
||||
const baseResolved = path.resolve(this.baseDir);
|
||||
const snapshot = new Map<string, Buffer>();
|
||||
for (const file of PROTECTED_STACK_FILES) {
|
||||
const target = path.resolve(baseResolved, path.join(stackDir, file));
|
||||
if (!target.startsWith(baseResolved + path.sep)) {
|
||||
throw Object.assign(new Error('Path escapes compose directory'), { code: 'INVALID_PATH' });
|
||||
}
|
||||
try {
|
||||
snapshot.set(file, await fsPromises.readFile(target));
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') throw e;
|
||||
}
|
||||
}
|
||||
return async () => {
|
||||
for (const file of PROTECTED_STACK_FILES) {
|
||||
const target = path.resolve(baseResolved, path.join(stackDir, file));
|
||||
if (!target.startsWith(baseResolved + path.sep)) continue;
|
||||
const saved = snapshot.get(file);
|
||||
if (saved !== undefined) {
|
||||
await fsPromises.writeFile(target, saved);
|
||||
} else {
|
||||
try {
|
||||
await fsPromises.unlink(target);
|
||||
} catch (e: unknown) {
|
||||
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async getBackupInfo(stackName: string): Promise<{ exists: boolean; timestamp: number | null }> {
|
||||
const backupDir = this.getBackupDir(stackName);
|
||||
try {
|
||||
|
||||
@@ -3,14 +3,14 @@ title: Deploy Progress
|
||||
description: Stream live output from stack deploy, install, update, restart, and stop operations in a structured log view.
|
||||
---
|
||||
|
||||
When you trigger a stack action that runs through `docker compose` (Deploy, Update, Install from the App Store, Apply with deploy from a Git Source), an optional progress modal opens and streams the output in real time. Each line is parsed into a timestamped row with a stage badge so you can track the deployment lifecycle as it runs. The modal can be minimized to a small pill that follows you across navigation, so you can leave the App Store mid-install and still see the status from any screen.
|
||||
When you trigger a stack action that runs through `docker compose` (Deploy, Update, Install from the App Store, Apply with deploy from a Git Source), a progress modal opens and streams the output in real time. Each line is parsed into a timestamped row with a stage badge so you can track the deployment lifecycle as it runs. The modal can be minimized to a small pill that follows you across navigation, so you can leave the App Store mid-install and still see the status from any screen. If an operation goes quiet or fails, Sencho keeps the state observable: the modal warns when output has stopped, and the stack page surfaces recovery actions you can take without leaving Sencho.
|
||||
|
||||
## Enabling deploy progress
|
||||
## Showing or hiding deploy progress
|
||||
|
||||
The modal is opt-in. Open **Settings > Appearance > Display** and enable **Deploy progress modal**. The setting is **off by default**, saved to the current browser only, and synced across tabs in the same browser without a reload.
|
||||
The modal is **on by default**. To run operations without it, open **Settings > Appearance > Display** and turn off **Deploy progress modal**. The choice is saved to the current browser only and synced across tabs in the same browser without a reload.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/deploy-progress/setting-toggle.png" alt="Settings · Personal · Appearance panel with the Deploy progress modal field toggled to Enabled and the helper text 'Stream live output for deploy, restart, update, install, and Git operations.'" />
|
||||
<img src="/images/deploy-progress/setting-toggle.png" alt="Settings · Personal · Appearance panel with the Deploy progress modal field and its helper text describing live output streaming for deploy, restart, update, install, and Git operations." />
|
||||
</Frame>
|
||||
|
||||
## Using the modal
|
||||
@@ -62,9 +62,13 @@ When an action completes successfully, the status indicator switches to `Succeed
|
||||
<img src="/images/deploy-progress/modal-succeeded.png" alt="Deploy progress modal in the succeeded state with a green checkmark, the text 'Succeeded' and 'closes in 2s' in the header, and the full structured log body visible beneath" />
|
||||
</Frame>
|
||||
|
||||
### Stalled-output warning
|
||||
|
||||
A long pull or recreate can go quiet for a stretch. When an operation is still running but has produced no new output for a while, the modal shows a warning strip with the elapsed quiet time and the last line received (or a note that no output has arrived yet). The operation keeps running; the strip only makes the quiet visible so you are not left guessing whether anything is happening. If a step is genuinely hung, Sencho stops it after a longer idle window and shows a failure with recovery actions (see [Recovery actions](#recovery-actions)).
|
||||
|
||||
### On failure
|
||||
|
||||
If the action fails, the modal stays open. The status indicator switches to a destructive icon and shows the error message itself, truncated to 200 px in the header with the full text available on hover. Error rows in the body pick up the destructive left border so they are easy to find when scrolling. The modal stays open until you click **Close**.
|
||||
If the action fails, the modal stays open. The status indicator switches to a destructive icon and shows the error message itself, truncated to 200 px in the header with the full text available on hover. Error rows in the body pick up the destructive left border so they are easy to find when scrolling. The modal stays open until you click **Close**. Close it to reveal the recovery panel on the stack page.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/deploy-progress/modal-failed.png" alt="Deploy progress modal in the failed state, with the truncated error message visible in the header and several ERR rows highlighted with a destructive border in the body" />
|
||||
@@ -82,6 +86,20 @@ Minimizing, closing, or navigating away never cancels the operation. The progres
|
||||
<img src="/images/deploy-progress/pill.png" alt="Minimized deploy progress pill anchored at the bottom center of a stack editor view, showing the brand-colored pulsing dot and the text 'Updating docs-demo'" />
|
||||
</Frame>
|
||||
|
||||
## Recovery actions
|
||||
|
||||
When a deploy or update fails, times out, or its outcome is ambiguous, the stack page offers safe next steps so you can fix the stack in place. On desktop a small **Update failed** chip appears in the stack card; click it to open a menu of recovery actions. On a phone the same actions show as an inline card on the stack detail. Either way it shows the failed action, the error, and how long the operation ran, and it works whether or not the progress modal is enabled.
|
||||
|
||||
The available actions are:
|
||||
|
||||
- **Retry** the failed operation.
|
||||
- **Restart** the stack.
|
||||
- **Roll back** to the previous version, shown only when a backup exists for the stack.
|
||||
- **Refresh** to re-read the live container status after the failure.
|
||||
- **Copy details** to put the stack name, node, action, error, elapsed time, and last output line on your clipboard for a bug report.
|
||||
|
||||
Retry, Restart, and Roll back require deploy permission on the stack. After a failed or stalled operation, Sencho refreshes the container state automatically so the page reflects reality, and it never leaves the action stuck in an endless spinner.
|
||||
|
||||
## Supported entry points
|
||||
|
||||
The modal opens for the following actions:
|
||||
@@ -112,7 +130,10 @@ The HTTP API also exposes a `down` action (compose-level teardown) that streams
|
||||
<Accordion title="The structured rows are empty but raw output shows content">
|
||||
Some compose wrappers and Docker plugins emit non-standard output. The parser falls back to a **LOG** row for any line it cannot classify, but if a line is consumed entirely by ANSI control sequences it can drop out of the structured view. Toggle **Raw output** to see the full stream.
|
||||
</Accordion>
|
||||
<Accordion title="I toggled the setting but the modal does not appear">
|
||||
The setting is stored in `localStorage` under `sencho.deploy-feedback.enabled` and applies to the current tab without a reload. Other tabs in the same browser pick up the change through a `storage` event. If a tab still does not honour the setting, refresh that tab. The setting does not sync across browsers or devices; each one carries its own choice.
|
||||
<Accordion title="An update stalled or appears stuck">
|
||||
If a pull or recreate produces no output for a while, the modal shows a stalled-output warning so you know the operation has gone quiet. The operation keeps running. If a step is genuinely hung, Sencho stops it after a longer idle window (10 minutes by default) and the stack page offers recovery actions where you can retry, restart, roll back when a backup exists, refresh the container state, or copy troubleshooting details. To change how long Sencho waits before treating a silent step as stalled, set `SENCHO_COMPOSE_STALL_TIMEOUT_MS`; raise it on slow links or for heavy local image builds.
|
||||
</Accordion>
|
||||
<Accordion title="I turned the setting off but the modal still appears">
|
||||
The setting is stored in `localStorage` under `sencho.deploy-feedback.enabled` and applies to the current tab without a reload. The modal is on by default, so only an explicit off choice hides it. Other tabs in the same browser pick up the change through a `storage` event. If a tab still does not honour the setting, refresh that tab. The setting does not sync across browsers or devices; each one carries its own choice.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -29,6 +29,7 @@ When you point `COMPOSE_DIR` at a directory, Sencho expects each stack to live i
|
||||
| `SENCHO_USER` | *(unset)* | When set to a username present inside the container (`sencho` is pre-created for this purpose), the entrypoint drops privileges to that user at startup instead of running as `root`. See [Running as a non-root user](#running-as-a-non-root-user) below. |
|
||||
| `API_RATE_LIMIT` | `200` | Global API requests per minute per user session. Applies in production only; development uses a fixed higher cap. Authenticated requests are keyed by user ID, unauthenticated by IP. Internal node-to-node traffic bypasses this limit. |
|
||||
| `API_POLLING_RATE_LIMIT` | `300` | Rate limit for dashboard polling endpoints, in requests per minute. Applies in production only; development uses a fixed higher cap. Raise it for environments with many concurrent browser sessions behind shared NAT. |
|
||||
| `SENCHO_COMPOSE_STALL_TIMEOUT_MS` | `600000` | Idle-output backstop for deploy and update compose steps (pull and recreate). If a step produces no output for this long while still running, Sencho treats it as stalled and stops it, so a hung image pull surfaces a clear failure and the in-app recovery actions instead of spinning. Raise it on slow links or for heavy local image builds. |
|
||||
|
||||
## Listen port
|
||||
|
||||
|
||||
@@ -113,12 +113,12 @@ Density affects the dashboard stack table, the resource gauge strip, the Setting
|
||||
|
||||
### Deploy progress modal
|
||||
|
||||
When enabled, Sencho streams live output in a modal whenever you deploy, restart, update, install, or run a Git operation. The modal closes automatically on success or stays open on failure so you can read the error output.
|
||||
Sencho streams live output in a modal whenever you deploy, restart, update, install, or run a Git operation. The modal closes automatically on success or stays open on failure so you can read the error output, and it warns when an operation goes quiet. It is on by default; turn it off to run operations without the panel.
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| **Enabled** | A progress modal opens for every long-running operation |
|
||||
| **Disabled** (default) | Operations run silently; results surface via toast notifications only |
|
||||
| **Enabled** (default) | A progress modal opens for every long-running operation |
|
||||
| **Disabled** | Operations run without the panel; results surface via toast notifications, and a failed operation still shows recovery actions on the stack page |
|
||||
|
||||
### Diff preview before save
|
||||
|
||||
|
||||
@@ -96,8 +96,13 @@ async function syncDeployFeedbackState(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function disableDeployFeedback(page: Page): Promise<void> {
|
||||
// The panel is on by default (opt-out), so disabling means writing an
|
||||
// explicit 'false' that survives the reload, not clearing the key.
|
||||
await page.addInitScript((key: string) => {
|
||||
window.localStorage.setItem(key, 'false');
|
||||
}, DEPLOY_FEEDBACK_KEY);
|
||||
await page.evaluate((key: string) => {
|
||||
window.localStorage.removeItem(key);
|
||||
window.localStorage.setItem(key, 'false');
|
||||
window.dispatchEvent(new CustomEvent('SENCHO_SETTINGS_CHANGED'));
|
||||
}, DEPLOY_FEEDBACK_KEY);
|
||||
}
|
||||
@@ -165,7 +170,7 @@ test.describe('Deploy feedback modal', () => {
|
||||
await waitForStacksLoaded(page);
|
||||
});
|
||||
|
||||
test('no modal appears when opt-in is off', async ({ page }) => {
|
||||
test('no modal appears when the progress panel is turned off', async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await disableDeployFeedback(page);
|
||||
@@ -173,7 +178,7 @@ test.describe('Deploy feedback modal', () => {
|
||||
|
||||
await page.getByTestId('stack-deploy-button').click();
|
||||
|
||||
// Modal must not appear when opt-in is disabled
|
||||
// Modal must not appear when the panel has been explicitly disabled
|
||||
await expect(page.locator('[data-testid="deploy-feedback-modal"]')).not.toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
@@ -300,6 +305,47 @@ test.describe('Deploy feedback modal', () => {
|
||||
await expect(page.getByText(/client disconnected/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('failed operation shows a recovery panel with retry on desktop and mobile', async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
// The recovery panel lives in the stack detail and must work without the
|
||||
// streaming modal, so disable the modal and assert the panel directly.
|
||||
await disableDeployFeedback(page);
|
||||
|
||||
let opCalls = 0;
|
||||
await page.route('**/api/stacks/**/deploy*', async (route) => {
|
||||
opCalls += 1;
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'pull failed: connection reset' }),
|
||||
});
|
||||
});
|
||||
|
||||
await setupDeployStack(page, HAPPY_STACK, HAPPY_COMPOSE);
|
||||
|
||||
// Desktop: a failed operation surfaces a recovery chip and never leaves the
|
||||
// action button stuck in a spinner. Clicking the chip opens the popover with
|
||||
// the recovery actions.
|
||||
await page.getByTestId('stack-deploy-button').click();
|
||||
await expect(page.getByTestId('recovery-chip')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('Deploy failed')).toBeVisible();
|
||||
await expect(page.getByTestId('stack-deploy-button')).toBeEnabled({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId('recovery-chip').click();
|
||||
await expect(page.getByTestId('recovery-panel')).toBeVisible({ timeout: 10_000 });
|
||||
// Retry re-issues the operation through the shared handler.
|
||||
await page.getByRole('button', { name: 'Retry deploy' }).click();
|
||||
await expect.poll(() => opCalls, { timeout: 15_000 }).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Mobile shell (below the md break) renders the recovery card inline.
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.reload();
|
||||
await waitForStacksLoaded(page);
|
||||
await page.getByText(HAPPY_STACK, { exact: true }).first().click();
|
||||
await page.getByTestId('stack-deploy-button').click();
|
||||
await expect(page.getByTestId('recovery-panel')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteStackViaApi(page, HAPPY_STACK);
|
||||
await deleteStackViaApi(page, FAIL_STACK);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
X,
|
||||
Minimize2,
|
||||
Terminal as TerminalIcon,
|
||||
@@ -16,6 +17,11 @@ import { useDeployFeedback, VERB_LABELS } from '@/context/DeployFeedbackContext'
|
||||
|
||||
const AUTO_CLOSE_SECONDS = 4;
|
||||
|
||||
// Warn that an in-flight operation has gone quiet after this much silence. The
|
||||
// backend idle-output timeout terminates a truly hung step later (default
|
||||
// ~10min); this earlier heads-up keeps the modal from looking falsely busy.
|
||||
const STALL_WARN_MS = 75_000;
|
||||
|
||||
interface DeployFeedbackModalProps {
|
||||
isMinimized: boolean;
|
||||
onMinimize: () => void;
|
||||
@@ -31,7 +37,7 @@ function formatElapsed(seconds: number): string {
|
||||
}
|
||||
|
||||
export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) {
|
||||
const { panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback();
|
||||
const { panelState, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback();
|
||||
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
@@ -152,6 +158,15 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
const isDialogOpen = isOpen && !isMinimized;
|
||||
const verbLabel = VERB_LABELS[action];
|
||||
|
||||
// Re-evaluated each second by the elapsed-time interval's re-render. Warns
|
||||
// while the operation is still streaming but has produced no output for a
|
||||
// while, including the case where no first line ever arrived. Suppressed once
|
||||
// the progress stream is unavailable: no more output can arrive, so the modal
|
||||
// already shows "Live progress unavailable" and a stall warning would be noise.
|
||||
const lastLine = logRows.length > 0 ? logRows[logRows.length - 1].message : null;
|
||||
const secondsSinceOutput = lastOutputAt > 0 ? Math.floor((Date.now() - lastOutputAt) / 1000) : 0;
|
||||
const stalled = status === 'streaming' && !progressUnavailable && lastOutputAt > 0 && Date.now() - lastOutputAt > STALL_WARN_MS;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isDialogOpen}
|
||||
@@ -214,6 +229,28 @@ export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stalled-output warning: in-flight but quiet */}
|
||||
{stalled && (
|
||||
<div
|
||||
data-testid="deploy-feedback-stalled"
|
||||
className="flex items-start gap-2 px-4 py-2 border-b border-warning/30 bg-warning/5 shrink-0"
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0 text-warning" />
|
||||
<div className="min-w-0 text-xs text-warning">
|
||||
<p>
|
||||
{lastLine
|
||||
? `No new output for ${formatElapsed(secondsSinceOutput)}. The operation may be stalled.`
|
||||
: 'No output received yet. The operation may be stalled.'}
|
||||
</p>
|
||||
{lastLine && (
|
||||
<p className="mt-0.5 truncate font-mono text-[11px] text-warning/80" title={lastLine}>
|
||||
{lastLine}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
|
||||
@@ -52,7 +52,19 @@ import type { SectionId } from './settings/types';
|
||||
export default function EditorLayout() {
|
||||
const { isAdmin, can } = useAuth();
|
||||
const { status: trivy } = useTrivyStatus();
|
||||
const { runWithLog, panelState } = useDeployFeedback();
|
||||
const { runWithLog, panelState, logRows } = useDeployFeedback();
|
||||
|
||||
// The last live output line captured for a stack while its deploy-feedback
|
||||
// session is still streaming, used to enrich a failure record's diagnostics.
|
||||
// Guards on both the streaming status and an exact stack-name match, so
|
||||
// neither another stack's output nor a finished session's stale line leaks in.
|
||||
const getLastDeployOutputLine = useCallback(
|
||||
(forStack: string): string | undefined =>
|
||||
panelState.status === 'streaming' && panelState.stackName === forStack
|
||||
? logRows.at(-1)?.message
|
||||
: undefined,
|
||||
[panelState.status, panelState.stackName, logRows],
|
||||
);
|
||||
|
||||
const editorState = useEditorViewState();
|
||||
const {
|
||||
@@ -101,6 +113,9 @@ export default function EditorLayout() {
|
||||
isCollapsed, toggleCollapse,
|
||||
remoteSearchLoading,
|
||||
remoteSearchFailedNodes,
|
||||
lastActionResult,
|
||||
clearActionRecords,
|
||||
dismissActionResult,
|
||||
} = stackListState;
|
||||
|
||||
const { nodes, activeNode, setActiveNode } = useNodes();
|
||||
@@ -176,6 +191,7 @@ export default function EditorLayout() {
|
||||
setActiveNode,
|
||||
nodes,
|
||||
runWithLog,
|
||||
getLastDeployOutputLine,
|
||||
diffPreviewEnabled,
|
||||
});
|
||||
|
||||
@@ -368,6 +384,16 @@ export default function EditorLayout() {
|
||||
setGitSourceOpen={setGitSourceOpen}
|
||||
setCopiedDigest={setCopiedDigest}
|
||||
requestDeleteStack={stackActions.requestDeleteStack}
|
||||
recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined}
|
||||
onRefreshState={async () => {
|
||||
if (!selectedFile) return;
|
||||
const name = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||||
const ok = await stackActions.refreshSelectedContainers(name, selectedFile);
|
||||
await refreshStacks(true);
|
||||
if (ok) toast.success('Refreshed container state.');
|
||||
else toast.error('Could not refresh container state.');
|
||||
}}
|
||||
onDismissRecovery={() => { if (selectedFile) dismissActionResult(selectedFile); }}
|
||||
onMobileBack={goToMobileList}
|
||||
/>
|
||||
);
|
||||
@@ -429,6 +455,9 @@ export default function EditorLayout() {
|
||||
pendingStackLoadRef.current = null;
|
||||
|
||||
stackActions.resetEditorState();
|
||||
// Stack filenames can repeat across nodes; drop the previous node's failure
|
||||
// records so a stale recovery panel cannot surface on the new node.
|
||||
clearActionRecords();
|
||||
|
||||
if (pendingStack) {
|
||||
void stackActions.loadFile(pendingStack);
|
||||
|
||||
@@ -38,6 +38,8 @@ import { StackFileExplorer } from '@/components/files/StackFileExplorer';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
|
||||
import { MobileStackDetail } from './MobileStackDetail';
|
||||
import { RecoveryChip } from './RecoveryChip';
|
||||
import { retryHandlerFor } from './recovery-retry';
|
||||
import type { NotificationItem } from '../dashboard/types';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { useAuth } from '@/context/AuthContext';
|
||||
@@ -62,6 +64,32 @@ export type StackAction =
|
||||
| 'delete'
|
||||
| 'rollback';
|
||||
|
||||
/**
|
||||
* Stack operations the recovery panel can offer safe next steps for. A failed
|
||||
* stop/start/delete is not "recoverable" through retry/restart/rollback, so it
|
||||
* never produces a record; narrowing the type keeps the panel's retry routing
|
||||
* exhaustive.
|
||||
*/
|
||||
export type RecoverableAction = Extract<StackAction, 'deploy' | 'update' | 'restart' | 'rollback'>;
|
||||
|
||||
/**
|
||||
* Terminal record of a failed stack operation, kept in memory per stack so the
|
||||
* recovery panel can offer safe next steps after an update/deploy fails or
|
||||
* stalls. Cleared when the same stack's next operation succeeds or is dismissed,
|
||||
* and on active-node change (the keyed stack filename can repeat across nodes).
|
||||
*/
|
||||
export interface StackActionResult {
|
||||
action: RecoverableAction;
|
||||
rolledBack: boolean;
|
||||
errorMessage?: string;
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
// Last live output line captured only when a matching deploy-feedback
|
||||
// session was streaming this stack at failure time; omitted otherwise so a
|
||||
// line from another stack/session never leaks into diagnostics.
|
||||
lastOutputLine?: string;
|
||||
}
|
||||
|
||||
export interface ContainerStatsEntry {
|
||||
cpu: string;
|
||||
ram: string;
|
||||
@@ -143,6 +171,13 @@ export interface EditorViewProps {
|
||||
// Composed action: wraps setStackToDelete + setDeleteDialogOpen
|
||||
requestDeleteStack: () => void;
|
||||
|
||||
// Recovery surface for a failed/stalled operation on this stack (undefined
|
||||
// when the last op succeeded or none has run). onRefreshState re-syncs
|
||||
// container state; onDismissRecovery drops the record.
|
||||
recoveryResult?: StackActionResult;
|
||||
onRefreshState: () => void;
|
||||
onDismissRecovery: () => void;
|
||||
|
||||
// Mobile-only: back affordance in the detail header returns to the stack list.
|
||||
onMobileBack?: () => void;
|
||||
// Mobile-only: notifications + more-menu cluster for the detail header right
|
||||
@@ -200,6 +235,9 @@ export function EditorView(props: EditorViewProps) {
|
||||
setGitSourceOpen,
|
||||
setCopiedDigest,
|
||||
requestDeleteStack,
|
||||
recoveryResult,
|
||||
onRefreshState,
|
||||
onDismissRecovery,
|
||||
} = props;
|
||||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
@@ -262,28 +300,48 @@ export function EditorView(props: EditorViewProps) {
|
||||
{/* Command Center Card (identity + health strip) */}
|
||||
<Card className="rounded-xl border-muted bg-card shrink-0">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<StackIdentityHeader
|
||||
stackName={stackName}
|
||||
activeNode={activeNode}
|
||||
safeContainers={safeContainers}
|
||||
isRunning={isRunning}
|
||||
copiedDigest={copiedDigest}
|
||||
setCopiedDigest={setCopiedDigest}
|
||||
copiedDigestTimerRef={copiedDigestTimerRef}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
trivy={trivy}
|
||||
backupInfo={backupInfo}
|
||||
loadingAction={loadingAction}
|
||||
stackMisconfigScanning={stackMisconfigScanning}
|
||||
deployStack={deployStack}
|
||||
restartStack={restartStack}
|
||||
stopStack={stopStack}
|
||||
updateStack={updateStack}
|
||||
rollbackStack={rollbackStack}
|
||||
scanStackConfig={scanStackConfig}
|
||||
requestDeleteStack={requestDeleteStack}
|
||||
/>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<StackIdentityHeader
|
||||
stackName={stackName}
|
||||
activeNode={activeNode}
|
||||
safeContainers={safeContainers}
|
||||
isRunning={isRunning}
|
||||
copiedDigest={copiedDigest}
|
||||
setCopiedDigest={setCopiedDigest}
|
||||
copiedDigestTimerRef={copiedDigestTimerRef}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
trivy={trivy}
|
||||
backupInfo={backupInfo}
|
||||
loadingAction={loadingAction}
|
||||
stackMisconfigScanning={stackMisconfigScanning}
|
||||
deployStack={deployStack}
|
||||
restartStack={restartStack}
|
||||
stopStack={stopStack}
|
||||
updateStack={updateStack}
|
||||
rollbackStack={rollbackStack}
|
||||
scanStackConfig={scanStackConfig}
|
||||
requestDeleteStack={requestDeleteStack}
|
||||
/>
|
||||
</div>
|
||||
{recoveryResult && loadingAction == null && (
|
||||
<div className="shrink-0">
|
||||
<RecoveryChip
|
||||
stackName={stackName}
|
||||
result={recoveryResult}
|
||||
activeNode={activeNode}
|
||||
backupInfo={backupInfo}
|
||||
canDeploy={can('stack:deploy', 'stack', stackName)}
|
||||
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
|
||||
onRestart={restartStack}
|
||||
onRollback={rollbackStack}
|
||||
onRefreshState={onRefreshState}
|
||||
onDismiss={onDismissRecovery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
<ContainersHealth
|
||||
|
||||
@@ -4,6 +4,8 @@ import { cn } from '@/lib/utils';
|
||||
import ErrorBoundary from '../ErrorBoundary';
|
||||
import StackAnatomyPanel from '../StackAnatomyPanel';
|
||||
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
|
||||
import { RecoveryPanel } from './RecoveryPanel';
|
||||
import { retryHandlerFor } from './recovery-retry';
|
||||
import type { EditorViewProps } from './EditorView';
|
||||
|
||||
const SEGMENTS = [
|
||||
@@ -55,6 +57,9 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
requestDeleteStack,
|
||||
onMobileBack,
|
||||
headerActions,
|
||||
recoveryResult,
|
||||
onRefreshState,
|
||||
onDismissRecovery,
|
||||
} = props;
|
||||
|
||||
const [segment, setSegment] = useState<Segment>('logs');
|
||||
@@ -104,6 +109,23 @@ export function MobileStackDetail(props: EditorViewProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{recoveryResult && loadingAction == null && (
|
||||
<div className="shrink-0 px-4 pt-3">
|
||||
<RecoveryPanel
|
||||
stackName={stackName}
|
||||
result={recoveryResult}
|
||||
activeNode={activeNode}
|
||||
backupInfo={backupInfo}
|
||||
canDeploy={can('stack:deploy', 'stack', stackName)}
|
||||
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
|
||||
onRestart={restartStack}
|
||||
onRollback={rollbackStack}
|
||||
onRefreshState={onRefreshState}
|
||||
onDismiss={onDismissRecovery}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Segmented control: Health · Logs · Compose */}
|
||||
<div className="shrink-0 px-4 pt-3">
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { RotateCcw, RotateCw, Undo2, RefreshCw, Copy, Check } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { StackActionResult } from './EditorView';
|
||||
import { buildDiagnostics } from './recovery-format';
|
||||
|
||||
interface RecoveryActionsProps {
|
||||
stackName: string;
|
||||
result: StackActionResult;
|
||||
activeNode: Node | null;
|
||||
backupInfo: { exists: boolean; timestamp: number | null };
|
||||
canDeploy: boolean;
|
||||
onRetry: (e: React.MouseEvent) => void;
|
||||
onRestart: (e: React.MouseEvent) => void;
|
||||
onRollback: () => void;
|
||||
onRefreshState: () => void;
|
||||
// 'inline' wraps the actions in a row (mobile card); 'list' stacks them as
|
||||
// full-width menu rows (desktop chip popover).
|
||||
variant?: 'inline' | 'list';
|
||||
}
|
||||
|
||||
// The recovery action set shared by the mobile inline panel and the desktop
|
||||
// chip popover, so retry/restart/rollback/refresh/copy have one implementation.
|
||||
export function RecoveryActions({
|
||||
stackName,
|
||||
result,
|
||||
activeNode,
|
||||
backupInfo,
|
||||
canDeploy,
|
||||
onRetry,
|
||||
onRestart,
|
||||
onRollback,
|
||||
onRefreshState,
|
||||
variant = 'inline',
|
||||
}: RecoveryActionsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const verb = result.action;
|
||||
const showRestart = canDeploy && result.action !== 'restart';
|
||||
const showRollback = canDeploy && backupInfo.exists && result.action !== 'rollback';
|
||||
const list = variant === 'list';
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await copyToClipboard(buildDiagnostics(stackName, result, activeNode, backupInfo));
|
||||
setCopied(true);
|
||||
toast.success('Troubleshooting details copied.');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Could not copy troubleshooting details.');
|
||||
}
|
||||
};
|
||||
|
||||
const container = list ? 'flex flex-col' : 'flex flex-wrap items-center gap-1.5';
|
||||
const secondary = list
|
||||
? 'h-8 w-full justify-start gap-2 px-2 text-xs text-muted-foreground hover:text-foreground'
|
||||
: 'h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground';
|
||||
const primary = list
|
||||
? 'h-8 w-full justify-start gap-2 px-2 text-xs text-foreground'
|
||||
: 'h-7 gap-1.5 text-xs';
|
||||
|
||||
return (
|
||||
<div className={container}>
|
||||
{canDeploy && (
|
||||
<Button variant={list ? 'ghost' : 'outline'} size="sm" className={primary} onClick={onRetry}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Retry {verb}
|
||||
</Button>
|
||||
)}
|
||||
{showRestart && (
|
||||
<Button variant="ghost" size="sm" className={secondary} onClick={onRestart}>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{showRollback && (
|
||||
<Button variant="ghost" size="sm" className={secondary} onClick={() => onRollback()}>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
Roll back
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className={secondary} onClick={onRefreshState}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className={secondary} onClick={() => void handleCopy()}>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-success" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
Copy details
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, ChevronDown, X } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '../ui/button';
|
||||
import { RecoveryActions } from './RecoveryActions';
|
||||
import { capitalize, formatElapsed } from './recovery-format';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { StackActionResult } from './EditorView';
|
||||
|
||||
interface RecoveryChipProps {
|
||||
stackName: string;
|
||||
result: StackActionResult;
|
||||
activeNode: Node | null;
|
||||
backupInfo: { exists: boolean; timestamp: number | null };
|
||||
canDeploy: boolean;
|
||||
onRetry: (e: React.MouseEvent) => void;
|
||||
onRestart: (e: React.MouseEvent) => void;
|
||||
onRollback: () => void;
|
||||
onRefreshState: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// Desktop recovery surface: a compact status chip that opens a popover with the
|
||||
// error and recovery actions, so a failed operation stays discoverable without a
|
||||
// banner taking permanent space in the detail. The mobile detail uses the inline
|
||||
// RecoveryPanel instead.
|
||||
export function RecoveryChip({
|
||||
stackName,
|
||||
result,
|
||||
activeNode,
|
||||
backupInfo,
|
||||
canDeploy,
|
||||
onRetry,
|
||||
onRestart,
|
||||
onRollback,
|
||||
onRefreshState,
|
||||
onDismiss,
|
||||
}: RecoveryChipProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const elapsed = formatElapsed(result.endedAt - result.startedAt);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="recovery-chip"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-destructive/30 bg-destructive/5 px-2.5 py-1 text-xs text-destructive transition-colors hover:bg-destructive/10"
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" strokeWidth={1.8} />
|
||||
<span className="font-medium">{capitalize(result.action)} failed</span>
|
||||
<ChevronDown className="h-3 w-3 opacity-70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-64 overflow-hidden p-0" data-testid="recovery-panel">
|
||||
<div className="px-3 pb-2 pt-2.5">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{capitalize(result.action)} failed
|
||||
<span className="ml-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle tabular-nums">
|
||||
{elapsed}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-0.5 break-words text-xs text-muted-foreground">
|
||||
{result.errorMessage ?? 'The operation did not complete.'}
|
||||
{result.rolledBack && ' · rolled back to previous version'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-t border-glass-border p-1">
|
||||
<RecoveryActions
|
||||
variant="list"
|
||||
stackName={stackName}
|
||||
result={result}
|
||||
activeNode={activeNode}
|
||||
backupInfo={backupInfo}
|
||||
canDeploy={canDeploy}
|
||||
onRetry={onRetry}
|
||||
onRestart={onRestart}
|
||||
onRollback={onRollback}
|
||||
onRefreshState={onRefreshState}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-glass-border p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-full justify-start gap-2 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => { onDismiss(); setOpen(false); }}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { RecoveryActions } from './RecoveryActions';
|
||||
import { capitalize, formatElapsed } from './recovery-format';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { StackActionResult } from './EditorView';
|
||||
|
||||
interface RecoveryPanelProps {
|
||||
stackName: string;
|
||||
result: StackActionResult;
|
||||
activeNode: Node | null;
|
||||
backupInfo: { exists: boolean; timestamp: number | null };
|
||||
// Mirrors the existing header-action gate (can('stack:deploy', 'stack', name)).
|
||||
canDeploy: boolean;
|
||||
onRetry: (e: React.MouseEvent) => void;
|
||||
onRestart: (e: React.MouseEvent) => void;
|
||||
onRollback: () => void;
|
||||
onRefreshState: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
// Inline recovery card for the mobile stack detail, shown after an
|
||||
// update/deploy/restart/rollback fails or stalls. Styled as a quiet card with a
|
||||
// thin destructive rail (the toast accent language) so it blends with the
|
||||
// surrounding detail rather than shouting; the desktop surface uses RecoveryChip
|
||||
// instead. The full failure output stays in the deploy modal.
|
||||
export function RecoveryPanel({
|
||||
stackName,
|
||||
result,
|
||||
activeNode,
|
||||
backupInfo,
|
||||
canDeploy,
|
||||
onRetry,
|
||||
onRestart,
|
||||
onRollback,
|
||||
onRefreshState,
|
||||
onDismiss,
|
||||
}: RecoveryPanelProps) {
|
||||
const elapsed = formatElapsed(result.endedAt - result.startedAt);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="recovery-panel"
|
||||
role="region"
|
||||
aria-label={`${capitalize(result.action)} failed, recovery actions`}
|
||||
className="relative overflow-hidden rounded-xl border border-muted bg-card p-3"
|
||||
>
|
||||
<span aria-hidden className="absolute inset-y-0 left-0 w-[3px] bg-destructive/70" />
|
||||
<div className="flex items-start justify-between gap-3 pl-2">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" strokeWidth={1.8} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{capitalize(result.action)} failed
|
||||
<span className="ml-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle tabular-nums">
|
||||
{elapsed}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground" title={result.errorMessage}>
|
||||
{result.errorMessage ?? 'The operation did not complete.'}
|
||||
{result.rolledBack && ' · rolled back to previous version'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss"
|
||||
aria-label="Dismiss recovery panel"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 pl-2">
|
||||
<RecoveryActions
|
||||
stackName={stackName}
|
||||
result={result}
|
||||
activeNode={activeNode}
|
||||
backupInfo={backupInfo}
|
||||
canDeploy={canDeploy}
|
||||
onRetry={onRetry}
|
||||
onRestart={onRestart}
|
||||
onRollback={onRollback}
|
||||
onRefreshState={onRefreshState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { RecoveryChip } from '../RecoveryChip';
|
||||
import type { StackActionResult } from '../EditorView';
|
||||
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const baseResult: StackActionResult = {
|
||||
action: 'update',
|
||||
rolledBack: false,
|
||||
errorMessage: 'pull failed: connection reset',
|
||||
startedAt: 1000,
|
||||
endedAt: 4000,
|
||||
};
|
||||
|
||||
function setup(over: Partial<Parameters<typeof RecoveryChip>[0]> = {}) {
|
||||
const props = {
|
||||
stackName: 'web',
|
||||
result: baseResult,
|
||||
activeNode: null,
|
||||
backupInfo: { exists: false, timestamp: null },
|
||||
canDeploy: true,
|
||||
onRetry: vi.fn(),
|
||||
onRestart: vi.fn(),
|
||||
onRollback: vi.fn(),
|
||||
onRefreshState: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
render(<RecoveryChip {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('RecoveryChip', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders a chip with the failed action and keeps actions hidden until opened', () => {
|
||||
setup();
|
||||
expect(screen.getByTestId('recovery-chip')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Update failed/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Retry update')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals the recovery actions when the chip is clicked', () => {
|
||||
const props = setup();
|
||||
fireEvent.click(screen.getByTestId('recovery-chip'));
|
||||
expect(screen.getByText('Retry update')).toBeInTheDocument();
|
||||
expect(screen.getByText('Refresh')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('Retry update'));
|
||||
expect(props.onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dismisses from inside the popover', () => {
|
||||
const props = setup();
|
||||
fireEvent.click(screen.getByTestId('recovery-chip'));
|
||||
fireEvent.click(screen.getByText('Dismiss'));
|
||||
expect(props.onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { RecoveryPanel } from '../RecoveryPanel';
|
||||
import type { StackActionResult } from '../EditorView';
|
||||
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
|
||||
const baseResult: StackActionResult = {
|
||||
action: 'update',
|
||||
rolledBack: false,
|
||||
errorMessage: 'pull failed: connection reset',
|
||||
startedAt: 1000,
|
||||
endedAt: 4000,
|
||||
};
|
||||
|
||||
function setup(over: Partial<Parameters<typeof RecoveryPanel>[0]> = {}) {
|
||||
const props = {
|
||||
stackName: 'web',
|
||||
result: baseResult,
|
||||
activeNode: null,
|
||||
backupInfo: { exists: false, timestamp: null },
|
||||
canDeploy: true,
|
||||
onRetry: vi.fn(),
|
||||
onRestart: vi.fn(),
|
||||
onRollback: vi.fn(),
|
||||
onRefreshState: vi.fn(),
|
||||
onDismiss: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
render(<RecoveryPanel {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('RecoveryPanel', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('shows the failed action title and error message', () => {
|
||||
setup();
|
||||
expect(screen.getByText(/Update failed/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/pull failed: connection reset/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onRetry from the retry button', () => {
|
||||
const props = setup();
|
||||
fireEvent.click(screen.getByText('Retry update'));
|
||||
expect(props.onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides retry/restart/rollback without the deploy permission', () => {
|
||||
setup({ canDeploy: false, backupInfo: { exists: true, timestamp: 1 } });
|
||||
expect(screen.queryByText('Retry update')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Restart')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Roll back')).not.toBeInTheDocument();
|
||||
// Read-level actions remain available.
|
||||
expect(screen.getByText('Refresh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Copy details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers rollback only when a backup exists', () => {
|
||||
setup({ backupInfo: { exists: false, timestamp: null } });
|
||||
expect(screen.queryByText('Roll back')).not.toBeInTheDocument();
|
||||
setup({ backupInfo: { exists: true, timestamp: 123 } });
|
||||
expect(screen.getByText('Roll back')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show a redundant restart button when the failed action was a restart', () => {
|
||||
setup({ result: { ...baseResult, action: 'restart' } });
|
||||
expect(screen.getByText('Retry restart')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Restart')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies session-safe diagnostics including stack and error', () => {
|
||||
setup({ result: { ...baseResult, lastOutputLine: 'pulling app ...' } });
|
||||
fireEvent.click(screen.getByText('Copy details'));
|
||||
expect(copyToClipboard).toHaveBeenCalledTimes(1);
|
||||
const blob = vi.mocked(copyToClipboard).mock.calls[0][0];
|
||||
expect(blob).toContain('Stack: web');
|
||||
expect(blob).toContain('pull failed: connection reset');
|
||||
expect(blob).toContain('Last output: pulling app ...');
|
||||
});
|
||||
|
||||
it('wires refresh and dismiss callbacks', () => {
|
||||
const props = setup();
|
||||
fireEvent.click(screen.getByText('Refresh'));
|
||||
fireEvent.click(screen.getByLabelText('Dismiss recovery panel'));
|
||||
expect(props.onRefreshState).toHaveBeenCalledTimes(1);
|
||||
expect(props.onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useStackActions } from './useStackActions';
|
||||
import type { useEditorViewState } from './useEditorViewState';
|
||||
import type { useStackListState } from './useStackListState';
|
||||
@@ -62,6 +62,12 @@ function makeStackListState(over: Partial<StackListState> = {}): StackListState
|
||||
isStackBusy: vi.fn().mockReturnValue(false),
|
||||
refreshStacks: vi.fn(),
|
||||
setSearchQuery: vi.fn(),
|
||||
fetchImageUpdates: vi.fn(),
|
||||
lastActionResult: {},
|
||||
recordActionFailure: vi.fn(),
|
||||
recordActionSuccess: vi.fn(),
|
||||
clearActionRecords: vi.fn(),
|
||||
dismissActionResult: vi.fn(),
|
||||
};
|
||||
return { ...base, ...over } as unknown as StackListState;
|
||||
}
|
||||
@@ -85,9 +91,14 @@ function makeOverlay(over: Partial<OverlayState> = {}): OverlayState {
|
||||
const runWithLog: Parameters<typeof useStackActions>[0]['runWithLog'] = async (_p, run) =>
|
||||
run(Promise.resolve(), 'test-session');
|
||||
|
||||
function setup(over: { editorState?: Partial<EditorState>; overlay?: Partial<OverlayState> } = {}) {
|
||||
function setup(over: {
|
||||
editorState?: Partial<EditorState>;
|
||||
overlay?: Partial<OverlayState>;
|
||||
stackList?: Partial<StackListState>;
|
||||
getLastDeployOutputLine?: (stackName: string) => string | undefined;
|
||||
} = {}) {
|
||||
const editorState = makeEditorState(over.editorState);
|
||||
const stackListState = makeStackListState();
|
||||
const stackListState = makeStackListState(over.stackList);
|
||||
const navState = { setActiveView: vi.fn() } as unknown as NavState;
|
||||
const overlayState = makeOverlay(over.overlay);
|
||||
|
||||
@@ -101,6 +112,7 @@ function setup(over: { editorState?: Partial<EditorState>; overlay?: Partial<Ove
|
||||
setActiveNode: vi.fn(),
|
||||
nodes: [],
|
||||
runWithLog,
|
||||
getLastDeployOutputLine: over.getLastDeployOutputLine ?? (() => undefined),
|
||||
diffPreviewEnabled: false,
|
||||
}),
|
||||
);
|
||||
@@ -146,6 +158,7 @@ describe('useStackActions.saveFile', () => {
|
||||
setActiveNode: vi.fn(),
|
||||
nodes: [],
|
||||
runWithLog,
|
||||
getLastDeployOutputLine: () => undefined,
|
||||
diffPreviewEnabled: false,
|
||||
}),
|
||||
);
|
||||
@@ -351,3 +364,153 @@ describe('useStackActions.attemptLeaveEditor (mobile back / nav guard)', () => {
|
||||
expect(overlayState.setPendingLeaveAction).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStackActions recovery records', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
});
|
||||
|
||||
// Route every apiFetch by URL so the failure paths (which also refetch
|
||||
// /containers) get sensible responses.
|
||||
function routeApi(updateStatus: number, body = '{"error":"boom"}') {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/update') || u.includes('/deploy') || u.includes('/restart')) {
|
||||
return Promise.resolve(new Response(body, { status: updateStatus }));
|
||||
}
|
||||
if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 }));
|
||||
if (u.includes('/backup')) return Promise.resolve(new Response('{"exists":false,"timestamp":null}', { status: 200 }));
|
||||
return Promise.resolve(new Response('[]', { status: 200 }));
|
||||
});
|
||||
}
|
||||
|
||||
it('records a failure and refetches containers when an update fails', async () => {
|
||||
routeApi(500);
|
||||
const { result, stackListState, editorState } = setup();
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
|
||||
'web.yml',
|
||||
expect.objectContaining({ action: 'update', errorMessage: 'boom', rolledBack: false }),
|
||||
);
|
||||
expect(editorState.setContainers).toHaveBeenCalled();
|
||||
expect(stackListState.recordActionSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the record on a successful update', async () => {
|
||||
routeApi(200, '');
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml');
|
||||
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not record a failure for a stack-op-in-progress 409', async () => {
|
||||
const inProgress = JSON.stringify({
|
||||
code: 'stack_op_in_progress',
|
||||
inProgress: { action: 'update', startedAt: 1, user: 'someone' },
|
||||
});
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response(inProgress, { status: 409 }));
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores the deploy-feedback last line only for the matching stack', async () => {
|
||||
routeApi(500);
|
||||
const getLastDeployOutputLine = (stackName: string) =>
|
||||
stackName === 'web' ? 'pulling app ...' : undefined;
|
||||
const { result, stackListState } = setup({ getLastDeployOutputLine });
|
||||
await act(async () => { await result.current.updateStack(); });
|
||||
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
|
||||
'web.yml',
|
||||
expect.objectContaining({ lastOutputLine: 'pulling app ...' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not record a recovery panel for a failed stop (not recoverable)', async () => {
|
||||
routeApi(500);
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.stopStack(); });
|
||||
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records a deploy failure and carries the rolledBack flag', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/deploy')) {
|
||||
return Promise.resolve(new Response('{"error":"crash","rolledBack":true}', { status: 500 }));
|
||||
}
|
||||
if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 }));
|
||||
return Promise.resolve(new Response('[]', { status: 200 }));
|
||||
});
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => {
|
||||
await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent);
|
||||
});
|
||||
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
|
||||
'web.yml',
|
||||
expect.objectContaining({ action: 'deploy', rolledBack: true, errorMessage: 'crash' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('records a rollback failure', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/rollback')) return Promise.resolve(new Response('{"error":"no backup"}', { status: 500 }));
|
||||
if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 }));
|
||||
return Promise.resolve(new Response('[]', { status: 200 }));
|
||||
});
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.rollbackStack(); });
|
||||
expect(stackListState.recordActionFailure).toHaveBeenCalledWith(
|
||||
'web.yml',
|
||||
expect.objectContaining({ action: 'rollback', rolledBack: false, errorMessage: 'no backup' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not record a failure when only the post-rollback refetch fails', async () => {
|
||||
let rolledBack = false;
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/rollback')) { rolledBack = true; return Promise.resolve(new Response(null, { status: 200 })); }
|
||||
// After a successful rollback, the cosmetic content refetch throws.
|
||||
if (rolledBack && u.endsWith('/stacks/web.yml')) return Promise.reject(new Error('network blip'));
|
||||
return Promise.resolve(new Response('[]', { status: 200 }));
|
||||
});
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.rollbackStack(); });
|
||||
expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml');
|
||||
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes containers after a successful rollback (rollback redeploys)', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/rollback')) return Promise.resolve(new Response(null, { status: 200 }));
|
||||
if (u.includes('/containers')) {
|
||||
return Promise.resolve(new Response('[{"Id":"c1","Names":["/web"],"State":"running"}]', { status: 200 }));
|
||||
}
|
||||
return Promise.resolve(new Response('', { status: 200 }));
|
||||
});
|
||||
const { result, stackListState, editorState } = setup();
|
||||
await act(async () => { await result.current.rollbackStack(); });
|
||||
expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml');
|
||||
expect(editorState.setContainers).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ Id: 'c1' })]),
|
||||
);
|
||||
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not record a rollback failure when the post-rollback container refresh fails', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/rollback')) return Promise.resolve(new Response(null, { status: 200 }));
|
||||
if (u.includes('/containers')) return Promise.reject(new Error('network blip'));
|
||||
return Promise.resolve(new Response('', { status: 200 }));
|
||||
});
|
||||
const { result, stackListState } = setup();
|
||||
await act(async () => { await result.current.rollbackStack(); });
|
||||
expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml');
|
||||
expect(stackListState.recordActionFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { useViewNavigationState } from './useViewNavigationState';
|
||||
import type { OverlayState } from './useOverlayState';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { ActionVerb } from '@/context/DeployFeedbackContext';
|
||||
import type { StackAction } from '../EditorView';
|
||||
import type { StackAction, RecoverableAction } from '../EditorView';
|
||||
import type { NotificationItem } from '../../dashboard/types';
|
||||
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
|
||||
|
||||
@@ -62,6 +62,9 @@ interface UseStackActionsOptions {
|
||||
params: { stackName: string; action: ActionVerb },
|
||||
run: (deployStarted: Promise<void>, deploySessionId: string) => Promise<RunResult>,
|
||||
) => Promise<RunResult>;
|
||||
// Last live output line for a stack, but only while a deploy-feedback session
|
||||
// is streaming that exact stack; used to enrich failure diagnostics safely.
|
||||
getLastDeployOutputLine: (stackName: string) => string | undefined;
|
||||
diffPreviewEnabled: boolean;
|
||||
}
|
||||
|
||||
@@ -128,6 +131,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
setActiveNode,
|
||||
nodes,
|
||||
runWithLog,
|
||||
getLastDeployOutputLine,
|
||||
diffPreviewEnabled,
|
||||
} = options;
|
||||
|
||||
@@ -194,6 +198,55 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
editorState.setIsEditing(false);
|
||||
};
|
||||
|
||||
// Re-sync the open stack's container list. Used after both successful and
|
||||
// failed/stalled operations so the detail never shows containers that no
|
||||
// longer reflect reality. Returns true only when the live list was fetched;
|
||||
// false on a non-applicable stack, a non-ok response, or a network error, so
|
||||
// callers (e.g. the recovery panel's Refresh) can report the real outcome.
|
||||
const refreshSelectedContainers = async (stackName: string, stackFile: string): Promise<boolean> => {
|
||||
if (stackListState.selectedFile !== stackFile) return false;
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
if (!res.ok) return false;
|
||||
const conts = await res.json();
|
||||
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
||||
return true;
|
||||
} catch {
|
||||
// Non-critical when called from an action's failure path: refreshStacks(true)
|
||||
// in the caller's finally still reconciles the sidebar status.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Stack operations whose failure produces a recovery panel. A failed
|
||||
// stop/start/delete is not recoverable through retry/restart/rollback.
|
||||
const RECOVERABLE_ACTIONS: readonly StackAction[] = ['deploy', 'update', 'restart', 'rollback'];
|
||||
const isRecoverableAction = (action: StackAction): action is RecoverableAction =>
|
||||
RECOVERABLE_ACTIONS.includes(action);
|
||||
|
||||
// Store a terminal failure so the in-detail recovery panel can offer next
|
||||
// steps. Non-recoverable actions are skipped. Snapshots the last output line
|
||||
// only when the deploy-feedback panel is streaming this stack at failure time
|
||||
// (see getLastDeployOutputLine); undefined otherwise.
|
||||
const recordActionFailureFor = (
|
||||
stackFile: string,
|
||||
stackName: string,
|
||||
action: StackAction,
|
||||
startedAt: number,
|
||||
errorMessage: string | undefined,
|
||||
rolledBack: boolean,
|
||||
) => {
|
||||
if (!isRecoverableAction(action)) return;
|
||||
stackListState.recordActionFailure(stackFile, {
|
||||
action,
|
||||
errorMessage,
|
||||
rolledBack,
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
lastOutputLine: getLastDeployOutputLine(stackName),
|
||||
});
|
||||
};
|
||||
|
||||
const refreshGitSourcePending = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/git-sources');
|
||||
@@ -538,6 +591,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
deploySessionId?: string,
|
||||
): Promise<RunResult> => {
|
||||
const previousStatus = stackListState.stackStatuses[stackFile];
|
||||
const startedAt = Date.now();
|
||||
stackListState.setOptimisticStatus(stackFile, 'running');
|
||||
try {
|
||||
const path = ignorePolicy
|
||||
@@ -571,17 +625,14 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
toast.success(
|
||||
ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!',
|
||||
);
|
||||
if (stackListState.selectedFile === stackFile) {
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy:', error);
|
||||
@@ -594,6 +645,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
? `${errorMessage} - automatically rolled back to previous version.`
|
||||
: errorMessage,
|
||||
);
|
||||
recordActionFailureFor(stackFile, stackName, 'deploy', startedAt, errorMessage, deployError.rolledBack === true);
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
return { ok: false, errorMessage, rolledBack: deployError.rolledBack };
|
||||
}
|
||||
};
|
||||
@@ -660,6 +713,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
return;
|
||||
const stackFile = stackListState.selectedFile;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
const startedAt = Date.now();
|
||||
stackListState.setStackAction(stackFile, 'rollback');
|
||||
stackListState.setOptimisticStatus(stackFile, 'running');
|
||||
try {
|
||||
@@ -686,15 +740,26 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
}
|
||||
overlayState.setPolicyBlock(null);
|
||||
toast.success('Stack rolled back successfully.');
|
||||
const contentRes = await apiFetch(`/stacks/${stackFile}`);
|
||||
const text = await contentRes.text();
|
||||
editorState.setContent(text || '');
|
||||
editorState.setOriginalContent(text || '');
|
||||
const backupRes = await apiFetch(`/stacks/${stackFile}/backup`);
|
||||
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
// The rollback already succeeded; a failure of the cosmetic refetches below
|
||||
// (containers redeployed by the rollback, restored compose content, backup
|
||||
// info) must not be mis-recorded as a rollback failure.
|
||||
try {
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
const contentRes = await apiFetch(`/stacks/${stackFile}`);
|
||||
const text = await contentRes.text();
|
||||
editorState.setContent(text || '');
|
||||
editorState.setOriginalContent(text || '');
|
||||
const backupRes = await apiFetch(`/stacks/${stackFile}/backup`);
|
||||
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
||||
} catch (refetchError) {
|
||||
console.error('Post-rollback refetch failed:', refetchError);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Rollback failed';
|
||||
toast.error(msg);
|
||||
recordActionFailureFor(stackFile, stackName, 'rollback', startedAt, msg, false);
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
} finally {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
stackListState.refreshStacks(true);
|
||||
@@ -756,6 +821,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
if (stackListState.isStackBusy(stackFile)) return;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
const previousStatus = stackListState.stackStatuses[stackFile];
|
||||
const startedAt = Date.now();
|
||||
stackListState.setStackAction(stackFile, action);
|
||||
stackListState.setOptimisticStatus(stackFile, optimisticStatus);
|
||||
try {
|
||||
@@ -785,6 +851,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
}
|
||||
}
|
||||
const actionError = parseStackActionError(errText, `${action} failed`);
|
||||
recordActionFailureFor(stackFile, stackName, action, startedAt, actionError.message, actionError.rolledBack === true);
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
return {
|
||||
ok: false as const,
|
||||
errorMessage: actionError.message,
|
||||
@@ -794,14 +862,14 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
overlayState.setPolicyBlock(null);
|
||||
toast.success(successMessage);
|
||||
if (action === 'update') stackListState.fetchImageUpdates();
|
||||
if (stackListState.selectedFile === stackFile) {
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return { ok: true as const };
|
||||
} catch (err) {
|
||||
return { ok: false as const, errorMessage: (err as Error).message || `${action} failed` };
|
||||
const message = (err as Error).message || `${action} failed`;
|
||||
recordActionFailureFor(stackFile, stackName, action, startedAt, message, false);
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
return { ok: false as const, errorMessage: message };
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -949,6 +1017,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
) => {
|
||||
if (stackListState.isStackBusy(stackFile)) return;
|
||||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||||
const startedAt = Date.now();
|
||||
stackListState.setStackAction(stackFile, action);
|
||||
|
||||
if (action === 'stop') {
|
||||
@@ -978,11 +1047,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
throw parseStackActionError(errText, `${action} failed`);
|
||||
}
|
||||
toast.success(`Stack ${action}ed successfully!`);
|
||||
if (stackListState.selectedFile === stackFile) {
|
||||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||||
const conts = await containersRes.json();
|
||||
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
||||
}
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
if (action === 'update') stackListState.fetchImageUpdates();
|
||||
if (action === 'deploy') {
|
||||
try {
|
||||
@@ -992,6 +1057,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action}:`, error);
|
||||
const actionError = error as StackActionError;
|
||||
@@ -1001,6 +1067,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
? `${msg} - automatically rolled back to previous version.`
|
||||
: msg,
|
||||
);
|
||||
recordActionFailureFor(stackFile, stackName, action, startedAt, msg, actionError.rolledBack === true);
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
} finally {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
stackListState.refreshStacks(true);
|
||||
@@ -1065,6 +1133,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
getStackMenuVisibility,
|
||||
openStackApp,
|
||||
resetEditorState,
|
||||
refreshSelectedContainers,
|
||||
refreshGitSourcePending,
|
||||
loadFile,
|
||||
loadFileOnNode,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackAction
|
||||
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
|
||||
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
|
||||
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
|
||||
import type { StackAction, ContainerInfo } from '../EditorView';
|
||||
import type { StackAction, StackActionResult, ContainerInfo } from '../EditorView';
|
||||
import type { Label as StackLabel } from '../../label-types';
|
||||
import type { FilterChip } from '../../sidebar/sidebar-types';
|
||||
import type { StackRowStatus } from '../../sidebar/stack-status-utils';
|
||||
@@ -38,6 +38,12 @@ export function useStackListState() {
|
||||
const [stackActions, setStackActions] = useState<Record<string, StackAction>>({});
|
||||
const stackActionsRef = useRef<Record<string, StackAction>>({});
|
||||
|
||||
// Per-stack terminal failure records driving the in-detail recovery panel.
|
||||
// In-memory only. Node scoping is enforced by the caller, which clears these
|
||||
// on active-node change (see EditorLayout's node-switch effect) so a repeated
|
||||
// stack filename cannot carry a failure across nodes.
|
||||
const [lastActionResult, setLastActionResult] = useState<Record<string, StackActionResult>>({});
|
||||
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
||||
@@ -85,6 +91,27 @@ export function useStackListState() {
|
||||
setStackStatuses(prev => ({ ...prev, [stackFile]: status }));
|
||||
};
|
||||
|
||||
// Recovery record lifecycle. recordActionFailure stores a terminal failure;
|
||||
// recordActionSuccess / dismissActionResult drop it; clearActionRecords wipes
|
||||
// all (node switch). The recovery panel itself renders only when the stack is
|
||||
// not mid-operation, so a stale record never shows during a retry.
|
||||
const clearStackResult = useCallback((stackFile: string) => {
|
||||
setLastActionResult(prev => {
|
||||
if (!(stackFile in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[stackFile];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
const recordActionFailure = useCallback((stackFile: string, result: StackActionResult) => {
|
||||
setLastActionResult(prev => ({ ...prev, [stackFile]: result }));
|
||||
}, []);
|
||||
const recordActionSuccess = clearStackResult;
|
||||
const dismissActionResult = clearStackResult;
|
||||
const clearActionRecords = useCallback(() => {
|
||||
setLastActionResult({});
|
||||
}, []);
|
||||
|
||||
const refreshLabels = useCallback(async () => {
|
||||
try {
|
||||
const [labelsRes, assignmentsRes] = await Promise.all([
|
||||
@@ -333,6 +360,8 @@ export function useStackListState() {
|
||||
remoteResults,
|
||||
setStackAction, clearStackAction, isStackBusy,
|
||||
setOptimisticStatus,
|
||||
lastActionResult,
|
||||
recordActionFailure, recordActionSuccess, clearActionRecords, dismissActionResult,
|
||||
refreshLabels,
|
||||
refreshStacks,
|
||||
handleScanStacks,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import type { StackActionResult } from './EditorView';
|
||||
|
||||
export function capitalize(text: string): string {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
export function formatElapsed(ms: number): string {
|
||||
const seconds = Math.max(0, Math.round(ms / 1000));
|
||||
if (seconds >= 60) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
// Plain-text troubleshooting blob for the recovery panel's "Copy details". The
|
||||
// last output line is included only when the record carries one (session-safe).
|
||||
export function buildDiagnostics(
|
||||
stackName: string,
|
||||
result: StackActionResult,
|
||||
activeNode: Node | null,
|
||||
backupInfo: { exists: boolean; timestamp: number | null },
|
||||
): string {
|
||||
const lines = [
|
||||
`Stack: ${stackName}`,
|
||||
`Node: ${activeNode?.name ?? 'local'}${activeNode?.id != null ? ` (id ${activeNode.id})` : ''}`,
|
||||
`Action: ${result.action}`,
|
||||
`Outcome: failed${result.rolledBack ? ' (rolled back to previous version)' : ''}`,
|
||||
`Elapsed: ${formatElapsed(result.endedAt - result.startedAt)}`,
|
||||
`Error: ${result.errorMessage ?? 'unknown'}`,
|
||||
`Backup: ${backupInfo.exists
|
||||
? `available${backupInfo.timestamp ? ` (${new Date(backupInfo.timestamp).toISOString()})` : ''}`
|
||||
: 'none'}`,
|
||||
];
|
||||
if (result.lastOutputLine) lines.push(`Last output: ${result.lastOutputLine}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { RecoverableAction } from './EditorView';
|
||||
|
||||
// The stack action handlers the recovery panel routes "Retry" to. Shared by
|
||||
// both detail surfaces (desktop EditorView and MobileStackDetail) so the
|
||||
// action-to-handler mapping lives in one place.
|
||||
export interface RetryHandlers {
|
||||
deployStack: (e: React.MouseEvent) => void;
|
||||
restartStack: (e: React.MouseEvent) => void;
|
||||
updateStack: (e?: React.MouseEvent) => void;
|
||||
rollbackStack: () => void;
|
||||
}
|
||||
|
||||
// Resolve the retry handler for a recoverable action. Rollback takes no event,
|
||||
// so it is wrapped to match the panel's onRetry signature.
|
||||
export function retryHandlerFor(
|
||||
action: RecoverableAction,
|
||||
handlers: RetryHandlers,
|
||||
): (e: React.MouseEvent) => void {
|
||||
switch (action) {
|
||||
case 'deploy':
|
||||
return handlers.deployStack;
|
||||
case 'restart':
|
||||
return handlers.restartStack;
|
||||
case 'rollback':
|
||||
return () => handlers.rollbackStack();
|
||||
case 'update':
|
||||
return handlers.updateStack;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { DeployFeedbackProvider, useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||
import { DeployFeedbackModal } from '../DeployFeedbackModal';
|
||||
|
||||
// Lets a test simulate a mid-stream drop (onReady then onError) so the panel
|
||||
// reaches 'streaming' with progressUnavailable set.
|
||||
const ctl = vi.hoisted(() => ({ drop: false }));
|
||||
|
||||
// The real Terminal mounts xterm + a WebSocket; mock it to a no-op that signals
|
||||
// the stream connected on mount so the panel reaches the 'streaming' state.
|
||||
vi.mock('@/components/Terminal', () => {
|
||||
const MockTerminal = ({ onReady, onError }: { onReady?: () => void; onError?: () => void }) => {
|
||||
React.useEffect(() => {
|
||||
onReady?.();
|
||||
if (ctl.drop) onError?.();
|
||||
}, [onReady, onError]);
|
||||
return null;
|
||||
};
|
||||
return { default: MockTerminal };
|
||||
});
|
||||
|
||||
// Resolver for the in-flight operation, assigned inside the run callback (async,
|
||||
// after render) so the test can leave it pending or settle it on demand.
|
||||
let resolveRun: ((r: { ok: boolean; errorMessage?: string }) => void) | null = null;
|
||||
|
||||
function Driver() {
|
||||
const { runWithLog } = useDeployFeedback();
|
||||
React.useEffect(() => {
|
||||
void runWithLog({ stackName: 'web', action: 'update' }, async (started) => {
|
||||
await started;
|
||||
return new Promise<{ ok: boolean; errorMessage?: string }>((res) => { resolveRun = res; });
|
||||
});
|
||||
}, [runWithLog]);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderStreaming() {
|
||||
await act(async () => {
|
||||
render(
|
||||
<DeployFeedbackProvider>
|
||||
<Driver />
|
||||
<DeployFeedbackModal isMinimized={false} onMinimize={() => {}} />
|
||||
</DeployFeedbackProvider>,
|
||||
);
|
||||
// The mocked Terminal calls onReady on mount; flush the 50ms handshake.
|
||||
await vi.advanceTimersByTimeAsync(60);
|
||||
});
|
||||
}
|
||||
|
||||
describe('DeployFeedbackModal stalled-output warning', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
resolveRun = null;
|
||||
ctl.drop = false;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('warns after the stall threshold when streaming produces no output', async () => {
|
||||
await renderStreaming();
|
||||
|
||||
// Well under the threshold: no warning yet.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000); });
|
||||
expect(screen.queryByTestId('deploy-feedback-stalled')).toBeNull();
|
||||
|
||||
// Past the threshold with zero output: the warning appears.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(80_000); });
|
||||
expect(screen.getByTestId('deploy-feedback-stalled')).toBeInTheDocument();
|
||||
expect(screen.getByText(/No output received yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the stall warning once the operation fails', async () => {
|
||||
await renderStreaming();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(80_000); });
|
||||
expect(screen.getByTestId('deploy-feedback-stalled')).toBeInTheDocument();
|
||||
|
||||
// The operation finishes as a failure; status leaves 'streaming' so the
|
||||
// stall warning must clear rather than sit next to the failed state.
|
||||
await act(async () => {
|
||||
resolveRun?.({ ok: false, errorMessage: 'boom' });
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.queryByTestId('deploy-feedback-stalled')).toBeNull();
|
||||
});
|
||||
|
||||
it('suppresses the stall warning when the progress stream is unavailable', async () => {
|
||||
ctl.drop = true; // the stream connects then immediately drops mid-operation
|
||||
await renderStreaming();
|
||||
// Past the threshold, but with the stream gone the warning would be noise.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(80_000); });
|
||||
expect(screen.queryByTestId('deploy-feedback-stalled')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -188,7 +188,7 @@ export function AppearanceSection() {
|
||||
|
||||
<SettingsField
|
||||
label="Deploy progress modal"
|
||||
helper="Stream live output for deploy, restart, update, install, and Git operations."
|
||||
helper="Stream live output for deploy, restart, update, install, and Git operations, with a warning when an operation goes quiet. On by default; turn it off to run operations without the panel."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
|
||||
@@ -53,6 +53,14 @@ interface DeployFeedbackContextValue {
|
||||
) => Promise<RunResult>;
|
||||
panelState: DeployPanelState;
|
||||
logRows: ParsedLogRow[];
|
||||
/**
|
||||
* Epoch ms of the most recent activity for the current session: stamped when
|
||||
* the deploy starts, again when the stream connects, and on every output
|
||||
* chunk. The modal compares it against now to warn that an in-flight
|
||||
* operation has gone quiet (a possible stall), covering the no-first-line
|
||||
* case because it is seeded at start rather than at first output.
|
||||
*/
|
||||
lastOutputAt: number;
|
||||
onTerminalReady: () => void;
|
||||
onTerminalError: () => void;
|
||||
onMessage: (text: string) => void;
|
||||
@@ -92,6 +100,7 @@ const DeployFeedbackContext = createContext<DeployFeedbackContextValue | undefin
|
||||
export function DeployFeedbackProvider({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
const [panelState, setPanelState] = useState<DeployPanelState>(DEFAULT_PANEL_STATE);
|
||||
const [logRows, setLogRows] = useState<ParsedLogRow[]>([]);
|
||||
const [lastOutputAt, setLastOutputAt] = useState<number>(0);
|
||||
|
||||
// Idempotent resolver for the current session's deployStarted gate. Set at the
|
||||
// start of each runWithLog call; called by onTerminalReady (stream connected),
|
||||
@@ -116,6 +125,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
|
||||
const onTerminalReady = useCallback(() => {
|
||||
streamReadyRef.current = true;
|
||||
setLastOutputAt(Date.now());
|
||||
setPanelState((prev) => (prev.status === 'preparing' ? { ...prev, status: 'streaming' } : prev));
|
||||
// Give the connectTerminal handshake a beat to register on the backend
|
||||
// before the deploy POST fires, so the first lines are not missed.
|
||||
@@ -134,6 +144,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
const onMessage = useCallback((text: string) => {
|
||||
const newRows = parseLogChunk(text, idCounterRef.current);
|
||||
idCounterRef.current += newRows.length;
|
||||
setLastOutputAt(Date.now());
|
||||
setLogRows((prev) => {
|
||||
const combined = prev.length > 0 && prev[0].id === TRUNCATION_ROW_ID
|
||||
? [...prev.slice(1), ...newRows]
|
||||
@@ -182,6 +193,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
|
||||
// idCounterRef is intentionally not reset; keys must remain globally unique across sessions.
|
||||
setLogRows([]);
|
||||
setLastOutputAt(Date.now());
|
||||
|
||||
setPanelState({
|
||||
isOpen: true,
|
||||
@@ -236,7 +248,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
|
||||
return (
|
||||
<DeployFeedbackContext.Provider
|
||||
value={{ runWithLog, panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
|
||||
value={{ runWithLog, panelState, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
|
||||
>
|
||||
{children}
|
||||
</DeployFeedbackContext.Provider>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDeployFeedbackEnabled, DEPLOY_FEEDBACK_KEY } from '../use-deploy-feedback-enabled';
|
||||
|
||||
describe('useDeployFeedbackEnabled (opt-out default)', () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('defaults to enabled when no value is stored', () => {
|
||||
const { result } = renderHook(() => useDeployFeedbackEnabled());
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it('is disabled only when explicitly set to false', () => {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
|
||||
const { result } = renderHook(() => useDeployFeedbackEnabled());
|
||||
expect(result.current[0]).toBe(false);
|
||||
});
|
||||
|
||||
it('treats any non-false value as enabled', () => {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'true');
|
||||
const { result } = renderHook(() => useDeployFeedbackEnabled());
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it('re-enables when a storage event clears the key (newValue null)', () => {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
|
||||
const { result } = renderHook(() => useDeployFeedbackEnabled());
|
||||
expect(result.current[0]).toBe(false);
|
||||
act(() => {
|
||||
window.dispatchEvent(new StorageEvent('storage', { key: DEPLOY_FEEDBACK_KEY, newValue: null }));
|
||||
});
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it('setEnabled(false) persists false and disables', () => {
|
||||
const { result } = renderHook(() => useDeployFeedbackEnabled());
|
||||
act(() => result.current[1](false));
|
||||
expect(result.current[0]).toBe(false);
|
||||
expect(localStorage.getItem(DEPLOY_FEEDBACK_KEY)).toBe('false');
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,15 @@ import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
|
||||
|
||||
export const DEPLOY_FEEDBACK_KEY = 'sencho.deploy-feedback.enabled';
|
||||
|
||||
// Default on (opt-out): the live deploy/update output panel shows unless the
|
||||
// user has explicitly turned it off, so update progress and the stalled-output
|
||||
// warning are visible without a setting hunt. Only a stored 'false' disables it.
|
||||
function readStored(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (typeof window === 'undefined') return true;
|
||||
try {
|
||||
return window.localStorage.getItem(DEPLOY_FEEDBACK_KEY) === 'true';
|
||||
return window.localStorage.getItem(DEPLOY_FEEDBACK_KEY) !== 'false';
|
||||
} catch {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +29,9 @@ export function useDeployFeedbackEnabled(): [boolean, (next: boolean) => void] {
|
||||
useEffect(() => {
|
||||
function onStorage(event: StorageEvent) {
|
||||
if (event.key !== DEPLOY_FEEDBACK_KEY) return;
|
||||
setEnabledState(event.newValue === 'true');
|
||||
// Opt-out: anything other than an explicit 'false' (including a
|
||||
// cleared key) means enabled.
|
||||
setEnabledState(event.newValue !== 'false');
|
||||
}
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
|
||||
Reference in New Issue
Block a user