diff --git a/README.md b/README.md index 423ac9c..259ab1e 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,12 @@ to the container's `:8090`. Then point `gadfly`'s `GADFLY_FINDINGS_URL` and `gad | Method & path | Body / query | Purpose | |---|---|---| | `GET /healthz` | — | liveness (open even when a token is set) | +| `GET /` · `GET /ui` | — | **view-only dashboard** — HTML shell, public; its JS fetches the gated endpoints with the token | | `POST /runs` | one run object | upsert a model's review of a PR (timing/tokens) | | `POST /reports` | JSON **array** of report objects | record findings + which model reported each | | `POST /findings/{id}/grade` | `{is_real, severity?, usefulness?, notes?, grader?}` | record a triage grade | | `GET /export` | — | flat report×finding×run×latest-grade rows — the dashboard feed | +| `GET /runs` | — | list all runs (timing/tokens), oldest first | | `GET /scoreboard` | — | points-free per-model rollup | `POST /runs` body: `{run_id, repo, pr, model, provider, lenses, duration_secs, input_tokens?, output_tokens?, cost_usd?}` @@ -101,7 +103,9 @@ to the container's `:8090`. Then point `gadfly`'s `GADFLY_FINDINGS_URL` and `gad `GET /scoreboard` element: `{model, provider, runs, minutes, input_tokens, output_tokens, findings, confirmed, false_positive, ungraded, by_severity:{severity:count}}`. -If `GADFLY_REPORTS_TOKEN` is set, every route except `/healthz` requires `Authorization: Bearer `. +If `GADFLY_REPORTS_TOKEN` is set, every route except the public view shell (`/healthz`, `/`, `/ui`) +requires `Authorization: Bearer `. The `/ui` shell carries no data itself — its JS sends the +token on each fetch — so the public shell leaks nothing. ## Configuration @@ -113,12 +117,23 @@ If `GADFLY_REPORTS_TOKEN` is set, every route except `/healthz` requires `Author CLI flags `--addr` / `--db` / `--token` override the env. -## Dashboards +## Dashboard -Point anything at the JSON endpoints (or the SQLite file read-only). `GET /export` is the flat feed; -`GET /scoreboard` is the per-model rollup. Compute points and value-per-minute **in the dashboard**, -e.g. with a curve like `trivial=1, small=3, medium=5, high=8, critical=20` → -`points = Σ weight[severity]·by_severity[severity]`, `value/min = points / minutes`. +A built-in **read-only dashboard** ships at **`/ui`** (hit the host root and you're redirected +there). It's a single self-contained page that pulls `/runs` + `/export` and does everything in your +browser: a **per-model performance table** — runs, minutes, findings, confirmed / false-positive / +ungraded, points, **points-per-minute**, points-per-run, by-severity — with **drill-down filters** +(date range, repo, provider, model, lens, grade/severity), free-text search, and a click-to-scope +findings detail table. + +True to the store's "no points" rule, **scoring lives in the browser**: the page has an editable +points curve (default `trivial=1, small=3, medium=5, high=8, critical=20`) and computes +`points = Σ weight[severity]·count` and `value/min = points / minutes` on the fly — retune it without +touching stored data. + +Auth: the `/ui` shell is public (it holds no data); paste the store token into its **connect** box, +or open `/ui?token=` once (remembered in `localStorage`). Prefer your own dashboard? Point +Grafana/Metabase/etc. at the SQLite file or the same `/export` + `/scoreboard` + `/runs` JSON. ## How it fits together diff --git a/server.go b/server.go index 4f33135..54e0a8b 100644 --- a/server.go +++ b/server.go @@ -9,16 +9,22 @@ import ( ) // newServer wires the store to the HTTP API. If token is non-empty, every route -// except /healthz requires "Authorization: Bearer ". +// requires "Authorization: Bearer " EXCEPT the always-public ones: +// /healthz and the view shell (/ and /ui). The dashboard's data still comes from +// the token-gated endpoints — its JS sends the token — so the public shell leaks +// no data on its own. // // Routes: // -// GET /healthz liveness +// GET / redirect to /ui (public) +// GET /ui read-only dashboard (HTML shell; data via fetch) (public) +// GET /healthz liveness (public) +// GET /runs list all runs (timing/tokens), oldest first +// GET /export flat report×finding×grade rows (the dashboard feed) +// GET /scoreboard points-free per-model rollup // POST /runs upsert one run (model review of a PR; timing/tokens) // POST /reports record a batch of findings + this model's reports // POST /findings/{id}/grade record a triage grade (is_real, severity, …) -// GET /export flat report×finding×grade rows (the dashboard feed) -// GET /scoreboard points-free per-model rollup func newServer(store *Store, token string) http.Handler { mux := http.NewServeMux() @@ -82,13 +88,39 @@ func newServer(store *Store, token string) http.Handler { writeJSON(w, http.StatusOK, stats) }) + mux.HandleFunc("GET /runs", func(w http.ResponseWriter, _ *http.Request) { + runs, err := store.ListRuns() + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, runs) + }) + + // View-only dashboard. The shell is public (no data); it fetches the + // token-gated endpoints from the browser with the token the user supplies. + mux.HandleFunc("GET /ui", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(uiHTML)) + }) + mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/ui", http.StatusFound) + }) + return auth(token, mux) } -// auth gates everything but /healthz behind a bearer token, when one is set. +// publicPaths are reachable without the bearer token even when one is set: the +// liveness probe and the dashboard SHELL (which carries no data — its JS fetches +// the gated endpoints with the token). +func isPublicPath(p string) bool { + return p == "/healthz" || p == "/" || p == "/ui" || strings.HasPrefix(p, "/ui/") +} + +// auth gates every non-public route behind a bearer token, when one is set. func auth(token string, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if token != "" && r.URL.Path != "/healthz" { + if token != "" && !isPublicPath(r.URL.Path) { got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if strings.TrimSpace(got) != token { writeErr(w, http.StatusUnauthorized, errors.New("missing or invalid bearer token")) diff --git a/server_test.go b/server_test.go index 719391f..4dd7189 100644 --- a/server_test.go +++ b/server_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" ) @@ -86,6 +87,41 @@ func TestServerAuth(t *testing.T) { } } +// TestViewShellPublic: the dashboard shell (/ and /ui) is reachable without a +// token even when one is set, but the data endpoints stay gated. +func TestViewShellPublic(t *testing.T) { + srv := testServer(t, "secret") + + resp := mustGet(t, srv, "", "/ui") + if resp.StatusCode != 200 { + t.Errorf("GET /ui (no token) = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("GET /ui content-type = %q, want text/html", ct) + } + // data must still require the token + if resp := mustGet(t, srv, "", "/runs"); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("GET /runs (no token) = %d, want 401", resp.StatusCode) + } +} + +// TestListRuns: POSTed runs come back via GET /runs with created_at populated. +func TestListRuns(t *testing.T) { + srv := testServer(t, "") + if resp := post(t, srv, "", "/runs", Run{RunID: "r1", Repo: "r", PR: 1, Model: "m", Provider: "p", DurationSecs: 120}); resp.StatusCode != 200 { + t.Fatalf("POST /runs = %d", resp.StatusCode) + } + resp := mustGet(t, srv, "", "/runs") + if resp.StatusCode != 200 { + t.Fatalf("GET /runs = %d", resp.StatusCode) + } + var runs []Run + json.NewDecoder(resp.Body).Decode(&runs) + if len(runs) != 1 || runs[0].RunID != "r1" || runs[0].DurationSecs != 120 || runs[0].CreatedAt == "" { + t.Fatalf("unexpected runs: %+v", runs) + } +} + func mustGet(t *testing.T, srv *httptest.Server, token, path string) *http.Response { t.Helper() req, _ := http.NewRequest("GET", srv.URL+path, nil) diff --git a/store.go b/store.go index 6925dfa..46790ac 100644 --- a/store.go +++ b/store.go @@ -137,6 +137,7 @@ type Run struct { InputTokens *int64 `json:"input_tokens,omitempty"` OutputTokens *int64 `json:"output_tokens,omitempty"` CostUSD *float64 `json:"cost_usd,omitempty"` + CreatedAt string `json:"created_at,omitempty"` // set on read by ListRuns; ignored by AddRun } // AddRun upserts a run by run_id (a re-posted run overwrites timing/tokens). @@ -156,6 +157,44 @@ ON CONFLICT(run_id) DO UPDATE SET return err } +// ListRuns returns every run (oldest first), including runs that produced no +// findings — so a dashboard can charge a model for all the time it spent, not +// just the runs that surfaced something. Read-only. +func (s *Store) ListRuns() ([]Run, error) { + rows, err := s.db.Query(` +SELECT run_id, repo, pr, model, provider, lenses, duration_secs, input_tokens, output_tokens, cost_usd, created_at +FROM runs ORDER BY created_at, run_id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Run + for rows.Next() { + var r Run + var in, outTok sql.NullInt64 + var cost sql.NullFloat64 + if err := rows.Scan(&r.RunID, &r.Repo, &r.PR, &r.Model, &r.Provider, &r.Lenses, + &r.DurationSecs, &in, &outTok, &cost, &r.CreatedAt); err != nil { + return nil, err + } + if in.Valid { + v := in.Int64 + r.InputTokens = &v + } + if outTok.Valid { + v := outTok.Int64 + r.OutputTokens = &v + } + if cost.Valid { + v := cost.Float64 + r.CostUSD = &v + } + out = append(out, r) + } + return out, rows.Err() +} + // ReportIn is one finding as a single model reported it. type ReportIn struct { Repo string `json:"repo"` diff --git a/ui.go b/ui.go new file mode 100644 index 0000000..7cc9c52 --- /dev/null +++ b/ui.go @@ -0,0 +1,12 @@ +package main + +import _ "embed" + +// uiHTML is the self-contained, read-only dashboard served at /ui. It is a single +// vanilla-JS page (no external assets) that fetches the raw /runs and /export +// data and does ALL filtering, drill-down, and scoring in the browser — keeping +// the daemon a pure fact store (points/value-per-minute are computed client-side +// from an editable curve). +// +//go:embed ui.html +var uiHTML string diff --git a/ui.html b/ui.html new file mode 100644 index 0000000..08d840c --- /dev/null +++ b/ui.html @@ -0,0 +1,314 @@ + + + + + +gadfly-reports · model performance + + + +
+

🪰📋 gadfly-reports · model performance

+ + + + +
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + trivial + small + medium + high + critical +
+
+
+ +
+
+ + + +
+
+ +
+
+ findings — drill down (click a model row above to scope) + + + + + + + +
reportedrepoprlensfile:linetitlemodelgradeby
+
+
+
+ + + +