feat: add service-scoped Compose update and restore (#1648)

* feat: add service-scoped Compose update and restore

Allow updating or rebuilding one declared Compose service on multi-service
stacks without recreating siblings, with recovery snapshots, health-gate
observation, and prune holds for rollback images. Full-stack update paths
and single-service UX stay unchanged.

* fix: sanitize service-scoped update log messages for CodeQL

* fix: address service-scoped update audit findings B-01 through B-07

* fix: complete service-scoped update audit metadata and surfaces

* test: wrap Updates readiness tests for deploy-feedback context

* fix: keep service recovery reachable without Deploy Progress

Make failed service-gate recovery discoverable when Deploy Progress is
disabled or dismissed, suppress stale image-scan notification side
effects, normalize ComposeService line endings, and add focused
regression coverage.

* fix: resurface ContainersHealth density and expand on multi-service stacks

Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
This commit is contained in:
Anso
2026-07-19 02:42:29 -04:00
committed by GitHub
parent 31d4e4669b
commit 63213c0960
89 changed files with 7608 additions and 331 deletions
+187 -12
View File
@@ -3,6 +3,8 @@ import { apiFetch } from '../lib/api';
import { type ParsedLogRow, parseLogChunk } from '../components/log-rendering/composeLogParser';
import { useDeployFeedbackEnabled } from '../hooks/use-deploy-feedback-enabled';
import { readDeployFeedbackStyle } from '../hooks/use-deploy-feedback-style';
import { toast } from '../components/ui/toast-store';
import { fetchActiveServiceRecovery, requestServiceRestore } from '../lib/serviceUpdate';
export type ActionVerb = 'deploy' | 'update' | 'down' | 'restart' | 'stop' | 'install' | 'scan';
@@ -59,6 +61,8 @@ interface RunResult {
* node never returns one, so no gate UI appears.
*/
healthGateId?: string | null;
/** Service-scoped recovery snapshot id, when one was captured. */
recoveryId?: string | null;
}
/** Post-update health gate state for the current deploy session. */
@@ -74,6 +78,14 @@ export interface HealthGateUiState {
reason: string | null;
windowSeconds: number | null;
startedAt: number | null;
/** 'service' for a service-scoped update/restore gate; 'stack' otherwise. Absent on older gates. */
targetScope?: 'stack' | 'service';
/** Set only for a service-scoped gate; null/absent for a full-stack gate. */
serviceName?: string | null;
/** Which side of a service-scoped gate failed; null/absent for full-stack gates and non-failures. */
failureSource?: 'primary' | 'collateral' | null;
/** Recovery snapshot to offer restore after a failed service gate. */
recoveryId?: string | null;
}
const GATE_POLL_INTERVAL_MS = 4_000;
@@ -84,6 +96,8 @@ export interface RunWithLogParams {
action: ActionVerb;
/** Node the operation runs on (null = local), for node-scoped surfaces. */
nodeId: number | null;
/** When set, this is a service-scoped update/restore; gate polling uses targetScope=service. */
serviceName?: string;
}
interface DeployFeedbackContextValue {
@@ -164,9 +178,22 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
const [logRows, setLogRows] = useState<ParsedLogRow[]>([]);
const [lastOutputAt, setLastOutputAt] = useState<number>(0);
// Poll timer for the current session's health gate; cleared on panel close
// and whenever a new session starts.
// Poll timer for the current session's health gate; cleared when a watch
// ends or a newer session starts. Closing the panel no longer stops a
// service-scoped watch: recovery must stay discoverable after dismiss.
const gatePollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const healthGateRef = useRef<HealthGateUiState | null>(null);
const panelOpenRef = useRef(false);
/** True when gate polling runs without the Deploy Progress panel (disabled setting or after dismiss). */
const silentGateRef = useRef(false);
const offerRestoreToastRef = useRef<(gate: HealthGateUiState) => void>(() => {});
useEffect(() => {
healthGateRef.current = healthGate;
}, [healthGate]);
useEffect(() => {
panelOpenRef.current = panelState.isOpen;
}, [panelState.isOpen]);
const stopGatePolling = useCallback(() => {
if (gatePollRef.current !== null) {
@@ -245,16 +272,31 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
}, []);
const onPanelClose = useCallback(() => {
sessionIdRef.current += 1;
const gate = healthGateRef.current;
const keepServiceWatch = !!gate
&& gate.targetScope === 'service'
&& (gate.status === 'observing' || gate.status === 'failed');
settleStartRef.current = null;
streamReadyRef.current = false;
// The gate keeps observing server-side; only this session's poll stops.
stopGatePolling();
setHealthGate(null);
setPanelState(DEFAULT_PANEL_STATE);
setMinimized(false);
setBannerActive(false);
setLogRows([]);
if (keepServiceWatch) {
// Keep polling (or a failed gate with recovery) so Restore stays reachable.
silentGateRef.current = true;
if (gate.status === 'failed') {
offerRestoreToastRef.current(gate);
}
return;
}
sessionIdRef.current += 1;
silentGateRef.current = false;
stopGatePolling();
setHealthGate(null);
}, [stopGatePolling]);
// Poll the by-id gate endpoint until a terminal status. The id-scoped read
@@ -262,9 +304,24 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
// instead of this session showing a newer run's result. Mirrors the backend
// gate's own degradation: repeated failures (or an absurdly long poll)
// resolve to an honest client-side unknown rather than observing forever.
const startGatePolling = useCallback((stackName: string, nodeId: number | null, gateId: string, trigger: 'update' | 'deploy', mySession: number) => {
const startGatePolling = useCallback((
stackName: string,
nodeId: number | null,
gateId: string,
trigger: 'update' | 'deploy',
mySession: number,
options?: { serviceName?: string; recoveryId?: string | null; silent?: boolean },
) => {
stopGatePolling();
setHealthGate({ stackName, nodeId, gateId, trigger, status: 'observing', reason: null, windowSeconds: null, startedAt: null });
silentGateRef.current = options?.silent === true;
const targetScope = options?.serviceName ? 'service' : 'stack';
setHealthGate({
stackName, nodeId, gateId, trigger, status: 'observing', reason: null, windowSeconds: null, startedAt: null,
targetScope,
serviceName: options?.serviceName ?? null,
failureSource: null,
recoveryId: options?.recoveryId ?? null,
});
let strikes = 0;
// Single-flight: skip a tick while one request is outstanding so two
// overlapping responses cannot land out of order.
@@ -299,6 +356,9 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
reason: string | null;
windowSeconds: number | null;
startedAt: number | null;
targetScope?: 'stack' | 'service';
serviceName?: string | null;
failureSource?: 'primary' | 'collateral' | null;
}
: null;
if (sessionIdRef.current !== mySession || settled) return;
@@ -311,10 +371,33 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
return;
}
strikes = 0;
setHealthGate({ stackName, nodeId, gateId, trigger, status: report.status, reason: report.reason, windowSeconds: report.windowSeconds, startedAt: report.startedAt });
if (report.status !== 'observing') {
const status: HealthGateUiState['status'] =
report.status === 'observing' || report.status === 'passed'
|| report.status === 'failed' || report.status === 'unknown'
? report.status
: 'unknown';
const nextGate: HealthGateUiState = {
stackName, nodeId, gateId, trigger,
status,
reason: report.reason,
windowSeconds: report.windowSeconds, startedAt: report.startedAt,
targetScope: report.targetScope ?? targetScope,
serviceName: report.serviceName ?? options?.serviceName ?? null,
failureSource: report.failureSource ?? null,
recoveryId: healthGateRef.current?.recoveryId ?? options?.recoveryId ?? null,
};
setHealthGate(nextGate);
if (status !== 'observing') {
settled = true;
stopGatePolling();
if (
status === 'failed'
&& nextGate.targetScope === 'service'
&& nextGate.serviceName
&& (silentGateRef.current || !panelOpenRef.current)
) {
offerRestoreToastRef.current(nextGate);
}
}
} catch (e) {
strikes += 1;
@@ -328,6 +411,76 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
gatePollRef.current = setInterval(() => { void tick(); }, GATE_POLL_INTERVAL_MS);
}, [stopGatePolling]);
// Keep the toast helper current without re-creating startGatePolling on every render.
useEffect(() => {
offerRestoreToastRef.current = (gate: HealthGateUiState) => {
const serviceName = gate.serviceName;
if (!serviceName) return;
toast.error(
`Health gate failed for service "${serviceName}"${gate.reason ? `: ${gate.reason}` : ''}.`,
{
duration: 120_000,
action: {
label: 'Restore',
onClick: () => {
void (async () => {
let recoveryId = gate.recoveryId ?? null;
if (!recoveryId) {
const lookup = await fetchActiveServiceRecovery({
nodeId: gate.nodeId,
stackName: gate.stackName,
serviceName,
});
if (!lookup.ok) {
toast.error(lookup.error);
return;
}
recoveryId = lookup.recovery?.id ?? null;
}
if (!recoveryId) {
toast.error(`No recovery snapshot is available for "${serviceName}".`);
return;
}
const loadingId = toast.loading(`Restoring "${serviceName}"...`);
try {
const result = await requestServiceRestore({
nodeId: gate.nodeId,
stackName: gate.stackName,
serviceName,
recoveryId,
});
toast.dismiss(loadingId);
if (!result.ok) {
toast.error(result.error);
return;
}
if (result.healthGateId && result.observing) {
toast.info(`Service "${serviceName}" restored. Verifying health...`);
sessionIdRef.current += 1;
startGatePolling(
gate.stackName,
gate.nodeId,
result.healthGateId,
'update',
sessionIdRef.current,
{ serviceName, recoveryId: result.recoveryId, silent: true },
);
} else {
toast.success(`Service "${serviceName}" restored successfully`);
setHealthGate(null);
}
} catch (error) {
toast.dismiss(loadingId);
toast.error(error instanceof Error ? error.message : `Failed to restore "${serviceName}"`);
}
})();
},
},
},
);
};
}, [startGatePolling]);
const runWithLog = useCallback(
async (
params: RunWithLogParams,
@@ -344,7 +497,26 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
const deploySessionId = Array.from(idBytes, (b) => b.toString(16).padStart(2, '0')).join('');
if (!isEnabled) {
return run(Promise.resolve(), deploySessionId);
const result = await run(Promise.resolve(), deploySessionId);
// Service-scoped updates still need gate polling and Restore discovery
// when Deploy Progress is off; open no panel, watch silently.
if (
result.ok
&& result.healthGateId
&& params.serviceName
&& (params.action === 'update' || params.action === 'deploy')
) {
sessionIdRef.current += 1;
startGatePolling(
params.stackName,
params.nodeId,
result.healthGateId,
params.action === 'deploy' ? 'deploy' : 'update',
sessionIdRef.current,
{ serviceName: params.serviceName, recoveryId: result.recoveryId, silent: true },
);
}
return result;
}
// Read the persisted style synchronously so this deploy uses the style in
@@ -419,7 +591,10 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
errorMessage: result.ok ? undefined : result.errorMessage,
}));
if (result.ok && result.healthGateId && (params.action === 'update' || params.action === 'deploy')) {
startGatePolling(params.stackName, params.nodeId, result.healthGateId, params.action, mySession);
startGatePolling(params.stackName, params.nodeId, result.healthGateId, params.action, mySession, {
serviceName: params.serviceName,
recoveryId: result.recoveryId,
});
}
}
@@ -21,9 +21,11 @@ describe('DeployFeedbackContext', () => {
beforeEach(() => {
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'true');
vi.mocked(apiFetch).mockReset();
vi.useRealTimers();
});
afterEach(() => {
localStorage.clear();
vi.useRealTimers();
});
it('releases the deploy when the progress stream fails before connecting', async () => {
@@ -149,6 +151,90 @@ describe('DeployFeedbackContext', () => {
expect(result.current.panelState.isOpen).toBe(false);
});
it('silently polls a service health gate when Deploy Progress is disabled', async () => {
vi.useFakeTimers();
try {
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'false');
vi.mocked(apiFetch).mockImplementation(async (url: string) => {
if (String(url).includes('/health-gate')) {
return new Response(JSON.stringify({
id: 'gate-svc', status: 'observing', reason: null, windowSeconds: 90, startedAt: Date.now(),
targetScope: 'service', serviceName: 'api', failureSource: null,
}), { status: 200 });
}
return new Response('{}', { status: 200 });
});
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
await act(async () => {
await result.current.runWithLog(
{ stackName: 'web', action: 'update', nodeId: null, serviceName: 'api' },
async (started) => {
await started;
return { ok: true, healthGateId: 'gate-svc', recoveryId: 'rec-1' };
},
);
});
expect(result.current.panelState.isOpen).toBe(false);
expect(result.current.healthGate).toMatchObject({
gateId: 'gate-svc', serviceName: 'api', recoveryId: 'rec-1', status: 'observing',
});
await act(async () => { await vi.advanceTimersByTimeAsync(4_000); });
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining('/stacks/web/health-gate?gateId=gate-svc'),
expect.anything(),
);
} finally {
vi.useRealTimers();
}
});
it('keeps service gate recovery after the panel is closed', async () => {
vi.useFakeTimers();
try {
vi.mocked(apiFetch).mockImplementation(async (url: string) => {
if (String(url).includes('/health-gate')) {
return new Response(JSON.stringify({
id: 'gate-svc', status: 'observing', reason: null, windowSeconds: 90, startedAt: Date.now(),
targetScope: 'service', serviceName: 'api', failureSource: null,
}), { status: 200 });
}
return new Response('{}', { status: 200 });
});
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
let outer: Promise<unknown> | undefined;
await act(async () => {
outer = result.current.runWithLog(
{ stackName: 'web', action: 'update', nodeId: null, serviceName: 'api' },
async (started) => {
await started;
return { ok: true, healthGateId: 'gate-svc', recoveryId: 'rec-keep' };
},
);
await Promise.resolve();
});
// onTerminalReady schedules the deploy gate release after 50ms.
await act(async () => {
result.current.onTerminalReady();
await vi.advanceTimersByTimeAsync(60);
await outer;
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.healthGate?.recoveryId).toBe('rec-keep');
act(() => { result.current.onPanelClose(); });
expect(result.current.panelState.isOpen).toBe(false);
expect(result.current.healthGate).toMatchObject({
gateId: 'gate-svc', recoveryId: 'rec-keep', status: 'observing',
});
} finally {
vi.useRealTimers();
}
});
it('caps log rows and marks the truncation point', () => {
const { result } = renderHook(() => useDeployFeedback(), { wrapper });