promptlooper/frontend/src/pages/LivePage.tsx
John Lightner ad6b6ffb49 MAESTRO: Build useExperimentWS hook with typed events, exponential backoff reconnect, and refactor LivePage to use it
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.
2026-04-07 03:43:49 -05:00

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"
>
&larr; 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>
);
}