Extract WebSocket connection management from LivePage into a reusable custom hook. Supports connect/disconnect/reconnect/send, experiment event filtering, configurable backoff, and enabled flag. 20 tests added.
376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import { useParams, useNavigate, Link } from "react-router-dom";
|
|
import {
|
|
experiments,
|
|
runs as runsApi,
|
|
ApiError,
|
|
} from "../api/client";
|
|
import type {
|
|
ExperimentResponse,
|
|
RunResponse,
|
|
} from "../api/client";
|
|
import { useExperimentWS } from "../hooks/useExperimentWS";
|
|
import type { WsEvent, WsEventType, ConnectionStatus } from "../hooks/useExperimentWS";
|
|
import Leaderboard from "../components/Leaderboard";
|
|
import type { LeaderboardRow } from "../components/Leaderboard";
|
|
import Timeline from "../components/Timeline";
|
|
import type { TimelineEntry } from "../components/Timeline";
|
|
import SteeringControls from "../components/SteeringControls";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let _timelineIdCounter = 0;
|
|
function nextTimelineId(): string {
|
|
_timelineIdCounter += 1;
|
|
return `tl-${_timelineIdCounter}`;
|
|
}
|
|
|
|
function configSummary(config?: Record<string, unknown>): string {
|
|
if (!config) return "—";
|
|
const model = config.model ?? config.model_used ?? "";
|
|
const temp = config.temperature != null ? `t=${config.temperature}` : "";
|
|
const parts = [model, temp].filter(Boolean);
|
|
return parts.length > 0 ? parts.join(" ") : JSON.stringify(config).slice(0, 60);
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Connection Status Indicator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ConnectionIndicator({ status }: { status: ConnectionStatus }) {
|
|
const colors: Record<ConnectionStatus, string> = {
|
|
connected: "bg-green-500",
|
|
connecting: "bg-amber-500 animate-pulse",
|
|
disconnected: "bg-red-500",
|
|
};
|
|
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
|
|
<span
|
|
data-testid="connection-indicator"
|
|
className={`h-2 w-2 rounded-full ${colors[status]}`}
|
|
/>
|
|
{status === "connected"
|
|
? "Live"
|
|
: status === "connecting"
|
|
? "Connecting…"
|
|
: "Disconnected"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live Page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function LivePage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
// Experiment state
|
|
const [experiment, setExperiment] = useState<ExperimentResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [expStatus, setExpStatus] = useState("idle");
|
|
|
|
// Timeline
|
|
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
|
const [autoScroll, setAutoScroll] = useState(true);
|
|
const [eventFilter, setEventFilter] = useState<WsEventType | "all">("all");
|
|
// Leaderboard
|
|
const [leaderboard, setLeaderboard] = useState<LeaderboardRow[]>([]);
|
|
const [bestRunId, setBestRunId] = useState<string | null>(null);
|
|
|
|
// Progress
|
|
const [progress, setProgress] = useState({
|
|
completed: 0,
|
|
total: 0,
|
|
cache_hits: 0,
|
|
tokens_total: 0,
|
|
cost_total: 0,
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Load experiment
|
|
// -------------------------------------------------------------------------
|
|
|
|
const loadExperiment = useCallback(async () => {
|
|
if (!id) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const exp = await experiments.get(id);
|
|
setExperiment(exp);
|
|
setExpStatus(exp.status);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
setError(`Failed to load experiment (${err.status}).`);
|
|
} else {
|
|
setError("Network error. Is the server running?");
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [id]);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Load initial leaderboard
|
|
// -------------------------------------------------------------------------
|
|
|
|
const loadLeaderboard = useCallback(async () => {
|
|
if (!id) return;
|
|
try {
|
|
const resp = await runsApi.leaderboard(id);
|
|
const rows: LeaderboardRow[] = resp.items.map((r: RunResponse) => ({
|
|
run_id: r.id,
|
|
config_summary: configSummary(r.config),
|
|
scores: {},
|
|
weighted_total: 0,
|
|
status: r.status,
|
|
cached: false,
|
|
}));
|
|
setLeaderboard(rows);
|
|
if (rows.length > 0) {
|
|
setBestRunId(rows[0].run_id);
|
|
}
|
|
} catch {
|
|
// Non-critical — leaderboard will populate via WebSocket
|
|
}
|
|
}, [id]);
|
|
|
|
useEffect(() => {
|
|
loadExperiment();
|
|
loadLeaderboard();
|
|
}, [loadExperiment, loadLeaderboard]);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Process incoming WS events
|
|
// -------------------------------------------------------------------------
|
|
|
|
const processEvent = useCallback(
|
|
(evt: WsEvent) => {
|
|
const now = new Date(evt.timestamp ?? Date.now());
|
|
|
|
// Build timeline entry
|
|
let message = "";
|
|
let detail: string | undefined;
|
|
|
|
switch (evt.type) {
|
|
case "run.started":
|
|
message = `Run started: ${configSummary(evt.config)}`;
|
|
break;
|
|
case "run.completed":
|
|
message = `Run completed: ${configSummary(evt.config)}`;
|
|
detail =
|
|
evt.weighted_total != null
|
|
? `Score: ${evt.weighted_total.toFixed(3)}`
|
|
: undefined;
|
|
break;
|
|
case "new_best_found":
|
|
message = `New best config found!`;
|
|
detail =
|
|
evt.weighted_total != null
|
|
? `Score: ${evt.weighted_total.toFixed(3)}`
|
|
: undefined;
|
|
break;
|
|
case "cache_hit":
|
|
message = `Cache hit: ${configSummary(evt.config)}`;
|
|
break;
|
|
case "run.failed":
|
|
message = `Run failed: ${evt.error ?? "unknown error"}`;
|
|
break;
|
|
case "sweep.progress":
|
|
message = `Progress: ${evt.progress?.completed ?? 0}/${evt.progress?.total ?? 0} runs`;
|
|
break;
|
|
case "sweep.completed":
|
|
message = "Sweep completed!";
|
|
setExpStatus("completed");
|
|
break;
|
|
default:
|
|
message = evt.type;
|
|
}
|
|
|
|
setTimeline((prev) => [
|
|
...prev,
|
|
{
|
|
id: nextTimelineId(),
|
|
type: evt.type,
|
|
run_id: evt.run_id,
|
|
message,
|
|
detail,
|
|
timestamp: now,
|
|
},
|
|
]);
|
|
|
|
// Update leaderboard
|
|
if (
|
|
(evt.type === "run.completed" || evt.type === "new_best_found") &&
|
|
evt.run_id
|
|
) {
|
|
const row: LeaderboardRow = {
|
|
run_id: evt.run_id,
|
|
config_summary: configSummary(evt.config),
|
|
scores: evt.scores ?? {},
|
|
weighted_total: evt.weighted_total ?? 0,
|
|
status: "completed",
|
|
cached: evt.cached ?? false,
|
|
};
|
|
|
|
setLeaderboard((prev) => {
|
|
const existing = prev.findIndex((r) => r.run_id === row.run_id);
|
|
if (existing >= 0) {
|
|
const next = [...prev];
|
|
next[existing] = row;
|
|
return next;
|
|
}
|
|
return [...prev, row];
|
|
});
|
|
|
|
if (evt.type === "new_best_found") {
|
|
setBestRunId(evt.run_id);
|
|
}
|
|
}
|
|
|
|
if (evt.type === "cache_hit" && evt.run_id) {
|
|
const row: LeaderboardRow = {
|
|
run_id: evt.run_id,
|
|
config_summary: configSummary(evt.config),
|
|
scores: evt.scores ?? {},
|
|
weighted_total: evt.weighted_total ?? 0,
|
|
status: "completed",
|
|
cached: true,
|
|
};
|
|
setLeaderboard((prev) => {
|
|
const existing = prev.findIndex((r) => r.run_id === row.run_id);
|
|
if (existing >= 0) return prev;
|
|
return [...prev, row];
|
|
});
|
|
}
|
|
|
|
// Update progress
|
|
if (evt.progress) {
|
|
setProgress(evt.progress);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// -------------------------------------------------------------------------
|
|
// WebSocket connection via hook
|
|
// -------------------------------------------------------------------------
|
|
|
|
const { connectionStatus } = useExperimentWS(id, {
|
|
onEvent: processEvent,
|
|
enabled: !loading && !error,
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Render
|
|
// -------------------------------------------------------------------------
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 flex items-center justify-center">
|
|
<p className="text-slate-500 dark:text-slate-400 animate-pulse">
|
|
Loading experiment…
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-8">
|
|
<div className="mx-auto max-w-2xl">
|
|
<div
|
|
role="alert"
|
|
className="rounded-xl bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 p-6 text-center"
|
|
>
|
|
<p className="text-red-700 dark:text-red-300">{error}</p>
|
|
<button
|
|
type="button"
|
|
onClick={loadExperiment}
|
|
className="mt-3 text-sm font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 py-6">
|
|
<div className="mx-auto max-w-7xl">
|
|
{/* Header */}
|
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
to={`/experiments/${id}`}
|
|
className="text-sm text-indigo-600 dark:text-indigo-400 hover:text-indigo-500"
|
|
>
|
|
← Experiment
|
|
</Link>
|
|
<ConnectionIndicator status={connectionStatus} />
|
|
</div>
|
|
<h1 className="mt-1 text-2xl font-bold text-slate-900 dark:text-white">
|
|
{experiment?.name ?? "Live Dashboard"}
|
|
</h1>
|
|
{experiment?.description && (
|
|
<p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">
|
|
{experiment.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main layout: 60/40 split */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
|
{/* Left column — 60% */}
|
|
<div className="lg:col-span-3 space-y-6">
|
|
<Timeline
|
|
entries={timeline}
|
|
eventFilter={eventFilter}
|
|
onEventFilterChange={(f) => setEventFilter(f as WsEventType | "all")}
|
|
autoScroll={autoScroll}
|
|
onAutoScrollToggle={() => setAutoScroll(!autoScroll)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Right column — 40% */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* Steering Controls */}
|
|
<div className="rounded-xl bg-white dark:bg-slate-800 shadow ring-1 ring-slate-200 dark:ring-slate-700 p-4">
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
|
Controls
|
|
</h2>
|
|
<SteeringControls
|
|
experimentId={id!}
|
|
experimentStatus={expStatus}
|
|
progress={progress}
|
|
onStatusChange={setExpStatus}
|
|
/>
|
|
</div>
|
|
|
|
{/* Leaderboard */}
|
|
<div className="rounded-xl bg-white dark:bg-slate-800 shadow ring-1 ring-slate-200 dark:ring-slate-700 overflow-hidden">
|
|
<div className="border-b border-slate-200 dark:border-slate-700 px-4 py-3">
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">
|
|
Leaderboard
|
|
</h2>
|
|
</div>
|
|
<div className="p-4">
|
|
<Leaderboard rows={leaderboard} bestRunId={bestRunId} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|