From 11cfa95eeab6d89fbf084b0d0d121dcebc81ada3 Mon Sep 17 00:00:00 2001 From: Daedalus Date: Tue, 23 Jun 2026 22:39:34 +0200 Subject: [PATCH] feat(sessions): add User column and search-by-user to Sessions page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sessions table only exposed session IDs, making it hard to tell which user a session belonged to, and the search box filtered by session ID — opaque to users who never see those IDs (FLO-484). - Backend: surface the per-session user (spans.user_id, which stores the authenticated display name) via COALESCE(MAX(user_id), '') in the sessions query and a new user_id field on sessionItem. - Frontend: add a sortable "User" column; clicking a user applies the existing server-side ?user_id= filter. Empty user renders as "anonymous". Search box now filters the loaded page by user instead of session ID. - Test: assert the sessions API surfaces user_id. - Docs: update README Sessions bullet. Co-Authored-By: Daedalus Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- frontend/src/api/index.ts | 1 + frontend/src/pages/Sessions.module.css | 20 +++++++++++++++++ frontend/src/pages/Sessions.tsx | 31 ++++++++++++++++++++++++-- internal/api/handler.go | 6 +++-- internal/api/handler_test.go | 22 ++++++++++++++++++ 6 files changed, 77 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d29bf7c..deb8554 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ One Docker container. OTLP ingest on `:4318`, interactive analytics dashboard on ## What you get - **Overview dashboard** — KPI cards for sessions, unique users, total cost, and token counts (30-day window), with per-user filter across all charts -- **Sessions** — live table of every Claude Code session with model, duration, cost, and status (OK / ERROR) +- **Sessions** — live table of every Claude Code session with user, model, duration, cost, and status (OK / ERROR); search by user and click any user to filter the table to their sessions - **History** — time-series and daily-activity heatmaps for sessions and token spend over time - **Costs** — cumulative spend chart + breakdown table by model - **Tools** — call counts, average duration, and error rate per tool (`Bash`, `Read`, `Edit`, …) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6c3fcdb..36b52e7 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -20,6 +20,7 @@ export interface OverviewResponse { export interface SessionItem { session_id: string + user_id: string first_seen: string last_seen: string model: string diff --git a/frontend/src/pages/Sessions.module.css b/frontend/src/pages/Sessions.module.css index d61acc4..19ffdbc 100644 --- a/frontend/src/pages/Sessions.module.css +++ b/frontend/src/pages/Sessions.module.css @@ -85,6 +85,26 @@ color: var(--color-accent); } +.userLink { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: var(--color-accent); + font: inherit; + text-align: left; +} + +.userLink:hover { + text-decoration: underline; +} + +.anonUser { + color: var(--color-text-2); + font-style: italic; +} + .pagination { display: flex; align-items: center; diff --git a/frontend/src/pages/Sessions.tsx b/frontend/src/pages/Sessions.tsx index 8f5d6a0..191f789 100644 --- a/frontend/src/pages/Sessions.tsx +++ b/frontend/src/pages/Sessions.tsx @@ -22,11 +22,21 @@ export default function Sessions() { }) } + function applyUserFilter(uid: string) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev) + next.set('user_id', uid) + return next + }) + setPage(1) + } + const filtered = useMemo(() => { if (!data) return [] + const needle = search.toLowerCase() return data.items.filter( (s) => - (search === '' || s.session_id.toLowerCase().includes(search.toLowerCase())) && + (search === '' || (s.user_id ?? '').toLowerCase().includes(needle)) && (statusFilter === 'all' || s.status === statusFilter), ) }, [data, search, statusFilter]) @@ -47,7 +57,7 @@ export default function Sessions() {
{ setSearch(e.target.value); setPage(1) }} /> @@ -88,6 +98,23 @@ export default function Sessions() { ), }, + { + key: 'user_id', + label: 'User', + sortable: true, + render: (v) => + v ? ( + + ) : ( + anonymous + ), + }, { key: 'first_seen', label: 'Started', diff --git a/internal/api/handler.go b/internal/api/handler.go index d4bbd4b..89eca7a 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -309,6 +309,7 @@ func (h *Handler) handleOverview(w http.ResponseWriter, r *http.Request) { type sessionItem struct { SessionID string `json:"session_id"` + UserID string `json:"user_id"` FirstSeen string `json:"first_seen"` LastSeen string `json:"last_seen"` Model string `json:"model"` @@ -368,7 +369,8 @@ func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0), COUNT(*) FILTER (WHERE tool_name IS NOT NULL AND tool_name <> ''), - MAX(CASE WHEN status_code = 2 THEN 1 ELSE 0 END) + MAX(CASE WHEN status_code = 2 THEN 1 ELSE 0 END), + COALESCE(MAX(user_id), '') FROM spans WHERE session_id IS NOT NULL%s GROUP BY session_id @@ -394,7 +396,7 @@ func (h *Handler) handleSessions(w http.ResponseWriter, r *http.Request) { var firstSeen, lastSeen time.Time var hasError int if err := rows.Scan(&s.SessionID, &firstSeen, &lastSeen, &s.Model, - &s.CostUSD, &s.InputTokens, &s.OutputTokens, &s.ToolCalls, &hasError); err != nil { + &s.CostUSD, &s.InputTokens, &s.OutputTokens, &s.ToolCalls, &hasError, &s.UserID); err != nil { continue } s.FirstSeen = firstSeen.UTC().Format(time.RFC3339) diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 71d4936..4628a74 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -241,6 +241,28 @@ func TestSessions(t *testing.T) { } }, }, + { + name: "surfaces user_id", + seed: func(t *testing.T, db *storage.DB) { + now := time.Now() + insertSpan(t, db, storage.Span{ + TraceID: "t1", SpanID: "s1", Name: "llm", SessionID: "sess1", + UserID: "alice", StartTime: now, EndTime: now.Add(time.Second), + }) + }, + path: "/api/v1/sessions", + wantStatus: http.StatusOK, + checkBody: func(t *testing.T, body map[string]any) { + items, ok := body["items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("want 1 item, got %v", body["items"]) + } + item := items[0].(map[string]any) + if item["user_id"] != "alice" { + t.Errorf("want user_id=alice, got %v", item["user_id"]) + } + }, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) {