import { useState, useCallback } from "react"; import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder, Layers } from "lucide-react"; import type { Routine, RoutineExecutionResult, RoutineTriggerType, RoutineCatchUpPolicy, RoutineExecutionPolicy } from "@fusion/core"; /** * Format a duration in milliseconds to a human-readable string. */ function formatDurationMs(ms: number): string { if (ms < 1000) return `${ms}ms`; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes < 60) return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; } /** * Format an ISO timestamp to a relative time string. */ function relativeTime(iso: string): string { const now = Date.now(); const then = new Date(iso).getTime(); const diffMs = now - then; // Future if (diffMs < 0) { const absDiff = Math.abs(diffMs); if (absDiff < 60_000) return "in a moment"; if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`; if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`; return `in ${Math.floor(absDiff / 86_400_000)}d`; } // Past if (diffMs < 60_000) return "just now"; if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`; if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`; return `${Math.floor(diffMs / 86_400_000)}d ago`; } const TRIGGER_TYPE_COLORS: Record = { cron: "var(--color-blue, #3b82f6)", webhook: "var(--color-purple, #a855f7)", api: "var(--color-green, #22c55e)", manual: "var(--color-gray, #6b7280)", }; const TRIGGER_TYPE_LABELS: Record = { cron: "Cron", webhook: "Webhook", api: "API", manual: "Manual", }; const TRIGGER_TYPE_ICONS: Record> = { cron: Calendar, webhook: Webhook, api: Code, manual: Zap, }; const EXECUTION_POLICY_LABELS: Record = { parallel: "Concurrent", queue: "Queued", reject: "Exclusive", }; const CATCH_UP_POLICY_LABELS: Record = { run: "Catch up", skip: "Skip missed", run_one: "Catch up (latest)", }; interface RoutineCardProps { routine: Routine; onEdit: (routine: Routine) => void; onDelete: (routine: Routine) => void; onRun: (routine: Routine) => void; onToggle: (routine: Routine) => void; /** Whether a manual run is currently in progress. */ running?: boolean; } function RunResultBadge({ result }: { result: RoutineExecutionResult }) { const duration = result.completedAt && result.startedAt ? new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime() : 0; return ( {result.success ? ( ) : ( )} {result.success ? "Success" : "Failed"} {duration > 0 && ( {formatDurationMs(duration)} )} ); } function RunHistoryItem({ result, index }: { result: RoutineExecutionResult; index: number }) { const [expanded, setExpanded] = useState(false); const duration = result.completedAt && result.startedAt ? new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime() : 0; return (
{expanded && (
{result.output && (
{result.output}
)} {result.error && (
{result.error}
)}
)}
); } export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, running }: RoutineCardProps) { const [showHistory, setShowHistory] = useState(false); const handleDelete = useCallback(() => { if (window.confirm(`Delete routine "${routine.name}"? This cannot be undone.`)) { onDelete(routine); } }, [routine, onDelete]); const triggerColor = TRIGGER_TYPE_COLORS[routine.trigger.type]; const TriggerIcon = TRIGGER_TYPE_ICONS[routine.trigger.type]; // Get cron expression if available (from trigger or direct field) const cronExpression = routine.trigger.type === "cron" ? (("cronExpression" in routine.trigger ? routine.trigger.cronExpression : undefined) as string | undefined) || routine.cronExpression || "" : routine.cronExpression || ""; return (
{routine.name} {TRIGGER_TYPE_LABELS[routine.trigger.type]} {routine.scope && ( {routine.scope === "global" ? : } {routine.scope} )}
{routine.description && (

{routine.description}

)}
{routine.steps && routine.steps.length > 0 ? (
{routine.steps.length} step{routine.steps.length === 1 ? "" : "s"}
) : routine.command ? (
{routine.command}
) : null} {/* Cron expression for cron triggers */} {routine.trigger.type === "cron" && cronExpression && (
{cronExpression}
)} {/* Policy badges */}
{EXECUTION_POLICY_LABELS[routine.executionPolicy]}
{CATCH_UP_POLICY_LABELS[routine.catchUpPolicy]}
{/* Timing info */} {routine.nextRunAt && routine.enabled && (
Next: {relativeTime(routine.nextRunAt)}
)} {routine.lastRunAt && (
Last: {relativeTime(routine.lastRunAt)}
)} {routine.lastRunResult && ( )}
Runs: {routine.runCount}
{routine.runHistory.length > 0 && (
{showHistory && (
{routine.runHistory.slice(0, 10).map((result, i) => ( ))} {routine.runHistory.length > 10 && (
…and {routine.runHistory.length - 10} more
)}
)}
)}
); }