mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +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');
|
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);
|
const statusRes = await request(app).get('/api/image-updates/status').set('Cookie', adminCookie);
|
||||||
expect(statusRes.body.intervalMinutes).toBe(30);
|
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', () => {
|
describe('GET /api/image-updates/fleet', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router, type Request, type Response } from 'express';
|
import { Router, type Request, type Response } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
import DockerController from '../services/DockerController';
|
import DockerController from '../services/DockerController';
|
||||||
import { DatabaseService } from '../services/DatabaseService';
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
import { NodeRegistry } from '../services/NodeRegistry';
|
import { NodeRegistry } from '../services/NodeRegistry';
|
||||||
@@ -55,10 +56,33 @@ imageUpdatesRouter.get('/status', authMiddleware, (_req: Request, res: Response)
|
|||||||
res.json(ImageUpdateService.getInstance().getStatus());
|
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
|
// Min/max mirror ImageUpdateService's clamp; the service is the authority and
|
||||||
// re-clamps on read, so this is the user-facing validation boundary.
|
// re-clamps on read, so this is the user-facing validation boundary.
|
||||||
const IntervalPatchSchema = z.object({
|
const IntervalPatchSchema = z.object({
|
||||||
minutes: z.coerce.number().int().min(15).max(1440),
|
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 => {
|
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' });
|
res.status(400).json({ error: 'minutes must be an integer between 15 and 1440' });
|
||||||
return;
|
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 {
|
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.
|
// Reschedule the live timer so the new cadence takes effect without a restart.
|
||||||
ImageUpdateService.getInstance().restartPolling();
|
ImageUpdateService.getInstance().restartPolling();
|
||||||
res.json(ImageUpdateService.getInstance().getStatus());
|
res.json(ImageUpdateService.getInstance().getStatus());
|
||||||
|
|||||||
@@ -1585,6 +1585,8 @@ export class DatabaseService {
|
|||||||
stmt.run('health_gate_enabled', '1');
|
stmt.run('health_gate_enabled', '1');
|
||||||
stmt.run('health_gate_window_seconds', '90');
|
stmt.run('health_gate_window_seconds', '90');
|
||||||
stmt.run('image_update_check_interval_minutes', '120');
|
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');
|
stmt.run('env_block_deploy_on_missing_required', '0');
|
||||||
|
|
||||||
// Seed the default local node if none exists
|
// Seed the default local node if none exists
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
import DockerController from './DockerController';
|
import DockerController from './DockerController';
|
||||||
import { DatabaseService } from './DatabaseService';
|
import { DatabaseService } from './DatabaseService';
|
||||||
import { FileSystemService } from './FileSystemService';
|
import { FileSystemService } from './FileSystemService';
|
||||||
@@ -27,6 +28,8 @@ export interface ImageCheckResult {
|
|||||||
* remaining time (0 when a manual refresh is allowed). `lastCheckedAt` /
|
* remaining time (0 when a manual refresh is allowed). `lastCheckedAt` /
|
||||||
* `nextCheckAt` are epoch-ms or null ("never checked" / "not scheduled");
|
* `nextCheckAt` are epoch-ms or null ("never checked" / "not scheduled");
|
||||||
* `nextCheckAt` is meaningless while `checking` is true.
|
* `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 {
|
export interface ImageUpdateStatus {
|
||||||
checking: boolean;
|
checking: boolean;
|
||||||
@@ -35,6 +38,8 @@ export interface ImageUpdateStatus {
|
|||||||
nextCheckAt: number | null;
|
nextCheckAt: number | null;
|
||||||
manualCooldownMinutes: number;
|
manualCooldownMinutes: number;
|
||||||
manualCooldownRemainingMs: number;
|
manualCooldownRemainingMs: number;
|
||||||
|
mode: 'interval' | 'cron';
|
||||||
|
cronExpression: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Compose file helpers ────────────────────────────────────────────────────
|
// ─── Compose file helpers ────────────────────────────────────────────────────
|
||||||
@@ -162,6 +167,8 @@ export class ImageUpdateService {
|
|||||||
private static readonly MAX_INTERVAL_MINUTES = 1440; // 24 hours
|
private static readonly MAX_INTERVAL_MINUTES = 1440; // 24 hours
|
||||||
private static readonly DEFAULT_INTERVAL_MINUTES = 120; // 2 hours
|
private static readonly DEFAULT_INTERVAL_MINUTES = 120; // 2 hours
|
||||||
private static readonly INTERVAL_SETTING_KEY = 'image_update_check_interval_minutes';
|
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 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
|
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()
|
// Initialized at declaration so getStatus() never reports NaN before start()
|
||||||
// or configureFromSettings() has run (e.g. route tests that skip startServer).
|
// or configureFromSettings() has run (e.g. route tests that skip startServer).
|
||||||
private intervalMs = ImageUpdateService.DEFAULT_INTERVAL_MINUTES * 60 * 1000;
|
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 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 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
|
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
|
* Reads image_update_check_interval_minutes into intervalMs, clamped to
|
||||||
* [15, 1440], falling back to the 2-hour default on a missing, blank,
|
* [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 {
|
public configureFromSettings(): void {
|
||||||
this.intervalMs = ImageUpdateService.resolveIntervalMinutes() * 60 * 1000;
|
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 {
|
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 {
|
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;
|
const jitter = this.intervalMs * ImageUpdateService.JITTER_FRACTION;
|
||||||
return Math.round(this.intervalMs - jitter + Math.random() * 2 * jitter);
|
return Math.round(this.intervalMs - jitter + Math.random() * 2 * jitter);
|
||||||
}
|
}
|
||||||
@@ -328,6 +387,8 @@ export class ImageUpdateService {
|
|||||||
nextCheckAt: this.nextCheckAt,
|
nextCheckAt: this.nextCheckAt,
|
||||||
manualCooldownMinutes: ImageUpdateService.manualCooldownMinutes,
|
manualCooldownMinutes: ImageUpdateService.manualCooldownMinutes,
|
||||||
manualCooldownRemainingMs: this.getManualCooldownRemainingMs(),
|
manualCooldownRemainingMs: this.getManualCooldownRemainingMs(),
|
||||||
|
mode: this.mode,
|
||||||
|
cronExpression: this.cronExpression,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| Crash, OOM, and healthcheck events | Real-time over the Docker event stream |
|
||||||
| Host CPU / RAM / disk threshold checks | 30 seconds |
|
| Host CPU / RAM / disk threshold checks | 30 seconds |
|
||||||
| Per-stack alert rule evaluation | 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 |
|
| Sencho version check | 6 hours |
|
||||||
| Notification fanout to channels | Single shot per dispatch, 10-second timeout, no retries |
|
| Notification fanout to channels | Single shot per dispatch, 10-second timeout, no retries |
|
||||||
| Bell live updates | Pushed live over the notifications WebSocket per node |
|
| Bell live updates | Pushed live over the notifications WebSocket per node |
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ When nothing is pending, the board renders a single Shield-icon panel with the h
|
|||||||
|
|
||||||
## Detection cadence
|
## 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:
|
Detection is separate from applying updates:
|
||||||
|
|
||||||
|
|||||||
+73
-9
@@ -516,6 +516,35 @@ components:
|
|||||||
type: ["integer", "null"]
|
type: ["integer", "null"]
|
||||||
description: Unix timestamp of the last check.
|
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:
|
responses:
|
||||||
Unauthorized:
|
Unauthorized:
|
||||||
description: Authentication required. Provide a valid Bearer token.
|
description: Authentication required. Provide a valid Bearer token.
|
||||||
@@ -3118,19 +3147,54 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: getImageUpdateCheckStatus
|
operationId: getImageUpdateCheckStatus
|
||||||
tags: [Image Updates]
|
tags: [Image Updates]
|
||||||
summary: Check if update scan is running
|
summary: Get scanner cadence status
|
||||||
description: Returns whether an image update check is currently in progress.
|
description: Returns the scanner's current status including scheduling mode and next check time.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Check status.
|
description: Scanner status.
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
$ref: "#/components/schemas/ImageUpdateCheckStatus"
|
||||||
required: [checking]
|
|
||||||
properties:
|
|
||||||
checking:
|
|
||||||
type: boolean
|
|
||||||
description: "`true` if a scan is currently running."
|
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$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.
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ describe('CadenceStrip', () => {
|
|||||||
nextCheckAt: Date.now() + 110 * 60 * 1000,
|
nextCheckAt: Date.now() + 110 * 60 * 1000,
|
||||||
manualCooldownMinutes: 2,
|
manualCooldownMinutes: 2,
|
||||||
manualCooldownRemainingMs: 0,
|
manualCooldownRemainingMs: 0,
|
||||||
|
mode: 'interval' as const,
|
||||||
|
cronExpression: null,
|
||||||
};
|
};
|
||||||
render(<CadenceStrip cadence={cadence} />);
|
render(<CadenceStrip cadence={cadence} />);
|
||||||
expect(screen.getByText(/Last checked 10m ago/)).toBeInTheDocument();
|
expect(screen.getByText(/Last checked 10m ago/)).toBeInTheDocument();
|
||||||
@@ -158,6 +160,8 @@ describe('CadenceStrip', () => {
|
|||||||
nextCheckAt: null,
|
nextCheckAt: null,
|
||||||
manualCooldownMinutes: 2,
|
manualCooldownMinutes: 2,
|
||||||
manualCooldownRemainingMs: 0,
|
manualCooldownRemainingMs: 0,
|
||||||
|
mode: 'interval' as const,
|
||||||
|
cronExpression: null,
|
||||||
};
|
};
|
||||||
render(<CadenceStrip cadence={cadence} />);
|
render(<CadenceStrip cadence={cadence} />);
|
||||||
expect(screen.getByText(/Last checked never/)).toBeInTheDocument();
|
expect(screen.getByText(/Last checked never/)).toBeInTheDocument();
|
||||||
@@ -173,6 +177,8 @@ describe('CadenceStrip', () => {
|
|||||||
nextCheckAt: Date.now() + 7_200_000,
|
nextCheckAt: Date.now() + 7_200_000,
|
||||||
manualCooldownMinutes: 2,
|
manualCooldownMinutes: 2,
|
||||||
manualCooldownRemainingMs: 3000,
|
manualCooldownRemainingMs: 3000,
|
||||||
|
mode: 'interval' as const,
|
||||||
|
cronExpression: null,
|
||||||
};
|
};
|
||||||
render(<CadenceStrip cadence={cadence} />);
|
render(<CadenceStrip cadence={cadence} />);
|
||||||
expect(screen.getByText(/Recheck available in 3s/)).toBeInTheDocument();
|
expect(screen.getByText(/Recheck available in 3s/)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -7,16 +8,21 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||||
|
import { SettingsPrimaryButton } from './SettingsActions';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { formatTimeAgo, formatTimeUntil } from '@/lib/relativeTime';
|
import { formatTimeAgo, formatTimeUntil } from '@/lib/relativeTime';
|
||||||
|
import { getCronDescription, getCronFieldError } from '@/lib/scheduling';
|
||||||
import { SettingsSection } from './SettingsSection';
|
import { SettingsSection } from './SettingsSection';
|
||||||
import { SettingsField } from './SettingsField';
|
import { SettingsField } from './SettingsField';
|
||||||
import { useMastheadStats } from './MastheadStatsContext';
|
import { useMastheadStats } from './MastheadStatsContext';
|
||||||
import type { ImageUpdateStatus } from '@/types/imageUpdates';
|
import type { ImageUpdateStatus } from '@/types/imageUpdates';
|
||||||
|
|
||||||
|
type ImageCheckMode = 'interval' | 'cron';
|
||||||
|
|
||||||
const INTERVAL_PRESETS: { minutes: number; label: string }[] = [
|
const INTERVAL_PRESETS: { minutes: number; label: string }[] = [
|
||||||
{ minutes: 15, label: '15 minutes' },
|
{ minutes: 15, label: '15 minutes' },
|
||||||
{ minutes: 30, label: '30 minutes' },
|
{ minutes: 30, label: '30 minutes' },
|
||||||
@@ -44,6 +50,15 @@ export function UpdatesSection() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isSaving, setIsSaving] = 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<ImageCheckMode>('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<string | null>(null);
|
||||||
|
|
||||||
const intervalMinutes = status?.intervalMinutes ?? null;
|
const intervalMinutes = status?.intervalMinutes ?? null;
|
||||||
|
|
||||||
useMastheadStats(
|
useMastheadStats(
|
||||||
@@ -60,7 +75,13 @@ export function UpdatesSection() {
|
|||||||
const res = await apiFetch('/image-updates/status');
|
const res = await apiFetch('/image-updates/status');
|
||||||
if (!res.ok) throw new Error('Failed to load image-update status');
|
if (!res.ok) throw new Error('Failed to load image-update status');
|
||||||
const data = await res.json() as ImageUpdateStatus;
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch image-update status', e);
|
console.error('Failed to fetch image-update status', e);
|
||||||
if (!cancelled) toast.error((e as Error)?.message || 'Failed to load image-update status.');
|
if (!cancelled) toast.error((e as Error)?.message || 'Failed to load image-update status.');
|
||||||
@@ -72,14 +93,20 @@ export function UpdatesSection() {
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [activeNode?.id]);
|
}, [activeNode?.id]);
|
||||||
|
|
||||||
|
// ── Interval change (immediate save) ──────────────────────────────────
|
||||||
|
|
||||||
const handleIntervalChange = useCallback(async (value: string) => {
|
const handleIntervalChange = useCallback(async (value: string) => {
|
||||||
const minutes = Number(value);
|
const minutes = Number(value);
|
||||||
if (!Number.isInteger(minutes)) return;
|
if (!Number.isInteger(minutes)) return;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const body: Record<string, unknown> = { 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', {
|
const res = await apiFetch('/image-updates/interval', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ minutes }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
@@ -87,6 +114,8 @@ export function UpdatesSection() {
|
|||||||
}
|
}
|
||||||
const data = await res.json() as ImageUpdateStatus;
|
const data = await res.json() as ImageUpdateStatus;
|
||||||
setStatus(data);
|
setStatus(data);
|
||||||
|
setUiMode('interval');
|
||||||
|
setSaveError(null);
|
||||||
toast.success(`Sencho now checks for image updates every ${formatIntervalLabel(data.intervalMinutes)}.`);
|
toast.success(`Sencho now checks for image updates every ${formatIntervalLabel(data.intervalMinutes)}.`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error((e as Error)?.message || 'Failed to update interval.');
|
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 <SectionSkeleton />;
|
if (isLoading && !status) return <SectionSkeleton />;
|
||||||
|
|
||||||
// A value set via the API (any integer 15-1440) may not be a preset; surface
|
// A value set via the API (any integer 15-1440) may not be a preset; surface
|
||||||
@@ -115,25 +216,64 @@ export function UpdatesSection() {
|
|||||||
<SettingsSection title="Registry checks" kicker="node-scoped">
|
<SettingsSection title="Registry checks" kicker="node-scoped">
|
||||||
<SettingsField
|
<SettingsField
|
||||||
label="Check registries for image updates every"
|
label="Check registries for image updates every"
|
||||||
helper="Sencho checks registries on this interval to detect available image updates and raise notifications. Scheduled auto-update tasks apply updates on their own schedule; this only controls how often Sencho looks. Each node checks on its own interval."
|
helper="Sencho checks registries to detect available image updates and raise notifications. Choose a fixed interval, or set a cron expression for precise scheduling. Cron expressions run in the node's local timezone. Each node checks on its own schedule."
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-3">
|
||||||
<Select
|
<SegmentedControl<ImageCheckMode>
|
||||||
value={intervalMinutes != null ? String(intervalMinutes) : undefined}
|
value={uiMode}
|
||||||
onValueChange={handleIntervalChange}
|
onChange={handleModeChange}
|
||||||
disabled={readOnly || isSaving || intervalMinutes == null}
|
ariaLabel="Image check scheduling mode"
|
||||||
>
|
className="self-start"
|
||||||
<SelectTrigger className="w-44" aria-label="Image update check interval">
|
options={[
|
||||||
<SelectValue placeholder="Select interval" />
|
{ value: 'interval', label: 'Interval' },
|
||||||
</SelectTrigger>
|
{ value: 'cron', label: 'Cron' },
|
||||||
<SelectContent>
|
]}
|
||||||
{options.map(opt => (
|
/>
|
||||||
<SelectItem key={opt.minutes} value={String(opt.minutes)}>
|
|
||||||
{opt.label}
|
{uiMode === 'interval' ? (
|
||||||
</SelectItem>
|
<Select
|
||||||
))}
|
value={intervalMinutes != null ? String(intervalMinutes) : undefined}
|
||||||
</SelectContent>
|
onValueChange={handleIntervalChange}
|
||||||
</Select>
|
disabled={readOnly || isSaving || intervalMinutes == null}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-44" aria-label="Image update check interval">
|
||||||
|
<SelectValue placeholder="Select interval" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map(opt => (
|
||||||
|
<SelectItem key={opt.minutes} value={String(opt.minutes)}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
className="font-mono w-44"
|
||||||
|
placeholder="0 3 * * 1"
|
||||||
|
value={draftCron}
|
||||||
|
onChange={e => { setDraftCron(e.target.value); setSaveError(null); }}
|
||||||
|
disabled={readOnly || isSaving}
|
||||||
|
/>
|
||||||
|
<SettingsPrimaryButton
|
||||||
|
disabled={!canSaveCron}
|
||||||
|
onClick={handleSaveCron}
|
||||||
|
>
|
||||||
|
Save schedule
|
||||||
|
</SettingsPrimaryButton>
|
||||||
|
</div>
|
||||||
|
{saveError
|
||||||
|
? <p className="text-xs text-destructive">{saveError}</p>
|
||||||
|
: cronFieldError
|
||||||
|
? <p className="text-xs text-destructive">{cronFieldError}</p>
|
||||||
|
: cronDescription
|
||||||
|
? <p className="text-xs text-stat-subtitle">{cronDescription}</p>
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="font-mono text-[11px] text-stat-subtitle/90">
|
<p className="font-mono text-[11px] text-stat-subtitle/90">
|
||||||
Last checked {lastChecked} · Next check {nextCheck}
|
Last checked {lastChecked} · Next check {nextCheck}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ const STATUS = {
|
|||||||
nextCheckAt: Date.now() + 115 * 60 * 1000,
|
nextCheckAt: Date.now() + 115 * 60 * 1000,
|
||||||
manualCooldownMinutes: 2,
|
manualCooldownMinutes: 2,
|
||||||
manualCooldownRemainingMs: 0,
|
manualCooldownRemainingMs: 0,
|
||||||
|
mode: 'interval' as const,
|
||||||
|
cronExpression: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -18,4 +18,8 @@ export interface ImageUpdateStatus {
|
|||||||
nextCheckAt: number | null;
|
nextCheckAt: number | null;
|
||||||
manualCooldownMinutes: number;
|
manualCooldownMinutes: number;
|
||||||
manualCooldownRemainingMs: number;
|
manualCooldownRemainingMs: number;
|
||||||
|
/** Active scheduling mode. */
|
||||||
|
mode: 'interval' | 'cron';
|
||||||
|
/** 5-field cron expression when mode is 'cron', null otherwise. */
|
||||||
|
cronExpression: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user