Files
agentmon/docs/plans/2026-01-17-ui-query-validation-plan.md
William Valentin 4456997216 docs: add UI, query API, validation implementation plan
10 tasks covering validation, query endpoints, and frontend.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 01:48:53 -08:00

35 KiB

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

// 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

// 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

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:

// 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):

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

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

// 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

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

// 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

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:

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

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:

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

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

<!-- cmd/web-ui/static/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>agentmon</title>
  <link rel="stylesheet" href="/static/style.css">
</head>
<body>
  <header>
    <h1><a href="/sessions">agentmon</a></h1>
  </header>
  <main id="app">
    <p>Loading...</p>
  </main>
  <script src="/static/app.js"></script>
</body>
</html>

Step 2: Create style.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

// 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 = '<p>Page not found</p>';
    }
  }

  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 '<span class="status-success">&#10003;</span>';
    if (status === 'error') return '<span class="status-error">&#10007;</span>';
    return '<span class="status-unknown">&#9679;</span>';
  }

  // Sessions list
  let sessionsState = { sessions: [], cursor: null, filters: {} };

  async function renderSessions() {
    app.innerHTML = `
      <div class="filters">
        <label>From <input type="date" id="filter-from"></label>
        <label>To <input type="date" id="filter-to"></label>
        <label>Framework
          <select id="filter-framework">
            <option value="">All</option>
            <option value="claude-code">claude-code</option>
            <option value="opencode">opencode</option>
          </select>
        </label>
        <label>Host <input type="text" id="filter-host" placeholder="hostname"></label>
      </div>
      <table>
        <thead>
          <tr>
            <th>Session</th>
            <th>Framework</th>
            <th>Host</th>
            <th>Runs</th>
            <th>Time</th>
          </tr>
        </thead>
        <tbody id="sessions-body"></tbody>
      </table>
      <button id="load-more" class="load-more" style="display:none">Load more</button>
    `;

    // 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 => `
      <tr class="clickable" data-session="${s.session_id}">
        <td>${s.session_id.substring(0, 12)}...</td>
        <td>${s.framework || '-'}</td>
        <td>${s.host || '-'}</td>
        <td>${s.run_count}</td>
        <td title="${s.started_at}">${relativeTime(s.started_at)}</td>
      </tr>
    `).join('') || '<tr><td colspan="5" class="empty-state">No sessions found</td></tr>';

    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 = `
      <a href="/sessions" class="back-link">&larr; Back to Sessions</a>
      <div class="page-header">
        <h2>Session ${sessionID.substring(0, 16)}...</h2>
        <p class="meta">
          Started: ${new Date(s.started_at).toLocaleString()} &bull;
          Framework: ${s.framework || '-'} &bull;
          Host: ${s.host || '-'} &bull;
          Duration: ${duration}
        </p>
      </div>
      <h3>Runs (${runs.length})</h3>
      <table>
        <thead>
          <tr>
            <th>Run ID</th>
            <th>Status</th>
            <th>Spans</th>
            <th>Duration</th>
            <th>Started</th>
          </tr>
        </thead>
        <tbody>
          ${runs.map(r => {
            const dur = r.ended_at
              ? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
              : '-';
            return `
              <tr class="clickable" data-run="${r.run_id}">
                <td>${r.run_id.substring(0, 12)}...</td>
                <td>${statusIcon(r.status)} ${r.status}</td>
                <td>${r.span_count}</td>
                <td>${dur}</td>
                <td>${new Date(r.started_at).toLocaleTimeString()}</td>
              </tr>
            `;
          }).join('') || '<tr><td colspan="5" class="empty-state">No runs</td></tr>'}
        </tbody>
      </table>
    `;

    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 = `
      <a href="/sessions/${r.session_id}" class="back-link">&larr; Back to Session</a>
      <div class="page-header">
        <h2>Run ${runID.substring(0, 16)}... ${statusIcon(r.status)}</h2>
        <p class="meta">
          Started: ${new Date(r.started_at).toLocaleString()} &bull;
          Duration: ${duration}
        </p>
      </div>
      <h3>Spans (${spans.length})</h3>
      <table>
        <thead>
          <tr>
            <th>Name</th>
            <th>Kind</th>
            <th>Status</th>
            <th>Duration</th>
          </tr>
        </thead>
        <tbody id="spans-body">
          ${spans.map((sp, i) => `
            <tr class="expandable" data-index="${i}">
              <td><span class="expand-icon">&#9654;</span>${sp.name}</td>
              <td>${sp.kind}</td>
              <td>${statusIcon(sp.status)}</td>
              <td>${formatDuration(sp.duration_ms)}</td>
            </tr>
            <tr class="span-detail-row" data-index="${i}" style="display:none">
              <td colspan="4">
                <div class="span-details">${JSON.stringify(sp.payload, null, 2)}</div>
              </td>
            </tr>
          `).join('') || '<tr><td colspan="4" class="empty-state">No spans</td></tr>'}
        </tbody>
      </table>
    `;

    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 = '&#9660;';
        } else {
          detailRow.style.display = 'none';
          icon.innerHTML = '&#9654;';
        }
      });
    });

    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

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

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

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

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:

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

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