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 (
); } export function UpdatesSection() { const { activeNode } = useNodes(); const { isAdmin } = useAuth(); const readOnly = !isAdmin; const [status, setStatus] = useState(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('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(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 = { 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 ; // 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 (
value={uiMode} onChange={handleModeChange} ariaLabel="Image check scheduling mode" className="self-start" options={[ { value: 'interval', label: 'Interval' }, { value: 'cron', label: 'Cron' }, ]} /> {uiMode === 'interval' ? ( ) : (
{ setDraftCron(e.target.value); setSaveError(null); }} disabled={readOnly || isSaving} /> Save schedule
{saveError ?

{saveError}

: cronFieldError ?

{cronFieldError}

: cronDescription ?

{cronDescription}

: null}
)}

Last checked {lastChecked} · Next check {nextCheck}

); } function formatIntervalLabel(minutes: number): string { if (minutes % 1440 === 0) return `${minutes / 1440}d`; if (minutes % 60 === 0) return `${minutes / 60}h`; return `${minutes}m`; }