"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { getPhyloJobStatus } from "@/lib/api"; type JobStatus = "queued" | "running" | "completed" | "failed" | "unknown"; interface JobArtifact { name: string; url: string; mime?: string; bytes?: number; } interface Props { jobId: string; toolName?: string; /** Called when job completes with output artifacts. */ onComplete?: (artifacts: JobArtifact[]) => void; } const STATUS_COLORS: Record = { queued: { bg: "bg-amber-50", text: "text-amber-700", ring: "ring-amber-200" }, running: { bg: "bg-blue-50", text: "text-blue-700", ring: "ring-blue-200" }, completed: { bg: "bg-emerald-50", text: "text-emerald-700", ring: "ring-emerald-200" }, failed: { bg: "bg-red-50", text: "text-red-700", ring: "ring-red-200" }, unknown: { bg: "bg-gray-50", text: "text-gray-500", ring: "ring-gray-200" }, }; const STATUS_LABELS: Record = { queued: "Queued", running: "Running", completed: "Completed", failed: "Failed", unknown: "Unknown", }; function normalizeStatus(raw: string | undefined): JobStatus { if (!raw) return "unknown"; const s = raw.toLowerCase(); if (s === "completed" || s === "success" || s === "done") return "completed"; if (s === "failed" || s === "error" || s === "cancelled") return "failed"; if (s === "running" || s === "processing" || s === "in_progress") return "running"; if (s === "queued" || s === "pending" || s === "submitted") return "queued"; return "unknown"; } function formatElapsed(ms: number): string { const sec = Math.floor(ms / 1000); if (sec < 60) return `${sec}s`; const min = Math.floor(sec / 60); const rem = sec % 60; return `${min}m ${rem}s`; } export default function JobStatusCard({ jobId, toolName, onComplete }: Props) { const [status, setStatus] = useState("queued"); const [error, setError] = useState(null); const [artifacts, setArtifacts] = useState([]); const [costEstimate, setCostEstimate] = useState(null); const [elapsed, setElapsed] = useState(0); const [expanded, setExpanded] = useState(false); const pollRef = useRef | null>(null); const timerRef = useRef | null>(null); const startTimeRef = useRef(Date.now()); const completedRef = useRef(false); const stopPolling = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }, []); useEffect(() => { if (!jobId) return; // Start elapsed timer startTimeRef.current = Date.now(); timerRef.current = setInterval(() => { setElapsed(Date.now() - startTimeRef.current); }, 1000); // Start polling const poll = async () => { try { const data = await getPhyloJobStatus(jobId); const hubStatus = data.hub?.status; const normalized = normalizeStatus(hubStatus as string | undefined); setStatus(normalized); if (data.hub?.cost_estimate != null) { setCostEstimate(data.hub.cost_estimate as number); } if (normalized === "completed" && !completedRef.current) { completedRef.current = true; stopPolling(); const arts = (data.artifacts ?? []) as JobArtifact[]; setArtifacts(arts); onComplete?.(arts); } else if (normalized === "failed") { stopPolling(); const errMsg = (data.hub?.error as string) ?? (data.hub?.message as string) ?? "Job failed"; setError(String(errMsg)); } } catch { // Transient error, keep polling } }; // Poll immediately, then every 5s poll(); pollRef.current = setInterval(poll, 5000); return stopPolling; }, [jobId, onComplete, stopPolling]); const isActive = status === "queued" || status === "running"; const colors = STATUS_COLORS[status]; return (
{/* Header row */} {/* Expanded detail */} {expanded && (

Status

{STATUS_LABELS[status]}

Elapsed

{formatElapsed(elapsed)}

{costEstimate != null && (

Cost

${costEstimate.toFixed(2)}

)} {toolName && (

Tool

{toolName}

)}
)} {/* Completed: artifacts */} {status === "completed" && artifacts.length > 0 && (

Results

    {artifacts.map((a, i) => (
  • {a.name} {a.bytes != null && a.bytes > 0 && ( {(a.bytes / 1024).toFixed(1)}KB )}
  • ))}
)} {/* Failed: error message */} {error && (

{error}

)}
); }