| "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; |
| |
| onComplete?: (artifacts: JobArtifact[]) => void; |
| } |
|
|
| const STATUS_COLORS: Record<JobStatus, { bg: string; text: string; ring: string }> = { |
| 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<JobStatus, string> = { |
| 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<JobStatus>("queued"); |
| const [error, setError] = useState<string | null>(null); |
| const [artifacts, setArtifacts] = useState<JobArtifact[]>([]); |
| const [costEstimate, setCostEstimate] = useState<number | null>(null); |
| const [elapsed, setElapsed] = useState(0); |
| const [expanded, setExpanded] = useState(false); |
|
|
| const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); |
| const timerRef = useRef<ReturnType<typeof setInterval> | 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; |
|
|
| |
| startTimeRef.current = Date.now(); |
| timerRef.current = setInterval(() => { |
| setElapsed(Date.now() - startTimeRef.current); |
| }, 1000); |
|
|
| |
| 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 { |
| |
| } |
| }; |
|
|
| |
| poll(); |
| pollRef.current = setInterval(poll, 5000); |
|
|
| return stopPolling; |
| }, [jobId, onComplete, stopPolling]); |
|
|
| const isActive = status === "queued" || status === "running"; |
| const colors = STATUS_COLORS[status]; |
|
|
| return ( |
| <div |
| data-testid="job-status-card" |
| className="my-2 rounded-xl border border-border bg-white overflow-hidden shadow-sm animate-fade-in" |
| > |
| {/* Header row */} |
| <button |
| onClick={() => setExpanded(!expanded)} |
| className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-muted/20 transition-colors" |
| > |
| {/* Status indicator */} |
| {isActive ? ( |
| <div className="w-5 h-5 rounded-full border-2 border-accent border-t-transparent animate-spin shrink-0" /> |
| ) : status === "completed" ? ( |
| <div className="w-5 h-5 rounded-full bg-emerald-100 flex items-center justify-center shrink-0"> |
| <svg |
| className="w-3 h-3 text-emerald-600" |
| viewBox="0 0 24 24" |
| fill="none" |
| stroke="currentColor" |
| strokeWidth={3} |
| > |
| <polyline points="20 6 9 17 4 12" /> |
| </svg> |
| </div> |
| ) : ( |
| <div className="w-5 h-5 rounded-full bg-red-100 flex items-center justify-center shrink-0"> |
| <svg |
| className="w-3 h-3 text-red-600" |
| viewBox="0 0 24 24" |
| fill="none" |
| stroke="currentColor" |
| strokeWidth={3} |
| > |
| <line x1="18" y1="6" x2="6" y2="18" /> |
| <line x1="6" y1="6" x2="18" y2="18" /> |
| </svg> |
| </div> |
| )} |
| |
| <div className="flex-1 min-w-0"> |
| <div className="flex items-center gap-2"> |
| <span className="text-xs font-medium text-foreground"> |
| {toolName ? toolName.replace(/_/g, " ") : "Job"}{" "} |
| </span> |
| <span |
| className={`inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium ring-1 ${colors.bg} ${colors.text} ${colors.ring}`} |
| > |
| {STATUS_LABELS[status]} |
| </span> |
| </div> |
| <span className="text-[10px] text-muted-fg font-mono">{jobId}</span> |
| </div> |
| |
| {/* Right side: elapsed + cost */} |
| <div className="text-right shrink-0"> |
| <p className="text-[11px] text-foreground tabular-nums"> |
| {formatElapsed(elapsed)} |
| </p> |
| {costEstimate != null && ( |
| <p className="text-[10px] text-muted-fg"> |
| ~${costEstimate.toFixed(2)} |
| </p> |
| )} |
| </div> |
| |
| <svg |
| className={`w-3.5 h-3.5 text-muted-fg transition-transform shrink-0 ${ |
| expanded ? "rotate-180" : "" |
| }`} |
| viewBox="0 0 24 24" |
| fill="none" |
| stroke="currentColor" |
| strokeWidth={2} |
| > |
| <polyline points="6 9 12 15 18 9" /> |
| </svg> |
| </button> |
| |
| {/* Expanded detail */} |
| {expanded && ( |
| <div className="border-t border-border-subtle px-4 py-3 space-y-2"> |
| <div className="grid grid-cols-2 gap-2 text-[11px]"> |
| <div> |
| <p className="text-[10px] text-muted-fg uppercase tracking-wider font-semibold"> |
| Status |
| </p> |
| <p className="text-foreground">{STATUS_LABELS[status]}</p> |
| </div> |
| <div> |
| <p className="text-[10px] text-muted-fg uppercase tracking-wider font-semibold"> |
| Elapsed |
| </p> |
| <p className="text-foreground tabular-nums"> |
| {formatElapsed(elapsed)} |
| </p> |
| </div> |
| {costEstimate != null && ( |
| <div> |
| <p className="text-[10px] text-muted-fg uppercase tracking-wider font-semibold"> |
| Cost |
| </p> |
| <p className="text-foreground">${costEstimate.toFixed(2)}</p> |
| </div> |
| )} |
| {toolName && ( |
| <div> |
| <p className="text-[10px] text-muted-fg uppercase tracking-wider font-semibold"> |
| Tool |
| </p> |
| <p className="text-foreground">{toolName}</p> |
| </div> |
| )} |
| </div> |
| </div> |
| )} |
| |
| {/* Completed: artifacts */} |
| {status === "completed" && artifacts.length > 0 && ( |
| <div className="border-t border-border-subtle px-4 py-2"> |
| <p className="text-[10px] text-muted-fg uppercase tracking-wider font-semibold mb-1"> |
| Results |
| </p> |
| <ul className="space-y-1"> |
| {artifacts.map((a, i) => ( |
| <li key={i} className="flex items-center gap-2"> |
| <svg |
| className="w-3 h-3 text-muted-fg shrink-0" |
| viewBox="0 0 24 24" |
| fill="none" |
| stroke="currentColor" |
| strokeWidth={2} |
| > |
| <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> |
| <polyline points="14 2 14 8 20 8" /> |
| </svg> |
| <a |
| href={a.url} |
| target="_blank" |
| rel="noopener noreferrer" |
| className="text-[11px] text-accent hover:text-accent-hover hover:underline truncate" |
| > |
| {a.name} |
| </a> |
| {a.bytes != null && a.bytes > 0 && ( |
| <span className="text-[10px] text-muted-fg shrink-0"> |
| {(a.bytes / 1024).toFixed(1)}KB |
| </span> |
| )} |
| </li> |
| ))} |
| </ul> |
| </div> |
| )} |
| |
| {/* Failed: error message */} |
| {error && ( |
| <div className="border-t border-red-200 bg-red-50 px-4 py-2"> |
| <p className="text-[11px] text-red-700">{error}</p> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|