Files
sencho/backend/src/services/FleetUpdateTrackerService.ts
T
Anso dc3699189d refactor(backend): extract remote proxy, WebSocket upgrade handler, and server factory (phase 3) (#733)
Phase 3 of the index.ts refactor. Pulls the remote HTTP/WS proxy plumbing,
the WebSocket upgrade dispatcher, and the http/WSS construction out of the
monolith. index.ts drops roughly 620 lines.

New modules:
- proxy/websocketProxy.ts: shared httpProxy.createProxyServer singleton
  (used by both the HTTP proxy middleware and the remote WS forwarder)
- proxy/remoteNodeProxy.ts: createRemoteProxyMiddleware() factory; consumes
  the isProxyExemptPath helper instead of open-coding the prefix list
- server.ts: createServer(app) returns { server, wss, pilotTunnelWss }
- services/FleetUpdateTrackerService.ts: singleton wrapping the in-flight
  fleet update tracker Map with create()/resolve() helpers
- helpers/consoleSession.ts: mintConsoleSession(), isConsoleSessionScope()
- websocket/upgradeHandler.ts: attachUpgrade(server, deps) dispatcher that
  runs the manual cookie/JWT verify and delegates to sub-handlers
- websocket/pilotTunnel.ts: handlePilotTunnel (pilot_enroll consumption and
  pilot_tunnel registration)
- websocket/notifications.ts: /ws/notifications local subscriber
- websocket/remoteForwarder.ts: remote-node WS proxy with console_session
  token exchange for interactive paths
- websocket/logs.ts: /api/stacks/:name/logs supervisor stream
- websocket/hostConsole.ts: /api/system/host-console PTY, Admiral-gated
- websocket/generic.ts: /ws exec + streamStats action dispatch, owns the
  terminalWs single-instance reference
- websocket/reject.ts: shared rejectUpgrade helper (replaces five copies)

Service extension:
- NotificationService: setBroadcaster(fn) replaced by subscribe(ws) that
  returns an unsubscriber; broadcastToSubscribers is now internal. Subscriber
  set lives on the service rather than in index.ts.

Wiring in index.ts:
- const app = createApp() already in place from Phase 2
- const { server, wss, pilotTunnelWss } = createServer(app)
- attachUpgrade(server, { wss, pilotTunnelWss })
- app.use('/api/', createRemoteProxyMiddleware())
- /api/system/console-token route now uses mintConsoleSession()
- deploy/down/update routes read the streaming target via getTerminalWs()
  (return type is WebSocket | undefined so the || undefined fallback is gone)

Code review fixes: five duplicated reject helpers collapsed into
websocket/reject.ts; dropped the createTracker/resolveTracker bind
aliases in index.ts so call sites go through the service directly;
removed em dashes; replaced req.url! with req.url || '/'.
2026-04-23 19:31:16 -04:00

79 lines
2.5 KiB
TypeScript

export interface UpdateTracker {
status: 'updating' | 'completed' | 'timeout' | 'failed';
startedAt: number;
previousVersion: string | null;
error?: 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;
}
export type TerminalStatus = 'completed' | 'failed' | 'timeout';
/**
* 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. */
public create(
status: UpdateTracker['status'],
previousVersion: string | null,
previousProcessStart: number | null,
error?: string,
): UpdateTracker {
const now = Date.now();
return {
status,
startedAt: now,
previousVersion,
previousProcessStart,
wasOffline: false,
error,
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 };
}
}