mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
b0b423b234
* feat(fleet): reapply Compose configuration without a version update Add a distinct Fleet Reapply configuration path so Compose-managed nodes can recreate Sencho from the current on-disk project when already up to date, without pulling or rewriting the image reference. * fix(fleet): confirm remote reapply and close concurrent tracker race Require confirmation for remote compose reapply, and lock dispatch before the remote POST so a second request cannot overwrite a successful in-flight tracker. * fix(ui): icon-only Reapply control so Up to date badge can breathe Collapse the Node updates Reapply label into a tooltip so the status pill no longer wraps in the Status column. * feat(editor): Save & Reapply self-stack via fleet compose reapply (#1726) * feat(editor): Save & Reapply self-stack via fleet compose reapply Eligible admins can apply on-disk Compose edits to Sencho's own stack from the editor using the same confirm, dispatch, and reconnect path as Fleet Node Updates. * fix(editor): gate Save & Reapply label to self-stack only Ordinary stacks were labeled Save & Reapply whenever the node was reapply-eligible. Require the selected file to be the self-stack for the toolbar label and diff confirm CTA. * fix(ui): move compose diff action label helper out of dialog module Keep ComposeDiffPreviewDialog component-only so react-refresh Fast Refresh lint passes after the Save and reapply stacked merge.
124 lines
4.5 KiB
TypeScript
124 lines
4.5 KiB
TypeScript
export type FleetOperationKind = 'update' | 'reapply_configuration';
|
|
|
|
export interface UpdateTracker {
|
|
status: 'updating' | 'completed' | 'timeout' | 'failed';
|
|
startedAt: number;
|
|
previousVersion: string | null;
|
|
error?: string;
|
|
/** Machine-readable failure code returned by a remote update request. */
|
|
code?: string;
|
|
/** Process start time of the remote node before the update was triggered. */
|
|
previousProcessStart: number | null;
|
|
/** True when the node became unreachable at least once during the update window. */
|
|
wasOffline: boolean;
|
|
/** Timestamp when the tracker transitioned to a terminal state (completed/failed/timeout). */
|
|
resolvedAt?: number;
|
|
/** Distinguishes version updates from compose reapply so poll heuristics stay correct. */
|
|
operationKind: FleetOperationKind;
|
|
}
|
|
|
|
export type TerminalStatus = 'completed' | 'failed' | 'timeout';
|
|
|
|
/** Hard ceiling for an in-flight update before it is declared timed out. */
|
|
export const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
export const UPDATE_TIMEOUT_MSG = 'Node did not come back online within 5 minutes.';
|
|
/** How long a resolved `completed` tracker lingers before it is reaped, so the
|
|
* badge stays briefly visible after the update lands. */
|
|
export const TERMINAL_TTL_MS = 60 * 1000;
|
|
|
|
/**
|
|
* In-memory tracker for in-flight fleet node updates. Keyed by node id.
|
|
*
|
|
* State is intentionally process-local: a restart clears all trackers, which
|
|
* is correct because the primary's own restart means it cannot observe remote
|
|
* update progress anyway. Fleet routes consume this service to render and
|
|
* clear update status.
|
|
*/
|
|
export class FleetUpdateTrackerService {
|
|
private static instance: FleetUpdateTrackerService;
|
|
private readonly trackers = new Map<number, UpdateTracker>();
|
|
|
|
public static getInstance(): FleetUpdateTrackerService {
|
|
if (!FleetUpdateTrackerService.instance) {
|
|
FleetUpdateTrackerService.instance = new FleetUpdateTrackerService();
|
|
}
|
|
return FleetUpdateTrackerService.instance;
|
|
}
|
|
|
|
public get(nodeId: number): UpdateTracker | undefined {
|
|
return this.trackers.get(nodeId);
|
|
}
|
|
|
|
public set(nodeId: number, tracker: UpdateTracker): void {
|
|
this.trackers.set(nodeId, tracker);
|
|
}
|
|
|
|
public delete(nodeId: number): boolean {
|
|
return this.trackers.delete(nodeId);
|
|
}
|
|
|
|
public entries(): IterableIterator<[number, UpdateTracker]> {
|
|
return this.trackers.entries();
|
|
}
|
|
|
|
public size(): number {
|
|
return this.trackers.size;
|
|
}
|
|
|
|
/** Create a new tracker with `startedAt=now` and resolvedAt set if terminal.
|
|
* `operationKind` defaults to `'update'` so existing call sites stay unchanged. */
|
|
public create(
|
|
status: UpdateTracker['status'],
|
|
previousVersion: string | null,
|
|
previousProcessStart: number | null,
|
|
error?: string,
|
|
code?: string,
|
|
operationKind: FleetOperationKind = 'update',
|
|
): UpdateTracker {
|
|
const now = Date.now();
|
|
return {
|
|
status,
|
|
startedAt: now,
|
|
previousVersion,
|
|
previousProcessStart,
|
|
wasOffline: false,
|
|
error,
|
|
code,
|
|
operationKind,
|
|
resolvedAt: status !== 'updating' ? now : undefined,
|
|
};
|
|
}
|
|
|
|
/** Return a copy of `tracker` transitioned to a terminal state, with resolvedAt=now. */
|
|
public resolve(tracker: UpdateTracker, status: TerminalStatus, error?: string): UpdateTracker {
|
|
return { ...tracker, status, resolvedAt: Date.now(), error };
|
|
}
|
|
|
|
/**
|
|
* Safety-net sweep driven off the monitor tick rather than the frontend poll.
|
|
* The `/api/fleet/update-status` poll is the primary resolver, but it only
|
|
* runs while a client is watching; this bounds trackers when nothing polls:
|
|
* an in-flight tracker past the ceiling is timed out, and a resolved
|
|
* `completed` badge past its visibility window is reaped (mirroring the
|
|
* poll's auto-expire). Failed/timeout trackers persist until the operator
|
|
* dismisses them, matching the poll's behaviour. Returns counts for logging.
|
|
*/
|
|
public sweepStale(): { timedOut: number; reaped: number } {
|
|
const now = Date.now();
|
|
let timedOut = 0;
|
|
let reaped = 0;
|
|
for (const [nodeId, tracker] of this.trackers) {
|
|
if (tracker.status === 'updating') {
|
|
if (now - tracker.startedAt > UPDATE_TIMEOUT_MS) {
|
|
this.trackers.set(nodeId, this.resolve(tracker, 'timeout', UPDATE_TIMEOUT_MSG));
|
|
timedOut++;
|
|
}
|
|
} else if (tracker.status === 'completed' && tracker.resolvedAt && now - tracker.resolvedAt > TERMINAL_TTL_MS) {
|
|
this.trackers.delete(nodeId);
|
|
reaped++;
|
|
}
|
|
}
|
|
return { timedOut, reaped };
|
|
}
|
|
}
|