feat(scheduled-ops): add failure notifications, granular targeting, and history export (#286)

- Dispatch error alerts via NotificationService when scheduled tasks fail,
  with info-level recovery notifications when a previously-failing task succeeds
- Per-service restart targeting: scheduled stack restarts can target specific
  services instead of restarting the entire stack
- Prune label filter: scheduled prune operations can be scoped to resources
  matching a specific Docker label
- CSV export button in the execution history panel for one-click download
- Fix: prune_targets was silently dropped on task creation (missing in INSERT)
This commit is contained in:
Anso
2026-03-31 09:51:53 -04:00
committed by GitHub
parent 633208185d
commit eccdd1b879
12 changed files with 326 additions and 40 deletions
+100 -2
View File
@@ -2682,6 +2682,22 @@ app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response)
}
});
app.get('/api/stacks/:stackName/services', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
const content = await FileSystemService.getInstance(req.nodeId).getStackContent(stackName);
const parsed = YAML.parse(content);
const services = parsed?.services ? Object.keys(parsed.services) : [];
res.json(services);
} catch (error) {
console.error('[Stacks] Failed to fetch services:', error);
res.status(500).json({ error: 'Failed to fetch services' });
}
});
app.get('/api/containers/:id/logs', async (req: Request, res: Response) => {
try {
const id = req.params.id as string;
@@ -3691,7 +3707,7 @@ 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 } = req.body;
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter } = req.body;
if (!name || typeof name !== 'string') {
res.status(400).json({ error: 'Name is required' }); return;
@@ -3722,6 +3738,24 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
res.status(400).json({ error: 'prune_targets must be a non-empty array of: containers, images, networks, volumes' }); return;
}
}
// Validate target_services
if (target_services !== undefined && target_services !== null) {
if (!Array.isArray(target_services) || target_services.length === 0 || !target_services.every((s: unknown) => typeof s === 'string' && s.length > 0)) {
res.status(400).json({ error: 'target_services must be a non-empty array of service name strings' }); return;
}
if (action !== 'restart' || target_type !== 'stack') {
res.status(400).json({ error: 'target_services can only be used with restart action on stack target' }); return;
}
}
// Validate prune_label_filter
if (prune_label_filter !== undefined && prune_label_filter !== null) {
if (typeof prune_label_filter !== 'string' || prune_label_filter.trim().length === 0) {
res.status(400).json({ error: 'prune_label_filter must be a non-empty string' }); return;
}
if (action !== 'prune') {
res.status(400).json({ error: 'prune_label_filter can only be used with prune action' }); return;
}
}
// Validate cron expression
try { CronExpressionParser.parse(cron_expression); } catch {
res.status(400).json({ error: 'Invalid cron expression.' }); return;
@@ -3747,6 +3781,8 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
last_status: null,
last_error: null,
prune_targets: prune_targets ? JSON.stringify(prune_targets) : null,
target_services: target_services ? JSON.stringify(target_services) : null,
prune_label_filter: prune_label_filter ? prune_label_filter.trim() : null,
});
const task = DatabaseService.getInstance().getScheduledTask(id);
@@ -3783,7 +3819,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets } = req.body;
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;
@@ -3811,6 +3847,24 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
res.status(400).json({ error: 'prune_targets must be a non-empty array of: containers, images, networks, volumes' }); return;
}
}
// Validate target_services
if (target_services !== undefined && target_services !== null) {
if (!Array.isArray(target_services) || target_services.length === 0 || !target_services.every((s: unknown) => typeof s === 'string' && s.length > 0)) {
res.status(400).json({ error: 'target_services must be a non-empty array of service name strings' }); return;
}
if (finalAction !== 'restart' || finalTargetType !== 'stack') {
res.status(400).json({ error: 'target_services can only be used with restart action on stack target' }); return;
}
}
// Validate prune_label_filter
if (prune_label_filter !== undefined && prune_label_filter !== null) {
if (typeof prune_label_filter !== 'string' || prune_label_filter.trim().length === 0) {
res.status(400).json({ error: 'prune_label_filter must be a non-empty string' }); return;
}
if (finalAction !== 'prune') {
res.status(400).json({ error: 'prune_label_filter can only be used with prune action' }); return;
}
}
if (cron_expression) {
try { CronExpressionParser.parse(cron_expression); } catch {
@@ -3827,6 +3881,8 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
if (cron_expression !== undefined) updates.cron_expression = cron_expression;
if (enabled !== undefined) updates.enabled = enabled ? 1 : 0;
if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null;
if (target_services !== undefined) updates.target_services = target_services ? JSON.stringify(target_services) : null;
if (prune_label_filter !== undefined) updates.prune_label_filter = prune_label_filter ? prune_label_filter.trim() : null;
// Recalculate next_run if cron changed or if enabling
const finalCron = cron_expression || existing.cron_expression;
@@ -3915,6 +3971,48 @@ 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;
try {
const id = parseInt(req.params.id as string, 10);
if (isNaN(id)) { res.status(400).json({ error: 'Invalid task ID' }); return; }
const db = DatabaseService.getInstance();
const task = db.getScheduledTask(id);
if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
const runs = db.getAllScheduledTaskRuns(id);
const escapeCsv = (val: string): string => {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return `"${val.replace(/"/g, '""')}"`;
}
return val;
};
const lines = ['Timestamp,Source,Status,Duration (s),Details'];
for (const run of runs) {
const timestamp = new Date(run.started_at).toISOString();
const source = run.triggered_by === 'manual' ? 'Manual' : 'Scheduled';
const status = run.status.charAt(0).toUpperCase() + run.status.slice(1);
const duration = run.completed_at && run.started_at
? ((run.completed_at - run.started_at) / 1000).toFixed(1)
: '';
const details = run.error || run.output || '';
lines.push(`${escapeCsv(timestamp)},${escapeCsv(source)},${escapeCsv(status)},${escapeCsv(duration)},${escapeCsv(details)}`);
}
const safeName = task.name.replace(/[^a-zA-Z0-9_-]/g, '_');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="task-${safeName}-history.csv"`);
res.send(lines.join('\n'));
} catch (error) {
console.error('[ScheduledTasks] Export error:', error);
res.status(500).json({ error: 'Failed to export task runs' });
}
});
app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireAdmiral(req, res)) return;
+15 -2
View File
@@ -166,6 +166,8 @@ export interface ScheduledTask {
last_status: string | null;
last_error: string | null;
prune_targets: string | null;
target_services: string | null;
prune_label_filter: string | null;
}
export interface ScheduledTaskRun {
@@ -428,6 +430,8 @@ export class DatabaseService {
// Scheduled operations migrations
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'target_services', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'prune_label_filter', 'TEXT DEFAULT NULL');
// Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written)
const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key'];
@@ -1241,12 +1245,13 @@ export class DatabaseService {
public createScheduledTask(task: Omit<ScheduledTask, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
task.name, task.target_type, task.target_id, task.node_id,
task.action, task.cron_expression, task.enabled, task.created_by,
task.created_at, task.updated_at, task.last_run_at, task.next_run_at,
task.last_status, task.last_error
task.last_status, task.last_error, task.prune_targets, task.target_services,
task.prune_label_filter
);
return result.lastInsertRowid as number;
}
@@ -1261,6 +1266,8 @@ export class DatabaseService {
enabled: updates.enabled, created_by: updates.created_by, updated_at: updates.updated_at,
last_run_at: updates.last_run_at, next_run_at: updates.next_run_at,
last_status: updates.last_status, last_error: updates.last_error,
prune_targets: updates.prune_targets, target_services: updates.target_services,
prune_label_filter: updates.prune_label_filter,
};
for (const [col, val] of Object.entries(map)) {
@@ -1316,6 +1323,12 @@ export class DatabaseService {
this.db.prepare(`UPDATE scheduled_task_runs SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public getAllScheduledTaskRuns(taskId: number): ScheduledTaskRun[] {
return this.db.prepare(
'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC'
).all(taskId) as ScheduledTaskRun[];
}
public cleanupOldTaskRuns(retentionDays = 30): void {
const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
this.db.prepare('DELETE FROM scheduled_task_runs WHERE started_at < ?').run(cutoff);
+21 -7
View File
@@ -103,22 +103,34 @@ class DockerController {
};
}
public async pruneSystem(target: 'containers' | 'images' | 'networks' | 'volumes') {
let result: any = {};
public async pruneSystem(target: 'containers' | 'images' | 'networks' | 'volumes', labelFilter?: string) {
let spaceReclaimed = 0;
if (target === 'containers') {
result = await this.docker.pruneContainers();
const filters: Record<string, string[]> = {};
if (labelFilter) filters.label = [labelFilter];
const r = await this.docker.pruneContainers({ filters });
spaceReclaimed = r.SpaceReclaimed || 0;
} else if (target === 'images') {
// Remove all unused images, not just dangling ones
result = await this.docker.pruneImages({ filters: { dangling: { 'false': true } } });
const filters: Record<string, string[] | Record<string, boolean>> = { dangling: { 'false': true } };
if (labelFilter) filters.label = [labelFilter];
const r = await this.docker.pruneImages({ filters });
spaceReclaimed = r.SpaceReclaimed || 0;
} else if (target === 'networks') {
result = await this.docker.pruneNetworks();
const filters: Record<string, string[]> = {};
if (labelFilter) filters.label = [labelFilter];
const r = await this.docker.pruneNetworks({ filters });
spaceReclaimed = (r as { SpaceReclaimed?: number }).SpaceReclaimed || 0;
} else if (target === 'volumes') {
result = await this.docker.pruneVolumes({ filters: { all: ['true'] } });
const filters: Record<string, string[]> = { all: ['true'] };
if (labelFilter) filters.label = [labelFilter];
const r = await this.docker.pruneVolumes({ filters });
spaceReclaimed = r.SpaceReclaimed || 0;
}
return {
success: true,
reclaimedBytes: result?.SpaceReclaimed || 0
reclaimedBytes: spaceReclaimed
};
}
@@ -346,6 +358,7 @@ class DockerController {
interface ComposeContainer {
ID?: string;
Name?: string;
Service?: string;
State?: string;
Status?: string;
Publishers?: { URL?: string, TargetPort?: number, PublishedPort?: number }[];
@@ -387,6 +400,7 @@ class DockerController {
return {
Id: c.ID || '',
Names: ['/' + (c.Name || '')], // Add leading slash to match Dockerode format
Service: c.Service || '',
State: c.State || 'unknown',
Status: c.Status || '',
Ports
+30 -4
View File
@@ -5,6 +5,7 @@ import { LicenseService } from './LicenseService';
import DockerController from './DockerController';
import { FileSystemService } from './FileSystemService';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
export class SchedulerService {
private static instance: SchedulerService;
@@ -122,6 +123,12 @@ export class SchedulerService {
output,
});
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`);
if (task.last_status === 'failure') {
NotificationService.getInstance().dispatchAlert(
'info',
`Scheduled task "${task.name}" (${task.action}) recovered successfully`
);
}
} catch (error: unknown) {
const errMsg = error instanceof Error ? error.message : String(error);
let nextRun: number | null = null;
@@ -143,6 +150,10 @@ export class SchedulerService {
error: errMsg,
});
console.error(`[SchedulerService] Task "${task.name}" (id=${task.id}) failed:`, errMsg);
NotificationService.getInstance().dispatchAlert(
'error',
`Scheduled task "${task.name}" (${task.action}) failed: ${errMsg}`
);
}
}
@@ -155,8 +166,21 @@ export class SchedulerService {
if (!containers || containers.length === 0) {
throw new Error(`No containers found for stack "${task.target_id}"`);
}
await Promise.all(containers.map(c => docker.restartContainer(c.Id)));
return `Restarted ${containers.length} container(s) in stack "${task.target_id}"`;
let filtered = containers;
if (task.target_services) {
const serviceNames: string[] = JSON.parse(task.target_services);
filtered = containers.filter(c => c.Service && serviceNames.includes(c.Service));
if (filtered.length === 0) {
throw new Error(`No containers found matching services [${serviceNames.join(', ')}] in stack "${task.target_id}"`);
}
}
await Promise.all(filtered.map(c => docker.restartContainer(c.Id)));
const servicesSuffix = task.target_services
? ` (services: ${(JSON.parse(task.target_services) as string[]).join(', ')})`
: '';
return `Restarted ${filtered.length} container(s) in stack "${task.target_id}"${servicesSuffix}`;
}
private async executeSnapshot(task: ScheduledTask): Promise<string> {
@@ -305,11 +329,12 @@ export class SchedulerService {
const targets: PruneTarget[] = task.prune_targets
? (JSON.parse(task.prune_targets) as string[]).filter((t): t is PruneTarget => allTargets.includes(t as PruneTarget))
: [...allTargets];
const labelFilter = task.prune_label_filter || undefined;
const results: string[] = [];
for (const target of targets) {
try {
const result = await docker.pruneSystem(target);
const result = await docker.pruneSystem(target, labelFilter);
results.push(`${target}: ${result.reclaimedBytes ?? 0} bytes reclaimed`);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
@@ -317,6 +342,7 @@ export class SchedulerService {
}
}
return `System prune completed: ${results.join('; ')}`;
const filterSuffix = labelFilter ? ` (label: ${labelFilter})` : '';
return `System prune completed${filterSuffix}: ${results.join('; ')}`;
}
}