'use client'; import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { Button, Card, Input, Section, Switch } from '@/components/ui'; import { ClockIcon, RefreshIcon } from '@/components/icons/Icons'; type TaskRunStatus = 'idle' | 'running' | 'ok' | 'error'; interface TaskView { key: string; name: string; description?: string; enabled: boolean; intervalMs: number; lastStatus: TaskRunStatus; lastRunAt: number | null; lastDurationMs: number | null; lastError: string | null; lastResult: string | null; nextRunAt: number | null; running: boolean; } interface TaskSchedulerInfo { mode: 'self-hosted' | 'vercel-cron'; tickIntervalMs: number; minimumIntervalMs: number; } const TASKS_QUERY_KEY = ['admin-tasks'] as const; async function fetchTasks(): Promise<{ tasks: TaskView[]; scheduler: TaskSchedulerInfo }> { const res = await fetch('/api/admin/tasks'); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as { tasks: TaskView[]; scheduler: TaskSchedulerInfo }; } function RunningDot() { return ( ); } function formatRelative(ms: number | null): string { if (ms == null) return 'never'; const diff = ms - Date.now(); const abs = Math.abs(diff); const units: Array<[number, string]> = [ [86_400_000, 'd'], [3_600_000, 'h'], [60_000, 'm'], [1_000, 's'], ]; let label = 'now'; for (const [unitMs, suffix] of units) { if (abs >= unitMs) { label = `${Math.round(abs / unitMs)}${suffix}`; break; } } if (label === 'now') return 'just now'; return diff < 0 ? `${label} ago` : `in ${label}`; } function formatDuration(ms: number | null): string { if (ms == null) return '—'; if (ms < 1000) return `${ms}ms`; return `${(ms / 1000).toFixed(1)}s`; } export function AdminTasksPanel() { const queryClient = useQueryClient(); const { data, error, isPending: isLoading } = useQuery({ queryKey: TASKS_QUERY_KEY, queryFn: fetchTasks, refetchInterval: 5000, }); useEffect(() => { if (!error) return; console.error('[AdminTasksPanel] load failed:', error); toast.error('Failed to load tasks'); }, [error]); const invalidate = () => queryClient.invalidateQueries({ queryKey: TASKS_QUERY_KEY }); const patchTask = useMutation({ mutationFn: async ({ key, patch }: { key: string; patch: { enabled?: boolean; intervalMs?: number } }) => { const res = await fetch(`/api/admin/tasks/${encodeURIComponent(key)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); }, onSuccess: invalidate, onError: () => toast.error('Update failed'), }); const runTask = useMutation({ mutationFn: async (key: string) => { const res = await fetch(`/api/admin/tasks/${encodeURIComponent(key)}/run`, { method: 'POST' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return ((await res.json()) as { ran: boolean }).ran; }, onSuccess: (ran) => { toast.success(ran ? 'Task ran' : 'Task already running'); invalidate(); }, onError: () => toast.error('Run failed'), }); return (
{data?.scheduler.mode === 'vercel-cron' && (

Vercel Hobby invokes scheduled tasks once daily. Shorter intervals are unavailable on this deployment.

)} {isLoading ? ( ) : (
{(data?.tasks ?? []).map((task) => ( patchTask.mutate({ key: task.key, patch: { enabled } })} onSaveInterval={(intervalMs) => patchTask.mutate({ key: task.key, patch: { intervalMs } })} onRun={() => runTask.mutate(task.key)} /> ))} {data && data.tasks.length === 0 && (

No tasks registered.

)}
)}
); } function TasksSkeleton() { const rows = Array.from({ length: 2 }); return (
{rows.map((_, index) => (
))}
); } function TaskRow({ task, schedulerMode, minimumIntervalMs, busy, runPending, onToggle, onSaveInterval, onRun, }: { task: TaskView; schedulerMode: TaskSchedulerInfo['mode']; minimumIntervalMs: number; busy: boolean; runPending: boolean; onToggle: (enabled: boolean) => void; onSaveInterval: (intervalMs: number) => void; onRun: () => void; }) { const [minutes, setMinutes] = useState(String(task.intervalMs / 60000)); useEffect(() => { setMinutes(String(task.intervalMs / 60000)); }, [task.intervalMs]); const parsedMinutes = parseFloat(minutes); const newIntervalMs = parsedMinutes * 60000; const intervalDirty = Number.isFinite(parsedMinutes) && newIntervalMs >= minimumIntervalMs && newIntervalMs !== task.intervalMs; const running = task.running || runPending; return (
{running && } {task.name}
{task.description &&

{task.description}

}
{formatRelative(task.lastRunAt)} {task.enabled && schedulerMode === 'vercel-cron' && ( next daily cron )} {task.enabled && schedulerMode !== 'vercel-cron' && task.nextRunAt != null && ( next {formatRelative(task.nextRunAt)} )}
Every setMinutes(e.target.value)} aria-label={`${task.name} interval in minutes`} /> min {intervalDirty && ( )}
{task.lastStatus === 'error' && task.lastError ? (

{task.lastError}

) : ( task.lastStatus === 'ok' && task.lastResult && (

{task.lastResult}

) )}
); }