Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/FRONTEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@ what the agents are doing.

| Hash | Component | Purpose |
| ------------ | --------------------------------------- | ---------------------------------------------- |
| `#/` | [`Dashboard`](../src/web/Dashboard.tsx) | Live run list + status breakdown. |
| `#/` | [`Dashboard`](../src/web/Dashboard.tsx) | Live run list, filter pills, sortable columns. |
| `#/run/<id>` | [`RunDetail`](../src/web/RunDetail.tsx) | Per-turn transcript, rendered prompt, events. |
| `#/search` | [`Search`](../src/web/Search.tsx) | Full-text search across turn content + events. |

Add a route by adding a branch in `App.tsx`'s switch and a single component
file. Don't install a router.

The dashboard accepts a query string after the route:
`#/?status=failed,running&q=BEN-30&sort=cost&dir=asc`. Parse/serialize logic
lives in [`src/web/dashboardFilters.ts`](../src/web/dashboardFilters.ts) and
is unit-tested. Filters round-trip through the URL so views are reload-stable
and shareable.

## Data sources

- `GET /api/runs` — list of runs.
Expand Down
7 changes: 4 additions & 3 deletions src/web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { RunDetail } from "./RunDetail.js";
import { Search } from "./Search.js";

type Route =
| { view: "dashboard" }
| { view: "dashboard"; search: string }
| { view: "run"; runId: string }
| { view: "search"; query: string };

Expand All @@ -18,7 +18,8 @@ function parseHash(): Route {
h === "search" ? "" : (new URLSearchParams(h.slice("search?".length)).get("q") ?? "");
return { view: "search", query };
}
return { view: "dashboard" };
const qIdx = h.indexOf("?");
return { view: "dashboard", search: qIdx === -1 ? "" : h.slice(qIdx) };
}

export function App() {
Expand Down Expand Up @@ -62,7 +63,7 @@ export function App() {
)}
</header>
<main className="px-6 py-6">
{route.view === "dashboard" && <Dashboard />}
{route.view === "dashboard" && <Dashboard search={route.search} />}
{route.view === "run" && <RunDetail runId={route.runId} />}
{route.view === "search" && <Search query={route.query} />}
</main>
Expand Down
149 changes: 140 additions & 9 deletions src/web/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
fetchHealth,
fetchRecentEvents,
Expand All @@ -17,12 +17,22 @@ import { MetricsPanel } from "./MetricsPanel.js";
import { ErrorFeed } from "./ErrorFeed.js";
import { SettingsPanel } from "./SettingsPanel.js";
import { StatusBadge, formatTs } from "./shared.js";
import {
applyFilters,
parseFilters,
RUN_STATUSES,
serializeFilters,
toggleSort,
toggleStatus,
type DashboardFilters,
type SortKey,
} from "./dashboardFilters.js";

export { StatusBadge } from "./shared.js";

type LoadState = { tag: "loading" } | { tag: "ready" } | { tag: "error"; message: string };

export function Dashboard() {
export function Dashboard({ search }: { search: string }) {
const [load, setLoad] = useState<LoadState>({ tag: "loading" });
const [runs, setRuns] = useState<ApiRun[]>([]);
const [usage, setUsage] = useState<ApiUsage | null>(null);
Expand Down Expand Up @@ -79,6 +89,13 @@ export function Dashboard() {
};
}, []);

const filters = useMemo(() => parseFilters(search), [search]);
const updateFilters = (next: DashboardFilters) => {
const qs = serializeFilters(next);
window.location.hash = qs ? `#/${qs}` : "#/";
};
const visibleRuns = useMemo(() => applyFilters(runs, filters), [runs, filters]);

if (load.tag === "loading") return <p className="text-slate-400">loading…</p>;
if (load.tag === "error") return <p className="text-rose-400">error: {load.message}</p>;

Expand All @@ -93,7 +110,83 @@ export function Dashboard() {
<MetricsPanel runs={runs} />
<ErrorFeed events={events} runs={runs} />
<HistoryTotals runs={runs} />
{runs.length === 0 ? <EmptyState /> : <RunsTable runs={runs} />}
{runs.length === 0 ? (
<EmptyState />
) : (
<>
<FilterBar
filters={filters}
total={runs.length}
visible={visibleRuns.length}
onChange={updateFilters}
/>
{visibleRuns.length === 0 ? (
<NoMatches onClear={() => updateFilters({ ...filters, statuses: [], text: "" })} />
) : (
<RunsTable runs={visibleRuns} filters={filters} onSort={updateFilters} />
)}
</>
)}
</div>
);
}

