feat: make image-update check cadence configurable and visible (#1377)

* feat: make image-update check cadence configurable and visible

The background image-update scanner polled registries on a hardcoded
6-hour interval, with no way to see when it last ran or when the next
run was due. Operators testing updates read this as auto-update being
unreliable: a manual update checks the registry immediately and applies,
so the slow background scan rarely raised the "update available"
notification before the stack was already current.

Backend:
- ImageUpdateService reads image_update_check_interval_minutes (15-1440,
  default 120) and drives a single generation-guarded self-rescheduling
  timer with 10% per-run jitter so fleet nodes do not poll in lockstep.
  restartPolling() applies a new interval live, with no restart, and
  cannot leave a duplicate timer when a save lands mid-scan.
- GET /api/image-updates/status now returns checking, intervalMinutes,
  lastCheckedAt, nextCheckAt, and the manual-cooldown fields. New
  admin-only PUT /api/image-updates/interval persists the setting and
  reschedules.

Frontend:
- New Settings > Automation > Image update checks section to choose the
  interval (read-only for non-admins; admin enforced on the backend).
- The Auto-Update readiness view shows last-checked, next-check, and a
  ticking manual-recheck cooldown, and the copy distinguishes registry
  detection from scheduled auto-update execution.

Adds backend unit and route tests and frontend component tests, and
updates the auto-update documentation.

* fix: drop stale image-update status response in the readiness strip

loadCadence() ran on mount and again after a Recheck with no request
token, so a slow initial /image-updates/status response could resolve
after the recheck-triggered one and overwrite the fresh cooldown with
stale data, or set state after the view unmounted. Guard setCadence with
a monotonic token mirroring loadReadiness, and bump it on unmount. Adds a
regression test for the out-of-order resolution.
This commit is contained in:
Anso
2026-06-15 20:06:13 -04:00
committed by GitHub
parent 02c3b006eb
commit 058cf8f2c7
16 changed files with 890 additions and 29 deletions
@@ -8,6 +8,7 @@ import {
LicenseSection,
HostAlertsSection,
DockerStorageSection,
UpdatesSection,
FleetMeshSection,
NotificationsSection,
DeveloperSection,
@@ -82,6 +83,7 @@ function renderSection(
case 'labels': return <LabelsSection />;
case 'host-alerts': return <HostAlertsSection onDirtyChange={(d) => onDirtyChange('host-alerts', d)} />;
case 'docker-storage': return <DockerStorageSection onDirtyChange={(d) => onDirtyChange('docker-storage', d)} />;
case 'image-updates': return <UpdatesSection />;
case 'fleet-mesh': return <FleetMeshSection onDirtyChange={(d) => onDirtyChange('fleet-mesh', d)} />;
case 'notifications': return <NotificationsSection />;
case 'notification-routing': return <NotificationRoutingSection />;
@@ -0,0 +1,151 @@
import { useState, useEffect, useCallback } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
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 { SettingsSection } from './SettingsSection';
import { SettingsField } from './SettingsField';
import { useMastheadStats } from './MastheadStatsContext';
import type { ImageUpdateStatus } from '@/types/imageUpdates';
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);
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);
} 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]);
const handleIntervalChange = useCallback(async (value: string) => {
const minutes = Number(value);
if (!Number.isInteger(minutes)) return;
setIsSaving(true);
try {
const res = await apiFetch('/image-updates/interval', {
method: 'PUT',
body: JSON.stringify({ minutes }),
});
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);
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);
}
}, []);
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 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."
>
<div className="flex flex-col gap-2">
<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>
<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`;
}
@@ -0,0 +1,62 @@
/**
* UpdatesSection drives the registry-check cadence from the feature endpoint
* (GET /image-updates/status, PUT /image-updates/interval). It must load and
* show the current cadence, and present a read-only (disabled) control to
* non-admins while keeping the section visible. The PUT round-trip itself is
* covered by the backend route tests and the end-to-end check.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
}));
const authState = { isAdmin: true };
vi.mock('@/context/AuthContext', () => ({ useAuth: () => authState }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { UpdatesSection } from '../UpdatesSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
const STATUS = {
checking: false,
intervalMinutes: 120,
lastCheckedAt: Date.now() - 5 * 60 * 1000,
nextCheckAt: Date.now() + 115 * 60 * 1000,
manualCooldownMinutes: 2,
manualCooldownRemainingMs: 0,
};
beforeEach(() => {
mockedFetch.mockReset();
authState.isAdmin = true;
mockedFetch.mockResolvedValue({ ok: true, json: async () => ({ ...STATUS }) });
});
describe('UpdatesSection', () => {
it('loads the cadence status and enables the control for admins', async () => {
render(<UpdatesSection />);
await waitFor(() => expect(screen.getByText(/Last checked 5m ago/)).toBeInTheDocument());
expect(mockedFetch).toHaveBeenCalledWith('/image-updates/status');
expect(screen.getByRole('combobox', { name: /interval/i })).toBeEnabled();
});
it('shows the section read-only (control disabled) for non-admins', async () => {
authState.isAdmin = false;
render(<UpdatesSection />);
await waitFor(() => expect(screen.getByText(/Last checked/)).toBeInTheDocument());
expect(screen.getByRole('combobox', { name: /interval/i })).toBeDisabled();
});
it('toasts an error and leaves the control disabled when the status load fails', async () => {
mockedFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'boom' }) });
render(<UpdatesSection />);
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(screen.getByRole('combobox', { name: /interval/i })).toBeDisabled();
});
});
@@ -60,6 +60,16 @@ describe('settings registry', () => {
expect(dataRetention?.group).toBe('operations');
});
it('registers the Image update checks section under Automation, viewer-visible and node-scoped', () => {
const item = SETTINGS_ITEMS.find(i => i.id === 'image-updates');
expect(item?.group).toBe('automation');
expect(item?.scope).toBe('node');
expect(item?.tier).toBeNull();
// No adminOnly: viewers see the section read-only (the control disables
// itself); the backend PUT is the authoritative admin guard.
expect(item?.adminOnly).toBeUndefined();
});
it('applies the renamed section labels', () => {
const byId = new Map(SETTINGS_ITEMS.map(i => [i.id, i]));
expect(byId.get('notifications')?.label).toBe('Channels');
@@ -5,6 +5,7 @@ export { AppearanceSection } from './AppearanceSection';
export { LicenseSection } from './LicenseSection';
export { HostAlertsSection } from './HostAlertsSection';
export { DockerStorageSection } from './DockerStorageSection';
export { UpdatesSection } from './UpdatesSection';
export { FleetMeshSection } from './FleetMeshSection';
export { NotificationsSection } from './NotificationsSection';
export { DeveloperSection } from './DeveloperSection';
@@ -212,6 +212,15 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
hiddenOnRemote: true,
},
// Automation
{
id: 'image-updates',
group: 'automation',
label: 'Image update checks',
description: 'How often this node polls registries to detect available image updates and raise notifications.',
keywords: ['image', 'update', 'registry', 'check', 'interval', 'cadence', 'poll', 'auto-update', 'detection', 'recheck'],
tier: null,
scope: 'node',
},
{
id: 'webhooks',
group: 'automation',
@@ -51,6 +51,7 @@ export type SectionId =
| 'labels'
| 'host-alerts'
| 'docker-storage'
| 'image-updates'
| 'fleet-mesh'
| 'notifications'
| 'webhooks'