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
+11
View File
@@ -13,6 +13,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
* **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.
+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('; ')}`;
}
}
+43 -4
View File
@@ -20,9 +20,9 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i
| 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 |
| **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
@@ -32,7 +32,9 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i
- **Name** - a descriptive label (e.g. "Nightly staging restart").
- **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.
- **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.
- **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.
- **Enabled** - toggle the task on or off.
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" />
</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
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.
- **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
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.
### 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>
<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>
## 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.
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.
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

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 { Label } from '@/components/ui/label';
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 { apiFetch } from '@/lib/api';
import cronstrue from 'cronstrue';
@@ -33,6 +33,8 @@ interface ScheduledTask {
last_status: string | null;
last_error: string | null;
prune_targets: string | null;
target_services: string | null;
prune_label_filter: string | null;
}
interface TaskRun {
@@ -88,6 +90,9 @@ export default function ScheduledOperationsView() {
const [formCron, setFormCron] = useState('0 3 * * *');
const [formEnabled, setFormEnabled] = useState(true);
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 [runningTaskId, setRunningTaskId] = useState<number | null>(null);
const [runsPage, setRunsPage] = useState(1);
@@ -141,6 +146,26 @@ export default function ScheduledOperationsView() {
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 = () => {
setEditingTask(null);
setFormName('');
@@ -150,6 +175,8 @@ export default function ScheduledOperationsView() {
setFormCron('0 3 * * *');
setFormEnabled(true);
setFormPruneTargets(['containers', 'images', 'networks', 'volumes']);
setFormTargetServices([]);
setFormPruneLabelFilter('');
setDialogOpen(true);
};
@@ -164,6 +191,10 @@ export default function ScheduledOperationsView() {
setFormPruneTargets(
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);
};
@@ -186,6 +217,12 @@ export default function ScheduledOperationsView() {
if (formAction === 'prune' && formPruneTargets.length > 0) {
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);
try {
@@ -332,7 +369,11 @@ export default function ScheduledOperationsView() {
</Badge>
</TableCell>
<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>
<div className="text-sm">{getCronDescription(task.cron_expression)}</div>
@@ -394,7 +435,7 @@ export default function ScheduledOperationsView() {
<div className="space-y-2">
<Label>Action</Label>
<Select value={formAction} onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); }}>
<Select value={formAction} onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
@@ -434,28 +475,60 @@ export default function ScheduledOperationsView() {
</SelectContent>
</Select>
</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' && (
<div className="space-y-2">
<Label>Prune Targets</Label>
<div className="grid grid-cols-2 gap-2">
{['containers', 'images', 'networks', 'volumes'].map(target => (
<label key={target} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={formPruneTargets.includes(target)}
onCheckedChange={(checked) => {
setFormPruneTargets(prev =>
checked ? [...prev, target] : prev.filter(t => t !== target)
);
}}
/>
{target.charAt(0).toUpperCase() + target.slice(1)}
</label>
))}
<>
<div className="space-y-2">
<Label>Prune Targets</Label>
<div className="grid grid-cols-2 gap-2">
{['containers', 'images', 'networks', 'volumes'].map(target => (
<label key={target} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={formPruneTargets.includes(target)}
onCheckedChange={(checked) => {
setFormPruneTargets(prev =>
checked ? [...prev, target] : prev.filter(t => t !== target)
);
}}
/>
{target.charAt(0).toUpperCase() + target.slice(1)}
</label>
))}
</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">
@@ -505,7 +578,19 @@ export default function ScheduledOperationsView() {
<Sheet open={!!runsTask} onOpenChange={(open) => { if (!open) setRunsTask(null); }}>
<SheetContent className="sm:max-w-xl">
<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>
<div className="mt-4">
{runsLoading ? (