mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +00:00
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:
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user