diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 7acbc13..ea0a2cc 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -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/` | [`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. diff --git a/src/web/App.tsx b/src/web/App.tsx index daac30d..bcded83 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -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 }; @@ -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() { @@ -62,7 +63,7 @@ export function App() { )}
- {route.view === "dashboard" && } + {route.view === "dashboard" && } {route.view === "run" && } {route.view === "search" && }
diff --git a/src/web/Dashboard.tsx b/src/web/Dashboard.tsx index 45a37a4..a96a9fa 100644 --- a/src/web/Dashboard.tsx +++ b/src/web/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { fetchHealth, fetchRecentEvents, @@ -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({ tag: "loading" }); const [runs, setRuns] = useState([]); const [usage, setUsage] = useState(null); @@ -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

loading…

; if (load.tag === "error") return

error: {load.message}

; @@ -93,7 +110,83 @@ export function Dashboard() { - {runs.length === 0 ? : } + {runs.length === 0 ? ( + + ) : ( + <> + + {visibleRuns.length === 0 ? ( + updateFilters({ ...filters, statuses: [], text: "" })} /> + ) : ( + + )} + + )} + + ); +} + +function FilterBar({ + filters, + total, + visible, + onChange, +}: { + filters: DashboardFilters; + total: number; + visible: number; + onChange: (next: DashboardFilters) => void; +}) { + return ( +
+
+ {RUN_STATUSES.map((status) => { + const active = filters.statuses.includes(status); + return ( + + ); + })} + 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" + /> +
+

+ showing {visible} of{" "} + {total} runs +

+
+ ); +} + +function NoMatches({ onClear }: { onClear: () => void }) { + return ( +
+ No runs match the current filters.{" "} +
); } @@ -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 ( @@ -120,11 +221,11 @@ function RunsTable({ runs }: { runs: ApiRun[] }) { - - - - - + + + + + @@ -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 ( + + ); +} + function sumTokens(r: ApiRun): number { return ( (r.tokensInput ?? 0) + diff --git a/src/web/dashboardFilters.test.ts b/src/web/dashboardFilters.test.ts new file mode 100644 index 0000000..555878f --- /dev/null +++ b/src/web/dashboardFilters.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import type { ApiRun } from "./api.js"; +import { + applyFilters, + DEFAULT_FILTERS, + parseFilters, + serializeFilters, + toggleSort, + toggleStatus, +} from "./dashboardFilters.js"; + +function makeRun(overrides: Partial = {}): ApiRun { + const base: ApiRun = { + id: "run-1", + issueId: "ISSUE-1", + issueIdentifier: "BEN-1", + issueTitle: "first run", + status: "completed", + startedAt: "2026-04-01T00:00:00.000Z", + finishedAt: "2026-04-01T00:01:00.000Z", + scenario: null, + turnCount: 1, + tokensInput: null, + tokensOutput: null, + tokensCacheRead: null, + tokensCacheCreation: null, + totalCostUsd: null, + authStatus: null, + startFiveHourUtil: null, + startSevenDayUtil: null, + }; + return { ...base, ...overrides }; +} + +describe("parseFilters", () => { + it("returns defaults for an empty string", () => { + expect(parseFilters("")).toEqual(DEFAULT_FILTERS); + }); + + it("parses statuses, query and sort", () => { + const filters = parseFilters("?status=failed,running&q=BEN-30&sort=tokens&dir=asc"); + expect(filters).toEqual({ + statuses: ["failed", "running"], + text: "BEN-30", + sort: { key: "tokens", dir: "asc" }, + }); + }); + + it("ignores unknown statuses and unknown sort keys", () => { + const filters = parseFilters("?status=failed,bogus&sort=junk&dir=banana"); + expect(filters.statuses).toEqual(["failed"]); + expect(filters.sort).toEqual({ key: "startedAt", dir: "desc" }); + }); + + it("dedupes statuses", () => { + const filters = parseFilters("?status=failed,failed,completed"); + expect(filters.statuses).toEqual(["failed", "completed"]); + }); +}); + +describe("serializeFilters", () => { + it("emits empty string for defaults", () => { + expect(serializeFilters(DEFAULT_FILTERS)).toBe(""); + }); + + it("round-trips through parseFilters", () => { + const filters = { + statuses: ["failed" as const, "running" as const], + text: "BEN-30", + sort: { key: "cost" as const, dir: "asc" as const }, + }; + expect(parseFilters(serializeFilters(filters))).toEqual(filters); + }); +}); + +describe("applyFilters", () => { + const runs: ApiRun[] = [ + makeRun({ + id: "a", + issueIdentifier: "BEN-30", + issueTitle: "Fix login", + status: "failed", + startedAt: "2026-04-01T00:00:00.000Z", + tokensInput: 1000, + totalCostUsd: 0.5, + turnCount: 3, + }), + makeRun({ + id: "b", + issueIdentifier: "BEN-31", + issueTitle: "Add search", + status: "completed", + startedAt: "2026-04-02T00:00:00.000Z", + tokensInput: 500, + totalCostUsd: 0.1, + turnCount: 5, + }), + makeRun({ + id: "c", + issueIdentifier: "BEN-32", + issueTitle: "Polish dashboard", + status: "running", + startedAt: "2026-04-03T00:00:00.000Z", + finishedAt: null, + tokensInput: 200, + totalCostUsd: 0, + turnCount: 1, + }), + ]; + + it("defaults to startedAt desc", () => { + const out = applyFilters(runs, DEFAULT_FILTERS); + expect(out.map((r) => r.id)).toEqual(["c", "b", "a"]); + }); + + it("filters by status (multi-select)", () => { + const out = applyFilters(runs, { ...DEFAULT_FILTERS, statuses: ["failed", "running"] }); + expect(out.map((r) => r.id)).toEqual(["c", "a"]); + }); + + it("filters by free text against identifier and title (case-insensitive)", () => { + const byIdent = applyFilters(runs, { ...DEFAULT_FILTERS, text: "ben-31" }); + expect(byIdent.map((r) => r.id)).toEqual(["b"]); + const byTitle = applyFilters(runs, { ...DEFAULT_FILTERS, text: "DASH" }); + expect(byTitle.map((r) => r.id)).toEqual(["c"]); + }); + + it("sorts ascending by turns", () => { + const out = applyFilters(runs, { + ...DEFAULT_FILTERS, + sort: { key: "turns", dir: "asc" }, + }); + expect(out.map((r) => r.id)).toEqual(["c", "a", "b"]); + }); + + it("sorts by cost descending", () => { + const out = applyFilters(runs, { + ...DEFAULT_FILTERS, + sort: { key: "cost", dir: "desc" }, + }); + expect(out.map((r) => r.id)).toEqual(["a", "b", "c"]); + }); + + it("places null finishedAt last for desc, first for asc", () => { + const desc = applyFilters(runs, { + ...DEFAULT_FILTERS, + sort: { key: "finishedAt", dir: "desc" }, + }); + expect(desc[desc.length - 1].id).toBe("c"); + const asc = applyFilters(runs, { + ...DEFAULT_FILTERS, + sort: { key: "finishedAt", dir: "asc" }, + }); + expect(asc[0].id).toBe("c"); + }); +}); + +describe("toggleStatus", () => { + it("adds a status and removes it on second toggle", () => { + const once = toggleStatus(DEFAULT_FILTERS, "failed"); + expect(once.statuses).toEqual(["failed"]); + const twice = toggleStatus(once, "failed"); + expect(twice.statuses).toEqual([]); + }); +}); + +describe("toggleSort", () => { + it("switches key and resets to desc", () => { + const next = toggleSort(DEFAULT_FILTERS, "cost"); + expect(next.sort).toEqual({ key: "cost", dir: "desc" }); + }); + + it("flips direction when the key matches", () => { + const flipped = toggleSort(DEFAULT_FILTERS, "startedAt"); + expect(flipped.sort).toEqual({ key: "startedAt", dir: "asc" }); + const back = toggleSort(flipped, "startedAt"); + expect(back.sort).toEqual({ key: "startedAt", dir: "desc" }); + }); +}); diff --git a/src/web/dashboardFilters.ts b/src/web/dashboardFilters.ts new file mode 100644 index 0000000..271fc3d --- /dev/null +++ b/src/web/dashboardFilters.ts @@ -0,0 +1,126 @@ +import type { ApiRun } from "./api.js"; + +export const RUN_STATUSES = [ + "running", + "completed", + "failed", + "max_turns", + "rate_limited", + "cancelled", +] as const; + +export type RunStatus = (typeof RUN_STATUSES)[number]; + +export const SORT_KEYS = ["startedAt", "finishedAt", "turns", "tokens", "cost"] as const; +export type SortKey = (typeof SORT_KEYS)[number]; + +export type SortDir = "asc" | "desc"; + +export interface DashboardFilters { + statuses: RunStatus[]; + text: string; + sort: { key: SortKey; dir: SortDir }; +} + +export const DEFAULT_FILTERS: DashboardFilters = { + statuses: [], + text: "", + sort: { key: "startedAt", dir: "desc" }, +}; + +function isRunStatus(s: string): s is RunStatus { + return (RUN_STATUSES as readonly string[]).includes(s); +} + +function isSortKey(s: string): s is SortKey { + return (SORT_KEYS as readonly string[]).includes(s); +} + +export function parseFilters(search: string): DashboardFilters { + const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + const statusesRaw = params.get("status") ?? ""; + const statuses = statusesRaw + .split(",") + .map((s) => s.trim()) + .filter((s): s is RunStatus => s.length > 0 && isRunStatus(s)); + const uniqueStatuses = Array.from(new Set(statuses)); + const text = params.get("q") ?? ""; + const sortKeyRaw = params.get("sort") ?? ""; + const sortKey: SortKey = isSortKey(sortKeyRaw) ? sortKeyRaw : DEFAULT_FILTERS.sort.key; + const dirRaw = params.get("dir") ?? ""; + const dir: SortDir = dirRaw === "asc" ? "asc" : "desc"; + return { statuses: uniqueStatuses, text, sort: { key: sortKey, dir } }; +} + +export function serializeFilters(filters: DashboardFilters): string { + const params = new URLSearchParams(); + if (filters.statuses.length > 0) params.set("status", filters.statuses.join(",")); + if (filters.text) params.set("q", filters.text); + if (filters.sort.key !== DEFAULT_FILTERS.sort.key) params.set("sort", filters.sort.key); + if (filters.sort.dir !== DEFAULT_FILTERS.sort.dir) params.set("dir", filters.sort.dir); + const s = params.toString(); + return s ? `?${s}` : ""; +} + +function tokenSum(r: ApiRun): number { + return ( + (r.tokensInput ?? 0) + + (r.tokensOutput ?? 0) + + (r.tokensCacheRead ?? 0) + + (r.tokensCacheCreation ?? 0) + ); +} + +function sortValue(r: ApiRun, key: SortKey): number { + switch (key) { + case "startedAt": + return Date.parse(r.startedAt); + case "finishedAt": + return r.finishedAt ? Date.parse(r.finishedAt) : Number.NEGATIVE_INFINITY; + case "turns": + return r.turnCount; + case "tokens": + return tokenSum(r); + case "cost": + return r.totalCostUsd ?? 0; + } +} + +export function applyFilters(runs: ApiRun[], filters: DashboardFilters): ApiRun[] { + const text = filters.text.trim().toLowerCase(); + const filtered = runs.filter((r) => { + if (filters.statuses.length > 0 && !filters.statuses.includes(r.status as RunStatus)) { + return false; + } + if (text.length === 0) return true; + const ident = r.issueIdentifier.toLowerCase(); + const title = (r.issueTitle ?? "").toLowerCase(); + return ident.includes(text) || title.includes(text); + }); + const sign = filters.sort.dir === "asc" ? 1 : -1; + const sorted = [...filtered].sort((a, b) => { + const av = sortValue(a, filters.sort.key); + const bv = sortValue(b, filters.sort.key); + if (av === bv) return 0; + return av < bv ? -sign : sign; + }); + return sorted; +} + +export function toggleStatus(filters: DashboardFilters, status: RunStatus): DashboardFilters { + const has = filters.statuses.includes(status); + return { + ...filters, + statuses: has ? filters.statuses.filter((s) => s !== status) : [...filters.statuses, status], + }; +} + +export function toggleSort(filters: DashboardFilters, key: SortKey): DashboardFilters { + if (filters.sort.key !== key) { + return { ...filters, sort: { key, dir: "desc" } }; + } + return { + ...filters, + sort: { key, dir: filters.sort.dir === "desc" ? "asc" : "desc" }, + }; +}
Title Status ScenarioTurnsTokensCostStartedFinished
+ +