feat(auto-update): add auto-update policies and fix image update detection (#297)

* feat(auto-update): add auto-update policies and fix image update detection

Auto-Update Policies (Skipper+ tier):
- New scheduled task action type 'update' for check-then-update flow
- Dedicated AutoUpdatePoliciesView with CRUD, cron presets, and run history
- Conditional tier gating: Skipper gets auto-update, Admiral gets full scheduled ops
- Backend executeUpdate: checks digests, pulls only if newer, atomic redeploy

Image Update Detection fixes (all tiers):
- Fix stack name key mismatch: use working_dir label instead of project label
- Add 5-minute periodic frontend polling for background check results
- Replace fixed 3s timeout with polling-based manual refresh via /api/image-updates/status
- Clear update status after successful stack update

* fix(ui): remove Skipper tier badge from Auto-Update Policies header

* fix(ui): remove auto-update action from Scheduled Operations view

Admiral users have a dedicated Auto-Update view — showing update tasks
in Scheduled Operations too was confusing duplication. Each view now
owns a distinct, non-overlapping set of action types.

* fix(auto-update): fix node-stack linking and add All Stacks option

- Stack dropdown now re-fetches when node selection changes using
  fetchForNode, and resets the selected stack
- Node selector moved above stack selector with stack disabled until
  a node is picked
- Added "All Stacks" wildcard option that checks and updates every
  stack on the selected node
- Backend executeUpdate refactored to iterate over all stacks when
  target_id is "*", with per-stack error isolation

* refactor(ui): replace Select dropdowns with searchable Combobox component

Add a reusable Combobox component with inline search and use it for
Node/Stack selectors in both Auto-Update Policies and Scheduled
Operations dialogs. Also fixes node-stack linking bug where changing
node didn't update the stack list.

* fix(ui): resolve CI TypeScript errors in Combobox and ScheduledOperationsView

Add missing searchPlaceholder prop to ComboboxProps interface and remove
dead 'update' action filter that conflicted with the narrowed type union.

* fix(ui): use Geist Sans font in toast component

The toast renders via React portal on document.body, bypassing the app's
font inheritance. Add explicit font-family declaration using var(--font-sans)
to match Sencho's design system.
This commit is contained in:
Anso
2026-04-01 00:22:47 -04:00
committed by GitHub
parent d393d06885
commit 28c7a8fd54
14 changed files with 1073 additions and 57 deletions
+43 -13
View File
@@ -807,6 +807,12 @@ const requireAdmin = (req: Request, res: Response): boolean => {
return true;
};
// Tier gate for scheduled tasks: 'update' action requires Pro, everything else requires Admiral.
const requireScheduledTaskTier = (action: string, _req: Request, res: Response): boolean => {
if (action === 'update') return requirePro(_req, res);
return requireAdmiral(_req, res);
};
// --- Scoped RBAC Permission Engine (Admiral) ---
type PermissionAction =
@@ -2835,6 +2841,7 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) =>
try {
const atomic = LicenseService.getInstance().getTier() === 'pro';
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic);
DatabaseService.getInstance().clearStackUpdateStatus(stackName);
res.json({ status: 'Update completed' });
} catch (error) {
const rolledBack = LicenseService.getInstance().getTier() === 'pro';
@@ -3693,9 +3700,14 @@ app.delete('/api/api-tokens/:id', authMiddleware, async (req: Request, res: Resp
app.get('/api/scheduled-tasks', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
if (!requirePro(req, res)) return;
try {
const tasks = DatabaseService.getInstance().getScheduledTasks();
let tasks = DatabaseService.getInstance().getScheduledTasks();
// Skipper users only see 'update' tasks; Admiral sees all
const ls = LicenseService.getInstance();
if (ls.getVariant() !== 'team') {
tasks = tasks.filter(t => t.action === 'update');
}
res.json(tasks);
} catch (error) {
console.error('[ScheduledTasks] List error:', error);
@@ -3705,7 +3717,6 @@ app.get('/api/scheduled-tasks', (req: Request, res: Response): void => {
app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
try {
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body;
@@ -3715,13 +3726,18 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
if (!['stack', 'fleet', 'system'].includes(target_type)) {
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
}
if (!['restart', 'snapshot', 'prune'].includes(action)) {
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, or prune.' }); return;
if (!['restart', 'snapshot', 'prune', 'update'].includes(action)) {
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, or update.' }); return;
}
// Tier gate based on action type
if (!requireScheduledTaskTier(action, req, res)) return;
// Validate action-target combos
if (action === 'restart' && target_type !== 'stack') {
res.status(400).json({ error: 'Restart action requires target_type "stack".' }); return;
}
if (action === 'update' && target_type !== 'stack') {
res.status(400).json({ error: 'Update action requires target_type "stack".' }); return;
}
if (action === 'snapshot' && target_type !== 'fleet') {
res.status(400).json({ error: 'Snapshot action requires target_type "fleet".' }); return;
}
@@ -3795,12 +3811,13 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
app.get('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const task = DatabaseService.getInstance().getScheduledTask(id);
if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(task.action, req, res)) return;
res.json(task);
} catch (error) {
console.error('[ScheduledTasks] Get error:', error);
@@ -3810,7 +3827,7 @@ app.get('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
@@ -3818,13 +3835,14 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(existing.action, req, res)) return;
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body;
if (target_type && !['stack', 'fleet', 'system'].includes(target_type)) {
res.status(400).json({ error: 'Invalid target_type' }); return;
}
if (action && !['restart', 'snapshot', 'prune'].includes(action)) {
if (action && !['restart', 'snapshot', 'prune', 'update'].includes(action)) {
res.status(400).json({ error: 'Invalid action' }); return;
}
@@ -3833,6 +3851,9 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
if (finalAction === 'restart' && finalTargetType !== 'stack') {
res.status(400).json({ error: 'Restart action requires target_type "stack".' }); return;
}
if (finalAction === 'update' && finalTargetType !== 'stack') {
res.status(400).json({ error: 'Update action requires target_type "stack".' }); return;
}
if (finalAction === 'snapshot' && finalTargetType !== 'fleet') {
res.status(400).json({ error: 'Snapshot action requires target_type "fleet".' }); return;
}
@@ -3904,7 +3925,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
app.delete('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
@@ -3912,6 +3933,7 @@ app.delete('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(existing.action, req, res)) return;
db.deleteScheduledTask(id);
res.json({ success: true });
@@ -3923,7 +3945,7 @@ app.delete('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
@@ -3931,6 +3953,7 @@ app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(existing.action, req, res)) return;
const newEnabled = existing.enabled ? 0 : 1;
const nextRun = newEnabled ? SchedulerService.getInstance().calculateNextRun(existing.cron_expression) : null;
@@ -3951,7 +3974,7 @@ app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void
app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Promise<void> => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
@@ -3959,6 +3982,7 @@ app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Pr
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(existing.action, req, res)) return;
await SchedulerService.getInstance().triggerTask(id);
@@ -3973,7 +3997,7 @@ app.post('/api/scheduled-tasks/:id/run', async (req: Request, res: Response): Pr
app.get('/api/scheduled-tasks/:id/runs/export', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
@@ -3981,6 +4005,7 @@ app.get('/api/scheduled-tasks/:id/runs/export', (req: Request, res: Response): v
const db = DatabaseService.getInstance();
const task = db.getScheduledTask(id);
if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(task.action, req, res)) return;
const runs = db.getAllScheduledTaskRuns(id);
@@ -4015,7 +4040,7 @@ app.get('/api/scheduled-tasks/:id/runs/export', (req: Request, res: Response): v
app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
if (!requirePro(req, res)) return;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
@@ -4023,6 +4048,7 @@ app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void =>
const db = DatabaseService.getInstance();
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
if (!requireScheduledTaskTier(existing.action, req, res)) return;
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100);
const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0);
@@ -4429,6 +4455,10 @@ app.post('/api/image-updates/refresh', authMiddleware, (_req: Request, res: Resp
}
});
app.get('/api/image-updates/status', authMiddleware, (_req: Request, res: Response) => {
res.json({ checking: ImageUpdateService.getInstance().isChecking() });
});
// =========================
// Node Management API
// =========================
+5 -1
View File
@@ -155,7 +155,7 @@ export interface ScheduledTask {
target_type: 'stack' | 'fleet' | 'system';
target_id: string | null;
node_id: number | null;
action: 'restart' | 'snapshot' | 'prune';
action: 'restart' | 'snapshot' | 'prune' | 'update';
cron_expression: string;
enabled: number;
created_by: string;
@@ -851,6 +851,10 @@ export class DatabaseService {
return result;
}
public clearStackUpdateStatus(stackName: string): void {
this.db.prepare('DELETE FROM stack_update_status WHERE stack_name = ?').run(stackName);
}
// --- Webhooks ---
public getWebhooks(): Webhook[] {
+13 -3
View File
@@ -1,8 +1,10 @@
import https from 'https';
import http from 'http';
import path from 'path';
import DockerController from './DockerController';
import { DatabaseService } from './DatabaseService';
import { RegistryService } from './RegistryService';
import { NodeRegistry } from './NodeRegistry';
// ─── Image ref parsing ────────────────────────────────────────────────────────
@@ -230,14 +232,22 @@ export class ImageUpdateService {
private async checkNode(nodeId: number, db: DatabaseService) {
const docker = DockerController.getInstance(nodeId);
const containers = await docker.getAllContainers();
const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(nodeId));
// stackName → set of image refs used by that stack
// Key by directory name (matching FileSystemService.getStacks()) rather than
// com.docker.compose.project label, which diverges when compose files set `name:`.
const stackImages = new Map<string, Set<string>>();
for (const c of containers) {
const stackName: string | undefined = c.Labels?.['com.docker.compose.project'];
if (!stackName) continue;
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
if (!workingDir) continue;
// Only consider containers managed under COMPOSE_DIR
const resolved = path.resolve(workingDir);
if (resolved !== composeDir && !resolved.startsWith(composeDir + path.sep)) continue;
const stackName = path.basename(resolved);
const imageRef: string = c.Image ?? '';
if (!imageRef || imageRef.startsWith('sha256:')) continue;
@@ -270,7 +280,7 @@ export class ImageUpdateService {
}
}
private async checkImage(docker: DockerController, imageRef: string): Promise<boolean> {
public async checkImage(docker: DockerController, imageRef: string): Promise<boolean> {
const parsed = parseImageRef(imageRef);
if (!parsed) return false;
+97 -1
View File
@@ -3,7 +3,9 @@ import { DatabaseService } from './DatabaseService';
import type { ScheduledTask } from './DatabaseService';
import { LicenseService } from './LicenseService';
import DockerController from './DockerController';
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
import { ImageUpdateService } from './ImageUpdateService';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
@@ -47,7 +49,9 @@ export class SchedulerService {
this.isProcessing = true;
try {
const ls = LicenseService.getInstance();
if (ls.getTier() !== 'pro' || ls.getVariant() !== 'team') return;
const isPro = ls.getTier() === 'pro';
const isAdmiral = isPro && ls.getVariant() === 'team';
if (!isPro) return; // No scheduled tasks for non-Pro tiers
const db = DatabaseService.getInstance();
const now = Date.now();
@@ -57,6 +61,8 @@ export class SchedulerService {
db.cleanupOldTaskRuns(30);
for (const task of dueTasks) {
// Skipper users can only run 'update' tasks; other actions require Admiral
if (!isAdmiral && task.action !== 'update') continue;
if (this.runningTasks.has(task.id)) continue;
this.runningTasks.add(task.id);
this.executeTask(task).finally(() => this.runningTasks.delete(task.id));
@@ -107,6 +113,9 @@ export class SchedulerService {
case 'prune':
output = await this.executePrune(task);
break;
case 'update':
output = await this.executeUpdate(task);
break;
}
const nextRun = this.calculateNextRun(task.cron_expression);
@@ -345,4 +354,91 @@ export class SchedulerService {
const filterSuffix = labelFilter ? ` (label: ${labelFilter})` : '';
return `System prune completed${filterSuffix}: ${results.join('; ')}`;
}
private async executeUpdate(task: ScheduledTask): Promise<string> {
if (!task.target_id || task.node_id == null) {
throw new Error('Auto-update requires target_id (stack name or "*") and node_id');
}
// Resolve target stacks: "*" means all stacks on the node
let stackNames: string[];
if (task.target_id === '*') {
stackNames = await FileSystemService.getInstance(task.node_id).getStacks();
if (stackNames.length === 0) {
return 'No stacks found on node — skipped.';
}
} else {
stackNames = [task.target_id];
}
const docker = DockerController.getInstance(task.node_id);
const imageUpdateService = ImageUpdateService.getInstance();
const compose = ComposeService.getInstance(task.node_id);
const db = DatabaseService.getInstance();
const results: string[] = [];
for (const stackName of stackNames) {
try {
const output = await this.executeUpdateForStack(stackName, docker, imageUpdateService, compose, db);
results.push(output);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
results.push(`Stack "${stackName}" failed: ${msg}`);
console.error(`[SchedulerService] Auto-update failed for stack "${stackName}":`, e);
}
}
return results.join('\n');
}
private async executeUpdateForStack(
stackName: string,
docker: DockerController,
imageUpdateService: ImageUpdateService,
compose: ComposeService,
db: DatabaseService
): Promise<string> {
const containers = await docker.getContainersByStack(stackName);
if (!containers || containers.length === 0) {
return `Stack "${stackName}": no containers found — skipped.`;
}
const imageRefs = [...new Set(
containers
.map((c: { Image?: string }) => c.Image)
.filter((img): img is string => !!img && !img.startsWith('sha256:'))
)];
if (imageRefs.length === 0) {
return `Stack "${stackName}": no pullable images — skipped.`;
}
let hasUpdate = false;
const updatedImages: string[] = [];
for (const imageRef of imageRefs) {
try {
if (await imageUpdateService.checkImage(docker, imageRef)) {
hasUpdate = true;
updatedImages.push(imageRef);
}
} catch (e) {
console.warn(`[SchedulerService] Failed to check image ${imageRef}:`, e);
}
}
if (!hasUpdate) {
return `Stack "${stackName}": all images up to date.`;
}
await compose.updateStack(stackName, undefined, true);
db.clearStackUpdateStatus(stackName);
NotificationService.getInstance().dispatchAlert(
'info',
`Auto-update: stack "${stackName}" updated with new images`
);
return `Stack "${stackName}": updated (${updatedImages.join(', ')}).`;
}
}