diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts index 5ee320fe..0791dc9a 100644 --- a/backend/src/__tests__/monitor-service.test.ts +++ b/backend/src/__tests__/monitor-service.test.ts @@ -1079,9 +1079,9 @@ describe('MonitorService - Sencho version check', () => { (svc as any).lastVersionCheckAt = 0; await (svc as any).evaluate(); - expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.46.0')); + expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0')); // Message must include the real running version, not "0.0.0". - expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('currently running 0.45.0')); + expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('currently running 0.45.0')); expect(mockSetSystemState).toHaveBeenCalledWith('last_sencho_update_notified_version', '0.46.0'); }); @@ -1095,7 +1095,7 @@ describe('MonitorService - Sencho version check', () => { (svc as any).lastVersionCheckAt = 0; await (svc as any).evaluate(); - expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.46.0')); + expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0')); }); it('handles version check failure gracefully', async () => { @@ -1107,7 +1107,7 @@ describe('MonitorService - Sencho version check', () => { // Should not throw await expect((svc as any).evaluate()).resolves.toBeUndefined(); - expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'system', expect.stringContaining('available')); + expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('available')); }); it('respects the 6-hour cooldown interval', async () => { @@ -1134,7 +1134,7 @@ describe('MonitorService - Sencho version check', () => { (svc as any).lastVersionCheckAt = 0; await (svc as any).evaluate(); - expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.46.0')); + expect(mockDispatchAlert).not.toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.46.0')); expect(mockSetSystemState).not.toHaveBeenCalledWith('last_sencho_update_notified_version', expect.anything()); // Should not have even attempted the lookup. expect(mockGetLatestVersion).not.toHaveBeenCalled(); @@ -1193,7 +1193,7 @@ describe('MonitorService - Sencho version check', () => { (svc as any).lastVersionCheckAt = 0; await (svc as any).evaluate(); - expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'system', expect.stringContaining('0.47.0')); + expect(mockDispatchAlert).toHaveBeenCalledWith('info', 'node_update_available', expect.stringContaining('0.47.0')); expect(store.last_sencho_update_notified_version).toBe('0.47.0'); }); }); diff --git a/backend/src/__tests__/node-update-skips.test.ts b/backend/src/__tests__/node-update-skips.test.ts new file mode 100644 index 00000000..2f75926e --- /dev/null +++ b/backend/src/__tests__/node-update-skips.test.ts @@ -0,0 +1,206 @@ +/** + * Skip-version semantics: POST/DELETE /api/fleet/nodes/:nodeId/skip-version + * and the skip metadata surfaced by GET /api/fleet/update-status. + */ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let adminAuth: string; +let viewerAuth: string; +let localNodeId: number; +let db: import('../services/DatabaseService').DatabaseService; + +function signToken(username: string, role: string) { + return jwt.sign( + { username, role, email: `${username}@test.local` }, + TEST_JWT_SECRET, + { algorithm: 'HS256', expiresIn: '2h', issuer: 'sencho' }, + ); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + adminAuth = `Bearer ${signToken(TEST_USERNAME, 'admin')}`; + viewerAuth = `Bearer ${signToken('viewer', 'viewer')}`; + const dbModule = await import('../services/DatabaseService'); + db = dbModule.DatabaseService.getInstance(); + localNodeId = db.getNodes().find(n => n.type === 'local')!.id; + const index = await import('../index'); + app = index.app; +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +afterEach(() => { + vi.restoreAllMocks(); + // Clear skip state between tests + db.deleteNodeUpdateSkip(localNodeId); +}); + +describe('POST /api/fleet/nodes/:nodeId/skip-version', () => { + it('rejects non-admin with 403', async () => { + const res = await request(app) + .post(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', viewerAuth) + .send({ version: '0.99.0' }); + expect(res.status).toBe(401); + }); + + it('rejects missing body with 400', async () => { + const res = await request(app) + .post(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', adminAuth) + .send({}); + expect(res.status).toBe(400); + }); + + it('rejects non-semver version with 400', async () => { + const res = await request(app) + .post(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', adminAuth) + .send({ version: 'not-a-version' }); + expect(res.status).toBe(400); + }); + + it('rejects empty version string with 400', async () => { + const res = await request(app) + .post(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', adminAuth) + .send({ version: '' }); + expect(res.status).toBe(400); + }); + + it('rejects too-long version with 400', async () => { + const res = await request(app) + .post(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', adminAuth) + .send({ version: 'a'.repeat(65) }); + expect(res.status).toBe(400); + }); + + it('rejects unknown node with 404', async () => { + const res = await request(app) + .post('/api/fleet/nodes/99999/skip-version') + .set('Authorization', adminAuth) + .send({ version: '0.99.0' }); + expect(res.status).toBe(404); + }); + + it('persists skip and returns 204', async () => { + const res = await request(app) + .post(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', adminAuth) + .send({ version: '0.99.0' }); + expect(res.status).toBe(204); + + const skip = db.getNodeUpdateSkip(localNodeId); + expect(skip).not.toBeNull(); + expect(skip!.skippedVersion).toBe('0.99.0'); + expect(skip!.skippedBy).toBe(TEST_USERNAME); + }); + + it('normalizes v-prefixed version on persist', async () => { + const res = await request(app) + .post(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', adminAuth) + .send({ version: 'v0.99.0' }); + expect(res.status).toBe(204); + + const skip = db.getNodeUpdateSkip(localNodeId); + expect(skip).not.toBeNull(); + expect(skip!.skippedVersion).toBe('0.99.0'); // stored without v prefix + }); + + it('replaces existing skip on second POST', async () => { + // First skip + await request(app) + .post(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', adminAuth) + .send({ version: '0.99.0' }); + + // Second skip with different version + const res = await request(app) + .post(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', adminAuth) + .send({ version: '1.0.0' }); + expect(res.status).toBe(204); + + const skip = db.getNodeUpdateSkip(localNodeId); + expect(skip!.skippedVersion).toBe('1.0.0'); + }); +}); + +describe('DELETE /api/fleet/nodes/:nodeId/skip-version', () => { + it('rejects non-admin with 403', async () => { + const res = await request(app) + .delete(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', viewerAuth); + expect(res.status).toBe(401); + }); + + it('rejects unknown node with 404', async () => { + const res = await request(app) + .delete('/api/fleet/nodes/99999/skip-version') + .set('Authorization', adminAuth); + expect(res.status).toBe(404); + }); + + it('clears skip and returns 204', async () => { + db.setNodeUpdateSkip(localNodeId, '0.99.0', TEST_USERNAME); + expect(db.getNodeUpdateSkip(localNodeId)).not.toBeNull(); + + const res = await request(app) + .delete(`/api/fleet/nodes/${localNodeId}/skip-version`) + .set('Authorization', adminAuth); + expect(res.status).toBe(204); + expect(db.getNodeUpdateSkip(localNodeId)).toBeNull(); + }); +}); + +describe('GET /api/fleet/update-status skip metadata', () => { + it('returns skipActive=false when no skip exists', async () => { + const res = await request(app) + .get('/api/fleet/update-status') + .set('Authorization', adminAuth); + expect(res.status).toBe(200); + const local = res.body.nodes.find((n: any) => n.nodeId === localNodeId); + expect(local).toBeDefined(); + expect(local.skipActive).toBe(false); + expect(local.skippedVersion).toBeNull(); + }); + + it('skip metadata fields are present on every node', async () => { + const res = await request(app) + .get('/api/fleet/update-status') + .set('Authorization', adminAuth); + expect(res.status).toBe(200); + for (const n of res.body.nodes) { + expect(n).toHaveProperty('skipActive'); + expect(n).toHaveProperty('skippedVersion'); + } + }); +}); + +describe('node_update_skips table lifecycle', () => { + it('deleteNode removes the skip row', async () => { + // This test validates the cleanup pattern by using the get/delete + // methods directly (the full deleteNode flow requires auth context). + db.setNodeUpdateSkip(localNodeId, '0.99.0', TEST_USERNAME); + expect(db.getNodeUpdateSkip(localNodeId)).not.toBeNull(); + + // Simulate what deleteNode does: manual delete + db.deleteNodeUpdateSkip(localNodeId); + expect(db.getNodeUpdateSkip(localNodeId)).toBeNull(); + }); + + it('deleteNodeUpdateSkip is idempotent for missing rows', () => { + // Should not throw when row doesn't exist + expect(() => db.deleteNodeUpdateSkip(99999)).not.toThrow(); + }); +}); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 35caefc6..d276ce0d 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -20,7 +20,7 @@ import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierG import { scheduleLocalUpdate } from './license'; import { runPolicyGate, assertPolicyGateAllows, buildPolicyGateOptions } from '../helpers/policyGate'; import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, pickDossierFields, dossierHasContent, type SnapshotNodeData, type SnapshotDocumentation } from '../utils/snapshot-capture'; -import { getLatestVersion } from '../utils/version-check'; +import { getLatestVersion, getLatestRelease } from '../utils/version-check'; import { isValidStackName } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; @@ -1009,6 +1009,19 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp ) { invalidateRemoteMetaCache(node.id); } + + // Apply skip-version: suppress updateAvailable when the node has skipped + // the effective compare target (which may be the gateway fallback, not + // just the raw GitHub latest). + const skipRow = db.getNodeUpdateSkip(node.id); + let skipActive = false; + let skippedVersion: string | null = null; + if (skipRow && compareValid && skipRow.skippedVersion === compareVersion) { + updateAvailable = false; + skipActive = true; + skippedVersion = skipRow.skippedVersion; + } + return { nodeId: node.id, name: node.name, @@ -1018,6 +1031,8 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp updateAvailable, updateStatus: currentTracker?.status ?? null, error: currentTracker?.error ?? null, + skipActive, + skippedVersion, }; }), ); @@ -1034,6 +1049,8 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp updateAvailable: false, updateStatus: null, error: null, + skipActive: false, + skippedVersion: null, }; }); @@ -1047,6 +1064,21 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp res.status(500).json({ error: 'Failed to fetch update status' }); } }); +// Release notes for the Changelog tab in the Node Updates sheet. +fleetRouter.get('/update-status/release-notes', authMiddleware, async (req: Request, res: Response): Promise => { + try { + const forceRefresh = req.query.recheck === 'true'; + const release = await getLatestRelease(forceRefresh); + res.json({ + releaseNotes: release?.body ?? null, + htmlUrl: release?.html_url ?? null, + }); + } catch (error) { + console.error('[Fleet] Release notes error:', error); + res.status(500).json({ error: 'Failed to fetch release notes' }); + } +}); + // Pilot loopback targets carry an empty apiToken because the tunnel bridge // re-injects admin auth; sending a malformed `Bearer ` header would 401 on @@ -1061,6 +1093,57 @@ function postSystemUpdate(target: { apiUrl: string; apiToken: string }) { }); } +// --- Skip-version endpoints --- + +fleetRouter.post('/nodes/:nodeId/skip-version', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + try { + const nodeId = parseIntParam(req, res, 'nodeId'); + if (nodeId === null) { + return; + } + const db = DatabaseService.getInstance(); + const node = db.getNode(nodeId); + if (!node) { + res.status(404).json({ error: 'Node not found' }); + return; + } + const { version } = req.body ?? {}; + const normalized = typeof version === 'string' ? semver.valid(version) : null; + if (!normalized || version.length > 64) { + res.status(400).json({ error: 'Invalid version' }); + return; + } + const username = req.user?.username ?? 'unknown'; + db.setNodeUpdateSkip(nodeId, normalized, username); + res.status(204).end(); + } catch (error) { + console.error('[Fleet] Skip-version error:', error); + res.status(500).json({ error: 'Failed to skip version' }); + } + }); + +fleetRouter.delete('/nodes/:nodeId/skip-version', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + try { + const nodeId = parseIntParam(req, res, 'nodeId'); + if (nodeId === null) { + return; + } + const db = DatabaseService.getInstance(); + const node = db.getNode(nodeId); + if (!node) { + res.status(404).json({ error: 'Node not found' }); + return; + } + db.deleteNodeUpdateSkip(nodeId); + res.status(204).end(); + } catch (error) { + console.error('[Fleet] Unskip-version error:', error); + res.status(500).json({ error: 'Failed to unskip version' }); + } + }); + fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { @@ -1166,6 +1249,12 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon if (tracker && (tracker.status === 'timeout' || tracker.status === 'failed' || tracker.status === 'completed')) { updateTracker.delete(node.id); } + // Skip nodes that have skipped the current compare target version. + const skipRow = db.getNodeUpdateSkip(node.id); + if (skipRow && compareValid && skipRow.skippedVersion === compareVersion) { + if (debug) console.debug('[Fleet:debug] Update-all skipping', node.name, '(version', compareVersion, 'skipped)'); + return false; + } return true; }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 293d5d40..ed10fc03 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -848,6 +848,7 @@ export class DatabaseService { this.migrateFleetSyncStickyError(); this.migrateStackDossierHashes(); this.migrateGitSourceMultiFile(); + this.migrateNodeUpdateSkips(); // Reset the cache once at end of constructor in case any migration // populated it via getGlobalSettings() and a subsequent migration @@ -1781,6 +1782,22 @@ export class DatabaseService { } } + private migrateNodeUpdateSkips(): void { + try { + this.db.prepare(` + CREATE TABLE IF NOT EXISTS node_update_skips ( + node_id INTEGER PRIMARY KEY, + skipped_version TEXT NOT NULL, + skipped_at INTEGER NOT NULL, + skipped_by TEXT NOT NULL, + FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE + ) + `).run(); + } catch (e) { + console.warn('[DatabaseService] node_update_skips migration:', (e as Error).message); + } + } + private migrateScanPolicyFleetColumns(): void { this.tryAddColumn('scan_policies', 'node_identity', "TEXT NOT NULL DEFAULT ''"); this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0'); @@ -2079,6 +2096,30 @@ export class DatabaseService { return !!row?.mesh_enabled; } + // --- Node update skips --- + + public getNodeUpdateSkip(nodeId: number): { skippedVersion: string; skippedAt: number; skippedBy: string } | null { + const row = this.db.prepare( + 'SELECT skipped_version, skipped_at, skipped_by FROM node_update_skips WHERE node_id = ?' + ).get(nodeId) as { skipped_version: string; skipped_at: number; skipped_by: string } | undefined; + if (!row) return null; + return { + skippedVersion: row.skipped_version, + skippedAt: row.skipped_at, + skippedBy: row.skipped_by, + }; + } + + public setNodeUpdateSkip(nodeId: number, version: string, username: string): void { + this.db.prepare( + 'INSERT OR REPLACE INTO node_update_skips (node_id, skipped_version, skipped_at, skipped_by) VALUES (?, ?, ?, ?)' + ).run(nodeId, version, Date.now(), username); + } + + public deleteNodeUpdateSkip(nodeId: number): void { + this.db.prepare('DELETE FROM node_update_skips WHERE node_id = ?').run(nodeId); + } + // --- Agents --- public getAgents(nodeId: number): Agent[] { @@ -3043,6 +3084,7 @@ export class DatabaseService { this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id); this.deleteRoleAssignmentsByResource('node', String(id)); this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id); + this.db.prepare('DELETE FROM node_update_skips WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id); })(); } diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index a96289a7..6e136f9a 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -510,7 +510,7 @@ export class MonitorService { try { const notifier = NotificationService.getInstance(); - await notifier.dispatchAlert('info', 'system', + await notifier.dispatchAlert('info', 'node_update_available', `Sencho ${latest} is available (currently running ${currentVersion}). Visit the Fleet dashboard to update.`); db.setSystemState(stateKey, latest); if (isDebugEnabled()) console.debug(`[Monitor:diag] Dispatched version notification: ${currentVersion} -> ${latest}`); diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index b5771b50..75bc3b51 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -32,6 +32,7 @@ export type NotificationCategory = | 'update_started' | 'health_gate_passed' | 'health_gate_failed' + | 'node_update_available' | 'system'; export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [ @@ -40,7 +41,7 @@ export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [ 'autoheal_triggered', 'monitor_alert', 'scan_finding', 'blueprint_deployed', 'blueprint_deployment_failed', 'blueprint_drift_detected', 'blueprint_drift_correction_failed', - 'system', + 'node_update_available', 'system', ]; /** Webhook timeout: 10 seconds per external dispatch call. */ diff --git a/backend/src/utils/version-check.ts b/backend/src/utils/version-check.ts index af881155..a531c92f 100644 --- a/backend/src/utils/version-check.ts +++ b/backend/src/utils/version-check.ts @@ -74,3 +74,43 @@ export async function getLatestVersion(forceRefresh = false): Promise { + const res = await fetch('https://api.github.com/repos/studio-saelix/sencho/releases/latest', { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Sencho' }, + signal: AbortSignal.timeout(10000), + }); + if (!res.ok) throw new Error(`GitHub releases API returned ${res.status}`); + const data = await res.json() as { tag_name?: string; body?: string; html_url?: string }; + if (!data.tag_name) throw new Error('Release response missing tag_name'); + return { + tag_name: data.tag_name, + body: data.body ?? '', + html_url: data.html_url ?? `https://github.com/studio-saelix/sencho/releases/tag/${data.tag_name}`, + }; +} + +const LATEST_RELEASE_CACHE_KEY = 'latest-release'; + +export async function getLatestRelease(forceRefresh = false): Promise { + if (forceRefresh) { + CacheService.getInstance().invalidate(LATEST_RELEASE_CACHE_KEY); + } + try { + return await CacheService.getInstance().getOrFetch( + LATEST_RELEASE_CACHE_KEY, + LATEST_VERSION_CACHE_TTL, + fetchReleaseDetails, + ); + } catch { + return null; + } +} diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 1cfa9a44..159b58fa 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -239,18 +239,16 @@ The map is fleet-wide: the control instance gathers each node's view and merges ## Node Updates -Click **Check Updates** in the page header to open the **Node Updates** sheet. From here you can read every node's current Sencho version, see which nodes have an update available, and trigger updates one node at a time or across the whole fleet. +Click **Check Updates** in the page header to open the **Node Updates** sheet. From here you can read every node's current Sencho version, see which nodes have an update available, and trigger updates one node at a time or across the whole fleet. The sheet has two tabs: **Nodes** (the node table and summary) and **Changelog** (release notes for the available Sencho version, fetched from the GitHub Releases page). Node updates sheet titled 'Node updates · 8 nodes · 1 update available'. A Recheck button and an 'Update all (1)' button sit in the header. Below them, four summary cards read '7 Up to date', '1 Available', '0 Updating', '0 Failed'. A 'Filter nodes…' search box sits above the node table, which has columns Node / Type / Current / Latest / Status. Seven rows show a green 'Up to date' badge; the sencho-pilot-test row reports Current 'unknown' and surfaces an Update button on the right. The footer reads 'LATEST VERSION v0.76.3'. -The sheet has four sections from top to bottom. - ### Header actions - **Recheck** re-queries every node's `/api/meta` endpoint and re-resolves the latest available Sencho version from GitHub Releases (with a Docker Hub fallback if the GitHub API is unreachable). The button disables and shows a spinner while the check is in flight. -- **Update all (n)** carries the count of remote nodes with a pending update. Clicking it dispatches the update to every remote with a known current and latest version. +- **Update all (n)** carries the count of remote nodes with a pending update. Clicking it dispatches the update to every remote with a known current and latest version. Nodes with a skipped version are excluded. ### Summary cards @@ -273,10 +271,20 @@ The table lists every registered node, filtered by the search box at the top. Co | **Type** | `local` or `remote` outline pill | | **Current** | The node's reported Sencho version, in mono. Reads `unknown` if the node has not reported (offline, unreachable, or never connected). | | **Latest** | The newest published Sencho release. Highlighted when newer than Current. | -| **Status** | Either an `Up to date` success badge, an `Update` button (per-row), or an in-progress / failed badge with retry and dismiss controls. | +| **Status** | Either an `Up to date` success badge, an `Update` button (per-row), an in-progress / failed badge with retry and dismiss controls, or a `Skipped` badge when the version has been deferred. | The latest-version label is resolved from the GitHub Releases API (with a Docker Hub fallback) and cached for 30 minutes. **Recheck** flushes the cache and re-resolves immediately. +### Skipping a version + +When a node has an update available and both its current version and the target version are known, a **Skip** button appears next to the Update button. Skipping a version hides the update notification for that node until a newer version is released. The node is also excluded from **Update all** while the skip is active. + +Skipped nodes show a **Skipped vX.Y.Z** badge and an **Unskip** button. Unskipping re-enables the update prompt and returns the node to the **Update all** pool. Skipped state persists across restarts and reloads. + +### Changelog tab + +The **Changelog** tab shows the release notes for the latest published Sencho version, fetched from the GitHub Releases page. It displays the raw release notes alongside a link to view the full release on GitHub. If the release notes cannot be loaded, a message is shown in place. + ### What happens when you click Update When you click **Update** on a remote row, Sencho dispatches the update command to that remote's API. The remote pulls the latest Docker image directly, then spawns a short-lived helper container that bind-mounts the compose working directory from the host and runs `docker compose up -d --force-recreate `. The remote restarts with the new image; the table cell flips from **Updating** to a green **Updated** badge once the gateway detects the version change. The **Updated** state stays visible for a few seconds before the row settles back to **Up to date**. @@ -284,7 +292,7 @@ When you click **Update** on a remote row, Sencho dispatches the update command When you click **Update** on the local row, a confirmation dialog appears first ("Update local node"). Confirming kicks off the same pull-and-recreate flow on the local Sencho instance. Because the gateway is restarting itself, a full-screen reconnecting overlay takes over the browser tab. The overlay polls `/api/health` every 3 seconds and dismisses itself once the new gateway answers. If the gateway has not returned within 5 minutes the overlay switches to a *Taking longer than expected* state with a **Reload to check** button rather than waiting indefinitely. A large image pull can legitimately run past that window, so this state is not a failure on its own. - Triggering updates (per-row and **Update all**) requires the resolved user to have the admin role. Operator and viewer roles can read update status but cannot initiate updates. + Triggering updates (per-row and **Update all**), skipping versions, and unskipping versions all require the **admin** role. Viewer and operator roles can read update status, the changelog, and the skipping state but cannot change them. ## How fleet data is fetched diff --git a/docs/features/remote-updates.mdx b/docs/features/remote-updates.mdx index a1e1fcfa..ed898ea1 100644 --- a/docs/features/remote-updates.mdx +++ b/docs/features/remote-updates.mdx @@ -38,6 +38,8 @@ There are two surfaces that initiate an update on a remote node: - The **Update** button on a row inside the Node updates sheet. - The **Update to vX.Y.Z** outline button along the bottom of any online node card on the Fleet grid. +The **Skip** button next to Update hides the update prompt for that node and excludes it from **Update all** until a newer version is released. The skip is per-node and per-version: when the next Sencho release comes out, the skip clears automatically and the update surfaces again. Skipping requires the admin role. + Node card for the Opsix remote showing an Online badge, the version chip 'v0.76.6', a warning 'Update available' pill, the Running / Stopped / Stacks counters (4 / 0 / 3), CPU / RAM / Disk usage bars, and an outline 'Update to v0.76.7' button across the bottom of the card. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 585b3834..a704df31 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2372,11 +2372,96 @@ paths: type: string nullable: true enum: [updating, completed, timeout, failed, null] + skipActive: + type: boolean + skippedVersion: + type: string + nullable: true "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /api/fleet/nodes/{nodeId}/skip-version: + post: + operationId: skipNodeVersion + tags: [Fleet] + summary: Skip a node version + description: Marks a version as skipped for a node, hiding the update prompt and excluding it from update-all. Admin only. + parameters: + - name: nodeId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [version] + properties: + version: + type: string + description: Semver version string to skip. + responses: + "204": + description: Skip recorded. + "400": + description: Invalid parameters. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: Node not found. + delete: + operationId: unskipNodeVersion + tags: [Fleet] + summary: Clear a node version skip + description: Removes the skipped-version record for a node. Admin only. + parameters: + - name: nodeId + in: path + required: true + schema: + type: integer + responses: + "204": + description: Skip cleared. + "400": + description: Invalid parameters. + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: Node not found. + + /api/fleet/update-status/release-notes: + get: + operationId: getFleetReleaseNotes + tags: [Fleet] + summary: Release notes for the latest Sencho version + description: Returns the GitHub release body and URL for the latest Sencho release, used by the Changelog tab. + responses: + "200": + description: Release notes. + content: + application/json: + schema: + type: object + properties: + releaseNotes: + type: string + nullable: true + htmlUrl: + type: string + nullable: true + "401": + $ref: "#/components/responses/Unauthorized" + /api/fleet/nodes/{nodeId}/update: post: operationId: triggerNodeUpdate diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index e23d9759..8ecd05a3 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -54,6 +54,7 @@ import { BESPOKE_MOBILE_VIEWS } from './EditorLayout/mobile-treatments'; import { CapabilityGate } from './CapabilityGate'; import { HubOnlyGate } from './HubOnlyGate'; import type { SectionId } from './settings/types'; +import type { NotificationItem } from './dashboard/types'; // These bespoke phone screens reuse the desktop view's component (with a mobile // branch), code-split exactly like the desktop content path so the heavy chunks @@ -295,12 +296,15 @@ export default function EditorLayout() { // Optimistically flip to the detail surface the instant a row is tapped, // before loadFile's fetch resolves selectedFile; cleared once it settles. const [pendingDetailStack, setPendingDetailStack] = useState(null); + const [fleetUpdatesIntent, setFleetUpdatesIntent] = useState<{ tab: 'nodes' | 'changelog' } | null>(null); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect if (!isFileLoading && pendingDetailStack) setPendingDetailStack(null); }, [isFileLoading, pendingDetailStack]); + const handleFleetUpdatesIntentConsumed = useCallback(() => setFleetUpdatesIntent(null), []); + const { surface: mobileSurface, detailReady, detailOpen } = deriveMobileSurface({ activeView, selectedFile, @@ -404,6 +408,32 @@ export default function EditorLayout() { } }, [stackActions, setActiveView, isMobile, navigateMobileAware]); + // Notification navigation: node_update_available notifications route to the + // Fleet view and open the Node Updates sheet (desktop only). The intent state + // handles both cross-view navigation and same-view re-entry (handleNavigate + // returns early when already on Fleet, but the state change triggers render). + const handleNotificationNavigate = useCallback((notif: NotificationItem) => { + if (notif.category === 'node_update_available') { + if (isMobile) { + navigateMobileAware('fleet'); + } else { + setFleetUpdatesIntent({ tab: 'nodes' }); + handleNavigate('fleet'); + } + return; + } + stackActions.navigateToNotification(notif); + }, [isMobile, navigateMobileAware, handleNavigate, stackActions]); + + const handleNotificationNavigateChangelog = useCallback(() => { + if (isMobile) { + navigateMobileAware('fleet'); + } else { + setFleetUpdatesIntent({ tab: 'changelog' }); + handleNavigate('fleet'); + } + }, [isMobile, navigateMobileAware, handleNavigate]); + const renderEditor = (headerActions?: ReactNode) => ( ); const themeSwitchEl = openSettings('appearance')} />; @@ -727,6 +758,8 @@ export default function EditorLayout() { onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }} onOpenSettingsSection={(section) => openSettings(section)} onClearNotifications={clearAllNotifications} + fleetUpdatesIntent={fleetUpdatesIntent} + onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed} securityTab={securityTab} onSecurityTabChange={setSecurityTab} renderEditor={renderEditor} diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index 03ce4fe4..1a2ee2aa 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -87,6 +87,8 @@ export interface ViewRouterProps { onClearNotifications: () => void; securityTab: SecurityTab; onSecurityTabChange: (tab: SecurityTab) => void; + fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null; + onFleetUpdatesIntentConsumed?: () => void; // Render slot for the inline editor view. Kept as a callback so the // (large) editor JSX is only allocated when activeView === 'editor', // not on every parent render that lands on a different view. @@ -112,6 +114,8 @@ export function ViewRouter({ onClearNotifications, securityTab, onSecurityTabChange, + fleetUpdatesIntent, + onFleetUpdatesIntentConsumed, renderEditor, }: ViewRouterProps): ReactNode { const { can } = useAuth(); @@ -175,7 +179,11 @@ export function ViewRouter({ - + diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index ab7da51e..8f079fd2 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react'; import { RefreshCw, Camera, FileDown, Network, SlidersHorizontal, @@ -32,9 +33,11 @@ import { useNodeActions } from './nodes/useNodeActions'; interface FleetViewProps { onNavigateToNode: (nodeId: number, stackName: string) => void; + fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null; + onFleetUpdatesIntentConsumed?: () => void; } -export function FleetView({ onNavigateToNode }: FleetViewProps) { +export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdatesIntentConsumed }: FleetViewProps) { const { isPaid } = useLicense(); const { isAdmin } = useAuth(); @@ -50,6 +53,17 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { updateStatuses: updateStatus.updateStatuses, }); + const [initialUpdatesTab, setInitialUpdatesTab] = useState<'nodes' | 'changelog'>('nodes'); + + useEffect(() => { + if (fleetUpdatesIntent) { + setInitialUpdatesTab(fleetUpdatesIntent.tab); + updateStatus.setShowUpdateModal(true); + updateStatus.fetchUpdateStatus(); + onFleetUpdatesIntentConsumed?.(); + } + }, [fleetUpdatesIntent, updateStatus, onFleetUpdatesIntentConsumed]); + const { mastheadStats, lastSyncAt, loading, refreshing } = overview; const { openCreate, openEdit, openDelete, NodeActionModals } = useNodeActions({ @@ -248,6 +262,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { updateStatuses={updateStatus.updateStatuses} updatingNodeId={updateStatus.updatingNodeId} isAdmin={isAdmin} + initialTab={initialUpdatesTab} fetchUpdateStatus={updateStatus.fetchUpdateStatus} triggerNodeUpdate={updateStatus.triggerNodeUpdate} retryNodeUpdate={updateStatus.retryNodeUpdate} diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 7ec1ec52..587a933d 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -217,11 +217,16 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u onDismiss={isAdmin && onDismissUpdate ? () => onDismissUpdate(node.id) : undefined} /> )} - {updateStatus?.updateAvailable && !updateStatus.updateStatus && ( + {updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && ( Update available )} + {updateStatus?.skipActive && ( + + Skipped + + )} {isOnline && isCritical(node) && ( Critical @@ -295,7 +300,7 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u )} {/* Update button (mutating action: admin only, matches the requireAdmin route guard) */} - {isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && onUpdate && isAdmin && ( + {isOnline && updateStatus?.updateAvailable && !updateStatus.updateStatus && !updateStatus?.skipActive && onUpdate && isAdmin && (
-
+
Node Type Current @@ -176,7 +304,7 @@ export function NodeUpdatesSheet({
{filtered.map(s => ( -
+
{s.type === 'local' @@ -195,7 +323,7 @@ export function NodeUpdatesSheet({ {formatVersion(s.latestVersion) ?? unknown} -
+
{s.updateStatus && ( dismissNodeUpdate(s.nodeId) : undefined} /> )} - {!s.updateStatus && !s.updateAvailable && ( + {!s.updateStatus && !s.updateAvailable && !s.skipActive && ( Up to date )} - {s.updateAvailable && !s.updateStatus && isAdmin && ( + {s.skipActive && ( + + Skipped {formatVersion(s.skippedVersion)} + + )} + {s.skipActive && isAdmin && ( + + )} + {s.updateAvailable && !s.updateStatus && !s.skipActive && isAdmin && ( + )} + {s.updateAvailable && !s.updateStatus && !s.skipActive && !isAdmin && ( Available diff --git a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx index 42e86fb7..ae3cd48b 100644 --- a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx @@ -34,7 +34,12 @@ function baseProps(overrides: Partial apiFetchMock.mockReset()); +beforeEach(() => { + apiFetchMock.mockReset(); + // Default: release-notes fetch returns empty notes (called by useEffect on mount). + // Tests that need specific apiFetch responses override this. + apiFetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ releaseNotes: null, htmlUrl: null }) }); +}); afterEach(() => vi.clearAllMocks()); describe('NodeUpdatesSheet', () => { diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts index 7bc4255a..d6528d23 100644 --- a/frontend/src/components/FleetView/types.ts +++ b/frontend/src/components/FleetView/types.ts @@ -40,6 +40,8 @@ export interface NodeUpdateStatus { updateAvailable: boolean; updateStatus: 'updating' | 'completed' | 'timeout' | 'failed' | null; error?: string | null; + skipActive?: boolean; + skippedVersion?: string | null; } export type ViewMode = 'grid' | 'topology'; diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index 8109a112..b11a16c4 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -127,6 +127,7 @@ interface NotificationPanelProps { onClearAll: () => void; onDelete: (notif: NotificationItem) => void; onNavigate?: (notif: NotificationItem) => void; + onNavigateChangelog?: (notif: NotificationItem) => void; } export function NotificationPanel({ @@ -136,6 +137,7 @@ export function NotificationPanel({ onClearAll, onDelete, onNavigate, + onNavigateChangelog, }: NotificationPanelProps) { const [filter, setFilter] = useState('all'); const [nodeFilter, setNodeFilter] = useState(NODE_FILTER_ALL); @@ -151,6 +153,11 @@ export function NotificationPanel({ [notifications], ); + const hasNodeUpdateNotifs = useMemo( + () => notifications.some((n) => !n.is_read && n.category === 'node_update_available'), + [notifications], + ); + const remoteNodeIds = useMemo(() => { const ids = new Set(); for (const n of nodes) if (n.type === 'remote') ids.add(n.id); @@ -189,13 +196,25 @@ export function NotificationPanel({ const bellBadge = unreadCount > 0 ? (
@@ -372,13 +392,14 @@ interface NotificationRowProps { showNodeName: boolean; onDelete: (notif: NotificationItem) => void; onNavigate?: (notif: NotificationItem) => void; + onNavigateChangelog?: (notif: NotificationItem) => void; } -function NotificationRow({ notif, showNodeName, onDelete, onNavigate }: NotificationRowProps) { +function NotificationRow({ notif, showNodeName, onDelete, onNavigate, onNavigateChangelog }: NotificationRowProps) { const config = LEVEL_CONFIG[notif.level]; const Icon = config.icon; const isUnread = !notif.is_read; - const isRoutable = Boolean(onNavigate && notif.stack_name); + const isRoutable = Boolean(onNavigate && (notif.stack_name || notif.category === 'node_update_available')); const surfaceClasses = cn( 'flex w-full items-start gap-3 px-[var(--density-row-x)] py-[var(--density-row-y)] text-left transition-colors', @@ -400,25 +421,39 @@ function NotificationRow({ notif, showNodeName, onDelete, onNavigate }: Notifica > {notif.message}

-
- {showNodeName && notif.nodeName ? ( - <> - - {notif.nodeName} - - · - - ) : null} - {formatRelative(notif.timestamp)} +
+
+ {showNodeName && notif.nodeName ? ( + <> + + {notif.nodeName} + + · + + ) : null} + {formatRelative(notif.timestamp)} +
+ {notif.category === 'node_update_available' && onNavigateChangelog && ( + + )}
); const ariaLabel = isRoutable - ? (notif.container_name - ? `Open ${notif.stack_name} and view logs for ${notif.container_name}` - : `Open ${notif.stack_name}`) + ? (notif.category === 'node_update_available' + ? 'Open Fleet node updates' + : notif.container_name + ? `Open ${notif.stack_name} and view logs for ${notif.container_name}` + : `Open ${notif.stack_name}`) : undefined; return ( diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index 5bd837f0..21b2980a 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -54,6 +54,7 @@ export type NotificationCategory = | 'autoheal_triggered' | 'monitor_alert' | 'scan_finding' + | 'node_update_available' | 'system'; export interface NotificationItem { diff --git a/frontend/src/components/ui/system-sheet.tsx b/frontend/src/components/ui/system-sheet.tsx index bfbaf892..b6a9dd91 100644 --- a/frontend/src/components/ui/system-sheet.tsx +++ b/frontend/src/components/ui/system-sheet.tsx @@ -28,6 +28,7 @@ export interface SystemSheetTab { id: string; label: string; count?: number; + dot?: boolean; } export interface SystemSheetProps { @@ -283,6 +284,12 @@ function TabsBand({ tabs, activeTab, onTabChange }: TabsBandProps) { )} > {tab.label} + {tab.dot && ( + + + + + )} {tab.count !== undefined && ( {tab.count} )} diff --git a/frontend/src/lib/notificationCategories.ts b/frontend/src/lib/notificationCategories.ts index c0d1d647..844bedee 100644 --- a/frontend/src/lib/notificationCategories.ts +++ b/frontend/src/lib/notificationCategories.ts @@ -11,5 +11,6 @@ export const CATEGORY_LABELS: Record = { autoheal_triggered: 'Auto-heal', monitor_alert: 'Monitor alert', scan_finding: 'Scan finding', + node_update_available: 'Node update', system: 'System', };