MAESTRO: Implement ModelSelector component with endpoint grouping, refresh, and connectivity indicators

This commit is contained in:
John Lightner 2026-04-07 03:00:10 -05:00
parent 3cc1e22e3f
commit 7fc2a2b8c3
4 changed files with 456 additions and 13 deletions

View file

@ -20,7 +20,8 @@ Build the React frontend: setup wizard, experiment builder, real-time observabil
- [x] Build the prompt template editor component (frontend/src/components/PromptEditor.tsx). Use a code editor library (CodeMirror or Monaco, loaded from CDN). Support Jinja2 template syntax highlighting. Show available template variables in a sidebar (input_data, previous_stage_output, etc.). Include a "Preview" button that renders the template with sample data.
<!-- Implemented PromptEditor with transparent-textarea-over-highlighted-backdrop approach for Jinja2 syntax highlighting ({{ expressions }}, {% statements %}, {# comments #}). Variable sidebar with clickable insert buttons. Preview panel renders template with sample data substitution. Integrated into ExperimentPage's PipelineStageCard, replacing the old inline textarea. 27 tests added, existing ExperimentPage tests updated. -->
- [ ] Build the model selector component (frontend/src/components/ModelSelector.tsx). Dropdown grouped by endpoint. Each option shows model name + endpoint label. Include a "refresh models" button that calls the endpoint test API to refresh available models. Show a connectivity indicator (green dot = reachable, red = error).
- [x] Build the model selector component (frontend/src/components/ModelSelector.tsx). Dropdown grouped by endpoint. Each option shows model name + endpoint label. Include a "refresh models" button that calls the endpoint test API to refresh available models. Show a connectivity indicator (green dot = reachable, red = error).
<!-- Implemented ModelSelector with optgroup-based endpoint grouping, each option showing model name + endpoint label. Refresh button tests all endpoints in parallel via endpoints.test() API, updates connectivity indicators (green=reachable, red=error, yellow=testing, gray=unknown). Integrated into ExperimentPage PipelineStageCard replacing the inline select. 16 tests added. -->
- [ ] Implement the Live Observability page (frontend/src/pages/Live.tsx). This is the star of the show — the real-time dashboard during active sweeps. Layout: left column (60%) shows the activity timeline and current run details, right column (40%) shows the leaderboard and steering controls. Connect via WebSocket to /ws/experiments/{id}. Everything updates in real-time without page refresh.

View file

@ -0,0 +1,235 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import ModelSelector, { ModelSelectorProps } from "./ModelSelector";
import * as client from "../api/client";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const MOCK_ENDPOINTS: client.EndpointResponse[] = [
{
id: "ep-1",
name: "Local vLLM",
url: "http://localhost:8080",
default_model: "llama-3-70b",
},
{
id: "ep-2",
name: "OpenAI",
url: "https://api.openai.com",
default_model: "gpt-4",
},
{
id: "ep-3",
name: "Ollama",
url: "http://localhost:11434",
default_model: null,
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderSelector(overrides: Partial<ModelSelectorProps> = {}) {
const defaultProps: ModelSelectorProps = {
value: "::",
onChange: vi.fn(),
endpoints: MOCK_ENDPOINTS,
"data-testid": "model-selector",
...overrides,
};
return {
...render(<ModelSelector {...defaultProps} />),
props: defaultProps,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ModelSelector", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("renders a select element with placeholder option", () => {
renderSelector();
const select = screen.getByTestId("model-select");
expect(select).toBeInTheDocument();
expect(screen.getByText("Select a model...")).toBeInTheDocument();
});
it("groups options by endpoint using optgroups", () => {
renderSelector();
const select = screen.getByTestId("model-select");
const groups = select.querySelectorAll("optgroup");
expect(groups).toHaveLength(3);
expect(groups[0]).toHaveAttribute("label", "Local vLLM");
expect(groups[1]).toHaveAttribute("label", "OpenAI");
expect(groups[2]).toHaveAttribute("label", "Ollama");
});
it("shows model name + endpoint label in each option", () => {
renderSelector();
expect(screen.getByText(/llama-3-70b.*\(Local vLLM\)/)).toBeInTheDocument();
expect(screen.getByText(/gpt-4.*\(OpenAI\)/)).toBeInTheDocument();
});
it("falls back to endpoint name when default_model is null", () => {
renderSelector();
expect(screen.getByText(/Ollama.*\(Ollama\)/)).toBeInTheDocument();
});
it("calls onChange when a model is selected", async () => {
const onChange = vi.fn();
renderSelector({ onChange });
const user = userEvent.setup();
const select = screen.getByTestId("model-select");
await user.selectOptions(select, "ep-2::gpt-4");
expect(onChange).toHaveBeenCalledWith("ep-2::gpt-4");
});
it("renders the refresh button", () => {
renderSelector();
expect(screen.getByTestId("refresh-models-btn")).toBeInTheDocument();
expect(screen.getByText("Refresh")).toBeInTheDocument();
});
it("shows 'Testing...' text while refreshing", async () => {
// Make endpoint.test hang
vi.spyOn(client.endpoints, "test").mockImplementation(
() => new Promise(() => {}),
);
renderSelector();
const user = userEvent.setup();
await user.click(screen.getByTestId("refresh-models-btn"));
expect(screen.getByText("Testing...")).toBeInTheDocument();
});
it("shows green dots for reachable endpoints after refresh", async () => {
vi.spyOn(client.endpoints, "test").mockResolvedValue({ ok: true });
vi.spyOn(client.endpoints, "list").mockResolvedValue({
items: MOCK_ENDPOINTS,
total: MOCK_ENDPOINTS.length,
});
renderSelector();
const user = userEvent.setup();
await user.click(screen.getByTestId("refresh-models-btn"));
await waitFor(() => {
expect(screen.getByTestId("status-ok-ep-1")).toBeInTheDocument();
expect(screen.getByTestId("status-ok-ep-2")).toBeInTheDocument();
expect(screen.getByTestId("status-ok-ep-3")).toBeInTheDocument();
});
});
it("shows red dots for unreachable endpoints after refresh", async () => {
vi.spyOn(client.endpoints, "test").mockRejectedValue(
new Error("Connection refused"),
);
vi.spyOn(client.endpoints, "list").mockResolvedValue({
items: MOCK_ENDPOINTS,
total: MOCK_ENDPOINTS.length,
});
renderSelector();
const user = userEvent.setup();
await user.click(screen.getByTestId("refresh-models-btn"));
await waitFor(() => {
expect(screen.getByTestId("status-error-ep-1")).toBeInTheDocument();
expect(screen.getByTestId("status-error-ep-2")).toBeInTheDocument();
expect(screen.getByTestId("status-error-ep-3")).toBeInTheDocument();
});
});
it("shows mixed status when some endpoints pass and some fail", async () => {
vi.spyOn(client.endpoints, "test").mockImplementation((id: string) => {
if (id === "ep-1") return Promise.resolve({ ok: true });
return Promise.reject(new Error("fail"));
});
vi.spyOn(client.endpoints, "list").mockResolvedValue({
items: MOCK_ENDPOINTS,
total: MOCK_ENDPOINTS.length,
});
renderSelector();
const user = userEvent.setup();
await user.click(screen.getByTestId("refresh-models-btn"));
await waitFor(() => {
expect(screen.getByTestId("status-ok-ep-1")).toBeInTheDocument();
expect(screen.getByTestId("status-error-ep-2")).toBeInTheDocument();
expect(screen.getByTestId("status-error-ep-3")).toBeInTheDocument();
});
});
it("calls onEndpointsRefreshed with updated list after refresh", async () => {
const onEndpointsRefreshed = vi.fn();
const updatedEndpoints = [MOCK_ENDPOINTS[0]];
vi.spyOn(client.endpoints, "test").mockResolvedValue({ ok: true });
vi.spyOn(client.endpoints, "list").mockResolvedValue({
items: updatedEndpoints,
total: 1,
});
renderSelector({ onEndpointsRefreshed });
const user = userEvent.setup();
await user.click(screen.getByTestId("refresh-models-btn"));
await waitFor(() => {
expect(onEndpointsRefreshed).toHaveBeenCalledWith(updatedEndpoints);
});
});
it("does not show connectivity indicators before refresh", () => {
renderSelector();
expect(
screen.queryByTestId("connectivity-indicators"),
).not.toBeInTheDocument();
});
it("disables refresh button while refreshing", async () => {
vi.spyOn(client.endpoints, "test").mockImplementation(
() => new Promise(() => {}),
);
renderSelector();
const user = userEvent.setup();
const btn = screen.getByTestId("refresh-models-btn");
await user.click(btn);
expect(btn).toBeDisabled();
});
it("renders with a pre-selected value", () => {
renderSelector({ value: "ep-1::llama-3-70b" });
const select = screen.getByTestId("model-select") as HTMLSelectElement;
expect(select.value).toBe("ep-1::llama-3-70b");
});
it("renders correctly with no endpoints", () => {
renderSelector({ endpoints: [] });
const select = screen.getByTestId("model-select");
const groups = select.querySelectorAll("optgroup");
expect(groups).toHaveLength(0);
// Only placeholder option
const options = select.querySelectorAll("option");
expect(options).toHaveLength(1);
expect(options[0].textContent).toBe("Select a model...");
});
it("applies custom data-testid", () => {
renderSelector({ "data-testid": "custom-selector" });
expect(screen.getByTestId("custom-selector")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,212 @@
import { useState, useCallback } from "react";
import { EndpointResponse, endpoints as endpointsApi } from "../api/client";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface ModelSelectorProps {
/** Currently selected value as "endpointId::modelName" */
value: string;
/** Called when user selects a new model */
onChange: (value: string) => void;
/** Available endpoints to populate the dropdown */
endpoints: EndpointResponse[];
/** Called after endpoints are refreshed so parent can update its list */
onEndpointsRefreshed?: (updated: EndpointResponse[]) => void;
/** Test id */
"data-testid"?: string;
}
/** Per-endpoint connectivity state tracked internally. */
interface EndpointStatus {
status: "unknown" | "reachable" | "error";
loading: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function ModelSelector({
value,
onChange,
endpoints,
onEndpointsRefreshed,
"data-testid": testId,
}: ModelSelectorProps) {
const [statusMap, setStatusMap] = useState<Record<string, EndpointStatus>>(
{},
);
const [refreshing, setRefreshing] = useState(false);
// -- helpers ---------------------------------------------------------------
function getStatus(endpointId: string): EndpointStatus {
return statusMap[endpointId] ?? { status: "unknown", loading: false };
}
function statusDot(endpointId: string) {
const s = getStatus(endpointId);
if (s.loading) {
return (
<span
data-testid={`status-loading-${endpointId}`}
className="inline-block h-2 w-2 rounded-full bg-yellow-400 animate-pulse"
title="Testing..."
/>
);
}
if (s.status === "reachable") {
return (
<span
data-testid={`status-ok-${endpointId}`}
className="inline-block h-2 w-2 rounded-full bg-green-500"
title="Reachable"
/>
);
}
if (s.status === "error") {
return (
<span
data-testid={`status-error-${endpointId}`}
className="inline-block h-2 w-2 rounded-full bg-red-500"
title="Unreachable"
/>
);
}
return (
<span
data-testid={`status-unknown-${endpointId}`}
className="inline-block h-2 w-2 rounded-full bg-slate-400"
title="Not tested"
/>
);
}
// -- refresh models --------------------------------------------------------
const refreshModels = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
const newStatusMap: Record<string, EndpointStatus> = {};
// Mark all as loading
for (const ep of endpoints) {
newStatusMap[ep.id] = { status: "unknown", loading: true };
}
setStatusMap({ ...newStatusMap });
// Test each endpoint in parallel
const results = await Promise.allSettled(
endpoints.map(async (ep) => {
try {
await endpointsApi.test(ep.id);
return { id: ep.id, status: "reachable" as const };
} catch {
return { id: ep.id, status: "error" as const };
}
}),
);
const finalStatusMap: Record<string, EndpointStatus> = {};
for (const result of results) {
if (result.status === "fulfilled") {
finalStatusMap[result.value.id] = {
status: result.value.status,
loading: false,
};
}
}
setStatusMap(finalStatusMap);
// Refresh endpoint list from server
if (onEndpointsRefreshed) {
try {
const list = await endpointsApi.list();
onEndpointsRefreshed(list.items);
} catch {
// Silently fail — status indicators already show the issue
}
}
setRefreshing(false);
}, [endpoints, onEndpointsRefreshed, refreshing]);
// -- group by endpoint -----------------------------------------------------
const groups = endpoints.reduce<
Record<string, { endpoint: EndpointResponse }>
>((acc, ep) => {
acc[ep.id] = { endpoint: ep };
return acc;
}, {});
// -- render ----------------------------------------------------------------
return (
<div data-testid={testId} className="space-y-1.5">
<div className="flex items-center gap-2">
<select
data-testid="model-select"
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition"
>
<option value="::">Select a model...</option>
{Object.values(groups).map(({ endpoint }) => (
<optgroup key={endpoint.id} label={endpoint.name}>
<option
value={`${endpoint.id}::${endpoint.default_model || endpoint.name}`}
>
{endpoint.default_model || endpoint.name}
{" "}
({endpoint.name})
</option>
</optgroup>
))}
</select>
<button
data-testid="refresh-models-btn"
type="button"
disabled={refreshing}
onClick={refreshModels}
className="inline-flex items-center gap-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-2.5 py-2 text-xs font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-600 disabled:opacity-50 transition"
title="Refresh models and test connectivity"
>
<svg
className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{refreshing ? "Testing..." : "Refresh"}
</button>
</div>
{/* Connectivity indicators */}
{endpoints.length > 0 && Object.keys(statusMap).length > 0 && (
<div
data-testid="connectivity-indicators"
className="flex flex-wrap gap-3 text-xs text-slate-600 dark:text-slate-400"
>
{endpoints.map((ep) => (
<span key={ep.id} className="inline-flex items-center gap-1">
{statusDot(ep.id)}
{ep.name}
</span>
))}
</div>
)}
</div>
);
}

View file

@ -11,6 +11,7 @@ import type {
EndpointResponse,
} from "../api/client";
import PromptEditor from "../components/PromptEditor";
import ModelSelector from "../components/ModelSelector";
// ---------------------------------------------------------------------------
// Types
@ -348,25 +349,19 @@ function PipelineStageCard({
<label className="mb-1 block text-xs font-medium text-slate-600 dark:text-slate-400">
Model
</label>
<select
<ModelSelector
value={`${stage.endpoint_id}::${stage.model}`}
onChange={(e) => {
const [eid, ...modelParts] = e.target.value.split("::");
onChange={(val) => {
const [eid, ...modelParts] = val.split("::");
onUpdate({
...stage,
endpoint_id: eid,
model: modelParts.join("::"),
});
}}
className="w-full rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-700 px-3 py-2 text-sm text-slate-900 dark:text-white focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20 transition"
>
<option value="::">Select a model...</option>
{endpointList.map((ep) => (
<option key={ep.id} value={`${ep.id}::${ep.default_model || ep.name}`}>
{ep.name}{ep.default_model ? `${ep.default_model}` : ""}
</option>
))}
</select>
endpoints={endpointList}
data-testid={`model-selector-${index}`}
/>
</div>
{/* Preview is now integrated in PromptEditor above */}