mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 00:18:00 +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:
+9
-2
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user