diff --git a/backend/src/__tests__/image-update-service.test.ts b/backend/src/__tests__/image-update-service.test.ts index a674f3b5..e0576932 100644 --- a/backend/src/__tests__/image-update-service.test.ts +++ b/backend/src/__tests__/image-update-service.test.ts @@ -988,3 +988,111 @@ services: expect(checkedImages).not.toContain('someapp:v2'); }); }); + +describe('ImageUpdateService cron scheduling', () => { + beforeEach(() => { + (ImageUpdateService as any).instance = undefined; + mockGetGlobalSettings.mockReturnValue({ developer_mode: '0' }); + }); + + it('getStatus returns mode and cronExpression fields', () => { + const service = ImageUpdateService.getInstance(); + // Before start/configureFromSettings, defaults apply. + mockGetGlobalSettings.mockReturnValue({ developer_mode: '0' }); + service.configureFromSettings(); + const status = service.getStatus(); + expect(status.mode).toBe('interval'); + expect(status.cronExpression).toBeNull(); + }); + + it('configureFromSettings sets cron mode with valid expression', () => { + mockGetGlobalSettings.mockReturnValue({ + developer_mode: '0', + image_update_check_mode: 'cron', + image_update_check_cron: '0 3 * * 1', + image_update_check_interval_minutes: '120', + }); + const service = ImageUpdateService.getInstance(); + service.configureFromSettings(); + const status = service.getStatus(); + expect(status.mode).toBe('cron'); + expect(status.cronExpression).toBe('0 3 * * 1'); + }); + + it('configureFromSettings falls back to interval on invalid cron', () => { + mockGetGlobalSettings.mockReturnValue({ + developer_mode: '0', + image_update_check_mode: 'cron', + image_update_check_cron: 'not a cron expression', + image_update_check_interval_minutes: '120', + }); + const service = ImageUpdateService.getInstance(); + service.configureFromSettings(); + const status = service.getStatus(); + expect(status.mode).toBe('interval'); + expect(status.cronExpression).toBeNull(); + }); + + it('configureFromSettings falls back to interval when cron mode has empty expression', () => { + mockGetGlobalSettings.mockReturnValue({ + developer_mode: '0', + image_update_check_mode: 'cron', + image_update_check_cron: '', + image_update_check_interval_minutes: '120', + }); + const service = ImageUpdateService.getInstance(); + service.configureFromSettings(); + const status = service.getStatus(); + expect(status.mode).toBe('interval'); + expect(status.cronExpression).toBeNull(); + }); + + it('configureFromSettings accepts cron nicknames like @daily', () => { + mockGetGlobalSettings.mockReturnValue({ + developer_mode: '0', + image_update_check_mode: 'cron', + image_update_check_cron: '@daily', + image_update_check_interval_minutes: '120', + }); + const service = ImageUpdateService.getInstance(); + service.configureFromSettings(); + const status = service.getStatus(); + expect(status.mode).toBe('cron'); + expect(status.cronExpression).toBe('@daily'); + }); + + it('nextDelayMs computes a positive delay for a valid cron expression', () => { + mockGetGlobalSettings.mockReturnValue({ + developer_mode: '0', + image_update_check_mode: 'cron', + image_update_check_cron: '0 3 * * 1', + image_update_check_interval_minutes: '120', + }); + const service = ImageUpdateService.getInstance(); + service.configureFromSettings(); + // nextDelayMs is private; access it to verify it does not throw and returns + // a positive number (next Monday at 03:00 is in the future). + const delay = (service as any).nextDelayMs(); + expect(typeof delay).toBe('number'); + expect(delay).toBeGreaterThan(0); + }); + + it('nextDelayMs falls back to interval on runtime parse failure', () => { + // Set up cron mode, then corrupt the expression at runtime before nextDelayMs. + mockGetGlobalSettings.mockReturnValue({ + developer_mode: '0', + image_update_check_mode: 'cron', + image_update_check_cron: '0 3 * * 1', + image_update_check_interval_minutes: '120', + }); + const service = ImageUpdateService.getInstance(); + service.configureFromSettings(); + // Corrupt the expression directly on the private field. + (service as any).cronExpression = '0 0 31 2 *'; // Feb 31 — invalid + const delay = (service as any).nextDelayMs(); + // Should fall back to interval mode after the parse error. + expect(service.getStatus().mode).toBe('interval'); + expect(typeof delay).toBe('number'); + expect(delay).toBeGreaterThan(0); + }); +}); diff --git a/backend/src/__tests__/image-updates-routes.test.ts b/backend/src/__tests__/image-updates-routes.test.ts index f62a2161..3efc3f4a 100644 --- a/backend/src/__tests__/image-updates-routes.test.ts +++ b/backend/src/__tests__/image-updates-routes.test.ts @@ -122,6 +122,94 @@ describe('PUT /api/image-updates/interval', () => { const statusRes = await request(app).get('/api/image-updates/status').set('Cookie', adminCookie); expect(statusRes.body.intervalMinutes).toBe(30); }); + + // ── Cron mode ────────────────────────────────────────────────────────── + + it('persists a valid cron expression and returns the enriched status', async () => { + const res = await request(app).put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 120, mode: 'cron', cron: '0 3 * * 1' }); + expect(res.status).toBe(200); + expect(res.body.mode).toBe('cron'); + expect(res.body.cronExpression).toBe('0 3 * * 1'); + const settings = DatabaseService.getInstance().getGlobalSettings(); + expect(settings.image_update_check_mode).toBe('cron'); + expect(settings.image_update_check_cron).toBe('0 3 * * 1'); + }); + + it('accepts a cron nickname like @daily', async () => { + const res = await request(app).put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 120, mode: 'cron', cron: '@daily' }); + expect(res.status).toBe(200); + expect(res.body.mode).toBe('cron'); + expect(res.body.cronExpression).toBe('@daily'); + }); + + it('rejects a 6-field cron expression', async () => { + const res = await request(app).put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 120, mode: 'cron', cron: '0 0 3 * * 1' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/5 fields/); + }); + + it('rejects a blank cron expression when mode is cron', async () => { + const res = await request(app).put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 120, mode: 'cron', cron: ' ' }); + expect(res.status).toBe(400); + }); + + it('rejects an invalid cron expression (backend-authoritative)', async () => { + const res = await request(app).put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 120, mode: 'cron', cron: '0 0 31 2 *' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Invalid cron/); + }); + + it('rejects cron mode without a cron field', async () => { + const res = await request(app).put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 120, mode: 'cron' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Cron expression/); + }); + + it('clears cron when switching back to interval mode', async () => { + // First set cron mode. + await request(app).put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 120, mode: 'cron', cron: '0 3 * * 1' }); + // Then switch to interval. + const res = await request(app).put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 60, mode: 'interval' }); + expect(res.status).toBe(200); + expect(res.body.mode).toBe('interval'); + expect(res.body.cronExpression).toBeNull(); + const settings = DatabaseService.getInstance().getGlobalSettings(); + expect(settings.image_update_check_mode).toBe('interval'); + expect(settings.image_update_check_cron).toBe(''); + }); + + it('old-client { minutes } only does not change mode (backward compat)', async () => { + // First set cron mode. + await request(app).put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 120, mode: 'cron', cron: '0 3 * * 1' }); + // Then send old-client payload. + const res = await request(app).put('/api/image-updates/interval') + .set('Cookie', adminCookie) + .send({ minutes: 30 }); + expect(res.status).toBe(200); + // Mode and cron are unchanged. + expect(res.body.mode).toBe('cron'); + expect(res.body.cronExpression).toBe('0 3 * * 1'); + // Interval was updated (the fallback value). + expect(res.body.intervalMinutes).toBe(30); + }); }); describe('GET /api/image-updates/fleet', () => { diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index 03f92d6c..90336000 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -1,5 +1,6 @@ import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; +import { CronExpressionParser } from 'cron-parser'; import DockerController from '../services/DockerController'; import { DatabaseService } from '../services/DatabaseService'; import { NodeRegistry } from '../services/NodeRegistry'; @@ -55,10 +56,33 @@ imageUpdatesRouter.get('/status', authMiddleware, (_req: Request, res: Response) res.json(ImageUpdateService.getInstance().getStatus()); }); +/** + * Validate a cron expression using the same contract as Scheduled Operations: + * non-empty, reject 6+ fields, parse with CronExpressionParser, and prove + * .next() can produce a future fire time. Nicknames like @daily are accepted. + */ +function validateImageCheckCron(cron: unknown): string | null { + if (typeof cron !== 'string' || !cron.trim()) { + return 'Cron expression is required.'; + } + if (cron.trim().split(/\s+/).length >= 6) { + return 'Cron expression must use 5 fields (minute hour day month weekday). The seconds field is not supported.'; + } + try { + const expr = CronExpressionParser.parse(cron); + expr.next(); // prove the expression can produce a next fire time + } catch { + return 'Invalid cron expression.'; + } + return null; +} + // 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), + mode: z.enum(['interval', 'cron']).optional(), + cron: z.string().optional(), }); imageUpdatesRouter.put('/interval', authMiddleware, (req: Request, res: Response): void => { @@ -68,8 +92,33 @@ imageUpdatesRouter.put('/interval', authMiddleware, (req: Request, res: Response res.status(400).json({ error: 'minutes must be an integer between 15 and 1440' }); return; } + + // Validate cron expression when mode is 'cron'. + if (parsed.data.mode === 'cron') { + const cronError = validateImageCheckCron(parsed.data.cron); + if (cronError) { + res.status(400).json({ error: cronError }); + return; + } + } + try { - DatabaseService.getInstance().updateGlobalSetting('image_update_check_interval_minutes', String(parsed.data.minutes)); + const db = DatabaseService.getInstance(); + const writeSettings = db.getDb().transaction((entries: [string, string][]) => { + for (const [k, v] of entries) db.updateGlobalSetting(k, v); + }); + const entries: [string, string][] = [ + ['image_update_check_interval_minutes', String(parsed.data.minutes)], + ]; + if (parsed.data.mode !== undefined) { + entries.push(['image_update_check_mode', parsed.data.mode]); + } + if (parsed.data.mode === 'cron' && parsed.data.cron !== undefined) { + entries.push(['image_update_check_cron', parsed.data.cron]); + } else if (parsed.data.mode === 'interval') { + entries.push(['image_update_check_cron', '']); // clear stale cron + } + writeSettings(entries); // Reschedule the live timer so the new cadence takes effect without a restart. ImageUpdateService.getInstance().restartPolling(); res.json(ImageUpdateService.getInstance().getStatus()); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 22fb474c..293d5d40 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1585,6 +1585,8 @@ export class DatabaseService { stmt.run('health_gate_enabled', '1'); stmt.run('health_gate_window_seconds', '90'); stmt.run('image_update_check_interval_minutes', '120'); + stmt.run('image_update_check_mode', 'interval'); + stmt.run('image_update_check_cron', ''); stmt.run('env_block_deploy_on_missing_required', '0'); // Seed the default local node if none exists diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 74a4e71e..06924401 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -1,5 +1,6 @@ import path from 'path'; import YAML from 'yaml'; +import { CronExpressionParser } from 'cron-parser'; import DockerController from './DockerController'; import { DatabaseService } from './DatabaseService'; import { FileSystemService } from './FileSystemService'; @@ -27,6 +28,8 @@ export interface ImageCheckResult { * 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. + * `mode` is the active scheduling mode; `cronExpression` is the 5-field + * expression when mode is 'cron', null otherwise or when unconfigured. */ export interface ImageUpdateStatus { checking: boolean; @@ -35,6 +38,8 @@ export interface ImageUpdateStatus { nextCheckAt: number | null; manualCooldownMinutes: number; manualCooldownRemainingMs: number; + mode: 'interval' | 'cron'; + cronExpression: string | null; } // ─── Compose file helpers ──────────────────────────────────────────────────── @@ -162,6 +167,8 @@ export class ImageUpdateService { 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 MODE_SETTING_KEY = 'image_update_check_mode'; + private static readonly CRON_SETTING_KEY = 'image_update_check_cron'; 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 @@ -182,6 +189,8 @@ export class ImageUpdateService { // 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 mode: 'interval' | 'cron' = 'interval'; + private cronExpression: string | null = null; 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 @@ -242,10 +251,37 @@ export class ImageUpdateService { /** * 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. + * malformed, or unreadable value. Also reads mode and cron expression + * from global_settings; falls back to interval mode when cron is + * unconfigured or unparseable. */ public configureFromSettings(): void { this.intervalMs = ImageUpdateService.resolveIntervalMinutes() * 60 * 1000; + + const settings = DatabaseService.getInstance().getGlobalSettings(); + const rawMode = settings[ImageUpdateService.MODE_SETTING_KEY]; + this.mode = (rawMode === 'cron') ? 'cron' : 'interval'; + + if (this.mode === 'cron') { + const rawCron = settings[ImageUpdateService.CRON_SETTING_KEY]; + if (typeof rawCron === 'string' && rawCron.trim()) { + try { + const expr = CronExpressionParser.parse(rawCron); + expr.next(); // prove the expression can produce a next fire time + this.cronExpression = rawCron.trim(); + } catch { + console.warn(`[ImageUpdateService] Cron expression is invalid; falling back to interval mode. Expression: "${rawCron}"`); + this.mode = 'interval'; + this.cronExpression = null; + } + } else { + console.warn('[ImageUpdateService] Cron mode is active but no expression is set; falling back to interval mode.'); + this.mode = 'interval'; + this.cronExpression = null; + } + } else { + this.cronExpression = null; + } } private static resolveIntervalMinutes(): number { @@ -290,8 +326,31 @@ export class ImageUpdateService { } } - /** intervalMs with ±10% jitter so multiple nodes do not hit registries together. */ + /** + * Compute the next check delay. In interval mode this is intervalMs with + * ±10% jitter. In cron mode the delay is the gap between now and the next + * cron fire time, with no jitter (the user chose a specific time). Falls + * back to interval mode if the cron expression cannot be parsed at runtime. + */ private nextDelayMs(): number { + if (this.mode === 'cron' && this.cronExpression) { + try { + const expr = CronExpressionParser.parse(this.cronExpression); + const nextFire = expr.next().toDate().getTime(); + const delay = nextFire - Date.now(); + if (delay <= 0) { + // We just passed the fire time; retry in 30 s so the next + // .next() call moves to the following occurrence. + return 30_000; + } + return delay; + } catch (e) { + console.warn('[ImageUpdateService] Cron expression became invalid at runtime; falling back to interval mode:', getErrorMessage(e, String(e))); + this.mode = 'interval'; + this.cronExpression = null; + // Fall through to interval-based delay below. + } + } const jitter = this.intervalMs * ImageUpdateService.JITTER_FRACTION; return Math.round(this.intervalMs - jitter + Math.random() * 2 * jitter); } @@ -328,6 +387,8 @@ export class ImageUpdateService { nextCheckAt: this.nextCheckAt, manualCooldownMinutes: ImageUpdateService.manualCooldownMinutes, manualCooldownRemainingMs: this.getManualCooldownRemainingMs(), + mode: this.mode, + cronExpression: this.cronExpression, }; } diff --git a/docs/features/alerts-notifications.mdx b/docs/features/alerts-notifications.mdx index 752d113e..402eb642 100644 --- a/docs/features/alerts-notifications.mdx +++ b/docs/features/alerts-notifications.mdx @@ -384,7 +384,7 @@ The **Host Alerts** panel also carries the **Host thresholds** rows (CPU limit, | Crash, OOM, and healthcheck events | Real-time over the Docker event stream | | Host CPU / RAM / disk threshold checks | 30 seconds | | Per-stack alert rule evaluation | 30 seconds | -| Image update poll | 6 hours, with a 2-minute startup delay and a 2-minute cooldown on manual refresh | +| Image update poll | Configurable (default every 2 hours), with a 2-minute startup delay and a 2-minute cooldown on manual refresh | | Sencho version check | 6 hours | | Notification fanout to channels | Single shot per dispatch, 10-second timeout, no retries | | Bell live updates | Pushed live over the notifications WebSocket per node | diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index 95eeadd2..638b261c 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -33,7 +33,7 @@ When nothing is pending, the board renders a single Shield-icon panel with the h ## 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. +Sencho polls your registries on a configurable schedule to detect available image updates and raise notifications. This detection cadence is configurable under **Settings > Automation > Image update checks**: choose a fixed interval (every 15 minutes to once a day) or set a cron expression for precise scheduling (e.g. "every Monday at 03:00"). The default is every 2 hours on an interval schedule, and changing it takes effect immediately, with no restart. Detection is separate from applying updates: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9145e37b..585b3834 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -516,6 +516,35 @@ components: type: ["integer", "null"] description: Unix timestamp of the last check. + ImageUpdateCheckStatus: + type: object + properties: + checking: + type: boolean + description: "`true` if a scan is currently running." + intervalMinutes: + type: integer + description: Configured fallback interval in minutes (15-1440). + lastCheckedAt: + type: ["integer", "null"] + description: Unix epoch-ms of the last check start, or null if never checked. + nextCheckAt: + type: ["integer", "null"] + description: Unix epoch-ms of the next scheduled check, or null when not scheduled. + manualCooldownMinutes: + type: integer + description: Fixed cooldown ceiling in minutes for manual refresh. + manualCooldownRemainingMs: + type: integer + description: Live remaining cooldown in milliseconds (0 when refresh is allowed). + mode: + type: string + enum: [interval, cron] + description: Active scheduling mode. + cronExpression: + type: ["string", "null"] + description: 5-field cron expression when mode is 'cron', null otherwise. + responses: Unauthorized: description: Authentication required. Provide a valid Bearer token. @@ -3118,19 +3147,54 @@ paths: get: operationId: getImageUpdateCheckStatus tags: [Image Updates] - summary: Check if update scan is running - description: Returns whether an image update check is currently in progress. + summary: Get scanner cadence status + description: Returns the scanner's current status including scheduling mode and next check time. responses: "200": - description: Check status. + description: Scanner status. content: application/json: schema: - type: object - required: [checking] - properties: - checking: - type: boolean - description: "`true` if a scan is currently running." + $ref: "#/components/schemas/ImageUpdateCheckStatus" "401": $ref: "#/components/responses/Unauthorized" + + /api/image-updates/interval: + put: + operationId: setImageUpdateCheckSchedule + tags: [Image Updates] + summary: Set the detection schedule + description: Configure the image-update check cadence. In interval mode only `minutes` is required. In cron mode also send `mode` and `cron`. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [minutes] + properties: + minutes: + type: integer + minimum: 15 + maximum: 1440 + description: Fallback interval in minutes (used in interval mode, or as fallback when cron is unset). + mode: + type: string + enum: [interval, cron] + description: Scheduling mode. Omit to leave unchanged. + cron: + type: string + description: 5-field cron expression (required when mode is 'cron'). Nicknames like @daily are accepted. + responses: + "200": + description: Schedule updated. + content: + application/json: + schema: + $ref: "#/components/schemas/ImageUpdateCheckStatus" + "400": + $ref: "#/components/responses/Forbidden" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: Non-admin users cannot change the schedule. diff --git a/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx b/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx index e25e7e18..8e396d1a 100644 --- a/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx +++ b/frontend/src/components/__tests__/AutoUpdateReadinessView.test.tsx @@ -143,6 +143,8 @@ describe('CadenceStrip', () => { nextCheckAt: Date.now() + 110 * 60 * 1000, manualCooldownMinutes: 2, manualCooldownRemainingMs: 0, + mode: 'interval' as const, + cronExpression: null, }; render(); expect(screen.getByText(/Last checked 10m ago/)).toBeInTheDocument(); @@ -158,6 +160,8 @@ describe('CadenceStrip', () => { nextCheckAt: null, manualCooldownMinutes: 2, manualCooldownRemainingMs: 0, + mode: 'interval' as const, + cronExpression: null, }; render(); expect(screen.getByText(/Last checked never/)).toBeInTheDocument(); @@ -173,6 +177,8 @@ describe('CadenceStrip', () => { nextCheckAt: Date.now() + 7_200_000, manualCooldownMinutes: 2, manualCooldownRemainingMs: 3000, + mode: 'interval' as const, + cronExpression: null, }; render(); expect(screen.getByText(/Recheck available in 3s/)).toBeInTheDocument(); diff --git a/frontend/src/components/settings/UpdatesSection.tsx b/frontend/src/components/settings/UpdatesSection.tsx index b1c60d9d..c31e68a8 100644 --- a/frontend/src/components/settings/UpdatesSection.tsx +++ b/frontend/src/components/settings/UpdatesSection.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; +import { Input } from '@/components/ui/input'; import { Select, SelectContent, @@ -7,16 +8,21 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { SegmentedControl } from '@/components/ui/segmented-control'; +import { SettingsPrimaryButton } from './SettingsActions'; 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 { getCronDescription, getCronFieldError } from '@/lib/scheduling'; import { SettingsSection } from './SettingsSection'; import { SettingsField } from './SettingsField'; import { useMastheadStats } from './MastheadStatsContext'; import type { ImageUpdateStatus } from '@/types/imageUpdates'; +type ImageCheckMode = 'interval' | 'cron'; + const INTERVAL_PRESETS: { minutes: number; label: string }[] = [ { minutes: 15, label: '15 minutes' }, { minutes: 30, label: '30 minutes' }, @@ -44,6 +50,15 @@ export function UpdatesSection() { const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); + // uiMode drives which control is visible. It is initialized from status.mode + // on first fetch and synced back only after successful PUTs, so the user can + // switch to Cron locally without persisting, and stay on Cron after a 400. + const [uiMode, setUiMode] = useState('interval'); + // Draft cron text for the input; the saved expression lives in status.cronExpression. + const [draftCron, setDraftCron] = useState(''); + // Inline error from a failed cron save (backend 400 message). + const [saveError, setSaveError] = useState(null); + const intervalMinutes = status?.intervalMinutes ?? null; useMastheadStats( @@ -60,7 +75,13 @@ export function UpdatesSection() { 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); + if (!cancelled) { + setStatus(data); + setUiMode(data.mode); + if (data.mode === 'cron' && data.cronExpression) { + setDraftCron(data.cronExpression); + } + } } 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.'); @@ -72,14 +93,20 @@ export function UpdatesSection() { return () => { cancelled = true; }; }, [activeNode?.id]); + // ── Interval change (immediate save) ────────────────────────────────── + const handleIntervalChange = useCallback(async (value: string) => { const minutes = Number(value); if (!Number.isInteger(minutes)) return; setIsSaving(true); try { + const body: Record = { minutes }; + // When in interval mode, always send mode: 'interval' to clear any + // stale cron config on the server. + body.mode = 'interval'; const res = await apiFetch('/image-updates/interval', { method: 'PUT', - body: JSON.stringify({ minutes }), + body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -87,6 +114,8 @@ export function UpdatesSection() { } const data = await res.json() as ImageUpdateStatus; setStatus(data); + setUiMode('interval'); + setSaveError(null); toast.success(`Sencho now checks for image updates every ${formatIntervalLabel(data.intervalMinutes)}.`); } catch (e) { toast.error((e as Error)?.message || 'Failed to update interval.'); @@ -95,6 +124,78 @@ export function UpdatesSection() { } }, []); + // ── Mode toggle ─────────────────────────────────────────────────────── + + const handleModeChange = useCallback((next: ImageCheckMode) => { + setSaveError(null); + if (next === 'interval') { + // Switching to Interval: immediately persist. + setIsSaving(true); + const minutes = intervalMinutes ?? 120; + apiFetch('/image-updates/interval', { + method: 'PUT', + body: JSON.stringify({ minutes, mode: 'interval' }), + }) + .then(async res => { + if (!res.ok) throw new Error('Failed to switch to interval mode'); + const data = await res.json() as ImageUpdateStatus; + setStatus(data); + setUiMode('interval'); + }) + .catch(e => { + toast.error((e as Error)?.message || 'Failed to switch to interval mode.'); + // Keep uiMode on 'cron' on failure; do not optimistically switch. + }) + .finally(() => setIsSaving(false)); + } else { + // Switching to Cron: local UI only. Draft input appears. + setUiMode('cron'); + if (!draftCron && status?.cronExpression) { + setDraftCron(status.cronExpression); + } + } + }, [intervalMinutes, status?.cronExpression, draftCron]); + + // ── Cron save ───────────────────────────────────────────────────────── + + const cronTrimmed = draftCron.trim(); + const cronFieldError = getCronFieldError(draftCron); + const cronDescription = cronTrimmed.length > 0 ? getCronDescription(draftCron) : ''; + const hasDescriptionError = cronTrimmed.length > 0 && cronDescription === 'Invalid expression'; + const canSaveCron = cronTrimmed.length > 0 && !cronFieldError && !hasDescriptionError && !isSaving; + + const handleSaveCron = useCallback(async () => { + if (!canSaveCron || intervalMinutes == null) return; + setIsSaving(true); + setSaveError(null); + try { + const res = await apiFetch('/image-updates/interval', { + method: 'PUT', + body: JSON.stringify({ + minutes: intervalMinutes, + mode: 'cron', + cron: cronTrimmed, + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + const msg = err?.error || 'Failed to save cron schedule'; + setSaveError(msg); + // Keep uiMode='cron', keep draft. status.mode stays unchanged. + return; + } + const data = await res.json() as ImageUpdateStatus; + setStatus(data); + setUiMode('cron'); + setDraftCron(data.cronExpression ?? ''); + toast.success('Image update checks now run on a cron schedule.'); + } catch (e) { + setSaveError((e as Error)?.message || 'Failed to save cron schedule.'); + } finally { + setIsSaving(false); + } + }, [canSaveCron, intervalMinutes, cronTrimmed]); + if (isLoading && !status) return ; // A value set via the API (any integer 15-1440) may not be a preset; surface @@ -115,25 +216,64 @@ export function UpdatesSection() { -
- +
+ + value={uiMode} + onChange={handleModeChange} + ariaLabel="Image check scheduling mode" + className="self-start" + options={[ + { value: 'interval', label: 'Interval' }, + { value: 'cron', label: 'Cron' }, + ]} + /> + + {uiMode === 'interval' ? ( + + ) : ( +
+
+ { setDraftCron(e.target.value); setSaveError(null); }} + disabled={readOnly || isSaving} + /> + + Save schedule + +
+ {saveError + ?

{saveError}

+ : cronFieldError + ?

{cronFieldError}

+ : cronDescription + ?

{cronDescription}

+ : null} +
+ )} +

Last checked {lastChecked} · Next check {nextCheck}

diff --git a/frontend/src/components/settings/__tests__/UpdatesSection.test.tsx b/frontend/src/components/settings/__tests__/UpdatesSection.test.tsx index 58274d5d..724283f1 100644 --- a/frontend/src/components/settings/__tests__/UpdatesSection.test.tsx +++ b/frontend/src/components/settings/__tests__/UpdatesSection.test.tsx @@ -30,6 +30,8 @@ const STATUS = { nextCheckAt: Date.now() + 115 * 60 * 1000, manualCooldownMinutes: 2, manualCooldownRemainingMs: 0, + mode: 'interval' as const, + cronExpression: null, }; beforeEach(() => { diff --git a/frontend/src/types/imageUpdates.ts b/frontend/src/types/imageUpdates.ts index a81d9334..e6a029bc 100644 --- a/frontend/src/types/imageUpdates.ts +++ b/frontend/src/types/imageUpdates.ts @@ -18,4 +18,8 @@ export interface ImageUpdateStatus { nextCheckAt: number | null; manualCooldownMinutes: number; manualCooldownRemainingMs: number; + /** Active scheduling mode. */ + mode: 'interval' | 'cron'; + /** 5-field cron expression when mode is 'cron', null otherwise. */ + cronExpression: string | null; }