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 (
);
}
function formatIntervalLabel(minutes: number): string {
if (minutes % 1440 === 0) return `${minutes / 1440}d`;
if (minutes % 60 === 0) return `${minutes / 60}h`;
return `${minutes}m`;
}