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();
});
});