From 44569972160619d5d548a295cbc0386547cf382e Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sat, 17 Jan 2026 01:48:53 -0800 Subject: [PATCH] docs: add UI, query API, validation implementation plan 10 tasks covering validation, query endpoints, and frontend. Co-Authored-By: Claude Opus 4.5 --- .../2026-01-17-ui-query-validation-plan.md | 1470 +++++++++++++++++ 1 file changed, 1470 insertions(+) create mode 100644 docs/plans/2026-01-17-ui-query-validation-plan.md diff --git a/docs/plans/2026-01-17-ui-query-validation-plan.md b/docs/plans/2026-01-17-ui-query-validation-plan.md new file mode 100644 index 0000000..2a437b9 --- /dev/null +++ b/docs/plans/2026-01-17-ui-query-validation-plan.md @@ -0,0 +1,1470 @@ +# UI, Query API, and Validation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Sessions/Run detail views with supporting query API and schema validation at ingest. + +**Architecture:** Three layers - validation in shared `internal/event` package, new query methods in `internal/store/postgres`, frontend as static files served by web-ui with History API routing. + +**Tech Stack:** Go 1.21+, chi router, pgx, Vanilla JS (no build step) + +--- + +## Task 1: Add Event Validation + +Add a `Validate` function to the event package for schema validation. + +**Files:** +- Create: `internal/event/validate.go` +- Create: `internal/event/validate_test.go` + +**Step 1: Write the test file** + +```go +// internal/event/validate_test.go +package event + +import ( + "encoding/json" + "testing" +) + +func TestValidate_ValidEvent(t *testing.T) { + raw := `{ + "schema": {"name": "agentmon.event", "version": 1}, + "event": { + "id": "e1", + "type": "run.start", + "ts": "2026-01-17T00:00:00Z", + "source": {"framework": "claude-code", "client_id": "c1", "host": "myhost"} + } + }` + var m map[string]any + _ = json.Unmarshal([]byte(raw), &m) + + err := Validate(m) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidate_MissingEventID(t *testing.T) { + raw := `{ + "schema": {"name": "agentmon.event", "version": 1}, + "event": { + "type": "run.start", + "ts": "2026-01-17T00:00:00Z", + "source": {"framework": "claude-code", "client_id": "c1", "host": "myhost"} + } + }` + var m map[string]any + _ = json.Unmarshal([]byte(raw), &m) + + err := Validate(m) + if err == nil { + t.Fatal("expected error for missing event.id") + } +} + +func TestValidate_InvalidType(t *testing.T) { + raw := `{ + "schema": {"name": "agentmon.event", "version": 1}, + "event": { + "id": "e1", + "type": "invalid.type", + "ts": "2026-01-17T00:00:00Z", + "source": {"framework": "claude-code", "client_id": "c1", "host": "myhost"} + } + }` + var m map[string]any + _ = json.Unmarshal([]byte(raw), &m) + + err := Validate(m) + if err == nil { + t.Fatal("expected error for invalid type") + } +} + +func TestValidate_WrongSchemaName(t *testing.T) { + raw := `{ + "schema": {"name": "wrong.schema", "version": 1}, + "event": { + "id": "e1", + "type": "run.start", + "ts": "2026-01-17T00:00:00Z", + "source": {"framework": "claude-code", "client_id": "c1", "host": "myhost"} + } + }` + var m map[string]any + _ = json.Unmarshal([]byte(raw), &m) + + err := Validate(m) + if err == nil { + t.Fatal("expected error for wrong schema name") + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd ~/lab/agentmon && go test ./internal/event/... -v` +Expected: FAIL (Validate undefined) + +**Step 3: Write the validation implementation** + +```go +// internal/event/validate.go +package event + +import ( + "errors" + "fmt" +) + +var validTypes = map[string]bool{ + "session.start": true, + "session.end": true, + "run.start": true, + "run.end": true, + "span.start": true, + "span.end": true, + "error": true, + "metric.snapshot": true, +} + +type ValidationError struct { + Field string + Message string +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +func Validate(m map[string]any) error { + // Check schema + schema, ok := m["schema"].(map[string]any) + if !ok { + return ValidationError{Field: "schema", Message: "missing or invalid"} + } + if name, _ := schema["name"].(string); name != "agentmon.event" { + return ValidationError{Field: "schema.name", Message: "must be 'agentmon.event'"} + } + if ver, _ := schema["version"].(float64); ver != 1 { + return ValidationError{Field: "schema.version", Message: "must be 1"} + } + + // Check event + event, ok := m["event"].(map[string]any) + if !ok { + return ValidationError{Field: "event", Message: "missing or invalid"} + } + + if id, _ := event["id"].(string); id == "" { + return ValidationError{Field: "event.id", Message: "required"} + } + + eventType, _ := event["type"].(string) + if eventType == "" { + return ValidationError{Field: "event.type", Message: "required"} + } + if !validTypes[eventType] { + return ValidationError{Field: "event.type", Message: fmt.Sprintf("invalid type '%s'", eventType)} + } + + if event["ts"] == nil { + return ValidationError{Field: "event.ts", Message: "required"} + } + + // Check source + source, ok := event["source"].(map[string]any) + if !ok { + return ValidationError{Field: "event.source", Message: "missing or invalid"} + } + + if fw, _ := source["framework"].(string); fw == "" { + return ValidationError{Field: "event.source.framework", Message: "required"} + } + if cid, _ := source["client_id"].(string); cid == "" { + return ValidationError{Field: "event.source.client_id", Message: "required"} + } + if host, _ := source["host"].(string); host == "" { + return ValidationError{Field: "event.source.host", Message: "required"} + } + + return nil +} + +func IsValidationError(err error) bool { + var ve ValidationError + return errors.As(err, &ve) +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd ~/lab/agentmon && go test ./internal/event/... -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/event/validate.go internal/event/validate_test.go +git commit -m "feat: add event validation" +``` + +--- + +## Task 2: Wire Validation into Ingest Gateway + +**Files:** +- Modify: `cmd/ingest-gateway/main.go` + +**Step 1: Update HTTP handler to validate** + +In `cmd/ingest-gateway/main.go`, update the POST handler: + +```go +// Add import at top: +// "agentmon/internal/event" + +// Replace the POST /v1/events handler (around line 39): +r.Post("/v1/events", func(w http.ResponseWriter, r *http.Request) { + var events []json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&events); err != nil { + httpx.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid_json"}) + return + } + + accepted := 0 + rejected := 0 + var errors []map[string]any + for _, raw := range events { + if len(raw) == 0 { + rejected++ + continue + } + + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + rejected++ + errors = append(errors, map[string]any{"error": "invalid_json"}) + continue + } + + if err := event.Validate(m); err != nil { + rejected++ + if ve, ok := err.(event.ValidationError); ok { + errors = append(errors, map[string]any{"error": "validation_failed", "field": ve.Field, "message": ve.Message}) + } + continue + } + + if err := pub.Publish(r.Context(), raw); err != nil { + rejected++ + continue + } + accepted++ + } + + resp := map[string]any{"accepted": accepted, "rejected": rejected} + if len(errors) > 0 { + resp["errors"] = errors + } + httpx.WriteJSON(w, http.StatusAccepted, resp) +}) +``` + +**Step 2: Update WebSocket handler to validate** + +Replace the wsHandler function body's message processing (around line 86): + +```go +var m map[string]any +if err := json.Unmarshal(msg, &m); err != nil { + _ = conn.WriteJSON(map[string]any{"error": "invalid_json"}) + continue +} + +if err := event.Validate(m); err != nil { + if ve, ok := err.(event.ValidationError); ok { + _ = conn.WriteJSON(map[string]any{"error": "validation_failed", "field": ve.Field, "message": ve.Message}) + } else { + _ = conn.WriteJSON(map[string]any{"error": "validation_failed"}) + } + continue +} + +if err := pub.Publish(r.Context(), msg); err != nil { + _ = conn.WriteJSON(map[string]any{"error": "publish_failed"}) + continue +} + +_ = conn.WriteJSON(map[string]any{"ack": map[string]any{"up_to_seq": nil}}) +``` + +**Step 3: Build to verify** + +Run: `cd ~/lab/agentmon && go build ./cmd/ingest-gateway` +Expected: Build succeeds + +**Step 4: Commit** + +```bash +git add cmd/ingest-gateway/main.go +git commit -m "feat: add validation to ingest gateway" +``` + +--- + +## Task 3: Add Sessions Query + +**Files:** +- Create: `internal/store/postgres/sessions.go` + +**Step 1: Create the sessions query file** + +```go +// internal/store/postgres/sessions.go +package postgres + +import ( + "context" + "fmt" + "strings" + "time" +) + +type SessionRow struct { + SessionID string `json:"session_id"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Framework string `json:"framework"` + Host string `json:"host"` + RunCount int `json:"run_count"` +} + +type SessionsFilter struct { + From *time.Time + To *time.Time + Framework string + Host string + Limit int + Cursor *time.Time // cursor is the last started_at seen +} + +func (d *DB) ListSessions(ctx context.Context, f SessionsFilter) ([]SessionRow, *time.Time, error) { + if f.Limit <= 0 { + f.Limit = 50 + } + if f.Limit > 200 { + f.Limit = 200 + } + + // Build query dynamically + var conditions []string + var args []any + argN := 1 + + // Default time range: last 24h + if f.From == nil { + t := time.Now().Add(-24 * time.Hour) + f.From = &t + } + conditions = append(conditions, fmt.Sprintf("ts >= $%d", argN)) + args = append(args, *f.From) + argN++ + + if f.To != nil { + conditions = append(conditions, fmt.Sprintf("ts <= $%d", argN)) + args = append(args, *f.To) + argN++ + } + + if f.Framework != "" { + conditions = append(conditions, fmt.Sprintf("source_framework = $%d", argN)) + args = append(args, f.Framework) + argN++ + } + + if f.Host != "" { + conditions = append(conditions, fmt.Sprintf("payload->'event'->'source'->>'host' = $%d", argN)) + args = append(args, f.Host) + argN++ + } + + if f.Cursor != nil { + conditions = append(conditions, fmt.Sprintf("ts < $%d", argN)) + args = append(args, *f.Cursor) + argN++ + } + + where := strings.Join(conditions, " AND ") + + query := fmt.Sprintf(` + SELECT + session_id, + MIN(ts) as started_at, + MAX(ts) as ended_at, + MAX(source_framework) as framework, + MAX(payload->'event'->'source'->>'host') as host, + COUNT(DISTINCT run_id) as run_count + FROM events + WHERE session_id IS NOT NULL AND %s + GROUP BY session_id + ORDER BY started_at DESC + LIMIT $%d + `, where, argN) + args = append(args, f.Limit+1) // fetch one extra to detect next page + + rows, err := d.sql.QueryContext(ctx, query, args...) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + var out []SessionRow + for rows.Next() { + var r SessionRow + var host *string + if err := rows.Scan(&r.SessionID, &r.StartedAt, &r.EndedAt, &r.Framework, &host, &r.RunCount); err != nil { + return nil, nil, err + } + if host != nil { + r.Host = *host + } + out = append(out, r) + } + + var nextCursor *time.Time + if len(out) > f.Limit { + out = out[:f.Limit] + nextCursor = &out[len(out)-1].StartedAt + } + + return out, nextCursor, rows.Err() +} +``` + +**Step 2: Build to verify** + +Run: `cd ~/lab/agentmon && go build ./...` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add internal/store/postgres/sessions.go +git commit -m "feat: add sessions list query" +``` + +--- + +## Task 4: Add Session Detail Query (Runs) + +**Files:** +- Create: `internal/store/postgres/runs.go` + +**Step 1: Create the runs query file** + +```go +// internal/store/postgres/runs.go +package postgres + +import ( + "context" + "time" +) + +type RunRow struct { + RunID string `json:"run_id"` + SessionID string `json:"session_id"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Status string `json:"status"` + SpanCount int `json:"span_count"` +} + +type SessionDetail struct { + SessionID string `json:"session_id"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Framework string `json:"framework"` + Host string `json:"host"` +} + +func (d *DB) GetSessionWithRuns(ctx context.Context, sessionID string) (*SessionDetail, []RunRow, error) { + // Get session info + sessionQuery := ` + SELECT + session_id, + MIN(ts) as started_at, + MAX(ts) as ended_at, + MAX(source_framework) as framework, + MAX(payload->'event'->'source'->>'host') as host + FROM events + WHERE session_id = $1 + GROUP BY session_id + ` + var session SessionDetail + var host *string + err := d.sql.QueryRowContext(ctx, sessionQuery, sessionID).Scan( + &session.SessionID, &session.StartedAt, &session.EndedAt, &session.Framework, &host, + ) + if err != nil { + return nil, nil, err + } + if host != nil { + session.Host = *host + } + + // Get runs + runsQuery := ` + SELECT + run_id, + session_id, + MIN(ts) as started_at, + MAX(ts) as ended_at, + CASE + WHEN bool_or(type = 'error' OR payload->'payload'->>'status' = 'error') THEN 'error' + ELSE 'success' + END as status, + COUNT(DISTINCT span_id) as span_count + FROM events + WHERE session_id = $1 AND run_id IS NOT NULL + GROUP BY run_id, session_id + ORDER BY started_at ASC + ` + rows, err := d.sql.QueryContext(ctx, runsQuery, sessionID) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + var runs []RunRow + for rows.Next() { + var r RunRow + if err := rows.Scan(&r.RunID, &r.SessionID, &r.StartedAt, &r.EndedAt, &r.Status, &r.SpanCount); err != nil { + return nil, nil, err + } + runs = append(runs, r) + } + + return &session, runs, rows.Err() +} +``` + +**Step 2: Build to verify** + +Run: `cd ~/lab/agentmon && go build ./...` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add internal/store/postgres/runs.go +git commit -m "feat: add session detail with runs query" +``` + +--- + +## Task 5: Add Run Detail Query (Spans) + +**Files:** +- Modify: `internal/store/postgres/runs.go` + +**Step 1: Add GetRunWithSpans function** + +Append to `internal/store/postgres/runs.go`: + +```go +type SpanRow struct { + SpanID string `json:"span_id"` + Name string `json:"name"` + Kind string `json:"kind"` + StartedAt time.Time `json:"started_at"` + Duration *int64 `json:"duration_ms,omitempty"` + Status string `json:"status"` + Payload json.RawMessage `json:"payload"` +} + +type RunDetail struct { + RunID string `json:"run_id"` + SessionID string `json:"session_id"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Status string `json:"status"` +} + +func (d *DB) GetRunWithSpans(ctx context.Context, runID string) (*RunDetail, []SpanRow, error) { + // Get run info + runQuery := ` + SELECT + run_id, + session_id, + MIN(ts) as started_at, + MAX(ts) as ended_at, + CASE + WHEN bool_or(type = 'error' OR payload->'payload'->>'status' = 'error') THEN 'error' + ELSE 'success' + END as status + FROM events + WHERE run_id = $1 + GROUP BY run_id, session_id + ` + var run RunDetail + err := d.sql.QueryRowContext(ctx, runQuery, runID).Scan( + &run.RunID, &run.SessionID, &run.StartedAt, &run.EndedAt, &run.Status, + ) + if err != nil { + return nil, nil, err + } + + // Get spans + spansQuery := ` + SELECT + span_id, + COALESCE(payload->'attributes'->>'name', payload->'event'->>'type', type) as name, + COALESCE(payload->'attributes'->>'span_kind', 'unknown') as kind, + ts as started_at, + (payload->'payload'->>'duration_ms')::bigint as duration_ms, + CASE + WHEN type = 'error' OR payload->'payload'->>'status' = 'error' THEN 'error' + ELSE 'success' + END as status, + payload + FROM events + WHERE run_id = $1 AND span_id IS NOT NULL + ORDER BY ts ASC + ` + rows, err := d.sql.QueryContext(ctx, spansQuery, runID) + if err != nil { + return nil, nil, err + } + defer rows.Close() + + var spans []SpanRow + for rows.Next() { + var s SpanRow + if err := rows.Scan(&s.SpanID, &s.Name, &s.Kind, &s.StartedAt, &s.Duration, &s.Status, &s.Payload); err != nil { + return nil, nil, err + } + spans = append(spans, s) + } + + return &run, spans, rows.Err() +} +``` + +**Step 2: Add import for encoding/json at top of file** + +Add `"encoding/json"` to the imports. + +**Step 3: Build to verify** + +Run: `cd ~/lab/agentmon && go build ./...` +Expected: Build succeeds + +**Step 4: Commit** + +```bash +git add internal/store/postgres/runs.go +git commit -m "feat: add run detail with spans query" +``` + +--- + +## Task 6: Add Query API Endpoints + +**Files:** +- Modify: `cmd/query-api/main.go` + +**Step 1: Add sessions endpoint** + +Add the new routes after the existing `/v1/events` handler. Full updated main.go: + +```go +package main + +import ( + "database/sql" + "log" + "net/http" + "os" + "strconv" + "time" + + "agentmon/internal/httpx" + "agentmon/internal/store/postgres" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func main() { + addr := envDefault("AGENTMON_QUERY_ADDR", ":8081") + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + log.Fatalf("DATABASE_URL is required") + } + + db, err := postgres.Open(dsn) + if err != nil { + log.Fatalf("failed to open DB: %v", err) + } + defer func() { _ = db.Close() }() + + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + r.Get("/v1/events", func(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + events, err := db.ListRecentEvents(r.Context(), limit) + if err != nil { + httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"events": events}) + }) + + r.Get("/v1/sessions", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + f := postgres.SessionsFilter{ + Framework: q.Get("framework"), + Host: q.Get("host"), + } + + if v := q.Get("limit"); v != "" { + f.Limit, _ = strconv.Atoi(v) + } + + if v := q.Get("from"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + f.From = &t + } else if t, err := time.Parse("2006-01-02", v); err == nil { + f.From = &t + } + } + + if v := q.Get("to"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + f.To = &t + } else if t, err := time.Parse("2006-01-02", v); err == nil { + end := t.Add(24*time.Hour - time.Nanosecond) + f.To = &end + } + } + + if v := q.Get("cursor"); v != "" { + if t, err := time.Parse(time.RFC3339Nano, v); err == nil { + f.Cursor = &t + } + } + + sessions, nextCursor, err := db.ListSessions(r.Context(), f) + if err != nil { + httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) + return + } + + resp := map[string]any{"sessions": sessions} + if nextCursor != nil { + resp["next_cursor"] = nextCursor.Format(time.RFC3339Nano) + } + httpx.WriteJSON(w, http.StatusOK, resp) + }) + + r.Get("/v1/sessions/{sessionID}", func(w http.ResponseWriter, r *http.Request) { + sessionID := chi.URLParam(r, "sessionID") + session, runs, err := db.GetSessionWithRuns(r.Context(), sessionID) + if err == sql.ErrNoRows { + httpx.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"}) + return + } + if err != nil { + httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"session": session, "runs": runs}) + }) + + r.Get("/v1/runs/{runID}", func(w http.ResponseWriter, r *http.Request) { + runID := chi.URLParam(r, "runID") + run, spans, err := db.GetRunWithSpans(r.Context(), runID) + if err == sql.ErrNoRows { + httpx.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "not_found"}) + return + } + if err != nil { + httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"}) + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"run": run, "spans": spans}) + }) + + log.Printf("query-api listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, r)) +} + +func envDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} +``` + +**Step 2: Build to verify** + +Run: `cd ~/lab/agentmon && go build ./cmd/query-api` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add cmd/query-api/main.go +git commit -m "feat: add sessions and runs endpoints to query-api" +``` + +--- + +## Task 7: Create Static File Structure for Web UI + +**Files:** +- Create: `cmd/web-ui/static/index.html` +- Create: `cmd/web-ui/static/style.css` +- Create: `cmd/web-ui/static/app.js` + +**Step 1: Create index.html** + +```html + + + + + + + agentmon + + + +
+

