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
@@ -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,
};
}