diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 628c48cc..d210e48c 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -2,7 +2,7 @@ * Unit tests for SchedulerService — task execution, concurrent prevention, * license gating, cron parsing, and error handling. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // ── Hoisted mocks ────────────────────────────────────────────────────── @@ -10,12 +10,14 @@ const { mockGetDueScheduledTasks, mockCreateScheduledTaskRun, mockUpdateScheduledTaskRun, mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode, mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus, + mockMarkStaleRunsAsFailed, mockGetTier, mockGetVariant, mockGetContainersByStack, mockRestartContainer, mockPruneSystem, mockUpdateStack, mockGetStacks, mockGetStackContent, mockGetEnvContent, mockCheckImage, mockDispatchAlert, + mockGetProxyTarget, } = vi.hoisted(() => ({ mockGetDueScheduledTasks: vi.fn().mockReturnValue([]), mockCreateScheduledTaskRun: vi.fn().mockReturnValue(1), @@ -28,6 +30,7 @@ const { mockCreateSnapshot: vi.fn().mockReturnValue(1), mockInsertSnapshotFiles: vi.fn(), mockClearStackUpdateStatus: vi.fn(), + mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0), mockGetTier: vi.fn().mockReturnValue('paid'), mockGetVariant: vi.fn().mockReturnValue('admiral'), mockGetContainersByStack: vi.fn().mockResolvedValue([]), @@ -39,6 +42,7 @@ const { mockGetEnvContent: vi.fn().mockResolvedValue(''), mockCheckImage: vi.fn().mockResolvedValue({ hasUpdate: false }), mockDispatchAlert: vi.fn().mockResolvedValue(undefined), + mockGetProxyTarget: vi.fn().mockReturnValue(null), })); vi.mock('../services/DatabaseService', () => ({ @@ -55,6 +59,7 @@ vi.mock('../services/DatabaseService', () => ({ createSnapshot: mockCreateSnapshot, insertSnapshotFiles: mockInsertSnapshotFiles, clearStackUpdateStatus: mockClearStackUpdateStatus, + markStaleRunsAsFailed: mockMarkStaleRunsAsFailed, }), }, })); @@ -117,6 +122,7 @@ vi.mock('../services/NodeRegistry', () => ({ getInstance: () => ({ getDefaultNodeId: () => 1, getNode: mockGetNode, + getProxyTarget: mockGetProxyTarget, }), }, })); @@ -774,3 +780,218 @@ describe('SchedulerService - isProcessing guard', () => { expect((svc as any).isProcessing).toBe(false); }); }); + +// ── Stale run cleanup (T1) ─────────────────────────────────────────── + +describe('SchedulerService - stale run cleanup', () => { + it('calls markStaleRunsAsFailed on start and logs when records exist', () => { + mockMarkStaleRunsAsFailed.mockReturnValue(2); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const svc = SchedulerService.getInstance(); + svc.start(); + + expect(mockMarkStaleRunsAsFailed).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Cleaned up 2 stale run record(s)')); + + logSpy.mockRestore(); + svc.stop(); + }); + + it('does not log when no stale runs exist', () => { + mockMarkStaleRunsAsFailed.mockReturnValue(0); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const svc = SchedulerService.getInstance(); + svc.start(); + + expect(mockMarkStaleRunsAsFailed).toHaveBeenCalledTimes(1); + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining('stale')); + + logSpy.mockRestore(); + svc.stop(); + }); +}); + +// ── Invalid cron at execution time (T2) ────────────────────────────── + +describe('SchedulerService - invalid cron at execution time', () => { + it('disables task and records error when cron becomes invalid', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 95, + name: 'bad-cron-task', + action: 'restart', + cron_expression: 'INVALID CRON', + enabled: true, + target_id: null, + node_id: null, + created_by: 'admin', + last_status: null, + }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(95); + + expect(mockUpdateScheduledTask).toHaveBeenCalledWith( + 95, + expect.objectContaining({ + enabled: 0, + last_status: 'failure', + last_error: expect.stringContaining('no longer valid'), + }) + ); + + expect(mockDispatchAlert).toHaveBeenCalledWith( + 'error', + expect.stringContaining('failed'), + undefined + ); + }); +}); + +// ── executeSnapshot (T3) ───────────────────────────────────────────── + +describe('SchedulerService - executeSnapshot', () => { + it('creates a fleet snapshot capturing all local nodes', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 75, + name: 'nightly-snapshot', + action: 'snapshot', + target_type: 'fleet', + cron_expression: '0 3 * * *', + enabled: true, + created_by: 'admin', + last_status: null, + }); + mockGetNodes.mockReturnValue([ + { id: 1, name: 'local', type: 'local' }, + ]); + mockGetStacks.mockResolvedValue(['app1']); + mockGetStackContent.mockResolvedValue('version: "3"\nservices:\n web:\n image: nginx'); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(75); + + expect(mockCreateSnapshot).toHaveBeenCalledWith( + expect.stringContaining('nightly-snapshot'), + 'admin', + 1, + 1, + expect.any(String), + ); + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + 1, + expect.objectContaining({ status: 'success' }) + ); + }); + + it('handles nodes with no stacks gracefully', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 76, + name: 'empty-snapshot', + action: 'snapshot', + target_type: 'fleet', + cron_expression: '0 3 * * *', + enabled: true, + created_by: 'admin', + last_status: null, + }); + mockGetNodes.mockReturnValue([ + { id: 1, name: 'local', type: 'local' }, + ]); + mockGetStacks.mockResolvedValue([]); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(76); + + expect(mockCreateSnapshot).toHaveBeenCalled(); + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + 1, + expect.objectContaining({ status: 'success' }) + ); + }); +}); + +// ── executeUpdateRemote (T4) ───────────────────────────────────────── + +describe('SchedulerService - executeUpdateRemote', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('proxies update execution to remote node', async () => { + mockGetNode.mockReturnValue({ id: 2, name: 'remote', type: 'remote', status: 'online' }); + mockGetProxyTarget.mockReturnValue({ + apiUrl: 'http://remote:3000', + apiToken: 'test-token', + }); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ result: 'Stack "web": updated (nginx:latest).' }), + }); + vi.stubGlobal('fetch', mockFetch); + + mockGetScheduledTask.mockReturnValue({ + id: 88, + name: 'remote-update', + action: 'update', + cron_expression: '0 4 * * *', + enabled: true, + target_id: 'web-app', + node_id: 2, + created_by: 'admin', + last_status: null, + }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(88); + + expect(mockFetch).toHaveBeenCalledWith( + 'http://remote:3000/api/auto-update/execute', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ target: 'web-app' }), + }) + ); + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + 1, + expect.objectContaining({ status: 'success' }) + ); + }); + + it('records failure when remote node returns error', async () => { + mockGetNode.mockReturnValue({ id: 2, name: 'remote', type: 'remote', status: 'online' }); + mockGetProxyTarget.mockReturnValue({ + apiUrl: 'http://remote:3000', + apiToken: 'test-token', + }); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => ({ error: 'Internal error' }), + }); + vi.stubGlobal('fetch', mockFetch); + + mockGetScheduledTask.mockReturnValue({ + id: 89, + name: 'remote-update-fail', + action: 'update', + cron_expression: '0 4 * * *', + enabled: true, + target_id: 'web-app', + node_id: 2, + created_by: 'admin', + last_status: null, + }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(89); + + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + 1, + expect.objectContaining({ status: 'failure', error: expect.stringContaining('Internal error') }) + ); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index eb563bba..41e62ce4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5015,7 +5015,7 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { prune_label_filter: prune_label_filter ? prune_label_filter.trim() : null, }); - if (isDebugEnabled()) console.debug(`[ScheduledTasks:debug] Created task id=${id} action=${action} target=${target_id || 'none'}`); + console.log(`[ScheduledTasks] Created task id=${id} action=${action} target=${target_id || 'none'}`); const task = DatabaseService.getInstance().getScheduledTask(id); res.status(201).json(task); } catch (error) { @@ -5131,7 +5131,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { } db.updateScheduledTask(id, updates as Partial>); - if (isDebugEnabled()) console.debug(`[ScheduledTasks:debug] Updated task id=${id}`); + console.log(`[ScheduledTasks] Updated task id=${id}`); const task = db.getScheduledTask(id); res.json(task); } catch (error) { @@ -5153,7 +5153,7 @@ app.delete('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { if (!requireScheduledTaskTier(existing.action, req, res)) return; db.deleteScheduledTask(id); - if (isDebugEnabled()) console.debug(`[ScheduledTasks:debug] Deleted task id=${id}`); + console.log(`[ScheduledTasks] Deleted task id=${id}`); res.json({ success: true }); } catch (error) { console.error('[ScheduledTasks] Delete error:', error); @@ -5182,6 +5182,7 @@ app.patch('/api/scheduled-tasks/:id/toggle', (req: Request, res: Response): void updated_at: Date.now(), }); + console.log(`[ScheduledTasks] Toggled task id=${id} enabled=${newEnabled}`); const task = db.getScheduledTask(id); res.json(task); } catch (error) { @@ -5207,6 +5208,7 @@ app.post('/api/scheduled-tasks/:id/run', (req: Request, res: Response): void => 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); @@ -5885,6 +5887,7 @@ app.post('/api/auto-update/execute', authMiddleware, async (req: Request, res: R if (!requireAdmin(req, res)) return; try { const { target } = req.body as { target?: string }; + console.log(`[AutoUpdate] Execute requested: target="${target || ''}"`); if (!target || typeof target !== 'string') { return res.status(400).json({ error: 'Missing "target" (stack name or "*" for all)' }); } diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 1dcd6dcb..96c28d23 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -437,6 +437,7 @@ export class DatabaseService { ); CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task ON scheduled_task_runs(task_id); + CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_status ON scheduled_task_runs(status); CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run ON scheduled_tasks(next_run_at); CREATE TABLE IF NOT EXISTS stack_labels ( @@ -1531,6 +1532,13 @@ export class DatabaseService { ).all(taskId) as ScheduledTaskRun[]; } + public markStaleRunsAsFailed(): number { + const result = this.db.prepare( + 'UPDATE scheduled_task_runs SET status = ?, completed_at = ?, error = ? WHERE status = ?' + ).run('failure', Date.now(), 'Server restarted during execution', 'running'); + return result.changes; + } + 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); diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index f3afa9dc..84cffb5c 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -29,6 +29,7 @@ export class SchedulerService { public start(): void { if (this.intervalId) return; + this.cleanupStaleRuns(); this.intervalId = setInterval(() => this.tick(), 60_000); setTimeout(() => this.tick(), 10_000); console.log('[SchedulerService] Started'); @@ -42,6 +43,17 @@ export class SchedulerService { console.log('[SchedulerService] Stopped'); } + private cleanupStaleRuns(): void { + try { + const count = DatabaseService.getInstance().markStaleRunsAsFailed(); + if (count > 0) { + console.log(`[SchedulerService] Cleaned up ${count} stale run record(s)`); + } + } catch (error) { + console.error('[SchedulerService] Failed to clean up stale runs:', error); + } + } + public calculateNextRun(cronExpression: string): number { const expr = CronExpressionParser.parse(cronExpression); return expr.next().toDate().getTime(); @@ -101,6 +113,7 @@ export class SchedulerService { const task = db.getScheduledTask(taskId); if (!task) throw new Error('Task not found'); if (this.runningTasks.has(task.id)) throw new Error('Task is already running'); + console.log(`[SchedulerService] Manual trigger: task "${task.name}" (id=${task.id})`); this.runningTasks.add(task.id); try { await this.executeTask(task, 'manual'); @@ -129,6 +142,8 @@ export class SchedulerService { if (node.status === 'offline') throw new Error(`Target node "${node.name}" is offline`); } + if (isDebugEnabled()) console.log(`[SchedulerService:debug] Task ${task.id} pre-checks passed, executing ${task.action}`); + const actionStart = Date.now(); let output = ''; switch (task.action) { case 'restart': @@ -145,6 +160,8 @@ export class SchedulerService { break; } + if (isDebugEnabled()) console.log(`[SchedulerService:debug] Task ${task.id} action completed in ${Date.now() - actionStart}ms`); + const nextRun = this.calculateNextRun(task.cron_expression); db.updateScheduledTask(task.id, { last_run_at: Date.now(), @@ -169,18 +186,26 @@ export class SchedulerService { } catch (error: unknown) { const errMsg = error instanceof Error ? error.message : String(error); let nextRun: number | null = null; + let cronInvalid = false; try { nextRun = this.calculateNextRun(task.cron_expression); } catch { - // If cron expression is somehow invalid, disable the task + cronInvalid = true; } - db.updateScheduledTask(task.id, { + const updates: Partial> = { last_run_at: Date.now(), next_run_at: nextRun, last_status: 'failure', - last_error: errMsg, + last_error: cronInvalid + ? `${errMsg}. Cron expression "${task.cron_expression}" is no longer valid; task has been disabled.` + : errMsg, updated_at: Date.now(), - }); + }; + if (cronInvalid) { + updates.enabled = 0; + console.warn(`[SchedulerService] Task "${task.name}" (id=${task.id}) auto-disabled: cron expression invalid`); + } + db.updateScheduledTask(task.id, updates); db.updateScheduledTaskRun(runId, { completed_at: Date.now(), status: 'failure', @@ -361,6 +386,9 @@ export class SchedulerService { private async executePrune(task: ScheduledTask): Promise { const nodeId = task.node_id ?? NodeRegistry.getInstance().getDefaultNodeId(); + if (task.node_id == null && isDebugEnabled()) { + console.log(`[SchedulerService:debug] Prune task ${task.id}: no node_id specified, using default node ${nodeId}`); + } const docker = DockerController.getInstance(nodeId); const allTargets = ['containers', 'images', 'networks', 'volumes'] as const; type PruneTarget = typeof allTargets[number]; diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx index 51215501..e9a61e11 100644 --- a/docs/features/auto-update-policies.mdx +++ b/docs/features/auto-update-policies.mdx @@ -180,3 +180,15 @@ Check that the target stack exists and has at least one running container. For w ### "Run Now" finishes instantly This is expected behavior. The Run Now button triggers the update check in the background and returns immediately. The actual check runs asynchronously; check the run history panel to see the result once it completes. + +### Policy was automatically disabled + +If a policy's cron expression becomes invalid after creation, the scheduler disables the policy and records the reason in the status column. To fix this: + +1. Open the policy in edit mode. +2. Re-enter a valid cron expression (or choose a preset). +3. Re-enable the policy using the toggle switch. + +### Run shows "Server restarted during execution" + +This means Sencho was restarted while this policy's update check was mid-execution. The run was marked as failed automatically on startup. The policy itself is still enabled and will run at its next scheduled time. Use **Run Now** if you want to trigger it immediately. diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index aec992d9..bbb58dd0 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -166,3 +166,19 @@ The Scheduler Service runs in the background and checks for due tasks every 60 s 5. The next run time is recalculated from the cron expression. If a task is still running from a previous execution, the scheduler skips it to prevent overlap. + +If the server restarts while a task is mid-execution, the orphaned run record is automatically marked as failed with a "Server restarted during execution" message on the next startup. No manual cleanup is needed. + +## Troubleshooting + +### Task was automatically disabled + +If a task's cron expression becomes invalid after creation (for example, due to a corrupt database edit or an expression that was valid in a previous version), the scheduler disables the task and records the reason in the last error column. To fix this: + +1. Open the task in edit mode. +2. Re-enter a valid cron expression. +3. Re-enable the task using the toggle switch. + +### Run shows "Server restarted during execution" + +This means Sencho was restarted (or crashed) while this task was mid-execution. The run was marked as failed automatically on startup. The task itself is still enabled and will run at its next scheduled time. If you want to re-run it immediately, use the **Run Now** button. diff --git a/frontend/src/components/AutoUpdatePoliciesView.tsx b/frontend/src/components/AutoUpdatePoliciesView.tsx index be4bdfe3..9ded6989 100644 --- a/frontend/src/components/AutoUpdatePoliciesView.tsx +++ b/frontend/src/components/AutoUpdatePoliciesView.tsx @@ -15,41 +15,8 @@ import { RefreshCw, Plus, Pencil, Trash2, History, Play, ChevronLeft, ChevronRig import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { PaidGate } from '@/components/PaidGate'; -import cronstrue from 'cronstrue'; - -interface ScheduledTask { - id: number; - name: string; - target_type: 'stack' | 'fleet' | 'system'; - target_id: string | null; - node_id: number | null; - action: 'restart' | 'snapshot' | 'prune' | 'update'; - cron_expression: string; - enabled: number; - created_by: string; - created_at: number; - updated_at: number; - last_run_at: number | null; - next_run_at: number | null; - last_status: string | null; - last_error: string | null; -} - -interface TaskRun { - id: number; - task_id: number; - started_at: number; - completed_at: number | null; - status: 'running' | 'success' | 'failure'; - output: string | null; - error: string | null; - triggered_by: 'scheduler' | 'manual'; -} - -interface NodeOption { - id: number; - name: string; -} +import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; +import { getCronDescription, formatTimestamp } from '@/lib/scheduling'; const CRON_PRESETS = [ { label: 'Every 6 hours', value: '0 */6 * * *' }, @@ -60,19 +27,6 @@ const CRON_PRESETS = [ { label: 'Custom', value: 'custom' }, ]; -function getCronDescription(expression: string): string { - try { - return cronstrue.toString(expression); - } catch { - return 'Invalid expression'; - } -} - -function formatTimestamp(ts: number | null): string { - if (!ts) return '-'; - return new Date(ts).toLocaleString(); -} - interface AutoUpdatePoliciesProps { filterNodeId?: number | null; onClearFilter?: () => void; diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index a1a5098f..98080235 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -4,9 +4,10 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { ScrollArea } from '@/components/ui/scroll-area'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; @@ -14,44 +15,8 @@ import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, Che import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; import { Combobox } from '@/components/ui/combobox'; -import cronstrue from 'cronstrue'; - -interface ScheduledTask { - id: number; - name: string; - target_type: 'stack' | 'fleet' | 'system'; - target_id: string | null; - node_id: number | null; - action: 'restart' | 'snapshot' | 'prune'; - cron_expression: string; - enabled: number; - created_by: string; - created_at: number; - updated_at: number; - last_run_at: number | null; - next_run_at: number | null; - last_status: string | null; - last_error: string | null; - prune_targets: string | null; - target_services: string | null; - prune_label_filter: string | null; -} - -interface TaskRun { - id: number; - task_id: number; - started_at: number; - completed_at: number | null; - status: 'running' | 'success' | 'failure'; - output: string | null; - error: string | null; - triggered_by: 'scheduler' | 'manual'; -} - -interface NodeOption { - id: number; - name: string; -} +import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; +import { getCronDescription, formatTimestamp } from '@/lib/scheduling'; const ACTION_OPTIONS = [ { value: 'restart', label: 'Restart Stack', targetType: 'stack' as const }, @@ -59,19 +24,6 @@ const ACTION_OPTIONS = [ { value: 'prune', label: 'System Prune', targetType: 'system' as const }, ]; -function getCronDescription(expression: string): string { - try { - return cronstrue.toString(expression); - } catch { - return 'Invalid expression'; - } -} - -function formatTimestamp(ts: number | null): string { - if (!ts) return '-'; - return new Date(ts).toLocaleString(); -} - interface ScheduledOperationsViewProps { filterNodeId?: number | null; onClearFilter?: () => void; @@ -329,7 +281,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: try { const res = await apiFetch(`/scheduled-tasks/${task.id}/run`, { method: 'POST', localOnly: true }); if (res.ok) { - toast.success(`Task "${task.name}" executed successfully`); + toast.success(`Task "${task.name}" triggered`); fetchTasks(); } else { const data = await res.json().catch(() => ({})); @@ -351,16 +303,16 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
- + Scheduled Operations
@@ -440,16 +392,16 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
-
@@ -466,6 +418,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: {editingTask ? 'Edit Scheduled Task' : 'New Scheduled Task'} + Configure a scheduled operation task.
@@ -621,7 +574,8 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: )}
-
+ +
{runsLoading ? (
Loading...
) : runs.length === 0 ? ( @@ -678,17 +632,18 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:

)} )} -
+
+
diff --git a/frontend/src/lib/scheduling.ts b/frontend/src/lib/scheduling.ts new file mode 100644 index 00000000..492fcda0 --- /dev/null +++ b/frontend/src/lib/scheduling.ts @@ -0,0 +1,14 @@ +import cronstrue from 'cronstrue'; + +export function getCronDescription(expression: string): string { + try { + return cronstrue.toString(expression); + } catch { + return 'Invalid expression'; + } +} + +export function formatTimestamp(ts: number | null): string { + if (ts == null) return '-'; + return new Date(ts).toLocaleString(); +} diff --git a/frontend/src/types/scheduling.ts b/frontend/src/types/scheduling.ts new file mode 100644 index 00000000..db6e3875 --- /dev/null +++ b/frontend/src/types/scheduling.ts @@ -0,0 +1,36 @@ +export interface ScheduledTask { + id: number; + name: string; + target_type: 'stack' | 'fleet' | 'system'; + target_id: string | null; + node_id: number | null; + action: 'restart' | 'snapshot' | 'prune' | 'update'; + cron_expression: string; + enabled: number; + created_by: string; + created_at: number; + updated_at: number; + last_run_at: number | null; + next_run_at: number | null; + last_status: 'success' | 'failure' | null; + last_error: string | null; + prune_targets: string | null; + target_services: string | null; + prune_label_filter: string | null; +} + +export interface TaskRun { + id: number; + task_id: number; + started_at: number; + completed_at: number | null; + status: 'running' | 'success' | 'failure'; + output: string | null; + error: string | null; + triggered_by: 'scheduler' | 'manual'; +} + +export interface NodeOption { + id: number; + name: string; +}