agentmon

+
+
+

Loading...

+
+ + + +``` + +**Step 2: Create style.css** + +```css +/* cmd/web-ui/static/style.css */ +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #0d1117; + color: #c9d1d9; + line-height: 1.5; +} + +header { + background: #161b22; + padding: 1rem 2rem; + border-bottom: 1px solid #30363d; +} + +header h1 a { + color: #58a6ff; + text-decoration: none; +} + +main { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.back-link { + display: inline-block; + margin-bottom: 1rem; + color: #58a6ff; + text-decoration: none; +} + +.back-link:hover { text-decoration: underline; } + +.page-header { + margin-bottom: 1.5rem; +} + +.page-header h2 { + font-size: 1.5rem; + margin-bottom: 0.5rem; +} + +.meta { color: #8b949e; font-size: 0.9rem; } + +.filters { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.filters label { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.85rem; + color: #8b949e; +} + +.filters input, .filters select { + background: #21262d; + border: 1px solid #30363d; + color: #c9d1d9; + padding: 0.5rem; + border-radius: 4px; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + text-align: left; + padding: 0.75rem 1rem; + border-bottom: 1px solid #21262d; +} + +th { + background: #161b22; + font-weight: 600; + font-size: 0.85rem; + text-transform: uppercase; + color: #8b949e; +} + +tr:hover { background: #161b22; } + +tr.clickable { cursor: pointer; } + +.status-success { color: #3fb950; } +.status-error { color: #f85149; } +.status-unknown { color: #d29922; } + +.load-more { + display: block; + width: 100%; + margin-top: 1rem; + padding: 0.75rem; + background: #21262d; + border: 1px solid #30363d; + color: #c9d1d9; + cursor: pointer; + border-radius: 4px; +} + +.load-more:hover { background: #30363d; } + +.expandable { cursor: pointer; } +.expand-icon { margin-right: 0.5rem; } +.span-details { + background: #161b22; + padding: 1rem; + margin: 0.5rem 0; + border-radius: 4px; + font-family: monospace; + font-size: 0.85rem; + white-space: pre-wrap; + word-break: break-all; +} + +.empty-state { + text-align: center; + padding: 3rem; + color: #8b949e; +} +``` + +**Step 3: Create app.js** + +```javascript +// cmd/web-ui/static/app.js +(function() { + const app = document.getElementById('app'); + + // Router + function route() { + const path = window.location.pathname; + + if (path === '/' || path === '/sessions') { + renderSessions(); + } else if (path.startsWith('/sessions/')) { + const sessionID = path.split('/sessions/')[1]; + renderSession(sessionID); + } else if (path.startsWith('/runs/')) { + const runID = path.split('/runs/')[1]; + renderRun(runID); + } else { + app.innerHTML = '

