mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +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:
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* UpdatesSection drives the registry-check cadence from the feature endpoint
|
||||
* (GET /image-updates/status, PUT /image-updates/interval). It must load and
|
||||
* show the current cadence, and present a read-only (disabled) control to
|
||||
* non-admins while keeping the section visible. The PUT round-trip itself is
|
||||
* covered by the backend route tests and the end-to-end check.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: 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() },
|
||||
}));
|
||||
const authState = { isAdmin: true };
|
||||
vi.mock('@/context/AuthContext', () => ({ useAuth: () => authState }));
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) }));
|
||||
vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} }));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { UpdatesSection } from '../UpdatesSection';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const STATUS = {
|
||||
checking: false,
|
||||
intervalMinutes: 120,
|
||||
lastCheckedAt: Date.now() - 5 * 60 * 1000,
|
||||
nextCheckAt: Date.now() + 115 * 60 * 1000,
|
||||
manualCooldownMinutes: 2,
|
||||
manualCooldownRemainingMs: 0,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
authState.isAdmin = true;
|
||||
mockedFetch.mockResolvedValue({ ok: true, json: async () => ({ ...STATUS }) });
|
||||
});
|
||||
|
||||
describe('UpdatesSection', () => {
|
||||
it('loads the cadence status and enables the control for admins', async () => {
|
||||
render(<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();
|
||||
});
|
||||
|
||||
it('shows the section read-only (control disabled) for non-admins', async () => {
|
||||
authState.isAdmin = false;
|
||||
render(<UpdatesSection />);
|
||||
await waitFor(() => expect(screen.getByText(/Last checked/)).toBeInTheDocument());
|
||||
expect(screen.getByRole('combobox', { name: /interval/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('toasts an error and leaves the control disabled when the status load fails', async () => {
|
||||
mockedFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'boom' }) });
|
||||
render(<UpdatesSection />);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(screen.getByRole('combobox', { name: /interval/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user