10 tasks covering validation, query endpoints, and frontend. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
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">✓</span>';
if (status === 'error') return '<span class="status-error">✗</span>';
return '<span class="status-unknown">●</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">← 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()} •
Framework: ${s.framework || '-'} •
Host: ${s.host || '-'} •
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">← 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()} •
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">▶</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 = '▼';
} 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
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 |