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