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
+6
View File
@@ -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)
+8
View File
@@ -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",
+2
View File
@@ -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",
+211 -2
View File
@@ -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<number, UpdateTracker>();
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<void> => {
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<void> => {
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<string, string>)?.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<void> => {
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<FleetNodeOverview> {
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();
@@ -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 {
+87
View File
@@ -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<void> {
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;
+6 -1
View File
@@ -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}",
+93
View File
@@ -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.
<Note>
Remote updates require **Sencho Pro** (Skipper or Admiral tier).
</Note>
## 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 1030 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.
<Frame>
<img src="/images/fleet/check-updates-modal.png" alt="Node Updates dialog showing update status for each node" />
</Frame>
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+145
View File
@@ -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
+466 -18
View File
@@ -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<string, StackLabel[]> }) {
function UpdateStatusBadge({ status }: { status: NodeUpdateStatus['updateStatus'] }) {
if (status === 'updating') return (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-info/15 text-info border-info/30 shrink-0">
<Loader2 className="w-2.5 h-2.5 mr-0.5 animate-spin" /> Updating
</Badge>
);
if (status === 'completed') return (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-success-muted text-success border-success/30 shrink-0">
<Check className="w-2.5 h-2.5 mr-0.5" /> Updated
</Badge>
);
if (status === 'timeout') return (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4 shrink-0">Timed out</Badge>
);
if (status === 'failed') return (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4 shrink-0">Failed</Badge>
);
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-[10px] backdrop-saturate-[1.15]">
<div className="text-center space-y-4">
{timedOut ? (
<>
<AlertTriangle className="w-10 h-10 text-warning mx-auto" strokeWidth={1.5} />
<h2 className="text-lg font-medium">Update timed out</h2>
<p className="text-sm text-muted-foreground max-w-sm">
The server has not come back within 5 minutes. Check the Docker host directly.
</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Try Reloading
</Button>
</>
) : (
<>
<Loader2 className="w-10 h-10 text-muted-foreground animate-spin mx-auto" strokeWidth={1.5} />
<h2 className="text-lg font-medium">Updating Sencho...</h2>
<p className="text-sm text-muted-foreground max-w-sm">
The server is pulling the latest image and restarting. This page will reload automatically.
</p>
<p className="text-xs text-muted-foreground tabular-nums">{elapsed}s elapsed</p>
</>
)}
</div>
</div>
);
}
function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, updatingNodeId }: { node: FleetNode; onNavigate: (nodeId: number, stackName: string) => void; labelMap?: Record<string, StackLabel[]>; updateStatus?: NodeUpdateStatus; onUpdate?: (nodeId: number) => void; updatingNodeId?: number | null }) {
const { isPro } = useLicense();
const [expanded, setExpanded] = useState(false);
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
@@ -312,19 +399,30 @@ function NodeCard({ node, onNavigate, labelMap }: { node: FleetNode; onNavigate:
</div>
<div className="min-w-0">
<h3 className="text-sm font-medium truncate">{node.name}</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant={isOnline ? 'default' : 'secondary'} className="text-[10px] px-1.5 py-0 h-4">
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<Badge variant={isOnline ? 'default' : 'secondary'} className="text-[10px] px-1.5 py-0 h-4 shrink-0">
{isOnline ? (
<><Wifi className="w-2.5 h-2.5 mr-0.5" /> Online</>
) : (
<><WifiOff className="w-2.5 h-2.5 mr-0.5" /> Offline</>
)}
</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
{node.type}
</Badge>
{updateStatus?.version && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 font-mono tabular-nums shrink-0">
v{updateStatus.version}
</Badge>
)}
{updateStatus?.updateStatus && <UpdateStatusBadge status={updateStatus.updateStatus} />}
{updateStatus?.updateAvailable && !updateStatus.updateStatus && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
Update available
</Badge>
)}
{isOnline && isCritical(node) && (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4">
<Badge variant="destructive" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" /> Critical
</Badge>
)}
@@ -386,6 +484,25 @@ function NodeCard({ node, onNavigate, labelMap }: { node: FleetNode; onNavigate:
</div>
)}
{/* Update button */}
{isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && onUpdate && (
<div className="mt-3 pt-3 border-t border-border/50">
<Button
variant="outline"
size="sm"
className="w-full h-7 text-xs"
onClick={() => onUpdate(node.id)}
disabled={updatingNodeId === node.id}
>
{updatingNodeId === node.id ? (
<><Loader2 className="w-3 h-3 mr-1.5 animate-spin" />Triggering...</>
) : (
<><Download className="w-3 h-3 mr-1.5" strokeWidth={1.5} />Update to v{updateStatus.latestVersion}</>
)}
</Button>
</div>
)}
{/* Offline placeholder */}
{!isOnline && (
<div className="flex items-center justify-center py-6 text-muted-foreground text-sm">
@@ -454,6 +571,15 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [labelFilters, setLabelFilters] = useState<Set<number>>(new Set());
const { isPro } = useLicense();
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false);
const [localUpdateConfirm, setLocalUpdateConfirm] = useState<number | null>(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<FleetPreferences>) => {
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`}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => fetchOverview(true)}
disabled={refreshing}
className="gap-2"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
<div className="flex items-center gap-2">
{isPro && (
<Button
variant="outline"
size="sm"
onClick={async () => {
setShowUpdateModal(true);
setCheckingUpdates(true);
await fetchUpdateStatus();
setCheckingUpdates(false);
}}
className="gap-2"
>
<Search className="w-4 h-4" />
Check Updates
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => fetchOverview(true)}
disabled={refreshing}
className="gap-2"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
<Tabs defaultValue="overview">
@@ -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}
/>
))}
</div>
@@ -863,6 +1116,201 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</TabsContent>
)}
</Tabs>
{/* Reconnecting overlay — shown when local node is updating */}
{reconnecting && <ReconnectingOverlay />}
{/* Node Updates modal */}
<Dialog open={showUpdateModal} onOpenChange={(open) => { setShowUpdateModal(open); if (!open) setModalSearch(''); }}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Node Updates</DialogTitle>
<DialogDescription className="sr-only">Check and apply updates across your fleet nodes.</DialogDescription>
</DialogHeader>
{checkingUpdates ? (
<div className="flex items-center justify-center py-16 text-muted-foreground text-sm gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Checking for updates...
</div>
) : updateStatuses.length === 0 ? (
<div className="flex items-center justify-center py-16 text-muted-foreground text-sm">
No nodes found.
</div>
) : (() => {
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 */}
<div className="grid grid-cols-4 gap-2">
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{upToDate}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<CircleCheck className="w-3 h-3 text-success" strokeWidth={1.5} /> Up to date
</div>
</div>
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{available}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<CircleAlert className="w-3 h-3 text-warning" strokeWidth={1.5} /> Available
</div>
</div>
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{updating}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<Loader2 className="w-3 h-3 text-info" strokeWidth={1.5} /> Updating
</div>
</div>
<div className="rounded-lg border border-card-border bg-card px-3 py-2 text-center">
<div className="text-lg font-medium tabular-nums tracking-tight text-stat-value">{failed}</div>
<div className="text-[10px] text-stat-subtitle flex items-center justify-center gap-1">
<AlertTriangle className="w-3 h-3 text-destructive/70" strokeWidth={1.5} /> Failed
</div>
</div>
</div>
{/* Search + gateway version */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Filter nodes..."
value={modalSearch}
onChange={e => setModalSearch(e.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
<div className="text-[11px] text-muted-foreground shrink-0">
Gateway: <span className="font-mono tabular-nums text-foreground">v{updateStatuses[0]?.latestVersion}</span>
</div>
</div>
{/* Table header */}
<div className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 px-3 text-[10px] text-muted-foreground uppercase tracking-wider">
<span>Node</span>
<span>Type</span>
<span>Current</span>
<span>Latest</span>
<span className="text-right">Status</span>
</div>
{/* Node list */}
<div className="flex-1 overflow-y-auto space-y-1 min-h-0 max-h-[40vh] -mx-1 px-1">
{filtered.map(s => (
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 items-center rounded-lg border border-card-border bg-card px-3 py-2">
{/* Node name */}
<div className="flex items-center gap-2.5 min-w-0">
<div className={`flex items-center justify-center w-6 h-6 rounded-md shrink-0 ${s.updateAvailable && !s.updateStatus ? 'bg-warning/10' : 'bg-muted'}`}>
{s.type === 'local'
? <Monitor className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
: <Globe className={`w-3 h-3 ${s.updateAvailable && !s.updateStatus ? 'text-warning' : 'text-muted-foreground'}`} strokeWidth={1.5} />
}
</div>
<span className="text-sm font-medium truncate">{s.name}</span>
</div>
{/* Type */}
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 w-fit">
{s.type}
</Badge>
{/* Current version */}
<span className="text-xs font-mono tabular-nums text-muted-foreground">
{s.version ? `v${s.version}` : <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
</span>
{/* Latest version */}
<span className="text-xs font-mono tabular-nums">
v{s.latestVersion}
</span>
{/* Status / Action */}
<div className="flex justify-end">
{s.updateStatus && <UpdateStatusBadge status={s.updateStatus} />}
{!s.updateStatus && !s.updateAvailable && (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">
<Check className="w-2.5 h-2.5 mr-0.5" /> Up to date
</Badge>
)}
{s.updateAvailable && !s.updateStatus && (
<Button
variant="outline"
size="sm"
className="h-6 text-[11px] px-2.5"
onClick={() => triggerNodeUpdate(s.nodeId)}
disabled={updatingNodeId === s.nodeId}
>
{updatingNodeId === s.nodeId ? (
<><Loader2 className="w-3 h-3 mr-1 animate-spin" />Updating</>
) : (
<><Download className="w-3 h-3 mr-1" strokeWidth={1.5} />Update</>
)}
</Button>
)}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
No nodes match &ldquo;{modalSearch}&rdquo;
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-2 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground"
onClick={async () => { setCheckingUpdates(true); await fetchUpdateStatus(); setCheckingUpdates(false); }}
>
<RefreshCw className="w-3 h-3 mr-1.5" strokeWidth={1.5} />
Recheck
</Button>
{updatableRemoteCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={triggerUpdateAll}
className="h-7 gap-1.5"
>
<Download className="w-3.5 h-3.5" strokeWidth={1.5} />
Update All ({updatableRemoteCount})
</Button>
)}
</div>
</>
);
})()}
</DialogContent>
</Dialog>
{/* Confirm dialog for local node update */}
<AlertDialog open={localUpdateConfirm !== null} onOpenChange={(open) => { if (!open) setLocalUpdateConfirm(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update local node?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmLocalUpdate}>
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
Update & Restart
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+1
View File
@@ -20,6 +20,7 @@ export const CAPABILITIES = [
'api-tokens',
'users',
'registries',
'self-update',
] as const;
export type Capability = (typeof CAPABILITIES)[number];