feat(fleet): add remote node update management (#353)

Add the ability to check for outdated nodes and trigger over-the-air
updates from Fleet View. Nodes self-update by pulling the latest Docker
image and recreating their container via the "last breath" pattern.

Backend:
- SelfUpdateService: self-container identification via HOSTNAME + Docker
  Compose labels, triggers pull + force-recreate
- CapabilityRegistry: runtime capability disabling via disableCapability()
- POST /api/system/update (202 + deferred self-update)
- GET /api/fleet/update-status (version comparison across fleet)
- POST /api/fleet/nodes/:nodeId/update (single node)
- POST /api/fleet/update-all (bulk remote update)
- In-memory update tracker with 5-min timeout

Frontend:
- Node Updates modal with summary stats, search filter, table layout,
  per-node Update buttons, and bulk Update All
- Version badges and update-available indicators on node cards
- ReconnectingOverlay for local node updates (polls /api/health)
- 5s fast-poll when any node is actively updating
- UpdateStatusBadge shared component for consistent badge rendering

Requires Skipper (Pro) tier. Nodes must be deployed via Docker Compose
with Docker socket access.
This commit is contained in:
Anso
2026-04-03 01:39:22 -04:00
committed by GitHub
parent d670984635
commit 87b5908288
13 changed files with 1039 additions and 21 deletions
@@ -27,6 +27,7 @@ export const CAPABILITIES = [
'api-tokens',
'users',
'registries',
'self-update',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
@@ -41,6 +42,19 @@ export interface RemoteMeta {
capabilities: string[];
}
// Runtime capability overrides — services call disableCapability() during init
const disabledCapabilities = new Set<Capability>();
export function disableCapability(c: Capability): void {
disabledCapabilities.add(c);
}
/** Returns capabilities this instance actually supports at runtime. */
export function getActiveCapabilities(): readonly string[] {
if (disabledCapabilities.size === 0) return CAPABILITIES;
return CAPABILITIES.filter(c => !disabledCapabilities.has(c));
}
/** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */
export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise<RemoteMeta> {
try {