Skip to content

Admin UI (trails-admin) — M10 Phases 3 + 4

Status: pre-alpha. Phase 3 MVP described in ADR-0019 §3. Phase 4 adds the graph browser and cost/budget views; both land as plain text tables backed by SPARQL / :class:CostTracker — no React, no Cytoscape.

trails-admin is an optional operator UI that renders every @capability registered in your app as an auto-generated form, shows the PROV-O trail for every invocation, and surfaces the ingestion dashboard for trails.ingest.

It lives in a SEPARATE top-level package (trails_admin) so import trails never pulls in FastAPI. Install with:

pip install 'trails[admin]'

That puts trails-admin on $PATH (see python/pyproject.toml [project.scripts]). To launch it:

trails-admin --app myapp.main --host 127.0.0.1 --port 4455

--app is the Python import path of the module that registers your capabilities (it's imported for its side effects — the @capability decorator writes into the shared singleton registry). Without it the admin UI starts empty.

Routes

Route Description
GET / Home page with links to the three sub-dashboards.
GET /capabilities List every registered @capability.
GET /capabilities/{id} Auto-generated invocation form.
GET /capabilities/{id}/schema JSON descriptor of parameters.
POST /runs/new Form target — invokes, renders the run page.
GET /runs Recent invocations (in-memory cache).
GET /runs/{trace_id} Run inspector: envelope + PROV-O triples.
GET /runs/{trace_id}.json Same, as JSON.
GET /ingestion Document list + new-ingest form.
GET /ingestion/list JSON projection of the document list.
POST /ingestion/new Triggers trails.ingest.ingest_file.
GET /graph List registered @node_type classes (Phase 4).
GET /graph/{label} Show up to 100 instances of a label.
GET /graph/subject/{iri} Every triple with this IRI as subject.
GET /cost Cost tracker records, filterable (Phase 4).
GET /cost/records.json JSON projection of the record table.
GET /cost/summary Totals + top-10 capabilities / principals.
GET /cost/summary.json JSON projection of the summary.

Auto-generated forms

The form renderer walks inspect.signature(handler) (with PEP 563 string-annotations resolved via typing.get_type_hints) and picks an input type per parameter:

Python annotation <input type>
int number (step="any")
float number (step="any")
bool checkbox
anything else text

Parameters with a default value are not marked required. When the form is submitted the route coerces each value back to the declared type before calling trails.invoke.

Run inspector

After invocation the admin UI renders:

  1. The response payload, pretty-printed.
  2. The full envelope (including provenance, cost, trace_id).
  3. A table of every triple in the PROV graph whose subject mentions the run's trace_id — the same SPARQL shape trails.prov_explorer.ProvenanceExplorer uses.

Ingestion dashboard

Lists every trails.ingest.Document in the KG with its chunk count (computed via Chunk.where(document=doc)). The form at the top accepts a local file path and calls trails.ingest.ingest_file with a fresh context bound to the singleton kernel store — so a Document ingested from the admin UI shows up in the Python surface and vice-versa.

Why HTMX + Jinja2, not React?

ADR-0019 §3 originally proposed a React + Vite + shadcn/ui bundle shipped prebuilt inside the wheel. The MVP defers that for a straightforward reason: a React bundle is a distribution bomb for a three-view admin surface. It adds:

  • Node + Vite + shadcn as contributor dependencies.
  • A reproducible-bundle CI step (ADR-0014) to reach Phase 4 parity.
  • A JS build pipeline entering the main repo purely to serve three list/form/detail views.

For the MVP a single index.html + per-route server-rendered pages delivers the same user-visible surface with none of that. If and when a Phase-4 graph browser actually needs real client-side state, we can revisit the React bundle; the FastAPI surface under trails_admin stays unchanged either way, because the admin sub-app talks to its front-end through plain HTML / JSON responses.

Graph browser (Phase 4)

GET /graph lists every class registered with @node_type — one row per class with its label, derived iri, declared fields, and any extends parents. Click a label to see up to 100 instances of that type rendered as a table keyed by IRI + declared fields; click the IRI to see every triple with that subject.

Design notes:

  • Text tables only — no interactive visualisation. Cytoscape.js / D3 / vis-network were considered and rejected for the MVP for the same reason we deferred the React bundle: a three-view admin surface doesn't need a layout engine. Phase 4's goal is to make the graph queryable without opening the Python REPL, and a triple table satisfies that. When a real operator workflow surfaces a genuine need for node/edge layout (e.g. inspecting a policy graph or a PROV-O chain visually), add it behind a route that lazy-loads the bundle so the text-table path stays zero-JS.
  • SPARQL caps at LIMIT 100 per query. The page shows the number actually returned so an operator notices when they hit the cap.
  • Long IRIs: every IRI renders inside a <code class=iri> tag with word-break: break-all so a 150-character trails-dev IRI wraps instead of horizontal-scrolling the whole table. The graph.css sheet carries the wrapping rule.
  • Default graph only. The browser reads from the default graph today; named-graph filtering and extends traversal are future sprints.

Cost & budget views (Phase 4)

GET /cost renders every record on the default CostTracker (trails.llm._module_tracker()) as a table with capability, tokens, usd, latency_ms, call_id, parent_call_id, and the dedupe flag. Query parameters apply an in-process filter:

  • ?capability=<substring> — case-insensitive substring match on the capability id column.
  • ?principal=<id> — matches records that carry a principal attribute (the current _CostEntry shape does not yet, but the knob is wired so future schema additions flow through unchanged).
  • ?limit=<int> — cap the rows rendered (default 100, max 1000).

GET /cost/summary aggregates:

  • Total USD, total tokens, total latency, invocation count — all derived from non-child entries so nested planner / capability / LLM envelopes don't multi-count.
  • Top 10 capabilities by USD spend.
  • Top 10 principals by spend — sourced from the tracker's budget map (CostTracker.set_budget(...) populates that). Records alone don't carry a principal in today's schema.

Zero-records / zero-budget is handled by a clean empty state — the aggregates page renders totals as $0.0000 with an explicit note explaining that's the clean-boot state, not an error.

Scope fence

  • No authentication in the MVP. The admin app MUST only be bound to 127.0.0.1 in dev. Cedar-gated admin routes land alongside the ADR-0019 Open Question #1 resolution (DID+biscuit vs. session cookie).
  • No interactive graph visualisation. Phase 4 ships text tables; a layout engine (Cytoscape et al.) is a later sprint if operator feedback demands it.
  • No budget mutation. Cost views are read-only. Writable budgets live in the Python surface per ADR-0019.
  • List-valued / dataclass params render as a plain text input. The auto-form is deliberately minimal; future sprints can wire input_shape metadata into the renderer once a compelling case appears.