/** * ComputeJobStatus — live status for an async image/video job. * * Given a jobId it polls /v1/jobs/{id} and renders queue → routing → running → * done, the selected device and GPU seconds (so the user sees *where* it ran), * and the resulting artifacts or an actionable error. Polling stops on a * terminal state. Polling is used rather than SSE so the component has no * transport dependency; the compute-client also exposes event subscription for * apps that inject one. */ import React, { useEffect, useRef, useState } from "react"; import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react"; import type { Job } from "@homepilot/types"; import { computeClient } from "../../api"; const TERMINAL = new Set(["succeeded", "failed", "canceled"]); export default function ComputeJobStatus({ jobId, pollMs = 1500, }: { jobId: string; pollMs?: number; }) { const [job, setJob] = useState(null); const [error, setError] = useState(null); const timer = useRef | null>(null); useEffect(() => { let cancelled = false; const tick = async () => { try { const j = await computeClient.getJobStatus(jobId); if (cancelled) return; setJob(j); setError(null); if (!TERMINAL.has(j.status)) { timer.current = setTimeout(tick, pollMs); } } catch (e) { if (cancelled) return; setError(e instanceof Error ? e.message : "Failed to fetch job"); timer.current = setTimeout(tick, pollMs * 2); } }; void tick(); return () => { cancelled = true; if (timer.current) clearTimeout(timer.current); }; }, [jobId, pollMs]); if (error && !job) { return (
{error}
); } if (!job) { return (
Starting…
); } const pct = Math.max(0, Math.min(100, Math.round(job.progress || 0))); const running = !TERMINAL.has(job.status); return (
{job.status === "succeeded" ? ( ) : job.status === "failed" || job.status === "canceled" ? ( ) : ( )} {job.status} {job.model && · {job.model}} {job.selectedDeviceId ? `on ${job.selectedDeviceId}` : ""} {job.gpuSeconds != null ? ` · ${job.gpuSeconds.toFixed(1)}s GPU` : ""}
{running && (
)} {job.error && (
{job.error.message} ({job.error.code})
)} {job.output?.artifacts?.length ? (
{job.output.artifacts.map((a, i) => a.contentType.startsWith("image/") ? ( {`artifact ) : ( Artifact {i + 1} ), )}
) : null}
); }