Build real cron engine + service-detail Cron UI

The cron backend was a stub: calculateNextRun returned now+1h for any
input and executeCronJob slept 2s then wrote "success" without running
the command. Now robfig/cron parses and validates schedules (400 on
invalid), a 60s scheduler goroutine executes due enabled jobs, and
commands run via docker exec sh -c inside the service's container with
stdout+stderr captured into cron_executions. Added Client.ExecRun for
attached output capture. UI: Cron tab on the service detail page with
create/edit/enable-toggle, manual Run, and execution history with logs.
Verified e2e against real Postgres + Docker (trigger and scheduler tick
both produce real captured output).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 17:01:16 +02:00
1 parent aab785afc1
commit a1e43524f5
9 files changed
+498 -26

No files matched your search

@@ -11,8 +11,15 @@ import {
listDeployments,
listServiceLogs,
listServiceVariables,
listCronJobs,
createCronJob,
updateCronJob,
deleteCronJob,
listCronExecutions,
triggerCronJob,
rollbackDeployment,
updateServiceVariables,
type CronJobEntity,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
@@ -40,15 +47,18 @@ import {
Sparkles,
RefreshCw,
Box,
Play,
ChevronDown,
} from 'lucide-react';
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'settings';
type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'settings';
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText },
{ key: 'config', label: 'Config', icon: Sliders },
{ key: 'variables', label: 'Variables', icon: KeyRound },
{ key: 'cron', label: 'Cron', icon: Clock },
{ key: 'settings', label: 'Settings', icon: Settings },
];
@@ -135,6 +145,66 @@ export function ServiceDetailPage() {
},
});
// --- Cron jobs ---
const [cronForm, setCronForm] = useState<{
name: string; schedule: string; command: string; timezone: string; enabled: boolean; retention: number;
} | null>(null);
const [editingCronId, setEditingCronId] = useState<string | null>(null);
const [historyCronId, setHistoryCronId] = useState<string | null>(null);
const cronJobsQuery = useQuery({
queryKey: ['service-cron-jobs', serviceId],
queryFn: () => listCronJobs(serviceId),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'cron',
});
const cronExecutionsQuery = useQuery({
queryKey: ['cron-executions', historyCronId],
queryFn: () => listCronExecutions(historyCronId as string),
enabled: Boolean(historyCronId) && !isDemoMode && activeSection === 'cron',
});
const invalidateCron = () => queryClient.invalidateQueries({ queryKey: ['service-cron-jobs', serviceId] });
const saveCronMutation = useMutation({
mutationFn: async () => {
if (!cronForm) return;
const payload = {
name: cronForm.name.trim(),
schedule: cronForm.schedule.trim(),
command: cronForm.command.trim(),
timezone: cronForm.timezone.trim() || 'UTC',
enabled: cronForm.enabled,
retention: cronForm.retention,
};
if (editingCronId) {
await updateCronJob(editingCronId, payload);
} else {
await createCronJob({ project_id: projectId, service_id: serviceId, ...payload });
}
},
onSuccess: () => {
setCronForm(null);
setEditingCronId(null);
invalidateCron();
},
});
const deleteCronMutation = useMutation({
mutationFn: (id: string) => deleteCronJob(id),
onSuccess: invalidateCron,
});
const toggleCronMutation = useMutation({
mutationFn: (job: CronJobEntity) => updateCronJob(job.id as string, { enabled: !job.enabled }),
onSuccess: invalidateCron,
});
const triggerCronMutation = useMutation({
mutationFn: (id: string) => triggerCronJob(id),
onSuccess: () => {
window.setTimeout(() => {
invalidateCron();
if (historyCronId) queryClient.invalidateQueries({ queryKey: ['cron-executions', historyCronId] });
}, 2500);
},
});
const serviceLogsQuery = useQuery({
queryKey: ['service-logs', serviceId, logTail],
queryFn: () => listServiceLogs(serviceId, { tail: logTail }),
@@ -1041,6 +1111,180 @@ export function ServiceDetailPage() {
</div>
)}
{activeSection === 'cron' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Clock size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Cron Jobs</h2>
<p className="text-sm text-[var(--text-secondary)]">Scheduled commands executed inside the service container</p>
</div>
</div>
<button
onClick={() => { setEditingCronId(null); setCronForm({ name: '', schedule: '', command: '', timezone: 'UTC', enabled: true, retention: 30 }); }}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
<Plus size={12} />
New job
</button>
</div>
{cronForm && (
<div className="mb-4 panel-soft p-4 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
value={cronForm.name}
onChange={(e) => setCronForm({ ...cronForm, name: e.target.value })}
placeholder="Name (e.g. Nightly backup)"
className="px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<input
value={cronForm.schedule}
onChange={(e) => setCronForm({ ...cronForm, schedule: e.target.value })}
placeholder="Schedule (e.g. 0 3 * * * or @daily)"
className="px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
</div>
<input
value={cronForm.command}
onChange={(e) => setCronForm({ ...cronForm, command: e.target.value })}
placeholder="Command (runs via sh -c inside the container)"
className="w-full px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] mono text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
<div className="flex flex-wrap items-center gap-3">
<input
value={cronForm.timezone}
onChange={(e) => setCronForm({ ...cronForm, timezone: e.target.value })}
placeholder="Timezone"
className="w-32 px-3 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all"
/>
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
<input type="number" min={1} value={cronForm.retention}
onChange={(e) => setCronForm({ ...cronForm, retention: Number(e.target.value) || 30 })}
className="w-16 px-2 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-xs text-[var(--text-primary)]" />
runs kept
</label>
<label className="flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
<input type="checkbox" checked={cronForm.enabled}
onChange={(e) => setCronForm({ ...cronForm, enabled: e.target.checked })} />
Enabled
</label>
<div className="ml-auto flex gap-2">
<button
onClick={() => { setCronForm(null); setEditingCronId(null); }}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:border-[var(--border-default)] transition-colors"
>
Cancel
</button>
<button
onClick={() => saveCronMutation.mutate()}
disabled={!cronForm.name.trim() || !cronForm.schedule.trim() || !cronForm.command.trim() || saveCronMutation.isPending}
className="flex items-center gap-2 px-4 py-1.5 rounded-[var(--radius-md)] text-xs font-medium text-[var(--accent-on)] transition-all disabled:opacity-50"
style={{ background: 'var(--accent-primary)' }}
>
{saveCronMutation.isPending ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
{editingCronId ? 'Save job' : 'Create job'}
</button>
</div>
</div>
{saveCronMutation.isError && (
<p className="text-xs text-[var(--error)]">
{saveCronMutation.error instanceof Error ? saveCronMutation.error.message : 'Failed to save job'}
</p>
)}
</div>
)}
{cronJobsQuery.isLoading ? (
<div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
<Loader2 size={20} className="animate-spin" />
</div>
) : (cronJobsQuery.data ?? []).length === 0 ? (
<div className="panel-soft p-8 text-center">
<p className="text-sm text-[var(--text-secondary)]">No cron jobs configured.</p>
<p className="mt-1 text-xs text-[var(--text-muted)]">Create a job to run commands on a schedule inside this service's container.</p>
</div>
) : (
<div className="space-y-2">
{(cronJobsQuery.data ?? []).map((job) => (
<div key={job.id} className="panel-soft p-3">
<div className="flex items-center gap-3">
<button
onClick={() => toggleCronMutation.mutate(job)}
title={job.enabled ? 'Disable' : 'Enable'}
className={`w-8 h-5 rounded-full relative transition-colors shrink-0 ${job.enabled ? 'bg-[var(--accent-primary)]' : 'bg-[var(--border-default)]'}`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${job.enabled ? 'left-3.5' : 'left-0.5'}`} />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)] truncate">{job.name}</span>
<span className="mono text-xs text-[var(--text-muted)]">{job.schedule}</span>
</div>
<p className="mono text-xs text-[var(--text-tertiary)] truncate">{job.command}</p>
<p className="text-xs text-[var(--text-muted)]">
{job.next_run_at ? `next ${formatRelative(job.next_run_at)}` : 'not scheduled'}
{job.last_status ? ` · last ${job.last_status}` : ''}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => triggerCronMutation.mutate(job.id as string)} title="Run now"
disabled={triggerCronMutation.isPending}
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors disabled:opacity-50">
<Play size={13} />
</button>
<button onClick={() => setHistoryCronId(historyCronId === job.id ? null : (job.id as string))} title="History"
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors">
<ChevronDown size={13} className={historyCronId === job.id ? 'rotate-180 transition-transform' : 'transition-transform'} />
</button>
<button
onClick={() => { setEditingCronId(job.id as string); setCronForm({ name: job.name ?? '', schedule: job.schedule ?? '', command: job.command ?? '', timezone: job.timezone ?? 'UTC', enabled: job.enabled ?? true, retention: job.retention ?? 30 }); }}
title="Edit"
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--accent-primary)] hover:bg-[var(--accent-primary-soft)] transition-colors">
<Sliders size={13} />
</button>
<button
onClick={() => { if (window.confirm(`Delete cron job "${job.name}"?`)) deleteCronMutation.mutate(job.id as string); }}
title="Delete"
className="p-1.5 rounded-[var(--radius-md)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--error-soft)] transition-colors">
<Trash2 size={13} />
</button>
</div>
</div>
{historyCronId === job.id && (
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
{cronExecutionsQuery.isLoading ? (
<p className="text-xs text-[var(--text-muted)]">Loading history…</p>
) : (cronExecutionsQuery.data ?? []).length === 0 ? (
<p className="text-xs text-[var(--text-muted)]">No executions yet.</p>
) : (
<div className="space-y-2">
{(cronExecutionsQuery.data ?? []).slice(0, 10).map((ex) => (
<div key={ex.id} className="text-xs">
<div className="flex items-center gap-2">
<span className={`inline-block w-1.5 h-1.5 rounded-full ${ex.status === 'success' ? 'bg-[var(--success)]' : ex.status === 'running' ? 'bg-[var(--warning)]' : 'bg-[var(--error)]'}`} />
<span className="text-[var(--text-secondary)]">{formatRelative(ex.started_at)}</span>
<span className="text-[var(--text-muted)]">{ex.status}</span>
</div>
{(ex.output || ex.error) && (
<pre className="mono mt-1 ml-3.5 whitespace-pre-wrap break-all text-[var(--text-tertiary)]">{ex.output || ex.error}</pre>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{activeSection === 'settings' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">