refactor(auto-update): retire per-stack gate, drive auto-update from schedules only (#1233)

* refactor(auto-update): retire per-stack gate, drive auto-update from schedules only

The per-stack Auto-update toggle in the stack sidebar context menu wrote a
gate row to `stack_auto_update_settings`, but actual updates only ran when a
`scheduled_tasks` row with `action='update'` fired. On a fresh install the
toggle was inert: detection ran every 6h, nothing was applied.

The same context menu already exposes `Schedule task`, which opens
ScheduledOperationsView pre-filled for the stack where the user can pick
`Auto-update Stack` and any cron. Keeping the toggle alongside that flow
duplicated the same action and turned the gate table into a parallel store
of "is a covering schedule active" derivable from `scheduled_tasks` itself.

Drop the gate model entirely:
- Backend: remove the `stack_auto_update_settings` table and its four
  accessors, the three routes under /api/stacks/*/auto-update, the per-stack
  skip in /api/auto-update/execute and SchedulerService.executeUpdate's
  fleet branch, and the clearStackAutoUpdateSetting call on stack delete.
  Dashboard `autoUpdate` count derives from scheduled_tasks (action='update'
  rows pinned to the node, total/enabled split).
- Frontend: drop the Auto-update entry from the sidebar context menu and its
  optimistic toggle plumbing. Drop autoUpdateSettings state, the
  /stacks/auto-update-settings fetch, and the auto-update-settings-changed
  WebSocket branch. Slim useSidebarActivitySummary (just nextRunAt; no
  enabled/total counts). AutoUpdateReadinessView's per-card autoUpdateEnabled
  now means "a covering enabled action='update' schedule exists" (per-stack
  row or fleet row on this node, earliest next_run_at wins, per-stack row
  wins on ties), with the gate-fetch removed.
- New: scheduledTasksRouter broadcasts scope: 'scheduled-tasks' on POST,
  PUT, PATCH /toggle, and DELETE so useConfigurationStatus and
  useNextAutoUpdateRun refetch under the 250ms debounce instead of waiting
  for the 60s poll. The broadcast is wrapped so a broken subscriber socket
  cannot turn a successful mutation into a 500.
- Docs: rewrite the "Per-stack control" section of auto-update-policies.mdx
  to describe the schedule-based model; update the matching troubleshooting
  entry. The misleading fleet-update help text in ScheduledOperationsView
  is corrected to reflect that every stack on the node is covered.

Tier parity: the surviving auto-update path (Schedule task -> Auto-update
Stack / All Stacks) is gated `requirePaid + requireAdmin` backend and
`isPaid + isAdmin` frontend, matching the gate the deleted routes carried.
The pre-commit grep returns no tier-related diff outside this PR's scope.

No data migration is provided: greenfield rules apply, and the leftover
table on already-shipped instances is harmless because no code reads or
writes it after this PR.

* docs: sweep remaining references to the per-stack auto-update toggle

The previous commit retired the per-stack Auto-update gate in favor of
configuring auto-update purely through scheduled tasks. This commit
removes the now-stale mentions of that toggle across the operator docs:

- docs/features/sidebar.mdx: drop the Auto-update entry from the Inspect
  group description, the matching screenshot alt-text, and the Skipper
  Note that listed it. Schedule task now carries the cross-link to
  Auto-Update Policies.
- docs/features/stack-management.mdx: drop the Auto-update list item;
  refresh the Schedule task entry to mention the Auto-update Stack action.
- docs/features/dashboard.mdx: rename the Configuration Status row from
  "Auto-update stacks" to "Auto-update schedules" with the new value
  shape, and rewrite the troubleshooting accordion to describe the
  scheduled-tasks invalidation path.
- docs/features/scheduled-operations.mdx: rewrite the Auto-update All
  Stacks row and helper text to reflect that every stack on the node is
  covered (no per-stack opt-out from this surface anymore).
- docs/features/multi-node.mdx: rewrite the Updates column definition to
  derive the Auto/Off flag from enabled Auto-update Stack / Auto-update
  All Stacks schedules instead of the removed per-stack policy.

The auto-update-policies.mdx rewrite in the previous commit already
covered the main reference page. The sidebar-context-menu.png screenshot
will be refreshed on release once the new menu is live in production;
the alt text is updated in this commit so it accurately describes the
shipping state.

No website edits needed: the Auto-Update Policies feature card description
("Schedule automatic image pulls and redeployments per stack on your own
cadence") and the feature matrix labels ("Auto-update stack schedule",
"Auto-update all stacks schedule") remain accurate under the new model.

* fix(stacks): drop orphaned requireAdmin import after auto-update route removal

CI's backend lint step flagged this PR's earlier deletion of the three
/api/stacks/*/auto-update routes: those handlers were the only callers of
`requireAdmin` inside routes/stacks.ts, leaving the named import on line 15
unreferenced. `requirePaid` and `effectiveTier` from the same line are still
in use elsewhere in the file and stay.

tsc --noEmit does not flag unused named imports; ESLint's no-unused-vars
does. Local backend lint reproduces and now reports 0 errors against the
existing 334-warning baseline.
This commit is contained in:
Anso
2026-05-26 11:08:33 -04:00
committed by GitHub
parent 2a29fed117
commit adcd04b01a
36 changed files with 295 additions and 668 deletions
@@ -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
@@ -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();
}
});
});
@@ -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' })
@@ -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');
}
});
});
+3 -3
View File
@@ -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;
-5
View File
@@ -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.`);
+19
View File
@@ -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<Omit<ScheduledTask, 'id'>>);
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);
+1 -49
View File
@@ -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<BulkLifecycleAction> = 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);
-42
View File
@@ -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<string, boolean> {
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<string, boolean> = {};
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
-7
View File
@@ -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) {
+6 -21
View File
@@ -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."
<Note>
Per-stack auto-update control requires a **Skipper** or **Admiral** license.
</Note>
### 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.
</Accordion>
<Accordion title='Scheduled auto-update runs are not applying to a specific stack'>
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.
</Accordion>
<Accordion title='Banner says "X of Y nodes reachable"'>
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.
+2 -2
View File
@@ -98,7 +98,7 @@ The Automation block only renders on Skipper or Admiral.
| Row | What it shows |
|-----|---------------|
| **Auto-heal policies** | `<enabled> / <total> active` for crash-recovery policies across all stacks; reads `None` when no policies exist |
| **Auto-update stacks** | `<enabled> / <total>` count of stacks enrolled in automated image-update checks; reads `None` when none are enrolled |
| **Auto-update schedules** | `<enabled> / <total> 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 `<n> actives` |
| **Scheduled tasks** (Admiral) | Active scheduled operations (backups, restarts, scripts), formatted `<n> 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).
</Accordion>
<Accordion title="Configuration Status still shows the old value after I changed a setting">
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.
</Accordion>
<Accordion title="The masthead shows a 'metrics stale' chip next to the node name">
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.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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.
+4 -4
View File
@@ -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**.
<Note>
**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.
</Note>
<Frame>
<img src="/images/sidebar/sidebar-context-menu.png" alt="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." />
<img src="/images/sidebar/sidebar-context-menu.png" alt="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." />
</Frame>
## Keyboard shortcuts
+1 -2
View File
@@ -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**
@@ -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 && (
<span className="font-mono text-[11px] text-stat-subtitle/90">
{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` : ''}
</span>
)}
</div>
@@ -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<string, ScheduledTask>();
const fleetTaskByNode = new Map<number, ScheduledTask>();
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<number, Record<string, boolean>>();
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<string, boolean>);
} 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]);
+2 -14
View File
@@ -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
@@ -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 });
@@ -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<NotificationItem[]>([]);
const [tickerConnected, setTickerConnected] = useState(false);
@@ -22,8 +21,6 @@ export function useNotifications({ nodes, onStateInvalidate, onAutoUpdateChange,
const remoteNotifWsRef = useRef<Map<number, () => 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) {
@@ -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;
@@ -44,7 +44,6 @@ export function useStackListState() {
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
const [labels, setLabels] = useState<StackLabel[]>([]);
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [autoUpdateSettings, setAutoUpdateSettings] = useState<Record<string, boolean>>({});
const [filterChip, setFilterChip] = useState<FilterChip>('all');
const [bulkMode, setBulkMode] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(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<string, boolean>);
} 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,
@@ -736,7 +736,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
onValueChange={setFormNodeId}
placeholder="Select node..."
/>
<p className="text-xs text-muted-foreground">Only stacks with auto-updates enabled on this node will be updated.</p>
<p className="text-xs text-muted-foreground">Every stack on the selected node will be checked and updated when new images are available.</p>
</div>
)}
@@ -165,8 +165,8 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
onClick={open('system')}
/>
<Row
label="Auto-update stacks"
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total}`}
label="Auto-update schedules"
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total} active`}
onClick={open('system')}
/>
{!automation.webhooks.locked && (
@@ -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();
@@ -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);
@@ -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<typeof setTimeout> | 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;
@@ -88,8 +88,7 @@ function buildConfig(summary: SidebarActivitySummary): RenderConfig {
iconClass: 'text-warning',
primary: (
<span className="font-mono text-[11px] truncate">
<span className="text-foreground">Auto-update </span>
<span className="text-brand">{summary.enabledCount}/{summary.totalCount}</span>
<span className="text-foreground">Auto-update</span>
<span className="text-muted-foreground"> · next run {nextLabel}</span>
</span>
),
@@ -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' });
@@ -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);
@@ -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<Parameters<typeof deriveSummary>[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);
});
});
@@ -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<void>;
openLabelManager: () => void;
setAutoUpdateEnabled: (enabled: boolean) => void;
openScheduleTask: () => void;
}
@@ -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);
};
@@ -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<string, boolean>): 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 };
@@ -18,7 +18,6 @@ function makeCtx(overrides: Partial<StackMenuCtx> = {}): 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> = {}): 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 },
+1 -9
View File
@@ -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,
]);