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;
}
}