"use client"; import { useEffect, useState, useCallback } from "react"; import type { CronJob, HeartbeatInfo, CronStatusInfo, CronJobsResponse, } from "../../types/cron"; /* ─── Helpers ─── */ function formatSchedule(schedule: CronJob["schedule"]): string { switch (schedule.kind) { case "cron": return `cron: ${schedule.expr}${schedule.tz ? ` (${schedule.tz})` : ""}`; case "every": { const ms = schedule.everyMs; if (ms >= 86_400_000) {return `every ${Math.round(ms / 86_400_000)}d`;} if (ms >= 3_600_000) {return `every ${Math.round(ms / 3_600_000)}h`;} if (ms >= 60_000) {return `every ${Math.round(ms / 60_000)}m`;} return `every ${Math.round(ms / 1000)}s`; } case "at": return `at ${schedule.at}`; default: return "unknown"; } } function formatCountdown(ms: number): string { if (ms <= 0) {return "now";} const totalSec = Math.ceil(ms / 1000); if (totalSec < 60) {return `${totalSec}s`;} const min = Math.floor(totalSec / 60); const sec = totalSec % 60; if (min < 60) {return sec > 0 ? `${min}m ${sec}s` : `${min}m`;} const hr = Math.floor(min / 60); const remMin = min % 60; return remMin > 0 ? `${hr}h ${remMin}m` : `${hr}h`; } function formatTimeAgo(ms: number): string { const ago = Date.now() - ms; if (ago < 60_000) {return "just now";} if (ago < 3_600_000) {return `${Math.floor(ago / 60_000)}m ago`;} if (ago < 86_400_000) {return `${Math.floor(ago / 3_600_000)}h ago`;} return `${Math.floor(ago / 86_400_000)}d ago`; } function formatDuration(ms: number): string { if (ms < 1000) {return `${ms}ms`;} if (ms < 60_000) {return `${(ms / 1000).toFixed(1)}s`;} return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1000)}s`; } function jobStatusLabel(job: CronJob): string { if (!job.enabled) {return "disabled";} if (job.state.runningAtMs) {return "running";} return job.state.lastStatus ?? "idle"; } function jobStatusColor(status: string): string { switch (status) { case "ok": return "var(--color-success, #22c55e)"; case "running": return "var(--color-accent)"; case "error": return "var(--color-error, #ef4444)"; case "disabled": return "var(--color-text-muted)"; case "skipped": return "var(--color-warning, #f59e0b)"; default: return "var(--color-text-muted)"; } } /* ─── Countdown hook ─── */ function useCountdown(targetMs: number | null | undefined): string | null { const [now, setNow] = useState(Date.now()); useEffect(() => { if (!targetMs) {return;} const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, [targetMs]); if (!targetMs) {return null;} return formatCountdown(targetMs - now); } /* ─── Main component ─── */ export function CronDashboard({ onSelectJob, }: { onSelectJob: (jobId: string) => void; }) { const [jobs, setJobs] = useState([]); const [heartbeat, setHeartbeat] = useState({ intervalMs: 30 * 60_000, nextDueEstimateMs: null }); const [cronStatus, setCronStatus] = useState({ enabled: false, nextWakeAtMs: null }); const [loading, setLoading] = useState(true); const fetchData = useCallback(async () => { try { const res = await fetch("/api/cron/jobs"); const data: CronJobsResponse = await res.json(); setJobs(data.jobs ?? []); setHeartbeat(data.heartbeat ?? { intervalMs: 30 * 60_000, nextDueEstimateMs: null }); setCronStatus(data.cronStatus ?? { enabled: false, nextWakeAtMs: null }); } catch { // ignore } finally { setLoading(false); } }, []); useEffect(() => { void fetchData(); const id = setInterval(() => void fetchData(), 30_000); return () => clearInterval(id); }, [fetchData]); const heartbeatCountdown = useCountdown(heartbeat.nextDueEstimateMs); const cronWakeCountdown = useCountdown(cronStatus.nextWakeAtMs); if (loading) { return (
); } const enabledJobs = jobs.filter((j) => j.enabled); const disabledJobs = jobs.filter((j) => !j.enabled); return (
{/* Header */}

Cron

Scheduled jobs and heartbeat status

{/* Status cards */}
{/* Heartbeat card */} } value={heartbeatCountdown ? `in ${heartbeatCountdown}` : "unknown"} subtitle={`Interval: ${formatCountdown(heartbeat.intervalMs)}`} description="The heartbeat wakes the agent periodically. Cron jobs with wakeMode=next-heartbeat piggyback on this loop." /> {/* Cron scheduler card */} } value={cronWakeCountdown ? `next in ${cronWakeCountdown}` : jobs.length === 0 ? "no jobs" : "idle"} subtitle={`${enabledJobs.length} active / ${jobs.length} total jobs`} description="The cron timer fires every ~60s, checking for due jobs. Isolated jobs run independently; main-session jobs wake the heartbeat." /> {/* Running card */} } value={`${jobs.filter((j) => j.state.runningAtMs).length}`} subtitle={`${jobs.filter((j) => j.state.lastStatus === "error").length} errors`} description="Jobs currently executing. Errors show consecutive failures." />
{/* Timeline - upcoming runs in next 24h */} {/* Jobs table */}

Jobs

{jobs.length === 0 ? (

No cron jobs configured. Use ironclaw cron add to create one.

) : (
{[...enabledJobs, ...disabledJobs].map((job) => ( onSelectJob(job.id)} /> ))}
Name Schedule Status Next Run Last Run Target
)}
); } /* ─── Status card ─── */ function StatusCard({ title, icon, value, subtitle, description, }: { title: string; icon: React.ReactNode; value: string; subtitle: string; description: string; }) { return (
{icon} {title}
{value}
{subtitle}
{description}
); } /* ─── Timeline ─── */ function TimelineSection({ jobs }: { jobs: CronJob[] }) { const [now, setNow] = useState(Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 10_000); return () => clearInterval(id); }, []); const horizon = 24 * 60 * 60 * 1000; // 24h const upcoming = jobs .filter((j) => j.state.nextRunAtMs && j.state.nextRunAtMs > now && j.state.nextRunAtMs < now + horizon) .toSorted((a, b) => (a.state.nextRunAtMs ?? 0) - (b.state.nextRunAtMs ?? 0)); if (upcoming.length === 0) {return null;} return (

Upcoming (next 24h)

{/* Timeline bar */}
{upcoming.map((job) => { const timeUntil = (job.state.nextRunAtMs ?? 0) - now; return (
{job.name} in {formatCountdown(timeUntil)}
{new Date(job.state.nextRunAtMs!).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
); })}
); } /* ─── Job row ─── */ function JobRow({ job, onClick }: { job: CronJob; onClick: () => void }) { const status = jobStatusLabel(job); const statusColor = jobStatusColor(status); const [now, setNow] = useState(Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 5000); return () => clearInterval(id); }, []); const nextRunStr = job.state.nextRunAtMs ? job.state.nextRunAtMs > now ? `in ${formatCountdown(job.state.nextRunAtMs - now)}` : "overdue" : "-"; const lastRunStr = job.state.lastRunAtMs ? `${formatTimeAgo(job.state.lastRunAtMs)}${job.state.lastDurationMs ? ` (${formatDuration(job.state.lastDurationMs)})` : ""}` : "-"; return ( { (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = "transparent"; }} >
{job.name}
{job.description && (
{job.description}
)} {formatSchedule(job.schedule)} {status === "running" && ( )} {status} {nextRunStr} {lastRunStr} {job.sessionTarget} ); } /* ─── Icons ─── */ function HeartbeatIcon() { return ( ); } function ClockIcon() { return ( ); } function RunningIcon() { return ( ); }