From cd92227243fbf18432e99fc7b2a405816786b030 Mon Sep 17 00:00:00 2001 From: Evanfeenstra Date: Tue, 15 Sep 2026 10:04:23 -0700 Subject: [PATCH] =?UTF-8?q?gateway:=20agent=20"Recent=20runs"=20=E2=80=94?= =?UTF-8?q?=20scope=20to=20the=20agent,=20newest=20first,=20show=20user/mo?= =?UTF-8?q?del/time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AgentDetail page built its Recent runs table from /histogram/cost?dimension=run-id, which is not agent-scoped and orders by spend: selecting canvas-agent listed every agent's runs, ranked by cost, with the run just fired nowhere near the top. New GET /_plugin/agents/:name/runs (agentruns.go): one row per run in the window, filtered by metadata.agent-name, sorted by last activity (newest first), with user_id, models (most-used first), spend, tokens, call count and first/last seen. ?limit=/?offset= paging plus `total` for the footer. Timestamps compare as times, not strings, so sub-second RFC3339Nano trimming can't misorder rows. UI: useAgentRuns hook; the table shows Run (ellipsized, full id on hover), User (first 8 chars, links to /people/:id), Model (+N pill when a run used more than one), Spend, Calls, Last call (relative, absolute on hover). The cost chart now passes agent_name so the server filters instead of the page; the stale "phase-8 limitation" comment goes with it. --- gateway/internal/adminapi/agentruns.go | 182 ++++++++++++++++++ gateway/internal/adminapi/agentruns_test.go | 160 +++++++++++++++ gateway/internal/adminapi/server.go | 11 ++ .../internal/adminapi/ui/src/api/queries.ts | 23 +++ gateway/internal/adminapi/ui/src/api/types.ts | 38 ++++ .../adminapi/ui/src/pages/AgentDetail.tsx | 172 +++++++++++------ .../adminapi/ui/src/styles/components.css | 29 +++ gateway/plans/llm-governance-v2.md | 2 +- gateway/plans/phases/phase-7-observability.md | 11 ++ 9 files changed, 567 insertions(+), 61 deletions(-) create mode 100644 gateway/internal/adminapi/agentruns.go create mode 100644 gateway/internal/adminapi/agentruns_test.go diff --git a/gateway/internal/adminapi/agentruns.go b/gateway/internal/adminapi/agentruns.go new file mode 100644 index 000000000..7a77b5898 --- /dev/null +++ b/gateway/internal/adminapi/agentruns.go @@ -0,0 +1,182 @@ +package adminapi + +import ( + "net/http" + "sort" + "time" +) + +// ─── /_plugin/agents/:name/runs ────────────────────────────────────── +// +// The agent's runs in the window, one row per run-id, most recent +// activity first. Backs the "Recent runs" table on the AgentDetail +// page. Before this endpoint the page derived that table from +// `/histogram/cost?dimension=run-id`, which is not agent-scoped and +// orders by spend — so an operator selecting canvas-agent saw every +// agent's runs, ranked by cost, with the run they had just fired +// nowhere near the top. +// +// Same strategy as the other rollups: page the window out of +// Bifrost's /api/logs filtered by `metadata.agent-name`, then group +// by `metadata.run-id` in Go. Rows without a run-id are dropped (a +// bare call outside any run has nothing to link to). Same 200k-row +// ceiling as the rest of observability.go. + +// AgentRunSummary is one row of /_plugin/agents/:name/runs. +type AgentRunSummary struct { + RunID string `json:"run_id"` + // UserID is `metadata.user-id` from the run's first row that + // carries one — the same key the People pages are keyed on, so + // the dashboard can link straight to /people/:id. Empty when + // no row was stamped. + UserID string `json:"user_id,omitempty"` + // Models the run called, most-used first (ties by name). A run + // usually has one; a "+N" affordance in the UI covers the rest. + Models []string `json:"models"` + TotalCost float64 `json:"total_cost"` + TotalTokens int64 `json:"total_tokens"` + RequestCount int64 `json:"request_count"` + FirstSeen string `json:"first_seen,omitempty"` + LastSeen string `json:"last_seen,omitempty"` +} + +// AgentRunsResponse is the envelope for /_plugin/agents/:name/runs. +// `total` is the run count in the window before ?limit=/?offset= +// paging, so the UI can say "showing 50 of 120". +type AgentRunsResponse struct { + AgentName string `json:"agent_name"` + Window string `json:"window"` + Total int `json:"total"` + Runs []AgentRunSummary `json:"runs"` +} + +func (h *observabilityHandlers) agentRuns(w http.ResponseWriter, r *http.Request, name string) { + if r.Method != http.MethodGet { + methodNotAllowed(w, http.MethodGet) + return + } + window, start, end, ok := parseWindow(w, r) + if !ok { + return + } + limit, offset, ok := parsePagination(w, r) + if !ok { + return + } + logs, err := h.logs.searchAll(r.Context(), searchOpts{ + StartTime: &start, + EndTime: &end, + Metadata: map[string]string{"agent-name": name}, + }, 1000, 200_000) + if err != nil { + writeUpstreamError(w, err, "agents.runs") + return + } + + runs := summarizeRuns(logs) + total := len(runs) + if offset > len(runs) { + offset = len(runs) + } + runs = runs[offset:] + if len(runs) > limit { + runs = runs[:limit] + } + writeJSON(w, http.StatusOK, AgentRunsResponse{ + AgentName: name, + Window: window, + Total: total, + Runs: runs, + }) +} + +// summarizeRuns groups log rows by `metadata.run-id` and returns one +// summary per run, sorted by last activity, newest first (ties by +// run-id so the order is stable across polls). Rows with no run-id +// are skipped. +func summarizeRuns(logs []logstoreLog) []AgentRunSummary { + type agg struct { + user string + models map[string]int64 + cost float64 + tokens int64 + count int64 + first time.Time + firstSeen string + last time.Time + lastSeen string + } + byRun := map[string]*agg{} + for _, l := range logs { + runID := l.Metadata["run-id"] + if runID == "" { + continue + } + a, ok := byRun[runID] + if !ok { + a = &agg{models: map[string]int64{}} + byRun[runID] = a + } + if a.user == "" { + a.user = l.Metadata["user-id"] + } + if l.Model != "" { + a.models[l.Model]++ + } + a.cost += l.Cost + a.tokens += l.tokens() + a.count++ + // Compare as times, not strings: RFC3339Nano trims trailing + // zeros, so "…:00Z" sorts after "…:00.5Z" lexicographically. + ts := parseLogTimestamp(l.Timestamp) + if a.firstSeen == "" || ts.Before(a.first) { + a.first, a.firstSeen = ts, l.Timestamp + } + if a.lastSeen == "" || ts.After(a.last) { + a.last, a.lastSeen = ts, l.Timestamp + } + } + + out := make([]AgentRunSummary, 0, len(byRun)) + for id, a := range byRun { + models := make([]string, 0, len(a.models)) + for m := range a.models { + models = append(models, m) + } + sort.Slice(models, func(i, j int) bool { + if a.models[models[i]] != a.models[models[j]] { + return a.models[models[i]] > a.models[models[j]] + } + return models[i] < models[j] + }) + out = append(out, AgentRunSummary{ + RunID: id, + UserID: a.user, + Models: models, + TotalCost: a.cost, + TotalTokens: a.tokens, + RequestCount: a.count, + FirstSeen: a.firstSeen, + LastSeen: a.lastSeen, + }) + } + sort.Slice(out, func(i, j int) bool { + ti, tj := parseLogTimestamp(out[i].LastSeen), parseLogTimestamp(out[j].LastSeen) + if !ti.Equal(tj) { + return ti.After(tj) + } + return out[i].RunID < out[j].RunID + }) + return out +} + +// parseLogTimestamp reads a Bifrost row timestamp. Unparseable +// values sort as the zero time — oldest — rather than being dropped, +// so a malformed row still counts toward the run's totals. +func parseLogTimestamp(s string) time.Time { + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return time.Time{} + } + return t +} diff --git a/gateway/internal/adminapi/agentruns_test.go b/gateway/internal/adminapi/agentruns_test.go new file mode 100644 index 000000000..d10f16be6 --- /dev/null +++ b/gateway/internal/adminapi/agentruns_test.go @@ -0,0 +1,160 @@ +package adminapi + +import ( + "net/http" + "testing" + "time" +) + +// /_plugin/agents/:name/runs — the per-run rollup behind the +// AgentDetail "Recent runs" table. Same fakeBifrost harness as +// observability_test.go; phase7Logs gives coder two runs (r1 by +// u_alice on haiku, r3 by u_bob on gpt-4o-mini) and web-search one +// (r2), which must not leak into coder's list. + +func TestAgentRuns_ScopedAndNewestFirst(t *testing.T) { + now := time.Now().UTC() + srv := newObservabilityTestServer(t, newFakeBifrost(t, phase7Logs(now))) + + out := decodeOK[AgentRunsResponse](t, bearerGet(t, srv, "/_plugin/agents/coder/runs?window=1h")) + if out.AgentName != "coder" || out.Window != "1h" || out.Total != 2 || len(out.Runs) != 2 { + t.Fatalf("envelope: %+v", out) + } + // r3's last call (4m ago) is newer than r1's (28m ago). + r3, r1 := out.Runs[0], out.Runs[1] + if r3.RunID != "r3" || r1.RunID != "r1" { + t.Fatalf("order: %s, %s", r3.RunID, r1.RunID) + } + if r3.UserID != "u_bob" || r3.RequestCount != 2 || r3.TotalTokens != 100 || + len(r3.Models) != 1 || r3.Models[0] != "gpt-4o-mini" { + t.Errorf("r3: %+v", r3) + } + if r3.TotalCost < 0.039 || r3.TotalCost > 0.041 { + t.Errorf("r3 cost: %v", r3.TotalCost) + } + if r1.UserID != "u_alice" || r1.RequestCount != 2 || r1.TotalTokens != 360 || + len(r1.Models) != 1 || r1.Models[0] != "claude-3-5-haiku" { + t.Errorf("r1: %+v", r1) + } + if r1.TotalCost < 0.149 || r1.TotalCost > 0.151 { + t.Errorf("r1 cost: %v", r1.TotalCost) + } + base := now.Truncate(10 * time.Minute) + if r1.FirstSeen != base.Add(-29*time.Minute).Format(time.RFC3339Nano) || + r1.LastSeen != base.Add(-28*time.Minute).Format(time.RFC3339Nano) { + t.Errorf("r1 seen: %s .. %s", r1.FirstSeen, r1.LastSeen) + } + for _, r := range out.Runs { + if r.RunID == "r2" { + t.Fatal("web-search's run leaked into coder's list") + } + } + + none := decodeOK[AgentRunsResponse](t, bearerGet(t, srv, "/_plugin/agents/ghost/runs")) + if none.Total != 0 || len(none.Runs) != 0 || none.Window != "24h" { + t.Errorf("unknown agent: %+v", none) + } +} + +// Models are ordered by call count; the user-id comes from the first +// row that carries one; rows with no run-id are skipped rather than +// crashing the rollup; sub-second timestamps compare as times. +func TestAgentRuns_ModelsUserAndOrphans(t *testing.T) { + now := time.Now().UTC() + ts := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339Nano) } + md := func(run, user string) map[string]string { + m := map[string]string{"agent-name": "coder"} + if run != "" { + m["run-id"] = run + } + if user != "" { + m["user-id"] = user + } + return m + } + logs := []fakeLog{ + {ID: "1", Timestamp: ts(3 * time.Minute), Model: "big", Cost: 1, Metadata: md("rx", "")}, + {ID: "2", Timestamp: ts(2 * time.Minute), Model: "small", Cost: 1, Metadata: md("rx", "u_carol")}, + {ID: "3", Timestamp: ts(1 * time.Minute), Model: "small", Cost: 1, Metadata: md("rx", "u_dave")}, + {ID: "4", Timestamp: ts(30 * time.Second), Model: "", Cost: 1, Metadata: md("rx", "")}, + // No run-id: excluded from every run, must not 500. + {ID: "5", Timestamp: ts(10 * time.Second), Model: "big", Cost: 9, Metadata: md("", "u_carol")}, + // A second run whose only call is a whole second older than + // rx's newest but has a *lexicographically* larger timestamp + // ("…:SSZ" vs "…:SS.5Z"). Must sort after rx. + {ID: "6", Timestamp: now.Add(-31 * time.Second).Truncate(time.Second).Format(time.RFC3339Nano), + Model: "big", Cost: 1, Metadata: md("ry", "u_erin")}, + } + srv := newObservabilityTestServer(t, newFakeBifrost(t, logs)) + + out := decodeOK[AgentRunsResponse](t, bearerGet(t, srv, "/_plugin/agents/coder/runs?window=1h")) + if out.Total != 2 || len(out.Runs) != 2 { + t.Fatalf("want 2 runs, got %+v", out) + } + rx := out.Runs[0] + if rx.RunID != "rx" || out.Runs[1].RunID != "ry" { + t.Fatalf("order: %s, %s", out.Runs[0].RunID, out.Runs[1].RunID) + } + if rx.UserID != "u_carol" { + t.Errorf("user: %q", rx.UserID) + } + if len(rx.Models) != 2 || rx.Models[0] != "small" || rx.Models[1] != "big" { + t.Errorf("models: %v", rx.Models) + } + if rx.RequestCount != 4 || rx.TotalCost != 4 { + t.Errorf("totals: %+v", rx) + } + if rx.FirstSeen != ts(3*time.Minute) || rx.LastSeen != ts(30*time.Second) { + t.Errorf("seen: %s .. %s", rx.FirstSeen, rx.LastSeen) + } +} + +func TestAgentRuns_Pagination(t *testing.T) { + now := time.Now().UTC() + srv := newObservabilityTestServer(t, newFakeBifrost(t, phase7Logs(now))) + + page := decodeOK[AgentRunsResponse](t, bearerGet(t, srv, "/_plugin/agents/coder/runs?window=1h&limit=1")) + if page.Total != 2 || len(page.Runs) != 1 || page.Runs[0].RunID != "r3" { + t.Errorf("limit=1: %+v", page) + } + next := decodeOK[AgentRunsResponse](t, bearerGet(t, srv, "/_plugin/agents/coder/runs?window=1h&limit=1&offset=1")) + if next.Total != 2 || len(next.Runs) != 1 || next.Runs[0].RunID != "r1" { + t.Errorf("offset=1: %+v", next) + } + past := decodeOK[AgentRunsResponse](t, bearerGet(t, srv, "/_plugin/agents/coder/runs?window=1h&offset=9")) + if past.Total != 2 || len(past.Runs) != 0 { + t.Errorf("offset past end: %+v", past) + } + + resp := bearerGet(t, srv, "/_plugin/agents/coder/runs?limit=0") + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("limit=0: want 400, got %d", resp.StatusCode) + } + resp = bearerGet(t, srv, "/_plugin/agents/coder/runs?window=bogus") + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("bad window: want 400, got %d", resp.StatusCode) + } +} + +func TestAgentRuns_404WithoutLogstore(t *testing.T) { + srv, _ := newBudgetTestServer(t, nil) // no logstore in routeDeps + resp := bearerDo(t, srv, http.MethodGet, "/_plugin/agents/coder/runs", "") + resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("want 404, got %d", resp.StatusCode) + } +} + +func TestAgentRuns_Upstream502(t *testing.T) { + now := time.Now().UTC() + bf := newFakeBifrost(t, phase7Logs(now)) + srv := newObservabilityTestServer(t, bf) + bf.failNextWith = http.StatusInternalServerError + resp := bearerGet(t, srv, "/_plugin/agents/coder/runs") + resp.Body.Close() + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("want 502, got %d", resp.StatusCode) + } +} diff --git a/gateway/internal/adminapi/server.go b/gateway/internal/adminapi/server.go index 66db5c659..76f2d60ba 100644 --- a/gateway/internal/adminapi/server.go +++ b/gateway/internal/adminapi/server.go @@ -360,6 +360,17 @@ func registerRoutes(mux *http.ServeMux, deps routeDeps) { obs.agentSpend(w, r, parts[0]) return } + // `/runs` (GET): the agent's runs in the window, newest + // activity first, with user / models / spend per run. Backs + // the AgentDetail "Recent runs" table. Same logstore gate. + if len(parts) == 2 && parts[0] != "" && parts[1] == "runs" { + if obs == nil { + http.NotFound(w, r) + return + } + obs.agentRuns(w, r, parts[0]) + return + } // `/_plugin/agents/catalog` (single segment) is the catalog // list — every registry agent, traffic or not. Distinct from // `/catalog` (two segments) which is one agent's detail. diff --git a/gateway/internal/adminapi/ui/src/api/queries.ts b/gateway/internal/adminapi/ui/src/api/queries.ts index b1586037d..00324b941 100644 --- a/gateway/internal/adminapi/ui/src/api/queries.ts +++ b/gateway/internal/adminapi/ui/src/api/queries.ts @@ -15,6 +15,7 @@ import { apiFetch, ApiCallError, getErrorMessage } from "./client"; import type { AgentBudgetResponse, AgentCatalogResponse, + AgentRunsResponse, AgentEvalsResponse, AgentStateResponse, KillAgentResponse, @@ -196,6 +197,28 @@ export function useAgentBudgets(names: string[]) { return out; } +// ─── /agents/:name/runs ───────────────────────────────────────────── +// +// One row per run of this agent in the window, newest activity +// first, with the user, model(s), spend and call count. Scoped +// server-side by `metadata.agent-name`, so unlike a run-id +// histogram it never shows another agent's runs. 30s poll, the +// by-agent cadence: an operator fires a run and expects it to land +// at the top of the table on the next tick. + +export function useAgentRuns(name: string | undefined, window: Window) { + return useQuery({ + queryKey: ["agents", name, "runs", window], + queryFn: () => + apiFetch( + `/agents/${encodeURIComponent(name!)}/runs?window=${encodeURIComponent(window)}` + ), + enabled: !!name, + refetchInterval: 30_000, + staleTime: 10_000, + }); +} + // ─── /agents/catalog (list) ───────────────────────────────────────── // // The whole registry — every catalog agent, traffic or not. The Agents diff --git a/gateway/internal/adminapi/ui/src/api/types.ts b/gateway/internal/adminapi/ui/src/api/types.ts index 1c661885d..a832cfca6 100644 --- a/gateway/internal/adminapi/ui/src/api/types.ts +++ b/gateway/internal/adminapi/ui/src/api/types.ts @@ -6,6 +6,44 @@ // pass-through shapes) live in manual.ts. /* eslint-disable */ +////////// +// source: agentruns.go + +/** + * AgentRunSummary is one row of /_plugin/agents/:name/runs. + */ +export interface AgentRunSummary { + run_id: string; + /** + * UserID is `metadata.user-id` from the run's first row that + * carries one — the same key the People pages are keyed on, so + * the dashboard can link straight to /people/:id. Empty when + * no row was stamped. + */ + user_id?: string; + /** + * Models the run called, most-used first (ties by name). A run + * usually has one; a "+N" affordance in the UI covers the rest. + */ + models: string[]; + total_cost: number /* float64 */; + total_tokens: number /* int64 */; + request_count: number /* int64 */; + first_seen?: string; + last_seen?: string; +} +/** + * AgentRunsResponse is the envelope for /_plugin/agents/:name/runs. + * `total` is the run count in the window before ?limit=/?offset= + * paging, so the UI can say "showing 50 of 120". + */ +export interface AgentRunsResponse { + agent_name: string; + window: string; + total: number /* int */; + runs: AgentRunSummary[]; +} + ////////// // source: budgets.go diff --git a/gateway/internal/adminapi/ui/src/pages/AgentDetail.tsx b/gateway/internal/adminapi/ui/src/pages/AgentDetail.tsx index 5562e0c92..632609ff0 100644 --- a/gateway/internal/adminapi/ui/src/pages/AgentDetail.tsx +++ b/gateway/internal/adminapi/ui/src/pages/AgentDetail.tsx @@ -1,15 +1,17 @@ -// AgentDetail — per-agent cost histogram + recent runs. +// AgentDetail — one agent's cost histogram, budget card and recent +// runs, plus the catalog tabs (prompts / tools / skills / evals) and +// the swarm-wide kill switch. // -// Phase-8 limitation -// ------------------ -// The backend's /_plugin/histogram/cost endpoint doesn't yet accept -// a per-agent metadata filter; it returns one series per agent -// across the whole window. We work around that here by filtering -// the response client-side to just this agent's series. A future -// backend revision can add a server-side filter without touching -// this page. - -import { useMemo, useState } from "preact/hooks"; +// Data flow +// -------- +// The chart is `useHistogramCost` scoped server-side with +// `agent_name=` (observability.go `metadataFilterFromQuery`), +// so the response carries only this agent's series. The "Recent +// runs" table is `useAgentRuns` → `/agents/:name/runs`: one row per +// run in the window, newest activity first, with the user, the +// model(s), spend and call count. Both follow the page's window. + +import { useState } from "preact/hooks"; import { Link } from "wouter-preact"; import { CostHistogram } from "../components/charts/CostHistogram"; @@ -17,7 +19,7 @@ import { ErrorBoundary } from "../components/ErrorBoundary"; import { WindowPicker } from "../components/controls/WindowPicker"; import { KillConfirmModal } from "../components/KillConfirmModal"; import { StatusBadge, deriveAgentStatus } from "../components/StatusBadge"; -import { StopIcon } from "../components/icons"; +import { StopIcon, UserIcon } from "../components/icons"; import type { AgentBudgetResponse, AgentCatalogResponse, @@ -31,6 +33,7 @@ import { useAgentBudget, useAgentCatalog, useAgentEvals, + useAgentRuns, useAgentState, useHistogramCost, useKillAgent, @@ -38,7 +41,6 @@ import { useToggleTool, useUnkillAgent, } from "../api/queries"; -import type { HistogramCostResponse } from "../api/types"; import type { Window } from "../api/manual"; import { windowToSeconds } from "../api/window"; import { EvalsView } from "./EvalsView"; @@ -63,6 +65,34 @@ const fmtUSD = (v: number) => { const fmtInt = (v: number) => new Intl.NumberFormat("en-US").format(Math.round(v)); +const fmtTs = (s?: string) => { + if (!s) return ""; + try { + return new Date(s).toLocaleString(); + } catch { + return s; + } +}; + +// "3m ago" for the runs table. Same helper as UserDetail's — the +// pages keep their own copies by convention (see AGENTS.md). +function fmtRelative(absISO?: string): string { + if (!absISO) return ""; + try { + const then = new Date(absISO).getTime(); + const sec = Math.round((Date.now() - then) / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.round(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.round(min / 60); + if (hr < 48) return `${hr}h ago`; + const day = Math.round(hr / 24); + return `${day}d ago`; + } catch { + return ""; + } +} + export function AgentDetail({ name }: Props) { const [window, setWindow] = useState("24h"); const bucket = window === "1h" ? "5m" : window === "6h" ? "10m" : "1h"; @@ -73,41 +103,16 @@ export function AgentDetail({ name }: Props) { window, bucket, dimension: "agent-name", + agentName: name, }); + const runs = useAgentRuns(name, window); - // Filter the histogram down to just this agent's series. - const filtered = useMemo(() => { - if (!histogram.data) return undefined; - return { - ...histogram.data, - series: histogram.data.series.filter((s) => s.dimension_value === name), - }; - }, [histogram.data, name]); - - // Pull run-ids from the same window to derive the "recent runs" - // table. We don't have a dedicated `/spend/by-run` endpoint yet, - // so we use the agent dimension histogram to surface activity and - // a separate dimension histogram by run-id for the same window. - const runsHistogram = useHistogramCost({ - window, - bucket, - dimension: "run-id", - }); - const recentRuns = useMemo(() => { - if (!runsHistogram.data) return []; - // Sort by total cost desc and cap to top 50 — the dashboard plan - // commits to "first 100 runs in window"; 50 keeps render cheap. - return [...runsHistogram.data.series] - .map((s) => ({ - run_id: s.dimension_value, - cost: s.points.reduce((acc, p) => acc + p.cost, 0), - calls: s.points.length, // approximate; chart points are by bucket - })) - .sort((a, b) => b.cost - a.cost) - .slice(0, 50); - }, [runsHistogram.data]); - - const totalCost = filtered?.series[0]?.points.reduce((s, p) => s + p.cost, 0) ?? 0; + // Server-filtered, so every series is this agent's; summing all + // points is the window spend. + const totalCost = + histogram.data?.series + .flatMap((s) => s.points) + .reduce((acc, p) => acc + p.cost, 0) ?? 0; const [tab, setTab] = useState("overview"); const catalog = useAgentCatalog(name); @@ -201,9 +206,8 @@ export function AgentDetail({ name }: Props) { totalCost={totalCost} budget={budget.data} histogram={histogram} - filtered={filtered} windowSeconds={windowSeconds} - recentRuns={recentRuns} + runs={runs} /> )} @@ -216,9 +220,8 @@ interface OverviewProps { totalCost: number; budget: AgentBudgetResponse | undefined; histogram: ReturnType; - filtered: HistogramCostResponse | undefined; windowSeconds: number; - recentRuns: { run_id: string; cost: number; calls: number }[]; + runs: ReturnType; } function OverviewTab({ @@ -227,9 +230,8 @@ function OverviewTab({ totalCost, budget, histogram, - filtered, windowSeconds, - recentRuns, + runs, }: OverviewProps) { return ( <> @@ -253,9 +255,9 @@ function OverviewTab({ {histogram.isError ? (
{getErrorMessage(histogram.error)}
- ) : filtered ? ( + ) : histogram.data ? ( - + ) : (
Loading…
@@ -264,7 +266,11 @@ function OverviewTab({

Recent runs

- {recentRuns.length === 0 ? ( + {runs.isError ? ( +
{getErrorMessage(runs.error)}
+ ) : !runs.data ? ( +
Loading…
+ ) : runs.data.runs.length === 0 ? (
No runs in this window.
) : (
@@ -272,24 +278,51 @@ function OverviewTab({ Run + User + Model Spend - Buckets seen + Calls + Last call - {recentRuns.map((r) => ( + {runs.data.runs.map((r) => ( - + {r.run_id} - {fmtUSD(r.cost)} - {fmtInt(r.calls)} + + {r.user_id ? ( + + + + {r.user_id.slice(0, 8)} + + + ) : ( + — + )} + + + + + {fmtUSD(r.total_cost)} + {fmtInt(r.request_count)} + + {fmtRelative(r.last_seen) || fmtTs(r.last_seen)} + ))} + {runs.data.total > runs.data.runs.length ? ( +
+ Showing the {runs.data.runs.length} most recent of{" "} + {runs.data.total} runs in this window. +
+ ) : null}
)}
@@ -297,6 +330,25 @@ function OverviewTab({ ); } +// ModelCell shows the run's most-used model, plus a "+N" pill when +// the run touched more than one (the others ride on the pill's +// title). Empty when no row recorded a model — a run of pure +// failures can look like that. +function ModelCell({ models }: { models: string[] }) { + if (models.length === 0) return —; + const [primary, ...rest] = models; + return ( + + {primary} + {rest.length > 0 ? ( + + +{rest.length} + + ) : null} + + ); +} + // BudgetCard renders the configured cap, the live spend against it, // remaining headroom, and a coloured progress bar. The same data is // summarised inline on the Agents list; here it gets the full diff --git a/gateway/internal/adminapi/ui/src/styles/components.css b/gateway/internal/adminapi/ui/src/styles/components.css index a52edba77..3c55be8a8 100644 --- a/gateway/internal/adminapi/ui/src/styles/components.css +++ b/gateway/internal/adminapi/ui/src/styles/components.css @@ -215,6 +215,35 @@ table.table { text-align: right; font-family: var(--font-mono); } +/* Truncating id cell: a run uuid clips with an ellipsis instead of + * stretching the table; the full id rides on the cell's `title` for + * hover-to-copy. `max-width` is what makes text-overflow bite inside + * a table cell. */ +.table td.cell-trunc { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +/* One-line note under a table (e.g. "showing 50 of 120 runs"). */ +.table-wrap .table-foot { + padding: var(--sp-3) var(--sp-4); + font-size: 12px; + border-top: 1px solid var(--border); +} +/* Model column of the runs table: primary model + optional "+N". + * One line — a wrapped model id reads as two models. */ +.model-cell { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + white-space: nowrap; +} +/* Relative-time cell ("14m ago") and its header never wrap. */ +.table td.cell-when, +.table th.cell-when { + white-space: nowrap; +} .table .pager { display: flex; justify-content: space-between; diff --git a/gateway/plans/llm-governance-v2.md b/gateway/plans/llm-governance-v2.md index de16ca324..8791004f1 100644 --- a/gateway/plans/llm-governance-v2.md +++ b/gateway/plans/llm-governance-v2.md @@ -323,7 +323,7 @@ auth middleware. The wrapper + loopback server solves both. | `/_plugin/health`, `/_plugin/admin-credentials` | 3 | Swarm bootstrap | | `/_plugin/trust/*` | 5 | Trust registry CRUD | | `/_plugin/runs/:id/{state,kill}`, `/_plugin/agents/:name/{state,kill}` | 6 | Hot-state kill switches and Redis snapshots | -| `/_plugin/spend/by-*`, `/_plugin/histogram/*`, `/_plugin/sessions/:id`, `/_plugin/users/:id/*`, `/_plugin/agents/:name/spend`, `/_plugin/runs/:id` (drill-down) | 7 | Per-dim analytics over `logs.db` | +| `/_plugin/spend/by-*`, `/_plugin/histogram/*`, `/_plugin/sessions/:id`, `/_plugin/users/:id/*`, `/_plugin/agents/:name/{spend,runs}`, `/_plugin/runs/:id` (drill-down) | 7 | Per-dim analytics over `logs.db` | Aggregations call Bifrost's `/api/logs` over loopback (`http://127.0.0.1:8080/api/logs`), composing diff --git a/gateway/plans/phases/phase-7-observability.md b/gateway/plans/phases/phase-7-observability.md index 5a091e40f..adeaa8c1f 100644 --- a/gateway/plans/phases/phase-7-observability.md +++ b/gateway/plans/phases/phase-7-observability.md @@ -234,6 +234,14 @@ GET /_plugin/users/:user_id/quota GET /_plugin/agents/:name/spend?window=24h MetadataFilters: {"agent-name": }; SearchStats, one limit=1 call returns: { agent_name, window, total_cost, total_tokens, request_count } + +GET /_plugin/agents/:name/runs?window=24h&limit=50&offset=0 + MetadataFilters: {"agent-name": }; paged scan, grouped by run-id in Go + returns: { agent_name, window, total, + runs: [ { run_id, user_id, models, total_cost, total_tokens, + request_count, first_seen, last_seen } ] } + Newest last_seen first; `models` most-used first; `total` is the run + count before paging. Backs the AgentDetail "Recent runs" table. ``` ## Query parameters @@ -332,6 +340,9 @@ Error codes: `hotstate.go`. - [x] `GET /_plugin/agents/:name/spend` (dispatched from the shared `/_plugin/agents/` subtree in `server.go`). +- [x] `GET /_plugin/agents/:name/runs` (`agentruns.go`) — per-run + rollup (user, models, spend, first/last seen) for the AgentDetail + "Recent runs" table; same subtree dispatch. - [x] Route registration in `server.go` — `routeDeps.logstore`; every read route is cookie-or-bearer.