annotation-studio / frontend /src /JobsView.tsx
pepijn223's picture
pepijn223 HF Staff
Add separate left and right hand or gripper activity timelines
cddaad2 verified
Raw History Blame Contribute Delete
17 kB
import { useState, type ReactNode } from "react";
import {
ArrowUpRight,
ChevronDown,
LoaderCircle,
Plus,
RefreshCw,
} from "lucide-react";
import "./jobs.css";
import SubtaskViews from "./SubtaskViews";
export type Job = {
id: string;
name?: string;
stage: string;
created_at: string;
owner: string;
output?: string;
};
export type Progress = {
model?: string;
captioner?: string;
status: string;
expected: number;
completed: number;
exported?: number;
export_pending?: number;
failed: number;
failed_recordings?: {
source_episode_index: number;
recording: string;
hand: string;
stage: string;
error: string;
}[];
stopped?: number;
pending?: number;
usage: {
measured_inference_usd?: number;
successful_calls?: number;
input_tokens?: number;
output_tokens?: number;
rate_limited_requests?: number;
media_usd?: number;
completed_media_requests?: number;
};
cost?: {
inference_usd: number | null;
cpu_usd: number | null;
total_usd: number | null;
uncertain_inference_usd: number;
running_seconds: number | null;
basis: string;
billing_url: string;
};
annotation_url?: string | null;
hand_visualizer_url?: string | null;
output_mode?: string;
subtask_views?: "primary" | "per-camera" | "per-hand";
subtask_cameras?: string[];
human_videos?: Record<string, number> | null;
};
type HumanVideo = {
episode_index?: number | null;
source_episode_index: number;
segment_index?: number;
subtask?: string;
caption?: string | null;
status: string;
robot_frame?: string;
first_frame?: string;
video?: string;
};
function HumanVideos({ jobId }: { jobId: string }) {
const [videos, setVideos] = useState<HumanVideo[] | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function load() {
setLoading(true);
setError("");
try {
const response = await fetch(`/api/jobs/${jobId}/human-videos`, {
credentials: "same-origin",
});
if (!response.ok) throw new Error("No human demo videos saved yet.");
setVideos(await response.json());
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
}
if (!videos)
return (
<div className="human-videos">
<button className="text-action" onClick={load} disabled={loading}>
{loading ? "Loading videos…" : "Preview human demo videos"}
</button>
{error && <p className="job-cost-note">{error}</p>}
</div>
);
const ready = videos.filter((v) => v.status === "completed" && v.video);
return (
<div className="human-videos">
{ready.length === 0 && <p>No completed videos yet.</p>}
<div className="human-video-grid">
{ready.map((v) => (
<figure key={`${v.source_episode_index}-${v.segment_index}`}>
<div className="human-video-pair">
{v.robot_frame && (
<img src={v.robot_frame} alt="Robot view" loading="lazy" />
)}
<video
src={v.video}
poster={v.first_frame}
controls
muted
loop
playsInline
preload="none"
/>
</div>
<figcaption>
<strong>
Episode {v.episode_index ?? v.source_episode_index}
</strong>{" "}
· {v.subtask}
</figcaption>
</figure>
))}
</div>
</div>
);
}
const terminal = new Set(["COMPLETED", "ERROR", "CANCELED", "DELETED"]);
const usd = (value: number | null | undefined) =>
value == null
? "—"
: new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: value > 0 && value < 1 ? 4 : 2,
maximumFractionDigits: 4,
}).format(value);
function status(job: Job, progress?: Progress) {
if (job.stage === "ERROR") return "Failed";
if (job.stage === "COMPLETED")
return progress?.status === "partial" ? "Partial" : "Completed";
if (job.stage === "CANCELED") return "Canceled";
if (["PENDING", "QUEUED"].includes(job.stage)) return "Queued";
if (
progress?.expected &&
progress.completed === progress.expected &&
progress.export_pending
)
return "Exporting";
return job.stage[0] + job.stage.slice(1).toLowerCase();
}
export default function JobsView({
jobs,
progress,
models,
signedIn,
loading,
signIn,
onRefresh,
onNew,
onCancel,
}: {
jobs: Job[];
progress: Record<string, Progress>;
models: { route: string; name: string; provider_name: string }[];
signedIn: boolean;
loading: boolean;
signIn: ReactNode;
onRefresh: () => void;
onNew: () => void;
onCancel: (id: string) => void;
}) {
return (
<div className="jobs-page">
<div className="page-heading">
<h1>Jobs</h1>
<div className="heading-actions">
<button
className="icon-button"
onClick={onRefresh}
disabled={!signedIn || loading}
aria-label="Refresh jobs"
>
<RefreshCw size={17} className={loading ? "spin" : ""} />
</button>
<button className="button primary" onClick={onNew}>
<Plus size={15} /> New job
</button>
</div>
</div>
{!signedIn ? (
<div className="jobs-empty">
<p>Sign in to see your jobs.</p>
{signIn}
</div>
) : jobs.length === 0 ? (
<div className="jobs-empty">
{loading ? (
<>
<LoaderCircle size={20} className="spin" />
<p>Loading jobs…</p>
</>
) : (
<>
<p>No jobs yet.</p>
<button className="button secondary" onClick={onNew}>
New annotation
</button>
</>
)}
</div>
) : (
<div className="compact-jobs">
<div className="jobs-columns" aria-hidden="true">
<span>Job</span>
<span>Status</span>
<span>Episodes</span>
<span>Usage cost</span>
<span />
</div>
{jobs.map((job) => {
const p = progress[job.id];
const ended = terminal.has(job.stage);
const state = status(job, p);
const profile = models.find((m) => m.route === p?.model);
const model =
p?.captioner === "flora"
? "Flora · TempSeg + Captioner"
: profile?.name || p?.model?.split(":")[0].split("/").pop();
const provider =
p?.captioner === "flora"
? "H200 GPU"
: profile?.provider_name || p?.model?.split(":")[1];
const name =
job.output?.split("/")[1] || job.name || "Annotation job";
const logs = `https://huggingface.co/jobs/${job.owner}/${job.id}`;
const results =
job.output && p?.completed
? `https://huggingface.co/datasets/${job.output}`
: null;
const annotations = p?.annotation_url;
const hands = p?.hand_visualizer_url;
const action = hands
? "View 3D hands"
: annotations
? "Show annotations"
: results
? "Results"
: "Logs";
const knownCost = p?.cost?.total_usd ?? p?.cost?.cpu_usd;
const incompleteCost =
p?.cost &&
(p.cost.total_usd == null ||
p.cost.uncertain_inference_usd > 0.000001);
return (
<details className="compact-job" key={job.id}>
<summary className="compact-job-row">
<span className="job-identity">
<strong title={job.output || name}>{name}</strong>
<span>
<time dateTime={job.created_at}>
{new Date(job.created_at).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</time>
{model && (
<>
<span className="job-meta-separator">·</span>
<span className="job-model">{model}</span>
</>
)}
</span>
</span>
<span className={`compact-status ${state.toLowerCase()}`}>
<i />
{state}
</span>
<span className="compact-progress">
<span>
{p?.expected
? `${p.completed.toLocaleString()} / ${p.expected.toLocaleString()} labeled`
: "—"}
</span>
{p?.exported != null && (
<small className="export-progress">
{p.exported.toLocaleString()} ready to view
</small>
)}
{!ended && !!p?.expected && (
<progress
max={p.expected}
value={p.completed}
aria-label={`${name} episodes completed`}
/>
)}
</span>
<span
className="compact-cost"
title={
incompleteCost
? "Some usage is unconfirmed. Expand for details."
: "Usage-derived cost"
}
>
<span className="mobile-label">Cost </span>
{usd(knownCost)}
{incompleteCost && knownCost != null && <sup>*</sup>}
</span>
<span className="compact-job-action">
<a
href={hands || annotations || results || logs}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
>
{action}
<ArrowUpRight size={13} />
</a>
<ChevronDown
size={15}
className="job-expand"
aria-hidden="true"
/>
</span>
</summary>
<div className="compact-job-details">
<dl>
<div>
<dt>Model</dt>
<dd>
{model
? `${model}${provider ? ` · ${provider}` : ""}`
: "—"}
</dd>
</div>
<div>
<dt>Episodes</dt>
<dd>
{p
? `${p.completed} labeled · ${p.exported ?? 0} ready to view${p.failed ? ` · ${p.failed} failed` : ""}${p.pending ? ` · ${p.pending} pending` : ""}`
: "—"}
</dd>
</div>
<div>
<dt>Inference</dt>
<dd>{usd(p?.cost?.inference_usd)}</dd>
</div>
<div>
<dt>Compute</dt>
<dd>{usd(p?.cost?.cpu_usd)}</dd>
</div>
<div>
<dt>Model calls</dt>
<dd>
{p?.usage.successful_calls?.toLocaleString() ?? "—"}
</dd>
</div>
{p?.human_videos && (
<div>
<dt>Human videos</dt>
<dd>
{(p.human_videos.completed || 0).toLocaleString()}
{p.human_videos.failed
? ` · ${p.human_videos.failed} failed`
: ""}
{p.usage.media_usd
? ` · ${usd(p.usage.media_usd)}`
: ""}
</dd>
</div>
)}
</dl>
{p?.subtask_views === "per-camera" && !!p.completed && (
<SubtaskViews jobId={job.id} />
)}
{p?.subtask_views === "per-hand" && !!p.completed && (
<SubtaskViews jobId={job.id} actors />
)}
{!!p?.human_videos?.completed && (
<HumanVideos jobId={job.id} />
)}
{p &&
(p.failed > 0 ||
(p.stopped || 0) > 0 ||
(p.pending || 0) > 0) && (
<p className="job-outcomes">
{[
p.failed ? `${p.failed} failed` : "",
p.stopped ? `${p.stopped} stopped` : "",
p.pending ? `${p.pending} not processed` : "",
]
.filter(Boolean)
.join(" · ")}
</p>
)}
{!!p?.failed_recordings?.length && (
<details className="job-recording-failures">
<summary>
Failed recordings ({p.failed_recordings.length})
</summary>
<ul>
{p.failed_recordings.map((r) => (
<li key={r.source_episode_index}>
<strong>
{r.recording}/{r.hand}
</strong>{" "}
· source episode {r.source_episode_index} ·{" "}
{r.stage.replace(/_/g, " ")}
<p>{r.error.split(": {")[0]}</p>
</li>
))}
</ul>
{job.output && (
<a
href={`https://huggingface.co/datasets/${job.output}/blob/main/annotation_studio/failures.json`}
target="_blank"
rel="noreferrer"
>
Full failure report <ArrowUpRight size={12} />
</a>
)}
</details>
)}
{incompleteCost && (
<p className="job-cost-note">
{p.cost!.uncertain_inference_usd > 0.000001
? `Unconfirmed inference: up to ${usd(p.cost!.uncertain_inference_usd)} extra.`
: "Some usage has not been reported yet."}
</p>
)}
<div className="job-detail-actions">
{hands && annotations && (
<a href={annotations} target="_blank" rel="noreferrer">
Language annotations <ArrowUpRight size={12} />
</a>
)}
<a href={logs} target="_blank" rel="noreferrer">
Logs <ArrowUpRight size={12} />
</a>
{results && (
<a href={results} target="_blank" rel="noreferrer">
Output files <ArrowUpRight size={12} />
</a>
)}
{p?.cost && (
<a
href={p.cost.billing_url}
target="_blank"
rel="noreferrer"
>
Billing <ArrowUpRight size={12} />
</a>
)}
{!ended && (
<button
className="text-danger"
onClick={() => onCancel(job.id)}
>
Cancel job
</button>
)}
</div>
<p className="job-billing-note">
Usage estimates; final billing may differ.
</p>
</div>
</details>
);
})}
</div>
)}
</div>
);
}