fix(scheduled-ops): audit log text, run attribution, prune targets, and pagination (#234)

- Fix "Run Now" audit log showing "Created scheduled task" instead of "Triggered scheduled task" by adding wildcard-based route matching with specificity sorting
- Add triggered_by column to track whether runs were started by the scheduler or manually via Run Now
- Add configurable prune targets (containers, images, networks, volumes) with checkbox UI
- Add pagination to execution history with offset-based navigation
- Document Run Now behavior on disabled tasks and add screenshots
This commit is contained in:
Anso
2026-03-29 01:01:45 -04:00
committed by GitHub
parent e756620e29
commit 330eec4bff
9 changed files with 180 additions and 30 deletions
+9 -2
View File
@@ -13,9 +13,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
### Fixed
* **scheduled-operations:** Team Pro feature for scheduling recurring Docker operations (stack restarts, fleet snapshots, system prunes) via cron expressions with full execution history logging. Includes new SchedulerService, CRUD API endpoints, and a dedicated UI section.
* **scheduled-ops:** fix "Run Now" audit log entry incorrectly showing "Created scheduled task" instead of "Triggered scheduled task"
* **scheduled-ops:** add `triggered_by` attribution to run records distinguishing scheduler vs manual executions
* **scheduled-ops:** add pagination to execution history view
* **scheduled-ops:** make system prune targets configurable (containers, images, networks, volumes)
### Docs
* **scheduled-ops:** add screenshots and document Run Now behavior for disabled tasks
## [0.14.2](https://github.com/AnsoCode/Sencho/compare/v0.14.1...v0.14.2) (2026-03-29)
+62 -13
View File
@@ -686,6 +686,7 @@ const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'DELETE /sso/config': 'Deleted SSO configuration',
'POST /api-tokens': 'Created API token',
'DELETE /api-tokens': 'Revoked API token',
'POST /scheduled-tasks/*/run': 'Triggered scheduled task',
'POST /scheduled-tasks': 'Created scheduled task',
'PUT /scheduled-tasks': 'Updated scheduled task',
'DELETE /scheduled-tasks': 'Deleted scheduled task',
@@ -693,15 +694,45 @@ const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
};
function getAuditSummary(method: string, apiPath: string): string {
// Try exact prefix matches from most specific to least
const normalized = apiPath.replace(/^\//, '');
for (const [pattern, summary] of Object.entries(AUDIT_ROUTE_SUMMARIES)) {
const [pMethod, pPath] = pattern.split(' ');
if (method === pMethod && normalized.startsWith(pPath.replace(/^\//, ''))) {
// Extract resource name from path if available (e.g., /stacks/myapp → "myapp")
const rest = normalized.slice(pPath.replace(/^\//, '').length).replace(/^\//, '');
const resourceName = rest.split('/')[0];
return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary;
const normalizedSegments = normalized.split('/');
// Sort patterns by segment count descending (most specific first)
const sortedEntries = Object.entries(AUDIT_ROUTE_SUMMARIES)
.sort((a, b) => b[0].split('/').length - a[0].split('/').length);
for (const [pattern, summary] of sortedEntries) {
const spaceIdx = pattern.indexOf(' ');
const pMethod = pattern.slice(0, spaceIdx);
const pPath = pattern.slice(spaceIdx + 1).replace(/^\//, '');
if (method !== pMethod) continue;
const patternSegments = pPath.split('/');
const hasWildcard = patternSegments.includes('*');
if (hasWildcard) {
// Wildcard matching: exact segment count, '*' matches any single segment
if (patternSegments.length > normalizedSegments.length) continue;
let match = true;
let resourceName = '';
for (let i = 0; i < patternSegments.length; i++) {
if (patternSegments[i] === '*') {
resourceName = resourceName || normalizedSegments[i];
} else if (patternSegments[i] !== normalizedSegments[i]) {
match = false;
break;
}
}
if (match) {
return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary;
}
} else {
// Prefix matching (original behavior)
if (normalized.startsWith(pPath)) {
const rest = normalized.slice(pPath.length).replace(/^\//, '');
const resourceName = rest.split('/')[0];
return resourceName ? `${summary}: ${decodeURIComponent(resourceName)}` : summary;
}
}
}
return `${method} /api/${normalized}`;
@@ -3388,7 +3419,7 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requireTeamPro(req, res)) return;
try {
const { name, target_type, target_id, node_id, action, cron_expression, enabled } = req.body;
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets } = req.body;
if (!name || typeof name !== 'string') {
res.status(400).json({ error: 'Name is required' }); return;
@@ -3412,6 +3443,13 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
if (target_type === 'stack' && (!target_id || !node_id)) {
res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return;
}
// Validate prune targets
const validPruneTargets = ['containers', 'images', 'networks', 'volumes'];
if (prune_targets !== undefined && prune_targets !== null) {
if (!Array.isArray(prune_targets) || prune_targets.length === 0 || !prune_targets.every((t: string) => validPruneTargets.includes(t))) {
res.status(400).json({ error: 'prune_targets must be a non-empty array of: containers, images, networks, volumes' }); return;
}
}
// Validate cron expression
try { CronExpressionParser.parse(cron_expression); } catch {
res.status(400).json({ error: 'Invalid cron expression.' }); return;
@@ -3436,6 +3474,7 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
next_run_at: nextRun,
last_status: null,
last_error: null,
prune_targets: prune_targets ? JSON.stringify(prune_targets) : null,
});
const task = DatabaseService.getInstance().getScheduledTask(id);
@@ -3472,7 +3511,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 } = req.body;
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets } = req.body;
if (target_type && !['stack', 'fleet', 'system'].includes(target_type)) {
res.status(400).json({ error: 'Invalid target_type' }); return;
@@ -3493,6 +3532,14 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
res.status(400).json({ error: 'Prune action requires target_type "system".' }); return;
}
// Validate prune targets
const validPruneTargets = ['containers', 'images', 'networks', 'volumes'];
if (prune_targets !== undefined && prune_targets !== null) {
if (!Array.isArray(prune_targets) || prune_targets.length === 0 || !prune_targets.every((t: string) => validPruneTargets.includes(t))) {
res.status(400).json({ error: 'prune_targets must be a non-empty array of: containers, images, networks, volumes' }); return;
}
}
if (cron_expression) {
try { CronExpressionParser.parse(cron_expression); } catch {
res.status(400).json({ error: 'Invalid cron expression.' }); return;
@@ -3507,6 +3554,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
if (action !== undefined) updates.action = action;
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;
// Recalculate next_run if cron changed or if enabling
const finalCron = cron_expression || existing.cron_expression;
@@ -3606,9 +3654,10 @@ app.get('/api/scheduled-tasks/:id/runs', (req: Request, res: Response): void =>
const existing = db.getScheduledTask(id);
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
const runs = db.getScheduledTaskRuns(id, limit);
res.json(runs);
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100);
const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0);
const result = db.getScheduledTaskRuns(id, limit, offset);
res.json(result);
} catch (error) {
console.error('[ScheduledTasks] Runs error:', error);
res.status(500).json({ error: 'Failed to fetch task runs' });
+16 -6
View File
@@ -153,6 +153,7 @@ export interface ScheduledTask {
next_run_at: number | null;
last_status: string | null;
last_error: string | null;
prune_targets: string | null;
}
export interface ScheduledTaskRun {
@@ -163,6 +164,7 @@ export interface ScheduledTaskRun {
status: 'running' | 'success' | 'failure';
output: string | null;
error: string | null;
triggered_by: 'scheduler' | 'manual';
}
export class DatabaseService {
@@ -395,6 +397,10 @@ export class DatabaseService {
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
// Scheduled operations migrations
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
maybeAddCol('scheduled_tasks', 'prune_targets', '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'];
for (const col of legacyCols) {
@@ -1133,16 +1139,20 @@ export class DatabaseService {
).all(now) as ScheduledTask[];
}
public getScheduledTaskRuns(taskId: number, limit = 50): ScheduledTaskRun[] {
return this.db.prepare(
'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC LIMIT ?'
).all(taskId, limit) as ScheduledTaskRun[];
public getScheduledTaskRuns(taskId: number, limit = 20, offset = 0): { runs: ScheduledTaskRun[]; total: number } {
const runs = this.db.prepare(
'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?'
).all(taskId, limit, offset) as ScheduledTaskRun[];
const { total } = this.db.prepare(
'SELECT COUNT(*) as total FROM scheduled_task_runs WHERE task_id = ?'
).get(taskId) as { total: number };
return { runs, total };
}
public createScheduledTaskRun(run: Omit<ScheduledTaskRun, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO scheduled_task_runs (task_id, started_at, completed_at, status, output, error) VALUES (?, ?, ?, ?, ?, ?)'
).run(run.task_id, run.started_at, run.completed_at, run.status, run.output, run.error);
'INSERT INTO scheduled_task_runs (task_id, started_at, completed_at, status, output, error, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(run.task_id, run.started_at, run.completed_at, run.status, run.output, run.error, run.triggered_by);
return result.lastInsertRowid as number;
}
+10 -3
View File
@@ -67,6 +67,8 @@ export class SchedulerService {
}
}
// Intentionally allows triggering disabled tasks — useful for testing before enabling a schedule.
// Manual triggers are attributed as 'manual' in the run record (see triggered_by column).
public async triggerTask(taskId: number): Promise<void> {
const db = DatabaseService.getInstance();
const task = db.getScheduledTask(taskId);
@@ -74,13 +76,13 @@ export class SchedulerService {
if (this.runningTasks.has(task.id)) throw new Error('Task is already running');
this.runningTasks.add(task.id);
try {
await this.executeTask(task);
await this.executeTask(task, 'manual');
} finally {
this.runningTasks.delete(task.id);
}
}
private async executeTask(task: ScheduledTask): Promise<void> {
private async executeTask(task: ScheduledTask, triggeredBy: 'scheduler' | 'manual' = 'scheduler'): Promise<void> {
const db = DatabaseService.getInstance();
const runId = db.createScheduledTaskRun({
task_id: task.id,
@@ -89,6 +91,7 @@ export class SchedulerService {
status: 'running',
output: null,
error: null,
triggered_by: triggeredBy,
});
try {
@@ -297,7 +300,11 @@ export class SchedulerService {
private async executePrune(task: ScheduledTask): Promise<string> {
const nodeId = task.node_id ?? NodeRegistry.getInstance().getDefaultNodeId();
const docker = DockerController.getInstance(nodeId);
const targets = ['containers', 'images', 'networks', 'volumes'] as const;
const allTargets = ['containers', 'images', 'networks', 'volumes'] as const;
type PruneTarget = typeof allTargets[number];
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 results: string[] = [];
for (const target of targets) {
+16 -1
View File
@@ -12,13 +12,17 @@ description: Automate recurring Docker operations like stack restarts, fleet sna
Scheduled Operations lets you automate recurring maintenance tasks across your infrastructure. Define a cron schedule, choose an action, and Sencho handles the rest - including a full execution history log so you always know what ran and when.
<Frame>
<img src="/images/scheduled-operations/overview.png" alt="Scheduled operations list view showing tasks with status, schedule, and actions" />
</Frame>
## Supported Actions
| Action | Target | Description |
|--------|--------|-------------|
| **Restart Stack** | A specific stack on a specific node | Restarts all 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 unused containers, images, networks, and volumes |
| **System Prune** | A specific node (or the default node) | Prunes selected resources (containers, images, networks, volumes — all by default) |
## Creating a Scheduled Task
@@ -28,10 +32,15 @@ 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.
- **Prune Targets** - if you chose System Prune, select which resources to prune (containers, images, networks, volumes). All are selected by default.
- **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**.
<Frame>
<img src="/images/scheduled-operations/create-dialog.png" alt="Create scheduled task dialog with action, cron expression, and prune target options" />
</Frame>
## Cron Expression Reference
Sencho uses standard 5-field cron expressions:
@@ -59,6 +68,7 @@ Sencho uses standard 5-field cron expressions:
## Managing Tasks
- **Enable/Disable** - Use the toggle switch in the task list to pause or resume a schedule without deleting it.
- **Run Now** - Click the play icon to immediately execute a task. This works even on disabled tasks, allowing you to test a schedule before enabling it. Manual runs are labeled "Manual" in the execution history.
- **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.
@@ -67,12 +77,17 @@ Sencho uses standard 5-field cron expressions:
Click the history icon on any task to view its execution log. Each entry shows:
- **Timestamp** - when the task ran.
- **Source** - whether the run was triggered by the scheduler or manually via Run Now.
- **Status** - success or failure.
- **Duration** - how long the execution took.
- **Details** - output message or error description.
Execution history is retained for 30 days.
<Frame>
<img src="/images/scheduled-operations/run-history.png" alt="Execution history showing run source, status, duration, and details with pagination" />
</Frame>
## How It Works
The Scheduler Service runs in the background and checks for due tasks every 60 seconds. When a task's next run time has passed:
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -10,7 +10,8 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
import { apiFetch } from '@/lib/api';
import cronstrue from 'cronstrue';
@@ -31,6 +32,7 @@ interface ScheduledTask {
next_run_at: number | null;
last_status: string | null;
last_error: string | null;
prune_targets: string | null;
}
interface TaskRun {
@@ -41,6 +43,7 @@ interface TaskRun {
status: 'running' | 'success' | 'failure';
output: string | null;
error: string | null;
triggered_by: 'scheduler' | 'manual';
}
interface NodeOption {
@@ -84,8 +87,12 @@ export default function ScheduledOperationsView() {
const [formNodeId, setFormNodeId] = useState('');
const [formCron, setFormCron] = useState('0 3 * * *');
const [formEnabled, setFormEnabled] = useState(true);
const [formPruneTargets, setFormPruneTargets] = useState<string[]>(['containers', 'images', 'networks', 'volumes']);
const [saving, setSaving] = useState(false);
const [runningTaskId, setRunningTaskId] = useState<number | null>(null);
const [runsPage, setRunsPage] = useState(1);
const [runsTotal, setRunsTotal] = useState(0);
const runsLimit = 20;
// Available stacks and nodes for selection
const [stacks, setStacks] = useState<string[]>([]);
@@ -142,6 +149,7 @@ export default function ScheduledOperationsView() {
setFormNodeId('');
setFormCron('0 3 * * *');
setFormEnabled(true);
setFormPruneTargets(['containers', 'images', 'networks', 'volumes']);
setDialogOpen(true);
};
@@ -153,6 +161,9 @@ export default function ScheduledOperationsView() {
setFormNodeId(task.node_id != null ? String(task.node_id) : '');
setFormCron(task.cron_expression);
setFormEnabled(task.enabled === 1);
setFormPruneTargets(
task.prune_targets ? JSON.parse(task.prune_targets) : ['containers', 'images', 'networks', 'volumes']
);
setDialogOpen(true);
};
@@ -172,6 +183,9 @@ export default function ScheduledOperationsView() {
body.target_id = formTargetId;
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
}
if (formAction === 'prune' && formPruneTargets.length > 0) {
body.prune_targets = formPruneTargets;
}
setSaving(true);
try {
@@ -226,13 +240,17 @@ export default function ScheduledOperationsView() {
}
};
const openRuns = async (task: ScheduledTask) => {
const openRuns = async (task: ScheduledTask, page = 1) => {
setRunsTask(task);
setRunsPage(page);
setRunsLoading(true);
const offset = (page - 1) * runsLimit;
try {
const res = await apiFetch(`/scheduled-tasks/${task.id}/runs?limit=50`, { localOnly: true });
const res = await apiFetch(`/scheduled-tasks/${task.id}/runs?limit=${runsLimit}&offset=${offset}`, { localOnly: true });
if (res.ok) {
setRuns(await res.json());
const data = await res.json();
setRuns(data.runs);
setRunsTotal(data.total);
}
} catch {
// Non-critical
@@ -419,6 +437,27 @@ export default function ScheduledOperationsView() {
</>
)}
{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>
</div>
)}
<div className="space-y-2">
<Label>Cron Expression</Label>
<Input
@@ -437,7 +476,7 @@ export default function ScheduledOperationsView() {
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving || !formName || !formCron || (targetType === 'stack' && (!formTargetId || !formNodeId))}>
<Button onClick={handleSave} disabled={saving || !formName || !formCron || (targetType === 'stack' && (!formTargetId || !formNodeId)) || (formAction === 'prune' && formPruneTargets.length === 0)}>
{saving ? 'Saving...' : editingTask ? 'Update' : 'Create'}
</Button>
</DialogFooter>
@@ -474,10 +513,12 @@ export default function ScheduledOperationsView() {
) : runs.length === 0 ? (
<div className="text-center text-muted-foreground py-8">No executions yet.</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Source</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Details</TableHead>
@@ -493,6 +534,11 @@ export default function ScheduledOperationsView() {
<TableCell className="text-xs font-mono text-muted-foreground">
{new Date(run.started_at).toLocaleString()}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{run.triggered_by === 'manual' ? 'Manual' : 'Scheduled'}
</Badge>
</TableCell>
<TableCell>
{run.status === 'success' ? (
<Badge className="bg-green-500/10 text-green-500 border-green-500/20">Success</Badge>
@@ -511,6 +557,22 @@ export default function ScheduledOperationsView() {
})}
</TableBody>
</Table>
{Math.ceil(runsTotal / runsLimit) > 1 && runsTask && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-muted-foreground">
Page {runsPage} of {Math.ceil(runsTotal / runsLimit)}
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => openRuns(runsTask, runsPage - 1)} disabled={runsPage <= 1}>
<ChevronLeft className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => openRuns(runsTask, runsPage + 1)} disabled={runsPage >= Math.ceil(runsTotal / runsLimit)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
</SheetContent>