feat(fleet): reapply Compose configuration without a version update (#1716)

* feat(fleet): reapply Compose configuration without a version update

Add a distinct Fleet Reapply configuration path so Compose-managed nodes can recreate Sencho from the current on-disk project when already up to date, without pulling or rewriting the image reference.

* fix(fleet): confirm remote reapply and close concurrent tracker race

Require confirmation for remote compose reapply, and lock dispatch before the remote POST so a second request cannot overwrite a successful in-flight tracker.

* fix(ui): icon-only Reapply control so Up to date badge can breathe

Collapse the Node updates Reapply label into a tooltip so the status pill no longer wraps in the Status column.

* feat(editor): Save & Reapply self-stack via fleet compose reapply (#1726)

* feat(editor): Save & Reapply self-stack via fleet compose reapply

Eligible admins can apply on-disk Compose edits to Sencho's own stack from the editor using the same confirm, dispatch, and reconnect path as Fleet Node Updates.

* fix(editor): gate Save & Reapply label to self-stack only

Ordinary stacks were labeled Save & Reapply whenever the node was
reapply-eligible. Require the selected file to be the self-stack for the
toolbar label and diff confirm CTA.

* fix(ui): move compose diff action label helper out of dialog module

Keep ComposeDiffPreviewDialog component-only so react-refresh Fast Refresh
lint passes after the Save and reapply stacked merge.
This commit is contained in:
Anso
2026-07-28 14:26:46 -04:00
committed by GitHub
parent 543e4ef256
commit b0b423b234
42 changed files with 1770 additions and 168 deletions
@@ -73,6 +73,7 @@ function setTracker(over: Partial<import('../services/FleetUpdateTrackerService'
previousVersion: '0.83.0',
previousProcessStart: 1,
wasOffline: false,
operationKind: 'update',
...over,
});
}
@@ -370,6 +371,7 @@ describe('forced-recheck throttle', () => {
FleetUpdateTrackerService.getInstance().set(proxyNodeId, {
status: 'failed', startedAt: Date.now(), previousVersion: null,
previousProcessStart: null, wasOffline: false, resolvedAt: Date.now(), error: 'boom',
operationKind: 'update',
});
const second = await request(app)
@@ -413,3 +415,170 @@ describe('GET /api/fleet/update-status/release-notes', () => {
expect(res.body.htmlUrl).toBeNull();
});
});
describe('compose reapply status and concurrency', () => {
it('exposes canReapplyCompose for local when SelfUpdateService is available', async () => {
vi.spyOn(SelfUpdateService.getInstance(), 'isAvailable').mockReturnValue(true);
mockCompareTargetFetch();
const res = await request(app).get('/api/fleet/update-status').set('Authorization', adminAuth);
expect(res.status).toBe(200);
const local = res.body.nodes.find((n: { type: string }) => n.type === 'local');
expect(local.canReapplyCompose).toBe(true);
});
it('sets canReapplyCompose false for a remote without self-update capability', async () => {
mockMeta(ONLINE({ capabilities: ['stacks'] }));
mockCompareTargetFetch();
const res = await request(app).get('/api/fleet/update-status').set('Authorization', adminAuth);
expect(res.status).toBe(200);
const remote = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === proxyNodeId);
expect(remote.canReapplyCompose).toBe(false);
});
it('resolves a reapply tracker via startedAt change without requiring a version bump', async () => {
setTracker({
operationKind: 'reapply_configuration',
previousVersion: '0.83.0',
previousProcessStart: 1,
startedAt: Date.now() - RECENT_MS,
});
mockMeta(ONLINE({ version: '0.83.0', startedAt: 2 }));
mockCompareTargetFetch();
expect(await getStatus()).toBe('completed');
});
it('does not complete a reapply tracker via signal 4 when version is already current', async () => {
setTracker({
operationKind: 'reapply_configuration',
previousVersion: '0.99.0',
previousProcessStart: 1,
startedAt: Date.now() - 20_000,
});
// Node already at compare target; signal 4 would false-complete an update,
// but must not for reapply while startedAt is unchanged.
mockMeta(ONLINE({ version: '0.99.0', startedAt: 1 }));
mockCompareTargetFetch();
expect(await getStatus()).toBe('updating');
});
it('returns 409 when reapply is requested while an update tracker is in flight', async () => {
setTracker({ operationKind: 'update' });
mockTarget();
const res = await request(app)
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
.set('Authorization', adminAuth);
expect(res.status).toBe(409);
expect(res.body?.error).toMatch(/already in progress/i);
});
it('dispatches remote reapply to /api/system/reapply-compose without updateBlocked gating', async () => {
mockTarget();
mockMeta(ONLINE({ updateBlocked: true, imagePinKind: 'digest', imageChannel: 'community' }));
let reapplyUrl: string | null = null;
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input);
try {
if (new URL(url).hostname === 'api.github.com') {
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
}
} catch { /* fall through */ }
reapplyUrl = url;
return new Response(JSON.stringify({ message: 'ok' }), { status: 202 });
});
const res = await request(app)
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
.set('Authorization', adminAuth);
expect(res.status).toBe(202);
expect(reapplyUrl).toContain('/api/system/reapply-compose');
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.operationKind)
.toBe('reapply_configuration');
});
it('reserves the tracker before remote dispatch so a concurrent reapply gets 409 without overwriting success', async () => {
mockTarget();
// Hold meta so the first request sits in the dispatch set before the
// pollable tracker exists; the second must still 409 on that lock.
let releaseMeta!: (value: RemoteMeta) => void;
const metaHeld = new Promise<RemoteMeta>((resolve) => { releaseMeta = resolve; });
vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockImplementation(async () => metaHeld);
let releaseRemote!: (value: Response) => void;
const remoteHeld = new Promise<Response>((resolve) => { releaseRemote = resolve; });
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input);
try {
if (new URL(url).hostname === 'api.github.com') {
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
}
} catch { /* fall through */ }
if (url.includes('/api/system/reapply-compose')) {
return remoteHeld;
}
return new Response('{}', { status: 200 });
});
// Supertest is lazy until the thenable is consumed; start the request now.
const firstPromise = request(app)
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
.set('Authorization', adminAuth)
.then((res) => res);
await vi.waitFor(() => {
expect(NodeRegistry.getInstance().fetchMetaForNode).toHaveBeenCalled();
});
const secondDuringMeta = await request(app)
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
.set('Authorization', adminAuth);
expect(secondDuringMeta.status).toBe(409);
expect(secondDuringMeta.body?.error).toMatch(/already in progress/i);
releaseMeta(ONLINE());
await vi.waitFor(() => {
expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.status).toBe('updating');
});
const secondDuringPost = await request(app)
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
.set('Authorization', adminAuth);
expect(secondDuringPost.status).toBe(409);
releaseRemote(new Response(JSON.stringify({ message: 'ok' }), { status: 202 }));
const first = await firstPromise;
expect(first.status).toBe(202);
const tracker = FleetUpdateTrackerService.getInstance().get(proxyNodeId);
expect(tracker?.status).toBe('updating');
expect(tracker?.operationKind).toBe('reapply_configuration');
expect(tracker?.previousVersion).toBe('0.83.0');
expect(tracker?.previousProcessStart).toBe(1);
expect(tracker?.error).toBeUndefined();
});
it('marks a reserved remote reapply as failed when the peer rejects, without leaving a false updating row for a second request', async () => {
mockTarget();
mockMeta(ONLINE());
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const url = String(input);
try {
if (new URL(url).hostname === 'api.github.com') {
return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 });
}
} catch { /* fall through */ }
return new Response(JSON.stringify({
error: 'An image operation is already in progress.',
code: 'IMAGE_OPERATION_IN_FLIGHT',
}), { status: 409 });
});
const res = await request(app)
.post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`)
.set('Authorization', adminAuth);
expect(res.status).toBe(502);
const tracker = FleetUpdateTrackerService.getInstance().get(proxyNodeId);
expect(tracker?.status).toBe('failed');
expect(tracker?.error).toMatch(/already in progress/i);
});
});
@@ -14,6 +14,7 @@ function mk(over: Partial<UpdateTracker>): UpdateTracker {
previousVersion: null,
previousProcessStart: null,
wasOffline: false,
operationKind: 'update',
...over,
};
}
@@ -270,4 +270,46 @@ describe('ImageOperationService', () => {
expect(changed).not.toBe(baseline);
});
it('executeClaimedComposeReapply transitions to recreating and watches helper exit before trigger', async () => {
const callOrder: string[] = [];
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation(() => {
callOrder.push('watch');
});
vi.spyOn(SelfUpdateService.getInstance(), 'triggerComposeReapply').mockImplementation(async () => {
callOrder.push('trigger');
});
vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null);
const service = ImageOperationService.getInstance();
const claim = await service.claimComposeReapply();
expect(claim).toEqual({ ok: true });
const result = await service.executeClaimedComposeReapply();
const current = await service.getCurrentOperation();
expect(result).toEqual({ ok: true });
expect(current?.kind).toBe('compose_reapply');
expect(current?.state).toBe('recreating');
expect(callOrder).toEqual(['watch', 'trigger']);
});
it('reconcileOnStartup resolves compose_reapply via marker-only success without pin match', async () => {
const service = ImageOperationService.getInstance();
vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null);
vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho');
const claim = await service.claimComposeReapply();
expect(claim).toEqual({ ok: true });
const current = await service.getCurrentOperation();
expect(current?.kind).toBe('compose_reapply');
expect(current?.targetImageRef).toBeNull();
const markerPath = path.join(tmpDir, `image-op-success-${current!.operationId}.json`);
await fs.writeFile(markerPath, JSON.stringify({ ok: true, operationId: current!.operationId }), 'utf8');
await service.reconcileOnStartup();
const resolved = await service.getCurrentOperation();
expect(resolved?.state).toBe('succeeded');
});
});
@@ -21,6 +21,7 @@ import {
} from '../helpers/selfUpdateCompose';
import {
buildComposeReadArgs,
buildComposeConfigValidateArgs,
buildSelfUpdateComposeCmd,
buildSelfUpdateRunArgs,
shQuote,
@@ -302,3 +303,22 @@ describe('buildSelfUpdateRunArgs (repinWritable branch)', () => {
expect(args).not.toContain('/opt/sencho:/opt/sencho:rw');
});
});
describe('buildComposeConfigValidateArgs', () => {
it('runs compose config in a throwaway helper with the working dir mounted read-only', () => {
const args = buildComposeConfigValidateArgs({
workingDir: '/opt/sencho',
imageName: 'saelix/sencho:1.0.0',
configFiles: 'docker-compose.yml,/opt/sencho/override.yml',
hostBindMounts: [{ source: '/etc/sencho', destination: '/etc/sencho' }],
});
expect(args).toContain('/opt/sencho:/opt/sencho:ro');
expect(args).toContain('/var/run/docker.sock:/var/run/docker.sock');
expect(args).toContain('/etc/sencho:/etc/sencho:ro');
const cmd = args[args.length - 1];
expect(cmd).toContain('docker compose');
expect(cmd).toContain('config');
expect(cmd).toContain(shQuote('docker-compose.yml'));
expect(cmd).toContain(shQuote('/opt/sencho/override.yml'));
});
});
+200 -25
View File
@@ -62,6 +62,8 @@ import { PROXY_TIER_HEADER, deployProvenanceHeaders } from '../services/license-
import { LicenseService } from '../services/LicenseService';
const updateTracker = FleetUpdateTrackerService.getInstance();
/** Sync lock for remote reapply while meta is fetched (before the pollable tracker exists). */
const remoteReapplyDispatching = new Set<number>();
const EARLY_FAIL_MS = 180 * 1000; // 3 minutes before declaring a probable pull failure
// Shown in the Node Updates UI when a node's image is pinned in a way Fleet
// cannot repin (digest or an unresolved value). Node-neutral so it reads the
@@ -1114,6 +1116,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
let remoteImagePinKind: ImagePinKind | null = null;
let remoteUpdateBlocked = false;
let remoteImageChannel: 'community' | 'hardened' | 'unknown' | null = null;
let remoteCapabilities: string[] = [];
if (node.type === 'local') {
version = gatewayVersion;
} else {
@@ -1125,8 +1128,18 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
remoteImagePinKind = meta.imagePinKind;
remoteUpdateBlocked = meta.updateBlocked;
remoteImageChannel = meta.imageChannel;
remoteCapabilities = meta.capabilities ?? [];
}
const isReapply = tracker?.operationKind === 'reapply_configuration';
const earlyFailMsg = isReapply
? (node.type === 'local'
? 'Local reapply did not complete. The container may not have restarted; check Docker logs on the host.'
: 'Reapply may have failed. The node is still running and its process start time has not changed.')
: (node.type === 'local'
? 'Local update did not complete. The container may not have restarted; check Docker logs on the host.'
: 'Update may have failed. The node is still running and its version has not changed.');
if (tracker?.status === 'updating') {
const elapsed = Date.now() - tracker.startedAt;
@@ -1136,7 +1149,9 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
if (elapsed > UPDATE_TIMEOUT_MS) {
if (debug) console.debug('[Fleet:debug] Node', node.id, 'timed out after', Math.round(elapsed / 1000) + 's');
resolveTerminal(node, tracker, 'timeout', UPDATE_TIMEOUT_MSG);
resolveTerminal(node, tracker, 'timeout', isReapply
? 'Node did not come back online within 5 minutes after reapply.'
: UPDATE_TIMEOUT_MSG);
} else if (node.type === 'remote') {
if (remoteUpdateError) {
if (debug) console.debug('[Fleet:debug] Node', node.id, 'reported pull failure:', remoteUpdateError);
@@ -1146,10 +1161,9 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
if (debug) console.debug('[Fleet:debug] Node', node.id, 'went offline (restarting)');
updateTracker.set(node.id, { ...tracker, wasOffline: true });
}
} else if (isValidVersion(version) && version !== tracker.previousVersion) {
// Signal 1: a valid, different version. A null/unparseable version
// from a transient /api/meta blip is NOT a version change, so it
// must not complete a still-running, same-process node here.
} else if (!isReapply && isValidVersion(version) && version !== tracker.previousVersion) {
// Signal 1: a valid, different version. Skipped for reapply because
// the authored image/version is not expected to change.
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 1 (version changed):', tracker.previousVersion, '->', version);
updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed'));
} else if (
@@ -1173,20 +1187,20 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 3 (offline then online, startedAt unavailable)');
updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed'));
} else if (
!isReapply &&
elapsed > 15_000 &&
isValidVersion(version) &&
gatewayValid &&
!semver.lt(version, compareVersion!)
) {
// Signal 4: remote is now at or above gateway version (after
// minimum processing time). Catches fast restarts where the 5s
// polling interval misses the offline window and startedAt
// hasn't been observed to change yet.
// minimum processing time). Never used for reapply: an already
// current node would false-complete before the helper runs.
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 4 (version >= compare target):', version, '>=', compareVersion);
updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed'));
} else if (elapsed > EARLY_FAIL_MS) {
if (debug) console.debug('[Fleet:debug] Node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's - no signals detected');
resolveTerminal(node, tracker, 'failed', 'Update may have failed. The node is still running and its version has not changed.');
resolveTerminal(node, tracker, 'failed', earlyFailMsg);
}
} else if (node.type === 'local') {
// Local node has only two failure signals: an explicit pull/spawn
@@ -1202,7 +1216,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
selfUpdate.clearLastError();
} else if (elapsed > EARLY_FAIL_MS) {
if (debug) console.debug('[Fleet:debug] Local node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's');
resolveTerminal(node, tracker, 'failed', 'Local update did not complete. The container may not have restarted; check Docker logs on the host.');
resolveTerminal(node, tracker, 'failed', earlyFailMsg);
}
}
}
@@ -1286,6 +1300,10 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
updateBlocked,
updateBlockedReason,
imageChannel,
operationKind: currentTracker?.operationKind ?? null,
canReapplyCompose: node.type === 'local'
? SelfUpdateService.getInstance().isAvailable()
: remoteOnline && remoteCapabilities.includes('self-update'),
};
}),
);
@@ -1305,6 +1323,8 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp
skipActive: false,
skippedVersion: null,
...EMPTY_PIN_STATUS,
operationKind: null,
canReapplyCompose: false,
};
});
@@ -1345,17 +1365,46 @@ fleetRouter.get('/update-status/release-notes', authMiddleware, async (req: Requ
// repin a semver-pinned compose to that release. It is omitted otherwise (never
// sent as null/invalid), and an older remote that predates this field simply
// ignores the extra body key and behaves as before.
function postSystemUpdate(target: { apiUrl: string; apiToken: string }, targetVersion?: string) {
function postSystemEndpoint(
target: { apiUrl: string; apiToken: string },
endpoint: '/api/system/update' | '/api/system/reapply-compose',
body: Record<string, unknown> = {},
) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`;
return fetch(`${target.apiUrl.replace(/\/$/, '')}/api/system/update`, {
return fetch(`${target.apiUrl.replace(/\/$/, '')}${endpoint}`, {
method: 'POST',
headers,
body: JSON.stringify(targetVersion ? { targetVersion } : {}),
body: JSON.stringify(body),
signal: AbortSignal.timeout(10000),
});
}
function postSystemUpdate(target: { apiUrl: string; apiToken: string }, targetVersion?: string) {
return postSystemEndpoint(target, '/api/system/update', targetVersion ? { targetVersion } : {});
}
function postSystemReapplyCompose(target: { apiUrl: string; apiToken: string }) {
return postSystemEndpoint(target, '/api/system/reapply-compose');
}
/** Clear a terminal tracker row, or time out a stale in-flight one. Returns a
* conflict message when another update/reapply is still actively running. */
function beginTrackerOperation(nodeId: number, conflictError: string): string | null {
const existing = updateTracker.get(nodeId);
if (existing?.status === 'updating') {
if (Date.now() - existing.startedAt > UPDATE_TIMEOUT_MS) {
updateTracker.set(nodeId, updateTracker.resolve(existing, 'timeout', UPDATE_TIMEOUT_MSG));
} else {
return conflictError;
}
}
if (existing && (existing.status === 'timeout' || existing.status === 'failed' || existing.status === 'completed')) {
updateTracker.delete(nodeId);
}
return null;
}
function parseRemoteUpdateFailure(payload: unknown): { error: string; code?: string } {
if (!payload || typeof payload !== 'object') {
return { error: 'Remote node rejected update request.' };
@@ -1435,18 +1484,10 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r
const requestedTarget = parseRequestedTargetVersion(req, res);
if (requestedTarget === null) return; // invalid supplied value; 400 already sent
const existing = updateTracker.get(nodeId);
if (existing?.status === 'updating') {
if (Date.now() - existing.startedAt > UPDATE_TIMEOUT_MS) {
updateTracker.set(nodeId, updateTracker.resolve(existing, 'timeout', UPDATE_TIMEOUT_MSG));
} else {
res.status(409).json({ error: 'Update already in progress for this node.' });
return;
}
}
// Clear terminal states to allow retry.
if (existing && (existing.status === 'timeout' || existing.status === 'failed' || existing.status === 'completed')) {
updateTracker.delete(nodeId);
const conflict = beginTrackerOperation(nodeId, 'Update already in progress for this node.');
if (conflict) {
res.status(409).json({ error: conflict });
return;
}
console.log('[Fleet] Update triggered for node', node.name, node.type);
@@ -1548,6 +1589,140 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r
}
});
fleetRouter.post('/nodes/:nodeId/reapply-compose', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
if (nodeId === null) return;
const db = DatabaseService.getInstance();
const node = db.getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
const conflict = beginTrackerOperation(nodeId, 'An update or reapply is already in progress for this node.');
if (conflict) {
res.status(409).json({ error: conflict });
return;
}
console.log('[Fleet] Compose reapply triggered for node', node.name, node.type);
if (node.type === 'local') {
const selfUpdate = SelfUpdateService.getInstance();
if (!selfUpdate.isAvailable()) {
res.status(503).json({ error: 'Compose reapply unavailable on the local node.' });
return;
}
const claim = await ImageOperationService.getInstance().claimComposeReapply();
if (!claim.ok) {
res.status(409).json({ error: 'An image operation is already in progress.', code: claim.failureCode });
return;
}
updateTracker.set(
nodeId,
updateTracker.create('updating', getSenchoVersion(), null, undefined, undefined, 'reapply_configuration'),
);
res.status(202).json({ message: 'Compose reapply initiated on local node. The server will restart shortly.' });
setTimeout(() => {
ImageOperationService.getInstance().executeClaimedComposeReapply().catch(error => {
console.error('[ImageOperation] Unexpected compose reapply failure:', error);
});
}, 500);
return;
}
// Sync lock before any await so a concurrent reapply gets 409 without
// racing the remote POST. The pollable tracker is created only after meta
// is known (full process identity), immediately before dispatch.
if (remoteReapplyDispatching.has(nodeId)) {
res.status(409).json({ error: 'An update or reapply is already in progress for this node.' });
return;
}
remoteReapplyDispatching.add(nodeId);
const failOwnedTracker = (
error: string,
code?: string,
previousVersion: string | null = null,
previousProcessStart: number | null = null,
) => {
const current = updateTracker.get(nodeId);
if (current?.status !== 'updating' || current.operationKind !== 'reapply_configuration') return;
updateTracker.set(
nodeId,
updateTracker.create('failed', previousVersion, previousProcessStart, error, code, 'reapply_configuration'),
);
};
try {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) {
const error = formatNoTargetError(node);
res.status(503).json({ error });
return;
}
const meta = await NodeRegistry.getInstance().fetchMetaForNode(node.id);
if (!meta.online) {
const error = 'Remote node is unreachable. Verify the node is running and the API URL is correct.';
res.status(503).json({ error });
return;
}
if (!meta.capabilities.includes('self-update')) {
const error = 'Remote node does not support compose reapply. It may need to be updated manually first.';
res.status(503).json({ error });
return;
}
// Digest pins and updateBlocked are intentional non-gates: reapply never
// repins the image, so blocked update rows remain eligible.
// Reserve before the remote POST so a concurrent reapply still sees
// 'updating' after this request leaves the dispatch set in finally.
updateTracker.set(
nodeId,
updateTracker.create(
'updating',
meta.version,
meta.startedAt,
undefined,
undefined,
'reapply_configuration',
),
);
const response = await postSystemReapplyCompose(target);
if (!response.ok) {
const failure = parseRemoteUpdateFailure(await response.json().catch(() => null));
failOwnedTracker(failure.error, failure.code, meta.version, meta.startedAt);
res.status(502).json(failure);
return;
}
res.status(202).json({ message: `Compose reapply initiated on ${node.name}.` });
} finally {
remoteReapplyDispatching.delete(nodeId);
}
} catch (error) {
console.error('[Fleet] Node compose reapply error:', error);
const errorMsg = getErrorMessage(error, 'Failed to trigger compose reapply.');
const failedNodeId = parseInt(req.params.nodeId as string, 10);
if (!isNaN(failedNodeId)) {
remoteReapplyDispatching.delete(failedNodeId);
const current = updateTracker.get(failedNodeId);
if (current?.status === 'updating' && current.operationKind === 'reapply_configuration') {
updateTracker.set(
failedNodeId,
updateTracker.create('failed', null, null, errorMsg, undefined, 'reapply_configuration'),
);
}
}
res.status(500).json({ error: 'Failed to trigger compose reapply.' });
}
});
fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
+20
View File
@@ -175,3 +175,23 @@ systemUpdateRouter.post('/update', async (req: Request, res: Response): Promise<
});
}, 500);
});
systemUpdateRouter.post('/reapply-compose', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
const selfUpdate = SelfUpdateService.getInstance();
if (!selfUpdate.isAvailable()) {
res.status(503).json({ error: 'Compose reapply unavailable. Sencho must be deployed via Docker Compose.' });
return;
}
const claim = await ImageOperationService.getInstance().claimComposeReapply();
if (!claim.ok) {
res.status(409).json({ error: 'An image operation is already in progress.', code: claim.failureCode });
return;
}
res.status(202).json({ message: 'Compose reapply initiated. The server will restart shortly.' });
setTimeout(() => {
ImageOperationService.getInstance().executeClaimedComposeReapply().catch(error => {
console.error('[ImageOperation] Unexpected compose reapply failure:', error);
});
}, 500);
});
@@ -1,3 +1,5 @@
export type FleetOperationKind = 'update' | 'reapply_configuration';
export interface UpdateTracker {
status: 'updating' | 'completed' | 'timeout' | 'failed';
startedAt: number;
@@ -11,6 +13,8 @@ export interface UpdateTracker {
wasOffline: boolean;
/** Timestamp when the tracker transitioned to a terminal state (completed/failed/timeout). */
resolvedAt?: number;
/** Distinguishes version updates from compose reapply so poll heuristics stay correct. */
operationKind: FleetOperationKind;
}
export type TerminalStatus = 'completed' | 'failed' | 'timeout';
@@ -61,13 +65,15 @@ export class FleetUpdateTrackerService {
return this.trackers.size;
}
/** Create a new tracker with `startedAt=now` and resolvedAt set if terminal. */
/** Create a new tracker with `startedAt=now` and resolvedAt set if terminal.
* `operationKind` defaults to `'update'` so existing call sites stay unchanged. */
public create(
status: UpdateTracker['status'],
previousVersion: string | null,
previousProcessStart: number | null,
error?: string,
code?: string,
operationKind: FleetOperationKind = 'update',
): UpdateTracker {
const now = Date.now();
return {
@@ -78,6 +84,7 @@ export class FleetUpdateTrackerService {
wasOffline: false,
error,
code,
operationKind,
resolvedAt: status !== 'updating' ? now : undefined,
};
}
+67 -26
View File
@@ -8,7 +8,7 @@ import type { ImagePinKind } from '../helpers/selfUpdateCompose';
import type { LocalRegistryAccess } from './hardenedEntitlementTypes';
import { getAuthToken, httpRequest } from './registry-api';
export type ImageOperationKind = 'switch' | 'update' | 'community_update';
export type ImageOperationKind = 'switch' | 'update' | 'community_update' | 'compose_reapply';
export type ImageOperationState = 'pending_pull' | 'pulling' | 'patching' | 'recreating' | 'succeeded' | 'failed';
type FailureCode = 'self_update_unavailable' | 'entitlement_denied' | 'preflight_mismatch' | 'compose_unavailable' | 'registry_access_unavailable' | 'update_failed' | 'interrupted_by_restart';
@@ -146,32 +146,12 @@ export class ImageOperationService {
public async claimCommunityUpdate(options?: { targetVersion?: string }): Promise<
{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }
> {
const selfUpdate = SelfUpdateService.getInstance();
const resolved = await selfUpdate.getResolvedComposeImageForUpdate();
const operation = this.newOperation(
'community_update',
resolved?.imageRef ?? null,
options?.targetVersion ?? null,
resolved?.filePath ?? null,
selfUpdate.getComposeServiceName(),
);
if (!await this.tryClaim(operation)) {
return { ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' };
}
// Disk non-terminal state is the concurrency lock; clear the in-memory mutex
// so a later claim can observe the persisted pending operation.
this.claimed = false;
return { ok: true };
return this.claimComposeOperation('community_update', options?.targetVersion ?? null);
}
public async executeClaimedCommunityUpdate(options?: { targetVersion?: string }): Promise<{ ok: boolean; failureCode?: string }> {
const operation = await this.getCurrentOperation();
if (!operation || operation.kind !== 'community_update') {
return { ok: false, failureCode: 'update_failed' };
}
if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) {
return { ok: false, failureCode: 'update_failed' };
}
const operation = await this.getActiveClaimedOperation('community_update');
if (!operation) return { ok: false, failureCode: 'update_failed' };
const selfUpdate = SelfUpdateService.getInstance();
try {
operation.state = 'pulling';
@@ -207,6 +187,37 @@ export class ImageOperationService {
return this.executeClaimedCommunityUpdate(options);
}
public async claimComposeReapply(): Promise<
{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }
> {
return this.claimComposeOperation('compose_reapply', null);
}
public async executeClaimedComposeReapply(): Promise<{ ok: boolean; failureCode?: string }> {
const operation = await this.getActiveClaimedOperation('compose_reapply');
if (!operation) return { ok: false, failureCode: 'update_failed' };
const selfUpdate = SelfUpdateService.getInstance();
try {
// No pull/patch for reapply: jump straight to recreating.
operation.state = 'recreating';
await this.persist(operation);
this.watchHelperExit(operation);
await selfUpdate.triggerComposeReapply({
successMarkerFile: this.successMarkerFile(operation),
successMarkerContent: JSON.stringify({ ok: true, operationId: operation.operationId }),
});
if (selfUpdate.getLastError()) {
await this.fail(operation, 'update_failed');
return { ok: false, failureCode: 'update_failed' };
}
return { ok: true };
} catch (error) {
console.error('[ImageOperation] Compose reapply failed:', error);
await this.fail(operation, 'update_failed');
return { ok: false, failureCode: 'update_failed' };
}
}
public async getOperation(operationId: string): Promise<ImageOperation | null> {
const filePath = this.operationFile(operationId);
if (!filePath) return null;
@@ -243,8 +254,9 @@ export class ImageOperationService {
const markerPath = this.successMarkerFile(operation);
for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) {
const markerOk = await this.isSuccessMarkerForOperation(markerPath, operation.operationId);
if (operation.kind === 'community_update') {
// Community success is the marker alone; floating tags may not equal targetImageRef.
if (operation.kind === 'community_update' || operation.kind === 'compose_reapply') {
// Marker-only success: community updates may leave floating tags that do
// not equal targetImageRef, and reapply never sets a target image at all.
if (markerOk) {
operation.state = 'succeeded';
operation.resolvedAt = new Date().toISOString();
@@ -284,6 +296,35 @@ export class ImageOperationService {
});
}
private async claimComposeOperation(
kind: 'community_update' | 'compose_reapply',
targetImageRef: string | null,
): Promise<{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }> {
const selfUpdate = SelfUpdateService.getInstance();
const resolved = await selfUpdate.getResolvedComposeImageForUpdate();
const operation = this.newOperation(
kind,
resolved?.imageRef ?? null,
targetImageRef,
resolved?.filePath ?? null,
selfUpdate.getComposeServiceName(),
);
if (!await this.tryClaim(operation)) {
return { ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' };
}
// Disk non-terminal state is the concurrency lock; clear the in-memory mutex
// so a later claim can observe the persisted pending operation.
this.claimed = false;
return { ok: true };
}
private async getActiveClaimedOperation(kind: ImageOperationKind): Promise<ImageOperation | null> {
const operation = await this.getCurrentOperation();
if (!operation || operation.kind !== kind) return null;
if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) return null;
return operation;
}
private newOperation(kind: ImageOperationKind, previousImageRef: string | null, targetImageRef: string | null, composeFilePath: string | null, serviceName: string | null, preflightFingerprint?: string): ImageOperation {
return {
schemaVersion: 1,
+65 -2
View File
@@ -205,6 +205,27 @@ export function buildSelfUpdateRunArgs(
];
}
/**
* Build the argv for a throwaway helper that runs `docker compose … config`
* against the host compose project. Reuses the recreate helper's mount layout
* (socket + working dir + host binds) without mounting /app/data, since
* validation is read-only. Pure and exported for unit testing.
*/
export function buildComposeConfigValidateArgs(
ctx: Pick<ComposeContext, 'workingDir' | 'imageName' | 'hostBindMounts'> & { configFiles: string },
): string[] {
const { workingDir, imageName, hostBindMounts, configFiles } = ctx;
const fFlags = configFiles.split(',').flatMap(f => {
const trimmed = f.trim();
return trimmed ? ['-f', trimmed] : [];
});
const composeCmd = ['docker compose', ...fFlags.map(shQuote), 'config'].join(' ');
return buildSelfUpdateRunArgs(
{ workingDir, imageName, dataDirHost: null, hostBindMounts },
composeCmd,
);
}
class SelfUpdateService {
private static instance: SelfUpdateService;
private canSelfUpdate = false;
@@ -527,6 +548,46 @@ class SelfUpdateService {
this.spawnHelper(env, composeCopy, options?.successMarkerFile, options?.successMarkerContent);
}
/**
* Recreate the Sencho service from the exact current on-disk Compose project
* without pulling or rewriting the image reference. Used by Fleet "Reapply
* configuration". Validates the authored compose via a throwaway helper
* before the last-breath recreate so invalid config fails before shutdown.
*/
async triggerComposeReapply(options?: {
successMarkerFile?: string;
successMarkerContent?: string;
}): Promise<void> {
if (!this.composeContext) return;
const env = this.buildEnv();
this.lastUpdateError = null;
this.pendingHelperExitError = undefined;
try { fs.unlinkSync(UPDATE_ERROR_FILE); } catch { /* absent is the steady state */ }
try { fs.unlinkSync(STAGED_PATCH_FILE); } catch { /* absent is the steady state */ }
const { workingDir, configFiles, imageName, hostBindMounts } = this.composeContext;
console.log('[SelfUpdate] Validating compose configuration before reapply...');
try {
await execFileAsync(
'docker',
buildComposeConfigValidateArgs({ workingDir, imageName, hostBindMounts, configFiles }),
{ env, timeout: 60_000, maxBuffer: 10 * 1024 * 1024 },
);
} catch (error) {
const stderr = (error as { stderr?: Buffer | string })?.stderr?.toString().trim();
const stdout = (error as { stdout?: Buffer | string })?.stdout?.toString().trim();
this.lastUpdateError =
stderr || stdout || (error as Error).message || 'Compose configuration validation failed.';
console.error('[SelfUpdate] Compose reapply validation failed:', this.lastUpdateError);
return;
}
// No pull and no compose rewrite: the authored image ref is authoritative.
// Skip dangling-image prune (nothing was pulled).
this.spawnHelper(env, undefined, options?.successMarkerFile, options?.successMarkerContent, false);
}
/**
* Spawn the "last breath" helper container that recreates Sencho (and, when a
* repin is staged, copies the rewritten compose file onto the host first).
@@ -538,6 +599,7 @@ class SelfUpdateService {
composeCopy?: ComposeCopy,
successMarkerFile?: string,
successMarkerContent?: string,
pruneOnUpdateOverride?: boolean,
): void {
if (!this.composeContext) return;
const { workingDir, configFiles, serviceName, imageName, dataDirHost, hostBindMounts } = this.composeContext;
@@ -551,8 +613,9 @@ class SelfUpdateService {
// Opt-out (default ON): after a clean recreate, prune the dangling image
// layers the pull orphaned. Read fresh so this node honors its own setting.
const stderrTmp = '/tmp/_sencho_err';
const pruneOnUpdate =
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1';
const pruneOnUpdate = pruneOnUpdateOverride ?? (
DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1'
);
const composeCmd = buildSelfUpdateComposeCmd(
fFlags,
serviceName,
+2
View File
@@ -42,6 +42,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'POST /system/networks/delete': 'Deleted networks',
'POST /system/networks': 'Created network',
'POST /system/console-token': 'Generated console token',
'POST /system/reapply-compose': 'Triggered compose reapply',
// Node management
'POST /nodes': 'Added node',
@@ -91,6 +92,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'DELETE /fleet/snapshots': 'Deleted fleet backup',
'POST /fleet/snapshots/*/restore': 'Restored fleet backup',
'POST /fleet/nodes/*/update': 'Triggered fleet node update',
'POST /fleet/nodes/*/reapply-compose': 'Triggered fleet node compose reapply',
'POST /fleet/update-all': 'Triggered fleet-wide update',
'POST /fleet/role/reanchor': 'Re-anchored fleet replica',
'POST /fleet/role/demote': 'Demoted fleet replica to control',
+8 -7
View File
@@ -28,7 +28,7 @@ The top card on the left holds the stack's identity and primary controls.
The action bar runs every state transition for the whole stack. The primary buttons (**Start**, **Restart**, **Stop**, **Take down** when running, **Update**) require the `stack:deploy` permission; the **Delete** entry in the kebab dropdown requires the `stack:delete` permission. The bar still appears when only **Delete** is authorised so the operator has a way to remove the stack.
<Note>
If a stack is the Sencho instance you are currently signed into, its deploy/delete actions are protected: clicking any of them opens a **Sencho instance protected** dialog instead of running the action. Update Sencho from **Fleet → Node Update**, or move Sencho's own compose project outside `COMPOSE_DIR` to manage it as a normal stack.
If a stack is the Sencho instance you are currently signed into, stop, take down, delete, update, and rollback stay protected: clicking them opens a **Sencho instance protected** dialog. Eligible admins can apply on-disk Compose changes with **Save &amp; Reapply** in this editor (same recreate procedure as Fleet Node Updates), or open **Fleet → Node Updates**. To manage the stack as a normal stack, move Sencho's compose project outside `COMPOSE_DIR`.
</Note>
| Button | Behavior |
@@ -144,19 +144,20 @@ The `.env` editor renders a teal banner above the textarea reminding you that va
### Save options
As soon as the compose editor opens, the toolbar shows a split button. The primary action is **Save & Deploy**; the dropdown chevron reveals two more.
As soon as the compose editor opens, the toolbar shows a split button. The primary action is **Save & Deploy** for ordinary stacks. On Sencho's own Compose-managed stack, eligible admins see **Save & Reapply** instead. The dropdown chevron reveals two more actions.
| Action | Effect |
|--------|--------|
| **Save & Deploy** | Writes the file to disk, then runs `docker compose up -d` to apply changes. |
| **Save Only** | Writes the file to disk without restarting any containers. Changes take effect on the next deploy. |
| **Save & Reapply** | Shown only for admins on the Compose-managed Sencho self-stack when reapply is available. Writes the file, then confirms and recreates Sencho from the current on-disk Compose project without selecting a newer image. Same procedure as Fleet **Reapply configuration**. |
| **Save Only** | Writes the file to disk without restarting any containers. Changes take effect on the next deploy or reapply. |
| **Discard Changes** | Reverts the active file (compose or env) to the last saved version. Unsaved edits in that file are lost. |
The same controls apply to the `compose.yaml` and `.env` editors.
## Diff preview before save
When **Diff preview before save** is enabled in **Settings → Infrastructure → Stacks**, clicking **Save & Deploy** or **Save Only** opens a side-by-side diff modal before anything is written to disk. The left pane is the on-disk content; the right pane is your unsaved edits with additions highlighted in green. The footer reads `ON DISK → UNSAVED` so the panes are unambiguous.
When **Diff preview before save** is enabled in **Settings → Infrastructure → Stacks**, clicking **Save & Deploy**, **Save & Reapply**, or **Save Only** opens a side-by-side diff modal before anything is written to disk. The left pane is the on-disk content; the right pane is your unsaved edits with additions highlighted in green. The footer reads `ON DISK → UNSAVED` so the panes are unambiguous.
<Frame>
<img src="/images/compose-diff-preview/diff-modal.png" alt="Diff preview modal showing side-by-side YAML diff with the unsaved version on the right and an ON DISK to UNSAVED legend in the footer" />
@@ -164,7 +165,7 @@ When **Diff preview before save** is enabled in **Settings → Infrastructure
Review the diff, then:
- Click the primary button (**Save & deploy** when triggered from **Save & Deploy**, or **Save** when triggered from **Save Only**) to confirm and write the changes.
- Click the primary button (**Save & deploy** or **Save & reapply** when triggered from that primary action, or **Save** when triggered from **Save Only**) to confirm and write the changes.
- Click **Cancel** to return to the editor without saving.
If there are no unsaved changes the modal is skipped and the save proceeds directly. The toggle is off by default and stored per browser, so each device remembers its own setting.
@@ -177,7 +178,7 @@ On a narrow screen the stack opens as a full-screen detail with **Health**, **Lo
<img src="/images/editor/editor-mobile.png" alt="Mobile compose editor with a Cancel button, the compose.yaml label, a monospace text field showing the compose file, a small-edits note, and Save and Save and Deploy buttons" />
</Frame>
The mobile editor is a lightweight monospace text field rather than Monaco. Tap **compose** or **.env** at the top to choose the file. The **.env** toggle appears only when the stack has an env file, and the file picker is locked while you have unsaved edits so switching files cannot drop them. The footer carries the same **Save** and **Save & Deploy** actions, and every protection is shared with desktop: the diff preview, save-conflict handling, and the unsaved-changes prompt all behave the same way. **Cancel** leaves the editor and asks before discarding unsaved edits.
The mobile editor is a lightweight monospace text field rather than Monaco. Tap **compose** or **.env** at the top to choose the file. The **.env** toggle appears only when the stack has an env file, and the file picker is locked while you have unsaved edits so switching files cannot drop them. The footer carries the same **Save** and **Save & Deploy** (or **Save & Reapply** when eligible) actions, and every protection is shared with desktop: the diff preview, save-conflict handling, and the unsaved-changes prompt all behave the same way. **Cancel** leaves the editor and asks before discarding unsaved edits.
A note at the bottom of the editor is a reminder that mobile editing is meant for small corrections such as bumping an image tag or fixing a value. For large compose rewrites, open the stack on a desktop. Editing requires the `stack:edit` permission.
@@ -252,7 +253,7 @@ Sencho tries `/bin/bash` first and transparently falls back to `/bin/sh` if bash
The editor blocks a silent loss of in-progress edits. Click **Cancel** to return to the original node with your edits intact; click **Discard** to abandon them and proceed to the other node.
</Accordion>
<Accordion title='"Sencho instance protected" dialog when I try to stop, update, or delete a stack'>
That stack is running the Sencho instance you are signed into, and its deploy/delete actions are blocked to prevent locking yourself out. Update Sencho from **Fleet → Node Update** instead. To manage the stack normally, move its compose project to a directory outside `COMPOSE_DIR`.
That stack is running the Sencho instance you are signed into. Stop, take down, delete, update, and rollback stay blocked. Eligible admins can apply Compose edits with **Save &amp; Reapply** in the editor, or use **Fleet → Node Updates**. To manage the stack normally, move its compose project to a directory outside `COMPOSE_DIR`.
</Accordion>
<Accordion title='"Stats unavailable" chip on the container list'>
The stats WebSocket failed to open, or closed unexpectedly. This usually means the Docker daemon on the node is unreachable. Container status, action buttons, and logs continue to work; only live CPU / memory / network rates pause.
+2 -2
View File
@@ -280,9 +280,9 @@ The table lists every registered node, filtered by the search box at the top. Co
| **Type** | `local` or `remote` outline pill |
| **Current** | The node's reported Sencho version, in mono. Reads `unknown` if the node has not reported (offline, unreachable, or never connected). |
| **Latest** | The newest published Sencho release. Highlighted when newer than Current. |
| **Status** | Either an `Up to date` success badge, an `Update` button (per-row), an in-progress / failed badge with retry and dismiss controls, or a `Skipped` badge when the version has been deferred. |
| **Status** | Either an `Up to date` success badge, an `Update` button when a newer release is available, an icon-only **Reapply configuration** control (tooltip) for Compose-managed nodes (including up-to-date rows), an in-progress / failed badge with retry and dismiss controls, or a `Skipped` badge when the version has been deferred. |
The latest-version label is resolved from the GitHub Releases API (with a Docker Hub fallback) and cached for 30 minutes. **Recheck** flushes the cache and re-resolves immediately.
The latest-version label is resolved from the GitHub Releases API (with a Docker Hub fallback) and cached for 30 minutes. **Recheck** flushes the cache and re-resolves immediately. See [Remote Updates · Reapply configuration](/features/remote-updates#reapply-configuration) for what reapply does and when to use it.
### Skipping a version
+15
View File
@@ -52,6 +52,21 @@ When an update is available, this same card gains a warning **Update available**
The gateway switches to a fast 5-second polling loop while any node is in the `Updating` state, so the badge advances in near real time without waiting for the next 30-second fleet refresh.
## Reapply configuration
When a node is already on the current Sencho release, **Update** is hidden, but Compose-managed nodes still expose a **Reapply configuration** icon control (tooltip on hover) in the Node updates sheet. Use this after you change the node's on-disk Compose project (environment variables, mounts, ports, labels, limits, healthcheck, networks, or socket-proxy settings) and need Sencho to recreate itself from that project without selecting a newer release.
Reapply:
- Uses the same Compose project context as a version update (multiple Compose files, `env_file`, configs, secrets, and bind mounts).
- Preserves the image reference declared in Compose. Sencho does not resolve a newer release or rewrite the pin.
- Works for digest-pinned installs, because no repin step runs.
- Requires the admin role.
Confirming a local reapply opens a dialog that states the node will recreate from its current Compose configuration, the dashboard may briefly disconnect, no newer version is selected, and the configured image reference is not rewritten. Confirming a remote reapply uses the same required acknowledgement (recreate from current Compose, no newer version, no image rewrite) before the request is sent; Fleet then shows a **Reapplying** badge until the node restarts or the operation fails.
Eligible admins can also run the same procedure from the Compose editor: on Sencho's own stack the primary save action becomes **Save & Reapply**, which saves the file first, then opens the same confirmation and fleet reapply path.
## Updating the local (gateway) node
Updating the gateway is special because the dashboard is hosted by the very container that is about to restart. Clicking **Update** on the local row, or **Update to vX.Y.Z** on the Local card, opens a confirmation dialog (kicker **LOCAL · UPDATE**, title **Update local node**, with **Cancel** and **Update & restart** buttons) before anything happens on disk. The body text depends on how the compose file pins the image: for a semver pin it names the exact rewrite (for example, "This install pins `saelix/sencho:0.94.1`. Updating rewrites it to `saelix/sencho:0.95.0`..."); for a floating tag it reads more generally ("Pulls Sencho v0.95.0 and restarts the server..."). Both variants end with the same note that the dashboard briefly disconnects and reconnects automatically.
@@ -3,6 +3,7 @@ import { DiffEditor } from '@/lib/monacoLoader';
import { Loader2 } from 'lucide-react';
import { Modal, ModalHeader, ModalFooter } from '@/components/ui/modal';
import { Button } from '@/components/ui/button';
import type { ComposeDiffActionLabel } from '@/components/resolveComposeDiffActionLabel';
export interface ComposeDiffPreviewDialogProps {
open: boolean;
@@ -12,7 +13,7 @@ export interface ComposeDiffPreviewDialogProps {
language: 'yaml' | 'ini';
original: string;
modified: string;
actionLabel: 'Save' | 'Save & deploy';
actionLabel: ComposeDiffActionLabel;
confirming: boolean;
isDarkMode: boolean;
onConfirm: () => void | Promise<void>;
+15 -1
View File
@@ -23,6 +23,9 @@ import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch';
import { useNotifications } from './EditorLayout/hooks/useNotifications';
import { useContainerStats } from './EditorLayout/hooks/useContainerStats';
import { useSidebarContextMenu } from './EditorLayout/hooks/useSidebarContextMenu';
import { useActiveNodeReapplyEligibility } from './EditorLayout/hooks/useActiveNodeReapplyEligibility';
import { resolveCanSaveAndReapply } from './EditorLayout/resolveCanSaveAndReapply';
import { useComposeReapplyAction } from './FleetView/hooks/useComposeReapplyAction';
import { NodeSwitcher } from './NodeSwitcher';
import {
GlobalCommandPalette,
@@ -186,6 +189,12 @@ export default function EditorLayout() {
createDialogOpen, setCreateDialogOpen,
} = overlayState;
const { canReapply: canReapplyCompose } = useActiveNodeReapplyEligibility(activeNode?.id);
const composeReapply = useComposeReapplyAction();
const isSelfStackSelected = selectedFile ? stackSelfFlags[selectedFile] === true : false;
// Ordinary stacks keep Save & Deploy even when the node supports compose reapply.
const canSaveAndReapply = resolveCanSaveAndReapply(isAdmin, canReapplyCompose, isSelfStackSelected);
// Which mode the create dialog opens on (always empty after import tab removal).
const [createDialogInitialMode, setCreateDialogInitialMode] = useState<CreateMode>('empty');
const [adoptDialogOpen, setAdoptDialogOpen] = useState(false);
@@ -292,6 +301,8 @@ export default function EditorLayout() {
canOfferVolumeRemoval,
onDeletedOpenStack: () => onDeletedOpenStackRef.current(),
removeNotificationsForStack,
isAdmin,
canReapplyCompose,
});
// Wire the ref now that stackActions is available
@@ -692,7 +703,8 @@ export default function EditorLayout() {
requestDeleteStack={stackActions.requestDeleteStack}
requestTakeDownStack={stackActions.requestTakeDownStack}
showTakeDown={selectedFile ? stackActions.getStackMenuVisibility(selectedFile).showTakeDown : false}
isSelfStack={selectedFile ? stackSelfFlags[selectedFile] === true : false}
isSelfStack={isSelfStackSelected}
canSaveAndReapply={canSaveAndReapply}
recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined}
onRefreshState={async () => {
if (!selectedFile) return;
@@ -1050,6 +1062,8 @@ export default function EditorLayout() {
gitSourceOpen={gitSourceOpen}
setGitSourceOpen={setGitSourceOpen}
canSelfUpdate={hasCapability('self-update')}
composeReapply={composeReapply}
canSaveAndReapply={canSaveAndReapply}
canOfferVolumeRemoval={canOfferVolumeRemoval}
onOpenFleetNodeUpdates={() => {
if (isMobile) {
@@ -207,6 +207,8 @@ export interface EditorViewProps {
showTakeDown: boolean;
/** True when this stack is the running Sencho instance on the active node. */
isSelfStack?: boolean;
/** Admin + node reapply eligibility + self-stack: show Save & Reapply instead of Save & Deploy. */
canSaveAndReapply?: boolean;
// Recovery surface for a failed/stalled operation on this stack (undefined
// when the last op succeeded or none has run). onRefreshState re-syncs
@@ -300,6 +302,7 @@ export function EditorView(props: EditorViewProps) {
requestTakeDownStack,
showTakeDown,
isSelfStack,
canSaveAndReapply = false,
recoveryResult,
onRefreshState,
onDismissRecovery,
@@ -609,7 +612,7 @@ export function EditorView(props: EditorViewProps) {
<div className="flex items-center">
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={requestSaveAndDeploy} disabled={loadingAction === 'deploy'}>
<Rocket className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save & Deploy
{canSaveAndReapply ? 'Save & Reapply' : 'Save & Deploy'}
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
@@ -27,6 +27,7 @@ interface MobileComposeEditorProps {
canEdit: boolean;
requestSave: () => void;
requestSaveAndDeploy: (e: React.MouseEvent) => void;
canSaveAndReapply?: boolean;
onClose: () => void;
hasUnsavedChanges: () => boolean;
}
@@ -54,6 +55,7 @@ export function MobileComposeEditor(props: MobileComposeEditorProps) {
canEdit,
requestSave,
requestSaveAndDeploy,
canSaveAndReapply = false,
onClose,
hasUnsavedChanges,
} = props;
@@ -195,7 +197,7 @@ export function MobileComposeEditor(props: MobileComposeEditorProps) {
className="h-11 flex-1 rounded-lg"
>
<Rocket className="mr-2 h-4 w-4" strokeWidth={1.5} />
Save &amp; Deploy
{canSaveAndReapply ? 'Save & Reapply' : 'Save & Deploy'}
</Button>
</div>
)}
@@ -75,6 +75,7 @@ export function MobileStackDetail(props: EditorViewProps) {
requestTakeDownStack,
showTakeDown,
isSelfStack = false,
canSaveAndReapply = false,
onMobileBack,
onCloseEditor,
hasUnsavedChanges,
@@ -115,6 +116,7 @@ export function MobileStackDetail(props: EditorViewProps) {
canEdit={canEditStack}
requestSave={requestSave}
requestSaveAndDeploy={requestSaveAndDeploy}
canSaveAndReapply={canSaveAndReapply}
onClose={onCloseEditor}
hasUnsavedChanges={hasUnsavedChanges}
/>
@@ -4,6 +4,8 @@ import { PreDeployScanDialog } from '../stack/PreDeployScanDialog';
import { MissingExternalNetworksDialog } from '../stack/MissingExternalNetworksDialog';
import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog';
import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog';
import { LocalUpdateConfirmDialog } from '../FleetView/LocalUpdateConfirmDialog';
import { ReconnectingOverlay } from '../FleetView/ReconnectingOverlay';
import { DeleteStackDialog } from './DeleteStackDialog';
import { TakeDownStackDialog } from './TakeDownStackDialog';
import { UnsavedChangesDialog } from './UnsavedChangesDialog';
@@ -12,9 +14,11 @@ import { GitSourcePanel } from '../stack/GitSourcePanel';
import { LogViewer } from '../LogViewer';
import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet';
import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog';
import { resolveComposeDiffActionLabel } from '@/components/resolveComposeDiffActionLabel';
import type { OverlayState } from './hooks/useOverlayState';
import type { StackActionsHook } from './hooks/useStackActions';
import type { PermissionAction } from '@/context/AuthContext';
import type { useComposeReapplyAction } from '../FleetView/hooks/useComposeReapplyAction';
interface ShellOverlaysProps {
overlayState: OverlayState;
@@ -27,6 +31,8 @@ interface ShellOverlaysProps {
gitSourceOpen: boolean;
setGitSourceOpen: (open: boolean) => void;
canSelfUpdate: boolean;
composeReapply: ReturnType<typeof useComposeReapplyAction>;
canSaveAndReapply: boolean;
canOfferVolumeRemoval: boolean;
onOpenFleetNodeUpdates: () => void;
}
@@ -42,6 +48,8 @@ export function ShellOverlays({
gitSourceOpen,
setGitSourceOpen,
canSelfUpdate,
composeReapply,
canSaveAndReapply,
canOfferVolumeRemoval,
onOpenFleetNodeUpdates,
}: ShellOverlaysProps) {
@@ -57,6 +65,7 @@ export function ShellOverlays({
preDeployAdvisory,
missingExternalNetworks, setMissingExternalNetworks,
selfStackProtectedOpen, setSelfStackProtectedOpen,
composeReapplyCapture, setComposeReapplyCapture,
stackMisconfigScanId, setStackMisconfigScanId,
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
} = overlayState;
@@ -85,6 +94,32 @@ export function ShellOverlays({
onOpenFleetUpdates={onOpenFleetNodeUpdates}
/>
<LocalUpdateConfirmDialog
open={composeReapplyCapture !== null}
mode="reapply"
nodeType={composeReapplyCapture?.nodeType ?? 'local'}
onOpenChange={(open) => {
if (!open) setComposeReapplyCapture(null);
}}
onConfirm={() => {
const capture = composeReapplyCapture;
setComposeReapplyCapture(null);
if (!capture || composeReapply.dispatching) return;
void composeReapply.runReapply({
nodeId: capture.nodeId,
type: capture.nodeType,
name: capture.nodeName,
});
}}
/>
{composeReapply.reconnecting && (
<ReconnectingOverlay
preUpdateStartedAt={composeReapply.preUpdateStartedAt}
mode="reapply"
/>
)}
<UnsavedChangesDialog
open={!!pendingUnsavedLoad || !!pendingLeaveAction}
onCancel={stackActions.cancelPendingUnsavedLoad}
@@ -197,7 +232,7 @@ export function ShellOverlays({
language={diffPreview?.language ?? 'yaml'}
original={diffPreview?.original ?? ''}
modified={diffPreview?.modified ?? ''}
actionLabel={diffPreview?.mode === 'save-and-deploy' ? 'Save & deploy' : 'Save'}
actionLabel={resolveComposeDiffActionLabel(diffPreview?.mode, canSaveAndReapply)}
confirming={diffPreviewConfirming}
isDarkMode={isDarkMode}
onConfirm={async () => {
@@ -164,6 +164,17 @@ describe('EditorView single edit gate', () => {
expect(lastReadOnly).toBe(false);
});
it('shows Save & Reapply when the self-stack is eligible for compose reapply', () => {
render(<EditorView {...makeProps({
editingCompose: true,
activeTab: 'compose',
isSelfStack: true,
canSaveAndReapply: true,
})} />);
expect(screen.getByRole('button', { name: 'Save & Reapply' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Save & Deploy' })).not.toBeInTheDocument();
});
it('disables the env file selector when hasUnsavedChanges is true', () => {
render(
<EditorView
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { resolveCanSaveAndReapply } from '../resolveCanSaveAndReapply';
describe('resolveCanSaveAndReapply', () => {
it('is true only when admin, node-eligible, and self-stack', () => {
expect(resolveCanSaveAndReapply(true, true, true)).toBe(true);
});
it('is false for ordinary stacks even when admin and node-eligible', () => {
expect(resolveCanSaveAndReapply(true, true, false)).toBe(false);
});
it('is false when not admin or not node-eligible', () => {
expect(resolveCanSaveAndReapply(false, true, true)).toBe(false);
expect(resolveCanSaveAndReapply(true, false, true)).toBe(false);
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { useActiveNodeReapplyEligibility } from '../useActiveNodeReapplyEligibility';
const apiFetchMock = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
}));
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('useActiveNodeReapplyEligibility', () => {
beforeEach(() => {
apiFetchMock.mockReset();
});
it('derives canReapply only when owned result matches active node and value is true', async () => {
apiFetchMock.mockResolvedValue(okJson({
nodes: [{ nodeId: 1, canReapplyCompose: true }],
}));
const { result } = renderHook(() => useActiveNodeReapplyEligibility(1));
expect(result.current.canReapply).toBe(false);
await waitFor(() => expect(result.current.canReapply).toBe(true));
});
it('ignores a late response for a previous node after switching', async () => {
let resolveA!: (value: Response) => void;
const pendingA = new Promise<Response>((resolve) => { resolveA = resolve; });
apiFetchMock
.mockImplementationOnce(() => pendingA)
.mockResolvedValueOnce(okJson({
nodes: [{ nodeId: 2, canReapplyCompose: false }],
}));
const { result, rerender } = renderHook(
({ id }: { id: number | null }) => useActiveNodeReapplyEligibility(id),
{ initialProps: { id: 1 as number | null } },
);
rerender({ id: 2 });
await waitFor(() => expect(apiFetchMock).toHaveBeenCalledTimes(2));
expect(result.current.canReapply).toBe(false);
await act(async () => {
resolveA(okJson({
nodes: [{ nodeId: 1, canReapplyCompose: true }],
}));
});
expect(result.current.canReapply).toBe(false);
expect(result.current.owned?.nodeId === 2 || result.current.owned === null || result.current.owned.nodeId === 2).toBe(true);
});
it('stays ineligible when the row is missing canReapplyCompose', async () => {
apiFetchMock.mockResolvedValue(okJson({
nodes: [{ nodeId: 1 }],
}));
const { result } = renderHook(() => useActiveNodeReapplyEligibility(1));
await waitFor(() => expect(result.current.owned).not.toBeNull());
expect(result.current.canReapply).toBe(false);
});
});
@@ -0,0 +1,64 @@
import { useState, useEffect, useRef } from 'react';
import { apiFetch } from '@/lib/api';
import type { NodeUpdateStatus } from '@/components/FleetView/types';
type OwnedEligibility = { nodeId: number; value: boolean };
/**
* Authoritative canReapplyCompose for the active node from /fleet/update-status.
* Result is keyed by nodeId so a late response for a previous node cannot enable
* Save & Reapply after the operator switches nodes. Derived canReapply is only
* true when the owned result's nodeId matches the current activeNodeId.
*/
export function useActiveNodeReapplyEligibility(activeNodeId: number | null | undefined) {
const [owned, setOwned] = useState<OwnedEligibility | null>(null);
const generationRef = useRef(0);
useEffect(() => {
if (activeNodeId == null) {
generationRef.current += 1;
setOwned(null);
return;
}
const generation = ++generationRef.current;
let cancelled = false;
(async () => {
try {
const res = await apiFetch('/fleet/update-status', { localOnly: true });
if (cancelled || generation !== generationRef.current) return;
if (!res.ok) {
setOwned({ nodeId: activeNodeId, value: false });
return;
}
const data = await res.json();
if (cancelled || generation !== generationRef.current) return;
const nodes: NodeUpdateStatus[] = data.nodes ?? [];
const row = nodes.find(n => n.nodeId === activeNodeId);
setOwned({
nodeId: activeNodeId,
value: row?.canReapplyCompose === true,
});
} catch (error) {
if (cancelled || generation !== generationRef.current) return;
console.warn('[Editor] Failed to load reapply eligibility:', error);
setOwned({ nodeId: activeNodeId, value: false });
}
})();
return () => {
cancelled = true;
};
}, [activeNodeId]);
// Synchronous ownership check: after a node switch, a stale owned row for the
// previous node must not enable reapply on the new active node.
const canReapply =
activeNodeId != null
&& owned !== null
&& owned.nodeId === activeNodeId
&& owned.value === true;
return { canReapply, owned };
}
@@ -166,6 +166,14 @@ export function useOverlayState() {
const openSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(true), []);
const closeSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(false), []);
/** Captured when Save & Reapply opens confirm; cleared on cancel, confirm, or ownership drift. */
const [composeReapplyCapture, setComposeReapplyCapture] = useState<{
nodeId: number;
nodeType: 'local' | 'remote';
nodeName: string;
stackFile: string;
} | null>(null);
const [stackMisconfigScanId, setStackMisconfigScanId] = useState<number | null>(null);
const [diffPreview, setDiffPreview] = useState<DiffPreview | null>(null);
@@ -187,6 +195,7 @@ export function useOverlayState() {
preDeployAdvisory, setPreDeployAdvisory,
missingExternalNetworks, setMissingExternalNetworks,
selfStackProtectedOpen, setSelfStackProtectedOpen, openSelfStackProtected, closeSelfStackProtected,
composeReapplyCapture, setComposeReapplyCapture,
stackMisconfigScanId, setStackMisconfigScanId,
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
} as const;
@@ -106,6 +106,8 @@ function makeOverlay(over: Partial<OverlayState> = {}): OverlayState {
preDeployAdvisory: null,
setPreDeployAdvisory: vi.fn(),
openSelfStackProtected: vi.fn(),
setComposeReapplyCapture: vi.fn(),
composeReapplyCapture: null,
setDiffPreview: vi.fn(),
stackToDelete: null,
closeDeleteDialog: vi.fn(),
@@ -131,6 +133,8 @@ function setup(over: {
setActiveNode?: Parameters<typeof useStackActions>[0]['setActiveNode'];
onDeletedOpenStack?: () => void;
removeNotificationsForStack?: (nodeId: number, stackName: string) => void;
isAdmin?: boolean;
canReapplyCompose?: boolean;
} = {}) {
const editorState = makeEditorState(over.editorState);
const stackListState = makeStackListState(over.stackList);
@@ -150,7 +154,7 @@ function setup(over: {
stackListState,
navState,
overlayState,
activeNode: over.activeNode ?? ({ id: 1, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode']),
activeNode: over.activeNode ?? ({ id: 1, name: 'Local', type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode']),
setActiveNode,
nodes: [],
runWithLog,
@@ -160,6 +164,8 @@ function setup(over: {
canEditStack: over.canEditStack ?? (() => true),
onDeletedOpenStack,
removeNotificationsForStack,
isAdmin: over.isAdmin ?? false,
canReapplyCompose: over.canReapplyCompose ?? false,
}),
);
return { result, editorState, stackListState, overlayState, navState, setActiveNode, onDeletedOpenStack, removeNotificationsForStack };
@@ -1127,6 +1133,152 @@ describe('useStackActions.getStackMenuVisibility', () => {
expect(stackListState.setStackAction).not.toHaveBeenCalled();
});
it('opens reapply capture for eligible admin Save & Deploy on self-stack without posting deploy', async () => {
vi.mocked(apiFetch).mockReset();
const { result, overlayState } = setup({
isAdmin: true,
canReapplyCompose: true,
activeNode: { id: 7, name: 'Gateway', type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); });
expect(overlayState.setComposeReapplyCapture).toHaveBeenCalledWith({
nodeId: 7,
nodeType: 'local',
nodeName: 'Gateway',
stackFile: 'sencho.yml',
});
expect(overlayState.openSelfStackProtected).not.toHaveBeenCalled();
expect(apiFetch).not.toHaveBeenCalled();
});
it('does not open reapply capture for ordinary stacks when canReapplyCompose is true', async () => {
// Node eligibility alone must not retarget ordinary stacks; isSelfStackFile gates capture.
vi.mocked(apiFetch).mockReset();
vi.mocked(apiFetch).mockResolvedValue(new Response(JSON.stringify({ hasIssues: false }), { status: 200 }));
const { result, overlayState, stackListState } = setup({
isAdmin: true,
canReapplyCompose: true,
activeNode: { id: 7, name: 'Gateway', type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
stackList: {
selectedFile: 'web.yml',
stackSelfFlags: { 'web.yml': false },
},
});
await act(async () => {
await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent);
});
expect(overlayState.setComposeReapplyCapture).not.toHaveBeenCalled();
expect(overlayState.openSelfStackProtected).not.toHaveBeenCalled();
expect(stackListState.setStackAction).toHaveBeenCalled();
});
it('opens protected dialog for self-stack deploy when reapply is not eligible', async () => {
const { result, overlayState } = setup({
isAdmin: true,
canReapplyCompose: false,
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(overlayState.setComposeReapplyCapture).not.toHaveBeenCalled();
});
it('opens protected dialog for non-admin even when canReapplyCompose is true', async () => {
const { result, overlayState } = setup({
isAdmin: false,
canReapplyCompose: true,
stackList: {
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
},
});
await act(async () => { await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); });
expect(overlayState.openSelfStackProtected).toHaveBeenCalled();
expect(overlayState.setComposeReapplyCapture).not.toHaveBeenCalled();
});
it('cancels open reapply capture when the active node changes', async () => {
const setComposeReapplyCapture = vi.fn();
const activeNodeA = { id: 1, name: 'A', type: 'local' as const };
const { rerender } = renderHook(
({ activeNode }: { activeNode: typeof activeNodeA }) =>
useStackActions({
editorState: makeEditorState(),
stackListState: makeStackListState({
selectedFile: 'sencho.yml',
stackSelfFlags: { 'sencho.yml': true },
}),
navState: { activeView: 'editor', setActiveView: vi.fn() } as unknown as NavState,
overlayState: makeOverlay({
composeReapplyCapture: {
nodeId: 1,
nodeType: 'local',
nodeName: 'A',
stackFile: 'sencho.yml',
},
setComposeReapplyCapture,
}),
activeNode: activeNode as unknown as Parameters<typeof useStackActions>[0]['activeNode'],
setActiveNode: vi.fn(),
nodes: [],
runWithLog,
getLastDeployOutputLine: () => undefined,
diffPreviewEnabled: false,
canEditStack: () => true,
onDeletedOpenStack: vi.fn(),
isAdmin: true,
canReapplyCompose: true,
}),
{ initialProps: { activeNode: activeNodeA } },
);
rerender({ activeNode: { id: 2, name: 'B', type: 'local' as const } });
expect(setComposeReapplyCapture).toHaveBeenCalledWith(null);
});
it('cancels open reapply capture when the selected stack changes', async () => {
const setComposeReapplyCapture = vi.fn();
const { rerender } = renderHook(
({ selectedFile }: { selectedFile: string }) =>
useStackActions({
editorState: makeEditorState(),
stackListState: makeStackListState({
selectedFile,
stackSelfFlags: { 'sencho.yml': true, 'other.yml': true },
}),
navState: { activeView: 'editor', setActiveView: vi.fn() } as unknown as NavState,
overlayState: makeOverlay({
composeReapplyCapture: {
nodeId: 1,
nodeType: 'local',
nodeName: 'A',
stackFile: 'sencho.yml',
},
setComposeReapplyCapture,
}),
activeNode: { id: 1, name: 'A', type: 'local' } as unknown as Parameters<typeof useStackActions>[0]['activeNode'],
setActiveNode: vi.fn(),
nodes: [],
runWithLog,
getLastDeployOutputLine: () => undefined,
diffPreviewEnabled: false,
canEditStack: () => true,
onDeletedOpenStack: vi.fn(),
isAdmin: true,
canReapplyCompose: true,
}),
{ initialProps: { selectedFile: 'sencho.yml' } },
);
rerender({ selectedFile: 'other.yml' });
expect(setComposeReapplyCapture).toHaveBeenCalledWith(null);
});
it('opens the self-stack modal instead of calling rollback on a protected stack', async () => {
vi.mocked(apiFetch).mockReset();
const { result, overlayState, stackListState } = setup({
@@ -182,6 +182,10 @@ interface UseStackActionsOptions {
* Optional so unit tests that do not exercise delete can omit it.
*/
removeNotificationsForStack?: (nodeId: number, stackName: string) => void;
/** Admin role: required together with canReapplyCompose for Save & Reapply. */
isAdmin?: boolean;
/** Authoritative canReapplyCompose === true for the active node. */
canReapplyCompose?: boolean;
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -406,6 +410,8 @@ export function useStackActions(options: UseStackActionsOptions) {
canOfferVolumeRemoval = false,
onDeletedOpenStack,
removeNotificationsForStack,
isAdmin = false,
canReapplyCompose = false,
} = options;
const pendingStackLoadRef = useRef<string | null>(null);
@@ -445,6 +451,24 @@ export function useStackActions(options: UseStackActionsOptions) {
containersRef.current = editorState.containers;
});
// Cancel an open Save & Reapply confirmation if the active node or selected
// stack diverges from the capture (never retarget a pending confirm).
useEffect(() => {
const capture = overlayState.composeReapplyCapture;
if (!capture) return;
if (
activeNode?.id !== capture.nodeId
|| stackListState.selectedFile !== capture.stackFile
) {
overlayState.setComposeReapplyCapture(null);
}
}, [
activeNode?.id,
stackListState.selectedFile,
overlayState.composeReapplyCapture,
overlayState.setComposeReapplyCapture,
]);
useEffect(() => {
return () => {
if (checkUpdatesIntervalRef.current !== null) {
@@ -1371,8 +1395,22 @@ export function useStackActions(options: UseStackActionsOptions) {
deployPendingRef.current
)
return;
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
const stackFile = stackListState.selectedFile;
if (isSelfStackFile(stackFile)) {
if (isAdmin && canReapplyCompose && activeNode) {
overlayState.setComposeReapplyCapture({
nodeId: activeNode.id,
nodeType: activeNode.type === 'local' ? 'local' : 'remote',
nodeName: activeNode.name,
stackFile,
});
return;
}
overlayState.openSelfStackProtected();
return;
}
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
// Snapshot the node once so the advisory fetch and the deploy stay bound to
// it even if the active node changes while the advisory dialog is open.
@@ -0,0 +1,8 @@
/** Toolbar/diff eligibility: admin + node reapply + selected file is self-stack. */
export function resolveCanSaveAndReapply(
isAdmin: boolean,
canReapplyCompose: boolean,
isSelfStack: boolean,
): boolean {
return isAdmin && canReapplyCompose && isSelfStack;
}
+32 -11
View File
@@ -74,9 +74,13 @@ export function FleetView({
const { prefs, updatePrefs } = useFleetPreferences();
const updateStatus = useFleetUpdateStatus();
const overview = useFleetOverview({ prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses });
// The local node's status backs the confirm dialog copy (pin + target ref).
const localUpdateConfirmStatus = updateStatus.localUpdateConfirm !== null
? updateStatus.updateStatuses.find(s => s.nodeId === updateStatus.localUpdateConfirm)
// Confirm dialogs: local update uses pin/target copy; reapply covers local
// and remote nodes with mode-specific wording. Prefer reapply when both set.
const confirmMode = updateStatus.reapplyConfirm !== null ? 'reapply' as const : 'update' as const;
const confirmNodeId = updateStatus.reapplyConfirm ?? updateStatus.localUpdateConfirm;
const confirmOpen = confirmNodeId !== null;
const confirmStatus = confirmNodeId !== null
? updateStatus.updateStatuses.find(s => s.nodeId === confirmNodeId)
: undefined;
const topology = useTopologyPreferences();
const { exporting, exportDossier } = useFleetDossierExport();
@@ -340,7 +344,10 @@ export function FleetView({
</Tabs>
{updateStatus.reconnecting && (
<ReconnectingOverlay preUpdateStartedAt={updateStatus.preUpdateStartedAt} />
<ReconnectingOverlay
preUpdateStartedAt={updateStatus.preUpdateStartedAt}
mode={updateStatus.reconnectMode}
/>
)}
<NodeUpdatesSheet
@@ -353,19 +360,33 @@ export function FleetView({
initialTab={initialUpdatesTab}
fetchUpdateStatus={updateStatus.fetchUpdateStatus}
triggerNodeUpdate={updateStatus.triggerNodeUpdate}
triggerNodeReapply={updateStatus.triggerNodeReapply}
retryNodeUpdate={updateStatus.retryNodeUpdate}
dismissNodeUpdate={updateStatus.dismissNodeUpdate}
triggerUpdateAll={updateStatus.triggerUpdateAll}
/>
<LocalUpdateConfirmDialog
open={updateStatus.localUpdateConfirm !== null}
onOpenChange={(open) => { if (!open) updateStatus.setLocalUpdateConfirm(null); }}
onConfirm={updateStatus.confirmLocalUpdate}
imagePinKind={localUpdateConfirmStatus?.imagePinKind}
composeImageRef={localUpdateConfirmStatus?.composeImageRef}
targetImageRef={localUpdateConfirmStatus?.targetImageRef}
targetVersion={localUpdateConfirmStatus?.latestVersion}
open={confirmOpen}
mode={confirmMode}
nodeType={
confirmMode === 'reapply'
? (updateStatus.reapplyConfirmTarget?.type ?? 'local')
: (confirmStatus?.type ?? 'local')
}
onOpenChange={(open) => {
if (!open) {
updateStatus.setLocalUpdateConfirm(null);
updateStatus.setReapplyConfirm(null);
}
}}
onConfirm={confirmMode === 'reapply'
? updateStatus.confirmReapply
: updateStatus.confirmLocalUpdate}
imagePinKind={confirmStatus?.imagePinKind}
composeImageRef={confirmStatus?.composeImageRef}
targetImageRef={confirmStatus?.targetImageRef}
targetVersion={confirmStatus?.latestVersion}
/>
{NodeActionModals}
@@ -1,4 +1,5 @@
import { Download } from 'lucide-react';
import { Download, RefreshCw } from 'lucide-react';
import type { ReactNode } from 'react';
import { ConfirmModal } from '@/components/ui/modal';
import { formatVersion } from '@/lib/version';
import type { ImagePinKind } from './types';
@@ -7,6 +8,9 @@ interface LocalUpdateConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
mode?: 'update' | 'reapply';
/** Distinguishes local vs remote reapply copy. Ignored for update mode. */
nodeType?: 'local' | 'remote';
imagePinKind?: ImagePinKind | null;
composeImageRef?: string | null;
targetImageRef?: string | null;
@@ -14,33 +18,72 @@ interface LocalUpdateConfirmDialogProps {
}
export function LocalUpdateConfirmDialog({
open, onOpenChange, onConfirm, imagePinKind, composeImageRef, targetImageRef, targetVersion,
open, onOpenChange, onConfirm, mode = 'update', nodeType = 'local',
imagePinKind, composeImageRef, targetImageRef, targetVersion,
}: LocalUpdateConfirmDialogProps) {
const isReapply = mode === 'reapply';
const isRemoteReapply = isReapply && nodeType === 'remote';
const versionLabel = formatVersion(targetVersion) ?? 'the latest release';
let kicker = 'LOCAL · UPDATE';
if (isRemoteReapply) kicker = 'REMOTE · REAPPLY';
else if (isReapply) kicker = 'LOCAL · REAPPLY';
let body: ReactNode;
if (isRemoteReapply) {
body = (
<p className="text-sm text-stat-subtitle">
Recreates this remote Sencho service from its current Compose configuration.
No newer Sencho version is selected, and Sencho will not rewrite the
configured image reference. The node will restart; Fleet tracks reconnection.
</p>
);
} else if (isReapply) {
body = (
<p className="text-sm text-stat-subtitle">
Recreates this Sencho service from its current Compose configuration.
No newer Sencho version is selected, and Sencho will not rewrite the
configured image reference. The dashboard may briefly disconnect and
reconnects automatically when the restart completes.
</p>
);
} else if (imagePinKind === 'semver' && composeImageRef && targetImageRef) {
body = (
<p className="text-sm text-stat-subtitle">
This install pins <code className="text-stat-value">{composeImageRef}</code>. Updating rewrites it to{' '}
<code className="text-stat-value">{targetImageRef}</code> and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
);
} else {
body = (
<p className="text-sm text-stat-subtitle">
Pulls Sencho {versionLabel} and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
);
}
return (
<ConfirmModal
open={open}
onOpenChange={onOpenChange}
kicker="LOCAL · UPDATE"
title="Update local node"
kicker={kicker}
title={isReapply ? 'Reapply configuration' : 'Update local node'}
confirmLabel={
<>
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Update &amp; restart
</>
isReapply ? (
<>
<RefreshCw className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Reapply &amp; restart
</>
) : (
<>
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Update &amp; restart
</>
)
}
onConfirm={onConfirm}
>
{imagePinKind === 'semver' && composeImageRef && targetImageRef ? (
<p className="text-sm text-stat-subtitle">
This install pins <code className="text-stat-value">{composeImageRef}</code>. Updating rewrites it to{' '}
<code className="text-stat-value">{targetImageRef}</code> and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
) : (
<p className="text-sm text-stat-subtitle">
Pulls Sencho {versionLabel} and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes.
</p>
)}
{body}
</ConfirmModal>
);
}
@@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { MarkdownContent } from '@/components/ui/MarkdownContent';
import { Skeleton } from '@/components/ui/skeleton';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { formatVersion, isValidVersion } from '@/lib/version';
@@ -29,6 +30,7 @@ interface NodeUpdatesSheetProps {
initialTab?: 'nodes' | 'changelog';
fetchUpdateStatus: () => Promise<void>;
triggerNodeUpdate: (nodeId: number) => void;
triggerNodeReapply: (nodeId: number) => void;
retryNodeUpdate: (nodeId: number) => void;
dismissNodeUpdate: (nodeId: number) => void;
triggerUpdateAll: () => Promise<void>;
@@ -37,7 +39,7 @@ interface NodeUpdatesSheetProps {
export function NodeUpdatesSheet({
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, isAdmin,
initialTab = 'nodes',
fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
fetchUpdateStatus, triggerNodeUpdate, triggerNodeReapply, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
}: NodeUpdatesSheetProps) {
const [search, setSearch] = useState('');
const [recheckingUpdates, setRecheckingUpdates] = useState(false);
@@ -410,12 +412,17 @@ export function NodeUpdatesSheet({
<UpdateStatusBadge
status={s.updateStatus}
error={s.error}
onRetry={isAdmin ? () => retryNodeUpdate(s.nodeId) : undefined}
operationKind={s.operationKind}
onRetry={isAdmin ? () => (
s.operationKind === 'reapply_configuration'
? triggerNodeReapply(s.nodeId)
: retryNodeUpdate(s.nodeId)
) : undefined}
onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined}
/>
)}
{!s.updateStatus && !s.updateAvailable && !s.skipActive && (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">
<Badge className="text-[10px] px-1.5 py-0 h-5 shrink-0 whitespace-nowrap bg-success-muted text-success border-success/30">
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
</Badge>
)}
@@ -456,6 +463,41 @@ export function NodeUpdatesSheet({
)}
</Button>
)}
{isAdmin && !s.updateStatus && s.canReapplyCompose && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0 text-muted-foreground hover:text-stat-value"
onClick={() => triggerNodeReapply(s.nodeId)}
disabled={updatingNodeId === s.nodeId}
aria-label={updatingNodeId === s.nodeId ? 'Reapplying configuration' : 'Reapply configuration'}
>
{updatingNodeId === s.nodeId ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" strokeWidth={1.5} />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{updatingNodeId === s.nodeId ? 'Reapplying…' : 'Reapply configuration'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isAdmin && !s.updateStatus && s.canReapplyCompose === false && (
<span
className="text-[10px] text-muted-foreground/70 max-w-[9rem] text-right leading-tight"
title={s.type === 'local'
? 'This node is not Compose-managed, so configuration reapply is unavailable.'
: 'This node does not advertise Compose self-management, or is unreachable.'}
>
Reapply unavailable
</span>
)}
{showSkip(s) && (
<Button
variant="ghost"
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
interface ReconnectingOverlayProps {
/** Gateway boot timestamp captured pre-update. Null falls back to offline-then-online detection. */
preUpdateStartedAt: number | null;
/** Distinguishes version-update copy from compose reapply copy. */
mode?: 'update' | 'reapply';
}
// Mirrors the backend UPDATE_TIMEOUT_MS (5 minutes) in routes/fleet.ts. Past
@@ -13,9 +15,13 @@ interface ReconnectingOverlayProps {
// run longer than the auto-reload budget.
const RECONNECT_TIMEOUT_SECONDS = 5 * 60;
export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayProps) {
export function ReconnectingOverlay({
preUpdateStartedAt,
mode = 'update',
}: ReconnectingOverlayProps) {
const [elapsed, setElapsed] = useState(0);
const timedOut = elapsed >= RECONNECT_TIMEOUT_SECONDS;
const isReapply = mode === 'reapply';
useEffect(() => {
const timer = setInterval(() => setElapsed(s => s + 1), 1000);
@@ -62,7 +68,9 @@ export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayP
<AlertTriangle className="w-10 h-10 text-warning mx-auto" strokeWidth={1.5} />
<h2 className="text-lg font-medium">Taking longer than expected</h2>
<p className="text-sm text-muted-foreground max-w-sm">
Sencho has not come back online yet. A large image pull can take a while, so the update may still be finishing. Reload to check, or inspect the Docker host if it persists.
{isReapply
? 'Sencho has not come back online yet. The recreate may still be finishing. Reload to check, or inspect the Docker host if it persists.'
: 'Sencho has not come back online yet. A large image pull can take a while, so the update may still be finishing. Reload to check, or inspect the Docker host if it persists.'}
</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Reload to check
@@ -71,9 +79,13 @@ export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayP
) : (
<>
<Loader2 className="w-10 h-10 text-muted-foreground animate-spin mx-auto" strokeWidth={1.5} />
<h2 className="text-lg font-medium">Updating Sencho...</h2>
<h2 className="text-lg font-medium">
{isReapply ? 'Reapplying configuration...' : 'Updating Sencho...'}
</h2>
<p className="text-sm text-muted-foreground max-w-sm">
The server is pulling the update and restarting. This page will reload automatically.
{isReapply
? 'The server is recreating from its current Compose configuration and restarting. This page will reload automatically.'
: 'The server is pulling the update and restarting. This page will reload automatically.'}
</p>
<p className="text-xs text-muted-foreground tabular-nums">{elapsed}s elapsed</p>
</>
@@ -8,17 +8,19 @@ interface UpdateStatusBadgeProps {
error?: string | null;
onRetry?: () => void;
onDismiss?: () => void;
operationKind?: NodeUpdateStatus['operationKind'];
}
export function UpdateStatusBadge({ status, error, onRetry, onDismiss }: UpdateStatusBadgeProps) {
export function UpdateStatusBadge({ status, error, onRetry, onDismiss, operationKind }: UpdateStatusBadgeProps) {
const isReapply = operationKind === 'reapply_configuration';
if (status === 'updating') return (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-brand/15 text-brand border-brand/30 shrink-0">
<Loader2 className="w-2.5 h-2.5 mr-0.5 animate-spin" /> Updating
<Loader2 className="w-2.5 h-2.5 mr-0.5 animate-spin" /> {isReapply ? 'Reapplying' : 'Updating'}
</Badge>
);
if (status === 'completed') return (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-success-muted text-success border-success/30 shrink-0">
<Check className="w-2.5 h-2.5 mr-0.5" /> Updated
<Check className="w-2.5 h-2.5 mr-0.5" /> {isReapply ? 'Reapplied' : 'Updated'}
</Badge>
);
if (status === 'timeout' || status === 'failed') {
@@ -31,8 +33,8 @@ export function UpdateStatusBadge({ status, error, onRetry, onDismiss }: UpdateS
<button
onClick={(e) => { e.stopPropagation(); onRetry(); }}
className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Retry update"
aria-label="Retry update"
title={isReapply ? 'Retry reapply' : 'Retry update'}
aria-label={isReapply ? 'Retry reapply' : 'Retry update'}
>
<RotateCcw className="w-3 h-3" strokeWidth={1.5} />
</button>
@@ -46,4 +46,38 @@ describe('LocalUpdateConfirmDialog', () => {
expect(screen.getByText(/Pulls Sencho v0\.94\.0/i)).toBeInTheDocument();
expect(screen.queryByText(/rewrites it to/i)).not.toBeInTheDocument();
});
it('explains local reapply without a version change or image rewrite', () => {
render(
<LocalUpdateConfirmDialog
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
mode="reapply"
nodeType="local"
/>,
);
expect(screen.getByRole('heading', { name: /Reapply configuration/i })).toBeInTheDocument();
expect(screen.getByText(/current Compose configuration/i)).toBeInTheDocument();
expect(screen.getByText(/No newer Sencho version is selected/i)).toBeInTheDocument();
expect(screen.getByText(/will not rewrite the configured image reference/i)).toBeInTheDocument();
expect(screen.getByText(/briefly disconnect/i)).toBeInTheDocument();
});
it('explains remote reapply with REMOTE kicker and restart acknowledgement', () => {
render(
<LocalUpdateConfirmDialog
open
onOpenChange={vi.fn()}
onConfirm={vi.fn()}
mode="reapply"
nodeType="remote"
/>,
);
expect(screen.getByRole('heading', { name: /Reapply configuration/i })).toBeInTheDocument();
expect(screen.getByText(/Recreates this remote Sencho service/i)).toBeInTheDocument();
expect(screen.getByText(/No newer Sencho version is selected/i)).toBeInTheDocument();
expect(screen.getByText(/will not rewrite the configured image reference/i)).toBeInTheDocument();
expect(screen.getByText(/The node will restart/i)).toBeInTheDocument();
});
});
@@ -27,6 +27,7 @@ function baseProps(overrides: Partial<React.ComponentProps<typeof NodeUpdatesShe
isAdmin: true,
fetchUpdateStatus: vi.fn(async () => {}),
triggerNodeUpdate: vi.fn(),
triggerNodeReapply: vi.fn(),
retryNodeUpdate: vi.fn(),
dismissNodeUpdate: vi.fn(),
triggerUpdateAll: vi.fn(async () => {}),
@@ -363,4 +364,26 @@ describe('NodeUpdatesSheet', () => {
expect(screen.queryByLabelText('Retry update')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Dismiss')).not.toBeInTheDocument();
});
it('shows an icon-only Reapply control on an up-to-date Compose-managed node', () => {
const triggerNodeReapply = vi.fn();
const statuses: NodeUpdateStatus[] = [
{
nodeId: 1,
name: 'Local',
type: 'local',
version: '1.1.0',
latestVersion: '1.1.0',
updateAvailable: false,
updateStatus: null,
canReapplyCompose: true,
},
];
render(<NodeUpdatesSheet {...baseProps({ updateStatuses: statuses, triggerNodeReapply })} />);
expect(screen.queryByRole('button', { name: /Update$/ })).not.toBeInTheDocument();
const reapply = screen.getByRole('button', { name: 'Reapply configuration' });
expect(reapply).not.toHaveTextContent(/Reapply configuration/);
fireEvent.click(reapply);
expect(triggerNodeReapply).toHaveBeenCalledWith(1);
});
});
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useComposeReapplyAction } from '../useComposeReapplyAction';
const apiFetchMock = vi.fn();
const toastSuccess = vi.fn();
const toastError = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
success: (...a: unknown[]) => toastSuccess(...a),
error: (...a: unknown[]) => toastError(...a),
},
}));
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('useComposeReapplyAction', () => {
beforeEach(() => {
apiFetchMock.mockReset();
toastSuccess.mockReset();
toastError.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('openConfirm does not POST until confirmReapply', async () => {
const { result } = renderHook(() => useComposeReapplyAction());
act(() => {
result.current.openConfirm({ nodeId: 2, type: 'remote', name: 'Edge' });
});
expect(result.current.confirmTarget?.nodeId).toBe(2);
expect(apiFetchMock).not.toHaveBeenCalled();
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
await act(async () => { await result.current.confirmReapply(); });
expect(apiFetchMock).toHaveBeenCalledWith(
'/fleet/nodes/2/reapply-compose',
expect.objectContaining({ method: 'POST', localOnly: true }),
);
expect(toastSuccess).toHaveBeenCalled();
expect(result.current.confirmTarget).toBeNull();
});
it('cancelConfirm clears without POST', () => {
const { result } = renderHook(() => useComposeReapplyAction());
act(() => {
result.current.openConfirm({ nodeId: 1, type: 'local', name: 'Local' });
result.current.cancelConfirm();
});
expect(result.current.confirmTarget).toBeNull();
expect(apiFetchMock).not.toHaveBeenCalled();
});
it('starts local reconnect after a successful local POST', async () => {
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
new Response(JSON.stringify({ startedAt: 42 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)));
const { result } = renderHook(() => useComposeReapplyAction());
await act(async () => {
await result.current.runReapply({ nodeId: 1, type: 'local', name: 'Local' });
});
expect(result.current.reconnecting).toBe(true);
expect(result.current.preUpdateStartedAt).toBe(42);
});
it('clears reconnect when tracker reports local failure', async () => {
vi.useFakeTimers();
apiFetchMock
.mockResolvedValueOnce(okJson({ message: 'ok' }))
.mockResolvedValue(okJson({
nodes: [{
type: 'local',
updateStatus: 'failed',
operationKind: 'reapply_configuration',
error: 'Compose config invalid',
}],
}));
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
new Response(JSON.stringify({ startedAt: 1 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)));
const { result } = renderHook(() => useComposeReapplyAction());
await act(async () => {
await result.current.runReapply({ nodeId: 1, type: 'local', name: 'Local' });
});
expect(result.current.reconnecting).toBe(true);
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
expect(result.current.reconnecting).toBe(false);
expect(toastError).toHaveBeenCalledWith('Compose config invalid');
});
it('ignores a second confirm while dispatch is pending', async () => {
let release!: (value: Response) => void;
const held = new Promise<Response>((resolve) => { release = resolve; });
apiFetchMock.mockImplementation(() => held);
const { result } = renderHook(() => useComposeReapplyAction());
const first = act(async () => {
await result.current.runReapply({ nodeId: 2, type: 'remote', name: 'Edge' });
});
await act(async () => {
await result.current.runReapply({ nodeId: 2, type: 'remote', name: 'Edge' });
});
expect(apiFetchMock).toHaveBeenCalledTimes(1);
release(okJson({ message: 'ok' }));
await first;
});
});
@@ -173,6 +173,54 @@ describe('useFleetUpdateStatus', () => {
expect(apiFetchMock).not.toHaveBeenCalled();
});
it('triggerNodeReapply on a remote node opens confirm and does not POST until confirmed', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
apiFetchMock.mockClear();
await act(async () => { await result.current.triggerNodeReapply(2); });
expect(result.current.reapplyConfirm).toBe(2);
expect(apiFetchMock).not.toHaveBeenCalled();
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
await act(async () => { await result.current.confirmReapply(); });
expect(apiFetchMock).toHaveBeenCalledWith(
'/fleet/nodes/2/reapply-compose',
expect.objectContaining({ method: 'POST', localOnly: true }),
);
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Edge'));
expect(result.current.reapplyConfirm).toBeNull();
});
it('triggerNodeReapply on a local node opens confirm then starts local reconnect flow', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
apiFetchMock.mockClear();
await act(async () => { await result.current.triggerNodeReapply(1); });
expect(result.current.reapplyConfirm).toBe(1);
expect(apiFetchMock).not.toHaveBeenCalled();
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
)));
await act(async () => { await result.current.confirmReapply(); });
expect(apiFetchMock).toHaveBeenCalledWith(
'/fleet/nodes/1/reapply-compose',
expect.objectContaining({ method: 'POST', localOnly: true }),
);
expect(result.current.reconnecting).toBe(true);
expect(result.current.reconnectMode).toBe('reapply');
vi.unstubAllGlobals();
});
it('confirmLocalUpdate forwards targetVersion when latestVersion is valid', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
@@ -0,0 +1,140 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import type { NodeUpdateStatus } from '../types';
export type ComposeReapplyTarget = {
nodeId: number;
type: 'local' | 'remote';
name: string;
};
function parseReapplyError(err: Record<string, unknown>, fallback: string): string {
const nested = err?.data as Record<string, unknown> | undefined;
const message = err?.message ?? err?.error ?? nested?.error;
return typeof message === 'string' && message ? message : fallback;
}
async function readBootStartedAt(): Promise<number | null> {
try {
const healthRes = await fetch('/api/health');
if (!healthRes.ok) return null;
const data = await healthRes.json();
return typeof data?.startedAt === 'number' ? data.startedAt : null;
} catch {
return null;
}
}
export type UseComposeReapplyActionOptions = {
/** Refresh fleet statuses after a successful remote dispatch. */
onRemoteSuccess?: () => void;
};
/**
* Shared confirm dispatch reconnect workflow for compose reapply.
* Used by Fleet Node Updates and the Compose editor Save & Reapply path.
*/
export function useComposeReapplyAction(options: UseComposeReapplyActionOptions = {}) {
const { onRemoteSuccess } = options;
const onRemoteSuccessRef = useRef(onRemoteSuccess);
onRemoteSuccessRef.current = onRemoteSuccess;
const [confirmTarget, setConfirmTarget] = useState<ComposeReapplyTarget | null>(null);
const [busyNodeId, setBusyNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false);
const [preUpdateStartedAt, setPreUpdateStartedAt] = useState<number | null>(null);
const dispatchingRef = useRef(false);
const openConfirm = useCallback((target: ComposeReapplyTarget) => {
setConfirmTarget(target);
}, []);
const cancelConfirm = useCallback(() => {
setConfirmTarget(null);
}, []);
const runReapply = useCallback(async (target: ComposeReapplyTarget) => {
if (dispatchingRef.current) return;
dispatchingRef.current = true;
setBusyNodeId(target.nodeId);
const path = `/fleet/nodes/${target.nodeId}/reapply-compose`;
const init = { method: 'POST', localOnly: true } as const;
try {
if (target.type === 'local') {
const bootBefore = await readBootStartedAt();
const res = await apiFetch(path, init);
if (res.ok) {
setPreUpdateStartedAt(bootBefore);
setReconnecting(true);
} else {
const err = await res.json().catch(() => ({}));
toast.error(parseReapplyError(err, 'Failed to trigger local compose reapply.'));
}
return;
}
const res = await apiFetch(path, init);
if (res.ok) {
toast.success(`Compose reapply initiated on ${target.name}.`);
onRemoteSuccessRef.current?.();
} else {
const err = await res.json().catch(() => ({}));
toast.error(parseReapplyError(err, 'Failed to trigger compose reapply.'));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
dispatchingRef.current = false;
setBusyNodeId(null);
}
}, []);
const confirmReapply = useCallback(async () => {
const target = confirmTarget;
setConfirmTarget(null);
if (!target) return;
await runReapply(target);
}, [confirmTarget, runReapply]);
// While reconnecting, poll fleet update-status so a validation/helper failure
// before restart dismisses the overlay instead of waiting for the timeout.
useEffect(() => {
if (!reconnecting) return;
const poll = setInterval(async () => {
try {
const res = await apiFetch('/fleet/update-status', { localOnly: true });
if (!res.ok) return;
const data = await res.json();
const nodes: NodeUpdateStatus[] = data.nodes ?? [];
const local = nodes.find(s => s.type === 'local');
if (local && (local.updateStatus === 'failed' || local.updateStatus === 'timeout')) {
setReconnecting(false);
setPreUpdateStartedAt(null);
toast.error(local.error || 'Local compose reapply failed. The server did not restart.');
onRemoteSuccessRef.current?.();
}
} catch (error) {
console.warn('[ComposeReapply] Reconnect status poll failed:', error);
}
}, 3000);
return () => clearInterval(poll);
}, [reconnecting]);
return {
confirmTarget,
openConfirm,
cancelConfirm,
confirmReapply,
runReapply,
busyNodeId,
dispatching: busyNodeId !== null,
reconnecting,
preUpdateStartedAt,
reconnectMode: 'reapply' as const,
setReconnecting,
setPreUpdateStartedAt,
};
}
@@ -3,6 +3,7 @@ import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { isValidVersion } from '@/lib/version';
import { PINNED_UPDATE_BLOCKED_FALLBACK, type NodeUpdateStatus } from '../types';
import { useComposeReapplyAction } from './useComposeReapplyAction';
/** POST body for an update trigger: forward the target release when it is a
* valid version so the receiving node can repin a semver pin to it; omit
@@ -28,12 +29,25 @@ function toastIfUpdateBlocked(status: NodeUpdateStatus | undefined): boolean {
return true;
}
async function readBootStartedAt(): Promise<number | null> {
try {
const healthRes = await fetch('/api/health');
if (!healthRes.ok) return null;
const data = await healthRes.json();
return typeof data?.startedAt === 'number' ? data.startedAt : null;
} catch {
// Fall back to offline-then-online detection in the reconnect overlay.
return null;
}
}
export function useFleetUpdateStatus() {
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false);
const [preUpdateStartedAt, setPreUpdateStartedAt] = useState<number | null>(null);
const [localUpdateConfirm, setLocalUpdateConfirm] = useState<number | null>(null);
const [reconnectMode, setReconnectMode] = useState<'update' | 'reapply'>('update');
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [checkingUpdates, setCheckingUpdates] = useState(false);
@@ -65,6 +79,69 @@ export function useFleetUpdateStatus() {
}
}, []);
const reapplyAction = useComposeReapplyAction({ onRemoteSuccess: fetchUpdateStatus });
const {
openConfirm: openReapplyConfirm,
cancelConfirm: cancelReapplyConfirm,
confirmReapply,
confirmTarget: reapplyConfirmTarget,
busyNodeId: reapplyBusyNodeId,
reconnecting: reapplyReconnecting,
preUpdateStartedAt: reapplyPreStartedAt,
} = reapplyAction;
const postRemoteAction = useCallback(async (
nodeId: number,
path: string,
init: RequestInit & { localOnly: true },
successMsg: string,
failFallback: string,
) => {
setUpdatingNodeId(nodeId);
try {
const res = await apiFetch(path, init);
if (res.ok) {
toast.success(successMsg);
fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(parseUpdateError(err, failFallback));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
}
}, [fetchUpdateStatus]);
const startLocalRestart = useCallback(async (
nodeId: number,
path: string,
init: RequestInit & { localOnly: true },
mode: 'update' | 'reapply',
failFallback: string,
) => {
setUpdatingNodeId(nodeId);
try {
// Capture pre-restart boot timestamp so the overlay can detect a real
// restart vs a false "online" response from the still-running process.
const bootBefore = await readBootStartedAt();
const res = await apiFetch(path, init);
if (res.ok) {
setReconnectMode(mode);
setPreUpdateStartedAt(bootBefore);
setReconnecting(true);
} else {
const err = await res.json().catch(() => ({}));
toast.error(parseUpdateError(err, failFallback));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
}
}, []);
const triggerNodeUpdate = useCallback(async (nodeId: number) => {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
// A pin we cannot repin (digest/unknown) has no update action; the button
@@ -75,22 +152,14 @@ export function useFleetUpdateStatus() {
return;
}
setUpdatingNodeId(nodeId);
try {
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, updateRequestInit(status));
if (res.ok) {
toast.success(`Update initiated on ${status?.name ?? 'node'}.`);
fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(parseUpdateError(err, 'Failed to trigger update.'));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
}
}, [fetchUpdateStatus]);
await postRemoteAction(
nodeId,
`/fleet/nodes/${nodeId}/update`,
updateRequestInit(status),
`Update initiated on ${status?.name ?? 'node'}.`,
'Failed to trigger update.',
);
}, [postRemoteAction]);
const confirmLocalUpdate = useCallback(async () => {
const nodeId = localUpdateConfirm;
@@ -99,35 +168,27 @@ export function useFleetUpdateStatus() {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
if (toastIfUpdateBlocked(status)) return;
setUpdatingNodeId(nodeId);
try {
// Capture pre-update boot timestamp so the overlay can detect a real restart
// vs a false "online" response from the still-running old process mid-pull.
let bootBefore: number | null = null;
try {
const healthRes = await fetch('/api/health');
if (healthRes.ok) {
const data = await healthRes.json();
if (typeof data?.startedAt === 'number') bootBefore = data.startedAt;
}
} catch { /* fall back to offline-then-online detection */ }
await startLocalRestart(
nodeId,
`/fleet/nodes/${nodeId}/update`,
updateRequestInit(status),
'update',
'Failed to trigger local update.',
);
}, [localUpdateConfirm, startLocalRestart]);
const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, updateRequestInit(status));
if (res.ok) {
setPreUpdateStartedAt(bootBefore);
setReconnecting(true);
} else {
// A blocked pin returns 409 fast (before any 202), so the overlay
// never starts here; surface the reason through the toast path.
const err = await res.json().catch(() => ({}));
toast.error(parseUpdateError(err, 'Failed to trigger local update.'));
}
} catch (e: unknown) {
toast.error((e as Error)?.message || 'Something went wrong.');
} finally {
setUpdatingNodeId(null);
const triggerNodeReapply = useCallback((nodeId: number) => {
const status = updateStatusesRef.current.find(s => s.nodeId === nodeId);
if (!status) {
toast.error('Node status is unavailable. Recheck updates and try again.');
return;
}
}, [localUpdateConfirm]);
openReapplyConfirm({
nodeId,
type: status.type === 'local' ? 'local' : 'remote',
name: status.name,
});
}, [openReapplyConfirm]);
const triggerUpdateAll = useCallback(async () => {
try {
@@ -176,13 +237,7 @@ export function useFleetUpdateStatus() {
setCheckingUpdates(false);
}, [fetchUpdateStatus]);
// While the reconnect overlay is up, poll the local node's update status.
// A pull/patch failure leaves the old gateway alive (no restart), so the
// overlay's health poll would sit for the full 5-minute timeout. Detecting
// the resolved `failed` status here dismisses the overlay fast and surfaces
// the error, instead of leaving the operator on the spinner. A genuine
// restart makes this endpoint unreachable (caught, keeps polling) and the
// overlay's own health poll reloads the page on success.
// Version-update reconnect failure poll (reapply uses useComposeReapplyAction).
useEffect(() => {
if (!reconnecting) return;
const poll = setInterval(async () => {
@@ -199,27 +254,37 @@ export function useFleetUpdateStatus() {
toast.error(local.error || 'Local update failed. The server did not restart.');
}
} catch (error) {
// Expected while the process restarts; the overlay's health poll
// drives the reload on success.
console.warn('[Fleet] Reconnect status poll failed:', error);
}
}, 3000);
return () => clearInterval(poll);
}, [reconnecting]);
const reapplyConfirm = reapplyConfirmTarget?.nodeId ?? null;
const setReapplyConfirm = useCallback((nodeId: number | null) => {
if (nodeId === null) cancelReapplyConfirm();
}, [cancelReapplyConfirm]);
return {
updateStatuses,
updatingNodeId,
reconnecting,
preUpdateStartedAt,
updatingNodeId: updatingNodeId ?? reapplyBusyNodeId,
// Prefer reapply reconnect when active so overlay mode stays correct.
reconnecting: reconnecting || reapplyReconnecting,
preUpdateStartedAt: reapplyReconnecting ? reapplyPreStartedAt : preUpdateStartedAt,
reconnectMode: reapplyReconnecting ? 'reapply' as const : reconnectMode,
localUpdateConfirm,
reapplyConfirm,
reapplyConfirmTarget,
showUpdateModal,
checkingUpdates,
setShowUpdateModal,
setLocalUpdateConfirm,
setReapplyConfirm,
fetchUpdateStatus,
triggerNodeUpdate,
confirmLocalUpdate,
triggerNodeReapply,
confirmReapply,
triggerUpdateAll,
dismissNodeUpdate,
retryNodeUpdate,
@@ -61,6 +61,10 @@ export interface NodeUpdateStatus {
updateBlockedReason?: string | null;
/** Coarse image channel from meta/update-status. Hardened digests still POST. */
imageChannel?: 'community' | 'hardened' | 'unknown' | null;
/** Active fleet self-management operation, when a tracker is present. */
operationKind?: 'update' | 'reapply_configuration' | null;
/** True when this Compose-managed node can reapply its on-disk configuration. */
canReapplyCompose?: boolean;
}
export type ViewMode = 'grid' | 'topology';
@@ -0,0 +1,48 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ComposeDiffPreviewDialog } from '../ComposeDiffPreviewDialog';
import { resolveComposeDiffActionLabel } from '../resolveComposeDiffActionLabel';
vi.mock('@/lib/monacoLoader', () => ({
DiffEditor: () => <div data-testid="diff-editor" />,
}));
describe('resolveComposeDiffActionLabel', () => {
it('returns Save for save-only mode', () => {
expect(resolveComposeDiffActionLabel('save', false)).toBe('Save');
expect(resolveComposeDiffActionLabel('save', true)).toBe('Save');
});
it('returns Save when mode is undefined', () => {
expect(resolveComposeDiffActionLabel(undefined, true)).toBe('Save');
});
it('returns Save & deploy for ordinary save-and-deploy', () => {
expect(resolveComposeDiffActionLabel('save-and-deploy', false)).toBe('Save & deploy');
});
it('returns Save & reapply when self-stack reapply is eligible', () => {
expect(resolveComposeDiffActionLabel('save-and-deploy', true)).toBe('Save & reapply');
});
});
describe('ComposeDiffPreviewDialog', () => {
it('renders the Save & reapply confirm CTA', () => {
render(
<ComposeDiffPreviewDialog
open
onOpenChange={vi.fn()}
stackName="sencho"
fileName="docker-compose.yml"
language="yaml"
original="a"
modified="b"
actionLabel="Save & reapply"
confirming={false}
isDarkMode={false}
onConfirm={vi.fn()}
/>,
);
expect(screen.getByRole('button', { name: 'Save & reapply' })).toBeInTheDocument();
});
});
@@ -0,0 +1,11 @@
export type ComposeDiffActionLabel = 'Save' | 'Save & deploy' | 'Save & reapply';
/** Maps diff preview mode + self-stack eligibility to the confirm CTA label. */
export function resolveComposeDiffActionLabel(
mode: 'save' | 'save-and-deploy' | undefined,
canSaveAndReapply: boolean,
): ComposeDiffActionLabel {
if (mode !== 'save-and-deploy') return 'Save';
if (canSaveAndReapply) return 'Save & reapply';
return 'Save & deploy';
}
@@ -40,8 +40,11 @@ export function SelfStackProtectedDialog({
}}
>
<p className="text-sm text-stat-subtitle">
This stack is the running Sencho instance. Use Fleet -&gt; Node Update to update Sencho.
To manage it as a normal stack, move Sencho&apos;s compose project outside COMPOSE_DIR.
This stack is the running Sencho instance. Destructive lifecycle actions
stay protected. Eligible admins can use Save &amp; Reapply in the Compose
editor, or Fleet -&gt; Node Updates, to recreate Sencho from its current
Compose configuration. To manage it as a normal stack, move Sencho&apos;s
compose project outside COMPOSE_DIR.
</p>
</ConfirmModal>
);