mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
Generated
+8
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
<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 |
@@ -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
|
||||
|
||||
@@ -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 “{modalSearch}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const CAPABILITIES = [
|
||||
'api-tokens',
|
||||
'users',
|
||||
'registries',
|
||||
'self-update',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
Reference in New Issue
Block a user