mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 06:23:18 +00:00
63213c0960
* 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.
149 lines
4.5 KiB
TypeScript
149 lines
4.5 KiB
TypeScript
/**
|
|
* In-memory counters + latency samples for stack lifecycle operations.
|
|
* Internal-only - never exported to any external system. Surfaced to
|
|
* admins via GET /api/stack-metrics so operators have a way to debug
|
|
* "why is this remote node slow today?" without scrolling logs.
|
|
*
|
|
* State is process-local on purpose. A Sencho restart clears all metrics;
|
|
* the alternative (persisting to SQLite) would add write amplification
|
|
* to every lifecycle op for very little operator value.
|
|
*/
|
|
|
|
export type StackOpAction = 'deploy' | 'down' | 'restart' | 'stop' | 'start' | 'update';
|
|
|
|
export interface StackOpRecordMeta {
|
|
targetScope?: 'stack' | 'service';
|
|
serviceName?: string | null;
|
|
}
|
|
|
|
interface StackOpStats {
|
|
count: number;
|
|
successCount: number;
|
|
errorCount: number;
|
|
totalMs: number;
|
|
/**
|
|
* Ring buffer of recent latencies (newest at the end). Capped at
|
|
* MAX_SAMPLES to bound memory regardless of throughput. p50/p95 are
|
|
* 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 {
|
|
nodeId: number;
|
|
action: StackOpAction;
|
|
count: number;
|
|
successCount: number;
|
|
errorCount: number;
|
|
avgMs: number;
|
|
p50Ms: number;
|
|
p95Ms: number;
|
|
targetScope: 'stack' | 'service';
|
|
serviceName: string | null;
|
|
}
|
|
|
|
const MAX_SAMPLES = 1000;
|
|
|
|
function percentile(sorted: readonly number[], p: number): number {
|
|
if (sorted.length === 0) return 0;
|
|
const idx = Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * p));
|
|
return sorted[idx];
|
|
}
|
|
|
|
export class StackOpMetricsService {
|
|
private static instance: StackOpMetricsService;
|
|
private readonly stats = new Map<string, StackOpStats>();
|
|
|
|
public static getInstance(): StackOpMetricsService {
|
|
if (!StackOpMetricsService.instance) {
|
|
StackOpMetricsService.instance = new StackOpMetricsService();
|
|
}
|
|
return StackOpMetricsService.instance;
|
|
}
|
|
|
|
public static resetForTests(): void {
|
|
this.instance = new StackOpMetricsService();
|
|
}
|
|
|
|
private key(nodeId: number, action: StackOpAction): string {
|
|
return `${nodeId}:${action}`;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
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: [],
|
|
lastTargetScope: 'stack',
|
|
lastServiceName: null,
|
|
};
|
|
this.stats.set(k, s);
|
|
}
|
|
s.count += 1;
|
|
if (ok) s.successCount += 1;
|
|
else s.errorCount += 1;
|
|
s.totalMs += durationMs;
|
|
s.recentSamples.push(durationMs);
|
|
if (s.recentSamples.length > MAX_SAMPLES) {
|
|
// Drop oldest. Array.shift is O(n) but n is bounded to MAX_SAMPLES
|
|
// 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[] {
|
|
const out: StackOpSnapshotEntry[] = [];
|
|
for (const [key, s] of this.stats.entries()) {
|
|
const [nodeIdStr, action] = key.split(':');
|
|
const nodeId = Number(nodeIdStr);
|
|
if (!Number.isFinite(nodeId)) continue;
|
|
const sorted = [...s.recentSamples].sort((a, b) => a - b);
|
|
out.push({
|
|
nodeId,
|
|
action: action as StackOpAction,
|
|
count: s.count,
|
|
successCount: s.successCount,
|
|
errorCount: s.errorCount,
|
|
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.
|
|
out.sort((a, b) => a.nodeId - b.nodeId || a.action.localeCompare(b.action));
|
|
return out;
|
|
}
|
|
|
|
public size(): number {
|
|
return this.stats.size;
|
|
}
|
|
}
|