mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 22:36:19 +00:00
feat: make image-update check cadence configurable and visible (#1377)
* feat: make image-update check cadence configurable and visible The background image-update scanner polled registries on a hardcoded 6-hour interval, with no way to see when it last ran or when the next run was due. Operators testing updates read this as auto-update being unreliable: a manual update checks the registry immediately and applies, so the slow background scan rarely raised the "update available" notification before the stack was already current. Backend: - ImageUpdateService reads image_update_check_interval_minutes (15-1440, default 120) and drives a single generation-guarded self-rescheduling timer with 10% per-run jitter so fleet nodes do not poll in lockstep. restartPolling() applies a new interval live, with no restart, and cannot leave a duplicate timer when a save lands mid-scan. - GET /api/image-updates/status now returns checking, intervalMinutes, lastCheckedAt, nextCheckAt, and the manual-cooldown fields. New admin-only PUT /api/image-updates/interval persists the setting and reschedules. Frontend: - New Settings > Automation > Image update checks section to choose the interval (read-only for non-admins; admin enforced on the backend). - The Auto-Update readiness view shows last-checked, next-check, and a ticking manual-recheck cooldown, and the copy distinguishes registry detection from scheduled auto-update execution. Adds backend unit and route tests and frontend component tests, and updates the auto-update documentation. * fix: drop stale image-update status response in the readiness strip loadCadence() ran on mount and again after a Recheck with no request token, so a slow initial /image-updates/status response could resolve after the recheck-triggered one and overwrite the fresh cooldown with stale data, or set state after the view unmounted. Guard setCadence with a monotonic token mirroring loadReadiness, and bump it on unmount. Adds a regression test for the out-of-order resolution.
This commit is contained in:
@@ -4,9 +4,20 @@
|
||||
* while in flight, or when no schedule covers the stack; enabled only when a
|
||||
* covering schedule exists and the preview loaded without a block.
|
||||
*/
|
||||
import { it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MobileReadinessCard, type StackCard } from '../AutoUpdateReadinessView';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, act, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() }));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/hooks/use-is-mobile', () => ({ useIsMobile: () => false }));
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ nodes: [{ id: 1, name: 'Local', type: 'local', status: 'online' }] }),
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import AutoUpdateReadinessView, { MobileReadinessCard, CadenceStrip, type StackCard } from '../AutoUpdateReadinessView';
|
||||
|
||||
function card(over: Partial<StackCard> = {}): StackCard {
|
||||
return {
|
||||
@@ -65,3 +76,132 @@ it('disables Apply when auto-update is off for the stack', () => {
|
||||
render(<MobileReadinessCard card={card({ autoUpdateEnabled: false })} onApply={vi.fn()} />);
|
||||
expect(apply()).toBeDisabled();
|
||||
});
|
||||
|
||||
/**
|
||||
* CadenceStrip surfaces the control instance's detection cadence by the
|
||||
* readiness card: a past last-check must read as an "ago" value (not the
|
||||
* future-oriented "due now"), null timestamps read as never/not-scheduled, and
|
||||
* the manual-recheck cooldown ticks down to "Recheck ready".
|
||||
*/
|
||||
describe('CadenceStrip', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders a past last-check as an "ago" value, not "due now"', () => {
|
||||
const cadence = {
|
||||
checking: false,
|
||||
intervalMinutes: 120,
|
||||
lastCheckedAt: Date.now() - 10 * 60 * 1000,
|
||||
nextCheckAt: Date.now() + 110 * 60 * 1000,
|
||||
manualCooldownMinutes: 2,
|
||||
manualCooldownRemainingMs: 0,
|
||||
};
|
||||
render(<CadenceStrip cadence={cadence} />);
|
||||
expect(screen.getByText(/Last checked 10m ago/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/due now/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Recheck ready/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders null timestamps as never / not scheduled', () => {
|
||||
const cadence = {
|
||||
checking: false,
|
||||
intervalMinutes: 120,
|
||||
lastCheckedAt: null,
|
||||
nextCheckAt: null,
|
||||
manualCooldownMinutes: 2,
|
||||
manualCooldownRemainingMs: 0,
|
||||
};
|
||||
render(<CadenceStrip cadence={cadence} />);
|
||||
expect(screen.getByText(/Last checked never/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Next check not scheduled/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('counts the manual-recheck cooldown down to "Recheck ready"', () => {
|
||||
vi.useFakeTimers();
|
||||
const cadence = {
|
||||
checking: false,
|
||||
intervalMinutes: 120,
|
||||
lastCheckedAt: Date.now(),
|
||||
nextCheckAt: Date.now() + 7_200_000,
|
||||
manualCooldownMinutes: 2,
|
||||
manualCooldownRemainingMs: 3000,
|
||||
};
|
||||
render(<CadenceStrip cadence={cadence} />);
|
||||
expect(screen.getByText(/Recheck available in 3s/)).toBeInTheDocument();
|
||||
act(() => { vi.advanceTimersByTime(3000); });
|
||||
expect(screen.getByText(/Recheck ready/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The cadence fetch runs on mount AND after a Recheck. A slow initial /status
|
||||
* response that resolves after the recheck-triggered one must not overwrite the
|
||||
* fresh cooldown the recheck just loaded.
|
||||
*/
|
||||
describe('AutoUpdateReadinessView cadence fetch race', () => {
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function statusDeferred() {
|
||||
let resolveWith!: (manualCooldownRemainingMs: number) => void;
|
||||
const promise = new Promise<{ ok: true; json: () => Promise<unknown> }>((resolve) => {
|
||||
resolveWith = (manualCooldownRemainingMs: number) =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
checking: false,
|
||||
intervalMinutes: 120,
|
||||
lastCheckedAt: Date.now() - 60_000,
|
||||
nextCheckAt: Date.now() + 3_600_000,
|
||||
manualCooldownMinutes: 2,
|
||||
manualCooldownRemainingMs,
|
||||
}),
|
||||
});
|
||||
});
|
||||
return { promise, resolveWith };
|
||||
}
|
||||
|
||||
it('drops a stale /status response so a recheck cooldown is not overwritten', async () => {
|
||||
const statusCalls: ReturnType<typeof statusDeferred>[] = [];
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
if (url.startsWith('/scheduled-tasks')) return Promise.resolve({ ok: true, json: async () => [] });
|
||||
if (url === '/image-updates/fleet/refresh') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ triggered: [1], rateLimited: [], failed: [] }) });
|
||||
}
|
||||
if (url === '/image-updates/status') {
|
||||
const d = statusDeferred();
|
||||
statusCalls.push(d);
|
||||
return d.promise;
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
|
||||
// Mount fired the first /status (A); it stays pending. The hero renders once
|
||||
// the readiness load settles.
|
||||
const recheck = await screen.findByRole('button', { name: /recheck registries/i });
|
||||
expect(statusCalls).toHaveLength(1);
|
||||
|
||||
// Recheck fires a second /status (B); resolve it with an active cooldown.
|
||||
await act(async () => { fireEvent.click(recheck); });
|
||||
await waitFor(() => expect(statusCalls).toHaveLength(2));
|
||||
await act(async () => { statusCalls[1].resolveWith(120_000); });
|
||||
await screen.findByText(/Recheck available in/);
|
||||
|
||||
// The slow initial load (A) resolves last with no cooldown. The token guard
|
||||
// must drop it so the strip keeps showing the recheck cooldown.
|
||||
await act(async () => {
|
||||
statusCalls[0].resolveWith(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.queryByText(/Recheck ready/)).toBeNull();
|
||||
expect(screen.getByText(/Recheck available in/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user