mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-29 03:36:55 +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:
@@ -35,11 +35,58 @@ export interface GlobalSetting {
|
||||
*/
|
||||
export type StackCheckStatus = 'ok' | 'partial' | 'failed';
|
||||
|
||||
/**
|
||||
* Per-service check outcome persisted in stack_update_status.services_json.
|
||||
* Distinct from StackCheckStatus: a service with no checkable image ref is
|
||||
* `not_checkable`, which never counts as a check failure at the aggregate
|
||||
* (stack-level) status.
|
||||
*/
|
||||
export type ServiceCheckStatus = 'ok' | 'partial' | 'failed' | 'not_checkable';
|
||||
|
||||
export interface StackServiceStatus {
|
||||
service: string;
|
||||
image: string | null;
|
||||
runtimeImages?: string[];
|
||||
hasUpdate: boolean;
|
||||
checkStatus: ServiceCheckStatus;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface StackUpdateDetail {
|
||||
hasUpdate: boolean;
|
||||
checkStatus: StackCheckStatus;
|
||||
lastError: string | null;
|
||||
checkedAt: number;
|
||||
/** Per-service breakdown; omitted when the stack has no persisted per-service data (no effective model available yet, or corrupt JSON). */
|
||||
services?: StackServiceStatus[];
|
||||
}
|
||||
|
||||
const SERVICES_JSON_VERSION = 1;
|
||||
|
||||
function isStackServiceStatus(value: unknown): value is StackServiceStatus {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return typeof v.service === 'string'
|
||||
&& (v.image === null || typeof v.image === 'string')
|
||||
&& typeof v.hasUpdate === 'boolean'
|
||||
&& (v.checkStatus === 'ok' || v.checkStatus === 'partial' || v.checkStatus === 'failed' || v.checkStatus === 'not_checkable')
|
||||
&& (v.lastError === null || typeof v.lastError === 'string');
|
||||
}
|
||||
|
||||
/** Parses stack_update_status.services_json safely; a missing, corrupt, or version-mismatched value yields an empty list rather than throwing. */
|
||||
function parseServicesJson(raw: string | null | undefined): StackServiceStatus[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { version?: unknown; services?: unknown };
|
||||
if (parsed?.version !== SERVICES_JSON_VERSION || !Array.isArray(parsed.services)) return [];
|
||||
return parsed.services.filter(isStackServiceStatus);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyServicesJson(services: StackServiceStatus[], generation: number): string {
|
||||
return JSON.stringify({ version: SERVICES_JSON_VERSION, generation, services });
|
||||
}
|
||||
|
||||
export interface StackAlert {
|
||||
@@ -146,7 +193,7 @@ export interface HealthGateRunRow {
|
||||
node_id: number;
|
||||
stack_name: string;
|
||||
/** Named trigger_action because TRIGGER is reserved in SQLite. */
|
||||
trigger_action: 'update' | 'deploy';
|
||||
trigger_action: 'update' | 'deploy' | 'service_update' | 'service_restore';
|
||||
status: 'observing' | 'passed' | 'failed' | 'unknown';
|
||||
reason: string | null;
|
||||
window_seconds: number;
|
||||
@@ -155,6 +202,30 @@ export interface HealthGateRunRow {
|
||||
started_at: number;
|
||||
ended_at: number | null;
|
||||
created_by: string | null;
|
||||
/** Additive; legacy rows default to stack. */
|
||||
target_scope: 'stack' | 'service';
|
||||
service_name: string | null;
|
||||
failure_source: 'primary' | 'collateral' | null;
|
||||
}
|
||||
|
||||
/** Pre-update image snapshot enabling a manual per-service restore after a service-scoped update. */
|
||||
export interface ServiceUpdateRecoveryRow {
|
||||
id: string;
|
||||
node_id: number;
|
||||
stack_name: string;
|
||||
service_name: string;
|
||||
/** JSON array of pre-update replica image snapshots (imageId + repoDigest when known). */
|
||||
replicas_json: string;
|
||||
majority_image_id: string;
|
||||
/** Audit/UI only: the tag the majority image is retagged onto during restore, never a policy scan target. */
|
||||
declared_image_ref: string;
|
||||
weak_floating_tag: number;
|
||||
health_gate_id: string | null;
|
||||
status: 'active' | 'restoring' | 'consumed' | 'expired' | 'invalidated';
|
||||
expires_at: number;
|
||||
claim_expires_at: number | null;
|
||||
created_at: number;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
/** One finding within a stored preflight run. Never carries an environment value. */
|
||||
@@ -1005,6 +1076,7 @@ export class DatabaseService {
|
||||
has_update INTEGER DEFAULT 0,
|
||||
check_status TEXT NOT NULL DEFAULT 'ok',
|
||||
last_error TEXT,
|
||||
services_json TEXT,
|
||||
checked_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (node_id, stack_name)
|
||||
);
|
||||
@@ -1525,18 +1597,44 @@ export class DatabaseService {
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
trigger_action TEXT NOT NULL CHECK (trigger_action IN ('update','deploy')),
|
||||
trigger_action TEXT NOT NULL CHECK (trigger_action IN ('update','deploy','service_update','service_restore')),
|
||||
status TEXT NOT NULL CHECK (status IN ('observing','passed','failed','unknown')),
|
||||
reason TEXT,
|
||||
window_seconds INTEGER NOT NULL,
|
||||
containers_json TEXT NOT NULL DEFAULT '[]',
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
created_by TEXT
|
||||
created_by TEXT,
|
||||
target_scope TEXT NOT NULL DEFAULT 'stack' CHECK (target_scope IN ('stack','service')),
|
||||
service_name TEXT,
|
||||
failure_source TEXT CHECK (failure_source IS NULL OR failure_source IN ('primary','collateral'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_health_gate_runs_node_stack
|
||||
ON health_gate_runs(node_id, stack_name, started_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS service_update_recovery (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
service_name TEXT NOT NULL,
|
||||
replicas_json TEXT NOT NULL,
|
||||
majority_image_id TEXT NOT NULL,
|
||||
declared_image_ref TEXT NOT NULL,
|
||||
weak_floating_tag INTEGER NOT NULL DEFAULT 0,
|
||||
health_gate_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','restoring','consumed','expired','invalidated')),
|
||||
expires_at INTEGER NOT NULL,
|
||||
claim_expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_update_recovery_node_stack_service
|
||||
ON service_update_recovery(node_id, stack_name, service_name, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_update_recovery_status_expires
|
||||
ON service_update_recovery(status, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_update_recovery_status_claim_expires
|
||||
ON service_update_recovery(status, claim_expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS secrets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@@ -1664,6 +1762,11 @@ export class DatabaseService {
|
||||
// failed check is no longer indistinguishable from "up to date".
|
||||
maybeAddCol('stack_update_status', 'check_status', "TEXT NOT NULL DEFAULT 'ok'");
|
||||
maybeAddCol('stack_update_status', 'last_error', 'TEXT');
|
||||
// Per-service image-update snapshot. Must run AFTER the composite-PK
|
||||
// recreate above so it is never silently dropped on old installs.
|
||||
maybeAddCol('stack_update_status', 'services_json', 'TEXT');
|
||||
|
||||
this.migrateHealthGateTargetSchema();
|
||||
|
||||
// Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written)
|
||||
const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key'];
|
||||
@@ -1791,6 +1894,66 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild health_gate_runs when the installed CHECK still only allows
|
||||
* update|deploy or target/failure columns are missing. Idempotent and
|
||||
* restart-safe: drops a stale temporary table, then rebuilds in one
|
||||
* better-sqlite3 transaction so an interrupted startup cannot leave
|
||||
* CREATE TABLE health_gate_runs_new blocking the next boot.
|
||||
*/
|
||||
private migrateHealthGateTargetSchema(): void {
|
||||
const tableSql = (this.db.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'health_gate_runs'"
|
||||
).get() as { sql: string } | undefined)?.sql ?? '';
|
||||
const cols = this.db.pragma('table_info(health_gate_runs)') as Array<{ name: string }>;
|
||||
const colNames = new Set(cols.map(c => c.name));
|
||||
const hasTarget = colNames.has('target_scope');
|
||||
const hasFailure = colNames.has('failure_source');
|
||||
const hasWideTrigger = tableSql.includes('service_update') && tableSql.includes('service_restore');
|
||||
if (hasTarget && hasFailure && hasWideTrigger) return;
|
||||
|
||||
// A previous crash between CREATE and RENAME leaves this temp table behind.
|
||||
this.db.exec('DROP TABLE IF EXISTS health_gate_runs_new');
|
||||
|
||||
const targetExpr = hasTarget ? 'target_scope' : "'stack'";
|
||||
const serviceExpr = colNames.has('service_name') ? 'service_name' : 'NULL';
|
||||
const failureExpr = hasFailure ? 'failure_source' : 'NULL';
|
||||
|
||||
this.db.transaction(() => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE health_gate_runs_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
node_id INTEGER NOT NULL,
|
||||
stack_name TEXT NOT NULL,
|
||||
trigger_action TEXT NOT NULL CHECK (trigger_action IN ('update','deploy','service_update','service_restore')),
|
||||
status TEXT NOT NULL CHECK (status IN ('observing','passed','failed','unknown')),
|
||||
reason TEXT,
|
||||
window_seconds INTEGER NOT NULL,
|
||||
containers_json TEXT NOT NULL DEFAULT '[]',
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER,
|
||||
created_by TEXT,
|
||||
target_scope TEXT NOT NULL DEFAULT 'stack' CHECK (target_scope IN ('stack','service')),
|
||||
service_name TEXT,
|
||||
failure_source TEXT CHECK (failure_source IS NULL OR failure_source IN ('primary','collateral'))
|
||||
);
|
||||
INSERT INTO health_gate_runs_new (
|
||||
id, node_id, stack_name, trigger_action, status, reason, window_seconds,
|
||||
containers_json, started_at, ended_at, created_by, target_scope, service_name, failure_source
|
||||
)
|
||||
SELECT
|
||||
id, node_id, stack_name, trigger_action, status, reason, window_seconds,
|
||||
containers_json, started_at, ended_at, created_by,
|
||||
${targetExpr}, ${serviceExpr}, ${failureExpr}
|
||||
FROM health_gate_runs;
|
||||
DROP TABLE health_gate_runs;
|
||||
ALTER TABLE health_gate_runs_new RENAME TO health_gate_runs;
|
||||
CREATE INDEX IF NOT EXISTS idx_health_gate_runs_node_stack
|
||||
ON health_gate_runs(node_id, stack_name, started_at);
|
||||
`);
|
||||
})();
|
||||
}
|
||||
|
||||
private migrateEncryptNodeTokens(): void {
|
||||
const crypto = CryptoService.getInstance();
|
||||
const rows = this.db.prepare("SELECT id, api_token FROM nodes WHERE api_token != '' AND api_token IS NOT NULL").all() as Array<{ id: number; api_token: string }>;
|
||||
@@ -3128,11 +3291,13 @@ export class DatabaseService {
|
||||
public insertHealthGateRun(run: HealthGateRunRow): void {
|
||||
this.db.prepare(
|
||||
`INSERT INTO health_gate_runs
|
||||
(id, node_id, stack_name, trigger_action, status, reason, window_seconds, containers_json, started_at, ended_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
(id, node_id, stack_name, trigger_action, status, reason, window_seconds, containers_json,
|
||||
started_at, ended_at, created_by, target_scope, service_name, failure_source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
run.id, run.node_id, run.stack_name, run.trigger_action, run.status, run.reason,
|
||||
run.window_seconds, run.containers_json, run.started_at, run.ended_at, run.created_by,
|
||||
run.target_scope, run.service_name, run.failure_source,
|
||||
);
|
||||
// Bounded history: keep only the 10 most recent runs per stack.
|
||||
this.db.prepare(
|
||||
@@ -3152,10 +3317,11 @@ export class DatabaseService {
|
||||
reason: string | null,
|
||||
endedAt: number,
|
||||
containersJson: string,
|
||||
failureSource: 'primary' | 'collateral' | null = null,
|
||||
): void {
|
||||
this.db.prepare(
|
||||
'UPDATE health_gate_runs SET status = ?, reason = ?, ended_at = ?, containers_json = ? WHERE id = ?'
|
||||
).run(status, reason, endedAt, containersJson, id);
|
||||
'UPDATE health_gate_runs SET status = ?, reason = ?, ended_at = ?, containers_json = ?, failure_source = ? WHERE id = ?'
|
||||
).run(status, reason, endedAt, containersJson, failureSource, id);
|
||||
}
|
||||
|
||||
public getHealthGateRun(nodeId: number, stackName: string, id: string): HealthGateRunRow | undefined {
|
||||
@@ -3178,6 +3344,128 @@ export class DatabaseService {
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
// --- Service Update Recovery ---
|
||||
|
||||
public insertServiceUpdateRecovery(row: ServiceUpdateRecoveryRow): void {
|
||||
this.db.prepare(
|
||||
`INSERT INTO service_update_recovery
|
||||
(id, node_id, stack_name, service_name, replicas_json, majority_image_id,
|
||||
declared_image_ref, weak_floating_tag, health_gate_id, status,
|
||||
expires_at, claim_expires_at, created_at, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
row.id, row.node_id, row.stack_name, row.service_name, row.replicas_json, row.majority_image_id,
|
||||
row.declared_image_ref, row.weak_floating_tag, row.health_gate_id, row.status,
|
||||
row.expires_at, row.claim_expires_at, row.created_at, row.created_by,
|
||||
);
|
||||
}
|
||||
|
||||
public getServiceUpdateRecovery(id: string): ServiceUpdateRecoveryRow | undefined {
|
||||
return this.db.prepare(
|
||||
'SELECT * FROM service_update_recovery WHERE id = ?'
|
||||
).get(id) as ServiceUpdateRecoveryRow | undefined;
|
||||
}
|
||||
|
||||
/** Active, unexpired rows for one service, most recent first. */
|
||||
public listActiveServiceUpdateRecoveries(nodeId: number, stackName: string, serviceName: string): ServiceUpdateRecoveryRow[] {
|
||||
return this.db.prepare(
|
||||
`SELECT * FROM service_update_recovery
|
||||
WHERE node_id = ? AND stack_name = ? AND service_name = ? AND status = 'active'
|
||||
ORDER BY created_at DESC`
|
||||
).all(nodeId, stackName, serviceName) as ServiceUpdateRecoveryRow[];
|
||||
}
|
||||
|
||||
/** Attach the update flow's own health gate run id while the row is still active. */
|
||||
public linkServiceUpdateRecoveryHealthGate(id: string, healthGateId: string): void {
|
||||
this.db.prepare(
|
||||
`UPDATE service_update_recovery SET health_gate_id = ? WHERE id = ? AND status = 'active'`
|
||||
).run(healthGateId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic claim CAS for restore: active -> restoring, only when the row has
|
||||
* not already expired. Returns the updated row, or undefined when another
|
||||
* claim, a sweep, or a terminal status won the race.
|
||||
*/
|
||||
public claimServiceUpdateRecovery(id: string, claimExpiresAt: number, now: number): ServiceUpdateRecoveryRow | undefined {
|
||||
const result = this.db.prepare(
|
||||
`UPDATE service_update_recovery
|
||||
SET status = 'restoring', claim_expires_at = ?
|
||||
WHERE id = ? AND status = 'active' AND expires_at > ?`
|
||||
).run(claimExpiresAt, id, now);
|
||||
if (result.changes === 0) return undefined;
|
||||
return this.getServiceUpdateRecovery(id);
|
||||
}
|
||||
|
||||
/** Renewal CAS: only while still restoring. Cannot revive a consumed/expired/invalidated/active row. */
|
||||
public renewServiceUpdateRecoveryClaim(id: string, claimExpiresAt: number): boolean {
|
||||
const result = this.db.prepare(
|
||||
`UPDATE service_update_recovery SET claim_expires_at = ? WHERE id = ? AND status = 'restoring'`
|
||||
).run(claimExpiresAt, id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/** Restore succeeded: restoring -> consumed, optionally linking the restore's own health gate run. */
|
||||
public markServiceUpdateRecoveryConsumed(id: string, healthGateId: string | null = null): boolean {
|
||||
const result = this.db.prepare(
|
||||
`UPDATE service_update_recovery
|
||||
SET status = 'consumed', claim_expires_at = NULL, health_gate_id = COALESCE(?, health_gate_id)
|
||||
WHERE id = ? AND status = 'restoring'`
|
||||
).run(healthGateId, id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/** Mid-flight restore failure with the image still local: restoring -> active, claim cleared. */
|
||||
public reactivateServiceUpdateRecovery(id: string): boolean {
|
||||
const result = this.db.prepare(
|
||||
`UPDATE service_update_recovery SET status = 'active', claim_expires_at = NULL WHERE id = ? AND status = 'restoring'`
|
||||
).run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/** Mid-flight restore failure with the image gone: restoring -> invalidated. */
|
||||
public invalidateServiceUpdateRecovery(id: string): boolean {
|
||||
const result = this.db.prepare(
|
||||
`UPDATE service_update_recovery SET status = 'invalidated', claim_expires_at = NULL WHERE id = ? AND status = 'restoring'`
|
||||
).run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
/** Startup/interval sweep: active rows whose TTL has lapsed. */
|
||||
public sweepExpiredActiveServiceUpdateRecoveries(now: number): number {
|
||||
const result = this.db.prepare(
|
||||
`UPDATE service_update_recovery SET status = 'expired' WHERE status = 'active' AND expires_at <= ?`
|
||||
).run(now);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startup/interval sweep: restoring rows abandoned by a dead claim (process
|
||||
* died mid-restore). A restoring row with a live claim is never expired here,
|
||||
* even if its original expires_at has long passed.
|
||||
*/
|
||||
public sweepAbandonedRestoringServiceUpdateRecoveries(now: number): number {
|
||||
const result = this.db.prepare(
|
||||
`UPDATE service_update_recovery
|
||||
SET status = 'expired'
|
||||
WHERE status = 'restoring' AND claim_expires_at IS NOT NULL AND claim_expires_at <= ?`
|
||||
).run(now);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/** Image IDs currently protected from prune: active rows and restoring rows with a live claim. */
|
||||
public listHeldServiceUpdateRecoveryImageIds(nodeId: number, now: number): string[] {
|
||||
const rows = this.db.prepare(
|
||||
`SELECT DISTINCT majority_image_id FROM service_update_recovery
|
||||
WHERE node_id = ? AND (status = 'active' OR (status = 'restoring' AND claim_expires_at > ?))`
|
||||
).all(nodeId, now) as Array<{ majority_image_id: string }>;
|
||||
return rows.map(r => r.majority_image_id);
|
||||
}
|
||||
|
||||
public deleteServiceUpdateRecoveries(nodeId: number, stackName: string): void {
|
||||
this.db.prepare('DELETE FROM service_update_recovery WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
// --- Notification History ---
|
||||
|
||||
private mapNotificationRow(row: any): NotificationHistory {
|
||||
@@ -3569,6 +3857,7 @@ export class DatabaseService {
|
||||
this.db.prepare('DELETE FROM preflight_acknowledgements WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM stack_exposure WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM health_gate_runs WHERE node_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM service_update_recovery WHERE node_id = ?').run(id);
|
||||
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
|
||||
this.deleteRoleAssignmentsByResource('node', String(id));
|
||||
this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id);
|
||||
@@ -3644,6 +3933,12 @@ export class DatabaseService {
|
||||
|
||||
// --- Stack Update Status ---
|
||||
|
||||
/**
|
||||
* `services` is omitted by legacy (non-service-aware) callers; omitting it
|
||||
* leaves the stack's existing services_json untouched (COALESCE against the
|
||||
* current row) rather than erasing a per-service breakdown that a model-aware
|
||||
* caller persisted on a previous check.
|
||||
*/
|
||||
public upsertStackUpdateStatus(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
@@ -3651,16 +3946,20 @@ export class DatabaseService {
|
||||
checkedAt: number,
|
||||
checkStatus: StackCheckStatus = 'ok',
|
||||
lastError: string | null = null,
|
||||
services?: StackServiceStatus[],
|
||||
generation = 0,
|
||||
): void {
|
||||
const servicesJson = services !== undefined ? stringifyServicesJson(services, generation) : null;
|
||||
this.db.prepare(
|
||||
`INSERT INTO stack_update_status (node_id, stack_name, has_update, check_status, last_error, checked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO stack_update_status (node_id, stack_name, has_update, check_status, last_error, checked_at, services_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(node_id, stack_name) DO UPDATE SET
|
||||
has_update = excluded.has_update,
|
||||
check_status = excluded.check_status,
|
||||
last_error = excluded.last_error,
|
||||
checked_at = excluded.checked_at`
|
||||
).run(nodeId, stackName, hasUpdate ? 1 : 0, checkStatus, lastError, checkedAt);
|
||||
checked_at = excluded.checked_at,
|
||||
services_json = COALESCE(excluded.services_json, stack_update_status.services_json)`
|
||||
).run(nodeId, stackName, hasUpdate ? 1 : 0, checkStatus, lastError, checkedAt, servicesJson);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3671,15 +3970,24 @@ export class DatabaseService {
|
||||
* first-ever failed check inserts a row with has_update = 0 so the stack
|
||||
* still appears with its failure reason.
|
||||
*/
|
||||
public recordStackCheckFailure(nodeId: number, stackName: string, lastError: string, checkedAt: number): void {
|
||||
public recordStackCheckFailure(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
lastError: string,
|
||||
checkedAt: number,
|
||||
services?: StackServiceStatus[],
|
||||
generation = 0,
|
||||
): void {
|
||||
const servicesJson = services !== undefined ? stringifyServicesJson(services, generation) : null;
|
||||
this.db.prepare(
|
||||
`INSERT INTO stack_update_status (node_id, stack_name, has_update, check_status, last_error, checked_at)
|
||||
VALUES (?, ?, 0, 'failed', ?, ?)
|
||||
`INSERT INTO stack_update_status (node_id, stack_name, has_update, check_status, last_error, checked_at, services_json)
|
||||
VALUES (?, ?, 0, 'failed', ?, ?, ?)
|
||||
ON CONFLICT(node_id, stack_name) DO UPDATE SET
|
||||
check_status = 'failed',
|
||||
last_error = excluded.last_error,
|
||||
checked_at = excluded.checked_at`
|
||||
).run(nodeId, stackName, lastError, checkedAt);
|
||||
checked_at = excluded.checked_at,
|
||||
services_json = COALESCE(excluded.services_json, stack_update_status.services_json)`
|
||||
).run(nodeId, stackName, lastError, checkedAt, servicesJson);
|
||||
}
|
||||
|
||||
public getStackUpdateStatus(nodeId?: number): Record<string, boolean> {
|
||||
@@ -3700,20 +4008,35 @@ export class DatabaseService {
|
||||
*/
|
||||
public getStackUpdateDetail(nodeId: number): Record<string, StackUpdateDetail> {
|
||||
const rows = this.db.prepare(
|
||||
'SELECT stack_name, has_update, check_status, last_error, checked_at FROM stack_update_status WHERE node_id = ?'
|
||||
).all(nodeId) as Array<{ stack_name: string; has_update: number; check_status: string | null; last_error: string | null; checked_at: number }>;
|
||||
'SELECT stack_name, has_update, check_status, last_error, checked_at, services_json FROM stack_update_status WHERE node_id = ?'
|
||||
).all(nodeId) as Array<{ stack_name: string; has_update: number; check_status: string | null; last_error: string | null; checked_at: number; services_json: string | null }>;
|
||||
const result: Record<string, StackUpdateDetail> = {};
|
||||
for (const row of rows) {
|
||||
const services = parseServicesJson(row.services_json);
|
||||
result[row.stack_name] = {
|
||||
hasUpdate: row.has_update === 1,
|
||||
checkStatus: (row.check_status === 'failed' || row.check_status === 'partial') ? row.check_status : 'ok',
|
||||
lastError: row.last_error,
|
||||
checkedAt: row.checked_at,
|
||||
...(services.length > 0 ? { services } : {}),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prior per-service breakdown for a single stack, used by ImageUpdateService
|
||||
* to look up each service's last-known hasUpdate before reducing a fresh
|
||||
* check (so a preserved value survives a partial/failed re-check). Corrupt
|
||||
* or missing JSON yields an empty list.
|
||||
*/
|
||||
public getStackServicesJson(nodeId: number, stackName: string): StackServiceStatus[] {
|
||||
const row = this.db.prepare(
|
||||
'SELECT services_json FROM stack_update_status WHERE node_id = ? AND stack_name = ?'
|
||||
).get(nodeId, stackName) as { services_json: string | null } | undefined;
|
||||
return parseServicesJson(row?.services_json);
|
||||
}
|
||||
|
||||
public clearStackUpdateStatus(nodeId: number, stackName: string): void {
|
||||
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user