mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('; ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user