Page not found

'; + } + } + + function navigate(path) { + history.pushState(null, '', path); + route(); + } + + window.addEventListener('popstate', route); + + // API helpers + async function api(path) { + const resp = await fetch('/api' + path); + if (!resp.ok) throw new Error('API error'); + return resp.json(); + } + + function relativeTime(ts) { + const now = Date.now(); + const then = new Date(ts).getTime(); + const diff = now - then; + + if (diff < 60000) return 'just now'; + if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago'; + if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago'; + return Math.floor(diff / 86400000) + 'd ago'; + } + + function formatDuration(ms) { + if (!ms) return '-'; + if (ms < 1000) return ms + 'ms'; + if (ms < 60000) return (ms / 1000).toFixed(1) + 's'; + return (ms / 60000).toFixed(1) + 'm'; + } + + function statusIcon(status) { + if (status === 'success') return ''; + if (status === 'error') return ''; + return ''; + } + + // Sessions list + let sessionsState = { sessions: [], cursor: null, filters: {} }; + + async function renderSessions() { + app.innerHTML = ` +
+ + + + +
+ + + + + + + + + + + +
SessionFrameworkHostRunsTime
+ + `; + + // Bind filter events + ['from', 'to', 'framework', 'host'].forEach(f => { + document.getElementById('filter-' + f).addEventListener('change', () => { + sessionsState.sessions = []; + sessionsState.cursor = null; + loadSessions(); + }); + }); + + document.getElementById('load-more').addEventListener('click', loadSessions); + + sessionsState = { sessions: [], cursor: null }; + await loadSessions(); + } + + async function loadSessions() { + const params = new URLSearchParams(); + const from = document.getElementById('filter-from').value; + const to = document.getElementById('filter-to').value; + const framework = document.getElementById('filter-framework').value; + const host = document.getElementById('filter-host').value; + + if (from) params.set('from', from); + if (to) params.set('to', to); + if (framework) params.set('framework', framework); + if (host) params.set('host', host); + if (sessionsState.cursor) params.set('cursor', sessionsState.cursor); + + const data = await api('/v1/sessions?' + params.toString()); + sessionsState.sessions = sessionsState.sessions.concat(data.sessions || []); + sessionsState.cursor = data.next_cursor; + + const tbody = document.getElementById('sessions-body'); + tbody.innerHTML = sessionsState.sessions.map(s => ` + + ${s.session_id.substring(0, 12)}... + ${s.framework || '-'} + ${s.host || '-'} + ${s.run_count} + ${relativeTime(s.started_at)} + + `).join('') || 'No sessions found'; + + tbody.querySelectorAll('tr.clickable').forEach(row => { + row.addEventListener('click', () => navigate('/sessions/' + row.dataset.session)); + }); + + document.getElementById('load-more').style.display = sessionsState.cursor ? 'block' : 'none'; + } + + // Session detail + async function renderSession(sessionID) { + const data = await api('/v1/sessions/' + sessionID); + const s = data.session; + const runs = data.runs || []; + + const duration = s.ended_at + ? formatDuration(new Date(s.ended_at) - new Date(s.started_at)) + : 'ongoing'; + + app.innerHTML = ` + ← Back to Sessions + +

