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
@@ -75,6 +75,16 @@ describe('stack_update_status tri-state accessors', () => {
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(0);
});
it('clearAllStackUpdateStatus deletes only the given node rows', () => {
const other = NODE + 1;
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(NODE, 'api', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(other, 'web', true, 1000, 'ok', null);
expect(db().clearAllStackUpdateStatus(NODE)).toBe(2);
expect(db().getStackUpdateDetail(NODE)).toEqual({});
expect(db().getStackUpdateDetail(other).web).toBeDefined();
});
it('getNodeUpdateSummary counts only confirmed updates', () => {
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
@@ -9,9 +9,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const {
mockGetAuthForRegistry,
mockGetStackUpdateStatus, mockUpsertStackUpdateStatus, mockClearStackUpdateStatus,
mockClearAllStackUpdateStatus, mockUpdateGlobalSetting,
mockRecordStackCheckFailure, mockGetStackServicesJson,
mockGetSystemState, mockSetSystemState, mockAddNotificationHistory,
mockDispatchAlert,
mockDispatchAlert, mockBroadcastEvent,
mockGetStacks, mockGetStackContent, mockGetEnvContent, mockEnvExists,
mockGetAllContainers, mockGetGlobalSettings, mockInspect,
mockBuildEffectiveServiceModel,
@@ -20,12 +21,15 @@ const {
mockGetStackUpdateStatus: vi.fn().mockReturnValue({}),
mockUpsertStackUpdateStatus: vi.fn(),
mockClearStackUpdateStatus: vi.fn(),
mockClearAllStackUpdateStatus: vi.fn().mockReturnValue(0),
mockUpdateGlobalSetting: vi.fn(),
mockRecordStackCheckFailure: vi.fn(),
mockGetStackServicesJson: vi.fn().mockReturnValue([]),
mockGetSystemState: vi.fn().mockReturnValue('1'), // default: backfilled
mockSetSystemState: vi.fn(),
mockAddNotificationHistory: vi.fn(),
mockDispatchAlert: vi.fn().mockResolvedValue({ persisted: true }),
mockBroadcastEvent: vi.fn(),
mockGetStacks: vi.fn().mockResolvedValue([]),
mockGetStackContent: vi.fn().mockResolvedValue(''),
mockGetEnvContent: vi.fn().mockRejectedValue(new Error('no env')),
@@ -53,12 +57,14 @@ vi.mock('../services/DatabaseService', () => ({
DatabaseService: {
getInstance: () => ({
getGlobalSettings: mockGetGlobalSettings,
updateGlobalSetting: mockUpdateGlobalSetting,
getNodes: () => [],
getGitSource: () => undefined,
getStackProjectEnvFiles: () => [],
upsertStackUpdateStatus: mockUpsertStackUpdateStatus,
getStackUpdateStatus: mockGetStackUpdateStatus,
clearStackUpdateStatus: mockClearStackUpdateStatus,
clearAllStackUpdateStatus: mockClearAllStackUpdateStatus,
recordStackCheckFailure: mockRecordStackCheckFailure,
getStackServicesJson: mockGetStackServicesJson,
getSystemState: mockGetSystemState,
@@ -76,10 +82,15 @@ vi.mock('../services/NotificationService', () => ({
NotificationService: {
getInstance: () => ({
dispatchAlert: mockDispatchAlert,
broadcastEvent: mockBroadcastEvent,
}),
},
}));
vi.mock('../helpers/fleetUpdateCache', () => ({
invalidateFleetUpdateCache: vi.fn(),
}));
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
@@ -1209,6 +1220,62 @@ describe('ImageUpdateService - configurable interval & status', () => {
service.stop();
checkSpy.mockRestore();
});
it('start() while checks disabled arms no timer and reports enabled false', () => {
vi.useFakeTimers();
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: '0', image_update_check_interval_minutes: '60' });
const service = ImageUpdateService.getInstance();
const checkSpy = vi.spyOn(service as any, 'check').mockResolvedValue(undefined);
service.start();
const status = service.getStatus();
expect(status.enabled).toBe(false);
expect(status.nextCheckAt).toBeNull();
expect(status.checking).toBe(false);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(10 * 60 * 1000);
expect(checkSpy).not.toHaveBeenCalled();
checkSpy.mockRestore();
});
it('treats a missing checks-enabled key as enabled', () => {
mockGetGlobalSettings.mockReturnValue({});
const service = ImageUpdateService.getInstance();
expect(ImageUpdateService.isChecksEnabled()).toBe(true);
expect(service.getStatus().enabled).toBe(true);
});
it('applyChecksEnabled(false) stops polling, clears local findings, and broadcasts invalidate', () => {
vi.useFakeTimers();
mockGetGlobalSettings.mockReturnValue({ image_update_check_interval_minutes: '60' });
const service = ImageUpdateService.getInstance();
service.start();
expect(service.getStatus().nextCheckAt).not.toBeNull();
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: '0', image_update_check_interval_minutes: '60' });
mockUpdateGlobalSetting.mockImplementation((key: string, value: string) => {
if (key === 'image_update_checks_enabled') {
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: value, image_update_check_interval_minutes: '60' });
}
});
const status = service.applyChecksEnabled(false);
expect(status.enabled).toBe(false);
expect(status.nextCheckAt).toBeNull();
expect(mockUpdateGlobalSetting).toHaveBeenCalledWith('image_update_checks_enabled', '0');
expect(mockClearAllStackUpdateStatus).toHaveBeenCalledWith(1);
expect(mockBroadcastEvent).toHaveBeenCalledWith(expect.objectContaining({
type: 'state-invalidate',
scope: 'image-updates',
nodeId: 1,
}));
expect(vi.getTimerCount()).toBe(0);
});
it('triggerManualRefresh returns false when checks are disabled', () => {
mockGetGlobalSettings.mockReturnValue({ image_update_checks_enabled: '0' });
const service = ImageUpdateService.getInstance();
expect(service.triggerManualRefresh()).toBe(false);
});
});
// ── Stale stack pruning ─────────────────────────────────────────────────
@@ -1572,6 +1639,7 @@ services:
mockGetSystemState.mockReturnValue('1');
mockGetAllContainers.mockResolvedValue([]);
mockEnvExists.mockResolvedValue(false);
mockGetGlobalSettings.mockReturnValue({ developer_mode: '0' });
});
it('reduces per-service status through the effective model and persists services_json with a generation', async () => {
@@ -1693,6 +1761,25 @@ services:
});
describe('recheckStack', () => {
it('skips registry probes and DB writes when checks are disabled', async () => {
mockGetGlobalSettings.mockReturnValueOnce({ image_update_checks_enabled: '0' });
const service = ImageUpdateService.getInstance();
(service as any).checkImage = vi.fn().mockResolvedValue({ hasUpdate: true });
const genBefore = service.peekStackWriteGeneration(1, 'stackA');
const result = await service.recheckStack(1, 'stackA');
expect(result).toEqual({ outcome: 'cleared', warning: null });
expect(service.peekStackWriteGeneration(1, 'stackA')).toBe(genBefore);
expect(mockBuildEffectiveServiceModel).not.toHaveBeenCalled();
expect(mockGetAllContainers).not.toHaveBeenCalled();
expect((service as any).checkImage).not.toHaveBeenCalled();
expect(mockUpsertStackUpdateStatus).not.toHaveBeenCalled();
expect(mockRecordStackCheckFailure).not.toHaveBeenCalled();
expect(mockClearStackUpdateStatus).not.toHaveBeenCalled();
expect(mockClearAllStackUpdateStatus).not.toHaveBeenCalled();
});
it('returns still_present when a checkable service still has an update', async () => {
mockBuildEffectiveServiceModel.mockResolvedValueOnce({
renderable: true,
@@ -115,6 +115,65 @@ describe('GET /api/image-updates/status', () => {
expect(typeof res.body.manualCooldownRemainingMs).toBe('number');
expect('lastCheckedAt' in res.body).toBe(true);
expect('nextCheckAt' in res.body).toBe(true);
expect(res.body.enabled).toBe(true);
});
});
describe('PUT /api/image-updates/enabled', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app).put('/api/image-updates/enabled').send({ enabled: false });
expect(res.status).toBe(401);
});
it('rejects non-admin users with 403', async () => {
const res = await request(app).put('/api/image-updates/enabled').set('Cookie', viewerCookie).send({ enabled: false });
expect(res.status).toBe(403);
});
it('disables checks, clears local findings, and returns enabled false', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'pending-stack', true, Date.now(), 'ok', null);
expect(Object.keys(db.getStackUpdateDetail(nodeId)).length).toBeGreaterThan(0);
const res = await request(app).put('/api/image-updates/enabled').set('Cookie', adminCookie).send({ enabled: false });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(false);
expect(res.body.nextCheckAt).toBeNull();
expect(db.getGlobalSettings().image_update_checks_enabled).toBe('0');
expect(db.getStackUpdateDetail(nodeId)).toEqual({});
});
it('re-enables checks and returns enabled true', async () => {
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0');
const res = await request(app).put('/api/image-updates/enabled').set('Cookie', adminCookie).send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
expect(DatabaseService.getInstance().getGlobalSettings().image_update_checks_enabled).toBe('1');
});
});
describe('POST /api/image-updates/refresh when disabled', () => {
it('returns 409 with enabled false instead of rate-limit 429', async () => {
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0');
const res = await request(app).post('/api/image-updates/refresh').set('Cookie', adminCookie);
expect(res.status).toBe(409);
expect(res.body.enabled).toBe(false);
expect(res.body.error).toMatch(/disabled/i);
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '1');
});
});
describe('POST /api/image-updates/fleet/refresh when disabled', () => {
it('lists the local node in disabled rather than triggered or rateLimited', async () => {
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '0');
const localId = DatabaseService.getInstance().getDefaultNode()!.id!;
const res = await request(app).post('/api/image-updates/fleet/refresh').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.disabled).toContain(localId);
expect(res.body.triggered).not.toContain(localId);
expect(res.body.rateLimited).not.toContain(localId);
DatabaseService.getInstance().updateGlobalSetting('image_update_checks_enabled', '1');
});
});
@@ -167,6 +167,8 @@ vi.mock('../services/FileSystemService', () => ({
vi.mock('../services/ImageUpdateService', () => ({
ImageUpdateService: {
// Default on so existing executeUpdate tests keep prior behavior.
isChecksEnabled: () => true,
getInstance: () => ({
checkImage: mockCheckImage,
recheckStack: mockRecheckStack,
@@ -37,6 +37,7 @@ vi.mock('../services/ImageUpdateService', async () => {
return {
...actual,
ImageUpdateService: {
isChecksEnabled: () => true,
getInstance: () => ({ recheckStack: mockRecheckStack }),
},
};
@@ -160,6 +160,13 @@ describe('updatePreviewSignal', () => {
it('degrades a preview failure to a non-verdict-affecting unknown', () => {
expect(updatePreviewSignal('error')).toMatchObject({ status: 'unknown', affectsVerdict: false });
});
it('reports detection disabled without treating it as up to date', () => {
const signal = updatePreviewSignal(summary({ detection_disabled: true, has_update: false }));
expect(signal.status).toBe('unknown');
expect(signal.affectsVerdict).toBe(false);
expect(signal.detail).toMatch(/disabled/i);
});
});
describe('buildServicesSignal', () => {
@@ -182,6 +182,53 @@ describe('ImageUpdateService.commitPreviewClear', () => {
});
describe('GET/POST /api/stacks/:stackName/update-preview reconcile', () => {
it('GET returns detection_disabled preview without calling getPreview when checks are off', async () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('image_update_checks_enabled', '0');
const getPreview = vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview');
try {
const res = await request(app)
.get('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.summary?.detection_disabled).toBe(true);
expect(res.body.summary?.has_update).toBe(false);
expect(res.body.images).toEqual([]);
expect(getPreview).not.toHaveBeenCalled();
} finally {
db.updateGlobalSetting('image_update_checks_enabled', '1');
}
});
it('POST returns detection_disabled without registry I/O or sticky reconcile when checks are off', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
db.upsertStackUpdateStatus(nodeId, 'web', true, 1000, 'partial', 'half');
db.updateGlobalSetting('image_update_checks_enabled', '0');
const getPreview = vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview');
const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined);
try {
const res = await request(app)
.post('/api/stacks/web/update-preview')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.summary?.detection_disabled).toBe(true);
expect(res.body.summary?.has_update).toBe(false);
expect(res.body.reconciled).toBe(false);
expect(getPreview).not.toHaveBeenCalled();
expect(db.getStackUpdateDetail(nodeId).web?.hasUpdate).toBe(true);
expect(broadcast).not.toHaveBeenCalled();
expect(invalidate).not.toHaveBeenCalled();
} finally {
db.updateGlobalSetting('image_update_checks_enabled', '1');
}
});
it('GET does not mutate sticky state even for authoritative-negative preview', async () => {
const db = DatabaseService.getInstance();
const nodeId = db.getDefaultNode()!.id!;
+38 -2
View File
@@ -77,6 +77,13 @@ imageUpdatesRouter.get('/detail', authMiddleware, (req: Request, res: Response):
imageUpdatesRouter.post('/refresh', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
if (!ImageUpdateService.isChecksEnabled()) {
res.status(409).json({
enabled: false,
error: 'Image update detection is disabled for this node.',
});
return;
}
const triggered = ImageUpdateService.getInstance().triggerManualRefresh();
if (!triggered) {
const mins = ImageUpdateService.manualCooldownMinutes;
@@ -181,6 +188,26 @@ imageUpdatesRouter.put('/interval', authMiddleware, (req: Request, res: Response
}
});
const EnabledPatchSchema = z.object({
enabled: z.boolean(),
});
imageUpdatesRouter.put('/enabled', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
const parsed = EnabledPatchSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'enabled must be a boolean' });
return;
}
try {
const status = ImageUpdateService.getInstance().applyChecksEnabled(parsed.data.enabled);
res.json(status);
} catch (error) {
console.error('Failed to update image-update checks enabled:', error);
res.status(500).json({ error: 'Failed to update image update checks setting' });
}
});
imageUpdatesRouter.get('/fleet', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
@@ -254,6 +281,7 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
const triggered: number[] = [];
const rateLimited: number[] = [];
const failed: number[] = [];
const disabled: number[] = [];
// ImageUpdateService is a per-instance singleton, so the local node's manual
// refresh fires at most once per request regardless of how many local rows
@@ -261,7 +289,9 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
const localNode = nodes.find(n => n.type === 'local');
if (localNode) {
try {
if (ImageUpdateService.getInstance().triggerManualRefresh()) {
if (!ImageUpdateService.isChecksEnabled()) {
disabled.push(localNode.id);
} else if (ImageUpdateService.getInstance().triggerManualRefresh()) {
triggered.push(localNode.id);
} else {
rateLimited.push(localNode.id);
@@ -306,6 +336,8 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
const { nodeId, status } = entry.value;
if (status >= 200 && status < 300) {
triggered.push(nodeId);
} else if (status === 409) {
disabled.push(nodeId);
} else if (status === 429) {
rateLimited.push(nodeId);
} else {
@@ -314,7 +346,7 @@ imageUpdatesRouter.post('/fleet/refresh', authMiddleware, async (_req: Request,
}
invalidateFleetUpdateCache();
res.json({ triggered, rateLimited, failed });
res.json({ triggered, rateLimited, failed, disabled });
});
/**
@@ -327,6 +359,10 @@ export const autoUpdateRouter = Router();
autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
try {
if (!ImageUpdateService.isChecksEnabled()) {
res.json({ result: 'Image update detection is disabled for this node; skipped.' });
return;
}
const { target } = req.body as { target?: string };
console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target || '')}"`);
if (!target || typeof target !== 'string') {
+16 -1
View File
@@ -17,7 +17,11 @@ import { StackUpdateOrchestrator, shortImageId, type OrchestratorResult } from '
import DockerController, { type BulkStackInfo } from '../services/DockerController';
import { DatabaseService, type StackDossierFields } from '../services/DatabaseService';
import { CacheService, type CacheFetchOutcome } from '../services/CacheService';
import { UpdatePreviewService, isAuthoritativeNegativePreview } from '../services/UpdatePreviewService';
import {
UpdatePreviewService,
isAuthoritativeNegativePreview,
buildDetectionDisabledPreview,
} from '../services/UpdatePreviewService';
import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService';
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { buildStackDriftReport, type DriftFindingKind, type StackDriftReport } from '../services/DriftDetectionService';
@@ -2248,6 +2252,12 @@ stacksRouter.post('/:stackName/services/:serviceName/restore', async (req: Reque
stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
try {
// Anatomy and other GET consumers must not contact registries while
// node-scoped detection is off.
if (!ImageUpdateService.isChecksEnabled()) {
res.json(buildDetectionDisabledPreview(stackName));
return;
}
// Read-only: sticky reconciliation lives on POST so UpdateGuard and other
// GET consumers never mutate persisted scanner state.
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
@@ -2261,6 +2271,11 @@ stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Respons
stacksRouter.post('/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
try {
if (!ImageUpdateService.isChecksEnabled()) {
// No registry I/O and no sticky reconcile on a synthetic disabled preview.
res.json({ ...buildDetectionDisabledPreview(stackName), reconciled: false });
return;
}
// Snapshot write-generation watermarks before the read-only preview so a
// later clear can erase older confirmed/sticky rows without racing a
// scanner that reserved or rewrote the row after this observation.
+9
View File
@@ -2022,6 +2022,9 @@ export class DatabaseService {
stmt.run('image_update_check_mode', 'interval');
stmt.run('image_update_check_cron', '');
stmt.run('image_update_sidebar_indicators', '1');
// Opt-out for background registry polling. Default on so upgrades keep
// current behavior; missing key is also treated as enabled at read time.
stmt.run('image_update_checks_enabled', '1');
stmt.run('notification_dispatch_retries', '0');
stmt.run('env_block_deploy_on_missing_required', '0');
stmt.run('auto_create_missing_external_networks', '0');
@@ -5005,6 +5008,12 @@ export class DatabaseService {
return result.changes;
}
/** Deletes every update row for a node. Returns deleted row count. */
public clearAllStackUpdateStatus(nodeId: number): number {
const result = this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(nodeId);
return result.changes;
}
// --- Stack Scan Attempts ---
//
// Tracks the latest post-deploy scan attempt per (nodeId, stackName) so
+91 -8
View File
@@ -14,6 +14,7 @@ import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { buildEffectiveServiceModel } from './effectiveServiceModel';
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
const BACKFILL_KEY = 'image_update_notifications_backfilled';
@@ -75,6 +76,8 @@ export function normalizeImageCheckStatus(r: ImageCheckResult): PreviewImageChec
* `nextCheckAt` is meaningless while `checking` is true.
* `mode` is the active scheduling mode; `cronExpression` is the 5-field
* expression when mode is 'cron', null otherwise or when unconfigured.
* `enabled` is whether background image-update detection is armed; always
* present on current nodes, optional on the wire for older remotes.
*/
export interface ImageUpdateStatus {
checking: boolean;
@@ -86,6 +89,7 @@ export interface ImageUpdateStatus {
mode: 'interval' | 'cron';
cronExpression: string | null;
sidebarIndicators: boolean;
enabled: boolean;
}
// ─── Compose file helpers ────────────────────────────────────────────────────
@@ -410,6 +414,7 @@ export class ImageUpdateService {
private static readonly INTERVAL_SETTING_KEY = 'image_update_check_interval_minutes';
private static readonly MODE_SETTING_KEY = 'image_update_check_mode';
private static readonly CRON_SETTING_KEY = 'image_update_check_cron';
private static readonly ENABLED_SETTING_KEY = 'image_update_checks_enabled';
private static readonly JITTER_FRACTION = 0.1; // ±10% so a fleet does not poll in lockstep
private static readonly STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 min after boot
@@ -452,8 +457,15 @@ export class ImageUpdateService {
public start() {
if (this.timer) return;
this.polling = true;
this.configureFromSettings();
if (!ImageUpdateService.isChecksEnabled()) {
// Detection opted out: stay stopped across restarts so a boot does
// not re-arm registry polling until the setting is turned back on.
this.polling = false;
this.nextCheckAt = null;
return;
}
this.polling = true;
// Interval mode keeps the 2-minute post-boot delay before the first check.
// Cron mode honors its schedule: arm at the next cron fire time so a restart
// never triggers an out-of-cadence check (e.g. a weekly cron must not run on
@@ -476,7 +488,8 @@ export class ImageUpdateService {
* cadence without restarting Sencho. Safe to call repeatedly: it always
* clears the existing timer first and only arms a new one while polling, so
* it never stacks timers and is a no-op (beyond reconfiguring intervalMs)
* when the service is stopped or was never started.
* when the service is stopped or was never started. When checks are
* disabled, clears nextCheckAt and does not arm.
*/
public restartPolling(): void {
this.scheduleGeneration++;
@@ -485,13 +498,65 @@ export class ImageUpdateService {
this.timer = null;
}
this.configureFromSettings();
if (this.polling) {
if (this.polling && ImageUpdateService.isChecksEnabled()) {
this.armNext(this.nextDelayMs());
} else {
this.nextCheckAt = null;
}
}
/**
* Whether background image-update detection is enabled. Missing or blank
* keys default to enabled so upgrades and pre-seed races keep polling.
*/
public static isChecksEnabled(): boolean {
try {
const raw = DatabaseService.getInstance().getGlobalSettings()[ImageUpdateService.ENABLED_SETTING_KEY];
if (raw == null || String(raw).trim() === '') return true;
return raw === '1';
} catch (e) {
console.warn('[ImageUpdateService] Could not read checks-enabled setting; treating as enabled:', getErrorMessage(e, String(e)));
return true;
}
}
/**
* Persist the checks-enabled setting and apply the live transition: stop
* + clear findings when turning off; start (or re-arm) when turning on.
* Safe under repeated toggles (scheduleGeneration bump via stop/start).
*/
public applyChecksEnabled(enabled: boolean): ImageUpdateStatus {
const db = DatabaseService.getInstance();
db.updateGlobalSetting(ImageUpdateService.ENABLED_SETTING_KEY, enabled ? '1' : '0');
if (!enabled) {
this.stop();
// Scanner only writes rows for local nodes. Use the local default
// node ID, not req.nodeId (which may be a remote active node).
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
db.clearAllStackUpdateStatus(localNodeId);
invalidateFleetUpdateCache();
NotificationService.getInstance().broadcastEvent({
type: 'state-invalidate',
scope: 'image-updates',
nodeId: localNodeId,
action: 'checks-disabled',
ts: Date.now(),
});
return this.getStatus();
}
// Re-enable: arm a fresh schedule. start() is a no-op if a timer already
// exists; when we were fully stopped, start() arms. When somehow still
// marked polling without a timer, restartPolling re-arms.
if (!this.timer) {
this.start();
} else {
this.restartPolling();
}
return this.getStatus();
}
/**
* Reads image_update_check_interval_minutes into intervalMs, clamped to
* [15, 1440], falling back to the 2-hour default on a missing, blank,
@@ -600,11 +665,16 @@ export class ImageUpdateService {
}
/**
* Triggers a check immediately, unless one is already running or the
* manual cooldown (MANUAL_COOLDOWN_MS) has not elapsed.
* Returns false if rate-limited, true if a check was started.
* Triggers a check immediately, unless detection is disabled, one is already
* running, or the manual cooldown (MANUAL_COOLDOWN_MS) has not elapsed.
* Returns false if rate-limited or disabled, true if a check was started.
* Callers that need to distinguish disabled from rate-limited must check
* isChecksEnabled() first.
*/
public triggerManualRefresh(): boolean {
if (!ImageUpdateService.isChecksEnabled()) {
return false;
}
const now = Date.now();
if (now - this.lastManualRefreshAt < ImageUpdateService.MANUAL_COOLDOWN_MS) {
return false;
@@ -624,6 +694,7 @@ export class ImageUpdateService {
}
public getStatus(): ImageUpdateStatus {
const enabled = ImageUpdateService.isChecksEnabled();
let sidebarIndicators = false;
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
@@ -632,21 +703,28 @@ export class ImageUpdateService {
console.warn('[ImageUpdateService] Failed to read sidebar indicator setting:', e);
}
return {
checking: this.isRunning,
checking: enabled ? this.isRunning : false,
intervalMinutes: Math.round(this.intervalMs / (60 * 1000)),
lastCheckedAt: this.lastCheckedAt,
nextCheckAt: this.nextCheckAt,
nextCheckAt: enabled ? this.nextCheckAt : null,
manualCooldownMinutes: ImageUpdateService.manualCooldownMinutes,
manualCooldownRemainingMs: this.getManualCooldownRemainingMs(),
mode: this.mode,
cronExpression: this.cronExpression,
sidebarIndicators,
enabled,
};
}
// ─── Core check ──────────────────────────────────────────────────────────
private async check() {
if (!ImageUpdateService.isChecksEnabled()) {
if (isDebugEnabled()) {
console.log('[ImageUpdateService:debug] Checks disabled; skipping scan.');
}
return;
}
// The finally block is the sole owner of isRunning, so a scan that
// overruns can never have its lock released out from under it. A
// previous fixed timer cleared the lock after CHECK_TIMEOUT_MS, which
@@ -908,6 +986,11 @@ export class ImageUpdateService {
* left untouched and a verification_failed result is returned.
*/
public async recheckStack(nodeId: number, stackName: string): Promise<StackRecheckResult> {
// While detection is off, skip registry probes and do not write
// stack_update_status (avoids stale findings after re-enable).
if (!ImageUpdateService.isChecksEnabled()) {
return { outcome: 'cleared', warning: null };
}
const generation = this.reserveStackWriteGeneration(nodeId, stackName);
const db = DatabaseService.getInstance();
const docker = DockerController.getInstance(nodeId);
+4
View File
@@ -1038,6 +1038,10 @@ export class SchedulerService {
imageUpdateService: ImageUpdateService,
isWildcard = false
): Promise<string> {
if (!ImageUpdateService.isChecksEnabled()) {
console.log(`[SchedulerService] Stack "${stackName}": image update detection is disabled; skipped.`);
return `Stack "${stackName}": image update detection is disabled; skipped.`;
}
const containers = await docker.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
if (!isWildcard) {
+14 -3
View File
@@ -3,7 +3,8 @@ import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
import { FileSystemService } from './FileSystemService';
import { ComposeDoctorService } from './ComposeDoctorService';
import { UpdatePreviewService, isMovingTag, filterPreviewForService } from './UpdatePreviewService';
import { UpdatePreviewService, isMovingTag, filterPreviewForService, buildDetectionDisabledPreview } from './UpdatePreviewService';
import { ImageUpdateService } from './ImageUpdateService';
import { buildEffectiveServiceModel, type EffectiveServiceModelResult } from './effectiveServiceModel';
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
import { withTimeout } from '../utils/withTimeout';
@@ -137,6 +138,12 @@ export class UpdateGuardService {
withTimeout(this.probeContainers(nodeId, stackName), INPUT_TIMEOUT_MS, 'readiness sibling probe'))
: Promise.resolve<ContainerProbe[] | Errored>([]),
this.collect('update preview', stackName, async () => {
// Check inside the thunk so the read stays with the getPreview call.
// Stack GET/POST update-preview use the same isChecksEnabled gate.
if (!ImageUpdateService.isChecksEnabled()) {
const disabled = buildDetectionDisabledPreview(stackName);
return serviceName ? filterPreviewForService(disabled, serviceName) : disabled;
}
const full = await withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'readiness update preview');
return serviceName ? filterPreviewForService(full, serviceName) : full;
}),
@@ -218,8 +225,12 @@ export class UpdateGuardService {
this.collect('backup info', stackName, () => fsSvc.getBackupInfo(stackName)),
this.collect('backup env summary', stackName, () => fsSvc.getBackupEnvSummary(stackName)),
this.collect('stack env presence', stackName, () => fsSvc.envExists(stackName)),
this.collect('update preview', stackName, () =>
withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'rollback readiness update preview')),
this.collect('update preview', stackName, async () => {
if (!ImageUpdateService.isChecksEnabled()) {
return buildDetectionDisabledPreview(stackName);
}
return withTimeout(UpdatePreviewService.getInstance().getPreview(nodeId, stackName), INPUT_TIMEOUT_MS, 'rollback readiness update preview');
}),
this.collect('activity history', stackName, async () => {
const events = db.getStackActivity(nodeId, stackName, { limit: 50 });
// A successful update is as good a known-good marker as a deploy.
@@ -111,6 +111,11 @@ export interface UpdatePreviewSummary {
verification_failed: boolean;
/** First image check_error when verification_failed; otherwise null. */
verification_error: string | null;
/**
* When true, background detection is disabled on this node and the preview
* was not fetched from registries. Optional for older remotes.
*/
detection_disabled?: boolean;
}
export interface UpdatePreview {
@@ -362,6 +367,33 @@ export function filterPreviewForService(preview: UpdatePreview, serviceName: str
return buildSummary(preview.stack_name, images, buildServices);
}
/** Minimal preview when node-scoped image update detection is disabled. */
export function buildDetectionDisabledPreview(stackName: string): UpdatePreview {
return {
stack_name: stackName,
images: [],
build_services: [],
summary: {
has_update: false,
primary_image: null,
current_tag: null,
next_tag: null,
semver_bump: 'none',
update_kind: 'none',
blocked: false,
blocked_reason: null,
has_build_services: false,
rebuild_available: false,
check_status: 'ok',
verification_failed: false,
verification_error: null,
detection_disabled: true,
},
rollback_target: null,
changelog: null,
};
}
export class UpdatePreviewService {
private static instance: UpdatePreviewService;
@@ -134,6 +134,14 @@ export function updatePreviewSignal(input: UpdatePreviewSummary | Errored, image
if (input === 'error') {
return { ...base, status: 'unknown', affectsVerdict: false, detail: 'The update preview is unavailable.' };
}
if (input.detection_disabled) {
return {
...base,
status: 'unknown',
affectsVerdict: false,
detail: 'Image update detection is disabled for this node.',
};
}
if (input.blocked) {
return {
...base,
+1 -1
View File
@@ -34,7 +34,7 @@ When nothing is pending, the board renders a single Shield-icon panel with the h
## Detection cadence
Sencho polls your registries on a configurable schedule to detect available image updates and raise notifications. This detection cadence is configurable under **Settings > Automation > Image update checks**: choose a fixed interval (every 15 minutes to once a day) or set a cron expression for precise scheduling (e.g. "every Monday at 03:00"). The default is every 2 hours on an interval schedule, and changing it takes effect immediately, with no restart.
Sencho polls your registries on a configurable schedule to detect available image updates and raise notifications. This detection cadence is configurable under **Settings > Automation > Image update checks**: choose a fixed interval (every 15 minutes to once a day) or set a cron expression for precise scheduling (e.g. "every Monday at 03:00"). The default is every 2 hours on an interval schedule, and changing it takes effect immediately, with no restart. You can also turn image update checks off for a node when another tool is the update authority; explicit stack Update and redeploy actions stay available.
Detection is separate from applying updates:
+7 -2
View File
@@ -489,11 +489,16 @@ Configure how often this node polls container registries to detect available ima
| Setting | Default | Description |
|---------|---------|-------------|
| **Scheduling mode** | Interval | **Interval**: check every fixed period. **Cron**: check on a precise cron schedule (runs in the node's local timezone). |
| **Enable image update checks** | On | When on, this node polls registries on schedule, raises update notifications, and feeds Home, sidebar, Anatomy, and Fleet Readiness. Turn off when another tool is the update authority for this node. Explicit stack Update, pull, and redeploy remain available. |
| **Scheduling mode** | Interval | **Interval**: check every fixed period. **Cron**: check on a precise cron schedule (runs in the node's local timezone). Greyed out while image update checks are off. |
| **Check interval** | 2 hours | How often to poll registries when in Interval mode. Presets: 15 min, 30 min, 1 h, 2 h, 6 h, 12 h, 24 h. Selecting a new preset saves immediately. |
| **Cron expression** | - | A standard five-field cron expression (for example, `0 3 * * 1` for every Monday at 03:00). A human-readable description appears below the field as you type. Click **Save schedule** to apply. |
The section footer shows the last-checked timestamp and when the next check is scheduled.
The section footer shows the last-checked timestamp and when the next check is scheduled. When checks are off, the footer reads **Next check: disabled** and retains the last-checked time from before detection was turned off.
<Note>
Nodes running older versions of Sencho do not expose the enable toggle. Upgrade the node to turn detection off.
</Note>
### Sidebar
@@ -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));
});
});
+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,
};
}
+5
View File
@@ -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;
}
/**