diff --git a/backend/src/__tests__/dashboard-routes.test.ts b/backend/src/__tests__/dashboard-routes.test.ts index a288db28..d41e278c 100644 --- a/backend/src/__tests__/dashboard-routes.test.ts +++ b/backend/src/__tests__/dashboard-routes.test.ts @@ -112,6 +112,77 @@ describe('GET /api/dashboard/configuration', () => { expect(res.body.security.scanPolicies.locked).toBe(false); }); + it('counts autoUpdate as enabled action=update scheduled tasks targeting this node, not other actions', async () => { + const db = DatabaseService.getInstance(); + const now = Date.now(); + const nodeId = 1; + const baseTask = { + created_by: 'admin', + created_at: now, + updated_at: now, + last_run_at: null, + next_run_at: now + 3600_000, + last_status: null, + last_error: null, + prune_targets: null, + target_services: null, + prune_label_filter: null, + }; + const idA = db.createScheduledTask({ + ...baseTask, + name: 'au-on', + target_type: 'stack', + target_id: 'app1', + node_id: nodeId, + action: 'update', + cron_expression: '0 3 * * *', + enabled: 1, + }); + const idB = db.createScheduledTask({ + ...baseTask, + name: 'au-off', + target_type: 'stack', + target_id: 'app2', + node_id: nodeId, + action: 'update', + cron_expression: '0 3 * * *', + enabled: 0, + }); + const idC = db.createScheduledTask({ + ...baseTask, + name: 'scan-row', + target_type: 'system', + target_id: null, + node_id: nodeId, + action: 'scan', + cron_expression: '0 3 * * *', + enabled: 1, + }); + const idD = db.createScheduledTask({ + ...baseTask, + name: 'au-other-node', + target_type: 'stack', + target_id: 'app3', + node_id: 999, + action: 'update', + cron_expression: '0 3 * * *', + enabled: 1, + }); + try { + const res = await request(app).get('/api/dashboard/configuration').set('Cookie', adminCookie); + expect(res.status).toBe(200); + // Two update rows on node 1 (one enabled, one disabled); the scan row + // and the update row on node 999 must not leak into the count. + expect(res.body.automation.autoUpdate.total).toBe(2); + expect(res.body.automation.autoUpdate.enabled).toBe(1); + } finally { + db.deleteScheduledTask(idA); + db.deleteScheduledTask(idB); + db.deleteScheduledTask(idC); + db.deleteScheduledTask(idD); + } + }); + it('does not leak agent URLs, tokens, or other secret material in the response', async () => { // Seed a node-1 agent with a Discord URL so the configuration path // exercises the `configured` truthy branch. The URL must never appear diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index a344bffb..f762bc15 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -562,3 +562,80 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => { expect(res.body.error).toMatch(/Invalid action/); }); }); + +describe('scheduled-tasks state-invalidate broadcast', () => { + // Two frontend hooks (useConfigurationStatus, useNextAutoUpdateRun) refetch + // when this scope fires; locking it down prevents a silent UX regression + // where a successful mutation no longer triggers their fast-refresh path. + it('fires scope=scheduled-tasks on create, update, toggle, and delete', async () => { + const { NotificationService } = await import('../services/NotificationService'); + const broadcastSpy = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined); + try { + const expectScheduledTasksBroadcast = () => { + expect(broadcastSpy).toHaveBeenCalledWith(expect.objectContaining({ + type: 'state-invalidate', + scope: 'scheduled-tasks', + ts: expect.any(Number), + })); + }; + + const createRes = await request(app) + .post('/api/scheduled-tasks') + .set('Cookie', adminCookie) + .send({ + name: 'broadcast-test', + target_type: 'system', + node_id: 1, + action: 'prune', + cron_expression: '0 4 * * *', + enabled: true, + prune_targets: ['images'], + }); + expect(createRes.status).toBe(201); + const taskId = createRes.body.id as number; + expectScheduledTasksBroadcast(); + + broadcastSpy.mockClear(); + const updateRes = await request(app) + .put(`/api/scheduled-tasks/${taskId}`) + .set('Cookie', adminCookie) + .send({ cron_expression: '0 5 * * *' }); + expect(updateRes.status).toBe(200); + expectScheduledTasksBroadcast(); + + broadcastSpy.mockClear(); + const toggleRes = await request(app) + .patch(`/api/scheduled-tasks/${taskId}/toggle`) + .set('Cookie', adminCookie); + expect(toggleRes.status).toBe(200); + expectScheduledTasksBroadcast(); + + broadcastSpy.mockClear(); + const deleteRes = await request(app) + .delete(`/api/scheduled-tasks/${taskId}`) + .set('Cookie', adminCookie); + expect(deleteRes.status).toBe(200); + expectScheduledTasksBroadcast(); + } finally { + broadcastSpy.mockRestore(); + } + }); + + it('does not broadcast when validation rejects the request', async () => { + const { NotificationService } = await import('../services/NotificationService'); + const broadcastSpy = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined); + try { + const res = await request(app) + .post('/api/scheduled-tasks') + .set('Cookie', adminCookie) + .send({ name: '', target_type: 'system', action: 'prune', cron_expression: '0 4 * * *' }); + expect(res.status).toBe(400); + const scheduledBroadcasts = broadcastSpy.mock.calls.filter( + ([event]) => (event as { scope?: string }).scope === 'scheduled-tasks', + ); + expect(scheduledBroadcasts).toHaveLength(0); + } finally { + broadcastSpy.mockRestore(); + } + }); +}); diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index cea66258..11242232 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -21,7 +21,6 @@ const { mockGetProxyTarget, mockIsTrivyAvailable, mockScanAllNodeImages, - mockGetStackAutoUpdateSettingsForNode, mockDeleteScheduledTask, mockGetMatchingPolicy, mockRunCommand, @@ -62,7 +61,6 @@ const { severity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }, violations: [], }), - mockGetStackAutoUpdateSettingsForNode: vi.fn().mockReturnValue({}), mockDeleteScheduledTask: vi.fn(), mockGetMatchingPolicy: vi.fn().mockReturnValue(null), mockRunCommand: vi.fn().mockResolvedValue(undefined), @@ -86,7 +84,6 @@ vi.mock('../services/DatabaseService', () => ({ clearStackUpdateStatus: mockClearStackUpdateStatus, markStaleRunsAsFailed: mockMarkStaleRunsAsFailed, deleteOldScans: mockDeleteOldScans, - getStackAutoUpdateSettingsForNode: mockGetStackAutoUpdateSettingsForNode, deleteScheduledTask: mockDeleteScheduledTask, getMatchingPolicy: mockGetMatchingPolicy, }), @@ -790,7 +787,7 @@ describe('SchedulerService - executeUpdate', () => { expect(svc.isTaskRunning(999)).toBe(false); }); - it('fleet target updates all stacks whose policy allows it', async () => { + it('fleet target updates every stack discovered on the node', async () => { mockGetScheduledTask.mockReturnValue({ id: 87, name: 'fleet-update', @@ -804,45 +801,13 @@ describe('SchedulerService - executeUpdate', () => { last_status: null, }); mockGetStacks.mockResolvedValue(['app1', 'app2', 'app3']); - // app2 explicitly disabled; app1 and app3 default to enabled - mockGetStackAutoUpdateSettingsForNode.mockReturnValue({ app2: false }); mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Image: 'nginx:latest' }]); mockCheckImage.mockResolvedValue({ hasUpdate: true }); const svc = SchedulerService.getInstance(); await svc.triggerTask(87); - // Only app1 and app3 should be updated - expect(mockUpdateStack).toHaveBeenCalledTimes(2); - expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( - 1, - expect.objectContaining({ - status: 'success', - output: expect.stringContaining('auto-updates disabled; skipped'), - }) - ); - }); - - it('fleet target with zero eligible stacks records success', async () => { - mockGetScheduledTask.mockReturnValue({ - id: 88, - name: 'fleet-update-all-off', - action: 'update', - target_type: 'fleet', - cron_expression: '0 4 * * *', - enabled: true, - target_id: null, - node_id: 1, - created_by: 'admin', - last_status: null, - }); - mockGetStacks.mockResolvedValue(['app1', 'app2']); - mockGetStackAutoUpdateSettingsForNode.mockReturnValue({ app1: false, app2: false }); - - const svc = SchedulerService.getInstance(); - await svc.triggerTask(88); - - expect(mockUpdateStack).not.toHaveBeenCalled(); + expect(mockUpdateStack).toHaveBeenCalledTimes(3); expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( 1, expect.objectContaining({ status: 'success' }) diff --git a/backend/src/__tests__/stack-auto-update-settings.test.ts b/backend/src/__tests__/stack-auto-update-settings.test.ts deleted file mode 100644 index 4913867e..00000000 --- a/backend/src/__tests__/stack-auto-update-settings.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Tests for per-stack auto-update settings: - * - DatabaseService accessors (round-trip, defaults) - * - GET/PUT /api/stacks/auto-update-settings and /api/stacks/:name/auto-update - * - /api/auto-update/execute skips disabled stacks - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import request from 'supertest'; -import bcrypt from 'bcrypt'; -import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; - -let tmpDir: string; -let app: import('express').Express; -let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; -let adminCookie: string; -let viewerCookie: string; - -beforeAll(async () => { - tmpDir = await setupTestDb(); - ({ DatabaseService } = await import('../services/DatabaseService')); - - const { LicenseService } = await import('../services/LicenseService'); - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); - vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); - vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); - - ({ app } = await import('../index')); - adminCookie = await loginAsTestAdmin(app); - - const viewerHash = await bcrypt.hash('viewerpass2', 1); - DatabaseService.getInstance().addUser({ username: 'aus-viewer', password_hash: viewerHash, role: 'viewer' }); - const viewerRes = await request(app).post('/api/auth/login').send({ username: 'aus-viewer', password: 'viewerpass2' }); - const cookies = viewerRes.headers['set-cookie'] as string | string[]; - viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; -}); - -afterAll(() => cleanupTestDb(tmpDir)); - -describe('DatabaseService - stack auto-update settings', () => { - it('returns true by default when no row exists', () => { - const db = DatabaseService.getInstance(); - const result = db.getStackAutoUpdateEnabled(0, 'no-such-stack'); - expect(result).toBe(true); - }); - - it('upsert → get round-trip (disable)', () => { - const db = DatabaseService.getInstance(); - db.upsertStackAutoUpdateEnabled(0, 'test-stack', false); - expect(db.getStackAutoUpdateEnabled(0, 'test-stack')).toBe(false); - }); - - it('upsert → get round-trip (re-enable)', () => { - const db = DatabaseService.getInstance(); - db.upsertStackAutoUpdateEnabled(0, 'test-stack', true); - expect(db.getStackAutoUpdateEnabled(0, 'test-stack')).toBe(true); - }); - - it('getStackAutoUpdateSettingsForNode only returns stacks with explicit rows', () => { - const db = DatabaseService.getInstance(); - db.upsertStackAutoUpdateEnabled(0, 'explicit-stack', false); - const settings = db.getStackAutoUpdateSettingsForNode(0); - expect('explicit-stack' in settings).toBe(true); - expect(settings['explicit-stack']).toBe(false); - }); - - it('clearStackAutoUpdateSetting removes the row (reverts to default true)', () => { - const db = DatabaseService.getInstance(); - db.upsertStackAutoUpdateEnabled(0, 'to-clear', false); - db.clearStackAutoUpdateSetting(0, 'to-clear'); - expect(db.getStackAutoUpdateEnabled(0, 'to-clear')).toBe(true); - const settings = db.getStackAutoUpdateSettingsForNode(0); - expect('to-clear' in settings).toBe(false); - }); -}); - -describe('GET /api/stacks/auto-update-settings', () => { - it('rejects unauthenticated requests with 401', async () => { - const res = await request(app).get('/api/stacks/auto-update-settings'); - expect(res.status).toBe(401); - }); - - it('returns an object for authenticated admin', async () => { - const res = await request(app).get('/api/stacks/auto-update-settings').set('Cookie', adminCookie); - expect(res.status).toBe(200); - expect(typeof res.body).toBe('object'); - }); - - it('returns an object for authenticated viewer (read-only)', async () => { - const res = await request(app).get('/api/stacks/auto-update-settings').set('Cookie', viewerCookie); - expect(res.status).toBe(200); - }); -}); - -describe('GET /api/stacks/:stackName/auto-update', () => { - it('rejects unauthenticated requests with 401', async () => { - const res = await request(app).get('/api/stacks/mystack/auto-update'); - expect(res.status).toBe(401); - }); - - it('returns enabled:true by default', async () => { - const res = await request(app).get('/api/stacks/nonexistent/auto-update').set('Cookie', adminCookie); - expect(res.status).toBe(200); - expect(res.body.enabled).toBe(true); - }); - - it('rejects invalid stack names with 400', async () => { - // Dots are rejected by isValidStackName; unlike path-traversal sequences - // they are not normalised away by Express routing. - const res = await request(app).get('/api/stacks/my.stack/auto-update').set('Cookie', adminCookie); - expect(res.status).toBe(400); - }); -}); - -describe('PUT /api/stacks/:stackName/auto-update', () => { - it('rejects unauthenticated requests with 401', async () => { - const res = await request(app) - .put('/api/stacks/mystack/auto-update') - .send({ enabled: false }); - expect(res.status).toBe(401); - }); - - it('rejects viewer with 403', async () => { - const res = await request(app) - .put('/api/stacks/mystack/auto-update') - .set('Cookie', viewerCookie) - .send({ enabled: false }); - expect(res.status).toBe(403); - }); - - it('rejects Community tier with 403', async () => { - const { LicenseService } = await import('../services/LicenseService'); - const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); - try { - const res = await request(app) - .put('/api/stacks/mystack/auto-update') - .set('Cookie', adminCookie) - .send({ enabled: false }); - expect(res.status).toBe(403); - } finally { - // Use mockReturnValue rather than mockRestore: restoring would bypass the - // beforeAll spy that sets the tier to 'paid' for the rest of the suite. - spy.mockReturnValue('paid'); - } - }); - - it('rejects non-boolean enabled with 400', async () => { - const res = await request(app) - .put('/api/stacks/mystack/auto-update') - .set('Cookie', adminCookie) - .send({ enabled: 'yes' }); - expect(res.status).toBe(400); - }); - - it('rejects invalid stack names with 400', async () => { - // Dots are rejected by isValidStackName; unlike path-traversal sequences - // they are not normalised away by Express routing. - const res = await request(app) - .put('/api/stacks/my.stack/auto-update') - .set('Cookie', adminCookie) - .send({ enabled: false }); - expect(res.status).toBe(400); - }); - - it('accepts Skipper/Admiral admin and persists the setting', async () => { - const res = await request(app) - .put('/api/stacks/my-app/auto-update') - .set('Cookie', adminCookie) - .send({ enabled: false }); - expect(res.status).toBe(200); - expect(res.body.enabled).toBe(false); - - const db = DatabaseService.getInstance(); - const node = db.getNodes().find(n => n.type === 'local'); - expect(db.getStackAutoUpdateEnabled(node!.id, 'my-app')).toBe(false); - }); - - it('can re-enable a disabled stack', async () => { - await request(app) - .put('/api/stacks/my-app/auto-update') - .set('Cookie', adminCookie) - .send({ enabled: false }); - const res = await request(app) - .put('/api/stacks/my-app/auto-update') - .set('Cookie', adminCookie) - .send({ enabled: true }); - expect(res.status).toBe(200); - expect(res.body.enabled).toBe(true); - }); -}); - -describe('POST /api/auto-update/execute - per-stack disable gate', () => { - it('skips stacks with auto-updates disabled', async () => { - const db = DatabaseService.getInstance(); - const node = db.getNodes().find(n => n.type === 'local')!; - - db.upsertStackAutoUpdateEnabled(node.id, 'disabled-stack', false); - - // Mock FileSystemService so target='*' returns our test stack - const fsMod = await import('../services/FileSystemService'); - const getStacksSpy = vi.spyOn(fsMod.FileSystemService.prototype, 'getStacks') - .mockResolvedValue(['disabled-stack']); - - try { - const res = await request(app) - .post('/api/auto-update/execute') - .set('Cookie', adminCookie) - .send({ target: '*' }); - expect(res.status).toBe(200); - expect(res.body.result).toContain('auto-updates disabled; skipped'); - } finally { - getStacksSpy.mockRestore(); - db.clearStackAutoUpdateSetting(node.id, 'disabled-stack'); - } - }); - - it('skips a named disabled stack', async () => { - const db = DatabaseService.getInstance(); - const node = db.getNodes().find(n => n.type === 'local')!; - db.upsertStackAutoUpdateEnabled(node.id, 'named-disabled', false); - - try { - const res = await request(app) - .post('/api/auto-update/execute') - .set('Cookie', adminCookie) - .send({ target: 'named-disabled' }); - expect(res.status).toBe(200); - expect(res.body.result).toContain('auto-updates disabled; skipped'); - } finally { - db.clearStackAutoUpdateSetting(node.id, 'named-disabled'); - } - }); - - it('allows enabled stacks to proceed (may fail at image check, but not at disable gate)', async () => { - const db = DatabaseService.getInstance(); - const node = db.getNodes().find(n => n.type === 'local')!; - db.upsertStackAutoUpdateEnabled(node.id, 'enabled-stack', true); - - try { - const res = await request(app) - .post('/api/auto-update/execute') - .set('Cookie', adminCookie) - .send({ target: 'enabled-stack' }); - expect(res.status).toBe(200); - // Should NOT contain "auto-updates disabled" in result - expect(res.body.result).not.toContain('auto-updates disabled; skipped'); - } finally { - db.clearStackAutoUpdateSetting(node.id, 'enabled-stack'); - } - }); -}); diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 5b4030f0..3f3a2fcb 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -66,10 +66,10 @@ export function buildLocalConfigurationStatus( const notifRoutes = db.getNotificationRoutes(); const healPolicies = db.getAutoHealPolicies(undefined, nodeId); - const autoUpdateMap = db.getStackAutoUpdateSettingsForNode(nodeId); - const autoUpdateEnabled = Object.values(autoUpdateMap).filter(Boolean).length; - const autoUpdateTotal = Object.keys(autoUpdateMap).length; const scheduledTasks = db.getScheduledTasks(); + const nodeUpdateTasks = scheduledTasks.filter(t => t.action === 'update' && t.node_id === nodeId); + const autoUpdateTotal = nodeUpdateTasks.length; + const autoUpdateEnabled = nodeUpdateTasks.filter(t => t.enabled === 1).length; const webhooks = db.getWebhooks(); const mfaRow = userId ? db.getUserMfa(userId) : undefined; diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index 70f7e3b5..babcdcfd 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -229,11 +229,6 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp for (const stackName of stackNames) { try { - if (!db.getStackAutoUpdateEnabled(req.nodeId, stackName)) { - results.push(`Stack "${stackName}": auto-updates disabled; skipped.`); - continue; - } - const containers = await docker.getContainersByStack(stackName); if (!containers || containers.length === 0) { results.push(`Stack "${stackName}": no containers found; skipped.`); diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index e9fcc3a5..a07d52e3 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -2,6 +2,7 @@ import { Router, type Request, type Response } from 'express'; import { CronExpressionParser } from 'cron-parser'; import { DatabaseService, type ScheduledTask } from '../services/DatabaseService'; import { SchedulerService } from '../services/SchedulerService'; +import { NotificationService } from '../services/NotificationService'; import { requirePaid, requireAdmin } from '../middleware/tierGates'; import { escapeCsvField } from '../utils/csv'; import { getErrorMessage } from '../utils/errors'; @@ -9,6 +10,20 @@ import { parseIntParam } from '../utils/parseIntParam'; import { sanitizeForLog } from '../utils/safeLog'; import { isValidStackName } from '../utils/validation'; +// Frontend listeners filter on scope === 'scheduled-tasks'. Wrapped so a +// broken subscriber socket cannot turn a successful mutation into a 500. +function broadcastScheduledTasksChanged(): void { + try { + NotificationService.getInstance().broadcastEvent({ + type: 'state-invalidate', + scope: 'scheduled-tasks', + ts: Date.now(), + }); + } catch (err) { + console.error('[ScheduledTasks] broadcast failed:', getErrorMessage(err, String(err))); + } +} + const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const; const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start'] as const; const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const; @@ -201,6 +216,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { console.log(`[ScheduledTasks] Created task id=${id} action=${sanitizeForLog(action)} target=${sanitizeForLog(target_id || 'none')}`); const task = DatabaseService.getInstance().getScheduledTask(id); + broadcastScheduledTasksChanged(); res.status(201).json(task); } catch (error) { console.error('[ScheduledTasks] Create error:', error); @@ -297,6 +313,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { db.updateScheduledTask(id, updates as Partial>); console.log(`[ScheduledTasks] Updated task id=${id}`); const task = db.getScheduledTask(id); + broadcastScheduledTasksChanged(); res.json(task); } catch (error) { console.error('[ScheduledTasks] Update error:', error); @@ -317,6 +334,7 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => { db.deleteScheduledTask(id); console.log(`[ScheduledTasks] Deleted task id=${id}`); + broadcastScheduledTasksChanged(); res.json({ success: true }); } catch (error) { console.error('[ScheduledTasks] Delete error:', error); @@ -346,6 +364,7 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => console.log(`[ScheduledTasks] Toggled task id=${id} enabled=${newEnabled}`); const task = db.getScheduledTask(id); + broadcastScheduledTasksChanged(); res.json(task); } catch (error) { console.error('[ScheduledTasks] Toggle error:', error); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 47335815..86aa4d44 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -12,7 +12,7 @@ import { UpdatePreviewService } from '../services/UpdatePreviewService'; import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../services/GitSourceService'; import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { requirePermission, checkPermission } from '../middleware/permissions'; -import { requirePaid, requireAdmin, effectiveTier } from '../middleware/tierGates'; +import { requirePaid, effectiveTier } from '../middleware/tierGates'; import { NotificationService, type NotificationCategory } from '../services/NotificationService'; import { StackOpLockService, type StackOpAction } from '../services/StackOpLockService'; import { StackOpMetricsService, type StackOpAction as StackMetricAction } from '../services/StackOpMetricsService'; @@ -253,16 +253,6 @@ stacksRouter.get('/statuses', async (req: Request, res: Response) => { } }); -stacksRouter.get('/auto-update-settings', (req: Request, res: Response): void => { - try { - const settings = DatabaseService.getInstance().getStackAutoUpdateSettingsForNode(req.nodeId); - res.json(settings); - } catch (error) { - console.error('[Stacks] Failed to fetch auto-update settings:', error); - res.status(500).json({ error: 'Failed to fetch auto-update settings' }); - } -}); - type BulkLifecycleAction = 'start' | 'stop' | 'restart' | 'update'; const VALID_BULK_ACTIONS: ReadonlySet = new Set(['start', 'stop', 'restart', 'update']); const BULK_PARALLELISM = 4; @@ -420,43 +410,6 @@ stacksRouter.post('/bulk', async (req: Request, res: Response) => { res.json({ action: typedAction, results }); }); -stacksRouter.get('/:stackName/auto-update', (req: Request, res: Response): void => { - try { - const stackName = req.params.stackName as string; - const enabled = DatabaseService.getInstance().getStackAutoUpdateEnabled(req.nodeId, stackName); - res.json({ enabled }); - } catch (error) { - console.error('[Stacks] Failed to fetch auto-update setting:', error); - res.status(500).json({ error: 'Failed to fetch auto-update setting' }); - } -}); - -stacksRouter.put('/:stackName/auto-update', (req: Request, res: Response): void => { - if (!requirePaid(req, res)) return; - if (!requireAdmin(req, res)) return; - try { - const stackName = req.params.stackName as string; - const { enabled } = req.body as { enabled?: unknown }; - if (typeof enabled !== 'boolean') { - res.status(400).json({ error: '"enabled" must be a boolean' }); - return; - } - DatabaseService.getInstance().upsertStackAutoUpdateEnabled(req.nodeId, stackName, enabled); - NotificationService.getInstance().broadcastEvent({ - type: 'state-invalidate', - scope: 'stack', - nodeId: req.nodeId, - stackName, - action: 'auto-update-settings-changed', - ts: Date.now(), - }); - res.json({ enabled }); - } catch (error) { - console.error('[Stacks] Failed to update auto-update setting:', error); - res.status(500).json({ error: 'Failed to update auto-update setting' }); - } -}); - stacksRouter.get('/:stackName', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; @@ -852,7 +805,6 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => { // Step 4: database cleanup. Per-call idempotent; safe to run sequentially. try { DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName); - DatabaseService.getInstance().clearStackAutoUpdateSetting(req.nodeId, stackName); DatabaseService.getInstance().clearStackScanAttempts(req.nodeId, stackName); DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName); DatabaseService.getInstance().deleteGitSource(stackName); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 1ebbfc5c..e4714815 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -738,14 +738,6 @@ export class DatabaseService { PRIMARY KEY (node_id, stack_name) ); - CREATE TABLE IF NOT EXISTS stack_auto_update_settings ( - node_id INTEGER NOT NULL DEFAULT 0, - stack_name TEXT NOT NULL, - auto_update_enabled INTEGER NOT NULL DEFAULT 1, - updated_at INTEGER NOT NULL, - PRIMARY KEY (node_id, stack_name) - ); - CREATE TABLE IF NOT EXISTS stack_scan_attempts ( node_id INTEGER NOT NULL DEFAULT 0, stack_name TEXT NOT NULL, @@ -2406,40 +2398,6 @@ export class DatabaseService { this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); } - // --- Stack Auto-Update Settings --- - - public getStackAutoUpdateEnabled(nodeId: number, stackName: string): boolean { - const row = this.db.prepare( - 'SELECT auto_update_enabled FROM stack_auto_update_settings WHERE node_id = ? AND stack_name = ?' - ).get(nodeId, stackName) as { auto_update_enabled: number } | undefined; - return row === undefined ? true : row.auto_update_enabled === 1; - } - - // Returns only stacks with an explicit row. Missing keys default to true - // (auto-update enabled); callers must not treat absence as false. - public getStackAutoUpdateSettingsForNode(nodeId: number): Record { - const rows = this.db.prepare( - 'SELECT stack_name, auto_update_enabled FROM stack_auto_update_settings WHERE node_id = ?' - ).all(nodeId) as Array<{ stack_name: string; auto_update_enabled: number }>; - const result: Record = {}; - for (const row of rows) { - result[row.stack_name] = row.auto_update_enabled === 1; - } - return result; - } - - public upsertStackAutoUpdateEnabled(nodeId: number, stackName: string, enabled: boolean): void { - this.db.prepare( - `INSERT INTO stack_auto_update_settings (node_id, stack_name, auto_update_enabled, updated_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(node_id, stack_name) DO UPDATE SET auto_update_enabled = excluded.auto_update_enabled, updated_at = excluded.updated_at` - ).run(nodeId, stackName, enabled ? 1 : 0, Date.now()); - } - - public clearStackAutoUpdateSetting(nodeId: number, stackName: string): void { - this.db.prepare('DELETE FROM stack_auto_update_settings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); - } - // --- Stack Scan Attempts --- // // Tracks the latest post-deploy scan attempt per (nodeId, stackName) so diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 1452ed6e..39da4783 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -596,15 +596,8 @@ export class SchedulerService { const compose = ComposeService.getInstance(task.node_id); const results: string[] = []; - // Single batch query for fleet mode; per-stack default is enabled (true) when no explicit row exists. - const policyMap = isFleet ? db.getStackAutoUpdateSettingsForNode(task.node_id) : null; - for (const stackName of stackNames) { try { - if (isFleet && (policyMap![stackName] ?? true) === false) { - results.push(`Stack "${stackName}": auto-updates disabled; skipped.`); - continue; - } const output = await this.executeUpdateForStack(stackName, task.node_id, docker, imageUpdateService, compose, db, isFleet || isWildcard); results.push(output); } catch (e) { diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index f32bcbb6..d1afef03 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -58,27 +58,12 @@ Blocked updates still surface in scheduled check runs so you stay informed, but ## Per-stack control -Auto-updates can be disabled on a per-stack basis from the stack's context menu. This lets you keep the global schedule active while opting specific stacks out of unattended updates, such as databases, self-built images, or any stack pinned to a fixed tag. +Auto-update is opt-in per stack. A stack participates in unattended updates only when an enabled scheduled task covers it. To leave a stack out (databases, self-built images, anything pinned to a fixed tag), simply do not create a schedule for it. -### Disabling auto-updates for a stack - -1. Right-click the stack in the sidebar, or open the kebab menu (three dots). -2. In the **Inspect** group, click **Auto-update: Enabled** to toggle it off. The label changes to **Auto-update: Disabled** and the icon switches to a slash-circle. -3. The setting persists across restarts. The readiness board shows an **Auto: Off** pill on that card, and the **Apply now** button is disabled with the tooltip "Auto-updates are disabled for this stack. Update it from its actions menu." - - - Per-stack auto-update control requires a **Skipper** or **Admiral** license. - - -### What disabling means - -- **Scheduled and fleet-wide auto-update runs skip the stack entirely.** No registry call is made, and no image update is applied automatically. -- **Image update detection still runs.** The sidebar dot and the readiness card still reflect whether an update is available. You are informed; the system just does not apply it for you. -- **Manual updates are unaffected.** You can still click **Update** in the lifecycle menu (or **Deploy**) to apply an update on demand. Per-stack control governs only the automated path. - -### Re-enabling - -Open the same menu and click **Auto-update: Disabled** to toggle it back on. The next scheduled run will include the stack again. +- **Per-stack schedule.** Create a **Auto-update Stack** task targeting that stack alone. Only this stack is updated when the cron fires. +- **Fleet-wide schedule.** Create a **Auto-update All Stacks** task targeting a node. Every stack on that node is checked and updated when new images are available. If you do not want every stack covered, create per-stack schedules instead. +- **Stack list dot.** Image-update *detection* runs every six hours regardless of whether any schedule is configured. The sidebar dot and the readiness board still show available updates so you can decide what to do with them. +- **Manual updates are always available.** The lifecycle **Update** action on a stack applies an update on demand, independent of any scheduled task. ## Scheduling auto-updates @@ -124,7 +109,7 @@ The preview is recomputed each time the readiness board loads, so it reflects th Image update detection runs every six hours on each node and the readiness board uses the same cached status. Trigger **Recheck** to force a fresh check across every reachable node. - The stack likely has auto-updates disabled. Open the stack's kebab menu or right-click context menu and check the **Inspect** group. If the item reads **Auto-update: Disabled**, click it to re-enable. Once re-enabled, the next scheduled run will include the stack, or you can trigger an immediate run from the Auto-Update view. + No schedule covers that stack. Open **Schedules** in the top nav, create a new **Auto-update Stack** task targeting the stack (or an **Auto-update All Stacks** task on its node), and pick a cron. The next firing will include the stack, or you can trigger an immediate run from the row. One or more nodes that are marked online in your fleet did not respond within the request timeout. Pending updates from those nodes are not shown until they come back. Check the node's status from the Fleet view and the network path between this Sencho instance and the unreachable node. diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 135515a6..0bd4d2ec 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -98,7 +98,7 @@ The Automation block only renders on Skipper or Admiral. | Row | What it shows | |-----|---------------| | **Auto-heal policies** | ` / active` for crash-recovery policies across all stacks; reads `None` when no policies exist | -| **Auto-update stacks** | ` / ` count of stacks enrolled in automated image-update checks; reads `None` when none are enrolled | +| **Auto-update schedules** | ` / active` count of `Auto-update Stack` / `Auto-update All Stacks` rows configured for this node; reads `None` when none are configured | | **Webhooks** (Skipper) | Active inbound deploy webhooks tied to Git Sources or stacks, formatted ` actives` | | **Scheduled tasks** (Admiral) | Active scheduled operations (backups, restarts, scripts), formatted ` actives` | @@ -220,7 +220,7 @@ When you switch the active node from the node switcher, the dashboard resets eve Pilot-agent nodes connect outbound to the primary over a reverse tunnel, so there is no synchronous request-response loop to measure. The Heartbeat card uses the tunnel's last heartbeat to set the dot color, and intentionally renders `n/a` in the latency column. If the dot is red, check the agent container's logs for the first `[Pilot]` line and confirm the agent can reach the primary URL it is dialing. See [Pilot agent](/features/pilot-agent). - The card refreshes every 60 seconds on its own schedule. Toggling a stack's **Auto-update** setting also dispatches a live invalidation that the card picks up within a second. Other settings changes (cloud backup provider switch, SSO provider change, alert rule edit, agent toggle) update on the next 60-second tick. If the row stays stale after a minute, hard-reload the dashboard tab to force a fresh fetch. + The card refreshes every 60 seconds on its own schedule. Creating, editing, toggling, or deleting a scheduled task also dispatches a live invalidation that the card picks up within a second. Other settings changes (cloud backup provider switch, SSO provider change, alert rule edit, agent toggle) update on the next 60-second tick. If the row stays stale after a minute, hard-reload the dashboard tab to force a fresh fetch. The chip appears after three consecutive failures of the live metrics endpoints (`/api/stats` or `/api/system/stats`), which usually means the Docker socket is unreachable from this Sencho instance or the metrics service has stopped. The dashboard keeps polling on every cycle; the chip describes the freshness of the visible numbers, not the polling cadence. The chip clears on the first successful response once both endpoints are within the threshold. Check the Docker daemon status on the active node first, then the Sencho container logs for `[Dashboard]` lines if the chip persists. diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index 527d52a8..1d265e65 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -167,7 +167,7 @@ The Nodes table surfaces routing, status, and per-node automation at a glance fo | **Status** | `Online`, `Offline`, or `Unknown` badge. | | **Labels** | Per-node label palette. On Skipper and Admiral the cell shows the picker (an empty cell reads `No labels` with an Add label control); on Community the cell shows a single dash. | | **Schedules** | Number of active scheduled tasks targeting this node, plus a `next X` countdown to the next run. Click the count or the calendar icon in the Actions column to filter the Schedules view to that node. | -| **Updates** | `Auto` if at least one auto-update policy is enabled on the node; `Off` otherwise. A pulsing dot and count appear when stacks have pending image updates. | +| **Updates** | `Auto` if at least one enabled `Auto-update Stack` or `Auto-update All Stacks` schedule targets the node; `Off` otherwise. A pulsing dot and count appear when stacks have pending image updates. | | **Actions** | **View Schedules**, **Test Connection**, **Edit Node**, and **Delete Node** icon buttons. The local row hides Delete because the local node cannot be removed. | Clicking the schedules-link icon opens the Schedules view filtered to the selected node. From there you can create, edit, or manage scheduled tasks scoped to that node. The filter bar shows which node you are viewing, with a Clear filter control to return to the full list. diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 87a2583b..f6315e09 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -60,7 +60,7 @@ The All tasks toggle swaps the lane track for a sortable table. |---|---|---| | **Restart Stack** | A specific stack (optionally specific services) on a specific node | Restarts all or selected containers in the stack. | | **Auto-update Stack** | A specific stack on a specific node | Checks each image in the stack for a newer tag and recreates the stack if any image has an update. See [Auto-Update Policies](/features/auto-update-policies) for the companion review board. | -| **Auto-update All Stacks** | A specific node | Runs the auto-update check across every stack on the node that has auto-updates enabled. Stacks with auto-updates turned off are skipped. | +| **Auto-update All Stacks** | A specific node | Runs the auto-update check across every stack on the node. Pair with **Auto-update Stack** rows when you want different cadences for specific stacks. | | **Fleet Snapshot** | The whole fleet | Creates a versioned, fleet-wide snapshot of every node's compose files and `.env` files. See [Fleet Backups](/features/fleet-backups). | | **System Prune** | The selected node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. | | **Vulnerability Scan** | A specific node | Runs Trivy against every image on the node and persists the results. Requires Trivy to be installed on the target node ([Installing Trivy](/operations/trivy-setup)). | @@ -88,7 +88,7 @@ Common fields: Conditional fields per action: - **Stack actions** (Restart Stack, Auto-update Stack, Backup Stack Files, Stop / Take Down / Start Stack) add a **Node** combobox and a **Stack** combobox. Restart Stack additionally renders a **Services** checkbox grid sourced from the stack's compose services, so you can scope the restart to a subset instead of restarting the entire stack. -- **Auto-update All Stacks** adds a **Node** combobox with the helper text "Only stacks with auto-updates enabled on this node will be updated." +- **Auto-update All Stacks** adds a **Node** combobox with the helper text "Every stack on the selected node will be checked and updated when new images are available." - **Vulnerability Scan** adds a **Node** combobox with the helper text "Every image on the selected node will be scanned." - **System Prune** adds a **Prune Targets** group (Containers, Images, Networks, Volumes; all selected by default) and a **Label Filter** input for scoping the prune to resources matching a Docker label. diff --git a/docs/features/sidebar.mdx b/docs/features/sidebar.mdx index a59d3638..e0442f80 100644 --- a/docs/features/sidebar.mdx +++ b/docs/features/sidebar.mdx @@ -79,17 +79,17 @@ Click rows to toggle their selection. The toolbar header shows a running count. Right-click any stack (or open the kebab that appears on hover) for its context menu. Items are grouped by purpose: -- **Inspect**: **Alerts**, **Auto-Heal**, the **Auto-update** toggle, **Check updates**, and **Open App** (the last only appears when the stack is running and exposes a port). +- **Inspect**: **Alerts**, **Auto-Heal**, **Check updates**, and **Open App** (the last only appears when the stack is running and exposes a port). - **Organize**: **Labels** (a submenu listing every label on the node, with a check next to each label the stack already carries) and **Pin to top** / **Unpin**. -- **Lifecycle**: **Deploy** (when stopped), **Stop**, **Restart**, **Update** (when running), and **Schedule task**. +- **Lifecycle**: **Deploy** (when stopped), **Stop**, **Restart**, **Update** (when running), and **Schedule task** (opens the Schedules view pre-filled for this stack, where **Auto-update Stack** sets up unattended updates on your own cadence). - **Destructive**: **Delete**. - **Auto-Heal**, the **Auto-update** toggle, and **Schedule task** require a **Skipper** or **Admiral** license. + **Auto-Heal** and **Schedule task** require a **Skipper** or **Admiral** license. - Right-click context menu on a running plex stack with four sections. INSPECT lists Alerts, Auto-Heal, Auto-update: Enabled, Check updates, and Open App. ORGANIZE lists Labels with a submenu arrow and Pin to top. LIFECYCLE lists Stop, Restart, Update, and Schedule task. DESTRUCTIVE lists Delete in red. + Right-click context menu on a running plex stack with four sections. INSPECT lists Alerts, Auto-Heal, Check updates, and Open App. ORGANIZE lists Labels with a submenu arrow and Pin to top. LIFECYCLE lists Stop, Restart, Update, and Schedule task. DESTRUCTIVE lists Delete in red. ## Keyboard shortcuts diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 433c9df0..6e4fe7b7 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -330,7 +330,6 @@ Click the kebab on a stack row in the sidebar to open its context menu. The menu - **Alerts** (`A`): open the alert rule editor for this stack. See [Alerts and Notifications](/features/alerts-notifications). - **Auto-Heal** (`H`): configure auto-recovery for this stack (Skipper+). See [Auto-Heal Policies](/features/auto-heal-policies). -- **Auto-update** (`Enabled` or `Disabled`): toggle automatic updates for this stack (Skipper+). See [Auto-Update Policies](/features/auto-update-policies). - **Check updates** (`U`): force an image update check now. - **Open App** (`↗`): open the stack's web interface in a new tab. Shown only when the stack is running and exposes a web port. @@ -344,7 +343,7 @@ Click the kebab on a stack row in the sidebar to open its context menu. The menu - **Stop** (`⌘.`): shown when running. - **Restart** (`⌘R`): shown when running. - **Update** (`⌘↑`): pulls the latest image tags and redeploys. -- **Schedule task**: open the scheduler for this stack (Skipper+). See [Scheduled Operations](/features/scheduled-operations). +- **Schedule task**: open the scheduler pre-filled for this stack (Skipper+); pick **Auto-update Stack** to set up unattended image updates on your own cadence. See [Scheduled Operations](/features/scheduled-operations) and [Auto-Update Policies](/features/auto-update-policies). - **Deploy** (`⌘↵`): shown when stopped, in place of Stop and Restart. **destructive** diff --git a/frontend/src/components/AutoUpdateReadinessView.tsx b/frontend/src/components/AutoUpdateReadinessView.tsx index 4727d872..1a52b08a 100644 --- a/frontend/src/components/AutoUpdateReadinessView.tsx +++ b/frontend/src/components/AutoUpdateReadinessView.tsx @@ -46,6 +46,9 @@ interface StackCard { previewLoaded: boolean; scheduledTask: ScheduledTask | null; applying: boolean; + // True when at least one enabled action='update' scheduled task covers this + // stack on this node (per-stack row or fleet row). Drives the Auto: Off pill + // and the Apply now button's disabled state. autoUpdateEnabled: boolean; } @@ -239,7 +242,7 @@ function StackReadinessCard({ disabled={blocked || applying || !autoUpdateEnabled} title={ !autoUpdateEnabled - ? 'Auto-updates are disabled for this stack. Update it from its actions menu.' + ? 'No schedule covers this stack. Create one in Schedules → Auto-update Stack.' : (blocked ? (blockedReason ?? undefined) : undefined) } className="gap-1.5" @@ -295,7 +298,7 @@ function ReadinessHero({ {total > 0 && ( {ready} of {total} ready to apply automatically{acrossNodes} - {total - ready > 0 ? ` · ${total - ready} blocked by major bump` : ''} + {total - ready > 0 ? ` · ${total - ready} need a schedule or review` : ''} )} @@ -395,8 +398,6 @@ function AutoUpdateReadinessContent() { apiFetch('/image-updates/fleet', { localOnly: true }), apiFetch('/scheduled-tasks?action=update', { localOnly: true }), ]); - // Auto-update settings are per-node; fetch lazily after we know which nodes have updates. - // Collected into a map keyed by nodeId once we know the fleet topology. if (token !== loadTokenRef.current) return; if (!statusRes.ok) { @@ -406,33 +407,33 @@ function AutoUpdateReadinessContent() { setReachableNodeCount(Object.keys(fleetStatus).length); const tasks: ScheduledTask[] = tasksRes.ok ? await tasksRes.json() : []; + // A stack is "covered" by an enabled action='update' row when either + // a per-stack row targets it or a fleet row targets its node. We pick + // the earliest next-run covering task so the readiness card renders + // the next-run time accurately for both shapes. const taskByNodeStack = new Map(); + const fleetTaskByNode = new Map(); for (const t of tasks) { - if (t.target_type !== 'stack' || !t.target_id) continue; - // Tasks with node_id=null are local-node-scoped. + if (!t.enabled) continue; + // The fetch URL filters on action=update; this guard makes the + // coverage check robust against a future regression there. + if (t.action !== 'update') continue; const taskNodeId = t.node_id ?? localNodeId; if (taskNodeId == null) continue; - const key = `${taskNodeId}::${t.target_id}`; - const existing = taskByNodeStack.get(key); - if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) { - taskByNodeStack.set(key, t); + if (t.target_type === 'fleet') { + const existing = fleetTaskByNode.get(taskNodeId); + if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) { + fleetTaskByNode.set(taskNodeId, t); + } + } else if (t.target_type === 'stack' && t.target_id) { + const key = `${taskNodeId}::${t.target_id}`; + const existing = taskByNodeStack.get(key); + if (!existing || (t.next_run_at ?? Infinity) < (existing.next_run_at ?? Infinity)) { + taskByNodeStack.set(key, t); + } } } - // Fetch auto-update settings for all nodes that have pending updates. - const nodeIdsWithUpdates = [...new Set( - Object.keys(fleetStatus).map(Number).filter(id => Object.values(fleetStatus[String(id)]).some(Boolean)) - )]; - const autoUpdateByNode = new Map>(); - await Promise.all(nodeIdsWithUpdates.map(async (nodeId) => { - try { - const res = await fetchForNode('/stacks/auto-update-settings', nodeId); - if (res.ok) autoUpdateByNode.set(nodeId, await res.json() as Record); - } catch { - // If the fetch fails, default all stacks on that node to enabled. - } - })); - const flatPairs: { nodeId: number; stack: string }[] = []; const initialGroups: NodeGroup[] = []; const currentNodes = nodesRef.current; @@ -445,17 +446,24 @@ function AutoUpdateReadinessContent() { .map(([stack]) => stack) .sort(); if (stacks.length === 0) continue; - const nodeAutoUpdateSettings = autoUpdateByNode.get(nodeId) ?? {}; const cards: StackCard[] = stacks.map(stack => { flatPairs.push({ nodeId, stack }); + const stackTask = taskByNodeStack.get(`${nodeId}::${stack}`) ?? null; + const fleetTask = fleetTaskByNode.get(nodeId) ?? null; + // Prefer whichever covering task fires next. + // Earliest next-run wins; on a tie, the per-stack row beats the + // fleet row so the user sees the more specific schedule. + const scheduledTask = stackTask && fleetTask + ? ((stackTask.next_run_at ?? Infinity) <= (fleetTask.next_run_at ?? Infinity) ? stackTask : fleetTask) + : (stackTask ?? fleetTask); return { stack, nodeId, preview: null, previewLoaded: false, - scheduledTask: taskByNodeStack.get(`${nodeId}::${stack}`) ?? null, + scheduledTask, applying: false, - autoUpdateEnabled: nodeAutoUpdateSettings[stack] ?? true, + autoUpdateEnabled: scheduledTask !== null, }; }); initialGroups.push({ @@ -593,7 +601,15 @@ function AutoUpdateReadinessContent() { const flatCards = useMemo(() => groups.flatMap(g => g.cards), [groups]); const { total, ready } = useMemo(() => { const t = flatCards.length; - const r = flatCards.filter(c => c.previewLoaded && c.preview !== null && !c.preview.summary.blocked).length; + // "Ready" means a schedule covers the stack, the preview loaded without + // error, and no major-bump blocked it. Without a covering schedule the + // stack cannot apply automatically regardless of preview state. + const r = flatCards.filter(c => + c.autoUpdateEnabled + && c.previewLoaded + && c.preview !== null + && !c.preview.summary.blocked, + ).length; return { total: t, ready: r }; }, [flatCards]); diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 654fb12b..808925ae 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { Button } from './ui/button'; import { Plus } from 'lucide-react'; import { UserProfileDropdown } from './UserProfileDropdown'; @@ -32,7 +32,7 @@ import { useDeployFeedback } from '@/context/DeployFeedbackContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { StackSidebar } from '@/components/sidebar/StackSidebar'; import type { StackRowStatus } from '@/components/sidebar/stack-status-utils'; -import { useSidebarActivitySummary, countEnabledAutoUpdates } from '@/components/sidebar/useSidebarActivitySummary'; +import { useSidebarActivitySummary } from '@/components/sidebar/useSidebarActivitySummary'; import { useNextAutoUpdateRun } from '@/components/sidebar/useNextAutoUpdateRun'; import { usePanelSessionStartedAt } from '@/components/sidebar/usePanelSessionStartedAt'; import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivityTicker'; @@ -68,7 +68,6 @@ export default function EditorLayout() { const stackListState = useStackListState(); const { - files, selectedFile, isLoading, stackActions: stackActionMap, @@ -76,7 +75,6 @@ export default function EditorLayout() { searchQuery, setSearchQuery, stackStatuses, stackLabelMap, - autoUpdateSettings, filterChip, setFilterChip, bulkMode, selectedFiles, @@ -85,7 +83,6 @@ export default function EditorLayout() { remoteResults, isStackBusy, refreshStacks, - fetchAutoUpdateSettings, handleScanStacks, scheduleStateInvalidateRefresh, toggleBulkMode, toggleSelect, clearSelection, handleBulkAction, @@ -147,7 +144,6 @@ export default function EditorLayout() { } = useNotifications({ nodes, onStateInvalidate: scheduleStateInvalidateRefresh, - onAutoUpdateChange: fetchAutoUpdateSettings, onImageUpdatesChange: fetchImageUpdates, }); @@ -190,19 +186,12 @@ export default function EditorLayout() { const panelStartedAt = usePanelSessionStartedAt(panelState); - const autoUpdateEnabledCount = useMemo( - () => countEnabledAutoUpdates(files, autoUpdateSettings), - [files, autoUpdateSettings], - ); - const nextAutoUpdateRunAt = useNextAutoUpdateRun(); const activitySummary = useSidebarActivitySummary({ notifications, tickerConnected, panelState, panelStartedAt, - autoUpdateEnabledCount, - totalStackCount: files.length, nextAutoUpdateRunAt, }); @@ -292,7 +281,6 @@ export default function EditorLayout() { } refreshStacks(); - fetchAutoUpdateSettings(); void stackActions.refreshGitSourcePending(); }, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts b/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts index b64181f4..c4e338ab 100644 --- a/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useNotifications.test.ts @@ -60,7 +60,7 @@ describe('useNotifications', () => { it('starts with empty notifications and disconnected state', () => { const { result } = renderHook(() => - useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }), + useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }), ); expect(result.current.notifications).toEqual([]); expect(result.current.tickerConnected).toBe(false); @@ -68,7 +68,7 @@ describe('useNotifications', () => { it('opens a local notification WebSocket on mount', () => { renderHook(() => - useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }), + useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }), ); expect(MockWS.instances.length).toBeGreaterThanOrEqual(1); expect(MockWS.instances[0]).toBeDefined(); @@ -76,7 +76,7 @@ describe('useNotifications', () => { it('sets tickerConnected true when local WS opens', () => { const { result } = renderHook(() => - useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }), + useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }), ); act(() => { MockWS.instances[0]?.onopen?.(); }); expect(result.current.tickerConnected).toBe(true); @@ -84,7 +84,7 @@ describe('useNotifications', () => { it('adds notification when local WS receives notification message', () => { const { result } = renderHook(() => - useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }), + useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }), ); act(() => { MockWS.instances[0]?.onopen?.(); }); act(() => { @@ -98,7 +98,7 @@ describe('useNotifications', () => { it('clearAllNotifications empties the local state', async () => { const { result } = renderHook(() => - useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }), + useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }), ); act(() => { MockWS.instances[0]?.onopen?.(); }); act(() => { @@ -114,9 +114,8 @@ describe('useNotifications', () => { it('fires onImageUpdatesChange on state-invalidate with action="stack-updated"', () => { const onStateInvalidate = vi.fn(); const onImageUpdatesChange = vi.fn(); - const onAutoUpdateChange = vi.fn(); renderHook(() => - useNotifications({ nodes: [localNode], onStateInvalidate, onAutoUpdateChange, onImageUpdatesChange }), + useNotifications({ nodes: [localNode], onStateInvalidate, onImageUpdatesChange }), ); act(() => { MockWS.instances[0]?.onopen?.(); }); act(() => { @@ -129,14 +128,13 @@ describe('useNotifications', () => { }); expect(onImageUpdatesChange).toHaveBeenCalledTimes(1); expect(onStateInvalidate).toHaveBeenCalledTimes(1); - expect(onAutoUpdateChange).not.toHaveBeenCalled(); }); it('does not fire onImageUpdatesChange on a generic state-invalidate', () => { const onStateInvalidate = vi.fn(); const onImageUpdatesChange = vi.fn(); renderHook(() => - useNotifications({ nodes: [localNode], onStateInvalidate, onAutoUpdateChange: vi.fn(), onImageUpdatesChange }), + useNotifications({ nodes: [localNode], onStateInvalidate, onImageUpdatesChange }), ); act(() => { MockWS.instances[0]?.onopen?.(); }); act(() => { @@ -151,29 +149,9 @@ describe('useNotifications', () => { expect(onImageUpdatesChange).not.toHaveBeenCalled(); }); - it('does not fire onImageUpdatesChange on auto-update-settings-changed', () => { - const onAutoUpdateChange = vi.fn(); - const onImageUpdatesChange = vi.fn(); - const onStateInvalidate = vi.fn(); - renderHook(() => - useNotifications({ nodes: [localNode], onStateInvalidate, onAutoUpdateChange, onImageUpdatesChange }), - ); - act(() => { MockWS.instances[0]?.onopen?.(); }); - act(() => { - MockWS.instances[0]?.onmessage?.({ - data: JSON.stringify({ - type: 'state-invalidate', nodeId: 1, action: 'auto-update-settings-changed', ts: 1000, - }), - }); - }); - expect(onAutoUpdateChange).toHaveBeenCalledTimes(1); - expect(onImageUpdatesChange).not.toHaveBeenCalled(); - expect(onStateInvalidate).not.toHaveBeenCalled(); - }); - it('deleteNotification removes the matching item', async () => { const { result } = renderHook(() => - useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onAutoUpdateChange: vi.fn(), onImageUpdatesChange: vi.fn() }), + useNotifications({ nodes: [localNode], onStateInvalidate: vi.fn(), onImageUpdatesChange: vi.fn() }), ); act(() => { MockWS.instances[0]?.onopen?.(); }); const notif = makeNotif({ id: 5, nodeId: localNode.id }); diff --git a/frontend/src/components/EditorLayout/hooks/useNotifications.ts b/frontend/src/components/EditorLayout/hooks/useNotifications.ts index 5921aa8a..24202ba2 100644 --- a/frontend/src/components/EditorLayout/hooks/useNotifications.ts +++ b/frontend/src/components/EditorLayout/hooks/useNotifications.ts @@ -7,11 +7,10 @@ import type { NotificationItem } from '../../dashboard/types'; interface UseNotificationsOptions { nodes: Node[]; onStateInvalidate: () => void; - onAutoUpdateChange: () => void; onImageUpdatesChange: () => void; } -export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange, onImageUpdatesChange }: UseNotificationsOptions) { +export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChange }: UseNotificationsOptions) { const [notifications, setNotifications] = useState([]); const [tickerConnected, setTickerConnected] = useState(false); @@ -22,8 +21,6 @@ export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange, const remoteNotifWsRef = useRef void>>(new Map()); const onStateInvalidateRef = useRef(onStateInvalidate); onStateInvalidateRef.current = onStateInvalidate; - const onAutoUpdateChangeRef = useRef(onAutoUpdateChange); - onAutoUpdateChangeRef.current = onAutoUpdateChange; const onImageUpdatesChangeRef = useRef(onImageUpdatesChange); onImageUpdatesChangeRef.current = onImageUpdatesChange; @@ -98,13 +95,9 @@ export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange, setNotifications(prev => [tagged, ...prev].sort((a, b) => b.timestamp - a.timestamp)); } else if (msg.type === 'state-invalidate') { window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail: msg })); - if (msg.action === 'auto-update-settings-changed') { - onAutoUpdateChangeRef.current(); - } else { - onStateInvalidateRef.current(); - if (msg.scope === 'image-updates' && msg.action === 'stack-updated') { - onImageUpdatesChangeRef.current(); - } + onStateInvalidateRef.current(); + if (msg.scope === 'image-updates' && msg.action === 'stack-updated') { + onImageUpdatesChangeRef.current(); } } } catch (e) { diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index f8b702ee..5c2756fc 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -52,7 +52,6 @@ export function useSidebarContextMenu({ labels: stackListState.labels, assignedLabelIds: (stackListState.stackLabelMap[file] ?? []).map(l => l.id), menuVisibility: stackActions.getStackMenuVisibility(file), - autoUpdateEnabled: stackListState.autoUpdateSettings[sName] ?? true, openAlertSheet: () => overlayState.openAlertSheet(file), openAutoHeal: () => overlayState.openAutoHeal(file), checkUpdates: () => stackActions.checkUpdatesForStack(), @@ -64,22 +63,6 @@ export function useSidebarContextMenu({ remove: () => overlayState.openDeleteDialog(sName), pin: () => stackListState.pin(file), unpin: () => stackListState.unpin(file), - setAutoUpdateEnabled: async (enabled: boolean) => { - stackListState.setAutoUpdateSettings(prev => ({ ...prev, [sName]: enabled })); - try { - const res = await apiFetch(`/stacks/${encodeURIComponent(sName)}/auto-update`, { - method: 'PUT', - body: JSON.stringify({ enabled }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error((data as { error?: string })?.error || 'Failed to update auto-update setting.'); - } - } catch (err: unknown) { - stackListState.setAutoUpdateSettings(prev => ({ ...prev, [sName]: !enabled })); - toast.error((err as Error)?.message || 'Failed to update auto-update setting.'); - } - }, toggleLabel: async (labelId: number) => { const currentIds = (stackListState.stackLabelMap[file] ?? []).map(l => l.id); const assigned = currentIds.includes(labelId); @@ -142,7 +125,7 @@ export function useSidebarContextMenu({ }, [ stackListState.stackStatuses, stackListState.stackPorts, isPaid, isAdmiral, stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap, - stackListState.autoUpdateSettings, stackListState.pin, stackListState.unpin, + stackListState.pin, stackListState.unpin, ]); return buildMenuCtx; diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.ts index 408f08df..876468ad 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackListState.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackListState.ts @@ -44,7 +44,6 @@ export function useStackListState() { const [stackPorts, setStackPorts] = useState>({}); const [labels, setLabels] = useState([]); const [stackLabelMap, setStackLabelMap] = useState>({}); - const [autoUpdateSettings, setAutoUpdateSettings] = useState>({}); const [filterChip, setFilterChip] = useState('all'); const [bulkMode, setBulkMode] = useState(false); const [selectedFiles, setSelectedFiles] = useState>(new Set()); @@ -181,20 +180,6 @@ export function useStackListState() { const refreshStacksRef = useRef(refreshStacks); useEffect(() => { refreshStacksRef.current = refreshStacks; }); - const fetchAutoUpdateSettings = async () => { - try { - const res = await apiFetch('/stacks/auto-update-settings'); - if (res.ok) { - const data = await res.json(); - setAutoUpdateSettings(data as Record); - } else { - console.error('[AutoUpdateSettings] fetch returned', res.status); - } - } catch (e: unknown) { - console.error('[AutoUpdateSettings] fetch failed:', e); - } - }; - const handleScanStacks = async () => { if (isScanning) return; setIsScanning(true); @@ -339,7 +324,6 @@ export function useStackListState() { stackPorts, setStackPorts, labels, stackLabelMap, - autoUpdateSettings, setAutoUpdateSettings, filterChip, setFilterChip, bulkMode, setBulkMode, selectedFiles, setSelectedFiles, @@ -351,7 +335,6 @@ export function useStackListState() { setOptimisticStatus, refreshLabels, refreshStacks, - fetchAutoUpdateSettings, handleScanStacks, scheduleStateInvalidateRefresh, toggleBulkMode, toggleSelect, clearSelection, handleBulkAction, diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index 6d51c089..282c5d4f 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -736,7 +736,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p onValueChange={setFormNodeId} placeholder="Select node..." /> -

Only stacks with auto-updates enabled on this node will be updated.

+

Every stack on the selected node will be checked and updated when new images are available.

)} diff --git a/frontend/src/components/dashboard/ConfigurationStatus.tsx b/frontend/src/components/dashboard/ConfigurationStatus.tsx index 1bb623eb..65d7a5f9 100644 --- a/frontend/src/components/dashboard/ConfigurationStatus.tsx +++ b/frontend/src/components/dashboard/ConfigurationStatus.tsx @@ -165,8 +165,8 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps onClick={open('system')} /> {!automation.webhooks.locked && ( diff --git a/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx b/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx index 0e00fb50..4d71973b 100644 --- a/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx +++ b/frontend/src/components/dashboard/__tests__/ConfigurationStatus.test.tsx @@ -79,7 +79,7 @@ describe('ConfigurationStatus tier parity', () => { expect(screen.queryByText('Notification routing')).toBeNull(); expect(screen.queryByText('Automation')).toBeNull(); expect(screen.queryByText('Auto-heal policies')).toBeNull(); - expect(screen.queryByText('Auto-update stacks')).toBeNull(); + expect(screen.queryByText('Auto-update schedules')).toBeNull(); expect(screen.queryByText('Webhooks')).toBeNull(); expect(screen.queryByText('Scheduled tasks')).toBeNull(); expect(screen.queryByText('Vulnerability scanning')).toBeNull(); @@ -121,7 +121,7 @@ describe('ConfigurationStatus tier parity', () => { expect(screen.getByText('Automation')).toBeDefined(); expect(screen.getByText('Auto-heal policies')).toBeDefined(); - expect(screen.getByText('Auto-update stacks')).toBeDefined(); + expect(screen.getByText('Auto-update schedules')).toBeDefined(); expect(screen.getByText('Webhooks')).toBeDefined(); expect(screen.getByText('Notification routing')).toBeDefined(); expect(screen.getByText('Vulnerability scanning')).toBeDefined(); diff --git a/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx b/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx index 3ea42190..75e2b712 100644 --- a/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx +++ b/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx @@ -83,17 +83,17 @@ describe('useConfigurationStatus state-invalidate handling', () => { expect(apiFetchMock.mock.calls.length).toBe(baseline); }); - it('refetches once on a settings-affecting auto-update-settings-changed event', async () => { + it('refetches once on a scheduled-tasks invalidation', async () => { renderHook(() => useConfigurationStatus()); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); const baseline = apiFetchMock.mock.calls.length; act(() => { - // Burst three settings-change events; the debounce should collapse - // them into a single refetch. - fireInvalidate({ action: 'auto-update-settings-changed' }); - fireInvalidate({ action: 'auto-update-settings-changed' }); - fireInvalidate({ action: 'auto-update-settings-changed' }); + // Burst three scheduled-tasks-change events; the debounce should + // collapse them into a single refetch. + fireInvalidate({ scope: 'scheduled-tasks', action: 'created' }); + fireInvalidate({ scope: 'scheduled-tasks', action: 'toggled' }); + fireInvalidate({ scope: 'scheduled-tasks', action: 'deleted' }); }); // Before debounce window elapses, no new fetch. expect(apiFetchMock.mock.calls.length).toBe(baseline); diff --git a/frontend/src/components/dashboard/useConfigurationStatus.ts b/frontend/src/components/dashboard/useConfigurationStatus.ts index 4d6b4816..9cc86382 100644 --- a/frontend/src/components/dashboard/useConfigurationStatus.ts +++ b/frontend/src/components/dashboard/useConfigurationStatus.ts @@ -80,19 +80,15 @@ export function useConfigurationStatus() { return visibilityInterval(guard, 60_000); }, [nodeId, fetchStatus]); - // Filter `sencho:state-invalidate` so only settings-affecting events - // refetch the configuration; the high-frequency `scope: 'stack'` and - // `scope: 'image-updates'` container/image bursts are ignored. Today the - // only such settings event is `auto-update-settings-changed` (emitted by - // the stack auto-update toggle). The filter is debounced and the - // configuration response includes the toggled state, so a user editing - // the setting sees the row update under a second instead of waiting up - // to a minute. + // Refetch the configuration card when a scheduled-tasks mutation fires + // an invalidate. High-frequency `scope: 'stack'` and `scope: 'image-updates'` + // events are ignored; they don't change any tile on this card. Debounced + // so a burst of edits coalesces into a single fetch. useEffect(() => { let invalidateTimer: ReturnType | null = null; const onInvalidate = (e: Event) => { - const detail = (e as CustomEvent<{ action?: string; scope?: string }>).detail; - if (detail?.action !== 'auto-update-settings-changed') return; + const detail = (e as CustomEvent<{ scope?: string }>).detail; + if (detail?.scope !== 'scheduled-tasks') return; if (invalidateTimer) clearTimeout(invalidateTimer); invalidateTimer = setTimeout(() => { invalidateTimer = null; diff --git a/frontend/src/components/sidebar/SidebarActivityTicker.tsx b/frontend/src/components/sidebar/SidebarActivityTicker.tsx index f9a224eb..35c350e8 100644 --- a/frontend/src/components/sidebar/SidebarActivityTicker.tsx +++ b/frontend/src/components/sidebar/SidebarActivityTicker.tsx @@ -88,8 +88,7 @@ function buildConfig(summary: SidebarActivitySummary): RenderConfig { iconClass: 'text-warning', primary: ( - Auto-update - {summary.enabledCount}/{summary.totalCount} + Auto-update · next run {nextLabel} ), diff --git a/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx b/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx index be2aacb2..f071085b 100644 --- a/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx +++ b/frontend/src/components/sidebar/__tests__/SidebarActivityTicker.test.tsx @@ -54,11 +54,11 @@ describe('SidebarActivityTicker', () => { }); }); - it('renders automation state with counts, next-run time, and routes click to open-auto-updates', () => { + it('renders automation state with next-run time, and routes click to open-auto-updates', () => { const nextRun = Math.floor(Date.now() / 1000) + 600; - const { onAction } = renderWith({ kind: 'automation', enabledCount: 3, totalCount: 8, nextRunAt: nextRun }); + const { onAction } = renderWith({ kind: 'automation', nextRunAt: nextRun }); expect(screen.getByText(/Auto-update/)).toBeInTheDocument(); - expect(screen.getByText('3/8')).toBeInTheDocument(); + expect(screen.getByText(/next run/)).toBeInTheDocument(); expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-warning'); fireEvent.click(screen.getByRole('button')); expect(onAction).toHaveBeenCalledWith({ kind: 'open-auto-updates' }); diff --git a/frontend/src/components/sidebar/__tests__/useNextAutoUpdateRun.test.tsx b/frontend/src/components/sidebar/__tests__/useNextAutoUpdateRun.test.tsx index 5de20bc5..bfcec7e0 100644 --- a/frontend/src/components/sidebar/__tests__/useNextAutoUpdateRun.test.tsx +++ b/frontend/src/components/sidebar/__tests__/useNextAutoUpdateRun.test.tsx @@ -56,14 +56,14 @@ describe('useNextAutoUpdateRun', () => { expect(result.current).toBe(1_700); }); - it('debounces rapid invalidations into a single refetch', async () => { + it('debounces rapid scheduled-tasks invalidations into a single refetch', async () => { renderHook(() => useNextAutoUpdateRun()); expect(apiFetchMock).toHaveBeenCalledTimes(1); act(() => { - fireInvalidate({ action: 'auto-update-settings-changed' }); - fireInvalidate({ action: 'auto-update-settings-changed' }); - fireInvalidate({ action: 'auto-update-settings-changed' }); + fireInvalidate({ scope: 'scheduled-tasks' }); + fireInvalidate({ scope: 'scheduled-tasks' }); + fireInvalidate({ scope: 'scheduled-tasks' }); }); // Debounce window not yet elapsed: still only the mount call. expect(apiFetchMock).toHaveBeenCalledTimes(1); @@ -76,8 +76,8 @@ describe('useNextAutoUpdateRun', () => { renderHook(() => useNextAutoUpdateRun()); apiFetchMock.mockClear(); act(() => { - fireInvalidate({ action: 'something-else' }); fireInvalidate({ scope: 'unrelated' }); + fireInvalidate({ scope: 'stack' }); }); vi.advanceTimersByTime(500); expect(apiFetchMock).toHaveBeenCalledTimes(0); @@ -98,7 +98,7 @@ describe('useNextAutoUpdateRun', () => { apiFetchMock.mockClear(); unmount(); await act(async () => { - fireInvalidate({ action: 'auto-update-settings-changed' }); + fireInvalidate({ scope: 'scheduled-tasks' }); vi.advanceTimersByTime(120_000); }); expect(apiFetchMock).toHaveBeenCalledTimes(0); diff --git a/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts b/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts index 61c2263c..05b99b9e 100644 --- a/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts +++ b/frontend/src/components/sidebar/__tests__/useSidebarActivitySummary.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { __testing, countEnabledAutoUpdates } from '../useSidebarActivitySummary'; +import { __testing } from '../useSidebarActivitySummary'; import type { NotificationItem } from '@/components/dashboard/types'; import type { DeployPanelState } from '@/context/DeployFeedbackContext'; @@ -29,8 +29,6 @@ function inputs(overrides: Partial[0]> = {}) { tickerConnected: true, panelState: IDLE_PANEL, panelStartedAt: null, - autoUpdateEnabledCount: 0, - totalStackCount: 0, nextAutoUpdateRunAt: null, ...overrides, }; @@ -69,8 +67,6 @@ describe('useSidebarActivitySummary.deriveSummary', () => { const recent = notif({ id: 10, timestamp: NOW_SECS - 5 }); const r = deriveSummary(inputs({ notifications: [failure, recent], - autoUpdateEnabledCount: 1, - totalStackCount: 1, nextAutoUpdateRunAt: NOW_SECS + 3600, }), NOW_SECS); expect(r.kind).toBe('failure'); @@ -84,16 +80,12 @@ describe('useSidebarActivitySummary.deriveSummary', () => { expect(r.kind).not.toBe('failure'); }); - it('returns automation when auto-update is enabled and no recent event exists', () => { + it('returns automation when a next-run is known and no recent event exists', () => { const r = deriveSummary(inputs({ - autoUpdateEnabledCount: 2, - totalStackCount: 4, nextAutoUpdateRunAt: NOW_SECS + 3600, }), NOW_SECS); expect(r.kind).toBe('automation'); if (r.kind === 'automation') { - expect(r.enabledCount).toBe(2); - expect(r.totalCount).toBe(4); expect(r.nextRunAt).toBe(NOW_SECS + 3600); } }); @@ -102,18 +94,14 @@ describe('useSidebarActivitySummary.deriveSummary', () => { const recent = notif({ id: 7, timestamp: NOW_SECS - 5 }); const r = deriveSummary(inputs({ notifications: [recent], - autoUpdateEnabledCount: 1, - totalStackCount: 1, nextAutoUpdateRunAt: NOW_SECS + 3600, }), NOW_SECS); expect(r.kind).toBe('recent-event'); if (r.kind === 'recent-event') expect(r.notif.id).toBe(7); }); - it('drops automation when no next-run is known, even with auto-update settings present', () => { + it('drops automation when no next-run is known', () => { const r = deriveSummary(inputs({ - autoUpdateEnabledCount: 1, - totalStackCount: 1, nextAutoUpdateRunAt: null, }), NOW_SECS); expect(r.kind).toBe('quiet-live'); @@ -160,25 +148,3 @@ describe('useSidebarActivitySummary.deriveSummary', () => { expect(r.kind).toBe('quiet-live'); }); }); - -describe('countEnabledAutoUpdates', () => { - it('counts a stack with no explicit row as enabled (backend default-true contract)', () => { - expect(countEnabledAutoUpdates(['web', 'api'], {})).toBe(2); - }); - - it('respects an explicit false', () => { - expect(countEnabledAutoUpdates(['web', 'api', 'db'], { api: false })).toBe(2); - }); - - it('respects an explicit true', () => { - expect(countEnabledAutoUpdates(['web'], { web: true })).toBe(1); - }); - - it('returns 0 for an empty file list', () => { - expect(countEnabledAutoUpdates([], { web: true })).toBe(0); - }); - - it('ignores settings rows that do not correspond to known files', () => { - expect(countEnabledAutoUpdates(['web'], { ghost: false, web: true })).toBe(1); - }); -}); diff --git a/frontend/src/components/sidebar/sidebar-types.ts b/frontend/src/components/sidebar/sidebar-types.ts index 681ac766..fcaa5961 100644 --- a/frontend/src/components/sidebar/sidebar-types.ts +++ b/frontend/src/components/sidebar/sidebar-types.ts @@ -34,7 +34,6 @@ export interface StackMenuCtx { labels: Label[]; assignedLabelIds: number[]; menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean }; - autoUpdateEnabled: boolean; openAlertSheet: () => void; openAutoHeal: () => void; checkUpdates: () => void; @@ -49,7 +48,6 @@ export interface StackMenuCtx { toggleLabel: (labelId: number) => void; createAndAssignLabel: (name: string, color: LabelColor) => Promise; openLabelManager: () => void; - setAutoUpdateEnabled: (enabled: boolean) => void; openScheduleTask: () => void; } diff --git a/frontend/src/components/sidebar/useNextAutoUpdateRun.ts b/frontend/src/components/sidebar/useNextAutoUpdateRun.ts index 11722d2f..f814ca3c 100644 --- a/frontend/src/components/sidebar/useNextAutoUpdateRun.ts +++ b/frontend/src/components/sidebar/useNextAutoUpdateRun.ts @@ -41,8 +41,8 @@ export function useNextAutoUpdateRun(): number | null { run(); const onInvalidate = (e: Event) => { - const detail = (e as CustomEvent<{ action?: string; scope?: string }>).detail; - if (detail?.action !== 'auto-update-settings-changed' && detail?.scope !== 'scheduled-tasks') return; + const detail = (e as CustomEvent<{ scope?: string }>).detail; + if (detail?.scope !== 'scheduled-tasks') return; if (invalidateTimer) clearTimeout(invalidateTimer); invalidateTimer = setTimeout(() => { invalidateTimer = null; run(); }, INVALIDATE_DEBOUNCE_MS); }; diff --git a/frontend/src/components/sidebar/useSidebarActivitySummary.ts b/frontend/src/components/sidebar/useSidebarActivitySummary.ts index df2ea10b..d37cd902 100644 --- a/frontend/src/components/sidebar/useSidebarActivitySummary.ts +++ b/frontend/src/components/sidebar/useSidebarActivitySummary.ts @@ -9,7 +9,7 @@ const RECENT_WINDOW_SECS = 60 * 60; export type SidebarActivitySummary = | { kind: 'active-op'; stackName: string; action: ActionVerb; startedAt: number } | { kind: 'failure'; notif: NotificationItem } - | { kind: 'automation'; enabledCount: number; totalCount: number; nextRunAt: number } + | { kind: 'automation'; nextRunAt: number } | { kind: 'recent-event'; notif: NotificationItem } | { kind: 'quiet-live' } | { kind: 'disconnected' }; @@ -19,9 +19,6 @@ interface SummaryInputs { tickerConnected: boolean; panelState: DeployPanelState; panelStartedAt: number | null; - /** Pre-aggregated by the caller so the memo dep list stays scalar; see EditorLayout. */ - autoUpdateEnabledCount: number; - totalStackCount: number; nextAutoUpdateRunAt: number | null; } @@ -54,7 +51,7 @@ function findRecent(notifications: NotificationItem[], nowSecs: number): Notific * 1. active-op: a deploy panel is preparing/streaming * 2. failure: newest unread stack-scoped error in the last 24h * 3. recent-event: newest non-error stack notification in the last hour - * 4. automation: auto-update is enabled and a next run is scheduled + * 4. automation: a next auto-update run is scheduled * 5. disconnected: notification WebSocket is down * 6. quiet-live: nothing else to surface * @@ -64,7 +61,7 @@ function findRecent(notifications: NotificationItem[], nowSecs: number): Notific * follow the same order; if you change the cascade, update both. */ function deriveSummary(inputs: SummaryInputs, nowSecs: number): SidebarActivitySummary { - const { panelState, panelStartedAt, notifications, autoUpdateEnabledCount, totalStackCount, nextAutoUpdateRunAt, tickerConnected } = inputs; + const { panelState, panelStartedAt, notifications, nextAutoUpdateRunAt, tickerConnected } = inputs; if (panelState.isOpen && (panelState.status === 'preparing' || panelState.status === 'streaming') && panelStartedAt !== null) { return { kind: 'active-op', stackName: panelState.stackName, action: panelState.action, startedAt: panelStartedAt }; @@ -77,8 +74,8 @@ function deriveSummary(inputs: SummaryInputs, nowSecs: number): SidebarActivityS } const recent = findRecent(notifications, nowSecs); - if (!recent && autoUpdateEnabledCount > 0 && nextAutoUpdateRunAt !== null) { - return { kind: 'automation', enabledCount: autoUpdateEnabledCount, totalCount: totalStackCount, nextRunAt: nextAutoUpdateRunAt }; + if (!recent && nextAutoUpdateRunAt !== null) { + return { kind: 'automation', nextRunAt: nextAutoUpdateRunAt }; } if (recent) { @@ -111,23 +108,10 @@ export function useSidebarActivitySummary(inputs: SummaryInputs): SidebarActivit inputs.panelState.action, inputs.panelState.status, inputs.panelStartedAt, - inputs.autoUpdateEnabledCount, - inputs.totalStackCount, inputs.nextAutoUpdateRunAt, ], ); } -/** - * Count stacks with auto-update enabled. Backend defaults missing rows to - * enabled (DatabaseService.getStackAutoUpdateSettingsForNode); callers must - * NOT treat absence as disabled. - */ -export function countEnabledAutoUpdates(files: string[], settings: Record): number { - let n = 0; - for (const f of files) if (settings[f] ?? true) n++; - return n; -} - // Exported for unit tests so we don't need to spin up a renderer to validate cascade logic. export const __testing = { deriveSummary }; diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index 12807a8b..f72c1edb 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -18,7 +18,6 @@ function makeCtx(overrides: Partial = {}): StackMenuCtx { labels: [], assignedLabelIds: [], menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false }, - autoUpdateEnabled: true, openAlertSheet: vi.fn(), openAutoHeal: vi.fn(), checkUpdates: vi.fn(), @@ -33,7 +32,6 @@ function makeCtx(overrides: Partial = {}): StackMenuCtx { toggleLabel: vi.fn(), createAndAssignLabel: vi.fn(), openLabelManager: vi.fn(), - setAutoUpdateEnabled: vi.fn(), openScheduleTask: vi.fn(), ...overrides, }; @@ -85,16 +83,15 @@ describe('useStackMenuItems', () => { expect(del.icon).toBe(Trash2); }); - it('shows auto-update toggle in inspect when isPaid', () => { - const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true, autoUpdateEnabled: true }))); - const inspect = result.current.find(g => g.id === 'inspect')!; - expect(inspect.items.find(i => i.id === 'auto-update')).toBeDefined(); - }); - - it('hides auto-update toggle when !isPaid', () => { - const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false }))); - const inspect = result.current.find(g => g.id === 'inspect')!; - expect(inspect.items.find(i => i.id === 'auto-update')).toBeUndefined(); + it('does not show an auto-update entry; Schedule task is the auto-update path', () => { + const paid = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: true }))); + const community = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false }))); + for (const r of [paid, community]) { + const groups = r.result.current; + expect(groups.some(g => g.items.some(i => i.id === 'auto-update'))).toBe(false); + } + const lifecycle = paid.result.current.find(g => g.id === 'lifecycle')!; + expect(lifecycle.items.some(i => i.id === 'schedule')).toBe(true); }); it('keeps label assignment available when !isPaid', () => { @@ -118,16 +115,6 @@ describe('useStackMenuItems', () => { expect(organize.items.find(i => i.id === 'pin')).toBeDefined(); }); - it('auto-update toggle calls setAutoUpdateEnabled with toggled value', () => { - const setAutoUpdateEnabled = vi.fn(); - const { result } = renderHook(() => - useStackMenuItems('web.yml', makeCtx({ isPaid: true, autoUpdateEnabled: true, setAutoUpdateEnabled })) - ); - const inspect = result.current.find(g => g.id === 'inspect')!; - inspect.items.find(i => i.id === 'auto-update')!.onSelect(); - expect(setAutoUpdateEnabled).toHaveBeenCalledWith(false); - }); - it('lifecycle items follow menuVisibility flags', () => { const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true }, diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index 32518cac..7218ed94 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -4,7 +4,6 @@ import { ArrowUpRight, BellRing, CalendarClock, - CircleSlash, Download, Pin, PinOff, @@ -22,7 +21,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] stackStatus, hasPort, isBusy, isPaid, canDelete, canEditLabels, isPinned, labels, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, - menuVisibility, autoUpdateEnabled, setAutoUpdateEnabled, openScheduleTask, + menuVisibility, openScheduleTask, } = ctx; const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility; @@ -34,12 +33,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] ]; if (isPaid) { inspect.push({ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal }); - inspect.push({ - id: 'auto-update', - label: autoUpdateEnabled ? 'Auto-update: Enabled' : 'Auto-update: Disabled', - icon: autoUpdateEnabled ? RefreshCw : CircleSlash, - onSelect: () => setAutoUpdateEnabled(!autoUpdateEnabled), - }); } inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates }); if (stackStatus === 'running' && hasPort) { @@ -89,7 +82,6 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] }, [ stackStatus, hasPort, isBusy, isPaid, canDelete, canEditLabels, isPinned, labels, showDeploy, showStop, showRestart, showUpdate, - autoUpdateEnabled, setAutoUpdateEnabled, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask, ]);