mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 18:32:52 +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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user