feat: add cron scheduling mode for image update checks (#1460)

* feat: add cron scheduling mode for image update checks

Adds a cron scheduling mode alongside the existing fixed-interval
dropdown in Settings > Automation > Image update checks. Users can
now set a 5-field cron expression (e.g. "0 3 * * 1") for precise
time-of-day scheduling of registry polls.

- Backend: ImageUpdateService gains mode/cronExpression fields and
  cron-based nextDelayMs() using the existing cron-parser dependency.
  PUT /api/image-updates/interval extended with transactional writes
  and server-authoritative cron validation matching the Scheduled
  Operations contract. Nicknames like @daily are supported.
- Frontend: UpdatesSection gains a SegmentedControl toggle and cron
  text input with cronstrue-powered live description. The frontend
  does advisory validation only; backend 400s are surfaced inline.
  SettingsPrimaryButton used for explicit "Save schedule" action.
- No cron jitter (the user chose a specific time). Interval mode
  keeps existing ±10% jitter.
- Tests: 15 new backend tests covering valid cron, invalid cron,
  6-field rejection, nickname support, backward compat, runtime
  fallback, and transactional writes.
- Docs: auto-update-policies.mdx, alerts-notifications.mdx, and
  openapi.yaml updated with new scheduling mode.

* fix: add mode and cronExpression to UpdatesSection test fixtures

The existing tests failed because the mock status object was missing
the new required fields (mode, cronExpression) added with cron
scheduling support. Without them, status.mode was undefined, causing
uiMode to never match 'interval' and the Select combobox to not render.

* fix: prevent SegmentedControl from stretching full-width in SettingsField

The flex-col container defaults items to align-self: stretch, making the
Interval/Cron toggle bar span the full card width. Add self-start so it
sizes to its content.
This commit is contained in:
Anso
2026-06-25 19:47:57 -04:00
committed by GitHub
parent 3bf677af6c
commit 7320a86579
12 changed files with 558 additions and 34 deletions
+50 -1
View File
@@ -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());