mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
ba57c67048
PR #1497 pinned an enabled one-shot's next_run_at on creation, but the chosen instant did not survive two paths: editing reconstructed the date from the yearless cron (current year), and a disabled one-shot nulled next_run_at with no other store, so enabling it later recomputed from the cron. Both moved a future-year one-shot to a different annual occurrence than the date displayed. Persist the one-shot's absolute fire time in a dedicated run_at column (additive, nullable; recurring schedules leave it null). Create and update store run_at independently of the enabled state; next_run_at is derived from it when enabled and null while disabled, so a disabled one-shot keeps its run_at and the enable toggle restores the exact instant from the column rather than the cron. The editor reconstructs a one-shot's date from the persisted run_at, so opening and re-saving without changes preserves the originally chosen year. No behavior change for recurring schedules or fresh installs; the column is added by an additive migration safe for upgrades from v0.92.0.
558 lines
24 KiB
TypeScript
558 lines
24 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { CronExpressionParser } from 'cron-parser';
|
|
import { DatabaseService, type ScheduledTask } from '../services/DatabaseService';
|
|
import {
|
|
VALID_TARGET_TYPES,
|
|
VALID_ACTIONS,
|
|
INVALID_ACTION_MESSAGE,
|
|
validateActionTarget,
|
|
getScheduledActionDefinition,
|
|
type TargetType,
|
|
type BackendScheduledAction,
|
|
} from '../services/scheduledActionRegistry';
|
|
import { SchedulerService } from '../services/SchedulerService';
|
|
import { NotificationService } from '../services/NotificationService';
|
|
import { requireAdmin } from '../middleware/tierGates';
|
|
import { escapeCsvField } from '../utils/csv';
|
|
import { getErrorMessage } from '../utils/errors';
|
|
import { parseIntParam } from '../utils/parseIntParam';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
import { isValidStackName } from '../utils/validation';
|
|
|
|
// Frontend listeners filter on scope === 'scheduled-tasks'. Wrapped so a
|
|
// broken subscriber socket cannot turn a successful mutation into a 500.
|
|
function broadcastScheduledTasksChanged(): void {
|
|
try {
|
|
NotificationService.getInstance().broadcastEvent({
|
|
type: 'state-invalidate',
|
|
scope: 'scheduled-tasks',
|
|
ts: Date.now(),
|
|
});
|
|
} catch (err) {
|
|
console.error('[ScheduledTasks] broadcast failed:', getErrorMessage(err, String(err)));
|
|
}
|
|
}
|
|
|
|
const VALID_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'] as const;
|
|
const ERR_FLEET_NODE_REQUIRED = 'Fleet update requires node_id.';
|
|
|
|
function parsePositiveNodeId(nodeId: unknown): number | null {
|
|
if (typeof nodeId !== 'number' && typeof nodeId !== 'string') return null;
|
|
if (typeof nodeId === 'string' && nodeId.trim().length === 0) return null;
|
|
const parsedNodeId = Number(nodeId);
|
|
return Number.isInteger(parsedNodeId) && parsedNodeId > 0 ? parsedNodeId : null;
|
|
}
|
|
|
|
function actionRequiresNode(action: BackendScheduledAction): boolean {
|
|
return getScheduledActionDefinition(action)?.requiresNode === true;
|
|
}
|
|
|
|
function nodeRequirementLabel(action: BackendScheduledAction, targetType: TargetType): string {
|
|
if (action === 'scan') return 'Scan';
|
|
if (action === 'prune') return 'Prune';
|
|
if (action === 'update' && targetType === 'fleet') return 'Fleet update';
|
|
return action;
|
|
}
|
|
|
|
function localNodeRequirementLabel(action: BackendScheduledAction): string {
|
|
if (action === 'scan') return 'Scheduled vulnerability scans';
|
|
if (action === 'prune') return 'Scheduled prunes';
|
|
return `${action} tasks`;
|
|
}
|
|
|
|
function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null {
|
|
if (targetType !== 'stack') return null;
|
|
|
|
if (typeof targetId !== 'string' || !targetId.trim() || nodeId === null || nodeId === undefined) {
|
|
return 'Stack operations require target_id and node_id.';
|
|
}
|
|
|
|
if (targetId !== targetId.trim() || !isValidStackName(targetId)) {
|
|
return 'Stack target_id must be a valid stack name.';
|
|
}
|
|
|
|
if (parsePositiveNodeId(nodeId) === null) {
|
|
return 'Stack operations require a valid node_id.';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Shared guard for non-stack actions that require a node. Stack actions use
|
|
* validateStackTarget because they also require target_id.
|
|
*/
|
|
function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null {
|
|
if (targetType === 'stack') return null;
|
|
const def = getScheduledActionDefinition(action);
|
|
if (!def?.requiresNode) return null;
|
|
|
|
const labelSingular = nodeRequirementLabel(action, targetType);
|
|
const labelPlural = localNodeRequirementLabel(action);
|
|
|
|
if (nodeId == null) {
|
|
return action === 'update' && targetType === 'fleet'
|
|
? ERR_FLEET_NODE_REQUIRED
|
|
: `${labelSingular} action requires node_id.`;
|
|
}
|
|
|
|
const parsedNodeId = parsePositiveNodeId(nodeId);
|
|
if (parsedNodeId === null) return `${labelSingular} action requires a valid node_id.`;
|
|
if (def.nodeScope !== 'local') return null;
|
|
|
|
const node = DatabaseService.getInstance().getNode(parsedNodeId);
|
|
if (!node) return `${labelPlural} require an existing local node.`;
|
|
if (node.type === 'remote') return `${labelPlural} currently require a local node.`;
|
|
return null;
|
|
}
|
|
|
|
/** Shared validation for prune_targets, target_services, prune_label_filter. Returns an error string or null. */
|
|
function validateOptionalFields(
|
|
action: BackendScheduledAction,
|
|
targetType: TargetType,
|
|
prune_targets: unknown,
|
|
target_services: unknown,
|
|
prune_label_filter: unknown,
|
|
): string | null {
|
|
if (prune_targets !== undefined && prune_targets !== null) {
|
|
if (!Array.isArray(prune_targets) || prune_targets.length === 0
|
|
|| !prune_targets.every((t: string) => (VALID_PRUNE_TARGETS as readonly string[]).includes(t))) {
|
|
return 'prune_targets must be a non-empty array of: containers, images, networks, volumes';
|
|
}
|
|
}
|
|
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)) {
|
|
return 'target_services must be a non-empty array of service name strings';
|
|
}
|
|
if (action !== 'restart' || targetType !== 'stack') {
|
|
return 'target_services can only be used with restart action on stack target';
|
|
}
|
|
}
|
|
if (prune_label_filter !== undefined && prune_label_filter !== null) {
|
|
if (typeof prune_label_filter !== 'string' || prune_label_filter.trim().length === 0) {
|
|
return 'prune_label_filter must be a non-empty string';
|
|
}
|
|
if (action !== 'prune') {
|
|
return 'prune_label_filter can only be used with prune action';
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Validate a cron expression for Scheduled Operations. The scheduler ticks once
|
|
* per minute, so an expression with a leading seconds field (6 or more fields)
|
|
* is rejected: its sub-minute precision could never be honored. Cron nicknames
|
|
* such as `@daily` (a single token) are left untouched. Returns an error message
|
|
* or null.
|
|
*/
|
|
function validateCronExpression(cron: unknown): string | null {
|
|
if (typeof cron !== 'string' || !cron.trim()) {
|
|
return 'Cron expression is required.';
|
|
}
|
|
if (cron.trim().split(/\s+/).length >= 6) {
|
|
return 'Cron expression must use 5 fields (minute hour day month weekday). The seconds field is not supported.';
|
|
}
|
|
try {
|
|
CronExpressionParser.parse(cron);
|
|
} catch (e) {
|
|
console.warn('[Scheduler] Invalid cron expression rejected:', sanitizeForLog(cron), sanitizeForLog(getErrorMessage(e, 'unknown')));
|
|
return 'Invalid cron expression.';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Validate the optional explicit fire time. When present it must be a future
|
|
* epoch-ms integer; absent (undefined/null) leaves the cron-derived next run in
|
|
* place. By convention only a one-time ('once') schedule populates run_at (the
|
|
* frontend's getOnceRunAt), because a 5-field cron has no year field and the
|
|
* cron alone would fire on the next annual occurrence rather than the exact date
|
|
* the admin chose; this validator itself only checks shape and futureness.
|
|
*/
|
|
function validateRunAt(runAt: unknown): string | null {
|
|
if (runAt === undefined || runAt === null) return null;
|
|
if (typeof runAt !== 'number' || !Number.isInteger(runAt)) {
|
|
return 'run_at must be an epoch-millisecond timestamp.';
|
|
}
|
|
if (runAt <= Date.now()) {
|
|
return 'run_at must be in the future.';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export const scheduledTasksRouter = Router();
|
|
|
|
scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
let tasks = DatabaseService.getInstance().getScheduledTasks();
|
|
// The Scheduled Operations view manages every task type, so it lists all of
|
|
// them. `action` / `exclude_action` exist for the read-only consumers that
|
|
// want a slice: the Auto-Update readiness card and the sidebar next-run
|
|
// indicator both request `?action=update`.
|
|
const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined;
|
|
const excludeAction = typeof req.query.exclude_action === 'string' ? req.query.exclude_action : undefined;
|
|
if (actionFilter) {
|
|
tasks = tasks.filter(t => t.action === actionFilter);
|
|
} else if (excludeAction) {
|
|
tasks = tasks.filter(t => t.action !== excludeAction);
|
|
}
|
|
|
|
// Timeline view wants every firing inside a rolling window, not just the next run.
|
|
const scheduler = SchedulerService.getInstance();
|
|
const windowHours = Math.min(Math.max(Number(req.query.window_hours) || 24, 1), 168);
|
|
const from = Date.now();
|
|
const to = from + windowHours * 60 * 60 * 1000;
|
|
const enriched = tasks.map(t => ({
|
|
...t,
|
|
next_runs: t.enabled === 1 ? scheduler.calculateRunsWithin(t.cron_expression, from, to) : [],
|
|
}));
|
|
|
|
res.json(enriched);
|
|
} catch (error) {
|
|
console.error('[ScheduledTasks] List error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch scheduled tasks' });
|
|
}
|
|
});
|
|
|
|
scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run, run_at } = req.body;
|
|
|
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
res.status(400).json({ error: 'Name is required' }); return;
|
|
}
|
|
if (!(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) {
|
|
res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return;
|
|
}
|
|
if (!(VALID_ACTIONS as readonly string[]).includes(action)) {
|
|
res.status(400).json({ error: INVALID_ACTION_MESSAGE }); return;
|
|
}
|
|
|
|
const targetErr = validateActionTarget(action, target_type);
|
|
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
|
|
|
const nodeErr = validateActionNode(action, target_type, node_id);
|
|
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
|
const stackTargetErr = validateStackTarget(target_type, target_id, node_id);
|
|
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
|
|
|
|
const optionalErr = validateOptionalFields(action, target_type, prune_targets, target_services, prune_label_filter);
|
|
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
|
|
|
|
const cronErr = validateCronExpression(cron_expression);
|
|
if (cronErr) { res.status(400).json({ error: cronErr }); return; }
|
|
|
|
const runAtErr = validateRunAt(run_at);
|
|
if (runAtErr) { res.status(400).json({ error: runAtErr }); return; }
|
|
|
|
const scheduler = SchedulerService.getInstance();
|
|
const now = Date.now();
|
|
// Persist the one-shot's pinned instant in its own column so it survives a
|
|
// disabled state and edit (the yearless cron cannot reconstruct the year).
|
|
// next_run_at is the cron-derived run unless a run_at pins it, and is null
|
|
// while disabled; the pinned run_at is retained regardless so enabling later
|
|
// restores the exact instant.
|
|
const pinnedRunAt = typeof run_at === 'number' ? run_at : null;
|
|
const nextRun = (enabled === false)
|
|
? null
|
|
: (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression));
|
|
const normalizedTargetId = target_type === 'stack' ? target_id : null;
|
|
const normalizedNodeId = actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null;
|
|
|
|
const id = DatabaseService.getInstance().createScheduledTask({
|
|
name: name.trim(),
|
|
target_type,
|
|
target_id: normalizedTargetId,
|
|
node_id: normalizedNodeId,
|
|
action,
|
|
cron_expression,
|
|
enabled: enabled !== false ? 1 : 0,
|
|
created_by: req.user?.username || 'admin',
|
|
created_at: now,
|
|
updated_at: now,
|
|
last_run_at: null,
|
|
next_run_at: nextRun,
|
|
last_status: null,
|
|
last_error: null,
|
|
prune_targets: action === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null,
|
|
target_services: action === 'restart' && target_type === 'stack' && target_services ? JSON.stringify(target_services) : null,
|
|
prune_label_filter: action === 'prune' && prune_label_filter ? prune_label_filter.trim() : null,
|
|
delete_after_run: delete_after_run ? 1 : 0,
|
|
run_at: pinnedRunAt,
|
|
});
|
|
|
|
console.log(`[ScheduledTasks] Created task id=${id} action=${sanitizeForLog(action)} target=${sanitizeForLog(target_id || 'none')}`);
|
|
const task = DatabaseService.getInstance().getScheduledTask(id);
|
|
broadcastScheduledTasksChanged();
|
|
res.status(201).json(task);
|
|
} catch (error) {
|
|
console.error('[ScheduledTasks] Create error:', error);
|
|
res.status(500).json({ error: 'Failed to create scheduled task' });
|
|
}
|
|
});
|
|
|
|
scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const id = parseIntParam(req, res, 'id', 'task ID');
|
|
if (id === null) return;
|
|
const task = DatabaseService.getInstance().getScheduledTask(id);
|
|
if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
|
res.json(task);
|
|
} catch (error) {
|
|
console.error('[ScheduledTasks] Get error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch scheduled task' });
|
|
}
|
|
});
|
|
|
|
scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const id = parseIntParam(req, res, 'id', 'task ID');
|
|
if (id === null) return;
|
|
|
|
const db = DatabaseService.getInstance();
|
|
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, target_services, prune_label_filter, delete_after_run, run_at } = req.body;
|
|
|
|
if (target_type !== undefined && !(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) {
|
|
res.status(400).json({ error: 'Invalid target_type' }); return;
|
|
}
|
|
if (action !== undefined && !(VALID_ACTIONS as readonly string[]).includes(action)) {
|
|
res.status(400).json({ error: 'Invalid action' }); return;
|
|
}
|
|
|
|
const finalAction = (action ?? existing.action) as BackendScheduledAction;
|
|
const finalTargetType = (target_type ?? existing.target_type) as TargetType;
|
|
const finalTargetId = finalTargetType === 'stack'
|
|
? (target_id !== undefined ? target_id : existing.target_id)
|
|
: null;
|
|
const finalNodeId = actionRequiresNode(finalAction)
|
|
? (node_id !== undefined ? node_id : existing.node_id)
|
|
: null;
|
|
const targetErr = validateActionTarget(finalAction, finalTargetType);
|
|
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
|
|
|
const nodeErr = validateActionNode(finalAction, finalTargetType, finalNodeId);
|
|
if (nodeErr) { res.status(400).json({ error: nodeErr }); return; }
|
|
|
|
const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId);
|
|
if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; }
|
|
|
|
const optionalErr = validateOptionalFields(finalAction, finalTargetType, prune_targets, target_services, prune_label_filter);
|
|
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
|
|
|
|
if (cron_expression !== undefined) {
|
|
const cronErr = validateCronExpression(cron_expression);
|
|
if (cronErr) { res.status(400).json({ error: cronErr }); return; }
|
|
}
|
|
|
|
const runAtErr = validateRunAt(run_at);
|
|
if (runAtErr) { res.status(400).json({ error: runAtErr }); return; }
|
|
|
|
const updates: Partial<Omit<ScheduledTask, 'id'>> = { updated_at: Date.now() };
|
|
if (name !== undefined) {
|
|
if (typeof name !== 'string' || !name.trim()) {
|
|
res.status(400).json({ error: 'Name is required' }); return;
|
|
}
|
|
updates.name = name.trim();
|
|
}
|
|
if (target_type !== undefined) updates.target_type = finalTargetType;
|
|
if (target_id !== undefined || finalTargetType !== 'stack') updates.target_id = finalTargetId || null;
|
|
if (node_id !== undefined || !actionRequiresNode(finalAction)) {
|
|
updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null;
|
|
}
|
|
if (action !== undefined) updates.action = finalAction;
|
|
if (cron_expression !== undefined && typeof cron_expression === 'string') updates.cron_expression = cron_expression;
|
|
if (enabled !== undefined) updates.enabled = enabled ? 1 : 0;
|
|
if (prune_targets !== undefined) {
|
|
updates.prune_targets = finalAction === 'prune' && prune_targets ? JSON.stringify(prune_targets) : null;
|
|
} else if (finalAction !== 'prune') {
|
|
updates.prune_targets = null;
|
|
}
|
|
if (target_services !== undefined) {
|
|
updates.target_services = finalAction === 'restart' && finalTargetType === 'stack' && target_services
|
|
? JSON.stringify(target_services)
|
|
: null;
|
|
} else if (finalAction !== 'restart' || finalTargetType !== 'stack') {
|
|
updates.target_services = null;
|
|
}
|
|
if (prune_label_filter !== undefined) {
|
|
updates.prune_label_filter = finalAction === 'prune' && prune_label_filter ? prune_label_filter.trim() : null;
|
|
} else if (finalAction !== 'prune') {
|
|
updates.prune_label_filter = null;
|
|
}
|
|
if (delete_after_run !== undefined) updates.delete_after_run = delete_after_run ? 1 : 0;
|
|
|
|
// Persist a re-supplied run_at to its column (a number pins a one-shot; null
|
|
// clears it when an edit switches the schedule to a recurring shape). When
|
|
// run_at is omitted, the existing pinned value carries forward.
|
|
const runAtProvided = run_at !== undefined;
|
|
if (runAtProvided) updates.run_at = typeof run_at === 'number' ? run_at : null;
|
|
const effectiveRunAt = (runAtProvided ? updates.run_at : existing.run_at) ?? null;
|
|
|
|
const finalCron = cron_expression || existing.cron_expression;
|
|
const finalEnabled = enabled !== undefined ? enabled : existing.enabled;
|
|
if (finalEnabled) {
|
|
// The pinned one-shot instant wins; otherwise recompute from the (possibly
|
|
// updated) cron.
|
|
updates.next_run_at = effectiveRunAt ?? SchedulerService.getInstance().calculateNextRun(finalCron);
|
|
} else {
|
|
updates.next_run_at = null;
|
|
}
|
|
|
|
db.updateScheduledTask(id, updates);
|
|
console.log(`[ScheduledTasks] Updated task id=${id}`);
|
|
const task = db.getScheduledTask(id);
|
|
broadcastScheduledTasksChanged();
|
|
res.json(task);
|
|
} catch (error) {
|
|
console.error('[ScheduledTasks] Update error:', error);
|
|
res.status(500).json({ error: 'Failed to update scheduled task' });
|
|
}
|
|
});
|
|
|
|
scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const id = parseIntParam(req, res, 'id', 'task ID');
|
|
if (id === null) return;
|
|
|
|
const db = DatabaseService.getInstance();
|
|
const existing = db.getScheduledTask(id);
|
|
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
|
|
|
db.deleteScheduledTask(id);
|
|
console.log(`[ScheduledTasks] Deleted task id=${id}`);
|
|
broadcastScheduledTasksChanged();
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('[ScheduledTasks] Delete error:', error);
|
|
res.status(500).json({ error: 'Failed to delete scheduled task' });
|
|
}
|
|
});
|
|
|
|
scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const id = parseIntParam(req, res, 'id', 'task ID');
|
|
if (id === null) return;
|
|
|
|
const db = DatabaseService.getInstance();
|
|
const existing = db.getScheduledTask(id);
|
|
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
|
|
|
const newEnabled = existing.enabled ? 0 : 1;
|
|
// On enable, a one-shot's persisted run_at restores the exact pinned instant
|
|
// (its yearless cron cannot reconstruct the chosen year); a recurring task
|
|
// recomputes from the cron. Disabling clears next_run_at for both; the
|
|
// run_at column is untouched, so re-enabling later still restores the instant.
|
|
const nextRun: number | null = newEnabled
|
|
? (existing.run_at ?? SchedulerService.getInstance().calculateNextRun(existing.cron_expression))
|
|
: null;
|
|
|
|
db.updateScheduledTask(id, {
|
|
enabled: newEnabled,
|
|
next_run_at: nextRun,
|
|
updated_at: Date.now(),
|
|
});
|
|
|
|
console.log(`[ScheduledTasks] Toggled task id=${id} enabled=${newEnabled}`);
|
|
const task = db.getScheduledTask(id);
|
|
broadcastScheduledTasksChanged();
|
|
res.json(task);
|
|
} catch (error) {
|
|
console.error('[ScheduledTasks] Toggle error:', error);
|
|
res.status(500).json({ error: 'Failed to toggle scheduled task' });
|
|
}
|
|
});
|
|
|
|
scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const id = parseIntParam(req, res, 'id', 'task ID');
|
|
if (id === null) return;
|
|
|
|
const db = DatabaseService.getInstance();
|
|
const existing = db.getScheduledTask(id);
|
|
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
|
|
|
const scheduler = SchedulerService.getInstance();
|
|
if (scheduler.isTaskRunning(id)) {
|
|
res.status(409).json({ error: 'Task is already running' }); return;
|
|
}
|
|
|
|
console.log(`[ScheduledTasks] Manual run requested for task id=${id}`);
|
|
scheduler.triggerTask(id).catch((err: unknown) => {
|
|
const msg = getErrorMessage(err, String(err));
|
|
console.error(`[ScheduledTasks] Background run error for task ${id}:`, msg);
|
|
});
|
|
|
|
res.status(202).json({ message: 'Task triggered', task_id: id });
|
|
} catch (error) {
|
|
const msg = getErrorMessage(error, 'Failed to run task');
|
|
console.error('[ScheduledTasks] Run error:', msg);
|
|
res.status(500).json({ error: msg });
|
|
}
|
|
});
|
|
|
|
scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const id = parseIntParam(req, res, 'id', 'task ID');
|
|
if (id === null) 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 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([timestamp, source, status, duration, details].map(escapeCsvField).join(','));
|
|
}
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => {
|
|
if (!requireAdmin(req, res)) return;
|
|
try {
|
|
const id = parseIntParam(req, res, 'id', 'task ID');
|
|
if (id === null) return;
|
|
|
|
const db = DatabaseService.getInstance();
|
|
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) || 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' });
|
|
}
|
|
});
|