mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 12:49:03 +00:00
7320a86579
* 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.
292 lines
13 KiB
TypeScript
292 lines
13 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
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' },
|
|
{ minutes: 60, label: '1 hour' },
|
|
{ minutes: 120, label: '2 hours' },
|
|
{ minutes: 360, label: '6 hours' },
|
|
{ minutes: 720, label: '12 hours' },
|
|
{ minutes: 1440, label: '24 hours' },
|
|
];
|
|
|
|
function SectionSkeleton() {
|
|
return (
|
|
<div className="space-y-3 rounded-lg border border-glass-border bg-glass p-4">
|
|
<Skeleton className="h-10 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function UpdatesSection() {
|
|
const { activeNode } = useNodes();
|
|
const { isAdmin } = useAuth();
|
|
const readOnly = !isAdmin;
|
|
const [status, setStatus] = useState<ImageUpdateStatus | null>(null);
|
|
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<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;
|
|
|
|
useMastheadStats(
|
|
isLoading || intervalMinutes == null
|
|
? null
|
|
: [{ label: 'INTERVAL', value: formatIntervalLabel(intervalMinutes), tone: 'value' }],
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const fetchStatus = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
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);
|
|
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.');
|
|
} finally {
|
|
if (!cancelled) setIsLoading(false);
|
|
}
|
|
};
|
|
fetchStatus();
|
|
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<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', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err?.error || 'Failed to update interval');
|
|
}
|
|
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.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, []);
|
|
|
|
// ── 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 />;
|
|
|
|
// A value set via the API (any integer 15-1440) may not be a preset; surface
|
|
// it as a Custom option rather than leaving the picker blank.
|
|
const options = intervalMinutes != null && !INTERVAL_PRESETS.some(p => p.minutes === intervalMinutes)
|
|
? [{ minutes: intervalMinutes, label: `Custom: ${intervalMinutes} minutes` }, ...INTERVAL_PRESETS]
|
|
: INTERVAL_PRESETS;
|
|
|
|
const lastChecked = status?.lastCheckedAt != null ? formatTimeAgo(status.lastCheckedAt) : 'never';
|
|
const nextCheck = status?.checking
|
|
? 'checking now'
|
|
: status?.nextCheckAt != null
|
|
? `in ${formatTimeUntil(status.nextCheckAt)}`
|
|
: 'not scheduled';
|
|
|
|
return (
|
|
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
|
|
<SettingsSection title="Registry checks" kicker="node-scoped">
|
|
<SettingsField
|
|
label="Check registries for image updates every"
|
|
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-3">
|
|
<SegmentedControl<ImageCheckMode>
|
|
value={uiMode}
|
|
onChange={handleModeChange}
|
|
ariaLabel="Image check scheduling mode"
|
|
className="self-start"
|
|
options={[
|
|
{ value: 'interval', label: 'Interval' },
|
|
{ value: 'cron', label: 'Cron' },
|
|
]}
|
|
/>
|
|
|
|
{uiMode === 'interval' ? (
|
|
<Select
|
|
value={intervalMinutes != null ? String(intervalMinutes) : undefined}
|
|
onValueChange={handleIntervalChange}
|
|
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">
|
|
Last checked {lastChecked} · Next check {nextCheck}
|
|
</p>
|
|
</div>
|
|
</SettingsField>
|
|
</SettingsSection>
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
function formatIntervalLabel(minutes: number): string {
|
|
if (minutes % 1440 === 0) return `${minutes / 1440}d`;
|
|
if (minutes % 60 === 0) return `${minutes / 60}h`;
|
|
return `${minutes}m`;
|
|
}
|