feat: add node-scoped opt-out for image update detection (#1715)

* feat: add node-scoped opt-out for image update detection

Operators who use an external update authority can disable Sencho registry
polling per node without losing explicit stack Update, pull, or redeploy.

* test: fix mocks and lint for image-update checks opt-out

Scheduler tests need isChecksEnabled on the ImageUpdateService mock, and the UpdatesSection older-node fixture must not leave an unused binding.

* fix: gate update-preview and recheck when detection is off

Anatomy was still calling stack update-preview (and contacting registries)
while checks were disabled. Short-circuit those routes and skip recheckStack
writes so disabled nodes stay quiet until detection is re-enabled.
This commit is contained in:
Anso
2026-07-28 10:10:04 -04:00
committed by GitHub
parent e175db8e62
commit fa503ddf27
23 changed files with 722 additions and 80 deletions
@@ -135,15 +135,20 @@ export function CadenceStrip({ cadence, className }: { cadence: ImageUpdateStatu
if (!cadence) return null;
const checksOff = cadence.enabled === false;
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';
const nextCheck = checksOff
? 'disabled'
: cadence.checking
? 'checking now'
: cadence.nextCheckAt != null
? formatRelative(cadence.nextCheckAt)
: 'not scheduled';
const cooldown = checksOff
? 'Detection off'
: 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 ?? ''}`}>
@@ -465,6 +470,7 @@ function ReadinessHero({
refreshing,
onRefresh,
unresolvedChecks = false,
detectionDisabled = false,
}: {
total: number;
ready: number;
@@ -472,12 +478,15 @@ function ReadinessHero({
refreshing: boolean;
onRefresh: () => void;
unresolvedChecks?: boolean;
detectionDisabled?: boolean;
}) {
const headline = total === 0
? (unresolvedChecks ? 'No verified updates' : 'Everything is up to date')
: total === 1
? '1 update pending'
: `${total} updates pending`;
const headline = detectionDisabled
? 'Image update detection disabled'
: total === 0
? (unresolvedChecks ? 'No verified updates' : 'Everything is up to date')
: total === 1
? '1 update pending'
: `${total} updates pending`;
const acrossNodes = nodeCount > 1
? ` across ${nodeCount} nodes`
: nodeCount === 1
@@ -1269,19 +1278,25 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
<div className="flex h-full min-h-0 flex-col">
<Masthead
kicker="fleet · updates"
state={total === 0
? (checkFailures.length > 0 ? 'No verified updates' : 'Up to date')
: `${total} pending`}
stateTone={total === 0 && checkFailures.length === 0 ? 'success' : 'warning'}
live={total > 0}
meta={total > 0
? `${ready} ready · ${total - ready} in review`
: (checkFailures.length > 0 ? 'some checks unresolved' : 'all stacks current')}
state={cadence?.enabled === false
? 'Disabled'
: total === 0
? (checkFailures.length > 0 ? 'No verified updates' : 'Up to date')
: `${total} pending`}
stateTone={cadence?.enabled === false
? 'brand'
: total === 0 && checkFailures.length === 0 ? 'success' : 'warning'}
live={total > 0 && cadence?.enabled !== false}
meta={cadence?.enabled === false
? 'image update detection off'
: total > 0
? `${ready} ready · ${total - ready} in review`
: (checkFailures.length > 0 ? 'some checks unresolved' : 'all stacks current')}
right={headerActions}
/>
<div className="flex-1 min-h-0 overflow-y-auto p-4 [&>*+*]:mt-4">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing} aria-label="Recheck registries" className="gap-1.5">
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing || cadence?.enabled === false} aria-label="Recheck registries" className="gap-1.5">
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} strokeWidth={1.5} aria-hidden="true" />
Recheck
</Button>
@@ -1300,12 +1315,16 @@ 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 ${checkFailures.length > 0 ? 'text-warning/70' : 'text-success/70'}`} strokeWidth={1.5} aria-hidden="true" />
<div className="font-display italic text-xl text-stat-value">
{checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
{cadence?.enabled === false
? 'Detection disabled'
: checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
</div>
<div className="font-mono text-[11px] text-stat-subtitle">
{checkFailures.length > 0
? 'Review the unresolved checks above, then recheck.'
: 'Sencho rechecks registries on the configured interval.'}
{cadence?.enabled === false
? 'Turn image update checks back on in Settings when Sencho should monitor registries again.'
: checkFailures.length > 0
? 'Review the unresolved checks above, then recheck.'
: 'Sencho rechecks registries on the configured interval.'}
</div>
</div>
) : (
@@ -1333,6 +1352,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps)
refreshing={refreshing}
onRefresh={handleRefresh}
unresolvedChecks={checkFailures.length > 0}
detectionDisabled={cadence?.enabled === false}
/>
<CadenceStrip cadence={cadence} className="-mt-3 pl-7" />
@@ -1353,12 +1373,16 @@ 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">
<Shield className={`h-8 w-8 ${checkFailures.length > 0 ? 'text-warning/70' : 'text-success/70'}`} strokeWidth={1.5} aria-hidden="true" />
<div className="font-display italic text-xl text-stat-value">
{checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
{cadence?.enabled === false
? 'Detection disabled'
: checkFailures.length > 0 ? 'No verified updates pending' : 'All stacks on current builds'}
</div>
<div className="font-mono text-[11px] text-stat-subtitle">
{checkFailures.length > 0
? 'Review the unresolved checks above, then recheck.'
: 'Sencho rechecks registries on the configured interval.'}
{cadence?.enabled === false
? 'Turn image update checks back on in Settings when Sencho should monitor registries again.'
: checkFailures.length > 0
? 'Review the unresolved checks above, then recheck.'
: 'Sencho rechecks registries on the configured interval.'}
</div>
</div>
) : (
@@ -68,11 +68,45 @@ export function UpdatesSection() {
const activeNodeIdRef = useRef(activeNode?.id ?? null);
activeNodeIdRef.current = activeNode?.id ?? null;
const checksEnabled = status?.enabled ?? true;
const nodeSupportsEnabledSetting = status !== null && status.enabled !== undefined;
const cadenceLocked = !checksEnabled || readOnly || isSaving;
// Derive toggle state from the current status. When the field is missing
// (older remote node) the toggle is disabled with a helpful message.
const sidebarIndicators = status?.sidebarIndicators ?? false;
const nodeSupportsSidebarSetting = status !== null && status.sidebarIndicators !== undefined;
const handleChecksEnabledChange = useCallback(async (next: boolean) => {
const targetNodeId = activeNodeIdRef.current;
setIsSaving(true);
try {
const res = await apiFetch('/image-updates/enabled', {
method: 'PUT',
nodeId: targetNodeId ?? null,
body: JSON.stringify({ enabled: next }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to update setting');
}
const data = await res.json() as ImageUpdateStatus;
if (activeNodeIdRef.current === targetNodeId) {
setStatus(data);
setUiMode(data.mode);
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: ['image_update_checks_enabled'] },
}));
}
} catch (e) {
if (activeNodeIdRef.current === targetNodeId) {
toast.error((e as Error)?.message || 'Failed to update image update checks setting.');
}
} finally {
setIsSaving(false);
}
}, []);
const handleSidebarIndicatorsChange = useCallback(async (next: boolean) => {
const targetNodeId = activeNodeIdRef.current;
setIsSaving(true);
@@ -87,7 +121,7 @@ export function UpdatesSection() {
throw new Error(err?.error || 'Failed to update setting');
}
// Guard: if the active node changed while the PATCH was in flight,
// discard the response it belongs to a different node.
// discard the response; it belongs to a different node.
if (activeNodeIdRef.current === targetNodeId) {
setStatus(prev => prev ? { ...prev, sidebarIndicators: next } : prev);
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
@@ -109,7 +143,11 @@ export function UpdatesSection() {
useMastheadStats(
isLoading || intervalMinutes == null
? null
: [{ label: 'INTERVAL', value: formatIntervalLabel(intervalMinutes), tone: 'value' }],
: [{
label: checksEnabled ? 'INTERVAL' : 'CHECKS',
value: checksEnabled ? formatIntervalLabel(intervalMinutes) : 'Off',
tone: 'value',
}],
);
useEffect(() => {
@@ -208,7 +246,7 @@ export function UpdatesSection() {
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 canSaveCron = cronTrimmed.length > 0 && !cronFieldError && !hasDescriptionError && !isSaving && checksEnabled;
const handleSaveCron = useCallback(async () => {
if (!canSaveCron || intervalMinutes == null) return;
@@ -251,15 +289,36 @@ export function UpdatesSection() {
: 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';
const nextCheck = status?.enabled === false
? 'disabled'
: status?.checking
? 'checking now'
: status?.nextCheckAt != null
? `in ${formatTimeUntil(status.nextCheckAt)}`
: 'not scheduled';
const enableHelper = status !== null && status.enabled === undefined
? 'This node is running an older version of Sencho that does not support this setting. Upgrade the node to enable it.'
: checksEnabled
? 'When on, Sencho polls registries on a schedule, raises update notifications, and feeds Home, sidebar, Anatomy, and Fleet Readiness. Turn off when another tool is the update authority for this node.'
: 'Image update detection is off for this node. Scheduled registry checks and update notifications are stopped. Explicit stack Update, pull, and redeploy actions remain available.';
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="Enable image update checks"
helper={enableHelper}
htmlFor="image-checks-enabled-toggle"
>
<TogglePill
id="image-checks-enabled-toggle"
checked={checksEnabled && nodeSupportsEnabledSetting}
onChange={handleChecksEnabledChange}
disabled={status === null || !nodeSupportsEnabledSetting || readOnly || isSaving}
/>
</SettingsField>
<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."
@@ -270,6 +329,7 @@ export function UpdatesSection() {
onChange={handleModeChange}
ariaLabel="Image check scheduling mode"
className="self-start"
disabled={cadenceLocked || intervalMinutes == null}
options={[
{ value: 'interval', label: 'Interval' },
{ value: 'cron', label: 'Cron' },
@@ -280,7 +340,7 @@ export function UpdatesSection() {
<Select
value={intervalMinutes != null ? String(intervalMinutes) : undefined}
onValueChange={handleIntervalChange}
disabled={readOnly || isSaving || intervalMinutes == null}
disabled={cadenceLocked || intervalMinutes == null}
>
<SelectTrigger className="w-44" aria-label="Image update check interval">
<SelectValue placeholder="Select interval" />
@@ -301,7 +361,7 @@ export function UpdatesSection() {
placeholder="0 3 * * 1"
value={draftCron}
onChange={e => { setDraftCron(e.target.value); setSaveError(null); }}
disabled={readOnly || isSaving}
disabled={cadenceLocked}
/>
<SettingsPrimaryButton
disabled={!canSaveCron}
@@ -327,24 +387,26 @@ export function UpdatesSection() {
</SettingsField>
</SettingsSection>
<SettingsSection title="Sidebar" kicker="node-scoped">
<SettingsField
label="Show update status in sidebar"
helper={
status !== null && status.sidebarIndicators === undefined
? "This node is running an older version of Sencho that does not support this setting. Upgrade the node to enable it."
: "Show a pulsing dot when a stack has an available update and a warning icon when the check fails. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected."
}
htmlFor="sidebar-indicators-toggle"
>
<TogglePill
id="sidebar-indicators-toggle"
checked={sidebarIndicators}
onChange={handleSidebarIndicatorsChange}
disabled={status === null || !nodeSupportsSidebarSetting || readOnly || isSaving}
/>
</SettingsField>
</SettingsSection>
{checksEnabled && (
<SettingsSection title="Sidebar" kicker="node-scoped">
<SettingsField
label="Show update status in sidebar"
helper={
status !== null && status.sidebarIndicators === undefined
? "This node is running an older version of Sencho that does not support this setting. Upgrade the node to enable it."
: "Show a pulsing dot when a stack has an available update and a warning icon when the check fails. The Stack Health table on the home page always shows update status regardless of this setting. Notifications are unaffected."
}
htmlFor="sidebar-indicators-toggle"
>
<TogglePill
id="sidebar-indicators-toggle"
checked={sidebarIndicators}
onChange={handleSidebarIndicatorsChange}
disabled={status === null || !nodeSupportsSidebarSetting || readOnly || isSaving}
/>
</SettingsField>
</SettingsSection>
)}
</fieldset>
);
}
@@ -32,6 +32,8 @@ const STATUS = {
manualCooldownRemainingMs: 0,
mode: 'interval' as const,
cronExpression: null,
sidebarIndicators: true,
enabled: true,
};
beforeEach(() => {
@@ -46,6 +48,7 @@ describe('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();
expect(screen.getByLabelText(/Enable image update checks/i)).toBeChecked();
});
it('shows the section read-only (control disabled) for non-admins', async () => {
@@ -61,4 +64,34 @@ describe('UpdatesSection', () => {
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(screen.getByRole('combobox', { name: /interval/i })).toBeDisabled();
});
it('greys out cadence and shows Next check disabled when checks are off', async () => {
mockedFetch.mockResolvedValue({
ok: true,
json: async () => ({ ...STATUS, enabled: false, nextCheckAt: null }),
});
render(<UpdatesSection />);
await waitFor(() => expect(screen.getByText(/Next check disabled/)).toBeInTheDocument());
expect(screen.getByRole('combobox', { name: /interval/i })).toBeDisabled();
expect(screen.queryByText(/Show update status in sidebar/i)).not.toBeInTheDocument();
});
it('disables the enable toggle with upgrade copy when enabled is absent', async () => {
const older = {
checking: STATUS.checking,
intervalMinutes: STATUS.intervalMinutes,
lastCheckedAt: STATUS.lastCheckedAt,
nextCheckAt: STATUS.nextCheckAt,
manualCooldownMinutes: STATUS.manualCooldownMinutes,
manualCooldownRemainingMs: STATUS.manualCooldownRemainingMs,
mode: STATUS.mode,
cronExpression: STATUS.cronExpression,
sidebarIndicators: STATUS.sidebarIndicators,
};
mockedFetch.mockResolvedValue({ ok: true, json: async () => older });
render(<UpdatesSection />);
await waitFor(() => expect(screen.getByText(/older version of Sencho/i)).toBeInTheDocument());
expect(screen.getByLabelText(/Enable image update checks/i)).toBeDisabled();
expect(screen.getByRole('combobox', { name: /interval/i })).toBeEnabled();
});
});
@@ -1,9 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { renderHook, waitFor, act } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
import { apiFetch } from '@/lib/api';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import { useImageUpdates } from '../useImageUpdates';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
@@ -54,4 +55,80 @@ describe('useImageUpdates', () => {
expect(result.current.stackUpdates.web).toEqual({ hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 0 });
expect(result.current.stackUpdates.api.hasUpdate).toBe(false);
});
it('clears stack updates when status reports checks disabled', async () => {
mockedFetch.mockImplementation((url: string) => {
if (url === '/image-updates/status') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
checking: false,
intervalMinutes: 120,
lastCheckedAt: null,
nextCheckAt: null,
manualCooldownMinutes: 2,
manualCooldownRemainingMs: 0,
mode: 'interval',
cronExpression: null,
enabled: false,
}),
});
}
if (url === '/image-updates/detail') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
web: { hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 5 },
}),
});
}
return Promise.resolve({ ok: false, status: 500, json: async () => ({}) });
});
const { result } = renderHook(() => useImageUpdates(1));
await waitFor(() => expect(result.current.checksEnabled).toBe(false));
expect(result.current.stackUpdates).toEqual({});
});
it('refreshes when SENCHO_SETTINGS_CHANGED includes image_update_checks_enabled', async () => {
let statusCalls = 0;
mockedFetch.mockImplementation((url: string) => {
if (url === '/image-updates/status') {
statusCalls += 1;
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({
checking: false,
intervalMinutes: 120,
lastCheckedAt: null,
nextCheckAt: Date.now() + 60_000,
manualCooldownMinutes: 2,
manualCooldownRemainingMs: 0,
mode: 'interval',
cronExpression: null,
enabled: true,
sidebarIndicators: true,
}),
});
}
if (url === '/image-updates/detail') {
return Promise.resolve({ ok: true, status: 200, json: async () => ({}) });
}
return Promise.resolve({ ok: false, status: 500, json: async () => ({}) });
});
renderHook(() => useImageUpdates(1));
await waitFor(() => expect(statusCalls).toBeGreaterThanOrEqual(1));
const before = statusCalls;
await act(async () => {
window.dispatchEvent(new CustomEvent(SENCHO_SETTINGS_CHANGED, {
detail: { changedKeys: ['image_update_checks_enabled'] },
}));
});
await waitFor(() => expect(statusCalls).toBeGreaterThan(before));
});
});
+28 -3
View File
@@ -20,6 +20,7 @@ const IMAGE_UPDATE_POLL_MS = 5 * 60 * 1000;
export function useImageUpdates(activeNodeId: number | undefined) {
const [stackUpdates, setStackUpdates] = useState<Record<string, StackUpdateInfo>>({});
const [sidebarIndicators, setSidebarIndicators] = useState(false);
const [checksEnabled, setChecksEnabled] = useState(true);
// Track which node owns the current state. When activeNodeId changes
// React renders once with the old owner before the passive effect clears
@@ -42,6 +43,7 @@ export function useImageUpdates(activeNodeId: number | undefined) {
// Self-contained status helper: owns fetch, parse, and state write.
// A failure here never blocks the detail path below.
let detectionOn = true;
const fetchStatus = async (): Promise<void> => {
try {
const res = await apiFetch('/image-updates/status', { nodeId: targetNodeId });
@@ -50,6 +52,12 @@ export function useImageUpdates(activeNodeId: number | undefined) {
const data = await res.json() as ImageUpdateStatus;
if (genRef.current !== gen) return;
setSidebarIndicators(data.sidebarIndicators ?? false);
// Older remotes omit enabled; treat absence as on for badge logic.
detectionOn = data.enabled !== false;
setChecksEnabled(detectionOn);
if (!detectionOn) {
setStackUpdates({});
}
} else {
console.error('[ImageUpdates] status fetch returned', res.status);
}
@@ -64,9 +72,17 @@ export function useImageUpdates(activeNodeId: number | undefined) {
try {
const res = await apiFetch('/image-updates/detail', { nodeId: targetNodeId });
if (genRef.current !== gen) return;
if (!detectionOn) {
setStackUpdates({});
return;
}
if (res.ok) {
const data = await res.json() as Record<string, StackUpdateInfo>;
if (genRef.current !== gen) return;
if (!detectionOn) {
setStackUpdates({});
return;
}
setStackUpdates(data);
return;
}
@@ -96,7 +112,10 @@ export function useImageUpdates(activeNodeId: number | undefined) {
}
};
await Promise.allSettled([fetchStatus(), fetchDetail()]);
// Status first so a disabled node clears findings before detail can repopulate.
await fetchStatus();
if (genRef.current !== gen) return;
await fetchDetail();
// Background milestone: both image-update requests have settled for the
// active node. Fire once per node session, and only if this refresh still
@@ -120,18 +139,23 @@ export function useImageUpdates(activeNodeId: number | undefined) {
genRef.current += 1;
setStackUpdates({}); // eslint-disable-line react-hooks/set-state-in-effect
setSidebarIndicators(false); // eslint-disable-line react-hooks/set-state-in-effect
setChecksEnabled(true); // eslint-disable-line react-hooks/set-state-in-effect
setOwnerNodeId(activeNodeId); // eslint-disable-line react-hooks/set-state-in-effect
void refreshRef.current();
const id = setInterval(() => { void refreshRef.current(); }, IMAGE_UPDATE_POLL_MS);
return () => clearInterval(id);
}, [activeNodeId]);
// React to settings changes so toggling the sidebar-indicator preference
// React to settings changes so toggling sidebar indicators or checks-enabled
// propagates immediately without waiting for the 5-minute poll.
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<{ changedKeys?: string[] }>).detail;
if (detail?.changedKeys?.includes('image_update_sidebar_indicators')) {
const keys = detail?.changedKeys ?? [];
if (
keys.includes('image_update_sidebar_indicators')
|| keys.includes('image_update_checks_enabled')
) {
refreshRef.current();
}
};
@@ -147,5 +171,6 @@ export function useImageUpdates(activeNodeId: number | undefined) {
stackUpdates: isOwner ? stackUpdates : {} as Record<string, StackUpdateInfo>,
refresh,
sidebarIndicators: isOwner ? sidebarIndicators : false,
checksEnabled: isOwner ? checksEnabled : true,
};
}
+5
View File
@@ -24,6 +24,11 @@ export interface ImageUpdateStatus {
cronExpression: string | null;
/** Whether sidebar update-status indicators are enabled. Optional for older-node compatibility. */
sidebarIndicators?: boolean;
/**
* Whether background image-update detection is armed. Optional for older
* remotes; absence means the node does not support the opt-out yet.
*/
enabled?: boolean;
}
/**