mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
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:
@@ -5,6 +5,8 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, Shield, AlertTriangle, ShieldAlert, CircleSlash, Clock, Play, CalendarClock, Monitor, Globe } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import type { ImageUpdateStatus } from '@/types/imageUpdates';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
import { Masthead, Kicker } from '@/components/mobile/mobile-ui';
|
||||
@@ -65,6 +67,46 @@ interface FleetUpdateResponse {
|
||||
[nodeId: string]: Record<string, boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection-cadence status for the control instance's scanner, shown by the
|
||||
* readiness card: when the last registry check ran, when the next is due, and
|
||||
* how long the manual-recheck cooldown has left (ticking once a second).
|
||||
*/
|
||||
export function CadenceStrip({ cadence, className }: { cadence: ImageUpdateStatus | null; className?: string }) {
|
||||
const [remainingMs, setRemainingMs] = useState(0);
|
||||
useEffect(() => {
|
||||
setRemainingMs(cadence?.manualCooldownRemainingMs ?? 0);
|
||||
}, [cadence]);
|
||||
const cooling = remainingMs > 0;
|
||||
useEffect(() => {
|
||||
if (!cooling) return;
|
||||
const id = setInterval(() => setRemainingMs(prev => Math.max(0, prev - 1000)), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [cooling]);
|
||||
|
||||
if (!cadence) return null;
|
||||
|
||||
const lastChecked = cadence.lastCheckedAt != null ? formatTimeAgo(cadence.lastCheckedAt) : 'never';
|
||||
const nextCheck = cadence.checking
|
||||
? 'checking now'
|
||||
: cadence.nextCheckAt != null
|
||||
? formatRelative(cadence.nextCheckAt)
|
||||
: 'not scheduled';
|
||||
const cooldown = cooling
|
||||
? `Recheck available in ${Math.ceil(remainingMs / 1000)}s`
|
||||
: 'Recheck ready';
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[11px] text-stat-subtitle/90 ${className ?? ''}`}>
|
||||
<span>Last checked {lastChecked}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>Next check {nextCheck}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{cooldown}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelative(ts: number | null): string {
|
||||
if (ts == null) return '';
|
||||
const delta = ts - Date.now();
|
||||
@@ -469,9 +511,14 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
const [reachableNodeCount, setReachableNodeCount] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [cadence, setCadence] = useState<ImageUpdateStatus | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Monotonic token guards against stale setGroups from older fetches.
|
||||
const loadTokenRef = useRef(0);
|
||||
// Separate token for the cadence fetch: a slow initial /status must not
|
||||
// overwrite the fresher status a Recheck just loaded, and neither may set
|
||||
// state after unmount.
|
||||
const cadenceTokenRef = useRef(0);
|
||||
// Holds the latest nodes array so loadReadiness can reference it without
|
||||
// re-firing every time NodeContext rebuilds the array on a meta refresh.
|
||||
const nodesRef = useRef(nodes);
|
||||
@@ -612,18 +659,37 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
}
|
||||
}, [localNodeId]);
|
||||
|
||||
// Detection-cadence status for the control instance (localOnly): the readiness
|
||||
// list is fleet-wide, but the cadence shown by the card is this instance's own
|
||||
// scanner, configured in Settings. Each node runs its own scanner.
|
||||
const loadCadence = useCallback(async () => {
|
||||
const token = ++cadenceTokenRef.current;
|
||||
try {
|
||||
const res = await apiFetch('/image-updates/status', { localOnly: true });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json() as ImageUpdateStatus;
|
||||
// Drop the result if a newer cadence load started, or the view unmounted,
|
||||
// while this one was in flight.
|
||||
if (token === cadenceTokenRef.current) setCadence(data);
|
||||
} catch (e) {
|
||||
console.error('[AutoUpdate] failed to load image-update cadence status', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (nodesSignature === '') return;
|
||||
loadReadiness();
|
||||
void loadCadence();
|
||||
return () => {
|
||||
// Invalidate any in-flight fetch and cancel pending refresh timers on unmount.
|
||||
loadTokenRef.current++;
|
||||
cadenceTokenRef.current++;
|
||||
if (refreshTimerRef.current) {
|
||||
clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loadReadiness, nodesSignature]);
|
||||
}, [loadReadiness, loadCadence, nodesSignature]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
@@ -637,6 +703,9 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
const tCount = data.triggered.length;
|
||||
const rCount = data.rateLimited.length;
|
||||
const fCount = data.failed.length;
|
||||
// Re-seed the cadence strip so the manual-cooldown countdown reflects the
|
||||
// recheck we just fired.
|
||||
void loadCadence();
|
||||
if (tCount > 0) {
|
||||
toast.success(`Rechecking ${tCount} ${tCount === 1 ? 'node' : 'nodes'}...`);
|
||||
}
|
||||
@@ -660,7 +729,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [loadReadiness]);
|
||||
}, [loadReadiness, loadCadence]);
|
||||
|
||||
const handleApply = useCallback(async (stack: string, nodeId: number) => {
|
||||
const setCardField = (predicate: (c: StackCard) => boolean, patch: Partial<StackCard>) =>
|
||||
@@ -732,6 +801,8 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
Recheck
|
||||
</Button>
|
||||
</div>
|
||||
<CadenceStrip cadence={cadence} />
|
||||
|
||||
{showPartialBanner && (
|
||||
<div className="font-mono text-[11px] text-stat-subtitle">
|
||||
{reachableNodeCount} of {onlineNodeCount} nodes reachable. Unreachable nodes are not shown.
|
||||
@@ -743,7 +814,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-card-border bg-card/40 py-16 text-center">
|
||||
<Shield className="h-8 w-8 text-success/70" strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="font-display italic text-xl text-stat-value">All stacks on current builds</div>
|
||||
<div className="font-mono text-[11px] text-stat-subtitle">Sencho rechecks on the scheduler interval.</div>
|
||||
<div className="font-mono text-[11px] text-stat-subtitle">Sencho rechecks registries on the configured interval.</div>
|
||||
</div>
|
||||
) : (
|
||||
groups.map(group => <MobileNodeSection key={group.nodeId} group={group} onApply={handleApply} />)
|
||||
@@ -763,6 +834,8 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
|
||||
<CadenceStrip cadence={cadence} className="-mt-3 pl-7" />
|
||||
|
||||
{showPartialBanner && (
|
||||
<div className="font-mono text-[11px] text-stat-subtitle/90 -mt-3 pl-7">
|
||||
{reachableNodeCount} of {onlineNodeCount} nodes reachable. Unreachable nodes are not shown.
|
||||
@@ -778,7 +851,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
|
||||
<Shield className="h-8 w-8 text-success/70" strokeWidth={1.5} aria-hidden="true" />
|
||||
<div className="font-display italic text-xl text-stat-value">All stacks on current builds</div>
|
||||
<div className="font-mono text-[11px] text-stat-subtitle">
|
||||
Sencho will recheck registries on the scheduler interval.
|
||||
Sencho rechecks registries on the configured interval.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -4,9 +4,20 @@
|
||||
* while in flight, or when no schedule covers the stack; enabled only when a
|
||||
* covering schedule exists and the preview loaded without a block.
|
||||
*/
|
||||
import { it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MobileReadinessCard, type StackCard } from '../AutoUpdateReadinessView';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, act, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: 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() },
|
||||
}));
|
||||
vi.mock('@/hooks/use-is-mobile', () => ({ useIsMobile: () => false }));
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ nodes: [{ id: 1, name: 'Local', type: 'local', status: 'online' }] }),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import AutoUpdateReadinessView, { MobileReadinessCard, CadenceStrip, type StackCard } from '../AutoUpdateReadinessView';
|
||||
|
||||
function card(over: Partial<StackCard> = {}): StackCard {
|
||||
return {
|
||||
@@ -65,3 +76,132 @@ it('disables Apply when auto-update is off for the stack', () => {
|
||||
render(<MobileReadinessCard card={card({ autoUpdateEnabled: false })} onApply={vi.fn()} />);
|
||||
expect(apply()).toBeDisabled();
|
||||
});
|
||||
|
||||
/**
|
||||
* CadenceStrip surfaces the control instance's detection cadence by the
|
||||
* readiness card: a past last-check must read as an "ago" value (not the
|
||||
* future-oriented "due now"), null timestamps read as never/not-scheduled, and
|
||||
* the manual-recheck cooldown ticks down to "Recheck ready".
|
||||
*/
|
||||
describe('CadenceStrip', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders a past last-check as an "ago" value, not "due now"', () => {
|
||||
const cadence = {
|
||||
checking: false,
|
||||
intervalMinutes: 120,
|
||||
lastCheckedAt: Date.now() - 10 * 60 * 1000,
|
||||
nextCheckAt: Date.now() + 110 * 60 * 1000,
|
||||
manualCooldownMinutes: 2,
|
||||
manualCooldownRemainingMs: 0,
|
||||
};
|
||||
render(<CadenceStrip cadence={cadence} />);
|
||||
expect(screen.getByText(/Last checked 10m ago/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/due now/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Recheck ready/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders null timestamps as never / not scheduled', () => {
|
||||
const cadence = {
|
||||
checking: false,
|
||||
intervalMinutes: 120,
|
||||
lastCheckedAt: null,
|
||||
nextCheckAt: null,
|
||||
manualCooldownMinutes: 2,
|
||||
manualCooldownRemainingMs: 0,
|
||||
};
|
||||
render(<CadenceStrip cadence={cadence} />);
|
||||
expect(screen.getByText(/Last checked never/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Next check not scheduled/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts the manual-recheck cooldown down to "Recheck ready"', () => {
|
||||
vi.useFakeTimers();
|
||||
const cadence = {
|
||||
checking: false,
|
||||
intervalMinutes: 120,
|
||||
lastCheckedAt: Date.now(),
|
||||
nextCheckAt: Date.now() + 7_200_000,
|
||||
manualCooldownMinutes: 2,
|
||||
manualCooldownRemainingMs: 3000,
|
||||
};
|
||||
render(<CadenceStrip cadence={cadence} />);
|
||||
expect(screen.getByText(/Recheck available in 3s/)).toBeInTheDocument();
|
||||
act(() => { vi.advanceTimersByTime(3000); });
|
||||
expect(screen.getByText(/Recheck ready/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The cadence fetch runs on mount AND after a Recheck. A slow initial /status
|
||||
* response that resolves after the recheck-triggered one must not overwrite the
|
||||
* fresh cooldown the recheck just loaded.
|
||||
*/
|
||||
describe('AutoUpdateReadinessView cadence fetch race', () => {
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function statusDeferred() {
|
||||
let resolveWith!: (manualCooldownRemainingMs: number) => void;
|
||||
const promise = new Promise<{ ok: true; json: () => Promise<unknown> }>((resolve) => {
|
||||
resolveWith = (manualCooldownRemainingMs: number) =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
checking: false,
|
||||
intervalMinutes: 120,
|
||||
lastCheckedAt: Date.now() - 60_000,
|
||||
nextCheckAt: Date.now() + 3_600_000,
|
||||
manualCooldownMinutes: 2,
|
||||
manualCooldownRemainingMs,
|
||||
}),
|
||||
});
|
||||
});
|
||||
return { promise, resolveWith };
|
||||
}
|
||||
|
||||
it('drops a stale /status response so a recheck cooldown is not overwritten', async () => {
|
||||
const statusCalls: ReturnType<typeof statusDeferred>[] = [];
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
if (url.startsWith('/scheduled-tasks')) return Promise.resolve({ ok: true, json: async () => [] });
|
||||
if (url === '/image-updates/fleet/refresh') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ triggered: [1], rateLimited: [], failed: [] }) });
|
||||
}
|
||||
if (url === '/image-updates/status') {
|
||||
const d = statusDeferred();
|
||||
statusCalls.push(d);
|
||||
return d.promise;
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
|
||||
// Mount fired the first /status (A); it stays pending. The hero renders once
|
||||
// the readiness load settles.
|
||||
const recheck = await screen.findByRole('button', { name: /recheck registries/i });
|
||||
expect(statusCalls).toHaveLength(1);
|
||||
|
||||
// Recheck fires a second /status (B); resolve it with an active cooldown.
|
||||
await act(async () => { fireEvent.click(recheck); });
|
||||
await waitFor(() => expect(statusCalls).toHaveLength(2));
|
||||
await act(async () => { statusCalls[1].resolveWith(120_000); });
|
||||
await screen.findByText(/Recheck available in/);
|
||||
|
||||
// The slow initial load (A) resolves last with no cooldown. The token guard
|
||||
// must drop it so the strip keeps showing the recheck cooldown.
|
||||
await act(async () => {
|
||||
statusCalls[0].resolveWith(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Recheck ready/)).toBeNull();
|
||||
expect(screen.getByText(/Recheck available in/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user