4456997216
10 tasks covering validation, query endpoints, and frontend. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1471 lines
35 KiB
Markdown
1471 lines
35 KiB
Markdown
# 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
|
|
<!-- 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**
|
|
|
|
```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 = '<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**
|
|
|
|
```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 |
|