chore: snapshot working tree - pty_exited notifications + in-flight inference WIP

feat(booterm): structured pty_exited WS notifications. Plan-validated, impl-validated, code-reviewed green (contracts build clean, contracts test 29/29, booterm + web typecheck clean).

wip: in-progress inference/provider refactor (agents.ts, provider.ts, new llama-providers.ts, removed llama-args-validator), plus arena, dispatcher, compaction, schema changes.

openspec: pty-exit-notifications complete; x-agent-flags planned (not yet implemented).
This commit is contained in:
2026-06-14 12:48:47 +00:00
parent 0ed506f1da
commit b18de2a331
204 changed files with 25344 additions and 867 deletions

View File

@@ -20,6 +20,7 @@
"@xterm/xterm": "5.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"echarts": "^6.1.0",
"framer-motion": "^12.40.0",
"lucide-react": "^1.16.0",
"radix-ui": "^1.4.3",

View File

@@ -10,6 +10,7 @@ import { Settings } from '@/pages/Settings';
import { Analytics } from '@/pages/Analytics';
import { Results } from '@/pages/Results';
import { Memory } from '@/pages/Memory';
import { Control } from '@/pages/Control';
import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner';
import { useUserEvents } from '@/hooks/useUserEvents';
@@ -135,6 +136,7 @@ function AppShell() {
<Route path="/analytics" element={<Analytics />} />
<Route path="/results" element={<Results />} />
<Route path="/memory" element={<Memory />} />
<Route path="/control" element={<Control />} />
</Routes>
</main>
<MobileRightRailBackdrop />

View File

@@ -5,6 +5,7 @@ import type {
Chat,
Message,
ModelInfo,
ModelCatalogResponse,
SidebarResponse,
ListDirResult,
ViewFileResult,
@@ -414,7 +415,7 @@ export const api = {
),
},
models: () => request<ModelInfo[]>('/api/models'),
models: () => request<ModelCatalogResponse>('/api/models'),
coder: {
snapshot: (cwd?: string) => {

View File

@@ -201,6 +201,17 @@ export interface ModelInfo {
[key: string]: unknown;
}
// v2.x: provider-grouped model catalog (W2, D-4).
export interface ModelCatalogProvider {
id: string;
label: string;
models: ModelInfo[];
}
export interface ModelCatalogResponse {
providers: ModelCatalogProvider[];
}
export type {
ProviderModel,
ProviderMode,
@@ -520,6 +531,71 @@ export interface WorkspaceState {
closedPaneStack: ClosedPaneEntry[];
}
// ── BooControl fleet frames ─────────────────────────────────────────────────
//
// 2-location sync: contracts (WsFrameSchema + KNOWN_FRAME_TYPES) + web strict
// union only. They skip the server's broker entirely.
export type ControlFleetFrame = {
type: 'control_fleet';
seq: number;
hosts: Array<{
providerId: string;
liveness: 'connected' | 'reconnecting' | 'down';
lastSeenAt: string | null;
seq: number;
models: Array<{
model: string;
state: string;
ts: string;
ttlDeadline: string | null;
inflight: number;
}>;
}>;
};
export type ControlActivityFrame = {
type: 'control_activity';
seq: number;
providerId: string;
entry: {
id: number;
ts: string;
model: string | null;
reqPath: string | null;
statusCode: number | null;
durationMs: number | null;
};
};
export type ControlPerfFrame = {
type: 'control_perf';
seq: number;
providerId: string;
ts: string;
gpu: unknown;
sys: unknown;
};
export type ControlLogFrame = {
type: 'control_log';
seq: number;
providerId: string;
source: 'proxy' | 'upstream' | 'model';
line: string;
};
export type ControlJobFrame = {
type: 'control_job';
seq: number;
jobType: 'bench' | 'eval' | 'action';
jobId: string;
status: 'queued' | 'running' | 'completed' | 'failed';
detail?: Record<string, unknown>;
};
// ── end BooControl fleet frames ─────────────────────────────────────────────
export type WsFrame =
| { type: 'snapshot'; messages: Message[] }
| { type: 'message_started'; message_id: string; chat_id?: string; role: MessageRole; compare_group_id?: string }
@@ -720,7 +796,13 @@ export type WsFrame =
finished_at?: string | null;
model?: string | null;
metadata?: MessageMetadata | null;
};
}
// BooControl fleet frames
| ControlFleetFrame
| ControlActivityFrame
| ControlPerfFrame
| ControlLogFrame
| ControlJobFrame;
// tool traces: per-tool-call record returned by GET /api/chats/:id/traces.
export interface ToolTrace {

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, ShieldAlert, Eye, Brain, Bot } from 'lucide-react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, ShieldAlert, Eye, Brain, Bot, Star } from 'lucide-react';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
@@ -9,6 +9,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { BottomSheet } from '@/components/BottomSheet';
@@ -113,14 +115,22 @@ interface PickerProps {
/** Grow to fill the row's free space and render the value brighter — used for
* the Model picker so the active model is the most visible control. */
flexible?: boolean;
/** Grouped rendering: renders sections with labels (Favorites-first, then
* per-provider). When provided, `options` is ignored. */
groups?: ModelGroup[];
}
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly, flexible }: PickerProps) {
interface ModelGroup {
label: string;
options: Array<{ id: string; label: string }>;
}
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly, flexible, groups }: PickerProps) {
const { isMobile } = useViewport();
const [open, setOpen] = useState(false);
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
const list = (
const flatList = (
<div className="py-1">
{options.map((o) => (
<button
@@ -139,6 +149,36 @@ function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly
</div>
);
const groupedList = (
<div className="py-1">
{groups!.map((g, gi) => {
if (g.options.length === 0) return null;
return (
<div key={g.label}>
{gi > 0 && <div className="h-px bg-border mx-2 my-1" />}
<div className="text-[10px] font-medium text-muted-foreground px-2 py-0.5 uppercase tracking-wider">{g.label}</div>
{g.options.map((o) => (
<button
key={o.id}
type="button"
onClick={() => {
onPick(o.id);
setOpen(false);
}}
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
>
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">{o.label}</span>
</button>
))}
</div>
);
})}
</div>
);
const list = groups ? groupedList : flatList;
if (isMobile) {
return (
<>
@@ -243,6 +283,8 @@ function AgentStatusDot({ entry, agent }: { entry: AgentStatusEntry; agent: stri
);
}
const FAVORITE_MODELS_KEY = 'favorite_models';
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, agentStatus }: Props) {
const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
@@ -254,9 +296,20 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
[allEntries],
);
const [refreshing, setRefreshing] = useState(false);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const hydratedRef = useRef(false);
// Fetch favorites from settings for the grouped model picker (W5).
useEffect(() => {
api.settings.get().then((settings) => {
const raw = settings[FAVORITE_MODELS_KEY];
if (Array.isArray(raw)) {
setFavoriteModels(raw.filter((m): m is string => typeof m === 'string'));
}
}).catch(() => { /* settings fetch is best-effort */ });
}, []);
useEffect(() => {
hydratedRef.current = false;
}, [projectPath]);
@@ -318,6 +371,54 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
onProviderCommandsChange?.(currentEntry?.commands ?? []);
}, [currentEntry, onProviderCommandsChange]);
// Build grouped model options for the native boocode provider (W5).
// For other providers, use a flat list. Groups: Favorites first, then
// one section per local provider prefix (matching BooChat's ModelPicker).
const modelGroups = useMemo<ModelGroup[] | null>(() => {
if (!currentEntry || currentEntry.name !== 'boocode') return null;
const models = currentEntry.models;
if (models.length === 0) return [];
const favSet = new Set(favoriteModels);
// Build a model map for quick lookup
const modelMap = new Map(models.map((m) => [m.id, m]));
// Group models by provider prefix (the part before the first slash)
const byProvider = new Map<string, Array<{ id: string; label: string }>>();
for (const m of models) {
const slash = m.id.indexOf('/');
const providerPrefix = slash > 0 ? m.id.slice(0, slash) : 'other';
const formatted = { id: m.id, label: formatModelLabel(m.label) };
const arr = byProvider.get(providerPrefix) ?? [];
arr.push(formatted);
byProvider.set(providerPrefix, arr);
}
const groups: ModelGroup[] = [];
// Favorites section: only models that exist in the live inventory
const favModels = [...favSet]
.filter((id) => modelMap.has(id))
.map((id) => ({ id, label: formatModelLabel(modelMap.get(id)!.label) }));
if (favModels.length > 0) {
groups.push({ label: 'Favorites', options: favModels });
}
// One section per provider group
for (const [provider, opts] of byProvider) {
groups.push({ label: provider, options: opts });
}
return groups;
}, [currentEntry, favoriteModels]);
// Flat model options for non-boocode providers
const modelOptions = useMemo(
() => (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) })),
[currentEntry],
);
function persist(next: AgentSessionConfig): void {
const prefs = loadPrefs();
prefs[next.provider] = {
@@ -369,7 +470,6 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
// derived from it.
const permissionModes = availablePermissionModes(currentEntry?.modes ?? []);
const currentPermission = permissionForModeId(value.modeId, currentEntry?.modes ?? []);
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) }));
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
return (
@@ -423,8 +523,9 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
<CompactPicker
label="Model"
value={value.model}
disabled={modelOptions.length === 0}
disabled={modelGroups ? modelGroups.every((g) => g.options.length === 0) : modelOptions.length === 0}
options={modelOptions}
groups={modelGroups ?? undefined}
onPick={pickModel}
icon={<Bot size={13} className="shrink-0" />}
flexible

View File

@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react';
import { Check, ChevronDown, Cpu } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Check, ChevronDown, Cpu, Star } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import type { ModelInfo } from '@/api/types';
import type { ModelCatalogProvider, ModelInfo } from '@/api/types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { BottomSheet } from '@/components/BottomSheet';
@@ -17,65 +20,364 @@ interface Props {
onChange: (model: string) => void | Promise<void>;
}
// v1.9: shared list rendered inside both shells. Lazy-fetches /api/models on
// first open so the picker doesn't pay for a request when it's never shown.
function ModelList({
models,
error,
value,
onPick,
}: {
models: ModelInfo[] | null;
interface PickerState {
providers: ModelCatalogProvider[];
favoriteModels: string[];
/** P6.1: compositeId -> advisory badge kinds (from BooControl). */
badges: Record<string, string[]>;
/** P6.1: badge kind -> human label. */
badgeLabels: Record<string, string>;
error: string | null;
value: string | null;
}
const FAVORITE_MODELS_KEY = 'favorite_models';
/** Short chip text per advisory badge kind. */
const BADGE_SHORT: Record<string, string> = {
'best-code': 'code',
'best-chat': 'chat',
'best-fast': 'fast',
};
// P6.1: advisory routing scores from BooControl. Non-fatal — the control
// service may be down, in which case the picker simply shows no badges.
async function fetchRoutingBadges(): Promise<{ badges: Record<string, string[]>; badgeLabels: Record<string, string> }> {
try {
const res = await fetch('/api/control/routing/scores');
if (!res.ok) return { badges: {}, badgeLabels: {} };
const data = await res.json() as { badges?: Record<string, string[]>; badgeLabels?: Record<string, string> };
return { badges: data.badges ?? {}, badgeLabels: data.badgeLabels ?? {} };
} catch {
return { badges: {}, badgeLabels: {} };
}
}
async function fetchPickerData(): Promise<PickerState> {
const [catalog, settings, routing] = await Promise.all([
api.models(),
api.settings.get(),
fetchRoutingBadges(),
]);
const raw = settings[FAVORITE_MODELS_KEY];
const favoriteModels = Array.isArray(raw)
? raw.filter((m): m is string => typeof m === 'string')
: [];
return {
providers: catalog.providers,
favoriteModels,
badges: routing.badges,
badgeLabels: routing.badgeLabels,
error: null,
};
}
// P7.3: detect an orphaned auto:* session — the selected model looks like a
// gateway virtual model but no provider in the live catalog serves it (the
// gateway registry entry was removed). The session keeps its id; we flag it.
function isOrphanedGatewayValue(value: string | null, providers: ModelCatalogProvider[]): boolean {
if (!value) return false;
const tail = value.includes('/') ? value.slice(value.indexOf('/') + 1) : value;
const looksGateway = tail === 'auto' || tail.startsWith('auto:');
if (!looksGateway) return false;
const present = providers.some((p) => p.models.some((m) => m.id === value));
return !present;
}
function ModelBadges({ ids, labels }: { ids: string[] | undefined; labels: Record<string, string> }) {
if (!ids || ids.length === 0) return null;
return (
<span className="flex items-center gap-1 shrink-0">
{ids.map((kind) => (
<span
key={kind}
title={labels[kind] ?? kind}
className="px-1 py-px text-[10px] leading-none rounded bg-emerald-500/15 text-emerald-400 border border-emerald-500/30"
>
{BADGE_SHORT[kind] ?? kind}
</span>
))}
</span>
);
}
function ModelRow({
id,
isSelected,
isFavorite,
badges,
badgeLabels,
onPick,
onToggleFavorite,
}: {
id: string;
isSelected: boolean;
isFavorite: boolean;
badges?: string[];
badgeLabels: Record<string, string>;
onPick: (id: string) => void;
onToggleFavorite: (id: string, favorite: boolean) => void;
}) {
if (error) {
return <div className="px-2 py-1.5 text-xs text-destructive">{error}</div>;
}
if (models === null) {
return <div className="px-2 py-1.5 text-xs text-muted-foreground">Loading</div>;
}
return (
<div className="flex items-center gap-1 group">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleFavorite(id, !isFavorite);
}}
className="shrink-0 flex items-center justify-center size-5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
<Star
className={`size-3 ${isFavorite ? 'fill-yellow-400 text-yellow-400' : 'opacity-0 group-hover:opacity-60'}`}
/>
</button>
<button
type="button"
onClick={() => onPick(id)}
className="flex-1 text-left flex items-center gap-2 font-mono text-xs py-1 rounded hover:bg-accent"
>
<Check className={`size-3 shrink-0 ${isSelected ? 'opacity-100' : 'opacity-0'}`} />
<span className="truncate">{formatModelLabel(id)}</span>
<ModelBadges ids={badges} labels={badgeLabels} />
</button>
</div>
);
}
function ModelSections({
providers,
favoriteModels,
selectedModel,
badges,
badgeLabels,
onPick,
onToggleFavorite,
}: {
providers: ModelCatalogProvider[];
favoriteModels: string[];
selectedModel: string | null;
badges: Record<string, string[]>;
badgeLabels: Record<string, string>;
onPick: (id: string) => void;
onToggleFavorite: (id: string, favorite: boolean) => void;
}) {
const favSet = useMemo(() => new Set(favoriteModels), [favoriteModels]);
// Build model map for quick lookup
const modelMap = useMemo(() => {
const map = new Map<string, ModelInfo>();
for (const p of providers) {
for (const m of p.models) {
map.set(m.id, m);
}
}
return map;
}, [providers]);
// Favorites section: only models that exist in the live inventory.
const favoriteModelsInInventory = useMemo(
() => favoriteModels.filter((id) => modelMap.has(id)),
[favoriteModels, modelMap],
);
// For the non-dropdown (mobile bottom sheet) view, wrap each section.
// The dropdown version uses the primitives directly.
return (
<>
{models.map((m) => (
<button
key={m.id}
type="button"
onClick={() => onPick(m.id)}
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
>
<Check className={`size-3 ${m.id === value ? 'opacity-100' : 'opacity-0'}`} />
<span className="truncate">{formatModelLabel(m.id)}</span>
</button>
))}
{favoriteModelsInInventory.length > 0 && (
<>
<DropdownMenuLabel>Favorites</DropdownMenuLabel>
{favoriteModelsInInventory.map((id) => (
<DropdownMenuItem
key={id}
onSelect={(e) => {
e.preventDefault();
}}
className="flex items-center gap-1 p-0"
>
<ModelRow
id={id}
isSelected={selectedModel === id}
isFavorite={favSet.has(id)}
badges={badges[id]}
badgeLabels={badgeLabels}
onPick={onPick}
onToggleFavorite={onToggleFavorite}
/>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
{providers.map((provider) => {
if (provider.models.length === 0) return null;
return (
<div key={provider.id}>
<DropdownMenuLabel>{provider.label}</DropdownMenuLabel>
{provider.models.map((m) => (
<DropdownMenuItem
key={m.id}
onSelect={(e) => {
e.preventDefault();
}}
className="flex items-center gap-1 p-0"
>
<ModelRow
id={m.id}
isSelected={selectedModel === m.id}
isFavorite={favSet.has(m.id)}
badges={badges[m.id]}
badgeLabels={badgeLabels}
onPick={onPick}
onToggleFavorite={onToggleFavorite}
/>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</div>
);
})}
</>
);
}
// Mobile bottom-sheet version of the grouped model list.
function MobileModelList({
providers,
favoriteModels,
selectedModel,
badges,
badgeLabels,
onPick,
onToggleFavorite,
}: {
providers: ModelCatalogProvider[];
favoriteModels: string[];
selectedModel: string | null;
badges: Record<string, string[]>;
badgeLabels: Record<string, string>;
onPick: (id: string) => void;
onToggleFavorite: (id: string, favorite: boolean) => void;
}) {
const favSet = useMemo(() => new Set(favoriteModels), [favoriteModels]);
const modelMap = useMemo(() => {
const map = new Map<string, ModelInfo>();
for (const p of providers) {
for (const m of p.models) {
map.set(m.id, m);
}
}
return map;
}, [providers]);
const favoriteModelsInInventory = useMemo(
() => favoriteModels.filter((id) => modelMap.has(id)),
[favoriteModels, modelMap],
);
return (
<div className="space-y-1">
{favoriteModelsInInventory.length > 0 && (
<div>
<div className="text-xs font-medium text-muted-foreground px-2 py-1">Favorites</div>
{favoriteModelsInInventory.map((id) => (
<ModelRow
key={id}
id={id}
isSelected={selectedModel === id}
isFavorite={favSet.has(id)}
badges={badges[id]}
badgeLabels={badgeLabels}
onPick={onPick}
onToggleFavorite={onToggleFavorite}
/>
))}
<div className="h-px bg-border mx-2 my-1" />
</div>
)}
{providers.map((provider) => {
if (provider.models.length === 0) return null;
return (
<div key={provider.id}>
<div className="text-xs font-medium text-muted-foreground px-2 py-1">{provider.label}</div>
{provider.models.map((m) => (
<ModelRow
key={m.id}
id={m.id}
isSelected={selectedModel === m.id}
isFavorite={favSet.has(m.id)}
badges={badges[m.id]}
badgeLabels={badgeLabels}
onPick={onPick}
onToggleFavorite={onToggleFavorite}
/>
))}
<div className="h-px bg-border mx-2 my-1" />
</div>
);
})}
</div>
);
}
export function ModelPicker({ value, onChange }: Props) {
const { isMobile } = useViewport();
const [models, setModels] = useState<ModelInfo[] | null>(null);
const [state, setState] = useState<PickerState | null>(null);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open || models !== null) return;
api
.models()
.then(setModels)
if (!open || state !== null) return;
fetchPickerData()
.then(setState)
.catch((err) =>
setError(err instanceof Error ? err.message : 'failed to load models'),
);
}, [open, models]);
}, [open, state]);
// Reset state when dropdown closes so we re-fetch fresh data next open.
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v);
if (!v) {
setState(null);
setError(null);
}
}, []);
const toggleFavorite = useCallback(
async (id: string, favorite: boolean) => {
const current = state?.favoriteModels ?? [];
const next = favorite
? [...current, id]
: current.filter((m) => m !== id);
try {
const settings = await api.settings.patch({
[FAVORITE_MODELS_KEY]: next,
});
const raw = settings[FAVORITE_MODELS_KEY];
const normalized = Array.isArray(raw)
? raw.filter((m): m is string => typeof m === 'string')
: [];
setState((prev) =>
prev ? { ...prev, favoriteModels: normalized } : prev,
);
} catch (err) {
toast.error(
err instanceof Error ? err.message : 'Failed to update favorites',
);
}
},
[state],
);
function handlePick(id: string) {
setOpen(false);
void onChange(id);
}
// v1.9: mobile = icon-only trigger + bottom-sheet shell. Desktop = labeled
// trigger (model name + chevron) + dropdown. Same ModelList under the hood.
if (isMobile) {
return (
<>
@@ -88,9 +390,30 @@ export function ModelPicker({ value, onChange }: Props) {
>
<Cpu className="size-4" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title="Model">
<div className="px-2 py-2 space-y-1">
<ModelList models={models} error={error} value={value} onPick={handlePick} />
<BottomSheet open={open} onClose={() => handleOpenChange(false)} title="Model">
<div className="px-2 py-2">
{error && (
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
)}
{state === null && !error && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">Loading</div>
)}
{state && isOrphanedGatewayValue(value, state.providers) && (
<div className="px-2 py-1.5 mb-1 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded">
Routing gateway offline this session's <span className="font-mono">{value}</span> model can't route. Pick a concrete model.
</div>
)}
{state && (
<MobileModelList
providers={state.providers}
favoriteModels={state.favoriteModels}
selectedModel={value}
badges={state.badges}
badgeLabels={state.badgeLabels}
onPick={handlePick}
onToggleFavorite={toggleFavorite}
/>
)}
</div>
</BottomSheet>
</>
@@ -98,7 +421,7 @@ export function ModelPicker({ value, onChange }: Props) {
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<button
type="button"
@@ -108,25 +431,29 @@ export function ModelPicker({ value, onChange }: Props) {
<ChevronDown className="size-3 opacity-70" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-72 min-w-[16rem] overflow-y-auto">
<DropdownMenuContent align="end" className="max-h-72 min-w-[18rem] overflow-y-auto">
{error && (
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
)}
{models === null && !error && (
{state === null && !error && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">Loading</div>
)}
{models?.map((m) => (
<DropdownMenuItem
key={m.id}
onSelect={() => handlePick(m.id)}
className="font-mono text-xs"
>
<Check
className={`size-3 ${m.id === value ? 'opacity-100' : 'opacity-0'}`}
/>
{formatModelLabel(m.id)}
</DropdownMenuItem>
))}
{state && isOrphanedGatewayValue(value, state.providers) && (
<div className="px-2 py-1.5 mb-1 text-xs text-amber-400 bg-amber-500/10 border border-amber-500/30 rounded">
Routing gateway offline this session's <span className="font-mono">{value}</span> model can't route. Pick a concrete model.
</div>
)}
{state && (
<ModelSections
providers={state.providers}
favoriteModels={state.favoriteModels}
selectedModel={value}
badges={state.badges}
badgeLabels={state.badgeLabels}
onPick={handlePick}
onToggleFavorite={toggleFavorite}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { BarChart3, Brain, ChevronRight, ExternalLink, Folder, MessageSquare, Plus, ScrollText, Settings as SettingsIcon, X, Code } from 'lucide-react';
import { BarChart3, Brain, ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Radio, ScrollText, Settings as SettingsIcon, X, Code } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import mascot from '@/assets/brand/banner-mascot.png';
@@ -563,6 +563,20 @@ export function ProjectSidebar() {
<span className="flex-1 text-left">Memory</span>
</NavLink>
<NavLink
to="/control"
onClick={() => { if (isMobile) setDrawerOpen(false); }}
className={({ isActive }) =>
`w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground ${
isActive ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''
}`
}
aria-label="Control"
>
<Radio className="size-3.5 shrink-0 opacity-70" />
<span className="flex-1 text-left">Control</span>
</NavLink>
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
workspace settings pane via the sessionEvents bus (Session.tsx owns
the panesHook). Outside a session there's no workspace to mount the

View File

@@ -0,0 +1,226 @@
import { useCallback, useMemo, useState } from 'react';
import { Virtuoso, type FollowOutput } from 'react-virtuoso';
import { ControlRequestEntry } from '@/hooks/useControlStream';
import { cn } from '@/lib/utils';
import { Pause, Play, Search } from 'lucide-react';
interface ActivityTabProps {
requests: ControlRequestEntry[];
providerIds: string[];
onOpenCapture?: (entry: ControlRequestEntry) => void;
}
function formatDuration(ms: number | null): string {
if (ms == null) return '-';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function formatStatus(code: number | null): string {
if (code == null) return '-';
return String(code);
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export function ActivityTab({ requests, providerIds, onOpenCapture }: ActivityTabProps) {
const [paused, setPaused] = useState(false);
const [modelFilter, setModelFilter] = useState<string | null>(null);
const [hostFilter, setHostFilter] = useState<string | null>(null);
// Extract unique models from requests
const models = useMemo(() => {
const set = new Set<string>();
for (const r of requests) {
if (r.model) set.add(r.model);
}
return Array.from(set).sort();
}, [requests]);
const filtered = useMemo(() => {
return requests.filter((r) => {
if (modelFilter && r.model !== modelFilter) return false;
if (hostFilter && r.providerId !== hostFilter) return false;
return true;
});
}, [requests, modelFilter, hostFilter]);
const handleScroll = useCallback((isAtBottom: boolean) => {
if (!isAtBottom && !paused) {
setPaused(true);
} else if (isAtBottom) {
setPaused(false);
}
}, [paused]);
const itemContent = useCallback(
(_index: number, entry: ControlRequestEntry) => {
const isError = entry.statusCode != null && entry.statusCode >= 400;
return (
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 text-xs border-b border-border/20',
isError && 'bg-red-500/5',
)}
>
{/* Time */}
<span className="text-muted-foreground font-mono shrink-0 w-20">
{formatTime(entry.ts)}
</span>
{/* Provider */}
<span className="shrink-0 text-muted-foreground w-24 truncate">
{entry.providerId}
</span>
{/* Model */}
<span className="shrink-0 w-48 truncate">
{entry.model || '-'}
</span>
{/* Status */}
<span
className={cn(
'shrink-0 w-10 text-right font-mono',
isError && 'text-red-400 font-bold',
)}
>
{formatStatus(entry.statusCode)}
</span>
{/* Duration */}
<span className="shrink-0 w-16 text-right font-mono text-muted-foreground">
{formatDuration(entry.durationMs)}
</span>
{/* P2.4: Capture inspector button */}
{onOpenCapture && (
<button
type="button"
onClick={() => onOpenCapture(entry)}
className="ml-auto p-0.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors shrink-0"
title="Inspect capture"
>
<Search className="size-3" />
</button>
)}
</div>
);
},
[onOpenCapture],
);
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Filter bar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/40 shrink-0 flex-wrap">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Host
</div>
<FilterChip
label="All"
active={hostFilter === null}
onClick={() => setHostFilter(null)}
/>
{providerIds.map((pid) => (
<FilterChip
key={pid}
label={pid}
active={hostFilter === pid}
onClick={() => setHostFilter(hostFilter === pid ? null : pid)}
/>
))}
<div className="w-px h-4 bg-border mx-1" />
<div className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Model
</div>
<FilterChip
label="All"
active={modelFilter === null}
onClick={() => setModelFilter(null)}
/>
{models.slice(0, 12).map((m) => (
<FilterChip
key={m}
label={m}
active={modelFilter === m}
onClick={() => setModelFilter(modelFilter === m ? null : m)}
/>
))}
<div className="flex-1" />
{/* Pause toggle */}
<button
type="button"
onClick={() => setPaused((p) => !p)}
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium',
'border border-border/40 transition-colors',
paused
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
: 'bg-muted/30 text-muted-foreground hover:text-foreground',
)}
aria-label={paused ? 'Resume follow' : 'Pause follow'}
title={paused ? 'Resume follow' : 'Pause follow'}
>
{paused ? <Play className="size-3" /> : <Pause className="size-3" />}
{paused ? 'Paused' : 'Follow'}
</button>
</div>
{/* Feed */}
<div className="flex-1 min-h-0">
<Virtuoso
data={filtered}
itemContent={itemContent}
followOutput={paused ? undefined : 'bottom' as FollowOutput}
overscan={400}
components={{
Footer: () => (
<div className="h-2" />
),
}}
className="h-full"
onMouseEnter={() => {
// pause on hover for readability
if (!paused) setPaused(true);
}}
onMouseLeave={() => {
if (paused) setPaused(false);
}}
/>
</div>
</div>
);
}
function FilterChip({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'px-2 py-0.5 rounded text-[11px] font-medium transition-colors border',
active
? 'bg-primary/10 text-foreground border-primary/30'
: 'bg-muted/20 text-muted-foreground border-border/30 hover:text-foreground hover:border-border/60',
)}
>
{label}
</button>
);
}

View File

@@ -0,0 +1,669 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components';
import { buildEChartsTheme } from './buildEChartsTheme';
import {
Play,
Loader2,
BarChart3,
TrendingDown,
TrendingUp,
AlertTriangle,
Plus,
History,
} from 'lucide-react';
echarts.use([LineChart, CanvasRenderer, GridComponent, TooltipComponent, LegendComponent, TitleComponent]);
interface BenchTabProps {
providerIds: string[];
}
interface BenchSuite {
id: string;
name: string;
providerId: string;
model: string;
promptTokens: number[];
genTokens: number[];
concurrency: number[];
repetitions: number;
createdAt: string;
}
interface BenchRun {
id: string;
suiteId: string;
jobType: string;
status: string;
startedAt: string | null;
finishedAt: string | null;
totalSamples: number;
completedSamples: number;
concurrentForeignRequests: number;
regressionFlag: 'baseline' | 'regression' | 'improvement' | null;
aggregate: Record<string, unknown> | null;
error: string | null;
createdAt: string;
}
interface BenchSample {
id: number;
promptTokens: number;
genTokens: number;
concurrency: number;
repetition: number;
ttftMs: number | null;
totalMs: number | null;
promptTps: number | null;
genTps: number | null;
cacheN: number | null;
error: string | null;
}
export function BenchTab({ providerIds }: BenchTabProps) {
const [view, setView] = useState<'launcher' | 'history' | 'results'>('launcher');
const [suites, setSuites] = useState<BenchSuite[]>([]);
const [runs, setRuns] = useState<BenchRun[]>([]);
const [selectedRun, setSelectedRun] = useState<BenchRun | null>(null);
const [samples, setSamples] = useState<BenchSample[]>([]);
const [loading, setLoading] = useState(false);
const [running, setRunning] = useState(false);
const [recentTraffic, setRecentTraffic] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const chartRef = useRef<HTMLDivElement>(null);
const historyChartRef = useRef<HTMLDivElement>(null);
// Suite form state
const [suiteName, setSuiteName] = useState('');
const [suiteProvider, setSuiteProvider] = useState('');
const [suiteModel, setSuiteModel] = useState('');
const [suitePromptTokens, setSuitePromptTokens] = useState('256,512,1024');
const [suiteGenTokens, setSuiteGenTokens] = useState('64,128,256');
const [suiteConcurrency, setSuiteConcurrency] = useState('1,2,4');
const [suiteRepetitions, setSuiteRepetitions] = useState('3');
useEffect(() => {
loadSuites();
loadRuns();
}, []);
// N2: Clear polling interval on unmount.
useEffect(() => {
return () => {
if (pollRef.current) {
clearInterval(pollRef.current);
}
};
}, []);
useEffect(() => {
if (view === 'history' && historyChartRef.current && runs.length > 0) {
renderHistoryChart();
}
}, [view, runs]);
useEffect(() => {
if (view === 'results' && chartRef.current && selectedRun && samples.length > 0) {
renderResultsChart();
}
}, [view, selectedRun, samples]);
const loadSuites = useCallback(async () => {
try {
const res = await fetch('/api/control/bench/suites');
if (!res.ok) return;
const data = await res.json() as { suites: BenchSuite[] };
setSuites(data.suites);
} catch {
// silent
}
}, []);
const loadRuns = useCallback(async () => {
try {
const res = await fetch('/api/control/bench/runs');
if (!res.ok) return;
const data = await res.json() as { runs: BenchRun[] };
setRuns(data.runs);
} catch {
// silent
}
}, []);
const loadRunDetails = useCallback(async (runId: string) => {
try {
const res = await fetch(`/api/control/bench/runs/${runId}`);
if (!res.ok) return;
const data = await res.json() as { run: BenchRun; samples: BenchSample[] };
setSelectedRun(data.run);
setSamples(data.samples);
setView('results');
} catch {
// silent
}
}, []);
const createSuite = async () => {
const promptTokens = suitePromptTokens.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n));
const genTokens = suiteGenTokens.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n));
const concurrency = suiteConcurrency.split(',').map((s) => parseInt(s.trim())).filter((n) => !isNaN(n));
const repetitions = parseInt(suiteRepetitions) || 1;
if (!suiteName || !suiteProvider || !suiteModel) return;
if (!promptTokens.length || !genTokens.length || !concurrency.length) return;
try {
const res = await fetch('/api/control/bench/suite', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: suiteName,
providerId: suiteProvider,
model: suiteModel,
promptTokens,
genTokens,
concurrency,
repetitions,
}),
});
if (res.ok) {
await loadSuites();
setSuiteName('');
}
} catch {
// silent
}
};
const runBench = async (suiteId: string) => {
setLoading(true);
setRunning(true);
try {
const res = await fetch('/api/control/bench/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ suiteId }),
});
const data = await res.json().catch(() => ({}));
if (data.recentTraffic) {
setRecentTraffic(true);
}
} catch {
// silent
} finally {
setLoading(false);
}
// Poll for completion
pollRef.current = setInterval(async () => {
await loadRuns();
const latestRun = runs[0];
if (latestRun && (latestRun.status === 'completed' || latestRun.status === 'failed')) {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
setRunning(false);
}
}, 2000);
// Timeout after 10 minutes
setTimeout(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
setRunning(false);
loadRuns();
}, 600_000);
};
const loadBaselines = useCallback(async () => {
try {
const res = await fetch('/api/control/bench/baselines');
if (!res.ok) return;
return await res.json() as { baselines: Array<{ providerId: string; model: string; aggregate: Record<string, unknown> | null }> };
} catch {
return { baselines: [] };
}
}, []);
const [baselines, setBaselines] = useState<Array<{ providerId: string; model: string; aggregate: Record<string, unknown> | null }>>([]);
useEffect(() => {
loadBaselines().then((d) => setBaselines(d?.baselines ?? []));
}, [loadBaselines]);
const getRegressionFlag = (aggregate: Record<string, unknown> | null, baselineAggregate: Record<string, unknown> | null): 'baseline' | 'regression' | 'improvement' | null => {
if (!aggregate || !baselineAggregate) return null;
const currentGenTps = aggregate.avgGenTps as number | undefined;
const baselineGenTps = baselineAggregate.avgGenTps as number | undefined;
if (currentGenTps == null || baselineGenTps == null) return null;
// N5: guard against divide-by-zero.
if (baselineGenTps === 0) return null;
const delta = (currentGenTps - baselineGenTps) / baselineGenTps;
if (delta < -0.1) return 'regression';
if (delta > 0.05) return 'improvement';
return 'baseline';
};
const renderResultsChart = () => {
if (!chartRef.current || !samples.length) return;
const instance = echarts.getInstanceByDom(chartRef.current);
if (instance) instance.dispose();
const theme = buildEChartsTheme();
// Group samples by concurrency, compute avg TTFT
const byConcurrency = new Map<number, { ttfts: number[]; genTps: number[] }>();
for (const s of samples) {
if (!byConcurrency.has(s.concurrency)) {
byConcurrency.set(s.concurrency, { ttfts: [], genTps: [] });
}
const group = byConcurrency.get(s.concurrency)!;
if (s.ttftMs != null) group.ttfts.push(s.ttftMs);
if (s.genTps != null) group.genTps.push(s.genTps);
}
const sorted = Array.from(byConcurrency.entries()).sort((a, b) => a[0] - b[0]);
const concurrencies = sorted.map(([c]) => c);
const avgTtft = sorted.map(([, g]) => g.ttfts.length ? g.ttfts.reduce((a, b) => a + b, 0) / g.ttfts.length : 0);
const avgGenTps = sorted.map(([, g]) => g.genTps.length ? g.genTps.reduce((a, b) => a + b, 0) / g.genTps.length : 0);
echarts.init(chartRef.current, theme as echarts.EChartsCoreOption).setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: { data: ['Avg TTFT (ms)', 'Avg Gen Tok/s'], textStyle: { color: '#9ca3af' } },
grid: { left: 60, right: 30, top: 40, bottom: 40 },
xAxis: {
type: 'category',
data: concurrencies.map(String),
name: 'Concurrency',
nameLocation: 'center',
nameGap: 30,
axisLabel: { color: '#9ca3af' },
axisLine: { lineStyle: { color: '#374151' } },
},
yAxis: [
{
type: 'value',
name: 'TTFT (ms)',
axisLabel: { color: '#9ca3af' },
splitLine: { lineStyle: { color: '#1f2937' } },
axisLine: { lineStyle: { color: '#374151' } },
},
{
type: 'value',
name: 'Gen Tok/s',
axisLabel: { color: '#9ca3af' },
splitLine: { show: false },
axisLine: { lineStyle: { color: '#374151' } },
},
],
series: [
{
name: 'Avg TTFT (ms)',
type: 'line',
data: avgTtft,
smooth: true,
lineStyle: { color: '#f59e0b' },
itemStyle: { color: '#f59e0b' },
},
{
name: 'Avg Gen Tok/s',
type: 'line',
yAxisIndex: 1,
data: avgGenTps,
smooth: true,
lineStyle: { color: '#10b981' },
itemStyle: { color: '#10b981' },
},
],
});
};
const renderHistoryChart = () => {
if (!historyChartRef.current || runs.length < 2) return;
const instance = echarts.getInstanceByDom(historyChartRef.current);
if (instance) instance.dispose();
const theme = buildEChartsTheme();
const completed = runs.filter((r) => r.status === 'completed' && r.aggregate);
const labels = completed.map((r) => r.id.slice(0, 8));
const genTpsData = completed.map((r) => (r.aggregate?.avgGenTps as number) ?? 0);
const ttftData = completed.map((r) => (r.aggregate?.avgTtftMs as number) ?? 0);
echarts.init(historyChartRef.current, theme as echarts.EChartsCoreOption).setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: { data: ['Gen Tok/s', 'TTFT (ms)'], textStyle: { color: '#9ca3af' } },
grid: { left: 60, right: 30, top: 40, bottom: 60 },
xAxis: {
type: 'category',
data: labels,
axisLabel: { color: '#9ca3af', rotate: 45 },
axisLine: { lineStyle: { color: '#374151' } },
},
yAxis: [
{
type: 'value',
name: 'Gen Tok/s',
axisLabel: { color: '#9ca3af' },
splitLine: { lineStyle: { color: '#1f2937' } },
axisLine: { lineStyle: { color: '#374151' } },
},
{
type: 'value',
name: 'TTFT (ms)',
axisLabel: { color: '#9ca3af' },
splitLine: { show: false },
axisLine: { lineStyle: { color: '#374151' } },
},
],
series: [
{
name: 'Gen Tok/s',
type: 'line',
data: genTpsData,
smooth: true,
lineStyle: { color: '#10b981' },
itemStyle: { color: '#10b981' },
},
{
name: 'TTFT (ms)',
type: 'line',
yAxisIndex: 1,
data: ttftData,
smooth: true,
lineStyle: { color: '#f59e0b' },
itemStyle: { color: '#f59e0b' },
},
],
});
};
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Sub-nav */}
<div className="flex gap-1 px-4 pt-2 shrink-0">
{[
{ id: 'launcher' as const, label: 'Launcher', icon: Play },
{ id: 'history' as const, label: 'History', icon: History },
{ id: 'results' as const, label: 'Results', icon: BarChart3 },
].map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setView(tab.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md transition-colors',
view === tab.id
? 'bg-accent/20 text-accent'
: 'text-muted-foreground hover:text-foreground'
)}
>
<tab.icon className="size-3" />
{tab.label}
</button>
))}
</div>
{/* Launcher view */}
{view === 'launcher' && (
<div className="flex-1 overflow-y-auto px-4 py-3">
{/* Create suite form */}
<div className="mb-4 p-4 bg-muted/20 rounded-lg border border-border/30">
<h3 className="text-sm font-medium mb-3 flex items-center gap-2">
<Plus className="size-3" />
New Suite
</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground">Name</label>
<input
type="text"
value={suiteName}
onChange={(e) => setSuiteName(e.target.value)}
className="w-full bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
placeholder="my-bench"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Provider</label>
<select
value={suiteProvider}
onChange={(e) => setSuiteProvider(e.target.value)}
className="w-full bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
>
<option value="">Select host</option>
{providerIds.map((pid) => (
<option key={pid} value={pid}>{pid}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-muted-foreground">Model</label>
<input
type="text"
value={suiteModel}
onChange={(e) => setSuiteModel(e.target.value)}
className="w-full bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
placeholder="llama-3.1-8b-q4"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Repetitions</label>
<input
type="number"
min={1}
value={suiteRepetitions}
onChange={(e) => setSuiteRepetitions(e.target.value)}
className="w-full bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Prompt Tokens (comma-sep)</label>
<input
type="text"
value={suitePromptTokens}
onChange={(e) => setSuitePromptTokens(e.target.value)}
className="w-full bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Gen Tokens (comma-sep)</label>
<input
type="text"
value={suiteGenTokens}
onChange={(e) => setSuiteGenTokens(e.target.value)}
className="w-full bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">Concurrency (comma-sep)</label>
<input
type="text"
value={suiteConcurrency}
onChange={(e) => setSuiteConcurrency(e.target.value)}
className="w-full bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
/>
</div>
</div>
<button
type="button"
onClick={createSuite}
disabled={!suiteName || !suiteProvider || !suiteModel}
className="mt-3 px-3 py-1.5 bg-accent/20 text-accent rounded text-sm hover:bg-accent/30 disabled:opacity-50 transition-colors"
>
Create Suite
</button>
</div>
{/* Existing suites */}
<div className="space-y-2">
{suites.map((suite) => (
<div key={suite.id} className="flex items-center justify-between p-3 bg-muted/20 rounded-lg border border-border/30">
<div>
<div className="text-sm font-medium">{suite.name}</div>
<div className="text-xs text-muted-foreground">
{suite.providerId} / {suite.model}
{' '}
({suite.promptTokens.join(',')}pt x {suite.genTokens.join(',')}gt x {suite.concurrency.join(',')}c)
</div>
</div>
<button
type="button"
onClick={() => runBench(suite.id)}
disabled={loading || running}
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent/20 text-accent rounded text-xs hover:bg-accent/30 disabled:opacity-50 transition-colors"
>
{loading || running ? <Loader2 className="size-3 animate-spin" /> : <Play className="size-3" />}
Run
</button>
</div>
))}
</div>
{recentTraffic && (
<div className="mt-3 flex items-center gap-2 px-3 py-2 bg-yellow-500/10 border border-yellow-500/20 rounded text-xs text-yellow-400">
<AlertTriangle className="size-3 shrink-0" />
Target host has recent traffic. Bench results may be affected.
</div>
)}
</div>
)}
{/* History view */}
{view === 'history' && (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{runs.length >= 2 && (
<div ref={historyChartRef} className="h-[200px] shrink-0" />
)}
<div className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-2">
{runs.map((run) => {
const suite = suites.find((s) => s.id === run.suiteId);
const flag = run.regressionFlag;
return (
<div
key={run.id}
onClick={() => loadRunDetails(run.id)}
className="flex items-center justify-between p-3 bg-muted/20 rounded-lg border border-border/30 cursor-pointer hover:bg-muted/30 transition-colors"
>
<div>
<div className="text-sm font-medium flex items-center gap-2">
{run.id.slice(0, 12)}
<span className={cn(
'px-1.5 py-0.5 rounded text-[10px]',
run.status === 'completed' ? 'bg-green-500/20 text-green-400' :
run.status === 'failed' ? 'bg-red-500/20 text-red-400' :
'bg-yellow-500/20 text-yellow-400'
)}>
{run.status}
</span>
{flag === 'regression' && (
<span className="flex items-center gap-0.5 text-red-400">
<TrendingDown className="size-3" />
</span>
)}
{flag === 'improvement' && (
<span className="flex items-center gap-0.5 text-green-400">
<TrendingUp className="size-3" />
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{suite?.name} - {run.completedSamples}/{run.totalSamples} samples
{run.concurrentForeignRequests > 0 && (
<span className="text-yellow-400 ml-1">
({run.concurrentForeignRequests} foreign reqs)
</span>
)}
</div>
</div>
{run.aggregate && (
<div className="text-right text-xs text-muted-foreground">
{run.aggregate.avgGenTps != null && (
<div>{(run.aggregate.avgGenTps as number).toFixed(1)} tok/s</div>
)}
{run.aggregate.avgTtftMs != null && (
<div>{(run.aggregate.avgTtftMs as number).toFixed(0)}ms TTFT</div>
)}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
)}
{/* Results view */}
{view === 'results' && (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{selectedRun ? (
<>
<div ref={chartRef} className="h-[250px] shrink-0" />
<div className="flex-1 overflow-y-auto px-4 py-3">
<div className="text-xs text-muted-foreground mb-2">
{selectedRun.id.slice(0, 16)} - {selectedRun.completedSamples}/{selectedRun.totalSamples} samples
{selectedRun.concurrentForeignRequests > 0 && (
<span className="ml-2 text-yellow-400">
({selectedRun.concurrentForeignRequests} concurrent foreign requests)
</span>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b border-border/30">
<th className="text-left py-1 px-2">PT</th>
<th className="text-left py-1 px-2">GT</th>
<th className="text-left py-1 px-2">Conc</th>
<th className="text-left py-1 px-2">Rep</th>
<th className="text-right py-1 px-2">TTFT</th>
<th className="text-right py-1 px-2">Total</th>
<th className="text-right py-1 px-2">Prompt/s</th>
<th className="text-right py-1 px-2">Gen/s</th>
<th className="text-right py-1 px-2">Cache</th>
</tr>
</thead>
<tbody>
{samples.map((s) => (
<tr key={s.id} className="border-b border-border/20">
<td className="py-1 px-2">{s.promptTokens}</td>
<td className="py-1 px-2">{s.genTokens}</td>
<td className="py-1 px-2">{s.concurrency}</td>
<td className="py-1 px-2">{s.repetition}</td>
<td className="py-1 px-2 text-right">{s.ttftMs?.toFixed(0) ?? '-'}</td>
<td className="py-1 px-2 text-right">{s.totalMs?.toFixed(0) ?? '-'}</td>
<td className="py-1 px-2 text-right">{s.promptTps?.toFixed(1) ?? '-'}</td>
<td className="py-1 px-2 text-right">{s.genTps?.toFixed(1) ?? '-'}</td>
<td className="py-1 px-2 text-right">{s.cacheN ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
) : (
<div className="flex items-center justify-center flex-1 text-muted-foreground text-sm">
Select a run from History to view results
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,236 @@
import { useEffect, useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { X, ExternalLink, Copy } from 'lucide-react';
import { codeToHtml } from 'shiki';
interface CaptureDrawerProps {
requestId: number;
providerId: string;
onClose: () => void;
}
interface CaptureData {
id: number;
providerId: string;
timestamp: string;
model: string;
requestHeaders: Record<string, string>;
requestBody: string;
responseHeaders: Record<string, string>;
responseBody: string;
durationMs: number;
sizeBytes: number;
}
export function CaptureDrawer({ requestId, providerId, onClose }: CaptureDrawerProps) {
const [capture, setCapture] = useState<CaptureData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activePanel, setActivePanel] = useState<'req' | 'resp'>('req');
const [highlightedReq, setHighlightedReq] = useState('');
const [highlightedResp, setHighlightedResp] = useState('');
useEffect(() => {
let cancelled = false;
async function fetchCapture() {
try {
const res = await fetch(`/api/control/capture/${providerId}/${requestId}`);
if (!res.ok) {
if (!cancelled) {
setError(res.status === 404 ? 'Capture not found' : `Fetch failed: ${res.status}`);
setLoading(false);
}
return;
}
const data = await res.json();
if (!cancelled) {
setCapture(data);
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError((err as Error).message);
setLoading(false);
}
}
}
fetchCapture();
return () => { cancelled = true; };
}, [requestId, providerId]);
useEffect(() => {
if (!capture) return;
const reqBody = capture.requestBody || '{}';
const respBody = capture.responseBody || '{}';
let cancelled = false;
async function highlight() {
try {
const reqHtml = await codeToHtml(reqBody, {
lang: 'json',
theme: 'github-dark',
});
const respHtml = await codeToHtml(respBody, {
lang: 'json',
theme: 'github-dark',
});
if (!cancelled) {
setHighlightedReq(reqHtml);
setHighlightedResp(respHtml);
}
} catch {
// Fallback to plain text
}
}
highlight();
return () => { cancelled = true; };
}, [capture]);
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text).catch(() => {});
}, []);
if (loading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background border border-border rounded-lg p-6 w-[80vw] max-w-4xl max-h-[80vh]">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Loading capture...</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="size-4" />
</button>
</div>
<div className="flex items-center justify-center py-8">
<div className="animate-spin size-5 border-2 border-foreground/30 border-t-foreground rounded-full" />
</div>
</div>
</div>
);
}
if (error) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background border border-border rounded-lg p-6 w-[80vw] max-w-4xl max-h-[80vh]">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-red-400">Capture Error</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="size-4" />
</button>
</div>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
);
}
if (!capture) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background border border-border rounded-lg w-[80vw] max-w-4xl max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div>
<h2 className="text-lg font-semibold">Request Capture</h2>
<p className="text-xs text-muted-foreground">
{capture.model} &middot; {capture.durationMs}ms &middot; {(capture.sizeBytes / 1024).toFixed(1)}KB
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs border border-border/40 text-muted-foreground hover:text-foreground transition-colors"
title="Open in Playground (P3)"
>
<ExternalLink className="size-3" />
Open in Playground
</button>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="size-4" />
</button>
</div>
</div>
{/* Headers table */}
<div className="px-4 py-2 border-b border-border shrink-0">
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Request Headers</h3>
<HeadersTable headers={capture.requestHeaders} />
</div>
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-1">Response Headers</h3>
<HeadersTable headers={capture.responseHeaders} />
</div>
</div>
</div>
{/* Body panels */}
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex gap-1 px-4 pt-2 shrink-0">
<button
type="button"
onClick={() => setActivePanel('req')}
className={cn(
'px-3 py-1 text-xs rounded-t transition-colors',
activePanel === 'req'
? 'bg-muted/50 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
>
Request Body
</button>
<button
type="button"
onClick={() => setActivePanel('resp')}
className={cn(
'px-3 py-1 text-xs rounded-t transition-colors',
activePanel === 'resp'
? 'bg-muted/50 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
>
Response Body
</button>
<div className="flex-1" />
<button
type="button"
onClick={() => copyToClipboard(activePanel === 'req' ? capture.requestBody : capture.responseBody)}
className="px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Copy className="size-3" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto p-4">
<div
className="text-[11px] font-mono"
dangerouslySetInnerHTML={{
__html: activePanel === 'req' ? highlightedReq : highlightedResp,
}}
/>
</div>
</div>
</div>
</div>
);
}
function HeadersTable({ headers }: { headers: Record<string, string> }) {
const entries = Object.entries(headers);
if (entries.length === 0) {
return <p className="text-[11px] text-muted-foreground">No headers</p>;
}
return (
<div className="space-y-0.5">
{entries.slice(0, 8).map(([key, value]) => (
<div key={key} className="flex gap-2 text-[10px] font-mono">
<span className="text-muted-foreground shrink-0 truncate max-w-[120px]">{key}</span>
<span className="text-foreground/70 truncate">{value}</span>
</div>
))}
{entries.length > 8 && (
<p className="text-[10px] text-muted-foreground">+{entries.length - 8} more</p>
)}
</div>
);
}

View File

@@ -0,0 +1,456 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import * as echarts from 'echarts/core';
import { ScatterChart, BarChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent } from 'echarts/components';
import { buildEChartsTheme } from './buildEChartsTheme';
import {
Play,
Loader2,
BarChart3,
Table,
Brain,
Code,
Trophy,
} from 'lucide-react';
echarts.use([ScatterChart, BarChart, CanvasRenderer, GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent]);
interface EvalsTabProps {
providerIds: string[];
}
interface EvalSuite {
id: string;
name: string;
kind: string;
version: number;
tasks: unknown[];
judgeModel: string | null;
createdAt: string;
}
interface EvalRun {
id: string;
suiteId: string;
jobType: string;
providerId: string;
model: string;
quant: string | null;
status: string;
judgeModel: string | null;
startedAt: string | null;
finishedAt: string | null;
totalTasks: number;
completedTasks: number;
aggregate: Record<string, unknown> | null;
error: string | null;
createdAt: string;
}
interface LeaderboardEntry {
providerId: string;
model: string;
quant: string | null;
suiteKind: string;
avgScore: number | null;
runCount: number;
latestRunAt: string;
}
async function fetchSuites(): Promise<EvalSuite[]> {
const res = await fetch('/api/control/eval/suites');
const data = await res.json() as { suites: EvalSuite[] };
return data.suites ?? [];
}
async function fetchRuns(suiteId?: string): Promise<EvalRun[]> {
const url = suiteId ? `/api/control/eval/runs?suiteId=${suiteId}` : '/api/control/eval/runs';
const res = await fetch(url);
const data = await res.json() as { runs: EvalRun[] };
return data.runs ?? [];
}
async function fetchLeaderboard(kind?: string): Promise<LeaderboardEntry[]> {
const url = kind ? `/api/control/eval/leaderboard?kind=${kind}` : '/api/control/eval/leaderboard';
const res = await fetch(url);
const data = await res.json() as { leaderboard: LeaderboardEntry[] };
return data.leaderboard ?? [];
}
async function runEval(suiteId: string, providerId: string, model: string): Promise<void> {
const res = await fetch('/api/control/eval/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ suiteId, providerId, model }),
});
if (!res.ok) {
throw new Error(`eval run failed: ${res.status}`);
}
}
export function EvalsTab({ providerIds }: EvalsTabProps) {
const [suites, setSuites] = useState<EvalSuite[]>([]);
const [runs, setRuns] = useState<EvalRun[]>([]);
const [leaderboard, setLeaderboard] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'leaderboard' | 'runs' | 'scatter'>('leaderboard');
const [suiteFilter, setSuiteFilter] = useState<string>('all');
const [kindFilter, setKindFilter] = useState<string>('all');
const scatterRef = useRef<HTMLDivElement>(null);
const barRef = useRef<HTMLDivElement>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const [suitesData, runsData, lbData] = await Promise.all([
fetchSuites(),
fetchRuns(),
fetchLeaderboard(kindFilter !== 'all' ? kindFilter : undefined),
]);
setSuites(suitesData);
setRuns(runsData);
setLeaderboard(lbData);
} catch (err) {
console.error('evals: load failed', err);
} finally {
setLoading(false);
}
}, [kindFilter]);
useEffect(() => {
load();
}, [load]);
// Scatter chart: speed x quality
useEffect(() => {
if (!scatterRef.current || activeView !== 'scatter') return;
const chart = echarts.init(scatterRef.current, buildEChartsTheme() as echarts.EChartsCoreOption);
const scatterData = leaderboard.map((entry) => ({
x: entry.avgScore ?? 0,
y: entry.runCount,
name: `${entry.model}${entry.quant ? ` (${entry.quant})` : ''}`,
providerId: entry.providerId,
suiteKind: entry.suiteKind,
}));
const option: echarts.EChartsCoreOption = {
backgroundColor: 'transparent',
title: {
text: 'Quality vs Run Frequency',
left: 'center',
textStyle: { color: 'var(--foreground)', fontSize: 14 },
},
tooltip: {
trigger: 'item',
formatter: (p: { data: { name: string; providerId: string; x: number; y: number } }) => {
const d = p.data as { name: string; providerId: string; x: number; y: number };
return `<b>${d.name}</b><br/>Provider: ${d.providerId}<br/>Avg Score: ${d.x.toFixed(2)}<br/>Runs: ${d.y}`;
},
},
legend: {
data: [...new Set(leaderboard.map((e) => e.providerId))],
textStyle: { color: 'var(--foreground)' },
top: 30,
},
grid: { left: 60, right: 30, top: 70, bottom: 50 },
xAxis: {
name: 'Avg Score',
nameTextStyle: { color: 'var(--foreground)' },
axisLabel: { color: 'var(--foreground)' },
splitLine: { lineStyle: { color: 'var(--border, #333)' } },
},
yAxis: {
name: 'Run Count',
nameTextStyle: { color: 'var(--foreground)' },
axisLabel: { color: 'var(--foreground)' },
splitLine: { lineStyle: { color: 'var(--border, #333)' } },
},
series: [...new Set(leaderboard.map((e) => e.providerId))].map((pid, i) => ({
type: 'scatter',
name: pid,
data: scatterData.filter((d) => d.providerId === pid).map((d) => [d.x, d.y, d.name]),
symbolSize: (val: number[]) => Math.max(8, (val[1] ?? 1) * 3),
itemStyle: {
color: ['#60a5fa', '#f472b6', '#34d399', '#fbbf24'][i % 4],
},
})),
};
chart.setOption(option);
const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.dispose();
};
}, [leaderboard, activeView]);
// Bar chart for leaderboard view
useEffect(() => {
if (!barRef.current || activeView !== 'leaderboard') return;
const chart = echarts.init(barRef.current, buildEChartsTheme() as echarts.EChartsCoreOption);
const sorted = [...leaderboard].sort((a, b) => (b.avgScore ?? 0) - (a.avgScore ?? 0)).slice(0, 20);
const option: echarts.EChartsCoreOption = {
backgroundColor: 'transparent',
title: {
text: 'Model Leaderboard',
left: 'center',
textStyle: { color: 'var(--foreground)', fontSize: 14 },
},
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: unknown[]) => {
const p = params[0] as { name: string; value: number };
return `<b>${p.name}</b><br/>Score: ${(p.value as number).toFixed(2)}`;
},
},
grid: { left: 120, right: 30, top: 60, bottom: 30 },
xAxis: {
type: 'value',
axisLabel: { color: 'var(--foreground)' },
splitLine: { lineStyle: { color: 'var(--border, #333)' } },
},
yAxis: {
type: 'category',
data: sorted.map((e) => e.model).reverse(),
axisLabel: { color: 'var(--foreground)', fontSize: 11 },
axisLine: { lineStyle: { color: 'var(--border, #333)' } },
},
series: [{
type: 'bar',
data: sorted.map((e) => e.avgScore ?? 0).reverse(),
itemStyle: {
color: (params: { dataIndex?: number }) => {
const idx = params.dataIndex ?? 0;
const score = sorted[sorted.length - 1 - idx]?.avgScore ?? 0;
if (score != null && score >= 0.8) return '#34d399';
if (score != null && score >= 0.5) return '#60a5fa';
return '#f87171';
},
},
}],
};
chart.setOption(option);
const handleResize = () => chart.resize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.dispose();
};
}, [leaderboard, activeView]);
const handleRunEval = async (suiteId: string, providerId: string, model: string) => {
const key = `${suiteId}-${providerId}-${model}`;
setRunning(key);
try {
await runEval(suiteId, providerId, model);
} catch (err) {
console.error('eval: run failed', err);
} finally {
setRunning(null);
}
};
if (loading) {
return (
<div className="flex items-center justify-center flex-1">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex-1 flex flex-col min-h-0">
{/* View tabs */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/40">
<button
onClick={() => setActiveView('leaderboard')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${activeView === 'leaderboard' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<Trophy className="size-3 inline mr-1" />
Leaderboard
</button>
<button
onClick={() => setActiveView('scatter')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${activeView === 'scatter' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<BarChart3 className="size-3 inline mr-1" />
Scatter
</button>
<button
onClick={() => setActiveView('runs')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${activeView === 'runs' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<Table className="size-3 inline mr-1" />
Runs
</button>
<div className="flex-1" />
<select
value={kindFilter}
onChange={(e) => setKindFilter(e.target.value)}
className="text-xs bg-background border border-border rounded-md px-2 py-1"
>
<option value="all">All Kinds</option>
<option value="chat">Chat</option>
<option value="code">Code</option>
</select>
</div>
{/* Content */}
<div className="flex-1 overflow-auto">
{activeView === 'leaderboard' && (
<div className="p-4">
<div ref={barRef} style={{ width: '100%', height: '400px' }} />
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{leaderboard.map((entry) => (
<div key={`${entry.providerId}-${entry.model}-${entry.suiteKind}`} className="border border-border/40 rounded-lg p-3 bg-card/50">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{entry.model}</span>
<span className="text-xs text-muted-foreground">{entry.providerId}</span>
</div>
<div className="flex items-center gap-2 mt-1">
{entry.suiteKind === 'code' ? (
<Code className="size-3 text-blue-400" />
) : (
<Brain className="size-3 text-purple-400" />
)}
<span className="text-xs text-muted-foreground capitalize">{entry.suiteKind}</span>
{entry.quant && <span className="text-xs text-muted-foreground">{entry.quant}</span>}
</div>
<div className="mt-2 flex items-center justify-between">
<span className="text-lg font-mono">{entry.avgScore?.toFixed(2) ?? 'N/A'}</span>
<span className="text-xs text-muted-foreground">{entry.runCount} runs</span>
</div>
</div>
))}
</div>
</div>
)}
{activeView === 'scatter' && (
<div className="p-4">
<div ref={scatterRef} style={{ width: '100%', height: '500px' }} />
</div>
)}
{activeView === 'runs' && (
<div className="p-4">
{/* Run launcher */}
<div className="mb-4 p-3 border border-border/40 rounded-lg bg-card/30">
<h3 className="text-sm font-medium mb-2">Launch Eval</h3>
<div className="flex flex-wrap gap-2">
<select
id="eval-suite"
className="text-xs bg-background border border-border rounded-md px-2 py-1"
>
{suites.map((s) => (
<option key={s.id} value={s.id}>{s.name} ({s.kind})</option>
))}
</select>
<select
id="eval-provider"
className="text-xs bg-background border border-border rounded-md px-2 py-1"
>
{providerIds.map((pid) => (
<option key={pid} value={pid}>{pid}</option>
))}
</select>
<input
id="eval-model"
placeholder="Model ID"
className="text-xs bg-background border border-border rounded-md px-2 py-1 flex-1 min-w-[120px]"
/>
<button
onClick={async () => {
const suiteId = (document.getElementById('eval-suite') as HTMLSelectElement).value;
const providerId = (document.getElementById('eval-provider') as HTMLSelectElement).value;
const model = (document.getElementById('eval-model') as HTMLInputElement).value;
if (suiteId && providerId && model) {
await handleRunEval(suiteId, providerId, model);
}
}}
className="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
<Play className="size-3" />
Run
</button>
</div>
</div>
{/* Runs table */}
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/40 text-muted-foreground">
<th className="text-left py-2 px-3">Run ID</th>
<th className="text-left py-2 px-3">Suite</th>
<th className="text-left py-2 px-3">Provider</th>
<th className="text-left py-2 px-3">Model</th>
<th className="text-left py-2 px-3">Status</th>
<th className="text-left py-2 px-3">Score</th>
<th className="text-left py-2 px-3">Progress</th>
<th className="text-left py-2 px-3">Started</th>
</tr>
</thead>
<tbody>
{runs.map((run) => {
const suite = suites.find((s) => s.id === run.suiteId);
return (
<tr key={run.id} className="border-b border-border/20 hover:bg-muted/20">
<td className="py-2 px-3 font-mono">{run.id.slice(0, 16)}</td>
<td className="py-2 px-3">{suite?.name ?? run.suiteId}</td>
<td className="py-2 px-3">{run.providerId}</td>
<td className="py-2 px-3">{run.model}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded-full text-xs ${
run.status === 'completed' ? 'bg-green-500/20 text-green-400' :
run.status === 'running' ? 'bg-blue-500/20 text-blue-400' :
run.status === 'failed' ? 'bg-red-500/20 text-red-400' :
'bg-yellow-500/20 text-yellow-400'
}`}>
{run.status}
</span>
</td>
<td className="py-2 px-3 font-mono">
{run.aggregate?.avgScore != null ? (run.aggregate.avgScore as number).toFixed(2) : '-'}
</td>
<td className="py-2 px-3">
{run.totalTasks > 0 ? `${run.completedTasks}/${run.totalTasks}` : '-'}
</td>
<td className="py-2 px-3 text-muted-foreground">
{run.startedAt ? new Date(run.startedAt).toLocaleTimeString() : '-'}
</td>
</tr>
);
})}
{runs.length === 0 && (
<tr>
<td colSpan={8} className="py-8 text-center text-muted-foreground">
No eval runs yet. Launch one above.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { useState } from 'react';
import { AnimatePresence } from 'framer-motion';
import { Settings2 } from 'lucide-react';
import { ControlFleetHost } from '@/hooks/useControlStream';
import { HostCard } from './HostCard';
import { HostConfigEditor } from './HostConfigEditor';
export interface GpuData {
vram_used: number;
vram_total: number;
temperature: number;
power: number;
}
interface FleetTabProps {
hosts: ControlFleetHost[];
gpuMap: Map<string, GpuData>;
}
export function FleetTab({ hosts, gpuMap }: FleetTabProps) {
const [editing, setEditing] = useState<string | null>(null);
if (hosts.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">No hosts connected</p>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<AnimatePresence mode="popLayout">
{hosts.map((host) => (
<div key={host.providerId} className="relative">
<button
type="button"
onClick={() => setEditing(host.providerId)}
title="SSH config editor"
className="absolute top-2 right-2 z-10 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<Settings2 className="size-4" />
</button>
<HostCard host={host} gpuData={gpuMap.get(host.providerId) ?? null} />
</div>
))}
</AnimatePresence>
{editing && <HostConfigEditor providerId={editing} onClose={() => setEditing(null)} />}
</div>
);
}

View File

@@ -0,0 +1,336 @@
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
import { ControlFleetHost } from '@/hooks/useControlStream';
import { useReducedMotion } from '@/hooks/useReducedMotion';
import { VramGauge } from './VramGauge';
import { TtlRing } from './TtlRing';
import { cn } from '@/lib/utils';
import type { GpuData } from './FleetTab';
import { Play, Eraser } from 'lucide-react';
interface HostCardProps {
host: ControlFleetHost;
gpuData: GpuData | null;
}
const STATE_COLORS: Record<string, { bg: string; glowVar: string; animate: boolean }> = {
starting: { bg: 'bg-amber-500', glowVar: '--glow-amber', animate: true },
ready: { bg: 'bg-green-500', glowVar: '--glow-green', animate: false },
error: { bg: 'bg-red-500', glowVar: '--glow-red', animate: false },
down: { bg: 'bg-gray-500', glowVar: '--glow-gray', animate: false },
stopped: { bg: 'bg-gray-400', glowVar: '--glow-gray', animate: false },
stopping: { bg: 'bg-amber-400', glowVar: '--glow-amber', animate: true },
};
const FALLBACK_STATE = { bg: 'bg-gray-500', glowVar: '--glow-gray', animate: false };
function relTime(iso: string | null): string {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
function livenessLabel(state: string): string {
switch (state) {
case 'connected': return 'connected';
case 'reconnecting': return 'reconnecting';
case 'down': return 'down';
default: return state;
}
}
function getGlowColor(glowVar: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(glowVar).trim();
}
export function HostCard({ host, gpuData }: HostCardProps) {
const reducedMotion = useReducedMotion();
const livenessKey = host.liveness === 'connected' ? 'ready' : host.liveness === 'reconnecting' ? 'starting' : host.liveness;
const stateConfig = STATE_COLORS[livenessKey] ?? FALLBACK_STATE;
const glowColor = getGlowColor(stateConfig.glowVar);
const vramUsed = gpuData?.vram_used ?? 0;
const vramTotal = gpuData?.vram_total ?? 0;
const gpuTemp = gpuData?.temperature ?? null;
const gpuPower = gpuData?.power ?? null;
return (
<motion.div
layout
initial={reducedMotion ? undefined : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={reducedMotion ? undefined : { opacity: 0, scale: 0.97 }}
transition={reducedMotion ? undefined : { type: 'spring', stiffness: 300, damping: 25 }}
className={cn(
'rounded-xl border border-border/60 bg-card p-4',
'shadow-sm',
)}
>
{/* Header: provider ID + liveness chip + last seen */}
<div className="flex items-center gap-3 mb-3">
<h2 className="text-sm font-semibold tracking-tight">{host.providerId}</h2>
<motion.div
className={cn(
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-medium',
'border border-border/40',
)}
animate={
reducedMotion
? undefined
: stateConfig.animate
? { boxShadow: ['0 0 0px transparent', `0 0 8px ${glowColor}33`, '0 0 0px transparent'] }
: { boxShadow: [`0 0 6px ${glowColor}33`] }
}
transition={
reducedMotion
? undefined
: { duration: 1.5, repeat: stateConfig.animate ? Infinity : 0 }
}
>
<span
className={cn(
'w-1.5 h-1.5 rounded-full',
stateConfig.bg,
)}
/>
<span className="capitalize">{livenessLabel(host.liveness)}</span>
</motion.div>
{host.liveness === 'down' && host.lastSeenAt && (
<span className="text-[11px] text-muted-foreground">
last seen {relTime(host.lastSeenAt)}
</span>
)}
<span className="text-[10px] text-muted-foreground ml-auto font-mono">
seq {host.seq}
</span>
</div>
<div className="flex flex-col lg:flex-row gap-4">
{/* Left: VRAM gauge + GPU readouts */}
<div className="flex items-start gap-4 shrink-0">
{vramTotal > 0 ? (
<VramGauge used={vramUsed} total={vramTotal} size={110} />
) : (
<div className="w-[110px] h-[110px] flex items-center justify-center text-[11px] text-muted-foreground">
no GPU data
</div>
)}
{/* GPU readouts */}
<div className="space-y-2 pt-2">
{gpuTemp != null && (
<GpuReadout label="Temp" value={`${gpuTemp.toFixed(0)}\u00B0C`} />
)}
{gpuPower != null && (
<GpuReadout label="Power" value={`${gpuPower.toFixed(0)}W`} />
)}
<GpuReadout label="VRAM" value={`${vramUsed.toFixed(0)} / ${vramTotal.toFixed(0)} MB`} />
</div>
</div>
{/* Right: model chips + TTL rings */}
<div className="flex-1 min-w-0">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-medium">
Models
</div>
<div className="flex flex-wrap gap-2">
<AnimatePresence mode="popLayout">
{host.models.map((m) => (
<ModelChip key={`${m.model}-${m.state}`} model={m} />
))}
</AnimatePresence>
</div>
{/* TTL rings */}
{host.models.some((m) => m.ttlDeadline) && (
<div className="mt-3">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-medium">
TTL
</div>
<div className="flex gap-3">
{host.models.filter((m) => m.ttlDeadline).map((m) => (
<div key={`ttl-${m.model}`} className="flex flex-col items-center gap-1">
<TtlRing deadline={m.ttlDeadline} size={64} />
<span className="text-[10px] text-muted-foreground truncate max-w-[80px]">
{m.model}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</motion.div>
);
}
function GpuReadout({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline gap-1.5">
<span className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
{label}
</span>
<span className="text-sm font-bold font-[Orbitron] tabular-nums text-foreground">
{value}
</span>
</div>
);
}
interface ModelChipProps {
model: {
model: string;
state: string;
ts: string;
ttlDeadline: string | null;
inflight: number;
};
}
function ModelChip({ model }: ModelChipProps) {
const reducedMotion = useReducedMotion();
const stateConfig = STATE_COLORS[model.state] ?? FALLBACK_STATE;
const [actionError, setActionError] = useState<string | null>(null);
const [confirmUnload, setConfirmUnload] = useState(false);
// P2.2: Optimistic UI — API calls only, no local state mutation.
// The control_fleet delta from WS updates the UI.
const handleWarm = async () => {
try {
const res = await fetch('/api/control/action/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'warm', providerId: model.model.split(':')[0], model: model.model }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setActionError(data.error || `Warm failed: ${res.status}`);
setTimeout(() => setActionError(null), 3000);
}
} catch {
setActionError('Network error');
setTimeout(() => setActionError(null), 3000);
}
};
const handleUnload = async (confirmed: boolean) => {
try {
const res = await fetch('/api/control/action/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: 'unload',
providerId: model.model.split(':')[0],
model: model.model,
confirmed,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (data.requiresConfirmation) {
setConfirmUnload(true);
return;
}
setActionError(data.error || `Unload failed: ${res.status}`);
setTimeout(() => setActionError(null), 3000);
} else {
setConfirmUnload(false);
}
} catch {
setActionError('Network error');
setTimeout(() => setActionError(null), 3000);
}
};
const handleConfirmedUnload = async () => {
await handleUnload(true);
setConfirmUnload(false);
};
return (
<motion.span
layout
initial={reducedMotion ? undefined : { scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={reducedMotion ? undefined : { scale: 0.8, opacity: 0 }}
transition={reducedMotion ? undefined : { type: 'spring', stiffness: 400, damping: 20 }}
className={cn(
'inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
'border border-border/40 bg-muted/30',
'font-medium',
)}
>
<span
className={cn(
'w-1.5 h-1.5 rounded-full shrink-0',
stateConfig.bg,
)}
/>
<span className="truncate max-w-[160px]">{model.model}</span>
{model.inflight > 0 && (
<span className="text-[10px] text-muted-foreground ml-0.5">
({model.inflight})
</span>
)}
{/* Action buttons — fire-and-forget, UI updates from control_fleet delta */}
<button
type="button"
onClick={handleWarm}
className="p-0.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
title={`Warm ${model.model}`}
>
<Play className="size-2.5" />
</button>
<button
type="button"
onClick={() => handleUnload(false)}
className="p-0.5 rounded hover:bg-muted/50 text-muted-foreground hover:text-red-400 transition-colors"
title={`Unload ${model.model}`}
>
<Eraser className="size-2.5" />
</button>
{actionError && (
<span className="text-[9px] text-red-400 absolute -top-4 left-0 whitespace-nowrap">
{actionError}
</span>
)}
{confirmUnload && (
<div className="absolute top-full left-0 mt-1 z-10 bg-background border border-border rounded-md p-2 shadow-lg flex flex-col gap-1 min-w-[180px]">
<p className="text-[11px] text-foreground">
Model has active requests. Force unload?
</p>
<div className="flex gap-1">
<button
type="button"
onClick={handleConfirmedUnload}
className="px-2 py-0.5 text-[10px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
>
Force unload
</button>
<button
type="button"
onClick={() => setConfirmUnload(false)}
className="px-2 py-0.5 text-[10px] rounded bg-muted/30 text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</motion.span>
);
}

View File

@@ -0,0 +1,241 @@
import { useCallback, useEffect, useState } from 'react';
import { X, Loader2, Save, FileDown, GitCompare, CheckCircle2, AlertTriangle, ShieldCheck, Download } from 'lucide-react';
interface HostInfo {
providerId: string;
sshHost: string | null;
sshUser: string | null;
sshKeyPath: string | null;
configPath: string | null;
restartCmd: string | null;
sshMode: 'shell' | 'wrapper';
sshConfigured: boolean;
}
interface ApplyResult {
ok: boolean;
step: string;
backupPath?: string;
error?: string;
diff?: string;
}
/**
* P9.1: SSH config editor for a single llama-swap host. Set SSH settings, load
* the remote config, validate against the fork schema, preview a diff, and apply
* (backup -> write -> restart -> health-wait) behind a confirmation.
*/
export function HostConfigEditor({ providerId, onClose }: { providerId: string; onClose: () => void }) {
const [host, setHost] = useState<HostInfo | null>(null);
const [form, setForm] = useState<Partial<HostInfo>>({});
const [content, setContent] = useState('');
const [busy, setBusy] = useState<string | null>(null);
const [validation, setValidation] = useState<{ valid: boolean; errors: string[] } | null>(null);
const [diff, setDiff] = useState<string | null>(null);
const [applyResult, setApplyResult] = useState<ApplyResult | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [pullRepo, setPullRepo] = useState('');
const [pullMsg, setPullMsg] = useState<string | null>(null);
const loadHost = useCallback(async () => {
const res = await fetch('/api/control/hosts');
const data = await res.json() as { hosts: HostInfo[] };
const h = data.hosts.find((x) => x.providerId === providerId) ?? null;
setHost(h);
if (h) setForm({ sshHost: h.sshHost, sshUser: h.sshUser, sshKeyPath: h.sshKeyPath, configPath: h.configPath, restartCmd: h.restartCmd, sshMode: h.sshMode ?? 'shell' });
}, [providerId]);
useEffect(() => { void loadHost(); }, [loadHost]);
const saveSettings = async () => {
setBusy('settings');
setMessage(null);
try {
const res = await fetch(`/api/control/hosts/${providerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (res.ok) { setMessage('SSH settings saved'); await loadHost(); }
else setMessage(`Save failed: ${res.status}`);
} finally { setBusy(null); }
};
const loadConfig = async () => {
setBusy('load');
setMessage(null);
setDiff(null); setValidation(null); setApplyResult(null);
try {
const res = await fetch(`/api/control/hosts/${providerId}/config`);
const data = await res.json() as { content?: string; error?: string };
if (res.ok && data.content != null) setContent(data.content);
else setMessage(data.error ?? `Load failed: ${res.status}`);
} finally { setBusy(null); }
};
const validate = async () => {
setBusy('validate');
try {
const res = await fetch(`/api/control/hosts/${providerId}/config/validate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }),
});
setValidation(await res.json());
} finally { setBusy(null); }
};
const showDiff = async () => {
setBusy('diff');
try {
const res = await fetch(`/api/control/hosts/${providerId}/config/diff`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }),
});
const data = await res.json() as { diff?: string; error?: string };
setDiff(data.diff ?? data.error ?? '(no changes)');
} finally { setBusy(null); }
};
const apply = async () => {
if (!confirm('Apply config: backup, write, restart llama-swap, and health-wait?')) return;
setBusy('apply');
setApplyResult(null);
try {
const res = await fetch(`/api/control/hosts/${providerId}/config/apply`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content, confirm: true }),
});
setApplyResult(await res.json());
} finally { setBusy(null); }
};
const pull = async () => {
const repo = pullRepo.trim();
if (!repo) return;
setBusy('pull');
setPullMsg(null);
try {
const res = await fetch(`/api/control/hosts/${providerId}/pull`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ repo }),
});
const data = await res.json() as { jobId?: string; error?: string };
setPullMsg(res.ok ? `queued (job ${data.jobId}) — watch Reports/Logs for progress` : (data.error ?? `failed: ${res.status}`));
} finally { setBusy(null); }
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-background border border-border rounded-lg w-[min(900px,92vw)] max-h-[88vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-2 border-b border-border/40">
<h2 className="text-sm font-medium">SSH config {providerId}</h2>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
</div>
<div className="flex-1 overflow-auto p-4 space-y-4 min-h-0">
{/* SSH settings */}
<div className="grid grid-cols-2 gap-2">
{([
['sshHost', 'SSH host (Tailscale IP)'],
['sshUser', 'SSH user'],
['sshKeyPath', 'SSH key path (secrets/...)'],
['configPath', 'Remote config path'],
['restartCmd', 'Restart command (nssm/systemctl)'],
] as const).map(([key, label]) => (
<input
key={key}
placeholder={label}
value={(form[key] as string) ?? ''}
onChange={(e) => setForm({ ...form, [key]: e.target.value })}
className="text-xs bg-background border border-border rounded-md px-2 py-1 font-mono"
/>
))}
<select
value={form.sshMode ?? 'shell'}
onChange={(e) => setForm({ ...form, sshMode: e.target.value as 'shell' | 'wrapper' })}
className="text-xs bg-background border border-border rounded-md px-2 py-1"
title="shell = raw commands; wrapper = forced-command verbs (locked-down key)"
>
<option value="shell">SSH mode: shell (raw)</option>
<option value="wrapper">SSH mode: wrapper (forced-command)</option>
</select>
</div>
<div className="flex items-center gap-2">
<button onClick={saveSettings} disabled={busy !== null} className="flex items-center gap-1 px-3 py-1 text-xs bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50">
<Save className="size-3" /> Save settings
</button>
<button onClick={loadConfig} disabled={busy !== null || !host?.sshConfigured} className="flex items-center gap-1 px-3 py-1 text-xs border border-border rounded-md hover:bg-muted/30 disabled:opacity-50">
<FileDown className="size-3" /> Load remote config
</button>
{!host?.sshConfigured && <span className="text-xs text-muted-foreground">Set SSH host/user/key/config path, then save.</span>}
{message && <span className="text-xs text-muted-foreground">{message}</span>}
</div>
{/* Editor */}
<textarea
value={content}
onChange={(e) => { setContent(e.target.value); setValidation(null); setDiff(null); setApplyResult(null); }}
placeholder="Load the remote config or paste a candidate config.yaml…"
className="w-full h-64 text-xs font-mono bg-background border border-border rounded-md px-2 py-1"
spellCheck={false}
/>
<div className="flex items-center gap-2">
<button onClick={validate} disabled={busy !== null || !content} className="flex items-center gap-1 px-3 py-1 text-xs border border-border rounded-md hover:bg-muted/30 disabled:opacity-50">
<ShieldCheck className="size-3" /> Validate
</button>
<button onClick={showDiff} disabled={busy !== null || !content || !host?.sshConfigured} className="flex items-center gap-1 px-3 py-1 text-xs border border-border rounded-md hover:bg-muted/30 disabled:opacity-50">
<GitCompare className="size-3" /> Diff vs remote
</button>
<button onClick={apply} disabled={busy !== null || !content || !host?.sshConfigured || validation?.valid === false} className="flex items-center gap-1 px-3 py-1 text-xs bg-amber-500/20 text-amber-300 border border-amber-500/40 rounded-md hover:bg-amber-500/30 disabled:opacity-50">
{busy === 'apply' ? <Loader2 className="size-3 animate-spin" /> : <CheckCircle2 className="size-3" />} Apply (backup + restart)
</button>
</div>
{validation && (
<div className={`text-xs rounded-md border p-2 ${validation.valid ? 'border-green-500/30 bg-green-500/10 text-green-400' : 'border-red-500/30 bg-red-500/10 text-red-400'}`}>
{validation.valid ? 'Config is valid against the llama-swap schema.' : (
<>
<div className="flex items-center gap-1 font-medium"><AlertTriangle className="size-3" /> Invalid config</div>
<ul className="mt-1 list-disc list-inside">{validation.errors.map((e, i) => <li key={i}>{e}</li>)}</ul>
</>
)}
</div>
)}
{diff !== null && (
<pre className="text-xs font-mono bg-muted/20 border border-border/40 rounded-md p-2 overflow-auto max-h-48 whitespace-pre-wrap">{diff || '(no changes)'}</pre>
)}
{applyResult && (
<div className={`text-xs rounded-md border p-2 ${applyResult.ok ? 'border-green-500/30 bg-green-500/10 text-green-400' : 'border-red-500/30 bg-red-500/10 text-red-400'}`}>
<div className="font-medium">{applyResult.ok ? 'Applied successfully' : `Failed at step: ${applyResult.step}`}</div>
{applyResult.backupPath && <div className="text-muted-foreground">Backup: {applyResult.backupPath}</div>}
{applyResult.error && <div>{applyResult.error}</div>}
</div>
)}
{/* Pull model from HuggingFace */}
<div className="border-t border-border/40 pt-3">
<div className="text-xs font-medium mb-1">Pull model from HuggingFace</div>
<div className="flex items-center gap-2">
<input
placeholder="org/name (e.g. Qwen/Qwen3.5-9B)"
value={pullRepo}
onChange={(e) => setPullRepo(e.target.value)}
className="flex-1 text-xs font-mono bg-background border border-border rounded-md px-2 py-1"
/>
<button
onClick={pull}
disabled={busy !== null || !pullRepo.trim() || !host?.sshConfigured}
className="flex items-center gap-1 px-3 py-1 text-xs border border-border rounded-md hover:bg-muted/30 disabled:opacity-50"
>
{busy === 'pull' ? <Loader2 className="size-3 animate-spin" /> : <Download className="size-3" />} Pull
</button>
</div>
{pullMsg && <div className="mt-1 text-xs text-muted-foreground">{pullMsg}</div>}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,167 @@
import { useCallback, useMemo, useState } from 'react';
import { Virtuoso, type FollowOutput } from 'react-virtuoso';
import { ControlLogEntry } from '@/hooks/useControlStream';
import { cn } from '@/lib/utils';
import { Pause, Play, Filter } from 'lucide-react';
interface LogsTabProps {
logs: ControlLogEntry[];
providerIds: string[];
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
const SOURCE_COLORS: Record<string, string> = {
proxy: 'text-blue-400',
upstream: 'text-emerald-400',
model: 'text-amber-400',
};
export function LogsTab({ logs, providerIds }: LogsTabProps) {
const [paused, setPaused] = useState(false);
const [sourceFilter, setSourceFilter] = useState<string | null>(null);
const [hostFilter, setHostFilter] = useState<string | null>(null);
const sources = useMemo(() => {
const set = new Set<string>();
for (const l of logs) {
set.add(l.source);
}
return Array.from(set);
}, [logs]);
const filtered = useMemo(() => {
return logs.filter((l) => {
if (sourceFilter && l.source !== sourceFilter) return false;
if (hostFilter && l.providerId !== hostFilter) return false;
return true;
});
}, [logs, sourceFilter, hostFilter]);
const itemContent = useCallback(
(_index: number, entry: ControlLogEntry) => {
return (
<div className="flex items-start gap-2 px-3 py-0.5 text-[11px] font-mono border-b border-border/10">
<span className="text-muted-foreground shrink-0 w-20">
{formatTime(new Date().toISOString())}
</span>
<span
className={cn(
'shrink-0 w-20 font-medium',
SOURCE_COLORS[entry.source] ?? 'text-muted-foreground',
)}
>
[{entry.source}]
</span>
<span className="shrink-0 w-24 text-muted-foreground">
{entry.providerId}
</span>
<span className="text-foreground/80 break-all">{entry.line}</span>
</div>
);
},
[],
);
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Filter bar */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/40 shrink-0 flex-wrap">
<Filter className="size-3 text-muted-foreground" />
<div className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Source
</div>
<FilterChip
label="All"
active={sourceFilter === null}
onClick={() => setSourceFilter(null)}
/>
{sources.map((s) => (
<FilterChip
key={s}
label={s}
active={sourceFilter === s}
onClick={() => setSourceFilter(sourceFilter === s ? null : s)}
/>
))}
<div className="w-px h-4 bg-border mx-1" />
<div className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Host
</div>
<FilterChip
label="All"
active={hostFilter === null}
onClick={() => setHostFilter(null)}
/>
{providerIds.map((pid) => (
<FilterChip
key={pid}
label={pid}
active={hostFilter === pid}
onClick={() => setHostFilter(hostFilter === pid ? null : pid)}
/>
))}
<div className="flex-1" />
<button
type="button"
onClick={() => setPaused((p) => !p)}
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium',
'border border-border/40 transition-colors',
paused
? 'bg-amber-500/10 text-amber-400 border-amber-500/20'
: 'bg-muted/30 text-muted-foreground hover:text-foreground',
)}
aria-label={paused ? 'Resume follow' : 'Pause follow'}
>
{paused ? <Play className="size-3" /> : <Pause className="size-3" />}
{paused ? 'Paused' : 'Follow'}
</button>
</div>
{/* Log feed */}
<div className="flex-1 min-h-0 bg-muted/10">
<Virtuoso
data={filtered}
itemContent={itemContent}
followOutput={paused ? undefined : 'bottom' as FollowOutput}
overscan={400}
className="h-full"
/>
</div>
</div>
);
}
function FilterChip({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'px-2 py-0.5 rounded text-[11px] font-medium transition-colors border',
active
? 'bg-primary/10 text-foreground border-primary/30'
: 'bg-muted/20 text-muted-foreground border-border/30 hover:text-foreground hover:border-border/60',
)}
>
{label}
</button>
);
}

View File

@@ -0,0 +1,110 @@
import { useEffect, useRef } from 'react';
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import {
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
} from 'echarts/components';
import type { EChartsType } from 'echarts/core';
import { buildEChartsTheme } from './buildEChartsTheme';
echarts.use([LineChart, CanvasRenderer, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent]);
interface PerfSeries {
name: string;
data: number[];
color: string;
}
interface PerfChartProps {
series: PerfSeries[];
timestamps: string[];
height?: number;
}
export function PerfChart({ series, timestamps, height = 200 }: PerfChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<EChartsType | null>(null);
useEffect(() => {
if (!containerRef.current) return;
if (!chartRef.current) {
const theme = buildEChartsTheme();
chartRef.current = echarts.init(containerRef.current, theme);
}
const chart = chartRef.current;
const root = getComputedStyle(document.documentElement);
const get = (prop: string) => root.getPropertyValue(prop).trim();
chart.setOption({
backgroundColor: 'transparent',
textStyle: { color: get('--foreground') },
tooltip: {
trigger: 'axis',
backgroundColor: get('--muted'),
borderColor: get('--border'),
textStyle: { color: get('--foreground') },
},
legend: {
top: 0,
textStyle: { color: get('--foreground'), fontSize: 11 },
},
grid: {
left: 48,
right: 16,
top: 36,
bottom: 24,
},
xAxis: {
type: 'category',
data: timestamps,
axisLine: { lineStyle: { color: get('--border') } },
axisLabel: { color: get('--muted-foreground'), fontSize: 10 },
axisTick: { show: false },
},
yAxis: {
type: 'value',
axisLine: { show: false },
splitLine: { lineStyle: { color: get('--border'), type: 'dashed' } },
axisLabel: { color: get('--muted-foreground'), fontSize: 10 },
},
dataZoom: [
{
type: 'inside',
xAxisIndex: 0,
start: 80,
end: 100,
},
],
series: series.map((s) => ({
name: s.name,
type: 'line',
data: s.data,
smooth: true,
lineStyle: { width: 1.5, color: s.color },
symbol: 'none',
sampling: 'lttb',
})),
});
const observer = new ResizeObserver(() => {
chartRef.current?.resize();
});
observer.observe(containerRef.current);
return () => {
observer.disconnect();
chart.dispose();
chartRef.current = null;
};
}, [series, timestamps]);
return (
<div ref={containerRef} className="w-full" style={{ height }} />
);
}

View File

@@ -0,0 +1,494 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Send, Loader2, Swords, Sparkles } from 'lucide-react';
interface PlaygroundTabProps {
providerIds: string[];
}
interface ModelEntry {
id: string;
providerId: string;
}
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string;
}
export function PlaygroundTab({ providerIds }: PlaygroundTabProps) {
const [models, setModels] = useState<ModelEntry[]>([]);
const [selectedModel, setSelectedModel] = useState<string>('');
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [temperature, setTemperature] = useState(0.7);
const [topP, setTopP] = useState(0.9);
const [maxTokens, setMaxTokens] = useState(1024);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [streaming, setStreaming] = useState(false);
const [abMode, setAbMode] = useState(false);
const [modelB, setModelB] = useState('');
const [providerB, setProviderB] = useState('');
const [responseA, setResponseA] = useState('');
const [responseB, setResponseB] = useState('');
const [streamingAb, setStreamingAb] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
fetchModels();
}, []);
const fetchModels = useCallback(async () => {
try {
const res = await fetch('/api/control/playground/models');
if (!res.ok) return;
const data = await res.json() as { models: Array<{ providerId: string; models: string[] }> };
const flattened: ModelEntry[] = [];
for (const group of data.models) {
for (const m of group.models) {
flattened.push({ id: m, providerId: group.providerId });
}
}
setModels(flattened);
if (flattened.length > 0 && !selectedModel) {
const first = flattened[0];
if (first) {
setSelectedModel(first.id);
setSelectedProvider(first.providerId);
}
}
} catch {
// silent
}
}, [selectedModel]);
const groupedModels = models.reduce((acc, m) => {
if (!acc[m.providerId]) {
acc[m.providerId] = [];
}
const group = acc[m.providerId];
if (group) {
group.push(m);
}
return acc;
}, {} as Record<string, ModelEntry[]>);
const handleSend = async () => {
if (!input.trim() || !selectedModel || streaming) return;
const userMsg: ChatMessage = { role: 'user', content: input.trim() };
const newMessages = [...messages, userMsg, { role: 'assistant' as const, content: '' }];
setMessages(newMessages);
setInput('');
setStreaming(true);
try {
const chatMessages = newMessages.slice(0, -1).map((m) => ({
role: m.role,
content: m.content,
}));
const res = await fetch('/api/control/playground/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerId: selectedProvider,
model: selectedModel,
messages: chatMessages,
temperature,
topP,
maxTokens,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
setMessages((prev) => [...prev.slice(0, -1), { role: 'assistant', content: `Error: ${err.error || 'Request failed'}` }]);
setStreaming(false);
return;
}
const reader = res.body?.getReader();
if (!reader) {
setStreaming(false);
return;
}
const decoder = new TextDecoder();
let buffer = '';
let assistantContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed === 'data: [DONE]') continue;
const jsonStr = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
try {
const parsed = JSON.parse(jsonStr);
const delta = parsed.choices?.[0]?.delta?.content;
if (delta) {
assistantContent += delta;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = { role: 'assistant', content: assistantContent };
return updated;
});
}
} catch {
// skip
}
}
}
setStreaming(false);
} catch (err) {
const msg = (err as Error).message ?? String(err);
setMessages((prev) => [...prev.slice(0, -1), { role: 'assistant', content: `Error: ${msg}` }]);
setStreaming(false);
}
};
const handleABCompare = async () => {
if (!input.trim() || !selectedModel || !modelB || streamingAb) return;
const userMsg: ChatMessage = { role: 'user', content: input.trim() };
setMessages([...messages, userMsg]);
setInput('');
setResponseA('');
setResponseB('');
setStreamingAb(true);
try {
const res = await fetch('/api/control/playground/chat-ab', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
providerIdA: selectedProvider,
modelA: selectedModel,
providerIdB: providerB,
modelB,
messages: [...messages, userMsg],
temperature,
topP,
maxTokens,
}),
});
if (!res.ok) {
setStreamingAb(false);
return;
}
const reader = res.body?.getReader();
if (!reader) {
setStreamingAb(false);
return;
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const jsonStr = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
try {
const parsed = JSON.parse(jsonStr);
if (parsed.done) {
if (parsed.lane === 'A') setStreamingAb((p) => {
// Check if B is also done elsewhere
return p;
});
continue;
}
if (parsed.raw) {
const innerStr = parsed.raw.startsWith('data: ') ? parsed.raw.slice(6) : parsed.raw;
const inner = JSON.parse(innerStr);
const delta = inner.choices?.[0]?.delta?.content;
if (delta) {
if (parsed.lane === 'A') {
setResponseA((p) => p + delta);
} else {
setResponseB((p) => p + delta);
}
}
}
} catch {
// skip
}
}
}
setStreamingAb(false);
} catch {
setStreamingAb(false);
}
};
const getArenaBattleUrl = () => {
const prompt = encodeURIComponent(input || messages[messages.length - 1]?.content || '');
const modelA = encodeURIComponent(selectedModel);
const modelBParam = encodeURIComponent(modelB || '');
return `/arena?prompt=${prompt}&models=${modelA},${modelBParam}`;
};
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Model and param controls */}
<div className="flex flex-wrap items-center gap-3 px-4 py-3 border-b border-border/40 shrink-0">
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Host</label>
<select
value={selectedProvider}
onChange={(e) => {
setSelectedProvider(e.target.value);
const firstModel = groupedModels[e.target.value]?.[0]?.id;
if (firstModel) setSelectedModel(firstModel);
}}
className="bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
>
{Object.keys(groupedModels).map((pid) => (
<option key={pid} value={pid}>{pid}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Model</label>
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm min-w-[200px]"
>
{(groupedModels[selectedProvider] ?? []).map((m) => (
<option key={m.id} value={m.id}>{m.id}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Temp</label>
<input
type="number"
min={0}
max={2}
step={0.1}
value={temperature}
onChange={(e) => setTemperature(parseFloat(e.target.value) || 0.7)}
className="w-16 bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Top P</label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={topP}
onChange={(e) => setTopP(parseFloat(e.target.value) || 0.9)}
className="w-16 bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Max</label>
<input
type="number"
min={1}
max={8192}
step={128}
value={maxTokens}
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 1024)}
className="w-20 bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
/>
</div>
<button
type="button"
onClick={() => setAbMode(!abMode)}
className={cn(
'flex items-center gap-1 px-2 py-1 text-xs rounded transition-colors',
abMode
? 'bg-accent/20 text-accent border border-accent/30'
: 'text-muted-foreground hover:text-foreground border border-transparent'
)}
>
<Swords className="size-3" />
A/B
</button>
</div>
{/* A/B model B selector */}
{abMode && (
<div className="flex items-center gap-3 px-4 py-2 border-b border-border/40 bg-muted/20">
<label className="text-xs text-muted-foreground">Model B</label>
<select
value={providerB}
onChange={(e) => {
setProviderB(e.target.value);
const firstModel = groupedModels[e.target.value]?.[0]?.id;
if (firstModel) setModelB(firstModel);
}}
className="bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm"
>
{Object.keys(groupedModels).map((pid) => (
<option key={pid} value={pid}>{pid}</option>
))}
</select>
<select
value={modelB}
onChange={(e) => setModelB(e.target.value)}
className="bg-muted/50 border border-border/50 rounded px-2 py-1 text-sm min-w-[200px]"
>
{(groupedModels[providerB] ?? []).map((m) => (
<option key={m.id} value={m.id}>{m.id}</option>
))}
</select>
</div>
)}
{/* Chat area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{!abMode ? (
<>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.map((msg, i) => (
<div
key={i}
className={cn(
'max-w-[80%] rounded-lg px-3 py-2 text-sm',
msg.role === 'user'
? 'ml-auto bg-accent/20 text-accent-foreground'
: 'bg-muted/50 text-foreground'
)}
>
{msg.content || (msg.role === 'assistant' && streaming ? <Loader2 className="size-4 animate-spin" /> : null)}
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="flex items-center gap-2 px-4 py-3 border-t border-border/40 shrink-0">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Type a message..."
className="flex-1 bg-muted/50 border border-border/50 rounded-lg px-3 py-2 text-sm resize-none min-h-[40px] max-h-[120px]"
rows={1}
/>
<button
type="button"
onClick={handleSend}
disabled={streaming || !input.trim()}
className={cn(
'p-2 rounded-lg transition-colors',
streaming || !input.trim()
? 'text-muted-foreground/50'
: 'bg-accent/20 text-accent hover:bg-accent/30'
)}
>
{streaming ? <Loader2 className="size-4 animate-spin" /> : <Send className="size-4" />}
</button>
</div>
</>
) : (
<>
{/* A/B comparison */}
<div className="flex-1 flex gap-2 px-4 py-3 min-h-0 overflow-hidden">
<div className="flex-1 flex flex-col min-h-0 bg-muted/20 rounded-lg border border-border/30 overflow-hidden">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/30 shrink-0">
Model A: {selectedModel}
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 text-sm whitespace-pre-wrap">
{responseA || (streamingAb ? <Loader2 className="size-4 animate-spin" /> : 'Waiting...')}
</div>
</div>
<div className="flex-1 flex flex-col min-h-0 bg-muted/20 rounded-lg border border-border/30 overflow-hidden">
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/30 shrink-0">
Model B: {modelB}
</div>
<div className="flex-1 overflow-y-auto px-3 py-2 text-sm whitespace-pre-wrap">
{responseB || (streamingAb ? <Loader2 className="size-4 animate-spin" /> : 'Waiting...')}
</div>
</div>
</div>
{/* A/B input */}
<div className="flex items-center gap-2 px-4 py-3 border-t border-border/40 shrink-0">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleABCompare();
}
}}
placeholder="Type a prompt for A/B comparison..."
className="flex-1 bg-muted/50 border border-border/50 rounded-lg px-3 py-2 text-sm resize-none min-h-[40px]"
rows={1}
/>
<button
type="button"
onClick={handleABCompare}
disabled={streamingAb || !input.trim() || !modelB}
className={cn(
'p-2 rounded-lg transition-colors',
streamingAb || !input.trim() || !modelB
? 'text-muted-foreground/50'
: 'bg-accent/20 text-accent hover:bg-accent/30'
)}
>
{streamingAb ? <Loader2 className="size-4 animate-spin" /> : <Swords className="size-4" />}
</button>
</div>
</>
)}
</div>
{/* Battle in Arena link */}
<div className="px-4 py-2 border-t border-border/40 shrink-0">
<a
href={getArenaBattleUrl()}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Sparkles className="size-3" />
Battle in Arena
</a>
</div>
</div>
);
}

View File

@@ -0,0 +1,438 @@
import { useCallback, useEffect, useState } from 'react';
import { Loader2, FileText, Route, ListOrdered, Plus, Trash2, RefreshCw, Download } from 'lucide-react';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
interface ReportSummary {
id: string;
kind: string;
interval: string;
periodStart: string;
periodEnd: string;
createdAt: string;
}
interface ReportDetail extends ReportSummary {
markdown: string;
stats: Record<string, unknown> | null;
}
interface Policy {
id: string;
name: string;
virtualModel: string;
candidates: string[];
fallback: string | null;
enabled: boolean;
}
interface Dispatch {
id: number;
ts: string;
virtualModel: string;
chosenProviderId: string | null;
chosenModel: string | null;
candidatesTried: string[];
status: string;
source: string | null;
error: string | null;
durationMs: number | null;
}
type View = 'reports' | 'policies' | 'dispatch';
export function ReportsTab() {
const [view, setView] = useState<View>('reports');
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/40">
<button
onClick={() => setView('reports')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${view === 'reports' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<FileText className="size-3 inline mr-1" />
Reports
</button>
<button
onClick={() => setView('policies')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${view === 'policies' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<Route className="size-3 inline mr-1" />
Policies
</button>
<button
onClick={() => setView('dispatch')}
className={`px-3 py-1.5 text-xs rounded-md transition-colors ${view === 'dispatch' ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:text-foreground'}`}
>
<ListOrdered className="size-3 inline mr-1" />
Dispatch Log
</button>
</div>
<div className="flex-1 overflow-auto">
{view === 'reports' && <ReportsView />}
{view === 'policies' && <PoliciesView />}
{view === 'dispatch' && <DispatchView />}
</div>
</div>
);
}
// ─── Reports ──────────────────────────────────────────────────────────────
function ReportsView() {
const [reports, setReports] = useState<ReportSummary[]>([]);
const [selected, setSelected] = useState<ReportDetail | null>(null);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [schedule, setSchedule] = useState<{ interval: string; enabled: boolean; lastRunAt: string | null } | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const [rRes, sRes] = await Promise.all([
fetch('/api/control/reports'),
fetch('/api/control/reports/schedule'),
]);
const rData = await rRes.json() as { reports: ReportSummary[] };
setReports(rData.reports ?? []);
setSchedule(await sRes.json());
} catch (err) {
console.error('reports: load failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openReport = async (id: string) => {
const res = await fetch(`/api/control/reports/${id}`);
if (res.ok) setSelected(await res.json());
};
const generate = async () => {
setGenerating(true);
try {
const res = await fetch('/api/control/reports/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interval: schedule?.interval ?? 'daily' }),
});
if (res.ok) {
const { id } = await res.json() as { id: string };
await load();
await openReport(id);
}
} finally {
setGenerating(false);
}
};
const updateSchedule = async (patch: { interval?: string; enabled?: boolean }) => {
const next = { interval: schedule?.interval ?? 'daily', enabled: schedule?.enabled ?? true, ...patch };
await fetch('/api/control/reports/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(next),
});
setSchedule((prev) => prev ? { ...prev, ...patch } : prev);
};
const exportMarkdown = () => {
if (!selected) return;
const blob = new Blob([selected.markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selected.id}.md`;
a.click();
URL.revokeObjectURL(url);
};
if (loading) {
return <div className="flex items-center justify-center p-8"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>;
}
return (
<div className="flex h-full min-h-0">
{/* List + controls */}
<div className="w-72 shrink-0 border-r border-border/40 flex flex-col min-h-0">
<div className="p-3 border-b border-border/40 space-y-2">
<button
onClick={generate}
disabled={generating}
className="w-full flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{generating ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
Generate now
</button>
{schedule && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<select
value={schedule.interval}
onChange={(e) => updateSchedule({ interval: e.target.value })}
className="bg-background border border-border rounded px-1.5 py-0.5"
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={schedule.enabled}
onChange={(e) => updateSchedule({ enabled: e.target.checked })}
/>
scheduled
</label>
</div>
)}
</div>
<div className="flex-1 overflow-auto">
{reports.map((r) => (
<button
key={r.id}
onClick={() => openReport(r.id)}
className={`w-full text-left px-3 py-2 text-xs border-b border-border/20 hover:bg-muted/20 ${selected?.id === r.id ? 'bg-muted/30' : ''}`}
>
<div className="font-medium capitalize">{r.interval} digest</div>
<div className="text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</div>
</button>
))}
{reports.length === 0 && (
<div className="p-4 text-center text-xs text-muted-foreground">No reports yet. Generate one.</div>
)}
</div>
</div>
{/* Detail */}
<div className="flex-1 overflow-auto p-4 min-w-0">
{selected ? (
<>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium">{selected.interval} digest</h2>
<button
onClick={exportMarkdown}
className="flex items-center gap-1 px-2 py-1 text-xs border border-border rounded-md hover:bg-muted/30"
>
<Download className="size-3" /> Export .md
</button>
</div>
<div className="prose-sm max-w-none">
<MarkdownRenderer content={selected.markdown} />
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
Select a report to view it.
</div>
)}
</div>
</div>
);
}
// ─── Policies ─────────────────────────────────────────────────────────────
function PoliciesView() {
const [policies, setPolicies] = useState<Policy[]>([]);
const [virtualModels, setVirtualModels] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState<Partial<Policy> | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const [pRes, vRes] = await Promise.all([
fetch('/api/control/policies'),
fetch('/api/control/policies/virtual-models'),
]);
const pData = await pRes.json() as { policies: Policy[] };
const vData = await vRes.json() as { virtualModels: string[] };
setPolicies(pData.policies ?? []);
setVirtualModels(vData.virtualModels ?? []);
} catch (err) {
console.error('policies: load failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const save = async () => {
if (!editing?.name || !editing?.virtualModel) return;
await fetch('/api/control/policies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editing.name,
virtualModel: editing.virtualModel,
candidates: editing.candidates ?? [],
fallback: editing.fallback ?? null,
enabled: editing.enabled !== false,
}),
});
setEditing(null);
await load();
};
const remove = async (id: string) => {
await fetch(`/api/control/policies/${id}`, { method: 'DELETE' });
await load();
};
if (loading) {
return <div className="flex items-center justify-center p-8"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>;
}
return (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Route policies order candidate models for each <code>auto:*</code> virtual model. Candidates are composite ids (<code>provider/model</code>). Unset policies fall back to advisory scores.
</p>
<button
onClick={() => setEditing({ enabled: true, candidates: [] })}
className="flex items-center gap-1 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded-md hover:bg-primary/90 shrink-0"
>
<Plus className="size-3" /> New policy
</button>
</div>
{editing && (
<div className="border border-border/40 rounded-lg p-3 bg-card/30 space-y-2">
<input
placeholder="Policy name"
value={editing.name ?? ''}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
className="w-full text-xs bg-background border border-border rounded-md px-2 py-1"
/>
<select
value={editing.virtualModel ?? ''}
onChange={(e) => setEditing({ ...editing, virtualModel: e.target.value })}
className="w-full text-xs bg-background border border-border rounded-md px-2 py-1"
>
<option value="">Select virtual model</option>
{virtualModels.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
<textarea
placeholder="Candidates, one composite id per line (e.g. sam-desktop/qwopus-35b)"
value={(editing.candidates ?? []).join('\n')}
onChange={(e) => setEditing({ ...editing, candidates: e.target.value.split('\n').map((s) => s.trim()).filter(Boolean) })}
className="w-full text-xs font-mono bg-background border border-border rounded-md px-2 py-1 h-24"
/>
<input
placeholder="Fallback composite id (optional)"
value={editing.fallback ?? ''}
onChange={(e) => setEditing({ ...editing, fallback: e.target.value })}
className="w-full text-xs font-mono bg-background border border-border rounded-md px-2 py-1"
/>
<div className="flex items-center gap-2">
<button onClick={save} className="px-3 py-1 text-xs bg-primary text-primary-foreground rounded-md hover:bg-primary/90">Save</button>
<button onClick={() => setEditing(null)} className="px-3 py-1 text-xs border border-border rounded-md hover:bg-muted/30">Cancel</button>
</div>
</div>
)}
<div className="space-y-2">
{policies.map((p) => (
<div key={p.id} className="border border-border/40 rounded-lg p-3 bg-card/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{p.name}</span>
<code className="text-xs px-1.5 py-0.5 bg-muted/40 rounded">{p.virtualModel}</code>
{!p.enabled && <span className="text-xs text-muted-foreground">(disabled)</span>}
</div>
<div className="flex items-center gap-1">
<button onClick={() => setEditing(p)} className="px-2 py-0.5 text-xs border border-border rounded hover:bg-muted/30">Edit</button>
<button onClick={() => remove(p.id)} className="p-1 text-muted-foreground hover:text-red-400"><Trash2 className="size-3" /></button>
</div>
</div>
<ol className="mt-2 text-xs font-mono text-muted-foreground list-decimal list-inside">
{p.candidates.map((c) => <li key={c}>{c}</li>)}
{p.fallback && <li className="text-amber-400">{p.fallback} (fallback)</li>}
</ol>
</div>
))}
{policies.length === 0 && !editing && (
<div className="p-4 text-center text-xs text-muted-foreground">No policies. The gateway uses advisory scores until one is added.</div>
)}
</div>
</div>
);
}
// ─── Dispatch log ───────────────────────────────────────────────────────────
function DispatchView() {
const [dispatches, setDispatches] = useState<Dispatch[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/control/policies/dispatch-log');
const data = await res.json() as { dispatches: Dispatch[] };
setDispatches(data.dispatches ?? []);
} catch (err) {
console.error('dispatch-log: load failed', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
if (loading) {
return <div className="flex items-center justify-center p-8"><Loader2 className="size-5 animate-spin text-muted-foreground" /></div>;
}
return (
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">Gateway dispatches</h3>
<button onClick={load} className="flex items-center gap-1 px-2 py-1 text-xs border border-border rounded-md hover:bg-muted/30">
<RefreshCw className="size-3" /> Refresh
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border/40 text-muted-foreground">
<th className="text-left py-2 px-3">Time</th>
<th className="text-left py-2 px-3">Virtual</th>
<th className="text-left py-2 px-3">Chosen</th>
<th className="text-left py-2 px-3">Status</th>
<th className="text-left py-2 px-3">Source</th>
<th className="text-left py-2 px-3">ms</th>
<th className="text-left py-2 px-3">Tried</th>
</tr>
</thead>
<tbody>
{dispatches.map((d) => (
<tr key={d.id} className="border-b border-border/20 hover:bg-muted/20">
<td className="py-2 px-3 text-muted-foreground">{new Date(d.ts).toLocaleTimeString()}</td>
<td className="py-2 px-3 font-mono">{d.virtualModel}</td>
<td className="py-2 px-3 font-mono">{d.chosenProviderId ? `${d.chosenProviderId}/${d.chosenModel}` : '-'}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded-full ${
d.status === 'dispatched' ? 'bg-green-500/20 text-green-400' :
d.status === 'failed' || d.status === 'no_candidates' ? 'bg-red-500/20 text-red-400' :
'bg-yellow-500/20 text-yellow-400'
}`}>{d.status}</span>
</td>
<td className="py-2 px-3 text-muted-foreground">{d.source ?? '-'}</td>
<td className="py-2 px-3 font-mono">{d.durationMs ?? '-'}</td>
<td className="py-2 px-3 font-mono text-muted-foreground">{d.candidatesTried.length}</td>
</tr>
))}
{dispatches.length === 0 && (
<tr><td colSpan={7} className="py-8 text-center text-muted-foreground">No gateway dispatches yet.</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { useEffect, useRef } from 'react';
import * as echarts from 'echarts/core';
import { GaugeChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import type { EChartsType } from 'echarts/core';
import { buildEChartsTheme } from './buildEChartsTheme';
echarts.use([GaugeChart, CanvasRenderer]);
interface TtlRingProps {
deadline: string | null; // ISO timestamp
size?: number;
}
export function TtlRing({ deadline, size = 80 }: TtlRingProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<EChartsType | null>(null);
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
if (!containerRef.current || !deadline) return;
if (!chartRef.current) {
const theme = buildEChartsTheme();
chartRef.current = echarts.init(containerRef.current, theme);
}
const chart = chartRef.current;
const root = getComputedStyle(document.documentElement);
const get = (prop: string) => root.getPropertyValue(prop).trim();
const maxMs = 3600_000; // 1h max ring
const update = () => {
const remaining = new Date(deadline).getTime() - Date.now();
const value = Math.max(0, remaining);
const pct = Math.min(1, value / maxMs);
// Derive gauge progress color from CSS custom properties
let color = get('--glow-green');
if (pct < 0.3) color = get('--glow-red');
else if (pct < 0.6) color = get('--glow-amber');
const minutes = Math.floor(remaining / 60_000);
const seconds = Math.floor((remaining % 60_000) / 1000);
chart.setOption({
backgroundColor: 'transparent',
series: [
{
type: 'gauge',
startAngle: 220,
endAngle: -40,
min: 0,
max: 1,
radius: '90%',
center: ['50%', '55%'],
pointer: { show: false },
progress: {
show: true,
overlap: false,
roundCap: true,
clip: false,
itemStyle: { color },
width: 4,
},
axisLine: {
lineStyle: {
width: 4,
color: [[1, get('--border')]],
},
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
title: { show: false },
detail: {
show: true,
offsetCenter: ['0%', '5%'],
fontSize: 11,
fontWeight: 'bold',
color: get('--foreground'),
fontFamily: 'Orbitron',
formatter: () => remaining > 0 ? `${minutes}m ${seconds}s` : 'expired',
},
data: [{ value: pct, name: 'TTL' }],
},
],
});
};
update();
tickRef.current = setInterval(update, 1000);
const observer = new ResizeObserver(() => chart.resize());
observer.observe(containerRef.current);
return () => {
if (tickRef.current) clearInterval(tickRef.current);
observer.disconnect();
chart.dispose();
chartRef.current = null;
};
}, [deadline]);
if (!deadline) return null;
return (
<div
ref={containerRef}
className="flex items-center justify-center"
style={{ width: size, height: size }}
/>
);
}

View File

@@ -0,0 +1,107 @@
import { useEffect, useRef } from 'react';
import * as echarts from 'echarts/core';
import { GaugeChart } from 'echarts/charts';
import { CanvasRenderer } from 'echarts/renderers';
import type { EChartsType } from 'echarts/core';
import { buildEChartsTheme } from './buildEChartsTheme';
echarts.use([GaugeChart, CanvasRenderer]);
interface VramGaugeProps {
used: number; // MB
total: number; // MB
size?: number;
}
export function VramGauge({ used, total, size = 120 }: VramGaugeProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<EChartsType | null>(null);
useEffect(() => {
if (!containerRef.current) return;
if (!chartRef.current) {
const theme = buildEChartsTheme();
chartRef.current = echarts.init(containerRef.current, theme);
}
const chart = chartRef.current;
const root = getComputedStyle(document.documentElement);
const get = (prop: string) => root.getPropertyValue(prop).trim();
const pct = total > 0 ? Math.round((used / total) * 100) : 0;
// Derive gauge progress color from CSS custom properties
// Green -> Amber -> Red as utilization increases
let color = get('--glow-green');
if (pct > 80) color = get('--glow-red');
else if (pct > 60) color = get('--glow-amber');
chart.setOption({
backgroundColor: 'transparent',
series: [
{
type: 'gauge',
startAngle: 220,
endAngle: -40,
min: 0,
max: total,
radius: '90%',
center: ['50%', '55%'],
pointer: { show: false },
progress: {
show: true,
overlap: false,
roundCap: true,
clip: false,
itemStyle: { color },
width: 8,
},
axisLine: {
lineStyle: {
width: 8,
color: [[1, get('--border')]],
},
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
title: {
show: true,
offsetCenter: ['0%', '-10%'],
fontSize: 11,
color: get('--muted-foreground'),
fontFamily: 'Inter',
},
detail: {
show: true,
offsetCenter: ['0%', '10%'],
fontSize: 18,
fontWeight: 'bold',
color: get('--foreground'),
fontFamily: 'Orbitron',
formatter: () => `${used} / ${total} MB`,
},
data: [{ value: used, name: 'VRAM' }],
},
],
});
const observer = new ResizeObserver(() => chart.resize());
observer.observe(containerRef.current);
return () => {
observer.disconnect();
chart.dispose();
chartRef.current = null;
};
}, [used, total]);
return (
<div
ref={containerRef}
className="flex items-center justify-center"
style={{ width: size, height: size }}
/>
);
}

View File

@@ -0,0 +1,25 @@
import * as echarts from 'echarts/core';
/**
* Build an ECharts theme object from the active CSS custom properties.
* Reads from document.documentElement so it always reflects the current theme.
*/
export function buildEChartsTheme(): Record<string, unknown> {
const root = getComputedStyle(document.documentElement);
const get = (prop: string) => root.getPropertyValue(prop).trim();
return {
backgroundColor: 'transparent',
textStyle: {
color: get('--foreground'),
},
line: {
symbol: 'none',
},
gauge: {
itemStyle: {
color: undefined, // per-gauge override
},
},
};
}

View File

@@ -104,7 +104,11 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
useEffect(() => {
if (!showCompareSelector) return;
api.models()
.then((mods) => setAvailableModels(mods.map((m) => m.id).sort()))
.then((catalog) => {
// Flatten provider-grouped catalog into composite model ids.
const models = catalog.providers.flatMap((p) => p.models.map((m) => m.id)).sort();
setAvailableModels(models);
})
.catch(() => {
// Fallback: use session model if API fails
const sessionModel = sessionChats?.find((c) => c.id === chatId)?.model;

View File

@@ -234,6 +234,17 @@ export function useTerminalSocket({
t.write(`\r\n\x1b[2m[process exited with code ${frame.code}]\x1b[0m\r\n`);
return;
}
if (frame?.type === 'pty_exited') {
if (frame.timed_out) {
t.write('\r\n\x1b[2m[process timed out and was killed]\x1b[0m\r\n');
} else {
t.write(`\r\n\x1b[2m[process exited with code ${frame.exit_code}]\x1b[0m\r\n`);
}
if (frame.last_lines.length > 0) {
t.write(frame.last_lines[frame.last_lines.length - 1] + '\r\n');
}
return;
}
t.write(e.data);
} else {
t.write(new Uint8Array(e.data as ArrayBuffer));

View File

@@ -0,0 +1,305 @@
/**
* useControlStream: second app-level WS singleton for BooControl.
*
* Own React context + connection guard. Targets proxied /api/control/ws.
* Client discards deltas with seq <= snapshot_seq per-host.
*
* This is NOT the same as useUserEvents — it's a separate WS connection.
*/
import { createContext, useContext, useRef, useCallback, useEffect, useState } from 'react';
// ─── types ──────────────────────────────────────────────────────────────────
export interface ControlFleetHost {
providerId: string;
liveness: 'connected' | 'reconnecting' | 'down';
lastSeenAt: string | null;
seq: number;
models: Array<{
model: string;
state: string;
ts: string;
ttlDeadline: string | null;
inflight: number;
}>;
}
export interface ControlRequestEntry {
id: number;
providerId: string;
ts: string;
model: string | null;
reqPath: string | null;
statusCode: number | null;
durationMs: number | null;
}
export interface ControlPerfSample {
providerId: string;
ts: string;
gpu: unknown;
sys: unknown;
}
export interface ControlLogEntry {
providerId: string;
source: 'proxy' | 'upstream' | 'model';
line: string;
}
// ─── frame types ────────────────────────────────────────────────────────────
export type ControlFleetDelta = {
type: 'control_fleet';
seq: number;
hosts: ControlFleetHost[];
};
export type ControlActivityFrame = {
type: 'control_activity';
seq: number;
providerId: string;
entry: ControlRequestEntry;
};
export type ControlPerfFrame = {
type: 'control_perf';
seq: number;
providerId: string;
ts: string;
gpu: unknown;
sys: unknown;
};
export type ControlLogFrame = {
type: 'control_log';
seq: number;
providerId: string;
source: 'proxy' | 'upstream' | 'model';
line: string;
};
export type ControlJobFrame = {
type: 'control_job';
seq: number;
jobType: 'bench' | 'eval' | 'action';
jobId: string;
status: 'queued' | 'running' | 'completed' | 'failed';
detail?: Record<string, unknown>;
};
export type ControlFrame =
| ControlFleetDelta
| ControlActivityFrame
| ControlPerfFrame
| ControlLogFrame
| ControlJobFrame;
// ─── A3: type-guards for incoming WS frames ─────────────────────────────────
// Replace 'as unknown as' casts with runtime validation.
function isValidHost(h: unknown): h is ControlFleetHost {
if (!h || typeof h !== 'object') return false;
const obj = h as Record<string, unknown>;
return (
typeof obj.providerId === 'string' &&
['connected', 'reconnecting', 'down'].includes(obj.liveness as string) &&
(obj.lastSeenAt === null || typeof obj.lastSeenAt === 'string') &&
typeof obj.seq === 'number' &&
Array.isArray(obj.models)
);
}
function isControlFleetDelta(data: unknown): data is ControlFleetDelta {
if (!data || typeof data !== 'object') return false;
const obj = data as Record<string, unknown>;
return (
obj.type === 'control_fleet' &&
typeof obj.seq === 'number' &&
Array.isArray(obj.hosts) &&
obj.hosts.every(isValidHost)
);
}
function isControlActivityFrame(data: unknown): data is ControlActivityFrame {
if (!data || typeof data !== 'object') return false;
const obj = data as Record<string, unknown>;
return (
obj.type === 'control_activity' &&
typeof obj.seq === 'number' &&
typeof obj.providerId === 'string' &&
typeof obj.entry === 'object' &&
obj.entry !== null
);
}
function isControlPerfFrame(data: unknown): data is ControlPerfFrame {
if (!data || typeof data !== 'object') return false;
const obj = data as Record<string, unknown>;
return (
obj.type === 'control_perf' &&
typeof obj.seq === 'number' &&
typeof obj.providerId === 'string' &&
typeof obj.ts === 'string'
);
}
function isControlLogFrame(data: unknown): data is ControlLogFrame {
if (!data || typeof data !== 'object') return false;
const obj = data as Record<string, unknown>;
return (
obj.type === 'control_log' &&
typeof obj.seq === 'number' &&
typeof obj.providerId === 'string' &&
['proxy', 'upstream', 'model'].includes(obj.source as string) &&
typeof obj.line === 'string'
);
}
function isControlJobFrame(data: unknown): data is ControlJobFrame {
if (!data || typeof data !== 'object') return false;
const obj = data as Record<string, unknown>;
return (
obj.type === 'control_job' &&
typeof obj.seq === 'number' &&
['bench', 'eval', 'action'].includes(obj.jobType as string) &&
typeof obj.jobId === 'string' &&
['queued', 'running', 'completed', 'failed'].includes(obj.status as string)
);
}
// ─── context ────────────────────────────────────────────────────────────────
export interface ControlStreamState {
hosts: ControlFleetHost[];
requests: ControlRequestEntry[];
perfSamples: ControlPerfSample[];
logs: ControlLogEntry[];
jobs: Array<{
jobType: 'bench' | 'eval' | 'action';
jobId: string;
status: 'queued' | 'running' | 'completed' | 'failed';
}>;
}
const ControlContext = createContext<ControlStreamState | null>(null);
// ─── hook ───────────────────────────────────────────────────────────────────
export function useControlStream(): ControlStreamState {
const state = useContext(ControlContext);
if (!state) throw new Error('useControlStream must be used within ControlProvider');
return state;
}
export function ControlProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<ControlStreamState>({
hosts: [],
requests: [],
perfSamples: [],
logs: [],
jobs: [],
});
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const snapshotSeqRef = useRef(0);
const hasSnapshotRef = useRef(false);
const backoffRef = useRef(5_000);
const connect = useCallback(() => {
if (wsRef.current) return;
const ws = new WebSocket(`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/api/control/ws`);
wsRef.current = ws;
ws.onopen = () => {
snapshotSeqRef.current = 0;
hasSnapshotRef.current = false;
backoffRef.current = 5_000;
};
ws.onmessage = (event) => {
try {
const data: unknown = JSON.parse(event.data);
if (typeof data !== 'object' || !data || !('type' in data)) return;
if ((data as Record<string, unknown>).type === 'ping') return; // heartbeat
// A3: type-guard each frame shape before applying — no 'as unknown as' casts
if (isControlFleetDelta(data)) {
if (!hasSnapshotRef.current) {
// First frame after connect is the snapshot.
hasSnapshotRef.current = true;
snapshotSeqRef.current = data.seq;
setState((prev) => ({ ...prev, hosts: data.hosts }));
} else {
// Delta: merge by providerId so a delta for one host does not wipe the others.
if (data.seq > snapshotSeqRef.current) {
setState((prev) => {
const merged = [...prev.hosts];
for (const dh of data.hosts) {
const idx = merged.findIndex((h) => h.providerId === dh.providerId);
if (idx >= 0) {
merged[idx] = dh;
} else {
merged.push(dh);
}
}
return { ...prev, hosts: merged };
});
}
}
} else if (isControlActivityFrame(data)) {
setState((prev) => ({
...prev,
requests: [data.entry, ...prev.requests].slice(0, 500),
}));
} else if (isControlPerfFrame(data)) {
setState((prev) => ({
...prev,
perfSamples: [...prev.perfSamples, { providerId: data.providerId, ts: data.ts, gpu: data.gpu, sys: data.sys }].slice(-500),
}));
} else if (isControlLogFrame(data)) {
setState((prev) => ({
...prev,
logs: [...prev.logs, { providerId: data.providerId, source: data.source, line: data.line }].slice(-1000),
}));
} else if (isControlJobFrame(data)) {
setState((prev) => ({
...prev,
jobs: [...prev.jobs, { jobType: data.jobType, jobId: data.jobId, status: data.status }].slice(-200),
}));
}
// Unknown frame types are silently dropped (fail-closed)
} catch {
// Ignore parse errors
}
};
ws.onclose = () => {
wsRef.current = null;
// A6 fix: exponential backoff instead of fixed 5s delay.
const delay = backoffRef.current;
backoffRef.current = Math.min(30_000, backoffRef.current * 2);
reconnectTimerRef.current = setTimeout(connect, delay);
};
ws.onerror = () => {
ws.close();
};
}, []);
useEffect(() => {
connect();
return () => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
};
}, [connect]);
return <ControlContext.Provider value={state}>{children}</ControlContext.Provider>;
}

View File

@@ -0,0 +1,12 @@
import { useMemo } from 'react';
/**
* Stable prefers-reduced-motion check.
* Uses useMemo so it only re-evaluates when the media query actually changes.
*/
export function useReducedMotion(): boolean {
return useMemo(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[],
);
}

View File

@@ -28,7 +28,18 @@ export function encodeResize(cols: number, rows: number): string {
export type ServerControlFrame =
| { type: 'init' }
| { type: 'exit'; code: number };
| { type: 'exit'; code: number }
| {
type: 'pty_exited';
session_id: string;
pane_id: string;
exit_code: number;
last_lines: string[];
session_title?: string | null;
session_description?: string | null;
parent_agent?: string | null;
timed_out: boolean;
};
// Parse an inbound text frame. Returns a recognized control frame, or `null`
// when the text is not JSON or not a known control type — in which case the
@@ -36,11 +47,24 @@ export type ServerControlFrame =
// try/catch fall-through: a parse error or an unknown `type` both yield null.
export function parseServerFrame(data: string): ServerControlFrame | null {
try {
const parsed = JSON.parse(data) as { type?: string; code?: number };
const parsed = JSON.parse(data) as Record<string, unknown>;
if (parsed.type === 'init') return { type: 'init' };
if (parsed.type === 'exit') return { type: 'exit', code: parsed.code ?? 0 };
if (parsed.type === 'exit') return { type: 'exit', code: (parsed.code as number) ?? 0 };
if (parsed.type === 'pty_exited') {
return {
type: 'pty_exited',
session_id: parsed.session_id as string,
pane_id: parsed.pane_id as string,
exit_code: parsed.exit_code as number,
last_lines: parsed.last_lines as string[],
session_title: (parsed.session_title as string | null) ?? null,
session_description: (parsed.session_description as string | null) ?? null,
parent_agent: (parsed.parent_agent as string | null) ?? null,
timed_out: (parsed.timed_out as boolean) ?? false,
};
}
} catch {
/* not JSON caller writes as text */
/* not JSON -- caller writes as text */
}
return null;
}

View File

@@ -0,0 +1,112 @@
import { useState, useMemo } from 'react';
import { useControlStream } from '@/hooks/useControlStream';
import { FleetTab } from '@/components/control/FleetTab';
import { ActivityTab } from '@/components/control/ActivityTab';
import { LogsTab } from '@/components/control/LogsTab';
import { CaptureDrawer } from '@/components/control/CaptureDrawer';
import { PlaygroundTab } from '@/components/control/PlaygroundTab';
import { BenchTab } from '@/components/control/BenchTab';
import { EvalsTab } from '@/components/control/EvalsTab';
import { ReportsTab } from '@/components/control/ReportsTab';
import { cn } from '@/lib/utils';
import { Radio, Activity, ScrollText, Gamepad2, Gauge, Brain, FileText } from 'lucide-react';
type Tab = 'fleet' | 'activity' | 'logs' | 'playground' | 'bench' | 'evals' | 'reports';
export function Control() {
const [activeTab, setActiveTab] = useState<Tab>('fleet');
const fleet = useControlStream();
const providerIds = fleet.hosts.map((h) => h.providerId);
// P2.4: Capture drawer state
const [captureDrawer, setCaptureDrawer] = useState<{ requestId: number; providerId: string } | null>(null);
// Compute the latest GPU data per provider from perf samples.
const gpuMap = useMemo(() => {
const map = new Map<string, { vram_used: number; vram_total: number; temperature: number; power: number }>();
for (const sample of fleet.perfSamples) {
const gpu = sample.gpu as { vram_used?: number; vram_total?: number; temperature?: number; power?: number } | undefined;
if (gpu) {
map.set(sample.providerId, {
vram_used: gpu.vram_used ?? 0,
vram_total: gpu.vram_total ?? 0,
temperature: gpu.temperature ?? 0,
power: gpu.power ?? 0,
});
}
}
return map;
}, [fleet.perfSamples]);
return (
<div className="flex-1 flex flex-col bg-background text-foreground">
{/* Tab bar */}
<div className="flex gap-1 border-b border-border/40 px-4 shrink-0">
{(
[
{ id: 'fleet' as Tab, label: 'Fleet', icon: Radio },
{ id: 'activity' as Tab, label: 'Activity', icon: Activity },
{ id: 'logs' as Tab, label: 'Logs', icon: ScrollText },
{ id: 'playground' as Tab, label: 'Playground', icon: Gamepad2 },
{ id: 'bench' as Tab, label: 'Bench', icon: Gauge },
{ id: 'evals' as Tab, label: 'Evals', icon: Brain },
{ id: 'reports' as Tab, label: 'Reports', icon: FileText },
]
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-2 text-sm rounded-t-md border border-b-0 -mb-px transition-colors',
activeTab === tab.id
? 'bg-background border-border text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30',
)}
>
<tab.icon className="size-3.5" />
<span>{tab.label}</span>
</button>
))}
</div>
{/* Tab content */}
<div className="flex-1 flex flex-col min-h-0">
{activeTab === 'fleet' && (
<FleetTab hosts={fleet.hosts} gpuMap={gpuMap} />
)}
{activeTab === 'activity' && (
<ActivityTab
requests={fleet.requests}
providerIds={providerIds}
onOpenCapture={(entry) => setCaptureDrawer({ requestId: entry.id, providerId: entry.providerId })}
/>
)}
{activeTab === 'logs' && (
<LogsTab logs={fleet.logs} providerIds={providerIds} />
)}
{activeTab === 'playground' && (
<PlaygroundTab providerIds={providerIds} />
)}
{activeTab === 'bench' && (
<BenchTab providerIds={providerIds} />
)}
{activeTab === 'evals' && (
<EvalsTab providerIds={providerIds} />
)}
{activeTab === 'reports' && (
<ReportsTab />
)}
</div>
{/* P2.4: Capture drawer overlay */}
{captureDrawer && (
<CaptureDrawer
requestId={captureDrawer.requestId}
providerId={captureDrawer.providerId}
onClose={() => setCaptureDrawer(null)}
/>
)}
</div>
);
}

View File

@@ -56,6 +56,10 @@
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--glow-amber: oklch(0.85 0.15 85);
--glow-green: oklch(0.7 0.18 145);
--glow-red: oklch(0.7 0.18 25);
--glow-gray: oklch(0.5 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
@@ -92,6 +96,10 @@
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--glow-amber: oklch(0.85 0.15 85);
--glow-green: oklch(0.7 0.18 145);
--glow-red: oklch(0.7 0.18 25);
--glow-gray: oklch(0.5 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);