Runs (${runs.length})

+ + + + + + + + + + + + ${runs.map(r => { + const dur = r.ended_at + ? formatDuration(new Date(r.ended_at) - new Date(r.started_at)) + : '-'; + return ` + + + + + + + + `; + }).join('') || ''} + +
Run IDStatusSpansDurationStarted
${r.run_id.substring(0, 12)}...${statusIcon(r.status)} ${r.status}${r.span_count}${dur}${new Date(r.started_at).toLocaleTimeString()}
No runs
+ `; + + document.querySelectorAll('tr.clickable').forEach(row => { + row.addEventListener('click', () => navigate('/runs/' + row.dataset.run)); + }); + + document.querySelector('.back-link').addEventListener('click', e => { + e.preventDefault(); + navigate('/sessions'); + }); + } + + // Run detail + async function renderRun(runID) { + const data = await api('/v1/runs/' + runID); + const r = data.run; + const spans = data.spans || []; + + const duration = r.ended_at + ? formatDuration(new Date(r.ended_at) - new Date(r.started_at)) + : 'ongoing'; + + app.innerHTML = ` + ← Back to Session + +

Spans (${spans.length})

+ + + + + + + + + + + ${spans.map((sp, i) => ` + + + + + + + + + + `).join('') || ''} + +
NameKindStatusDuration
No spans
+ `; + + document.querySelectorAll('tr.expandable').forEach(row => { + row.addEventListener('click', () => { + const idx = row.dataset.index; + const detailRow = document.querySelector(`tr.span-detail-row[data-index="${idx}"]`); + const icon = row.querySelector('.expand-icon'); + if (detailRow.style.display === 'none') { + detailRow.style.display = 'table-row'; + icon.innerHTML = '▼'; + } else { + detailRow.style.display = 'none'; + icon.innerHTML = '▶'; + } + }); + }); + + document.querySelector('.back-link').addEventListener('click', e => { + e.preventDefault(); + navigate('/sessions/' + r.session_id); + }); + } + + // Start + route(); +})(); +``` + +**Step 4: Create static directory** + +Run: `mkdir -p ~/lab/agentmon/cmd/web-ui/static` + +**Step 5: Commit** + +```bash +git add cmd/web-ui/static/ +git commit -m "feat: add static frontend files" +``` + +--- + +## Task 8: Update Web UI to Serve Static Files + +**Files:** +- Modify: `cmd/web-ui/main.go` + +**Step 1: Rewrite main.go to serve static + SPA routing + API proxy** + +```go +package main + +import ( + "embed" + "io" + "io/fs" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "strings" +) + +//go:embed static +var staticFiles embed.FS + +func main() { + addr := envDefault("AGENTMON_UI_ADDR", ":8082") + queryAPIBase := envDefault("AGENTMON_QUERY_BASE", "http://query-api") + + mux := http.NewServeMux() + + // Health check + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + + // API proxy to query-api + queryURL, _ := url.Parse(queryAPIBase) + proxy := httputil.NewSingleHostReverseProxy(queryURL) + mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api") + r.Host = queryURL.Host + proxy.ServeHTTP(w, r) + }) + + // Static files + staticFS, _ := fs.Sub(staticFiles, "static") + fileServer := http.FileServer(http.FS(staticFS)) + + mux.HandleFunc("/static/", func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = strings.TrimPrefix(r.URL.Path, "/static") + fileServer.ServeHTTP(w, r) + }) + + // SPA catch-all: serve index.html for all other routes + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // Try to serve static file first + if r.URL.Path != "/" && !strings.HasPrefix(r.URL.Path, "/sessions") && !strings.HasPrefix(r.URL.Path, "/runs") { + http.NotFound(w, r) + return + } + + f, err := staticFiles.Open("static/index.html") + if err != nil { + http.Error(w, "index.html not found", http.StatusInternalServerError) + return + } + defer f.Close() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.Copy(w, f) + }) + + log.Printf("web-ui listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func envDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} +``` + +**Step 2: Build to verify** + +Run: `cd ~/lab/agentmon && go build ./cmd/web-ui` +Expected: Build succeeds + +**Step 3: Commit** + +```bash +git add cmd/web-ui/main.go +git commit -m "feat: serve static files with SPA routing and API proxy" +``` + +--- + +## Task 9: Build and Push Images + +**Files:** +- None (uses existing build scripts) + +**Step 1: Generate new image tag** + +Run: `export TAG=dev-$(date +%Y%m%d-%H%M)` + +**Step 2: Build all images** + +Run: `cd ~/lab/agentmon && ./build/build-images.sh $TAG` +Expected: All 4 images build successfully + +**Step 3: Push images** + +Run: `cd ~/lab/agentmon && docker push gitea-http.taildb3494.ts.net/will/agentmon/ingest-gateway:$TAG && docker push gitea-http.taildb3494.ts.net/will/agentmon/event-processor:$TAG && docker push gitea-http.taildb3494.ts.net/will/agentmon/query-api:$TAG && docker push gitea-http.taildb3494.ts.net/will/agentmon/web-ui:$TAG` + +**Step 4: Commit tag reference** + +```bash +git add -A +git commit -m "chore: build images with tag $TAG" +``` + +--- + +## Task 10: Update K8s Manifests and Deploy + +**Files:** +- Modify: `deploy/k8s/base/agentmon.yaml` + +**Step 1: Update image tags in agentmon.yaml** + +Replace all image tags with the new `$TAG` value. + +**Step 2: Apply to cluster** + +Run: `kubectl apply -k ~/lab/agentmon/deploy/k8s/base/` + +**Step 3: Wait for rollout** + +Run: `kubectl -n agentmon rollout status deploy/ingest-gateway deploy/event-processor deploy/query-api deploy/web-ui` + +**Step 4: Verify endpoints** + +Run: +```bash +curl -s http://web-ui.agentmon.192.168.153.240.nip.io/healthz +curl -s http://web-ui.agentmon.192.168.153.240.nip.io/api/v1/sessions +``` + +**Step 5: Commit and push** + +```bash +git add deploy/k8s/base/agentmon.yaml +git commit -m "chore: deploy with tag $TAG" +git push -u origin feature/ui-query-validation +``` + +--- + +## Summary + +| Task | Description | +|------|-------------| +| 1 | Add event validation (validate.go + tests) | +| 2 | Wire validation into ingest-gateway | +| 3 | Add sessions list query | +| 4 | Add session detail query (runs) | +| 5 | Add run detail query (spans) | +| 6 | Add query-api endpoints | +| 7 | Create static frontend files | +| 8 | Update web-ui to serve SPA | +| 9 | Build and push images | +| 10 | Deploy to cluster |