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)
@@ -13,6 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
* **scheduled-ops:** failure notifications — dispatches error alerts through configured notification channels (Discord, Slack, webhook) when a scheduled task fails, with info-level recovery notifications when a previously-failing task succeeds again
|
||||||
|
* **scheduled-ops:** per-service restart targeting — scheduled stack restarts can now target specific services rather than restarting the entire stack
|
||||||
|
* **scheduled-ops:** prune label filter — scheduled system prune operations can be scoped to resources matching a specific Docker label (e.g. `com.docker.compose.project=mystack`)
|
||||||
|
* **scheduled-ops:** CSV export for execution history — one-click download of the full run history from the execution history panel
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
* **scheduled-ops:** `prune_targets` field was silently dropped when creating scheduled tasks due to missing column in the INSERT statement
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
* **docker:** upgrade Docker Compose from v2.40.3 to v5.1.1 to remediate 5 Go stdlib/x/crypto CVEs (CVE-2025-68121, CVE-2025-61726, CVE-2025-61729, CVE-2026-25679, CVE-2025-47913). Compose v2.40.3 was compiled with Go 1.24.9 and x/crypto 0.38.0; v5.1.1 ships Go 1.25.8 and x/crypto 0.46.0. Docker CLI remains at v29.3.1 (already patched). CVE-2026-33186 (grpc ≥1.79.3) cannot be resolved until upstream Docker/Compose releases upgrade past grpc 1.78.0.
|
* **docker:** upgrade Docker Compose from v2.40.3 to v5.1.1 to remediate 5 Go stdlib/x/crypto CVEs (CVE-2025-68121, CVE-2025-61726, CVE-2025-61729, CVE-2026-25679, CVE-2025-47913). Compose v2.40.3 was compiled with Go 1.24.9 and x/crypto 0.38.0; v5.1.1 ships Go 1.25.8 and x/crypto 0.46.0. Docker CLI remains at v29.3.1 (already patched). CVE-2026-33186 (grpc ≥1.79.3) cannot be resolved until upstream Docker/Compose releases upgrade past grpc 1.78.0.
|
||||||
|
|||||||
@@ -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) => {
|
app.get('/api/containers/:id/logs', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
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 (!requireAdmin(req, res)) return;
|
||||||
if (!requireAdmiral(req, res)) return;
|
if (!requireAdmiral(req, res)) return;
|
||||||
try {
|
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') {
|
if (!name || typeof name !== 'string') {
|
||||||
res.status(400).json({ error: 'Name is required' }); return;
|
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;
|
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
|
// Validate cron expression
|
||||||
try { CronExpressionParser.parse(cron_expression); } catch {
|
try { CronExpressionParser.parse(cron_expression); } catch {
|
||||||
res.status(400).json({ error: 'Invalid cron expression.' }); return;
|
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_status: null,
|
||||||
last_error: null,
|
last_error: null,
|
||||||
prune_targets: prune_targets ? JSON.stringify(prune_targets) : 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);
|
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);
|
const existing = db.getScheduledTask(id);
|
||||||
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
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)) {
|
if (target_type && !['stack', 'fleet', 'system'].includes(target_type)) {
|
||||||
res.status(400).json({ error: 'Invalid target_type' }); return;
|
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;
|
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) {
|
if (cron_expression) {
|
||||||
try { CronExpressionParser.parse(cron_expression); } catch {
|
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 (cron_expression !== undefined) updates.cron_expression = cron_expression;
|
||||||
if (enabled !== undefined) updates.enabled = enabled ? 1 : 0;
|
if (enabled !== undefined) updates.enabled = enabled ? 1 : 0;
|
||||||
if (prune_targets !== undefined) updates.prune_targets = prune_targets ? JSON.stringify(prune_targets) : null;
|
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
|
// Recalculate next_run if cron changed or if enabling
|
||||||
const finalCron = cron_expression || existing.cron_expression;
|
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 => {
|
app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void => {
|
||||||
if (!requireAdmin(req, res)) return;
|
if (!requireAdmin(req, res)) return;
|
||||||
if (!requireAdmiral(req, res)) return;
|
if (!requireAdmiral(req, res)) return;
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export interface ScheduledTask {
|
|||||||
last_status: string | null;
|
last_status: string | null;
|
||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
prune_targets: string | null;
|
prune_targets: string | null;
|
||||||
|
target_services: string | null;
|
||||||
|
prune_label_filter: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduledTaskRun {
|
export interface ScheduledTaskRun {
|
||||||
@@ -428,6 +430,8 @@ export class DatabaseService {
|
|||||||
// Scheduled operations migrations
|
// Scheduled operations migrations
|
||||||
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
|
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
|
||||||
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
|
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)
|
// 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'];
|
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 {
|
public createScheduledTask(task: Omit<ScheduledTask, 'id'>): number {
|
||||||
const result = this.db.prepare(
|
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(
|
).run(
|
||||||
task.name, task.target_type, task.target_id, task.node_id,
|
task.name, task.target_type, task.target_id, task.node_id,
|
||||||
task.action, task.cron_expression, task.enabled, task.created_by,
|
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.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;
|
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,
|
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_run_at: updates.last_run_at, next_run_at: updates.next_run_at,
|
||||||
last_status: updates.last_status, last_error: updates.last_error,
|
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)) {
|
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);
|
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 {
|
public cleanupOldTaskRuns(retentionDays = 30): void {
|
||||||
const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
|
const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
|
||||||
this.db.prepare('DELETE FROM scheduled_task_runs WHERE started_at < ?').run(cutoff);
|
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') {
|
public async pruneSystem(target: 'containers' | 'images' | 'networks' | 'volumes', labelFilter?: string) {
|
||||||
let result: any = {};
|
let spaceReclaimed = 0;
|
||||||
if (target === 'containers') {
|
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') {
|
} else if (target === 'images') {
|
||||||
// Remove all unused images, not just dangling ones
|
// 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') {
|
} 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') {
|
} 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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
reclaimedBytes: result?.SpaceReclaimed || 0
|
reclaimedBytes: spaceReclaimed
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +358,7 @@ class DockerController {
|
|||||||
interface ComposeContainer {
|
interface ComposeContainer {
|
||||||
ID?: string;
|
ID?: string;
|
||||||
Name?: string;
|
Name?: string;
|
||||||
|
Service?: string;
|
||||||
State?: string;
|
State?: string;
|
||||||
Status?: string;
|
Status?: string;
|
||||||
Publishers?: { URL?: string, TargetPort?: number, PublishedPort?: number }[];
|
Publishers?: { URL?: string, TargetPort?: number, PublishedPort?: number }[];
|
||||||
@@ -387,6 +400,7 @@ class DockerController {
|
|||||||
return {
|
return {
|
||||||
Id: c.ID || '',
|
Id: c.ID || '',
|
||||||
Names: ['/' + (c.Name || '')], // Add leading slash to match Dockerode format
|
Names: ['/' + (c.Name || '')], // Add leading slash to match Dockerode format
|
||||||
|
Service: c.Service || '',
|
||||||
State: c.State || 'unknown',
|
State: c.State || 'unknown',
|
||||||
Status: c.Status || '',
|
Status: c.Status || '',
|
||||||
Ports
|
Ports
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { LicenseService } from './LicenseService';
|
|||||||
import DockerController from './DockerController';
|
import DockerController from './DockerController';
|
||||||
import { FileSystemService } from './FileSystemService';
|
import { FileSystemService } from './FileSystemService';
|
||||||
import { NodeRegistry } from './NodeRegistry';
|
import { NodeRegistry } from './NodeRegistry';
|
||||||
|
import { NotificationService } from './NotificationService';
|
||||||
|
|
||||||
export class SchedulerService {
|
export class SchedulerService {
|
||||||
private static instance: SchedulerService;
|
private static instance: SchedulerService;
|
||||||
@@ -122,6 +123,12 @@ export class SchedulerService {
|
|||||||
output,
|
output,
|
||||||
});
|
});
|
||||||
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`);
|
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) {
|
} catch (error: unknown) {
|
||||||
const errMsg = error instanceof Error ? error.message : String(error);
|
const errMsg = error instanceof Error ? error.message : String(error);
|
||||||
let nextRun: number | null = null;
|
let nextRun: number | null = null;
|
||||||
@@ -143,6 +150,10 @@ export class SchedulerService {
|
|||||||
error: errMsg,
|
error: errMsg,
|
||||||
});
|
});
|
||||||
console.error(`[SchedulerService] Task "${task.name}" (id=${task.id}) failed:`, 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) {
|
if (!containers || containers.length === 0) {
|
||||||
throw new Error(`No containers found for stack "${task.target_id}"`);
|
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> {
|
private async executeSnapshot(task: ScheduledTask): Promise<string> {
|
||||||
@@ -305,11 +329,12 @@ export class SchedulerService {
|
|||||||
const targets: PruneTarget[] = task.prune_targets
|
const targets: PruneTarget[] = task.prune_targets
|
||||||
? (JSON.parse(task.prune_targets) as string[]).filter((t): t is PruneTarget => allTargets.includes(t as PruneTarget))
|
? (JSON.parse(task.prune_targets) as string[]).filter((t): t is PruneTarget => allTargets.includes(t as PruneTarget))
|
||||||
: [...allTargets];
|
: [...allTargets];
|
||||||
|
const labelFilter = task.prune_label_filter || undefined;
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
|
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
try {
|
try {
|
||||||
const result = await docker.pruneSystem(target);
|
const result = await docker.pruneSystem(target, labelFilter);
|
||||||
results.push(`${target}: ${result.reclaimedBytes ?? 0} bytes reclaimed`);
|
results.push(`${target}: ${result.reclaimedBytes ?? 0} bytes reclaimed`);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const msg = error instanceof Error ? error.message : String(error);
|
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('; ')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i
|
|||||||
|
|
||||||
| Action | Target | Description |
|
| Action | Target | Description |
|
||||||
|--------|--------|-------------|
|
|--------|--------|-------------|
|
||||||
| **Restart Stack** | A specific stack on a specific node | Restarts all containers in the stack via the Docker Engine API |
|
| **Restart Stack** | A specific stack (or specific services within it) on a specific node | Restarts all or selected containers in the stack via the Docker Engine API |
|
||||||
| **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files |
|
| **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files |
|
||||||
| **System Prune** | A specific node (or the default node) | Prunes selected resources (containers, images, networks, volumes — all by default) |
|
| **System Prune** | A specific node (or the default node) | Prunes selected resources, optionally filtered by Docker label |
|
||||||
|
|
||||||
## Creating a Scheduled Task
|
## Creating a Scheduled Task
|
||||||
|
|
||||||
@@ -32,7 +32,9 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i
|
|||||||
- **Name** - a descriptive label (e.g. "Nightly staging restart").
|
- **Name** - a descriptive label (e.g. "Nightly staging restart").
|
||||||
- **Action** - choose Restart Stack, Fleet Snapshot, or System Prune.
|
- **Action** - choose Restart Stack, Fleet Snapshot, or System Prune.
|
||||||
- **Stack / Node** - if you chose Restart Stack, select the target stack and the node it runs on.
|
- **Stack / Node** - if you chose Restart Stack, select the target stack and the node it runs on.
|
||||||
|
- **Services** - optionally select specific services within the stack to restart. Leave empty to restart all services.
|
||||||
- **Prune Targets** - if you chose System Prune, select which resources to prune (containers, images, networks, volumes). All are selected by default.
|
- **Prune Targets** - if you chose System Prune, select which resources to prune (containers, images, networks, volumes). All are selected by default.
|
||||||
|
- **Label Filter** - optionally filter prune operations to resources matching a specific Docker label (e.g. `com.docker.compose.project=mystack`).
|
||||||
- **Cron Expression** - standard 5-field cron format. A human-readable preview appears below the input.
|
- **Cron Expression** - standard 5-field cron format. A human-readable preview appears below the input.
|
||||||
- **Enabled** - toggle the task on or off.
|
- **Enabled** - toggle the task on or off.
|
||||||
4. Click **Create**.
|
4. Click **Create**.
|
||||||
@@ -41,6 +43,26 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i
|
|||||||
<img src="/images/scheduled-operations/create-dialog.png" alt="Create scheduled task dialog with action, cron expression, and prune target options" />
|
<img src="/images/scheduled-operations/create-dialog.png" alt="Create scheduled task dialog with action, cron expression, and prune target options" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
|
## Granular Targeting
|
||||||
|
|
||||||
|
### Per-Service Restart
|
||||||
|
|
||||||
|
When creating a Restart Stack schedule, you can target individual services instead of restarting the entire stack. After selecting a stack, Sencho reads the compose file and displays checkboxes for each defined service. Select the services you want to restart, or leave all unchecked to restart every service in the stack.
|
||||||
|
|
||||||
|
<Frame>
|
||||||
|
<img src="/images/scheduled-operations/per-service-restart.png" alt="Service checkboxes displayed when creating a per-service restart schedule" />
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
### Prune Label Filter
|
||||||
|
|
||||||
|
When creating a System Prune schedule, you can scope the prune to resources matching a specific Docker label. This lets you target resources from a particular stack or project without affecting unrelated containers, images, or volumes.
|
||||||
|
|
||||||
|
Enter a label in `key=value` format (e.g. `com.docker.compose.project=mystack`). Leave the field empty to prune all unused resources of the selected types.
|
||||||
|
|
||||||
|
<Frame>
|
||||||
|
<img src="/images/scheduled-operations/prune-label-filter.png" alt="Label filter input for scoping prune operations to specific Docker labels" />
|
||||||
|
</Frame>
|
||||||
|
|
||||||
## Cron Expression Reference
|
## Cron Expression Reference
|
||||||
|
|
||||||
Sencho uses standard 5-field cron expressions:
|
Sencho uses standard 5-field cron expressions:
|
||||||
@@ -72,6 +94,18 @@ Sencho uses standard 5-field cron expressions:
|
|||||||
- **Edit** - Click the pencil icon to update the task name, schedule, or target.
|
- **Edit** - Click the pencil icon to update the task name, schedule, or target.
|
||||||
- **Delete** - Click the trash icon to permanently remove the task and all its execution history.
|
- **Delete** - Click the trash icon to permanently remove the task and all its execution history.
|
||||||
|
|
||||||
|
## Failure Notifications
|
||||||
|
|
||||||
|
When a scheduled task fails, Sencho automatically dispatches an **error-level alert** through your configured notification channels (Discord, Slack, or custom webhooks). The alert includes the task name, action type, and error message so you can diagnose the issue immediately.
|
||||||
|
|
||||||
|
When a previously failing task succeeds again, Sencho sends an **info-level recovery notification** to confirm the issue is resolved. This recovery-only approach avoids notification noise from tasks that succeed on every run.
|
||||||
|
|
||||||
|
To configure notification channels, go to **Settings > Notifications**.
|
||||||
|
|
||||||
|
<Frame>
|
||||||
|
<img src="/images/scheduled-operations/failure-notification.png" alt="Failure notification alert shown in the notification bell popover" />
|
||||||
|
</Frame>
|
||||||
|
|
||||||
## Execution History
|
## Execution History
|
||||||
|
|
||||||
Click the history icon on any task to view its execution log. Each entry shows:
|
Click the history icon on any task to view its execution log. Each entry shows:
|
||||||
@@ -84,8 +118,12 @@ Click the history icon on any task to view its execution log. Each entry shows:
|
|||||||
|
|
||||||
Execution history is retained for 30 days.
|
Execution history is retained for 30 days.
|
||||||
|
|
||||||
|
### Exporting History
|
||||||
|
|
||||||
|
Click the download icon in the top-right corner of the execution history panel to export the full history as a CSV file. The export includes all runs within the 30-day retention window — not just the current page.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/scheduled-operations/run-history.png" alt="Execution history showing run source, status, duration, and details with pagination" />
|
<img src="/images/scheduled-operations/run-history.png" alt="Execution history showing run source, status, duration, and details with export button" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -95,6 +133,7 @@ The Scheduler Service runs in the background and checks for due tasks every 60 s
|
|||||||
1. The scheduler verifies your Admiral license is active.
|
1. The scheduler verifies your Admiral license is active.
|
||||||
2. It executes the configured action using the same internal services that power the UI buttons (restart, snapshot, prune).
|
2. It executes the configured action using the same internal services that power the UI buttons (restart, snapshot, prune).
|
||||||
3. Results are logged to the execution history.
|
3. Results are logged to the execution history.
|
||||||
4. The next run time is recalculated from the cron expression.
|
4. On failure, an alert is dispatched via your configured notification channels.
|
||||||
|
5. The next run time is recalculated from the cron expression.
|
||||||
|
|
||||||
If a task is still running from a previous execution, the scheduler skips it to prevent overlap.
|
If a task is still running from a previous execution, the scheduler skips it to prevent overlap.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 24 KiB |
@@ -11,7 +11,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
|
|||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import cronstrue from 'cronstrue';
|
import cronstrue from 'cronstrue';
|
||||||
@@ -33,6 +33,8 @@ interface ScheduledTask {
|
|||||||
last_status: string | null;
|
last_status: string | null;
|
||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
prune_targets: string | null;
|
prune_targets: string | null;
|
||||||
|
target_services: string | null;
|
||||||
|
prune_label_filter: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskRun {
|
interface TaskRun {
|
||||||
@@ -88,6 +90,9 @@ export default function ScheduledOperationsView() {
|
|||||||
const [formCron, setFormCron] = useState('0 3 * * *');
|
const [formCron, setFormCron] = useState('0 3 * * *');
|
||||||
const [formEnabled, setFormEnabled] = useState(true);
|
const [formEnabled, setFormEnabled] = useState(true);
|
||||||
const [formPruneTargets, setFormPruneTargets] = useState<string[]>(['containers', 'images', 'networks', 'volumes']);
|
const [formPruneTargets, setFormPruneTargets] = useState<string[]>(['containers', 'images', 'networks', 'volumes']);
|
||||||
|
const [formTargetServices, setFormTargetServices] = useState<string[]>([]);
|
||||||
|
const [formPruneLabelFilter, setFormPruneLabelFilter] = useState('');
|
||||||
|
const [availableServices, setAvailableServices] = useState<string[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [runningTaskId, setRunningTaskId] = useState<number | null>(null);
|
const [runningTaskId, setRunningTaskId] = useState<number | null>(null);
|
||||||
const [runsPage, setRunsPage] = useState(1);
|
const [runsPage, setRunsPage] = useState(1);
|
||||||
@@ -141,6 +146,26 @@ export default function ScheduledOperationsView() {
|
|||||||
fetchNodes();
|
fetchNodes();
|
||||||
}, [fetchTasks, fetchStacks, fetchNodes]);
|
}, [fetchTasks, fetchStacks, fetchNodes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (formAction !== 'restart' || !formTargetId) {
|
||||||
|
setAvailableServices([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
const fetchServices = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/stacks/${encodeURIComponent(formTargetId)}/services`);
|
||||||
|
if (res.ok && !cancelled) {
|
||||||
|
setAvailableServices(await res.json());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchServices();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [formAction, formTargetId]);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditingTask(null);
|
setEditingTask(null);
|
||||||
setFormName('');
|
setFormName('');
|
||||||
@@ -150,6 +175,8 @@ export default function ScheduledOperationsView() {
|
|||||||
setFormCron('0 3 * * *');
|
setFormCron('0 3 * * *');
|
||||||
setFormEnabled(true);
|
setFormEnabled(true);
|
||||||
setFormPruneTargets(['containers', 'images', 'networks', 'volumes']);
|
setFormPruneTargets(['containers', 'images', 'networks', 'volumes']);
|
||||||
|
setFormTargetServices([]);
|
||||||
|
setFormPruneLabelFilter('');
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -164,6 +191,10 @@ export default function ScheduledOperationsView() {
|
|||||||
setFormPruneTargets(
|
setFormPruneTargets(
|
||||||
task.prune_targets ? JSON.parse(task.prune_targets) : ['containers', 'images', 'networks', 'volumes']
|
task.prune_targets ? JSON.parse(task.prune_targets) : ['containers', 'images', 'networks', 'volumes']
|
||||||
);
|
);
|
||||||
|
setFormTargetServices(
|
||||||
|
task.target_services ? JSON.parse(task.target_services) : []
|
||||||
|
);
|
||||||
|
setFormPruneLabelFilter(task.prune_label_filter || '');
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,6 +217,12 @@ export default function ScheduledOperationsView() {
|
|||||||
if (formAction === 'prune' && formPruneTargets.length > 0) {
|
if (formAction === 'prune' && formPruneTargets.length > 0) {
|
||||||
body.prune_targets = formPruneTargets;
|
body.prune_targets = formPruneTargets;
|
||||||
}
|
}
|
||||||
|
if (formAction === 'restart' && formTargetServices.length > 0) {
|
||||||
|
body.target_services = formTargetServices;
|
||||||
|
}
|
||||||
|
if (formAction === 'prune' && formPruneLabelFilter.trim()) {
|
||||||
|
body.prune_label_filter = formPruneLabelFilter.trim();
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -332,7 +369,11 @@ export default function ScheduledOperationsView() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{task.target_type === 'stack' ? task.target_id : task.target_type}
|
{task.target_type === 'stack'
|
||||||
|
? task.target_services
|
||||||
|
? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})`
|
||||||
|
: task.target_id
|
||||||
|
: task.target_type}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="text-sm">{getCronDescription(task.cron_expression)}</div>
|
<div className="text-sm">{getCronDescription(task.cron_expression)}</div>
|
||||||
@@ -394,7 +435,7 @@ export default function ScheduledOperationsView() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Action</Label>
|
<Label>Action</Label>
|
||||||
<Select value={formAction} onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); }}>
|
<Select value={formAction} onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -434,28 +475,60 @@ export default function ScheduledOperationsView() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{formAction === 'restart' && formTargetId && availableServices.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Services <span className="text-xs text-muted-foreground">(leave empty for all)</span></Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{availableServices.map(svc => (
|
||||||
|
<label key={svc} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={formTargetServices.includes(svc)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setFormTargetServices(prev =>
|
||||||
|
checked ? [...prev, svc] : prev.filter(s => s !== svc)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-xs">{svc}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{formAction === 'prune' && (
|
{formAction === 'prune' && (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<Label>Prune Targets</Label>
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<Label>Prune Targets</Label>
|
||||||
{['containers', 'images', 'networks', 'volumes'].map(target => (
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<label key={target} className="flex items-center gap-2 text-sm cursor-pointer">
|
{['containers', 'images', 'networks', 'volumes'].map(target => (
|
||||||
<Checkbox
|
<label key={target} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
checked={formPruneTargets.includes(target)}
|
<Checkbox
|
||||||
onCheckedChange={(checked) => {
|
checked={formPruneTargets.includes(target)}
|
||||||
setFormPruneTargets(prev =>
|
onCheckedChange={(checked) => {
|
||||||
checked ? [...prev, target] : prev.filter(t => t !== target)
|
setFormPruneTargets(prev =>
|
||||||
);
|
checked ? [...prev, target] : prev.filter(t => t !== target)
|
||||||
}}
|
);
|
||||||
/>
|
}}
|
||||||
{target.charAt(0).toUpperCase() + target.slice(1)}
|
/>
|
||||||
</label>
|
{target.charAt(0).toUpperCase() + target.slice(1)}
|
||||||
))}
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
|
<Label>Label Filter <span className="text-xs text-muted-foreground">(optional)</span></Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. com.docker.compose.project=mystack"
|
||||||
|
value={formPruneLabelFilter}
|
||||||
|
onChange={e => setFormPruneLabelFilter(e.target.value)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Only prune resources matching this Docker label.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -505,7 +578,19 @@ export default function ScheduledOperationsView() {
|
|||||||
<Sheet open={!!runsTask} onOpenChange={(open) => { if (!open) setRunsTask(null); }}>
|
<Sheet open={!!runsTask} onOpenChange={(open) => { if (!open) setRunsTask(null); }}>
|
||||||
<SheetContent className="sm:max-w-xl">
|
<SheetContent className="sm:max-w-xl">
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle>Execution History - {runsTask?.name}</SheetTitle>
|
<div className="flex items-center justify-between">
|
||||||
|
<SheetTitle>Execution History - {runsTask?.name}</SheetTitle>
|
||||||
|
{runsTask && runs.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.open(`/api/scheduled-tasks/${runsTask.id}/runs/export`, '_blank')}
|
||||||
|
title="Export as CSV"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" strokeWidth={1.5} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{runsLoading ? (
|
{runsLoading ? (
|
||||||
|
|||||||