function FilterBar({
filters,
total,
visible,
onChange,
}: {
filters: DashboardFilters;
total: number;
visible: number;
onChange: (next: DashboardFilters) => void;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
{RUN_STATUSES.map((status) => {
const active = filters.statuses.includes(status);
return (
<button
key={status}
type="button"
aria-pressed={active}
onClick={() => onChange(toggleStatus(filters, status))}
className={`rounded px-2 py-0.5 text-xs font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500 ${
active ? "ring-1 ring-inset ring-cyan-400" : "opacity-70 hover:opacity-100"
}`}
>
<StatusBadge status={status} />
</button>
);
})}
<input
value={filters.text}
onChange={(e) => onChange({ ...filters, text: e.target.value })}
placeholder="filter by issue id or title…"
className="ml-auto flex-1 min-w-[12rem] max-w-sm rounded border border-slate-800 bg-slate-900 px-2 py-1 text-xs text-slate-100 focus:outline-none focus:border-cyan-600 focus-visible:ring-2 focus-visible:ring-cyan-500"
/>
</div>
<p className="text-xs text-slate-500">
showing <span className="font-mono text-slate-300">{visible}</span> of{" "}
<span className="font-mono">{total}</span> runs
</p>
</div>
);
}

function NoMatches({ onClear }: { onClear: () => void }) {
return (
<div className="rounded-lg border border-slate-800 bg-slate-900/40 p-4 text-sm text-slate-400">
No runs match the current filters.{" "}
<button
type="button"
onClick={onClear}
className="text-cyan-400 hover:text-cyan-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500 rounded"
>
Clear filters
</button>
</div>
);
}
Expand All @@ -111,7 +204,15 @@ function EmptyState() {
);
}

function RunsTable({ runs }: { runs: ApiRun[] }) {
function RunsTable({
runs,
filters,
onSort,
}: {
runs: ApiRun[];
filters: DashboardFilters;
onSort: (next: DashboardFilters) => void;
}) {
return (
<table className="w-full text-sm">
<thead className="text-left text-slate-400 border-b border-slate-800">
Expand All @@ -120,11 +221,11 @@ function RunsTable({ runs }: { runs: ApiRun[] }) {
<th className="py-2 pr-4">Title</th>
<th className="py-2 pr-4">Status</th>
<th className="py-2 pr-4">Scenario</th>
<th className="py-2 pr-4">Turns</th>
<th className="py-2 pr-4">Tokens</th>
<th className="py-2 pr-4">Cost</th>
<th className="py-2 pr-4">Started</th>
<th className="py-2 pr-4">Finished</th>
<SortableHeader label="Turns" sortKey="turns" filters={filters} onSort={onSort} />
<SortableHeader label="Tokens" sortKey="tokens" filters={filters} onSort={onSort} />
<SortableHeader label="Cost" sortKey="cost" filters={filters} onSort={onSort} />
<SortableHeader label="Started" sortKey="startedAt" filters={filters} onSort={onSort} />
<SortableHeader label="Finished" sortKey="finishedAt" filters={filters} onSort={onSort} />
</tr>
</thead>
<tbody>
Expand Down Expand Up @@ -180,6 +281,36 @@ function RunsTable({ runs }: { runs: ApiRun[] }) {
);
}

function SortableHeader({
label,
sortKey,
filters,
onSort,
}: {
label: string;
sortKey: SortKey;
filters: DashboardFilters;
onSort: (next: DashboardFilters) => void;
}) {
const active = filters.sort.key === sortKey;
const indicator = active ? (filters.sort.dir === "asc" ? " ↑" : " ↓") : "";
const ariaSort = active ? (filters.sort.dir === "asc" ? "ascending" : "descending") : "none";
return (
<th className="py-2 pr-4" aria-sort={ariaSort}>
<button
type="button"
onClick={() => onSort(toggleSort(filters, sortKey))}
className={`-mx-1 px-1 rounded text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500 ${
active ? "text-slate-100" : "hover:text-slate-200"
}`}
>
{label}
<span aria-hidden="true">{indicator}</span>
</button>
</th>
);
}

function sumTokens(r: ApiRun): number {
return (
(r.tokensInput ?? 0) +
Expand Down
Loading
Loading