feat: add node update alerts with changelog tab and skip-version handling (#1463)

* feat: add node update alerts with changelog tab and skip-version handling

- Add node_update_available notification category with blue/brand bell dot
- Route node_update_available notifications to Fleet -> Node updates sheet
- Add Changelog tab to NodeUpdatesSheet with GitHub release notes
- Add per-node skip-version persistence (node_update_skips table)
- Skip hides update CTA on node card and sheet; re-surfaces on newer version
- Skipped nodes excluded from Update all backend filter
- Add pulsating dot indicator on Changelog tab when updates available
- Always-visible View changelog action in notification row bottom
- Admin-only for all mutating controls (skip, unskip, update)
- Backend tests for skip-version semantics (15 tests)
- Update fleet-view.mdx, remote-updates.mdx, and OpenAPI spec

* fix: address audit findings - nested button, stale changelog, semver normalization, mobile intent

- Move View changelog button outside routable button (sibling element)
- Fix aria-label for node_update_available notification rows
- Support ?recheck=true on release-notes endpoint
- Invalidate release notes cache on forced recheck
- Store normalized semver (semver.valid strips v prefix)
- Skip fleetUpdatesIntent on mobile (desktop only)
- Add v-prefix normalization test

* fix: restore View changelog on same line as timestamp, opposite sides

The button is always visible at the bottom right of the notification card,
on the same row as the timestamp (just now), using justify-between layout.

* fix: update tests for node_update_available category and release-notes fetch

- Backend: monitor-service tests now expect node_update_available instead of system
- Frontend: NodeUpdatesSheet tests mock release-notes API call to prevent undefined then()

* fix: resolve ci lint failures
This commit is contained in:
Anso
2026-06-26 00:07:51 -04:00
committed by GitHub
parent 0384c47d1e
commit 315e8b6379
21 changed files with 789 additions and 49 deletions
@@ -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');
});
});
@@ -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();
});
});
+90 -1
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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;
});
+42
View File
@@ -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);
})();
}
+1 -1
View File
@@ -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}`);
+2 -1
View File
@@ -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. */
+40
View File
@@ -74,3 +74,43 @@ export async function getLatestVersion(forceRefresh = false): Promise<string | n
return null;
}
}
// --- Release details (includes body/notes for the changelog tab) ---
export interface SenchoRelease {
tag_name: string;
body: string;
html_url: string;
}
async function fetchReleaseDetails(): Promise<SenchoRelease> {
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<SenchoRelease | null> {
if (forceRefresh) {
CacheService.getInstance().invalidate(LATEST_RELEASE_CACHE_KEY);
}
try {
return await CacheService.getInstance().getOrFetch<SenchoRelease>(
LATEST_RELEASE_CACHE_KEY,
LATEST_VERSION_CACHE_TTL,
fetchReleaseDetails,
);
} catch {
return null;
}
}
+14 -6
View File
@@ -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).
<Frame>
<img src="/images/fleet-view/fleet-node-updates.png" alt="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'." />
</Frame>
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 <service>`. 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.
<Note>
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.
</Note>
## How fleet data is fetched
+2
View File
@@ -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.
<Frame>
<img src="/images/fleet-view/node-card-update-available.png" alt="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." />
</Frame>
+85
View File
@@ -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
+34 -1
View File
@@ -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<string | null>(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) => (
<EditorView
headerActions={headerActions}
@@ -668,7 +698,8 @@ export default function EditorLayout() {
onMarkAllRead={markAllRead}
onClearAll={clearAllNotifications}
onDelete={deleteNotification}
onNavigate={stackActions.navigateToNotification}
onNavigate={handleNotificationNavigate}
onNavigateChangelog={handleNotificationNavigateChangelog}
/>
);
const themeSwitchEl = <ThemeQuickSwitch onOpenAppearance={() => 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}
@@ -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({
<HubOnlyGate>
<CapabilityGate capability="fleet" featureName="Fleet Management">
<LazyView>
<FleetView onNavigateToNode={onFleetNavigateToNode} />
<FleetView
onNavigateToNode={onFleetNavigateToNode}
fleetUpdatesIntent={fleetUpdatesIntent}
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
/>
</LazyView>
</CapabilityGate>
</HubOnlyGate>
+16 -1
View File
@@ -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}
@@ -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 && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-warning/15 text-warning border-warning/30 shrink-0">
Update available
</Badge>
)}
{updateStatus?.skipActive && (
<Badge className="text-[10px] px-1.5 py-0 h-4 bg-muted text-muted-foreground border-card-border/40 shrink-0">
Skipped
</Badge>
)}
{isOnline && isCritical(node) && (
<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
@@ -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 && (
<div className="mt-3 pt-3 border-t border-border/50">
<Button
variant="outline"
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import {
Search, Loader2, Check, CircleCheck, CircleAlert, AlertTriangle,
Download, RefreshCw, Monitor, Globe,
Download, RefreshCw, Monitor, Globe, ExternalLink, Ban,
} from 'lucide-react';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { Input } from '@/components/ui/input';
@@ -9,7 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { formatVersion } from '@/lib/version';
import { formatVersion, isValidVersion } from '@/lib/version';
import { UpdateStatusBadge } from './UpdateStatusBadge';
import type { NodeUpdateStatus } from './types';
@@ -23,6 +23,7 @@ interface NodeUpdatesSheetProps {
* only for admins, matching the requireAdmin guard on the fleet routes they
* call. Non-admins still see the read-only status table. */
isAdmin: boolean;
initialTab?: 'nodes' | 'changelog';
fetchUpdateStatus: () => Promise<void>;
triggerNodeUpdate: (nodeId: number) => void;
retryNodeUpdate: (nodeId: number) => void;
@@ -32,18 +33,61 @@ interface NodeUpdatesSheetProps {
export function NodeUpdatesSheet({
open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, isAdmin,
initialTab = 'nodes',
fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll,
}: NodeUpdatesSheetProps) {
const [search, setSearch] = useState('');
const [recheckingUpdates, setRecheckingUpdates] = useState(false);
const [activeTab, setActiveTab] = useState<'nodes' | 'changelog'>(initialTab);
const [skipLoading, setSkipLoading] = useState<number | null>(null);
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
const [releaseHtmlUrl, setReleaseHtmlUrl] = useState<string | null>(null);
const [loadingRelease, setLoadingRelease] = useState(false);
const [hasSeenChangelog, setHasSeenChangelog] = useState(false);
useEffect(() => {
if (open) setActiveTab(initialTab);
}, [open, initialTab]);
// Always fetch release notes when the sheet opens (changelog shows current
// release regardless of update availability). Pass recheck when the user
// forced a version recheck so the changelog stays in sync.
useEffect(() => {
if (open && releaseNotes === null && !loadingRelease) {
setLoadingRelease(true);
const recheck = recheckingUpdates ? '?recheck=true' : '';
apiFetch(`/fleet/update-status/release-notes${recheck}`, { localOnly: true })
.then(res => res.ok ? res.json() as Promise<{ releaseNotes: string | null; htmlUrl: string | null }> : null)
.then(data => {
if (data) {
setReleaseNotes(data.releaseNotes);
setReleaseHtmlUrl(data.htmlUrl);
}
})
.catch(() => { /* silent */ })
.finally(() => setLoadingRelease(false));
}
}, [open, releaseNotes, loadingRelease, recheckingUpdates]);
// Clear the changelog dot when user opens that tab.
useEffect(() => {
if (open && activeTab === 'changelog') {
setHasSeenChangelog(true);
}
}, [open, activeTab]);
const handleOpenChange = (next: boolean) => {
onOpenChange(next);
if (!next) setSearch('');
if (!next) {
setSearch('');
setActiveTab('nodes');
setHasSeenChangelog(false);
}
};
const handleRecheck = async () => {
setRecheckingUpdates(true);
setReleaseNotes(null); // force re-fetch with fresh release notes
try {
const res = await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
if (res.ok) {
@@ -70,6 +114,49 @@ export function NodeUpdatesSheet({
}
};
const handleSkipVersion = async (nodeId: number, version: string | null) => {
if (!version) return;
setSkipLoading(nodeId);
try {
const res = await apiFetch(`/fleet/nodes/${nodeId}/skip-version`, {
method: 'POST',
body: JSON.stringify({ version }),
localOnly: true,
});
if (res.ok || res.status === 204) {
toast.success('Version skipped.');
await fetchUpdateStatus();
} else {
const err = await res.json().catch(() => ({}));
toast.error(err?.error || 'Failed to skip version.');
}
} catch {
toast.error('Failed to skip version.');
} finally {
setSkipLoading(null);
}
};
const handleUnskipVersion = async (nodeId: number) => {
setSkipLoading(nodeId);
try {
const res = await apiFetch(`/fleet/nodes/${nodeId}/skip-version`, {
method: 'DELETE',
localOnly: true,
});
if (res.ok || res.status === 204) {
toast.success('Skip cleared.');
await fetchUpdateStatus();
} else {
toast.error('Failed to clear skip.');
}
} catch {
toast.error('Failed to clear skip.');
} finally {
setSkipLoading(null);
}
};
const upToDate = updateStatuses.filter(s => !s.updateAvailable && (!s.updateStatus || s.updateStatus === 'completed')).length;
const available = updateStatuses.filter(s => s.updateAvailable && !s.updateStatus).length;
const updating = updateStatuses.filter(s => s.updateStatus === 'updating').length;
@@ -98,6 +185,16 @@ export function NodeUpdatesSheet({
}]
: undefined;
const showChangelogDot = available > 0 && !hasSeenChangelog;
const showSkip = (s: NodeUpdateStatus) =>
s.updateAvailable && !s.updateStatus && isAdmin && isValidVersion(s.version) && isValidVersion(s.latestVersion);
const tabs: Array<{ id: string; label: string; count?: number; dot?: boolean }> = [
{ id: 'nodes', label: 'Nodes' },
{ id: 'changelog', label: 'Changelog', dot: showChangelogDot },
];
return (
<SystemSheet
open={open}
@@ -113,6 +210,9 @@ export function NodeUpdatesSheet({
} : undefined}
secondaryActions={secondaryActions}
footerContext={footerContext}
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => setActiveTab(id as 'nodes' | 'changelog')}
size="lg"
>
{checkingUpdates ? (
@@ -124,6 +224,34 @@ export function NodeUpdatesSheet({
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
No nodes found.
</div>
) : activeTab === 'changelog' ? (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-5">
{loadingRelease ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
</div>
) : releaseNotes ? (
<div className="space-y-4">
<pre className="whitespace-pre-wrap text-sm font-sans text-stat-value leading-relaxed">
{releaseNotes}
</pre>
{releaseHtmlUrl && (
<a
href={releaseHtmlUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-brand hover:underline"
>
View on GitHub <ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
)}
</div>
) : (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
Release notes could not be loaded.
</div>
)}
</div>
) : (
<>
<SheetSection title="Summary">
@@ -166,7 +294,7 @@ export function NodeUpdatesSheet({
/>
</div>
<div className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 px-3 pb-1 text-[10px] leading-3 font-mono text-stat-subtitle uppercase tracking-[0.18em]">
<div className="grid grid-cols-[1fr_80px_80px_100px_160px] gap-2 px-3 pb-1 text-[10px] leading-3 font-mono text-stat-subtitle uppercase tracking-[0.18em]">
<span>Node</span>
<span>Type</span>
<span>Current</span>
@@ -176,7 +304,7 @@ export function NodeUpdatesSheet({
<div className="divide-y divide-card-border/40">
{filtered.map(s => (
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_100px_100px_120px] gap-2 items-center px-3 py-2">
<div key={s.nodeId} className="grid grid-cols-[1fr_80px_80px_100px_160px] gap-2 items-center px-3 py-2">
<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'
@@ -195,7 +323,7 @@ export function NodeUpdatesSheet({
<span className="text-xs font-mono tabular-nums">
{formatVersion(s.latestVersion) ?? <span className="text-muted-foreground/50 italic text-[10px]">unknown</span>}
</span>
<div className="flex justify-end">
<div className="flex justify-end items-center gap-1">
{s.updateStatus && (
<UpdateStatusBadge
status={s.updateStatus}
@@ -204,12 +332,28 @@ export function NodeUpdatesSheet({
onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined}
/>
)}
{!s.updateStatus && !s.updateAvailable && (
{!s.updateStatus && !s.updateAvailable && !s.skipActive && (
<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 && isAdmin && (
{s.skipActive && (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-muted text-muted-foreground border-card-border/40">
<Ban className="w-2.5 h-2.5 mr-0.5" /> Skipped {formatVersion(s.skippedVersion)}
</Badge>
)}
{s.skipActive && isAdmin && (
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-1.5 text-muted-foreground hover:text-stat-value"
onClick={() => { void handleUnskipVersion(s.nodeId); }}
disabled={skipLoading === s.nodeId}
>
Unskip
</Button>
)}
{s.updateAvailable && !s.updateStatus && !s.skipActive && isAdmin && (
<Button
variant="outline"
size="sm"
@@ -224,7 +368,18 @@ export function NodeUpdatesSheet({
)}
</Button>
)}
{s.updateAvailable && !s.updateStatus && !isAdmin && (
{showSkip(s) && (
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] px-1.5 text-muted-foreground hover:text-warning"
onClick={() => { void handleSkipVersion(s.nodeId, s.latestVersion); }}
disabled={skipLoading === s.nodeId}
>
Skip
</Button>
)}
{s.updateAvailable && !s.updateStatus && !s.skipActive && !isAdmin && (
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-warning/15 text-warning border-warning/30">
<CircleAlert className="w-2.5 h-2.5 mr-0.5" /> Available
</Badge>
@@ -34,7 +34,12 @@ function baseProps(overrides: Partial<React.ComponentProps<typeof NodeUpdatesShe
};
}
beforeEach(() => 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', () => {
@@ -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';
+53 -18
View File
@@ -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<NotifFilter>('all');
const [nodeFilter, setNodeFilter] = useState<NodeFilter>(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<number>();
for (const n of nodes) if (n.type === 'remote') ids.add(n.id);
@@ -189,13 +196,25 @@ export function NotificationPanel({
const bellBadge =
unreadCount > 0 ? (
<span aria-hidden="true" className="absolute -right-1 -top-1 flex h-2.5 w-2.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-destructive" />
<span className={cn(
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
hasNodeUpdateNotifs ? 'bg-brand' : 'bg-destructive',
)} />
<span className={cn(
"relative inline-flex h-2.5 w-2.5 rounded-full",
hasNodeUpdateNotifs ? 'bg-brand' : 'bg-destructive',
)} />
</span>
) : null;
const handleNavigate = (notif: NotificationItem) => {
if (!onNavigate || !notif.stack_name) return;
if (!onNavigate) return;
if (notif.category === 'node_update_available') {
onNavigate(notif);
setOpen(false);
return;
}
if (!notif.stack_name) return;
onNavigate(notif);
setOpen(false);
};
@@ -356,6 +375,7 @@ export function NotificationPanel({
}
onDelete={onDelete}
onNavigate={onNavigate ? handleNavigate : undefined}
onNavigateChangelog={onNavigateChangelog}
/>
))}
</div>
@@ -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}
</p>
<div className="mt-1 flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
{showNodeName && notif.nodeName ? (
<>
<span className="rounded-sm border border-card-border bg-muted/40 px-1.5 py-0.5 normal-case tracking-normal text-stat-subtitle">
{notif.nodeName}
</span>
<span className="text-stat-icon">·</span>
</>
) : null}
<span className="tabular-nums">{formatRelative(notif.timestamp)}</span>
<div className="mt-1 flex items-center justify-between gap-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
<div className="flex items-center gap-1.5">
{showNodeName && notif.nodeName ? (
<>
<span className="rounded-sm border border-card-border bg-muted/40 px-1.5 py-0.5 normal-case tracking-normal text-stat-subtitle">
{notif.nodeName}
</span>
<span className="text-stat-icon">·</span>
</>
) : null}
<span className="tabular-nums">{formatRelative(notif.timestamp)}</span>
</div>
{notif.category === 'node_update_available' && onNavigateChangelog && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] font-sans normal-case tracking-normal text-brand hover:text-brand/80"
onClick={(e) => { e.stopPropagation(); onNavigateChangelog(notif); }}
>
View changelog
</Button>
)}
</div>
</div>
</>
);
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 (
@@ -54,6 +54,7 @@ export type NotificationCategory =
| 'autoheal_triggered'
| 'monitor_alert'
| 'scan_finding'
| 'node_update_available'
| 'system';
export interface NotificationItem {
@@ -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 && (
<span aria-hidden className="relative inline-flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand opacity-60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-brand" />
</span>
)}
{tab.count !== undefined && (
<span className="font-mono text-[10px] tabular-nums text-stat-subtitle">{tab.count}</span>
)}
@@ -11,5 +11,6 @@ export const CATEGORY_LABELS: Record<NotificationCategory, string> = {
autoheal_triggered: 'Auto-heal',
monitor_alert: 'Monitor alert',
scan_finding: 'Scan finding',
node_update_available: 'Node update',
system: 'System',
};