mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
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:
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user