From 058cf8f2c7caf5e2c76111ce2c79e8adfd9376ec Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 15 Jun 2026 20:06:13 -0400 Subject: [PATCH] feat: make image-update check cadence configurable and visible (#1377) * feat: make image-update check cadence configurable and visible The background image-update scanner polled registries on a hardcoded 6-hour interval, with no way to see when it last ran or when the next run was due. Operators testing updates read this as auto-update being unreliable: a manual update checks the registry immediately and applies, so the slow background scan rarely raised the "update available" notification before the stack was already current. Backend: - ImageUpdateService reads image_update_check_interval_minutes (15-1440, default 120) and drives a single generation-guarded self-rescheduling timer with 10% per-run jitter so fleet nodes do not poll in lockstep. restartPolling() applies a new interval live, with no restart, and cannot leave a duplicate timer when a save lands mid-scan. - GET /api/image-updates/status now returns checking, intervalMinutes, lastCheckedAt, nextCheckAt, and the manual-cooldown fields. New admin-only PUT /api/image-updates/interval persists the setting and reschedules. Frontend: - New Settings > Automation > Image update checks section to choose the interval (read-only for non-admins; admin enforced on the backend). - The Auto-Update readiness view shows last-checked, next-check, and a ticking manual-recheck cooldown, and the copy distinguishes registry detection from scheduled auto-update execution. Adds backend unit and route tests and frontend component tests, and updates the auto-update documentation. * fix: drop stale image-update status response in the readiness strip loadCadence() ran on mount and again after a Recheck with no request token, so a slow initial /image-updates/status response could resolve after the recheck-triggered one and overwrite the fresh cooldown with stale data, or set state after the view unmounted. Guard setCadence with a monotonic token mirroring loadReadiness, and bump it on unmount. Adds a regression test for the out-of-order resolution. --- .../__tests__/image-update-service.test.ts | 178 +++++++++++++++++- .../__tests__/image-updates-routes.test.ts | 47 ++++- backend/src/routes/imageUpdates.ts | 27 ++- backend/src/services/DatabaseService.ts | 1 + backend/src/services/ImageUpdateService.ts | 162 ++++++++++++++-- docs/features/auto-update-policies.mdx | 20 +- .../components/AutoUpdateReadinessView.tsx | 81 +++++++- .../AutoUpdateReadinessView.test.tsx | 146 +++++++++++++- .../settings/SettingsSectionContent.tsx | 2 + .../components/settings/UpdatesSection.tsx | 151 +++++++++++++++ .../__tests__/UpdatesSection.test.tsx | 62 ++++++ .../settings/__tests__/registry.test.ts | 10 + frontend/src/components/settings/index.ts | 1 + frontend/src/components/settings/registry.ts | 9 + frontend/src/components/settings/types.ts | 1 + frontend/src/types/imageUpdates.ts | 21 +++ 16 files changed, 890 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/settings/UpdatesSection.tsx create mode 100644 frontend/src/components/settings/__tests__/UpdatesSection.test.tsx create mode 100644 frontend/src/types/imageUpdates.ts diff --git a/backend/src/__tests__/image-update-service.test.ts b/backend/src/__tests__/image-update-service.test.ts index fb4a2711..f1bd1aa4 100644 --- a/backend/src/__tests__/image-update-service.test.ts +++ b/backend/src/__tests__/image-update-service.test.ts @@ -2,7 +2,7 @@ * Unit tests for ImageUpdateService: image ref parsing, compose extraction, * env file loading, checkImage digest comparison, and rate limiting. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // ── Hoisted mocks ────────────────────────────────────────────────────── @@ -12,7 +12,7 @@ const { mockGetSystemState, mockSetSystemState, mockAddNotificationHistory, mockDispatchAlert, mockGetStacks, mockGetStackContent, mockGetEnvContent, mockEnvExists, - mockGetAllContainers, + mockGetAllContainers, mockGetGlobalSettings, } = vi.hoisted(() => ({ mockGetAuthForRegistry: vi.fn().mockResolvedValue(null), mockGetStackUpdateStatus: vi.fn().mockReturnValue({}), @@ -27,6 +27,7 @@ const { mockGetEnvContent: vi.fn().mockRejectedValue(new Error('no env')), mockEnvExists: vi.fn().mockResolvedValue(false), mockGetAllContainers: vi.fn().mockResolvedValue([]), + mockGetGlobalSettings: vi.fn().mockReturnValue({ developer_mode: '0' }), })); vi.mock('../services/RegistryService', () => ({ @@ -40,7 +41,7 @@ vi.mock('../services/RegistryService', () => ({ vi.mock('../services/DatabaseService', () => ({ DatabaseService: { getInstance: () => ({ - getGlobalSettings: () => ({ developer_mode: '0' }), + getGlobalSettings: mockGetGlobalSettings, getNodes: () => [], upsertStackUpdateStatus: mockUpsertStackUpdateStatus, getStackUpdateStatus: mockGetStackUpdateStatus, @@ -696,6 +697,177 @@ describe('ImageUpdateService - stop() cancels startup timeout', () => { }); }); +// ── Configurable interval, status, and reschedule ─────────────────────── + +describe('ImageUpdateService - configurable interval & status', () => { + beforeEach(() => { + vi.clearAllMocks(); + (ImageUpdateService as any).instance = undefined; + mockGetGlobalSettings.mockReturnValue({ developer_mode: '0' }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function deferred() { + let resolve!: () => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; + } + + it('reports the default 120-minute interval before start() runs', () => { + const service = ImageUpdateService.getInstance(); + const status = service.getStatus(); + expect(status.intervalMinutes).toBe(120); + expect(status.checking).toBe(false); + expect(status.lastCheckedAt).toBeNull(); + expect(status.nextCheckAt).toBeNull(); + expect(status.manualCooldownMinutes).toBe(2); + expect(status.manualCooldownRemainingMs).toBe(0); + }); + + it('reads the configured interval from settings', () => { + mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '30' }); + const service = ImageUpdateService.getInstance(); + service.configureFromSettings(); + expect(service.getStatus().intervalMinutes).toBe(30); + }); + + it('clamps an interval below the minimum to 15', () => { + mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '5' }); + const service = ImageUpdateService.getInstance(); + service.configureFromSettings(); + expect(service.getStatus().intervalMinutes).toBe(15); + }); + + it('clamps an interval above the maximum to 1440', () => { + mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '5000' }); + const service = ImageUpdateService.getInstance(); + service.configureFromSettings(); + expect(service.getStatus().intervalMinutes).toBe(1440); + }); + + it('falls back to the default for a malformed or non-integer value', () => { + const service = ImageUpdateService.getInstance(); + const badValues: (string | undefined)[] = ['15abc', '30.5', '', undefined]; + for (const bad of badValues) { + mockGetGlobalSettings.mockReturnValue(bad === undefined ? {} : { image_update_check_interval_minutes: bad }); + service.configureFromSettings(); + expect(service.getStatus().intervalMinutes).toBe(120); + } + }); + + it('stamps lastCheckedAt when a manual refresh runs', async () => { + const service = ImageUpdateService.getInstance(); + // getNodes() returns [] in the shared mock, so check() completes immediately. + expect(service.getStatus().lastCheckedAt).toBeNull(); + const triggered = service.triggerManualRefresh(); + expect(triggered).toBe(true); + await Promise.resolve(); + await Promise.resolve(); + expect(service.getStatus().lastCheckedAt).not.toBeNull(); + }); + + it('applies ±10% jitter that actually reaches both endpoints', () => { + mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '60' }); + const service = ImageUpdateService.getInstance(); + service.configureFromSettings(); + const interval = 60 * 60 * 1000; + + // random=0 must reach the low edge (90%), proving jitter is applied and not + // collapsed to the bare interval. + const low = vi.spyOn(Math, 'random').mockReturnValue(0); + expect((service as any).nextDelayMs()).toBe(Math.round(interval * 0.9)); + low.mockRestore(); + + const mid = vi.spyOn(Math, 'random').mockReturnValue(0.5); + expect((service as any).nextDelayMs()).toBe(interval); + mid.mockRestore(); + + // random→1 must reach the high edge (≈110%). + const high = vi.spyOn(Math, 'random').mockReturnValue(0.999); + const hi = (service as any).nextDelayMs() as number; + expect(hi).toBeGreaterThan(interval); + expect(hi).toBeGreaterThanOrEqual(Math.round(interval * 1.09)); + expect(hi).toBeLessThanOrEqual(Math.round(interval * 1.1)); + high.mockRestore(); + }); + + it('reports the manual-refresh cooldown remaining and clears it after the window', () => { + vi.useFakeTimers(); + const service = ImageUpdateService.getInstance(); + expect(service.getManualCooldownRemainingMs()).toBe(0); + service.triggerManualRefresh(); + const remaining = service.getManualCooldownRemainingMs(); + expect(remaining).toBeGreaterThan(0); + expect(remaining).toBeLessThanOrEqual(2 * 60 * 1000); + vi.advanceTimersByTime(2 * 60 * 1000); + expect(service.getManualCooldownRemainingMs()).toBe(0); + }); + + it('stop() after start() clears the timer and nulls nextCheckAt without firing a check', () => { + vi.useFakeTimers(); + const service = ImageUpdateService.getInstance(); + const checkSpy = vi.spyOn(service as any, 'check').mockResolvedValue(undefined); + service.start(); + expect(service.getStatus().nextCheckAt).not.toBeNull(); + expect(vi.getTimerCount()).toBe(1); + + service.stop(); + expect(vi.getTimerCount()).toBe(0); + expect(service.getStatus().nextCheckAt).toBeNull(); + + // Past the old startup delay: the cleared timer + bumped generation mean no + // check fires on a stopped service. + vi.advanceTimersByTime(5 * 60 * 1000); + expect(checkSpy).not.toHaveBeenCalled(); + checkSpy.mockRestore(); + }); + + it('restartPolling() while stopped reconfigures the interval but arms no timer', () => { + vi.useFakeTimers(); + mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '45' }); + const service = ImageUpdateService.getInstance(); + // Never started: polling is false, so it reconfigures without arming. + service.restartPolling(); + expect(service.getStatus().intervalMinutes).toBe(45); + expect(service.getStatus().nextCheckAt).toBeNull(); + expect((service as any).timer).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('restartPolling() during an in-flight tick leaves exactly one timer', async () => { + vi.useFakeTimers(); + const service = ImageUpdateService.getInstance(); + const d = deferred(); + const checkSpy = vi.spyOn(service as any, 'check').mockReturnValue(d.promise); + + service.start(); + expect(vi.getTimerCount()).toBe(1); + + // Fire the startup tick: it invokes check() (our pending deferred) and does + // not re-arm until check resolves. + vi.advanceTimersByTime(2 * 60 * 1000); + expect(checkSpy).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + + // A settings save lands mid-scan: it arms a fresh timer. + service.restartPolling(); + expect(vi.getTimerCount()).toBe(1); + + // The original tick resolves; its generation is now stale, so it must not + // re-arm a second timer. + d.resolve(); + await d.promise; + await Promise.resolve(); + expect(vi.getTimerCount()).toBe(1); + + service.stop(); + checkSpy.mockRestore(); + }); +}); + // ── Stale stack pruning ───────────────────────────────────────────────── describe('ImageUpdateService - stale stack pruning', () => { diff --git a/backend/src/__tests__/image-updates-routes.test.ts b/backend/src/__tests__/image-updates-routes.test.ts index e90aa3b2..f62a2161 100644 --- a/backend/src/__tests__/image-updates-routes.test.ts +++ b/backend/src/__tests__/image-updates-routes.test.ts @@ -72,10 +72,55 @@ describe('GET /api/image-updates/status', () => { expect(res.status).toBe(401); }); - it('returns a checking flag', async () => { + it('returns the enriched status payload', async () => { const res = await request(app).get('/api/image-updates/status').set('Cookie', adminCookie); expect(res.status).toBe(200); expect(typeof res.body.checking).toBe('boolean'); + // start() never runs in route tests, so the interval reflects the seeded + // default (120) via the field initializer rather than NaN. + expect(res.body.intervalMinutes).toBe(120); + expect(res.body.manualCooldownMinutes).toBe(2); + expect(typeof res.body.manualCooldownRemainingMs).toBe('number'); + expect('lastCheckedAt' in res.body).toBe(true); + expect('nextCheckAt' in res.body).toBe(true); + }); +}); + +describe('PUT /api/image-updates/interval', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).put('/api/image-updates/interval').send({ minutes: 30 }); + expect(res.status).toBe(401); + }); + + it('rejects non-admin users with 403', async () => { + const res = await request(app).put('/api/image-updates/interval').set('Cookie', viewerCookie).send({ minutes: 30 }); + expect(res.status).toBe(403); + }); + + it('rejects an interval below the minimum', async () => { + const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 5 }); + expect(res.status).toBe(400); + }); + + it('rejects an interval above the maximum', async () => { + const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 5000 }); + expect(res.status).toBe(400); + }); + + it('rejects a non-integer interval', async () => { + const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 'soon' }); + expect(res.status).toBe(400); + }); + + it('persists a valid interval and returns the enriched status', async () => { + const res = await request(app).put('/api/image-updates/interval').set('Cookie', adminCookie).send({ minutes: 30 }); + expect(res.status).toBe(200); + expect(res.body.intervalMinutes).toBe(30); + // The value is persisted to global_settings... + expect(DatabaseService.getInstance().getGlobalSettings().image_update_check_interval_minutes).toBe('30'); + // ...and a follow-up status read reflects the rescheduled cadence. + const statusRes = await request(app).get('/api/image-updates/status').set('Cookie', adminCookie); + expect(statusRes.body.intervalMinutes).toBe(30); }); }); diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index 4fc6e194..670fc376 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -1,4 +1,5 @@ import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; import DockerController from '../services/DockerController'; import { DatabaseService } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; @@ -50,7 +51,31 @@ imageUpdatesRouter.post('/refresh', authMiddleware, (req: Request, res: Response }); imageUpdatesRouter.get('/status', authMiddleware, (_req: Request, res: Response): void => { - res.json({ checking: ImageUpdateService.getInstance().isChecking() }); + res.json(ImageUpdateService.getInstance().getStatus()); +}); + +// Min/max mirror ImageUpdateService's clamp; the service is the authority and +// re-clamps on read, so this is the user-facing validation boundary. +const IntervalPatchSchema = z.object({ + minutes: z.coerce.number().int().min(15).max(1440), +}); + +imageUpdatesRouter.put('/interval', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + const parsed = IntervalPatchSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: 'minutes must be an integer between 15 and 1440' }); + return; + } + try { + DatabaseService.getInstance().updateGlobalSetting('image_update_check_interval_minutes', String(parsed.data.minutes)); + // Reschedule the live timer so the new cadence takes effect without a restart. + ImageUpdateService.getInstance().restartPolling(); + res.json(ImageUpdateService.getInstance().getStatus()); + } catch (error) { + console.error('Failed to update image-update interval:', error); + res.status(500).json({ error: 'Failed to update interval' }); + } }); imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Response): Promise => { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index a98df26c..f84a03d7 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1463,6 +1463,7 @@ export class DatabaseService { stmt.run('reclaim_hero', '1'); stmt.run('health_gate_enabled', '1'); stmt.run('health_gate_window_seconds', '90'); + stmt.run('image_update_check_interval_minutes', '120'); // Seed the default local node if none exists const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0; diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index ab7ffbe6..e58effe2 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -19,6 +19,24 @@ export interface ImageCheckResult { error?: string; } +/** + * Snapshot of the scanner returned by GET /api/image-updates/status. + * Units differ by field: `intervalMinutes` / `manualCooldownMinutes` are + * minutes, `manualCooldownRemainingMs` is milliseconds. `manualCooldownMinutes` + * is the fixed cooldown ceiling; `manualCooldownRemainingMs` is the live + * remaining time (0 when a manual refresh is allowed). `lastCheckedAt` / + * `nextCheckAt` are epoch-ms or null ("never checked" / "not scheduled"); + * `nextCheckAt` is meaningless while `checking` is true. + */ +export interface ImageUpdateStatus { + checking: boolean; + intervalMinutes: number; + lastCheckedAt: number | null; + nextCheckAt: number | null; + manualCooldownMinutes: number; + manualCooldownRemainingMs: number; +} + // ─── Compose file helpers ──────────────────────────────────────────────────── export function loadDotEnv(content: string): Record { @@ -91,14 +109,31 @@ export function extractImagesFromCompose( export class ImageUpdateService { private static instance: ImageUpdateService; - private intervalId: NodeJS.Timeout | null = null; - private startupTimeoutId: NodeJS.Timeout | null = null; + + private static readonly MIN_INTERVAL_MINUTES = 15; + private static readonly MAX_INTERVAL_MINUTES = 1440; // 24 hours + private static readonly DEFAULT_INTERVAL_MINUTES = 120; // 2 hours + private static readonly INTERVAL_SETTING_KEY = 'image_update_check_interval_minutes'; + private static readonly JITTER_FRACTION = 0.1; // ±10% so a fleet does not poll in lockstep + private static readonly STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 min after boot + + // A single self-rescheduling timer (replacing the old setInterval): it lets + // us know nextCheckAt precisely, apply per-run jitter, and reschedule on a + // settings change without ever leaving two timers running. + private timer: NodeJS.Timeout | null = null; + private polling = false; + // Bumped by stop()/restartPolling(); a tick whose captured generation no + // longer matches must not re-arm. This is what stops a settings save that + // lands mid-scan from racing the in-flight tick into a duplicate timer. + private scheduleGeneration = 0; private isRunning = false; private checkStartedAt = 0; private lastManualRefreshAt = 0; - - private static readonly INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours - private static readonly STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 min after boot + private lastCheckedAt: number | null = null; // when the last scan body started + private nextCheckAt: number | null = null; + // Initialized at declaration so getStatus() never reports NaN before start() + // or configureFromSettings() has run (e.g. route tests that skip startServer). + private intervalMs = ImageUpdateService.DEFAULT_INTERVAL_MINUTES * 60 * 1000; private static readonly MANUAL_COOLDOWN_MS = 2 * 60 * 1000; // 2 min between manual triggers private static readonly INTER_IMAGE_DELAY_MS = 300; // be polite to registries private static readonly CHECK_TIMEOUT_MS = 5 * 60 * 1000; // threshold for the "running long" skip warning @@ -118,20 +153,99 @@ export class ImageUpdateService { } public start() { - if (this.intervalId) return; - this.startupTimeoutId = setTimeout(() => this.check(), ImageUpdateService.STARTUP_DELAY_MS); - this.intervalId = setInterval(() => this.check(), ImageUpdateService.INTERVAL_MS); + if (this.timer) return; + this.polling = true; + this.configureFromSettings(); + // Preserve the existing 2-minute post-boot delay before the first check. + this.armNext(ImageUpdateService.STARTUP_DELAY_MS); } public stop() { - if (this.startupTimeoutId) { - clearTimeout(this.startupTimeoutId); - this.startupTimeoutId = null; + this.scheduleGeneration++; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; } - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; + this.polling = false; + this.nextCheckAt = null; + } + + /** + * Re-read the configured interval and reschedule the next check at the new + * cadence without restarting Sencho. Safe to call repeatedly: it always + * clears the existing timer first and only arms a new one while polling, so + * it never stacks timers and is a no-op (beyond reconfiguring intervalMs) + * when the service is stopped or was never started. + */ + public restartPolling(): void { + this.scheduleGeneration++; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; } + this.configureFromSettings(); + if (this.polling) { + this.armNext(this.nextDelayMs()); + } else { + this.nextCheckAt = null; + } + } + + /** + * Reads image_update_check_interval_minutes into intervalMs, clamped to + * [15, 1440], falling back to the 2-hour default on a missing, blank, + * malformed, or unreadable value. + */ + public configureFromSettings(): void { + this.intervalMs = ImageUpdateService.resolveIntervalMinutes() * 60 * 1000; + } + + private static resolveIntervalMinutes(): number { + const fallback = ImageUpdateService.DEFAULT_INTERVAL_MINUTES; + try { + const raw = DatabaseService.getInstance().getGlobalSettings()[ImageUpdateService.INTERVAL_SETTING_KEY]; + // Treat missing/blank as unset; Number('') is 0, which would clamp to + // the minimum rather than fall back to the default. + if (raw == null || String(raw).trim() === '') return fallback; + // Number() (not parseInt) so a malformed value like "15abc" is + // rejected to the default rather than silently accepted as 15. + const parsed = Number(raw); + if (!Number.isInteger(parsed)) return fallback; + return Math.min( + ImageUpdateService.MAX_INTERVAL_MINUTES, + Math.max(ImageUpdateService.MIN_INTERVAL_MINUTES, parsed), + ); + } catch (e) { + console.warn('[ImageUpdateService] Could not read interval setting; using default:', getErrorMessage(e, String(e))); + return fallback; + } + } + + private armNext(delayMs: number): void { + this.nextCheckAt = Date.now() + delayMs; + const gen = this.scheduleGeneration; + this.timer = setTimeout(() => { void this.tick(gen); }, delayMs); + } + + private async tick(gen: number): Promise { + if (!this.polling || gen !== this.scheduleGeneration) return; + try { + await this.check(); + } finally { + // Only the tick whose generation is still current re-arms. A + // restartPolling()/stop() that landed during the await bumped the + // generation and already rescheduled or cleared, so a stale tick + // bailing here is what keeps exactly one timer alive. + if (this.polling && gen === this.scheduleGeneration) { + this.armNext(this.nextDelayMs()); + } + } + } + + /** intervalMs with ±10% jitter so multiple nodes do not hit registries together. */ + private nextDelayMs(): number { + const jitter = this.intervalMs * ImageUpdateService.JITTER_FRACTION; + return Math.round(this.intervalMs - jitter + Math.random() * 2 * jitter); } /** @@ -153,6 +267,22 @@ export class ImageUpdateService { return this.isRunning; } + /** Milliseconds left on the manual-refresh cooldown; 0 when a refresh is allowed. */ + public getManualCooldownRemainingMs(): number { + return Math.max(0, this.lastManualRefreshAt + ImageUpdateService.MANUAL_COOLDOWN_MS - Date.now()); + } + + public getStatus(): ImageUpdateStatus { + return { + checking: this.isRunning, + intervalMinutes: Math.round(this.intervalMs / (60 * 1000)), + lastCheckedAt: this.lastCheckedAt, + nextCheckAt: this.nextCheckAt, + manualCooldownMinutes: ImageUpdateService.manualCooldownMinutes, + manualCooldownRemainingMs: this.getManualCooldownRemainingMs(), + }; + } + // ─── Core check ────────────────────────────────────────────────────────── private async check() { @@ -176,6 +306,10 @@ export class ImageUpdateService { } this.isRunning = true; this.checkStartedAt = Date.now(); + // Stamp last-checked here, in the shared scan path, so a manual Recheck + // updates it too. A skipped concurrent trigger returns above this line, + // so it never bumps the timestamp. + this.lastCheckedAt = this.checkStartedAt; console.log('[ImageUpdateService] Starting image update check...'); try { diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index 06576486..f95899d4 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -29,7 +29,21 @@ Cards are grouped by node, with a section header for each node that has at least ## Empty state -When nothing is pending, the board renders a single Shield-icon panel with the headline "All stacks on current builds" and the sub-line "Sencho will recheck registries on the scheduler interval." Image update detection runs every six hours on each node; the readiness board reflects that cached status until the next cycle (or until you press **Recheck**). +When nothing is pending, the board renders a single Shield-icon panel with the headline "All stacks on current builds" and the sub-line "Sencho rechecks registries on the configured interval." Image update detection runs on a configurable interval (every 2 hours by default) on each node; the readiness board reflects that cached status until the next check, or until you press **Recheck**. Adjust the interval under **Settings > Automation > Image update checks**. + +## Detection cadence + +Sencho polls your registries on a set interval to detect available image updates and raise notifications. This detection cadence is configurable under **Settings > Automation > Image update checks**: choose anything from every 15 minutes to once a day. The default is every 2 hours, and changing it takes effect immediately, with no restart. + +Detection is separate from applying updates: + +- **Registry detection** is this interval. It only looks for newer images and raises an "update available" notification the first time a stack goes from up to date to having an update. +- **Scheduled auto-update tasks** apply updates on their own cron schedule, independent of the detection interval. See [Scheduling auto-updates](#scheduling-auto-updates). +- **Apply now** updates a single stack on demand, regardless of either schedule. + +The interval is node-scoped: each node runs its own scanner on its own cadence, and across a fleet Sencho staggers the runs slightly so the nodes do not all poll at the same instant. Setting the interval requires an admin account. + +The readiness hero shows this instance's cadence at a glance: when it last checked, when the next check is due, and, right after a manual **Recheck**, how long the 2-minute cooldown has left. ## Workflow @@ -58,7 +72,7 @@ Auto-update is opt-in per stack. A stack participates in unattended updates only - **Per-stack schedule.** Create a **Auto-update Stack** task targeting that stack alone. Only this stack is updated when the cron fires. - **Fleet-wide schedule.** Create a **Auto-update All Stacks** task targeting a node. Every stack on that node is checked and updated when new images are available. If you do not want every stack covered, create per-stack schedules instead. -- **Stack list dot.** Image-update *detection* runs every six hours regardless of whether any schedule is configured. The sidebar dot and the readiness board still show available updates so you can decide what to do with them. +- **Stack list dot.** Image-update *detection* runs on the configured interval (every 2 hours by default) regardless of whether any schedule is configured. The sidebar dot and the readiness board still show available updates so you can decide what to do with them. - **Manual updates are always available.** The lifecycle **Update** action on a stack applies an update on demand, independent of any scheduled task. ## Cleaning up after updates @@ -112,7 +126,7 @@ The preview is recomputed each time the readiness board loads, so it reflects th The registry call is either still pending or it failed. Click **Recheck** in the hero to retry. If the stack uses private-registry credentials, confirm they are still valid in **Settings > Registries**. - Image update detection runs every six hours on each node and the readiness board uses the same cached status. Trigger **Recheck** to force a fresh check across every reachable node. + Image update detection runs on the configured interval (every 2 hours by default) on each node, and the readiness board uses the same cached status. Trigger **Recheck** to force a fresh check across every reachable node, or shorten the interval under **Settings > Automation > Image update checks**. No schedule covers that stack. Open **Schedules** in the top nav, create a new **Auto-update Stack** task targeting the stack (or an **Auto-update All Stacks** task on its node), and pick a cron. The next firing will include the stack, or you can trigger an immediate run from the row. diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx index 97b8f56a..9463d533 100644 --- a/frontend/src/components/AutoUpdateReadinessView.tsx +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -5,6 +5,8 @@ import { Badge } from '@/components/ui/badge'; import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play, CalendarClock, Monitor, Globe } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; +import { formatTimeAgo } from '@/lib/relativeTime'; +import type { ImageUpdateStatus } from '@/types/imageUpdates'; import { useNodes } from '@/context/NodeContext'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { Masthead, Kicker } from '@/components/mobile/mobile-ui'; @@ -65,6 +67,46 @@ interface FleetUpdateResponse { [nodeId: string]: Record; } +/** + * Detection-cadence status for the control instance's scanner, shown by the + * readiness card: when the last registry check ran, when the next is due, and + * how long the manual-recheck cooldown has left (ticking once a second). + */ +export function CadenceStrip({ cadence, className }: { cadence: ImageUpdateStatus | null; className?: string }) { + const [remainingMs, setRemainingMs] = useState(0); + useEffect(() => { + setRemainingMs(cadence?.manualCooldownRemainingMs ?? 0); + }, [cadence]); + const cooling = remainingMs > 0; + useEffect(() => { + if (!cooling) return; + const id = setInterval(() => setRemainingMs(prev => Math.max(0, prev - 1000)), 1000); + return () => clearInterval(id); + }, [cooling]); + + if (!cadence) return null; + + const lastChecked = cadence.lastCheckedAt != null ? formatTimeAgo(cadence.lastCheckedAt) : 'never'; + const nextCheck = cadence.checking + ? 'checking now' + : cadence.nextCheckAt != null + ? formatRelative(cadence.nextCheckAt) + : 'not scheduled'; + const cooldown = cooling + ? `Recheck available in ${Math.ceil(remainingMs / 1000)}s` + : 'Recheck ready'; + + return ( +
+ Last checked {lastChecked} + + Next check {nextCheck} + + {cooldown} +
+ ); +} + function formatRelative(ts: number | null): string { if (ts == null) return ''; const delta = ts - Date.now(); @@ -469,9 +511,14 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) const [reachableNodeCount, setReachableNodeCount] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [cadence, setCadence] = useState(null); const refreshTimerRef = useRef | null>(null); // Monotonic token guards against stale setGroups from older fetches. const loadTokenRef = useRef(0); + // Separate token for the cadence fetch: a slow initial /status must not + // overwrite the fresher status a Recheck just loaded, and neither may set + // state after unmount. + const cadenceTokenRef = useRef(0); // Holds the latest nodes array so loadReadiness can reference it without // re-firing every time NodeContext rebuilds the array on a meta refresh. const nodesRef = useRef(nodes); @@ -612,18 +659,37 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) } }, [localNodeId]); + // Detection-cadence status for the control instance (localOnly): the readiness + // list is fleet-wide, but the cadence shown by the card is this instance's own + // scanner, configured in Settings. Each node runs its own scanner. + const loadCadence = useCallback(async () => { + const token = ++cadenceTokenRef.current; + try { + const res = await apiFetch('/image-updates/status', { localOnly: true }); + if (!res.ok) return; + const data = await res.json() as ImageUpdateStatus; + // Drop the result if a newer cadence load started, or the view unmounted, + // while this one was in flight. + if (token === cadenceTokenRef.current) setCadence(data); + } catch (e) { + console.error('[AutoUpdate] failed to load image-update cadence status', e); + } + }, []); + useEffect(() => { if (nodesSignature === '') return; loadReadiness(); + void loadCadence(); return () => { // Invalidate any in-flight fetch and cancel pending refresh timers on unmount. loadTokenRef.current++; + cadenceTokenRef.current++; if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); refreshTimerRef.current = null; } }; - }, [loadReadiness, nodesSignature]); + }, [loadReadiness, loadCadence, nodesSignature]); const handleRefresh = useCallback(async () => { setRefreshing(true); @@ -637,6 +703,9 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) const tCount = data.triggered.length; const rCount = data.rateLimited.length; const fCount = data.failed.length; + // Re-seed the cadence strip so the manual-cooldown countdown reflects the + // recheck we just fired. + void loadCadence(); if (tCount > 0) { toast.success(`Rechecking ${tCount} ${tCount === 1 ? 'node' : 'nodes'}...`); } @@ -660,7 +729,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) } finally { setRefreshing(false); } - }, [loadReadiness]); + }, [loadReadiness, loadCadence]); const handleApply = useCallback(async (stack: string, nodeId: number) => { const setCardField = (predicate: (c: StackCard) => boolean, patch: Partial) => @@ -732,6 +801,8 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) Recheck + + {showPartialBanner && (
{reachableNodeCount} of {onlineNodeCount} nodes reachable. Unreachable nodes are not shown. @@ -743,7 +814,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
) : ( groups.map(group => ) @@ -763,6 +834,8 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) onRefresh={handleRefresh} /> + + {showPartialBanner && (
{reachableNodeCount} of {onlineNodeCount} nodes reachable. Unreachable nodes are not shown. @@ -778,7 +851,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
) : ( diff --git a/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx b/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx index 745d767f..0524c8f7 100644 --- a/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx +++ b/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx @@ -4,9 +4,20 @@ * while in flight, or when no schedule covers the stack; enabled only when a * covering schedule exists and the preview loaded without a block. */ -import { it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { MobileReadinessCard, type StackCard } from '../AutoUpdateReadinessView'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, act, waitFor, fireEvent } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, +})); +vi.mock('@/hooks/use-is-mobile', () => ({ useIsMobile: () => false })); +vi.mock('@/context/NodeContext', () => ({ + useNodes: () => ({ nodes: [{ id: 1, name: 'Local', type: 'local', status: 'online' }] }), +})); + +import { apiFetch } from '@/lib/api'; +import AutoUpdateReadinessView, { MobileReadinessCard, CadenceStrip, type StackCard } from '../AutoUpdateReadinessView'; function card(over: Partial = {}): StackCard { return { @@ -65,3 +76,132 @@ it('disables Apply when auto-update is off for the stack', () => { render(); expect(apply()).toBeDisabled(); }); + +/** + * CadenceStrip surfaces the control instance's detection cadence by the + * readiness card: a past last-check must read as an "ago" value (not the + * future-oriented "due now"), null timestamps read as never/not-scheduled, and + * the manual-recheck cooldown ticks down to "Recheck ready". + */ +describe('CadenceStrip', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders a past last-check as an "ago" value, not "due now"', () => { + const cadence = { + checking: false, + intervalMinutes: 120, + lastCheckedAt: Date.now() - 10 * 60 * 1000, + nextCheckAt: Date.now() + 110 * 60 * 1000, + manualCooldownMinutes: 2, + manualCooldownRemainingMs: 0, + }; + render(); + expect(screen.getByText(/Last checked 10m ago/)).toBeInTheDocument(); + expect(screen.queryByText(/due now/)).not.toBeInTheDocument(); + expect(screen.getByText(/Recheck ready/)).toBeInTheDocument(); + }); + + it('renders null timestamps as never / not scheduled', () => { + const cadence = { + checking: false, + intervalMinutes: 120, + lastCheckedAt: null, + nextCheckAt: null, + manualCooldownMinutes: 2, + manualCooldownRemainingMs: 0, + }; + render(); + expect(screen.getByText(/Last checked never/)).toBeInTheDocument(); + expect(screen.getByText(/Next check not scheduled/)).toBeInTheDocument(); + }); + + it('counts the manual-recheck cooldown down to "Recheck ready"', () => { + vi.useFakeTimers(); + const cadence = { + checking: false, + intervalMinutes: 120, + lastCheckedAt: Date.now(), + nextCheckAt: Date.now() + 7_200_000, + manualCooldownMinutes: 2, + manualCooldownRemainingMs: 3000, + }; + render(); + expect(screen.getByText(/Recheck available in 3s/)).toBeInTheDocument(); + act(() => { vi.advanceTimersByTime(3000); }); + expect(screen.getByText(/Recheck ready/)).toBeInTheDocument(); + }); +}); + +/** + * The cadence fetch runs on mount AND after a Recheck. A slow initial /status + * response that resolves after the recheck-triggered one must not overwrite the + * fresh cooldown the recheck just loaded. + */ +describe('AutoUpdateReadinessView cadence fetch race', () => { + const mockedFetch = apiFetch as unknown as ReturnType; + + afterEach(() => { + vi.clearAllMocks(); + }); + + function statusDeferred() { + let resolveWith!: (manualCooldownRemainingMs: number) => void; + const promise = new Promise<{ ok: true; json: () => Promise }>((resolve) => { + resolveWith = (manualCooldownRemainingMs: number) => + resolve({ + ok: true, + json: async () => ({ + checking: false, + intervalMinutes: 120, + lastCheckedAt: Date.now() - 60_000, + nextCheckAt: Date.now() + 3_600_000, + manualCooldownMinutes: 2, + manualCooldownRemainingMs, + }), + }); + }); + return { promise, resolveWith }; + } + + it('drops a stale /status response so a recheck cooldown is not overwritten', async () => { + const statusCalls: ReturnType[] = []; + mockedFetch.mockImplementation((url: string) => { + if (url === '/image-updates/fleet') return Promise.resolve({ ok: true, json: async () => ({}) }); + if (url.startsWith('/scheduled-tasks')) return Promise.resolve({ ok: true, json: async () => [] }); + if (url === '/image-updates/fleet/refresh') { + return Promise.resolve({ ok: true, json: async () => ({ triggered: [1], rateLimited: [], failed: [] }) }); + } + if (url === '/image-updates/status') { + const d = statusDeferred(); + statusCalls.push(d); + return d.promise; + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + + render(); + + // Mount fired the first /status (A); it stays pending. The hero renders once + // the readiness load settles. + const recheck = await screen.findByRole('button', { name: /recheck registries/i }); + expect(statusCalls).toHaveLength(1); + + // Recheck fires a second /status (B); resolve it with an active cooldown. + await act(async () => { fireEvent.click(recheck); }); + await waitFor(() => expect(statusCalls).toHaveLength(2)); + await act(async () => { statusCalls[1].resolveWith(120_000); }); + await screen.findByText(/Recheck available in/); + + // The slow initial load (A) resolves last with no cooldown. The token guard + // must drop it so the strip keeps showing the recheck cooldown. + await act(async () => { + statusCalls[0].resolveWith(0); + await Promise.resolve(); + }); + + expect(screen.queryByText(/Recheck ready/)).toBeNull(); + expect(screen.getByText(/Recheck available in/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/settings/SettingsSectionContent.tsx b/frontend/src/components/settings/SettingsSectionContent.tsx index f98deecd..36fedaa3 100644 --- a/frontend/src/components/settings/SettingsSectionContent.tsx +++ b/frontend/src/components/settings/SettingsSectionContent.tsx @@ -8,6 +8,7 @@ import { LicenseSection, HostAlertsSection, DockerStorageSection, + UpdatesSection, FleetMeshSection, NotificationsSection, DeveloperSection, @@ -82,6 +83,7 @@ function renderSection( case 'labels': return ; case 'host-alerts': return onDirtyChange('host-alerts', d)} />; case 'docker-storage': return onDirtyChange('docker-storage', d)} />; + case 'image-updates': return ; case 'fleet-mesh': return onDirtyChange('fleet-mesh', d)} />; case 'notifications': return ; case 'notification-routing': return ; diff --git a/frontend/src/components/settings/UpdatesSection.tsx b/frontend/src/components/settings/UpdatesSection.tsx new file mode 100644 index 00000000..b1c60d9d --- /dev/null +++ b/frontend/src/components/settings/UpdatesSection.tsx @@ -0,0 +1,151 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { useNodes } from '@/context/NodeContext'; +import { useAuth } from '@/context/AuthContext'; +import { formatTimeAgo, formatTimeUntil } from '@/lib/relativeTime'; +import { SettingsSection } from './SettingsSection'; +import { SettingsField } from './SettingsField'; +import { useMastheadStats } from './MastheadStatsContext'; +import type { ImageUpdateStatus } from '@/types/imageUpdates'; + +const INTERVAL_PRESETS: { minutes: number; label: string }[] = [ + { minutes: 15, label: '15 minutes' }, + { minutes: 30, label: '30 minutes' }, + { minutes: 60, label: '1 hour' }, + { minutes: 120, label: '2 hours' }, + { minutes: 360, label: '6 hours' }, + { minutes: 720, label: '12 hours' }, + { minutes: 1440, label: '24 hours' }, +]; + +function SectionSkeleton() { + return ( +
+ + +
+ ); +} + +export function UpdatesSection() { + const { activeNode } = useNodes(); + const { isAdmin } = useAuth(); + const readOnly = !isAdmin; + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const intervalMinutes = status?.intervalMinutes ?? null; + + useMastheadStats( + isLoading || intervalMinutes == null + ? null + : [{ label: 'INTERVAL', value: formatIntervalLabel(intervalMinutes), tone: 'value' }], + ); + + useEffect(() => { + let cancelled = false; + const fetchStatus = async () => { + setIsLoading(true); + try { + const res = await apiFetch('/image-updates/status'); + if (!res.ok) throw new Error('Failed to load image-update status'); + const data = await res.json() as ImageUpdateStatus; + if (!cancelled) setStatus(data); + } catch (e) { + console.error('Failed to fetch image-update status', e); + if (!cancelled) toast.error((e as Error)?.message || 'Failed to load image-update status.'); + } finally { + if (!cancelled) setIsLoading(false); + } + }; + fetchStatus(); + return () => { cancelled = true; }; + }, [activeNode?.id]); + + const handleIntervalChange = useCallback(async (value: string) => { + const minutes = Number(value); + if (!Number.isInteger(minutes)) return; + setIsSaving(true); + try { + const res = await apiFetch('/image-updates/interval', { + method: 'PUT', + body: JSON.stringify({ minutes }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to update interval'); + } + const data = await res.json() as ImageUpdateStatus; + setStatus(data); + toast.success(`Sencho now checks for image updates every ${formatIntervalLabel(data.intervalMinutes)}.`); + } catch (e) { + toast.error((e as Error)?.message || 'Failed to update interval.'); + } finally { + setIsSaving(false); + } + }, []); + + if (isLoading && !status) return ; + + // A value set via the API (any integer 15-1440) may not be a preset; surface + // it as a Custom option rather than leaving the picker blank. + const options = intervalMinutes != null && !INTERVAL_PRESETS.some(p => p.minutes === intervalMinutes) + ? [{ minutes: intervalMinutes, label: `Custom: ${intervalMinutes} minutes` }, ...INTERVAL_PRESETS] + : INTERVAL_PRESETS; + + const lastChecked = status?.lastCheckedAt != null ? formatTimeAgo(status.lastCheckedAt) : 'never'; + const nextCheck = status?.checking + ? 'checking now' + : status?.nextCheckAt != null + ? `in ${formatTimeUntil(status.nextCheckAt)}` + : 'not scheduled'; + + return ( +
+ + +
+ +

+ Last checked {lastChecked} · Next check {nextCheck} +

+
+
+
+
+ ); +} + +function formatIntervalLabel(minutes: number): string { + if (minutes % 1440 === 0) return `${minutes / 1440}d`; + if (minutes % 60 === 0) return `${minutes / 60}h`; + return `${minutes}m`; +} diff --git a/frontend/src/components/settings/__tests__/UpdatesSection.test.tsx b/frontend/src/components/settings/__tests__/UpdatesSection.test.tsx new file mode 100644 index 00000000..58274d5d --- /dev/null +++ b/frontend/src/components/settings/__tests__/UpdatesSection.test.tsx @@ -0,0 +1,62 @@ +/** + * UpdatesSection drives the registry-check cadence from the feature endpoint + * (GET /image-updates/status, PUT /image-updates/interval). It must load and + * show the current cadence, and present a read-only (disabled) control to + * non-admins while keeping the section visible. The PUT round-trip itself is + * covered by the backend route tests and the end-to-end check. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, +})); +const authState = { isAdmin: true }; +vi.mock('@/context/AuthContext', () => ({ useAuth: () => authState })); +vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) })); +vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} })); + +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { UpdatesSection } from '../UpdatesSection'; + +const mockedFetch = apiFetch as unknown as ReturnType; + +const STATUS = { + checking: false, + intervalMinutes: 120, + lastCheckedAt: Date.now() - 5 * 60 * 1000, + nextCheckAt: Date.now() + 115 * 60 * 1000, + manualCooldownMinutes: 2, + manualCooldownRemainingMs: 0, +}; + +beforeEach(() => { + mockedFetch.mockReset(); + authState.isAdmin = true; + mockedFetch.mockResolvedValue({ ok: true, json: async () => ({ ...STATUS }) }); +}); + +describe('UpdatesSection', () => { + it('loads the cadence status and enables the control for admins', async () => { + render(); + await waitFor(() => expect(screen.getByText(/Last checked 5m ago/)).toBeInTheDocument()); + expect(mockedFetch).toHaveBeenCalledWith('/image-updates/status'); + expect(screen.getByRole('combobox', { name: /interval/i })).toBeEnabled(); + }); + + it('shows the section read-only (control disabled) for non-admins', async () => { + authState.isAdmin = false; + render(); + await waitFor(() => expect(screen.getByText(/Last checked/)).toBeInTheDocument()); + expect(screen.getByRole('combobox', { name: /interval/i })).toBeDisabled(); + }); + + it('toasts an error and leaves the control disabled when the status load fails', async () => { + mockedFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'boom' }) }); + render(); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(screen.getByRole('combobox', { name: /interval/i })).toBeDisabled(); + }); +}); diff --git a/frontend/src/components/settings/__tests__/registry.test.ts b/frontend/src/components/settings/__tests__/registry.test.ts index 9a88a4d1..c37c938d 100644 --- a/frontend/src/components/settings/__tests__/registry.test.ts +++ b/frontend/src/components/settings/__tests__/registry.test.ts @@ -60,6 +60,16 @@ describe('settings registry', () => { expect(dataRetention?.group).toBe('operations'); }); + it('registers the Image update checks section under Automation, viewer-visible and node-scoped', () => { + const item = SETTINGS_ITEMS.find(i => i.id === 'image-updates'); + expect(item?.group).toBe('automation'); + expect(item?.scope).toBe('node'); + expect(item?.tier).toBeNull(); + // No adminOnly: viewers see the section read-only (the control disables + // itself); the backend PUT is the authoritative admin guard. + expect(item?.adminOnly).toBeUndefined(); + }); + it('applies the renamed section labels', () => { const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i])); expect(byId.get('notifications')?.label).toBe('Channels'); diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts index 9308e57b..5d7abb15 100644 --- a/frontend/src/components/settings/index.ts +++ b/frontend/src/components/settings/index.ts @@ -5,6 +5,7 @@ export { AppearanceSection } from './AppearanceSection'; export { LicenseSection } from './LicenseSection'; export { HostAlertsSection } from './HostAlertsSection'; export { DockerStorageSection } from './DockerStorageSection'; +export { UpdatesSection } from './UpdatesSection'; export { FleetMeshSection } from './FleetMeshSection'; export { NotificationsSection } from './NotificationsSection'; export { DeveloperSection } from './DeveloperSection'; diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 7ad398d7..100664be 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -212,6 +212,15 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ hiddenOnRemote: true, }, // Automation + { + id: 'image-updates', + group: 'automation', + label: 'Image update checks', + description: 'How often this node polls registries to detect available image updates and raise notifications.', + keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck'], + tier: null, + scope: 'node', + }, { id: 'webhooks', group: 'automation', diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index 4c741ad2..10d5b074 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -51,6 +51,7 @@ export type SectionId = | 'labels' | 'host-alerts' | 'docker-storage' + | 'image-updates' | 'fleet-mesh' | 'notifications' | 'webhooks' diff --git a/frontend/src/types/imageUpdates.ts b/frontend/src/types/imageUpdates.ts new file mode 100644 index 00000000..a81d9334 --- /dev/null +++ b/frontend/src/types/imageUpdates.ts @@ -0,0 +1,21 @@ +/** + * Status of a node's image-update scanner, as returned by + * `GET /api/image-updates/status`. Shared by the Settings cadence section and + * the Auto-Update readiness strip so the wire shape lives in one place. + * + * Units differ by field: `intervalMinutes` and `manualCooldownMinutes` are + * minutes; `manualCooldownRemainingMs` is milliseconds (the UI needs ms to tick + * a 1-second countdown). `manualCooldownMinutes` is a fixed ceiling (the + * cooldown window), while `manualCooldownRemainingMs` is the live remaining + * time (0 when a manual refresh is allowed). `lastCheckedAt` / `nextCheckAt` + * are epoch-ms or null ("never checked" / "not scheduled"); `nextCheckAt` is + * ignored while `checking` is true. + */ +export interface ImageUpdateStatus { + checking: boolean; + intervalMinutes: number; + lastCheckedAt: number | null; + nextCheckAt: number | null; + manualCooldownMinutes: number; + manualCooldownRemainingMs: number; +}