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