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
+35 -2
View File
@@ -11,6 +11,11 @@
export type StackOpAction = 'deploy' | 'down' | 'restart' | 'stop' | 'start' | 'update';
export interface StackOpRecordMeta {
targetScope?: 'stack' | 'service';
serviceName?: string | null;
}
interface StackOpStats {
count: number;
successCount: number;
@@ -22,6 +27,10 @@ interface StackOpStats {
* computed from this window on demand.
*/
recentSamples: number[];
/** Most recent target scope recorded for this bucket (additive; stack default). */
lastTargetScope: 'stack' | 'service';
/** Most recent service name when lastTargetScope is service; otherwise null. */
lastServiceName: string | null;
}
export interface StackOpSnapshotEntry {
@@ -33,6 +42,8 @@ export interface StackOpSnapshotEntry {
avgMs: number;
p50Ms: number;
p95Ms: number;
targetScope: 'stack' | 'service';
serviceName: string | null;
}
const MAX_SAMPLES = 1000;
@@ -66,12 +77,26 @@ export class StackOpMetricsService {
* Record one completed op. Call from the route layer after the lifecycle
* call resolves or rejects; `ok=false` for the rejection path.
*/
public record(nodeId: number, action: StackOpAction, durationMs: number, ok: boolean): void {
public record(
nodeId: number,
action: StackOpAction,
durationMs: number,
ok: boolean,
meta?: StackOpRecordMeta,
): void {
if (!Number.isFinite(durationMs) || durationMs < 0) return;
const k = this.key(nodeId, action);
let s = this.stats.get(k);
if (!s) {
s = { count: 0, successCount: 0, errorCount: 0, totalMs: 0, recentSamples: [] };
s = {
count: 0,
successCount: 0,
errorCount: 0,
totalMs: 0,
recentSamples: [],
lastTargetScope: 'stack',
lastServiceName: null,
};
this.stats.set(k, s);
}
s.count += 1;
@@ -84,6 +109,12 @@ export class StackOpMetricsService {
// and this path is once-per-stack-op (low cadence).
s.recentSamples.shift();
}
if (meta?.targetScope) s.lastTargetScope = meta.targetScope;
if (meta?.targetScope === 'service') {
s.lastServiceName = meta.serviceName ?? null;
} else if (meta?.targetScope === 'stack') {
s.lastServiceName = null;
}
}
public snapshot(): StackOpSnapshotEntry[] {
@@ -102,6 +133,8 @@ export class StackOpMetricsService {
avgMs: s.count === 0 ? 0 : Math.round(s.totalMs / s.count),
p50Ms: percentile(sorted, 0.5),
p95Ms: percentile(sorted, 0.95),
targetScope: s.lastTargetScope,
serviceName: s.lastServiceName,
});
}
// Stable ordering: nodeId asc, then action asc.