Spaces:
Running
Running
| import { useState, useCallback } from "react"; | |
| import type { Artifact, ExperimentDetail as ExperimentDetailType, ExperimentNote } from "../types"; | |
| import { HF_ORG } from "../../config"; | |
| import { deriveDisplayStatus, statusBadgeColor } from "../types"; | |
| import { navigateTo, replaceRoute, parseHash } from "../../hashRouter"; | |
| import Markdown from "./Markdown"; | |
| import TimelineTab from "./TimelineTab"; | |
| import ArtifactsTab from "./ArtifactsTab"; | |
| type Tab = "overview" | "artifacts" | "notes" | "live" | "timeline" | "red_team_brief"; | |
| const LIVE_JOB_STATUS_COLORS: Record<string, string> = { | |
| pending: "text-gray-400", | |
| running: "text-yellow-400", | |
| completed: "text-green-400", | |
| failed: "text-red-400", | |
| blocked: "text-red-400", | |
| }; | |
| const TIMELINE_DOT_COLORS: Record<string, string> = { | |
| blocked: "bg-red-500", | |
| failed: "bg-red-500", | |
| error: "bg-red-500", | |
| completed: "bg-green-500", | |
| started: "bg-yellow-500", | |
| submitted: "bg-blue-500", | |
| }; | |
| interface Props { | |
| experiment: ExperimentDetailType; | |
| onBack: () => void; | |
| onSelectNote: (noteId: string) => void; | |
| onRefresh: () => void; | |
| } | |
| /** Group notes by their directory path */ | |
| function groupNotesByDir(notes: ExperimentNote[]): Map<string, ExperimentNote[]> { | |
| const groups = new Map<string, ExperimentNote[]>(); | |
| for (const note of notes) { | |
| const relPath = note.relative_path || note.filename || ""; | |
| const dir = relPath.includes("/") ? relPath.substring(0, relPath.lastIndexOf("/")) : "(root)"; | |
| if (!groups.has(dir)) groups.set(dir, []); | |
| groups.get(dir)!.push(note); | |
| } | |
| // Sort directories | |
| return new Map([...groups.entries()].sort(([a], [b]) => a.localeCompare(b))); | |
| } | |
| const VALID_TABS = new Set<Tab>(["overview", "artifacts", "notes", "live", "timeline", "red_team_brief"]); | |
| function getInitialTab(): Tab { | |
| const route = parseHash(); | |
| const t = route.params.get("tab"); | |
| if (t && VALID_TABS.has(t as Tab)) return t as Tab; | |
| return "overview"; | |
| } | |
| export default function ExperimentDetail({ experiment, onBack, onSelectNote, onRefresh }: Props) { | |
| const [tab, _setTab] = useState<Tab>(getInitialTab); | |
| const setTab = useCallback((t: Tab) => { | |
| _setTab(t); | |
| const route = parseHash(); | |
| const params = new URLSearchParams(route.params); | |
| if (t === "overview") { | |
| params.delete("tab"); | |
| } else { | |
| params.set("tab", t); | |
| } | |
| replaceRoute({ params }); | |
| }, []); | |
| const liveJobCount = Object.keys(experiment.live_jobs || {}).length; | |
| const isFinished = !!experiment.zayne_findings; | |
| const TABS: { id: Tab; label: string; count?: number }[] = [ | |
| { id: "overview", label: "Overview" }, | |
| ...(experiment.red_team_brief | |
| ? [{ id: "red_team_brief" as Tab, label: "Red Team Brief" }] | |
| : []), | |
| ...(experiment.live_status || liveJobCount > 0 | |
| ? [{ id: "live" as Tab, label: "Live Jobs", count: liveJobCount }] | |
| : []), | |
| { id: "timeline", label: "Timeline", count: experiment.activity_log?.length || 0 }, | |
| { id: "artifacts", label: "Artifacts", count: experiment.artifacts?.length || experiment.hf_repos?.length || 0 }, | |
| { id: "notes", label: "Files", count: experiment.experiment_notes?.length || 0 }, | |
| ]; | |
| return ( | |
| <div className={tab === "overview" ? "h-full overflow-y-auto" : "h-full flex flex-col"}> | |
| {/* Header */} | |
| <div className="px-6 py-4 border-b border-gray-800"> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <button | |
| onClick={onBack} | |
| className="text-gray-400 hover:text-gray-200 text-sm transition-colors" | |
| > | |
| ← Experiments | |
| </button> | |
| </div> | |
| <div className="flex items-start justify-between"> | |
| <div> | |
| <div className="flex items-center gap-2"> | |
| <h1 className="text-lg font-semibold text-gray-200">{experiment.name}</h1> | |
| {isFinished && ( | |
| <span className="text-xs px-2 py-0.5 rounded-full font-medium bg-emerald-900 text-emerald-300 border border-emerald-700"> | |
| Finished | |
| </span> | |
| )} | |
| {experiment.live_status && !isFinished && ( | |
| <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadgeColor(deriveDisplayStatus(experiment))}`}> | |
| {deriveDisplayStatus(experiment).replace("_", " ")} | |
| </span> | |
| )} | |
| </div> | |
| {experiment.live_message && ( | |
| <p className="text-xs text-cyan-400/80 mt-0.5">{experiment.live_message}</p> | |
| )} | |
| {experiment.zayne_summary ? ( | |
| <div className="mt-1 max-w-2xl"> | |
| <span className="text-[10px] font-bold uppercase tracking-wider text-amber-400/80">Researcher's Summary</span> | |
| <div className="text-sm text-gray-300 mt-0.5"> | |
| <Markdown content={experiment.zayne_summary} /> | |
| </div> | |
| </div> | |
| ) : experiment.hypothesis?.statement ? ( | |
| <p className="text-sm text-gray-400 mt-1 max-w-2xl italic"> | |
| {experiment.hypothesis.statement} | |
| </p> | |
| ) : null} | |
| </div> | |
| </div> | |
| {/* Detail tabs */} | |
| <div className="flex gap-1 mt-4"> | |
| {TABS.map((t) => ( | |
| <button | |
| key={t.id} | |
| onClick={() => setTab(t.id)} | |
| className={`px-3 py-1.5 text-sm rounded-t transition-colors ${ | |
| tab === t.id | |
| ? "bg-gray-800 text-gray-200 border border-gray-700 border-b-gray-800" | |
| : "text-gray-500 hover:text-gray-300" | |
| }`} | |
| > | |
| {t.label} | |
| {t.count !== undefined && ( | |
| <span className="ml-1 text-xs text-gray-500">({t.count})</span> | |
| )} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Tab content */} | |
| <div className={tab === "overview" ? "p-6" : "flex-1 overflow-y-auto p-6"}> | |
| {tab === "overview" && ( | |
| <div className="space-y-4"> | |
| {/* FINDINGS (above readme, only when filled) */} | |
| {experiment.zayne_findings && ( | |
| <div className="border-l-4 border-emerald-500 bg-emerald-950/30 rounded-r p-4"> | |
| <span className="text-[10px] font-bold uppercase tracking-wider text-emerald-400">Findings</span> | |
| <div className="mt-2"> | |
| <Markdown content={experiment.zayne_findings} /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Researcher's README */} | |
| {experiment.zayne_readme && ( | |
| <div className="border-l-4 border-amber-500 bg-gray-900/80 rounded-r p-4"> | |
| <span className="text-[10px] font-bold uppercase tracking-wider text-amber-400">Researcher's README</span> | |
| <div className="mt-2"> | |
| <Markdown content={experiment.zayne_readme} /> | |
| </div> | |
| </div> | |
| )} | |
| {/* DECISIONS (below readme, only when filled) */} | |
| {experiment.zayne_decisions && ( | |
| <div className="border-l-4 border-violet-500 bg-violet-950/30 rounded-r p-4"> | |
| <span className="text-[10px] font-bold uppercase tracking-wider text-violet-400">Decisions</span> | |
| <div className="mt-2"> | |
| <Markdown content={experiment.zayne_decisions} /> | |
| </div> | |
| </div> | |
| )} | |
| {/* Agent Notes (EXPERIMENT_README) - always shown */} | |
| {experiment.notes && ( | |
| <div className="border-l-4 border-cyan-500 bg-gray-900/80 rounded-r p-4"> | |
| <span className="text-[10px] font-bold uppercase tracking-wider text-cyan-400">Experiment Notes</span> | |
| <div className="mt-2"> | |
| <Markdown content={experiment.notes} /> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {tab === "artifacts" && ( | |
| <ArtifactsTab | |
| artifacts={experiment.artifacts || []} | |
| hfRepos={experiment.hf_repos || []} | |
| runs={experiment.runs || []} | |
| onOpenArtifact={(artifact: Artifact) => { | |
| const INLINE_TYPES = new Set(["table", "yaml_config", "plotly", "image"]); | |
| if (!artifact.visualizer_type || INLINE_TYPES.has(artifact.visualizer_type)) { | |
| return; | |
| } | |
| const vizTabMap: Record<string, string> = { | |
| model_trace: "model", | |
| }; | |
| const vizTab = vizTabMap[artifact.visualizer_type]; | |
| if (vizTab) { | |
| const fullName = artifact.dataset_name.includes("/") | |
| ? artifact.dataset_name | |
| : `${HF_ORG}/${artifact.dataset_name}`; | |
| navigateTo({ | |
| page: "viz", | |
| tab: vizTab, | |
| params: new URLSearchParams({ | |
| repos: fullName, | |
| from_exp: experiment.id, | |
| }), | |
| }); | |
| } | |
| }} | |
| /> | |
| )} | |
| {tab === "red_team_brief" && experiment.red_team_brief && ( | |
| <div> | |
| <div className="border-l-4 border-cyan-500 bg-gray-900/80 rounded-r p-6"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <span className="text-red-400 text-lg">⚠</span> | |
| <span className="text-[10px] font-bold uppercase tracking-wider text-cyan-400">Claude Code: Red Team Brief</span> | |
| </div> | |
| <Markdown content={experiment.red_team_brief} /> | |
| </div> | |
| </div> | |
| )} | |
| {tab === "notes" && ( | |
| <div> | |
| <div className="flex justify-between items-center mb-4"> | |
| <h2 className="text-sm font-medium text-gray-300">Project Files</h2> | |
| <span className="text-xs text-gray-500"> | |
| {experiment.experiment_notes?.length || 0} files | |
| </span> | |
| </div> | |
| {(experiment.experiment_notes || []).length === 0 ? ( | |
| <p className="text-sm text-gray-500">No project files found.</p> | |
| ) : ( | |
| <div className="space-y-4"> | |
| {[...groupNotesByDir(experiment.experiment_notes)].map(([dir, files]) => ( | |
| <div key={dir}> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <span className="text-xs text-cyan-400/70 font-mono">{dir}/</span> | |
| <span className="text-xs text-gray-600">({files.length})</span> | |
| </div> | |
| <div className="grid gap-1 ml-3"> | |
| {files.map((note) => ( | |
| <button | |
| key={note.id} | |
| onClick={() => onSelectNote(note.id)} | |
| className="w-full text-left bg-gray-900 hover:bg-gray-800 border border-gray-800 hover:border-gray-700 rounded px-3 py-2 transition-colors group" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs text-gray-500"> | |
| {note.filename.endsWith(".yaml") || note.filename.endsWith(".yml") ? "⚙" : "☰"} | |
| </span> | |
| <span className="text-sm text-gray-300 group-hover:text-gray-200 font-mono"> | |
| {note.filename} | |
| </span> | |
| </div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {tab === "timeline" && ( | |
| <TimelineTab | |
| entries={experiment.activity_log || []} | |
| onArtifactClick={(datasetName) => console.log("artifact clicked:", datasetName)} | |
| /> | |
| )} | |
| {tab === "live" && ( | |
| <div className="space-y-6"> | |
| {/* Unreachable clusters warning */} | |
| {experiment.unreachable_clusters && Object.keys(experiment.unreachable_clusters).length > 0 && ( | |
| <div className="bg-orange-900/20 border border-orange-800/50 rounded-lg p-3"> | |
| <h3 className="text-xs font-medium text-orange-400 uppercase tracking-wide mb-2">Unreachable Clusters</h3> | |
| <div className="space-y-1"> | |
| {Object.entries(experiment.unreachable_clusters).map(([cluster, info]) => ( | |
| <div key={cluster} className="flex items-center justify-between text-sm"> | |
| <span className="text-orange-300 font-medium">{cluster}</span> | |
| <span className="text-orange-400/70 text-xs"> | |
| {info.reason} (since {new Date(info.since).toLocaleString()}) | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Jobs table */} | |
| <div> | |
| <h2 className="text-sm font-medium text-gray-300 mb-3">Active Jobs</h2> | |
| {liveJobCount === 0 ? ( | |
| <p className="text-sm text-gray-500">No live jobs tracked.</p> | |
| ) : ( | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-sm"> | |
| <thead> | |
| <tr className="text-xs text-gray-500 uppercase tracking-wide border-b border-gray-800"> | |
| <th className="text-left py-2 px-2">ID</th> | |
| <th className="text-left py-2 px-2">Cluster</th> | |
| <th className="text-left py-2 px-2">GPUs</th> | |
| <th className="text-left py-2 px-2">Status</th> | |
| <th className="text-left py-2 px-2">Message</th> | |
| <th className="text-left py-2 px-2">ETA</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {Object.entries(experiment.live_jobs || {}).map(([jobId, job]) => ( | |
| <tr | |
| key={jobId} | |
| className={`border-b border-gray-800/50 ${ | |
| job.status === "blocked" ? "bg-red-900/10" : | |
| job.status === "failed" ? "bg-red-900/10" : | |
| "hover:bg-gray-900/50" | |
| }`} | |
| > | |
| <td className="py-2 px-2 text-gray-300 font-mono text-xs"> | |
| {jobId} | |
| {job.slurm_job_id && ( | |
| <span className="text-gray-600 ml-1">({job.slurm_job_id})</span> | |
| )} | |
| </td> | |
| <td className="py-2 px-2 text-gray-400"> | |
| {job.cluster} | |
| {job.partition && ( | |
| <span className="text-gray-600 text-xs ml-1">/{job.partition}</span> | |
| )} | |
| </td> | |
| <td className="py-2 px-2 text-gray-400">{job.gpus}</td> | |
| <td className={`py-2 px-2 font-medium ${LIVE_JOB_STATUS_COLORS[job.status] || "text-gray-400"}`}> | |
| {job.status} | |
| {job.blocker && ( | |
| <span className="text-red-400/70 text-xs ml-1"> | |
| ({job.blocker.reason}) | |
| </span> | |
| )} | |
| </td> | |
| <td className="py-2 px-2 text-gray-400 text-xs max-w-xs truncate"> | |
| {job.message || "-"} | |
| </td> | |
| <td className="py-2 px-2 text-gray-500 text-xs"> | |
| {job.estimated_completion | |
| ? new Date(job.estimated_completion).toLocaleString() | |
| : "-"} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </div> | |
| {/* Job metrics summary */} | |
| {liveJobCount > 0 && ( | |
| <div> | |
| <h2 className="text-sm font-medium text-gray-300 mb-3">Job Metrics</h2> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-2"> | |
| {Object.entries(experiment.live_jobs || {}).map(([jobId, job]) => | |
| Object.keys(job.metrics || {}).length > 0 ? ( | |
| <div key={jobId} className="bg-gray-900 rounded p-3 border border-gray-800"> | |
| <span className="text-xs text-gray-500 font-mono">{jobId}</span> | |
| <div className="mt-1 space-y-0.5"> | |
| {Object.entries(job.metrics).map(([k, v]) => ( | |
| <div key={k} className="flex justify-between text-xs"> | |
| <span className="text-gray-400">{k}</span> | |
| <span className="text-gray-300 font-mono"> | |
| {typeof v === "number" ? v.toFixed(3) : v} | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ) : null | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Timeline */} | |
| <div> | |
| <h2 className="text-sm font-medium text-gray-300 mb-3">Event Timeline</h2> | |
| {(!experiment.live_history || experiment.live_history.length === 0) ? ( | |
| <p className="text-sm text-gray-500">No timeline events recorded.</p> | |
| ) : ( | |
| <div className="space-y-0"> | |
| {experiment.live_history.slice(-10).reverse().map((entry, i) => { | |
| const eventLower = entry.event.toLowerCase(); | |
| const dotColor = | |
| TIMELINE_DOT_COLORS[eventLower] || | |
| (eventLower.includes("block") || eventLower.includes("fail") || eventLower.includes("error") | |
| ? "bg-red-500" | |
| : "bg-gray-500"); | |
| return ( | |
| <div key={i} className="flex items-start gap-3 py-2 border-l-2 border-gray-800 pl-4 relative"> | |
| <div className={`absolute -left-[5px] top-3 w-2 h-2 rounded-full ${dotColor}`} /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs font-medium text-gray-300">{entry.event}</span> | |
| {entry.cluster && ( | |
| <span className="text-xs text-gray-500">{entry.cluster}</span> | |
| )} | |
| {entry.job_id && ( | |
| <span className="text-xs text-gray-600 font-mono">{entry.job_id}</span> | |
| )} | |
| </div> | |
| {entry.message && ( | |
| <p className="text-xs text-gray-400 mt-0.5 truncate">{entry.message}</p> | |
| )} | |
| <span className="text-xs text-gray-600"> | |
| {new Date(entry.timestamp).toLocaleString()} | |
| </span> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </div> | |
| {/* Timestamps */} | |
| {(experiment.live_started_at || experiment.live_updated_at) && ( | |
| <div className="flex gap-6 text-xs text-gray-600 border-t border-gray-800 pt-3"> | |
| {experiment.live_started_at && ( | |
| <span>Started: {new Date(experiment.live_started_at).toLocaleString()}</span> | |
| )} | |
| {experiment.live_updated_at && ( | |
| <span>Last update: {new Date(experiment.live_updated_at).toLocaleString()}</span> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |