mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
feat(scheduled-ops): add scheduled operations for Team Pro users (#231)
Adds the ability to schedule recurring Docker operations (stack restarts, fleet snapshots, system prunes) via cron expressions with full execution history logging. Includes Run Now for on-demand execution.
This commit is contained in:
@@ -155,9 +155,9 @@ export function ApiTokensSection() {
|
||||
<Select value={formScope} onValueChange={setFormScope}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="read-only">Read Only — GET requests only</SelectItem>
|
||||
<SelectItem value="deploy-only">Deploy Only — read + deploy actions</SelectItem>
|
||||
<SelectItem value="full-admin">Full Admin — unrestricted access</SelectItem>
|
||||
<SelectItem value="read-only">Read Only - GET requests only</SelectItem>
|
||||
<SelectItem value="deploy-only">Deploy Only - read + deploy actions</SelectItem>
|
||||
<SelectItem value="full-admin">Full Admin - unrestricted access</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -187,7 +187,7 @@ export function ApiTokensSection() {
|
||||
{newToken && (
|
||||
<div className="bg-emerald-500/10 border border-emerald-500/30 rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<CheckCircle className="w-4 h-4" /> Token created — copy it now
|
||||
<CheckCircle className="w-4 h-4" /> Token created - copy it now
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">This token will not be shown again. Store it securely.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -43,7 +43,7 @@ export function AuditLogView() {
|
||||
setTotal(data.total);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — non-critical view
|
||||
// Silently fail - non-critical view
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -79,7 +79,6 @@ export function AuditLogView() {
|
||||
<div className="flex items-center gap-2">
|
||||
<ScrollText className="w-5 h-5" />
|
||||
<CardTitle>Audit Log</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">Team Pro</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchLogs} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
@@ -163,7 +162,7 @@ export function AuditLogView() {
|
||||
{entry.status_code}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{entry.node_id ?? '—'}
|
||||
{entry.node_id ?? '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download } from 'lucide-react';
|
||||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock } from 'lucide-react';
|
||||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { toast } from 'sonner';
|
||||
@@ -37,6 +37,7 @@ import { LogViewer } from './LogViewer';
|
||||
import { GlobalObservabilityView } from './GlobalObservabilityView';
|
||||
import { FleetView } from './FleetView';
|
||||
import { AuditLogView } from './AuditLogView';
|
||||
import ScheduledOperationsView from './ScheduledOperationsView';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
@@ -119,7 +120,7 @@ export default function EditorLayout() {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log'>('dashboard');
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops'>('dashboard');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
||||
@@ -1359,6 +1360,19 @@ export default function EditorLayout() {
|
||||
Audit
|
||||
</Button>
|
||||
)}
|
||||
{/* Scheduled Operations Toggle (Team Pro + Admin only) */}
|
||||
{isPro && license?.variant === 'team' && isAdmin && (
|
||||
<Button
|
||||
variant={activeView === 'scheduled-ops' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="rounded-lg"
|
||||
onClick={() => setActiveView(activeView === 'scheduled-ops' ? (selectedFile ? 'editor' : 'dashboard') : 'scheduled-ops')}
|
||||
title="Scheduled Operations"
|
||||
>
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
Schedules
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Notifications Popover */}
|
||||
<Popover>
|
||||
@@ -1764,6 +1778,8 @@ export default function EditorLayout() {
|
||||
}} />
|
||||
) : activeView === 'audit-log' ? (
|
||||
<AuditLogView />
|
||||
) : activeView === 'scheduled-ops' ? (
|
||||
<ScheduledOperationsView />
|
||||
) : (
|
||||
<HomeDashboard />
|
||||
)}
|
||||
|
||||
@@ -338,7 +338,7 @@ export function SSOSection() {
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Connect your identity provider so team members can sign in with their existing credentials.
|
||||
SSO works alongside password authentication — it does not replace it.
|
||||
SSO works alongside password authentication - it does not replace it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
||||
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 { toast } from 'sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
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;
|
||||
}
|
||||
|
||||
interface TaskRun {
|
||||
id: number;
|
||||
task_id: number;
|
||||
started_at: number;
|
||||
completed_at: number | null;
|
||||
status: 'running' | 'success' | 'failure';
|
||||
output: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface NodeOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
{ value: 'restart', label: 'Restart Stack', targetType: 'stack' as const },
|
||||
{ value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' as const },
|
||||
{ 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();
|
||||
}
|
||||
|
||||
export default function ScheduledOperationsView() {
|
||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingTask, setEditingTask] = useState<ScheduledTask | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ScheduledTask | null>(null);
|
||||
const [runsTask, setRunsTask] = useState<ScheduledTask | null>(null);
|
||||
const [runs, setRuns] = useState<TaskRun[]>([]);
|
||||
const [runsLoading, setRunsLoading] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formAction, setFormAction] = useState('restart');
|
||||
const [formTargetId, setFormTargetId] = useState('');
|
||||
const [formNodeId, setFormNodeId] = useState('');
|
||||
const [formCron, setFormCron] = useState('0 3 * * *');
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [runningTaskId, setRunningTaskId] = useState<number | null>(null);
|
||||
|
||||
// Available stacks and nodes for selection
|
||||
const [stacks, setStacks] = useState<string[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/scheduled-tasks', { localOnly: true });
|
||||
if (res.ok) {
|
||||
setTasks(await res.json());
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStacks = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/stacks');
|
||||
if (res.ok) {
|
||||
setStacks(await res.json());
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNodes = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiFetch('/nodes', { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setNodes(data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })));
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
fetchStacks();
|
||||
fetchNodes();
|
||||
}, [fetchTasks, fetchStacks, fetchNodes]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingTask(null);
|
||||
setFormName('');
|
||||
setFormAction('restart');
|
||||
setFormTargetId('');
|
||||
setFormNodeId('');
|
||||
setFormCron('0 3 * * *');
|
||||
setFormEnabled(true);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (task: ScheduledTask) => {
|
||||
setEditingTask(task);
|
||||
setFormName(task.name);
|
||||
setFormAction(task.action);
|
||||
setFormTargetId(task.target_id || '');
|
||||
setFormNodeId(task.node_id != null ? String(task.node_id) : '');
|
||||
setFormCron(task.cron_expression);
|
||||
setFormEnabled(task.enabled === 1);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const actionOption = ACTION_OPTIONS.find(a => a.value === formAction);
|
||||
if (!actionOption) return;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
name: formName,
|
||||
target_type: actionOption.targetType,
|
||||
action: formAction,
|
||||
cron_expression: formCron,
|
||||
enabled: formEnabled,
|
||||
};
|
||||
|
||||
if (actionOption.targetType === 'stack') {
|
||||
body.target_id = formTargetId;
|
||||
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = editingTask
|
||||
? await apiFetch(`/scheduled-tasks/${editingTask.id}`, { method: 'PUT', body: JSON.stringify(body), localOnly: true })
|
||||
: await apiFetch('/scheduled-tasks', { method: 'POST', body: JSON.stringify(body), localOnly: true });
|
||||
|
||||
if (res.ok) {
|
||||
toast.success(editingTask ? 'Task updated' : 'Task created');
|
||||
setDialogOpen(false);
|
||||
fetchTasks();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data?.error || 'Failed to save task');
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : 'Something went wrong.';
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (task: ScheduledTask) => {
|
||||
try {
|
||||
const res = await apiFetch(`/scheduled-tasks/${task.id}/toggle`, { method: 'PATCH', localOnly: true });
|
||||
if (res.ok) {
|
||||
fetchTasks();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data?.error || 'Failed to toggle task');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Something went wrong.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
const res = await apiFetch(`/scheduled-tasks/${deleteTarget.id}`, { method: 'DELETE', localOnly: true });
|
||||
if (res.ok) {
|
||||
toast.success('Task deleted');
|
||||
setDeleteTarget(null);
|
||||
fetchTasks();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data?.error || 'Failed to delete task');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Something went wrong.');
|
||||
}
|
||||
};
|
||||
|
||||
const openRuns = async (task: ScheduledTask) => {
|
||||
setRunsTask(task);
|
||||
setRunsLoading(true);
|
||||
try {
|
||||
const res = await apiFetch(`/scheduled-tasks/${task.id}/runs?limit=50`, { localOnly: true });
|
||||
if (res.ok) {
|
||||
setRuns(await res.json());
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
setRunsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunNow = async (task: ScheduledTask) => {
|
||||
setRunningTaskId(task.id);
|
||||
try {
|
||||
const res = await apiFetch(`/scheduled-tasks/${task.id}/run`, { method: 'POST', localOnly: true });
|
||||
if (res.ok) {
|
||||
toast.success(`Task "${task.name}" executed successfully`);
|
||||
fetchTasks();
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data?.error || 'Failed to run task');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Something went wrong.');
|
||||
} finally {
|
||||
setRunningTaskId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const targetType = ACTION_OPTIONS.find(a => a.value === formAction)?.targetType;
|
||||
const cronDescription = getCronDescription(formCron);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-6xl mx-auto">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" />
|
||||
<CardTitle>Scheduled Operations</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchTasks} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Schedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && tasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">Loading...</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
No scheduled tasks yet. Create one to automate recurring operations.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Schedule</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Next Run</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<TableRow key={task.id}>
|
||||
<TableCell className="font-medium">{task.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{ACTION_OPTIONS.find(a => a.value === task.action)?.label || task.action}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{task.target_type === 'stack' ? task.target_id : task.target_type}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">{getCronDescription(task.cron_expression)}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{task.cron_expression}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{task.last_status === 'success' ? (
|
||||
<Badge className="bg-green-500/10 text-green-500 border-green-500/20">Success</Badge>
|
||||
) : task.last_status === 'failure' ? (
|
||||
<Badge variant="destructive">Failed</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Never run</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(task.next_run_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={task.enabled === 1}
|
||||
onCheckedChange={() => handleToggle(task)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleRunNow(task)} title="Run now" disabled={runningTaskId === task.id}>
|
||||
<Play className={`w-4 h-4 ${runningTaskId === task.id ? 'animate-pulse' : ''}`} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openRuns(task)} title="Execution history">
|
||||
<History className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(task)} title="Edit">
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDeleteTarget(task)} title="Delete" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTask ? 'Edit Scheduled Task' : 'New Scheduled Task'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input placeholder="e.g. Nightly stack restart" value={formName} onChange={e => setFormName(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Action</Label>
|
||||
<Select value={formAction} onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTION_OPTIONS.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{targetType === 'stack' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Stack</Label>
|
||||
<Select value={formTargetId} onValueChange={setFormTargetId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select stack..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{stacks.map(s => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Select value={formNodeId} onValueChange={setFormNodeId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select node..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{nodes.map(n => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>{n.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Cron Expression</Label>
|
||||
<Input
|
||||
placeholder="0 3 * * *"
|
||||
value={formCron}
|
||||
onChange={e => setFormCron(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{cronDescription}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={formEnabled} onCheckedChange={setFormEnabled} id="task-enabled" />
|
||||
<Label htmlFor="task-enabled">Enabled</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !formName || !formCron || (targetType === 'stack' && (!formTargetId || !formNodeId))}>
|
||||
{saving ? 'Saving...' : editingTask ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Scheduled Task</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete “{deleteTarget?.name}”? This will also remove all execution history. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Run History Sheet */}
|
||||
<Sheet open={!!runsTask} onOpenChange={(open) => { if (!open) setRunsTask(null); }}>
|
||||
<SheetContent className="sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Execution History - {runsTask?.name}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-4">
|
||||
{runsLoading ? (
|
||||
<div className="text-center text-muted-foreground py-8">Loading...</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">No executions yet.</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((run) => {
|
||||
const duration = run.completed_at && run.started_at
|
||||
? `${((run.completed_at - run.started_at) / 1000).toFixed(1)}s`
|
||||
: '-';
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
{new Date(run.started_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{run.status === 'success' ? (
|
||||
<Badge className="bg-green-500/10 text-green-500 border-green-500/20">Success</Badge>
|
||||
) : run.status === 'failure' ? (
|
||||
<Badge variant="destructive">Failed</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Running</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{duration}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate" title={run.output || run.error || ''}>
|
||||
{run.error || run.output || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1192,12 +1192,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upgrade Cards — Community: show both, Personal Pro: show Team only, Team Pro: none */}
|
||||
{/* Upgrade Cards - Community: show both, Personal Pro: show Team only, Team Pro: none */}
|
||||
{(license?.tier !== 'pro' || (license?.variant === 'personal' && license?.status === 'active')) && (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">Upgrade your plan</Label>
|
||||
<div className={`grid gap-3 ${license?.tier !== 'pro' ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Personal Pro Card — only for Community users */}
|
||||
{/* Personal Pro Card - only for Community users */}
|
||||
{license?.tier !== 'pro' && (
|
||||
<div className="relative border border-border rounded-xl p-4 space-y-3 bg-muted/5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1262,7 +1262,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License key activation — show when not active */}
|
||||
{/* License key activation - show when not active */}
|
||||
{license?.status !== 'active' && (
|
||||
<div className="border-t border-border pt-4 space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">Have a license key?</Label>
|
||||
|
||||
Reference in New Issue
Block a user