diff --git a/CHANGELOG.md b/CHANGELOG.md index a5537ef3..8f35e862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +* **fleet:** remote node update management (Pro tier) — 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. Includes version badges on node cards, per-node and bulk "Update All" actions, real-time progress tracking with 5-second polling, reconnecting overlay for local node updates, and `POST /api/system/update` endpoint for programmatic self-updates. Requires Docker Compose deployment with Docker socket access. + ## [0.32.0](https://github.com/AnsoCode/Sencho/compare/v0.31.0...v0.32.0) (2026-04-03) diff --git a/backend/package-lock.json b/backend/package-lock.json index 48798f1d..db57efd1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "@types/dockerode": "^4.0.1", "@types/express": "^5.0.6", "@types/http-proxy": "^1.17.17", + "@types/semver": "^7.7.1", "@types/ws": "^8.18.1", "axios": "^1.13.6", "bcrypt": "^6.0.0", @@ -32,6 +33,7 @@ "ldapts": "^8.1.7", "node-pty": "^1.1.0", "openid-client": "^5.7.1", + "semver": "^7.7.4", "systeminformation": "^5.31.1", "ws": "^8.19.0", "yaml": "^2.8.2", @@ -2204,6 +2206,12 @@ "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", "license": "MIT" }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", diff --git a/backend/package.json b/backend/package.json index d7f58a24..51925e09 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,6 +38,7 @@ "@types/dockerode": "^4.0.1", "@types/express": "^5.0.6", "@types/http-proxy": "^1.17.17", + "@types/semver": "^7.7.1", "@types/ws": "^8.18.1", "axios": "^1.13.6", "bcrypt": "^6.0.0", @@ -56,6 +57,7 @@ "ldapts": "^8.1.7", "node-pty": "^1.1.0", "openid-client": "^5.7.1", + "semver": "^7.7.4", "systeminformation": "^5.31.1", "ws": "^8.19.0", "yaml": "^2.8.2", diff --git a/backend/src/index.ts b/backend/src/index.ts index f41f6f66..2dd9c1e3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -30,7 +30,9 @@ import { WebhookService } from './services/WebhookService'; import { SSOService } from './services/SSOService'; import { SchedulerService } from './services/SchedulerService'; import { RegistryService } from './services/RegistryService'; -import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta } from './services/CapabilityRegistry'; +import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta, getActiveCapabilities } from './services/CapabilityRegistry'; +import SelfUpdateService from './services/SelfUpdateService'; +import semver from 'semver'; import { CronExpressionParser } from 'cron-parser'; import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from './utils/validation'; import YAML from 'yaml'; @@ -323,7 +325,7 @@ app.get('/api/health', (_req: Request, res: Response): void => { // Public meta endpoint - returns this instance's version and supported capabilities. // No auth required (like /health). Used by remote nodes during connection tests. app.get('/api/meta', (_req: Request, res: Response): void => { - res.json({ version: getSenchoVersion(), capabilities: CAPABILITIES }); + res.json({ version: getSenchoVersion(), capabilities: getActiveCapabilities() }); }); // Auth Routes (no authentication required) @@ -1032,8 +1034,36 @@ app.post('/api/license/validate', async (_req: Request, res: Response): Promise< } }); +// --- Self-Update --- + +/** Respond 202 and trigger the "last breath" self-update after the response flushes. */ +function scheduleLocalUpdate(res: Response, message: string): void { + res.status(202).json({ message }); + res.on('finish', () => { + setTimeout(() => SelfUpdateService.getInstance().triggerUpdate(), 500); + }); +} + +app.post('/api/system/update', (_req: Request, res: Response): void => { + if (!SelfUpdateService.getInstance().isAvailable()) { + res.status(503).json({ error: 'Self-update unavailable. Sencho must be deployed via Docker Compose.' }); + return; + } + scheduleLocalUpdate(res, 'Update initiated. The server will restart shortly.'); +}); + // --- Fleet Overview (local-only, aggregates all nodes) --- +// In-memory tracker for remote node updates (transient — lost on gateway restart) +interface UpdateTracker { + status: 'updating' | 'completed' | 'timeout' | 'failed'; + startedAt: number; + previousVersion: string | null; + error?: string; +} +const updateTracker = new Map(); +const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + interface FleetNodeOverview { id: number; name: string; @@ -1171,6 +1201,182 @@ app.get('/api/fleet/node/:nodeId/stacks/:stackName/containers', async (req: Requ } }); +// Fleet Update Status — returns version comparison and active update status for all nodes +app.get('/api/fleet/update-status', async (_req: Request, res: Response): Promise => { + if (!requirePro(_req, res)) return; + try { + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + const gatewayVersion = getSenchoVersion(); + + const results = await Promise.allSettled( + nodes.map(async (node) => { + const tracker = updateTracker.get(node.id); + + let version: string | null = null; + if (node.type === 'local') { + version = gatewayVersion; + } else if (node.api_url && node.api_token) { + const meta = await fetchRemoteMeta(node.api_url, node.api_token); + version = meta.version; + } + + // For nodes actively updating, check if they've come back with a new version + if (tracker?.status === 'updating') { + if (Date.now() - tracker.startedAt > UPDATE_TIMEOUT_MS) { + updateTracker.set(node.id, { ...tracker, status: 'timeout' }); + } else if (node.type === 'remote' && version && version !== tracker.previousVersion) { + updateTracker.set(node.id, { ...tracker, status: 'completed' }); + } + } + + const currentTracker = updateTracker.get(node.id); + return { + nodeId: node.id, + name: node.name, + type: node.type, + version, + latestVersion: gatewayVersion, + updateAvailable: version === null + ? (node.type === 'remote') // Remote node without /api/meta is pre-capability-negotiation — definitely outdated + : (version !== gatewayVersion && !!semver.valid(version) && semver.lt(version, gatewayVersion)), + updateStatus: currentTracker?.status ?? null, + }; + }) + ); + + const nodeStatuses = results.map((r, i) => { + if (r.status === 'fulfilled') return r.value; + return { + nodeId: nodes[i].id, + name: nodes[i].name, + type: nodes[i].type, + version: null, + latestVersion: gatewayVersion, + updateAvailable: false, + updateStatus: null, + }; + }); + + res.json({ nodes: nodeStatuses }); + } catch (error) { + console.error('[Fleet] Update status error:', error); + res.status(500).json({ error: 'Failed to fetch update status' }); + } +}); + +// Trigger update on a specific node +app.post('/api/fleet/nodes/:nodeId/update', async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const nodeId = parseInt(req.params.nodeId as string, 10); + const db = DatabaseService.getInstance(); + const node = db.getNode(nodeId); + if (!node) { + res.status(404).json({ error: 'Node not found' }); + return; + } + + const existing = updateTracker.get(nodeId); + if (existing?.status === 'updating') { + res.status(409).json({ error: 'Update already in progress for this node.' }); + return; + } + + if (node.type === 'local') { + if (!SelfUpdateService.getInstance().isAvailable()) { + res.status(503).json({ error: 'Self-update unavailable on the local node.' }); + return; + } + updateTracker.set(nodeId, { status: 'updating', startedAt: Date.now(), previousVersion: getSenchoVersion() }); + scheduleLocalUpdate(res, 'Update initiated on local node. The server will restart shortly.'); + return; + } + + // Remote node + if (!node.api_url || !node.api_token) { + res.status(503).json({ error: 'Remote node not configured.' }); + return; + } + + // Check remote capabilities + const meta = await fetchRemoteMeta(node.api_url, node.api_token); + if (!meta.capabilities.includes('self-update')) { + res.status(503).json({ error: 'Remote node does not support self-update. It may need to be updated manually first.' }); + return; + } + + // Trigger remote update + const response = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/update`, { + method: 'POST', + headers: { + Authorization: `Bearer ${node.api_token}`, + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + res.status(502).json({ error: (err as Record)?.error || 'Remote node rejected update request.' }); + return; + } + + updateTracker.set(nodeId, { status: 'updating', startedAt: Date.now(), previousVersion: meta.version }); + res.status(202).json({ message: `Update initiated on ${node.name}.` }); + } catch (error) { + console.error('[Fleet] Node update error:', error); + res.status(500).json({ error: 'Failed to trigger node update.' }); + } +}); + +// Trigger update on all outdated nodes +app.post('/api/fleet/update-all', async (req: Request, res: Response): Promise => { + if (!requirePro(req, res)) return; + try { + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + const gatewayVersion = getSenchoVersion(); + + // Filter to eligible candidates, then trigger all in parallel + const candidates = nodes.filter(node => { + if (node.type === 'local') return false; + if (updateTracker.get(node.id)?.status === 'updating') return false; + if (!node.api_url || !node.api_token) return false; + return true; + }); + + const results = await Promise.allSettled(candidates.map(async (node) => { + const meta = await fetchRemoteMeta(node.api_url!, node.api_token!); + if (!meta.version || !semver.valid(meta.version) || !semver.lt(meta.version, gatewayVersion) || !meta.capabilities.includes('self-update')) { + return { name: node.name, triggered: false }; + } + const response = await fetch(`${node.api_url!.replace(/\/$/, '')}/api/system/update`, { + method: 'POST', + headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (response.ok) { + updateTracker.set(node.id, { status: 'updating', startedAt: Date.now(), previousVersion: meta.version }); + return { name: node.name, triggered: true }; + } + return { name: node.name, triggered: false }; + })); + + const updating: string[] = []; + const skipped = nodes.filter(n => !candidates.includes(n)).map(n => n.name); + for (const r of results) { + const val = r.status === 'fulfilled' ? r.value : { name: 'unknown', triggered: false }; + (val.triggered ? updating : skipped).push(val.name); + } + + res.status(202).json({ updating, skipped }); + } catch (error) { + console.error('[Fleet] Update all error:', error); + res.status(500).json({ error: 'Failed to trigger fleet update.' }); + } +}); + async function fetchLocalNodeOverview(node: Node): Promise { try { const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(node.id)); @@ -5262,6 +5468,9 @@ async function startServer() { // Initialize License Service (starts trial on first boot, periodic validation) LicenseService.getInstance().initialize(); + // Detect whether this instance can self-update (Docker Compose container inspection) + await SelfUpdateService.getInstance().initialize(); + // Start Background Watchdog MonitorService.getInstance().start(); diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 53605435..a919144e 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -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(); + +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 { try { diff --git a/backend/src/services/SelfUpdateService.ts b/backend/src/services/SelfUpdateService.ts new file mode 100644 index 00000000..904d44e3 --- /dev/null +++ b/backend/src/services/SelfUpdateService.ts @@ -0,0 +1,87 @@ +import { execSync, exec } from 'child_process'; +import DockerController from './DockerController'; +import { disableCapability } from './CapabilityRegistry'; + +interface ComposeContext { + workingDir: string; + configFiles: string; + serviceName: string; +} + +class SelfUpdateService { + private static instance: SelfUpdateService; + private canSelfUpdate = false; + private composeContext: ComposeContext | null = null; + + public static getInstance(): SelfUpdateService { + if (!SelfUpdateService.instance) { + SelfUpdateService.instance = new SelfUpdateService(); + } + return SelfUpdateService.instance; + } + + async initialize(): Promise { + const hostname = process.env.HOSTNAME; + if (!hostname) { + console.log('[SelfUpdate] HOSTNAME not set — self-update unavailable (not running in Docker?)'); + disableCapability('self-update'); + return; + } + + try { + const docker = DockerController.getInstance().getDocker(); + const container = docker.getContainer(hostname); + const info = await container.inspect(); + const labels = info.Config?.Labels ?? {}; + + const workingDir = labels['com.docker.compose.project.working_dir']; + const configFiles = labels['com.docker.compose.project.config_files']; + const serviceName = labels['com.docker.compose.service']; + + if (!workingDir || !configFiles || !serviceName) { + console.log('[SelfUpdate] Container lacks Docker Compose labels — self-update unavailable'); + disableCapability('self-update'); + return; + } + + this.composeContext = { workingDir, configFiles, serviceName }; + this.canSelfUpdate = true; + console.log(`[SelfUpdate] Ready — service="${serviceName}" in ${workingDir}`); + } catch (error) { + console.log('[SelfUpdate] Could not inspect own container — self-update unavailable:', (error as Error).message); + disableCapability('self-update'); + } + } + + isAvailable(): boolean { + return this.canSelfUpdate; + } + + triggerUpdate(): void { + if (!this.composeContext) return; + const { workingDir, configFiles, serviceName } = this.composeContext; + const env = { ...process.env, PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' }; + + console.log(`[SelfUpdate] Pulling latest image for ${serviceName}...`); + try { + execSync(`docker compose -f ${configFiles} pull ${serviceName}`, { + cwd: workingDir, + env, + stdio: 'pipe', + timeout: 300_000, // 5 min max for pull + }); + } catch (error) { + console.error('[SelfUpdate] Pull failed:', (error as Error).message); + return; + } + + console.log(`[SelfUpdate] Recreating container for ${serviceName}... (last breath)`); + exec(`docker compose -f ${configFiles} up -d --force-recreate ${serviceName}`, { + cwd: workingDir, + env, + }); + // Process will be killed by Docker during recreate — no code runs after this + } +} + +export default SelfUpdateService; diff --git a/docs/docs.json b/docs/docs.json index 08b25270..c40ff5a1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -104,6 +104,7 @@ "features/multi-node", "features/node-compatibility", "features/fleet-view", + "features/remote-updates", "features/stack-labels", "features/alerts-notifications", "features/notification-routing", @@ -149,7 +150,8 @@ "group": "Health & Meta", "pages": [ "GET /api/health", - "GET /api/meta" + "GET /api/meta", + "POST /api/system/update" ] }, { @@ -222,6 +224,9 @@ "GET /api/fleet/overview", "GET /api/fleet/node/{nodeId}/stacks", "GET /api/fleet/node/{nodeId}/stacks/{stackName}/containers", + "GET /api/fleet/update-status", + "POST /api/fleet/nodes/{nodeId}/update", + "POST /api/fleet/update-all", "POST /api/fleet/snapshots", "GET /api/fleet/snapshots", "GET /api/fleet/snapshots/{id}", diff --git a/docs/features/remote-updates.mdx b/docs/features/remote-updates.mdx new file mode 100644 index 00000000..afe442f8 --- /dev/null +++ b/docs/features/remote-updates.mdx @@ -0,0 +1,93 @@ +--- +title: Remote updates +description: Check for outdated nodes and trigger over-the-air updates from the Fleet View. +--- + +Sencho can update remote nodes directly from the dashboard. When the gateway is running a newer version than a remote node, a one-click update pulls the latest image and recreates the container automatically. + + + Remote updates require **Sencho Pro** (Skipper or Admiral tier). + + +## Prerequisites + +Remote updates work when each node meets these conditions: + +- Deployed via **Docker Compose** (the recommended method) +- The Docker socket (`/var/run/docker.sock`) is mounted into the container +- Running **Sencho v0.32.0** or later (the version that introduced the `self-update` capability) + +Nodes deployed with `docker run` or orchestrators like Kubernetes do not support self-update. These nodes will show a "self-update unavailable" message. + +## How it works + +1. The gateway compares each node's version (from `/api/meta`) against its own version. +2. If a node is running an older version, the Fleet View shows an **Update available** badge. +3. Clicking **Update** sends a command to the remote node. +4. The remote node pulls the latest `saelix/sencho` image and recreates its own container. +5. The gateway polls the node until it comes back online with the new version. + +The remote node briefly goes offline during the update (typically 10–30 seconds depending on image pull speed). The Fleet View tracks the progress and shows **Updating**, **Updated**, or **Timed out** status badges. + +## Checking for updates + +Open the **Fleet** tab and click **Check Updates** in the header. This opens the **Node Updates** dialog, which lists every node with its current version and update status. + + + Node Updates dialog showing update status for each node + + +Each node row shows: + +| Element | Meaning | +|---------|---------| +| Version label (e.g. `v0.31.0`) | The node's current Sencho version | +| **Up to date** badge | Node is running the latest version | +| **Update** button | A newer version is available — click to update | +| **Updating** badge | The node is pulling and restarting | +| **Updated** badge | The node came back online with the new version | +| **Timed out** badge | The node did not come back within 5 minutes | + +Nodes running a version too old to report their version will show `pre-X.Y.Z` as the current version. + +## Updating a single node + +Click the **Update** button next to any outdated node in the Node Updates dialog, or click the **Update to vX.Y.Z** button directly on the node card in Fleet View. For remote nodes, the update happens in the background. For the local (gateway) node, a confirmation dialog appears warning about the brief dashboard disconnection. + +## Updating all nodes + +Click **Update All (N)** in the Node Updates dialog to trigger updates on all outdated remote nodes simultaneously. The local node is intentionally excluded from bulk updates — update it separately to avoid losing dashboard connectivity. + +## Local node updates + +When updating the local (gateway) node: + +1. A confirmation dialog explains that the dashboard will briefly disconnect. +2. After confirming, the server pulls the new image and restarts. +3. A reconnecting overlay appears and polls the server every 3 seconds. +4. The page automatically reloads when the server comes back. + +If the server does not return within 5 minutes, a timeout message appears with a manual reload option. + +## Troubleshooting + +### Node shows "self-update unavailable" + +The node is not running inside a Docker Compose-managed container, or the Docker socket is not mounted. Check that your `docker-compose.yml` includes the socket volume mount: + +```yaml +volumes: + - /var/run/docker.sock:/var/run/docker.sock +``` + +### Update times out + +The image pull may be slow on the remote host, or the container failed to restart. SSH into the remote host and check `docker logs sencho` for errors. You can also manually update by running: + +```bash +docker compose pull && docker compose up -d +``` + +### Old node does not show update button + +Nodes running Sencho versions before v0.32.0 do not advertise the `self-update` capability. Update them manually first, then future updates can be triggered from the dashboard. diff --git a/docs/images/fleet/check-updates-modal.png b/docs/images/fleet/check-updates-modal.png new file mode 100644 index 00000000..4db54871 Binary files /dev/null and b/docs/images/fleet/check-updates-modal.png differ diff --git a/docs/images/fleet/fleet-update-available.png b/docs/images/fleet/fleet-update-available.png new file mode 100644 index 00000000..add0e127 Binary files /dev/null and b/docs/images/fleet/fleet-update-available.png differ diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 001664fd..055285fe 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -570,6 +570,35 @@ paths: type: string example: ["stacks", "containers", "fleet", "auto-updates", "host-console"] + /api/system/update: + post: + operationId: triggerSelfUpdate + tags: [Health] + summary: Trigger self-update + description: | + Instructs this Sencho instance to pull the latest Docker image and recreate its own container. + Returns 202 immediately; the actual update happens asynchronously after the response is sent. + Requires the instance to be deployed via Docker Compose with the Docker socket mounted. + responses: + "202": + description: Update initiated. + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: "Update initiated. The server will restart shortly." + "401": + $ref: "#/components/responses/Unauthorized" + "503": + description: Self-update not available (not running in Docker Compose). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + # ── Stacks ────────────────────────────────────────────── /api/stacks: get: @@ -1923,6 +1952,122 @@ paths: schema: $ref: "#/components/schemas/Error" + /api/fleet/update-status: + get: + operationId: getFleetUpdateStatus + tags: [Fleet] + summary: Fleet update status + description: Returns version comparison and active update status for all nodes. Compares each node's version against the gateway's version. + responses: + "200": + description: Update status for all nodes. + content: + application/json: + schema: + type: object + properties: + nodes: + type: array + items: + type: object + properties: + nodeId: + type: integer + name: + type: string + type: + type: string + enum: [local, remote] + version: + type: string + nullable: true + latestVersion: + type: string + updateAvailable: + type: boolean + updateStatus: + type: string + nullable: true + enum: [updating, completed, timeout, failed, null] + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ProRequired" + + /api/fleet/nodes/{nodeId}/update: + post: + operationId: triggerNodeUpdate + tags: [Fleet] + summary: Trigger node update + description: Initiates a self-update on the specified node. For remote nodes, sends the update command via the proxy. For local nodes, triggers the update directly. + parameters: + - name: nodeId + in: path + required: true + schema: + type: integer + responses: + "202": + description: Update initiated. + content: + application/json: + schema: + type: object + properties: + message: + type: string + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ProRequired" + "404": + description: Node not found. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Update already in progress. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "503": + description: Self-update not available on the target node. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /api/fleet/update-all: + post: + operationId: triggerFleetUpdateAll + tags: [Fleet] + summary: Update all outdated nodes + description: Triggers self-update on all remote nodes running an older version than the gateway. Local nodes are excluded from bulk updates. + responses: + "202": + description: Bulk update initiated. + content: + application/json: + schema: + type: object + properties: + updating: + type: array + items: + type: string + description: Names of nodes where update was triggered. + skipped: + type: array + items: + type: string + description: Names of nodes that were skipped. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/ProRequired" + /api/fleet/snapshots: post: operationId: createFleetSnapshot diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 2babc459..211ea22e 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight, Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Box, Activity, - Play, Square, RotateCcw, ExternalLink, Camera, + Play, Square, RotateCcw, ExternalLink, Camera, Download, Loader2, Check, + CircleCheck, CircleAlert, Globe, Monitor, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -11,6 +12,13 @@ import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '@/components/ui/tabs'; import { springs } from '@/lib/motion'; import { apiFetch } from '@/lib/api'; @@ -54,6 +62,16 @@ interface StackContainer { Status?: string; } +interface NodeUpdateStatus { + nodeId: number; + name: string; + type: 'local' | 'remote'; + version: string | null; + latestVersion: string; + updateAvailable: boolean; + updateStatus: 'updating' | 'completed' | 'timeout' | 'failed' | null; +} + type SortField = 'name' | 'cpu' | 'memory' | 'containers' | 'status'; type SortDir = 'asc' | 'desc'; type FilterStatus = 'all' | 'online' | 'offline'; @@ -268,7 +286,76 @@ function StackSection({ stackName, nodeId, onNavigate, labelMap }: { ); } -function NodeCard({ node, onNavigate, labelMap }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record }) { +function UpdateStatusBadge({ status }: { status: NodeUpdateStatus['updateStatus'] }) { + if (status === 'updating') return ( + + Updating + + ); + if (status === 'completed') return ( + + Updated + + ); + if (status === 'timeout') return ( + Timed out + ); + if (status === 'failed') return ( + Failed + ); + return null; +} + +function ReconnectingOverlay() { + const [elapsed, setElapsed] = useState(0); + const timedOut = elapsed >= 300; // 5 minutes + + useEffect(() => { + const timer = setInterval(() => setElapsed(s => s + 1), 1000); + return () => clearInterval(timer); + }, []); + + useEffect(() => { + if (timedOut) return; + const poll = setInterval(async () => { + try { + const res = await fetch('/api/health'); + if (res.ok) window.location.reload(); + } catch { /* still down */ } + }, 3000); + return () => clearInterval(poll); + }, [timedOut]); + + return ( +
+
+ {timedOut ? ( + <> + +

Update timed out

+

+ The server has not come back within 5 minutes. Check the Docker host directly. +

+ + + ) : ( + <> + +

Updating Sencho...

+

+ The server is pulling the latest image and restarting. This page will reload automatically. +

+

{elapsed}s elapsed

+ + )} +
+
+ ); +} + +function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record; updateStatus?: NodeUpdateStatus; onUpdate?: (nodeId: number) => void; updatingNodeId?: number | null }) { const { isPro } = useLicense(); const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); @@ -312,19 +399,30 @@ function NodeCard({ node, onNavigate, labelMap }: { node: FleetNode; onNavigate:

{node.name}

-
- +
+ {isOnline ? ( <> Online ) : ( <> Offline )} - + {node.type} + {updateStatus?.version && ( + + v{updateStatus.version} + + )} + {updateStatus?.updateStatus && } + {updateStatus?.updateAvailable && !updateStatus.updateStatus && ( + + Update available + + )} {isOnline && isCritical(node) && ( - + Critical )} @@ -386,6 +484,25 @@ function NodeCard({ node, onNavigate, labelMap }: { node: FleetNode; onNavigate:
)} + {/* Update button */} + {isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && onUpdate && ( +
+ +
+ )} + {/* Offline placeholder */} {!isOnline && (
@@ -454,6 +571,15 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const [fleetStackLabelMap, setFleetStackLabelMap] = useState>({}); const [labelFilters, setLabelFilters] = useState>(new Set()); const { isPro } = useLicense(); + const [updateStatuses, setUpdateStatuses] = useState([]); + const [updatingNodeId, setUpdatingNodeId] = useState(null); + const [reconnecting, setReconnecting] = useState(false); + const [localUpdateConfirm, setLocalUpdateConfirm] = useState(null); + const [showUpdateModal, setShowUpdateModal] = useState(false); + const [checkingUpdates, setCheckingUpdates] = useState(false); + const [modalSearch, setModalSearch] = useState(''); + const updateStatusesRef = useRef(updateStatuses); + updateStatusesRef.current = updateStatuses; const updatePrefs = useCallback((update: Partial) => { setPrefs(prev => { @@ -492,10 +618,90 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { } }, [isPro]); + const fetchUpdateStatus = useCallback(async () => { + if (!isPro) return; + try { + const res = await apiFetch('/fleet/update-status', { localOnly: true }); + if (res.ok) { + const data = await res.json(); + const next: NodeUpdateStatus[] = data.nodes ?? []; + setUpdateStatuses(prev => + JSON.stringify(prev) === JSON.stringify(next) ? prev : next + ); + } + } catch { /* non-critical */ } + }, [isPro]); + + const triggerNodeUpdate = useCallback(async (nodeId: number) => { + const status = updateStatusesRef.current.find(s => s.nodeId === nodeId); + if (status?.type === 'local') { + setLocalUpdateConfirm(nodeId); + return; + } + + setUpdatingNodeId(nodeId); + try { + const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true }); + if (res.ok) { + toast.success(`Update initiated on ${status?.name ?? 'node'}.`); + fetchUpdateStatus(); + } else { + const err = await res.json().catch(() => ({})); + toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger update.'); + } + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Something went wrong.'); + } finally { + setUpdatingNodeId(null); + } + }, [fetchUpdateStatus]); + + const confirmLocalUpdate = useCallback(async () => { + const nodeId = localUpdateConfirm; + setLocalUpdateConfirm(null); + if (!nodeId) return; + + setUpdatingNodeId(nodeId); + try { + const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, { method: 'POST', localOnly: true }); + if (res.ok) { + setReconnecting(true); + } else { + const err = await res.json().catch(() => ({})); + toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger local update.'); + } + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Something went wrong.'); + } finally { + setUpdatingNodeId(null); + } + }, [localUpdateConfirm]); + + const triggerUpdateAll = useCallback(async () => { + try { + const res = await apiFetch('/fleet/update-all', { method: 'POST', localOnly: true }); + if (res.ok) { + const data = await res.json(); + if (data.updating?.length > 0) { + toast.success(`Update initiated on ${data.updating.length} node${data.updating.length > 1 ? 's' : ''}.`); + } else { + toast.success('All nodes are up to date.'); + } + fetchUpdateStatus(); + } else { + const err = await res.json().catch(() => ({})); + toast.error(err?.message || err?.error || err?.data?.error || 'Failed to trigger fleet update.'); + } + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Something went wrong.'); + } + }, [fetchUpdateStatus]); + useEffect(() => { fetchOverview(); fetchLabels(); - }, [fetchOverview, fetchLabels]); + fetchUpdateStatus(); + }, [fetchOverview, fetchLabels, fetchUpdateStatus]); // Pro: auto-refresh every 30s useEffect(() => { @@ -504,6 +710,22 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { return () => clearInterval(interval); }, [isPro, fetchOverview]); + // Fast poll (5s) when any node is actively updating — uses ref to avoid interval thrashing + const hasUpdatingRef = useRef(false); + useEffect(() => { + hasUpdatingRef.current = updateStatuses.some(s => s.updateStatus === 'updating'); + }, [updateStatuses]); + + useEffect(() => { + const id = setInterval(() => { + if (hasUpdatingRef.current) { + fetchUpdateStatus(); + fetchOverview(); + } + }, 5000); + return () => clearInterval(id); + }, [fetchUpdateStatus, fetchOverview]); + // --- Computed values --- const onlineNodes = useMemo(() => nodes.filter(n => n.status === 'online'), [nodes]); @@ -523,6 +745,16 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { const totalMemUsed = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.used ?? 0), 0); const totalMemTotal = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.total ?? 0), 0); + const updatableRemoteCount = useMemo( + () => updateStatuses.filter(s => s.updateAvailable && !s.updateStatus && s.type === 'remote').length, + [updateStatuses] + ); + + const updateStatusMap = useMemo( + () => new Map(updateStatuses.map(s => [s.nodeId, s])), + [updateStatuses] + ); + // --- Filtering & Sorting (Pro) --- const processedNodes = useMemo(() => { @@ -596,16 +828,34 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { {loading ? 'Loading...' : `${onlineCount} of ${nodes.length} nodes online · ${totalContainers} containers · ${totalStacks} stacks`}

- +
+ {isPro && ( + + )} + +
@@ -804,6 +1054,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { node={node} onNavigate={onNavigateToNode} labelMap={fleetStackLabelMap} + updateStatus={updateStatusMap.get(node.id)} + onUpdate={isPro ? triggerNodeUpdate : undefined} + updatingNodeId={updatingNodeId} /> ))}
@@ -863,6 +1116,201 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} + + {/* Reconnecting overlay — shown when local node is updating */} + {reconnecting && } + + {/* Node Updates modal */} + { setShowUpdateModal(open); if (!open) setModalSearch(''); }}> + + + Node Updates + Check and apply updates across your fleet nodes. + + + {checkingUpdates ? ( +
+ + Checking for updates... +
+ ) : updateStatuses.length === 0 ? ( +
+ No nodes found. +
+ ) : (() => { + const upToDate = updateStatuses.filter(s => !s.updateAvailable && !s.updateStatus).length; + const available = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length; + const updating = updateStatuses.filter(s => s.updateStatus === 'updating').length; + const failed = updateStatuses.filter(s => s.updateStatus === 'failed' || s.updateStatus === 'timeout').length; + const q = modalSearch.toLowerCase(); + const filtered = q ? updateStatuses.filter(s => s.name.toLowerCase().includes(q) || s.type.includes(q)) : updateStatuses; + + return ( + <> + {/* Summary stats */} +
+
+
{upToDate}
+
+ Up to date +
+
+
+
{available}
+
+ Available +
+
+
+
{updating}
+
+ Updating +
+
+
+
{failed}
+
+ Failed +
+
+
+ + {/* Search + gateway version */} +
+
+ + setModalSearch(e.target.value)} + className="h-8 pl-8 text-xs" + /> +
+
+ Gateway: v{updateStatuses[0]?.latestVersion} +
+
+ + {/* Table header */} +
+ Node + Type + Current + Latest + Status +
+ + {/* Node list */} +
+ {filtered.map(s => ( +
+ {/* Node name */} +
+
+ {s.type === 'local' + ? + : + } +
+ {s.name} +
+ + {/* Type */} + + {s.type} + + + {/* Current version */} + + {s.version ? `v${s.version}` : unknown} + + + {/* Latest version */} + + v{s.latestVersion} + + + {/* Status / Action */} +
+ {s.updateStatus && } + {!s.updateStatus && !s.updateAvailable && ( + + Up to date + + )} + {s.updateAvailable && !s.updateStatus && ( + + )} +
+
+ ))} + {filtered.length === 0 && ( +
+ No nodes match “{modalSearch}” +
+ )} +
+ + {/* Footer */} +
+ + {updatableRemoteCount > 0 && ( + + )} +
+ + ); + })()} +
+
+ + {/* Confirm dialog for local node update */} + { if (!open) setLocalUpdateConfirm(null); }}> + + + Update local node? + + This will pull the latest Sencho image and restart the server. The dashboard will be + briefly disconnected and will automatically reconnect when the update completes. + + + + Cancel + + + Update & Restart + + + + ); } diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index 1ecc09eb..21913276 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -20,6 +20,7 @@ export const CAPABILITIES = [ 'api-tokens', 'users', 'registries', + 'self-update', ] as const; export type Capability = (typeof CAPABILITIES)[number];