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:
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
226
apps/web/src/components/control/ActivityTab.tsx
Normal file
226
apps/web/src/components/control/ActivityTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
669
apps/web/src/components/control/BenchTab.tsx
Normal file
669
apps/web/src/components/control/BenchTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
apps/web/src/components/control/CaptureDrawer.tsx
Normal file
236
apps/web/src/components/control/CaptureDrawer.tsx
Normal 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} · {capture.durationMs}ms · {(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>
|
||||
);
|
||||
}
|
||||
456
apps/web/src/components/control/EvalsTab.tsx
Normal file
456
apps/web/src/components/control/EvalsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/control/FleetTab.tsx
Normal file
51
apps/web/src/components/control/FleetTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
336
apps/web/src/components/control/HostCard.tsx
Normal file
336
apps/web/src/components/control/HostCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
apps/web/src/components/control/HostConfigEditor.tsx
Normal file
241
apps/web/src/components/control/HostConfigEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
apps/web/src/components/control/LogsTab.tsx
Normal file
167
apps/web/src/components/control/LogsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/web/src/components/control/PerfChart.tsx
Normal file
110
apps/web/src/components/control/PerfChart.tsx
Normal 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 }} />
|
||||
);
|
||||
}
|
||||
494
apps/web/src/components/control/PlaygroundTab.tsx
Normal file
494
apps/web/src/components/control/PlaygroundTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
438
apps/web/src/components/control/ReportsTab.tsx
Normal file
438
apps/web/src/components/control/ReportsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
apps/web/src/components/control/TtlRing.tsx
Normal file
115
apps/web/src/components/control/TtlRing.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
107
apps/web/src/components/control/VramGauge.tsx
Normal file
107
apps/web/src/components/control/VramGauge.tsx
Normal 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/components/control/buildEChartsTheme.ts
Normal file
25
apps/web/src/components/control/buildEChartsTheme.ts
Normal 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
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
305
apps/web/src/hooks/useControlStream.tsx
Normal file
305
apps/web/src/hooks/useControlStream.tsx
Normal 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>;
|
||||
}
|
||||
12
apps/web/src/hooks/useReducedMotion.ts
Normal file
12
apps/web/src/hooks/useReducedMotion.ts
Normal 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,
|
||||
[],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
112
apps/web/src/pages/Control.tsx
Normal file
112
apps/web/src/pages/Control.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user