| "use client"; |
|
|
| import { useCallback, useEffect, useRef, useState } from "react"; |
| import type { Artifact, Skill } from "@/lib/types"; |
| import { |
| estimateToolCostAPI, |
| submitJobAPI, |
| getJobStatusAPI, |
| type CostEstimate, |
| } from "@/lib/api"; |
|
|
| function detectMime(key: string): string { |
| if (key.endsWith('.pdb') || key.endsWith('.cif')) return 'chemical/x-pdb'; |
| if (key.endsWith('.fasta') || key.endsWith('.fa')) return 'application/fasta'; |
| if (key.endsWith('.csv')) return 'text/csv'; |
| if (key.endsWith('.json')) return 'application/json'; |
| return 'application/octet-stream'; |
| } |
|
|
| type RunState = "configure" | "submitting" | "polling" | "completed" | "failed"; |
|
|
| interface Props { |
| skill: Skill; |
| campaignId: string; |
| onComplete: (artifacts: Artifact[]) => void; |
| onClose: () => void; |
| } |
|
|
| export default function ToolRunPanel({ |
| skill, |
| campaignId, |
| onComplete, |
| onClose, |
| }: Props) { |
| const [runState, setRunState] = useState<RunState>("configure"); |
| const [estimate, setEstimate] = useState<CostEstimate | null>(null); |
| const [estimateError, setEstimateError] = useState<string | null>(null); |
| const [callParams, setCallParams] = useState("{}"); |
| const [file, setFile] = useState<File | null>(null); |
| const [provider, setProvider] = useState<"runpod" | "slurm">("runpod"); |
| const [error, setError] = useState<string | null>(null); |
| const [artifacts, setArtifacts] = useState<Artifact[]>([]); |
| const [elapsed, setElapsed] = useState(0); |
| const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); |
| const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); |
| const jobIdRef = useRef<string | null>(null); |
|
|
| |
| useEffect(() => { |
| let cancelled = false; |
| estimateToolCostAPI(skill.id, skill.gpuType) |
| .then((est) => { |
| if (!cancelled) setEstimate(est); |
| }) |
| .catch((e) => { |
| if (!cancelled) |
| setEstimateError(e instanceof Error ? e.message : "estimate_failed"); |
| }); |
| return () => { |
| cancelled = true; |
| }; |
| }, [skill.id, skill.gpuType]); |
|
|
| |
| useEffect(() => { |
| return () => { |
| if (pollRef.current) clearInterval(pollRef.current); |
| if (timerRef.current) clearInterval(timerRef.current); |
| }; |
| }, []); |
|
|
| const startPolling = useCallback( |
| (jobId: string) => { |
| setRunState("polling"); |
| setElapsed(0); |
| const start = Date.now(); |
|
|
| timerRef.current = setInterval(() => { |
| setElapsed(Math.floor((Date.now() - start) / 1000)); |
| }, 1000); |
|
|
| pollRef.current = setInterval(async () => { |
| try { |
| const status = await getJobStatusAPI(jobId); |
| const hubStatus = String( |
| status.hub?.status ?? "", |
| ).toLowerCase(); |
|
|
| if (hubStatus === "completed") { |
| if (pollRef.current) clearInterval(pollRef.current); |
| if (timerRef.current) clearInterval(timerRef.current); |
|
|
| |
| |
| |
| const hubOutputData = |
| status.hub?.output_data && typeof status.hub.output_data === "object" |
| ? (status.hub.output_data as Record<string, unknown>) |
| : undefined; |
| const mapped: Artifact[] = (status.artifacts ?? []).map( |
| (a, i) => ({ |
| id: a.id ?? `artifact-${i}`, |
| name: a.hub_output_key ?? `output-${i}`, |
| bytes: 0, |
| mime: detectMime(a.hub_output_key ?? ''), |
| url: `/api/jobs/${encodeURIComponent(jobId!)}/artifacts/${encodeURIComponent(a.id ?? "")}`, |
| createdAt: Date.now(), |
| hubOutputData, |
| }), |
| ); |
| setArtifacts(mapped); |
| setRunState("completed"); |
| onComplete(mapped); |
| } else if ( |
| hubStatus === "failed" || |
| hubStatus === "error" || |
| hubStatus === "cancelled" |
| ) { |
| if (pollRef.current) clearInterval(pollRef.current); |
| if (timerRef.current) clearInterval(timerRef.current); |
| setError( |
| String( |
| status.hub?.error ?? |
| status.hub?.message ?? |
| `Job ${hubStatus}`, |
| ), |
| ); |
| setRunState("failed"); |
| } |
| } catch (e) { |
| |
| console.warn("Poll error:", e); |
| } |
| }, 3000); |
| }, |
| [onComplete], |
| ); |
|
|
| const handleRun = useCallback(async () => { |
| if (!file) { |
| setError("Please select an input file."); |
| return; |
| } |
|
|
| |
| try { |
| JSON.parse(callParams); |
| } catch { |
| setError("call_params is not valid JSON."); |
| return; |
| } |
|
|
| setError(null); |
| setRunState("submitting"); |
|
|
| try { |
| const formData = new FormData(); |
| formData.set("endpoint_name", skill.id); |
| formData.set("provider", provider); |
| formData.set("call_params", callParams); |
| formData.set("input_file", file); |
|
|
| const result = await submitJobAPI(campaignId, formData); |
| jobIdRef.current = result.campaign_job_id; |
| startPolling(result.campaign_job_id); |
| } catch (e) { |
| setError(e instanceof Error ? e.message : "Submission failed"); |
| setRunState("failed"); |
| } |
| }, [file, callParams, skill.id, provider, campaignId, startPolling]); |
|
|
| const isRunning = runState === "submitting" || runState === "polling"; |
|
|
| return ( |
| <div |
| data-testid="tool-run-panel" |
| className="fixed inset-0 z-[70] flex justify-end bg-black/30 backdrop-blur-sm" |
| onClick={onClose} |
| > |
| <div |
| className="w-[560px] max-w-[96vw] h-full bg-white border-l border-border shadow-xl flex flex-col" |
| onClick={(e) => e.stopPropagation()} |
| > |
| {/* Header */} |
| <div className="px-4 py-3 border-b border-border flex items-start gap-3"> |
| <div className="min-w-0 flex-1"> |
| <p className="text-sm font-semibold text-foreground truncate"> |
| Run {skill.name} |
| </p> |
| <p className="text-[10px] text-muted-fg font-mono truncate"> |
| {skill.id} |
| </p> |
| </div> |
| <button |
| onClick={onClose} |
| className="text-[11px] text-muted-fg hover:text-foreground" |
| aria-label="Close" |
| > |
| ✕ |
| </button> |
| </div> |
| |
| {/* Body */} |
| <div className="flex-1 overflow-y-auto px-4 py-3 space-y-4"> |
| {/* Description */} |
| {skill.description && ( |
| <p className="text-[11px] text-foreground leading-relaxed"> |
| {skill.description} |
| </p> |
| )} |
| |
| {/* Cost estimate */} |
| <section> |
| <h3 className="text-[10px] uppercase tracking-wider text-muted-fg font-semibold mb-1"> |
| Cost Estimate |
| </h3> |
| {estimate ? ( |
| <div className="grid grid-cols-3 gap-2 text-[11px]"> |
| <div> |
| <p className="text-[10px] text-muted-fg">Cost</p> |
| <p |
| data-testid="estimate-cost" |
| className={`font-medium ${estimate.cost_alert ? "text-red-600" : "text-foreground"}`} |
| > |
| ${estimate.estimated_usd.toFixed(2)} |
| </p> |
| </div> |
| <div> |
| <p className="text-[10px] text-muted-fg">GPU</p> |
| <p className="text-foreground">{estimate.gpu_type || "CPU"}</p> |
| </div> |
| <div> |
| <p className="text-[10px] text-muted-fg">Est. Time</p> |
| <p className="text-foreground"> |
| {estimate.minutes_used.toFixed(1)} min |
| </p> |
| </div> |
| </div> |
| ) : estimateError ? ( |
| <p className="text-[11px] text-muted-fg"> |
| Estimate unavailable |
| </p> |
| ) : ( |
| <p className="text-[11px] text-muted-fg">Loading estimate...</p> |
| )} |
| </section> |
| |
| {/* Configuration (only visible before run) */} |
| {runState === "configure" && ( |
| <> |
| {/* Provider */} |
| <section> |
| <h3 className="text-[10px] uppercase tracking-wider text-muted-fg font-semibold mb-1"> |
| Provider |
| </h3> |
| <div className="flex gap-2"> |
| {(["runpod", "slurm"] as const).map((p) => ( |
| <button |
| key={p} |
| onClick={() => setProvider(p)} |
| className={`text-[11px] px-3 py-1.5 rounded-md border transition-colors ${ |
| provider === p |
| ? "border-accent bg-accent/10 text-accent font-medium" |
| : "border-border text-muted-fg hover:border-accent/30" |
| }`} |
| > |
| {p} |
| </button> |
| ))} |
| </div> |
| </section> |
| |
| {/* Input file */} |
| <section> |
| <h3 className="text-[10px] uppercase tracking-wider text-muted-fg font-semibold mb-1"> |
| Input File |
| </h3> |
| <input |
| type="file" |
| data-testid="tool-run-file-input" |
| onChange={(e) => setFile(e.target.files?.[0] ?? null)} |
| className="text-[11px] text-foreground file:mr-3 file:px-3 file:py-1.5 file:rounded-md file:border file:border-border file:bg-muted file:text-[11px] file:text-foreground file:cursor-pointer" |
| /> |
| </section> |
| |
| {/* Call params */} |
| <section> |
| <h3 className="text-[10px] uppercase tracking-wider text-muted-fg font-semibold mb-1"> |
| Parameters (JSON) |
| </h3> |
| <textarea |
| data-testid="tool-run-params" |
| value={callParams} |
| onChange={(e) => setCallParams(e.target.value)} |
| rows={4} |
| className="w-full rounded-md border border-border bg-white px-3 py-2 text-xs font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 resize-y" |
| placeholder='{"key": "value"}' |
| /> |
| </section> |
| </> |
| )} |
| |
| {/* Running state */} |
| {isRunning && ( |
| <section data-testid="tool-run-polling" className="py-6 text-center space-y-3"> |
| <div className="w-8 h-8 mx-auto rounded-full border-2 border-accent border-t-transparent animate-spin" /> |
| <p className="text-xs text-foreground font-medium"> |
| {runState === "submitting" |
| ? "Submitting..." |
| : `Running on ${provider}...`} |
| </p> |
| {runState === "polling" && ( |
| <p className="text-[11px] text-muted-fg"> |
| Elapsed: {elapsed}s |
| </p> |
| )} |
| </section> |
| )} |
| |
| {/* Completed */} |
| {runState === "completed" && ( |
| <section data-testid="tool-run-completed" className="space-y-2"> |
| <div className="flex items-center gap-2"> |
| <div className="w-5 h-5 rounded-full bg-success/10 flex items-center justify-center"> |
| <svg |
| className="w-3 h-3 text-success" |
| viewBox="0 0 24 24" |
| fill="none" |
| stroke="currentColor" |
| strokeWidth={3} |
| > |
| <polyline points="20 6 9 17 4 12" /> |
| </svg> |
| </div> |
| <p className="text-xs font-medium text-foreground"> |
| Completed |
| </p> |
| </div> |
| {artifacts.length > 0 && ( |
| <ul className="space-y-1 pl-7"> |
| {artifacts.map((a) => ( |
| <li |
| key={a.id} |
| className="text-[11px] text-foreground font-mono" |
| > |
| {a.name} |
| </li> |
| ))} |
| </ul> |
| )} |
| </section> |
| )} |
| |
| {/* Error */} |
| {error && ( |
| <div |
| data-testid="tool-run-error" |
| className="rounded-md border border-red-200 bg-red-50 px-3 py-2" |
| > |
| <p className="text-[11px] text-red-700">{error}</p> |
| </div> |
| )} |
| </div> |
| |
| {/* Footer */} |
| <div className="px-4 py-3 border-t border-border flex justify-end gap-2"> |
| {runState === "configure" && ( |
| <button |
| data-testid="tool-run-button" |
| onClick={handleRun} |
| disabled={!file} |
| className="px-4 py-2 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent-hover disabled:opacity-40 transition-colors" |
| > |
| Run |
| </button> |
| )} |
| {(runState === "completed" || runState === "failed") && ( |
| <button |
| onClick={onClose} |
| className="px-4 py-2 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent-hover transition-colors" |
| > |
| Done |
| </button> |
| )} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|