mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +00:00
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:
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user