'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 (
Vercel Hobby invokes scheduled tasks once daily. Shorter intervals are unavailable on this deployment.
No tasks registered.
{task.description}
}{task.lastError}
) : ( task.lastStatus === 'ok' && task.lastResult && ({task.lastResult}
) )}