feat: complete agent monitoring - hook, UI, and backend filter

- Add event_type and framework filters to events query endpoint
- Add /agents SPA route to web-ui server
- Add Agents nav link and route in frontend
- Add agents page CSS (timeline, VM pills, stats panel)
- Build VM status strip, activity timeline, and real-time stats
- Add agentmon hook for OpenClaw (HOOK.md + handler.ts)
- Add docker-compose, Dockerfile, and supporting infra files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-03-14 00:26:42 -07:00
parent 1927ec6622
commit 3434db3c59
29 changed files with 6228 additions and 231 deletions
+86 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"os"
"strconv"
"sync"
"time"
"agentmon/internal/httpx"
@@ -13,11 +14,80 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/gorilla/websocket"
"github.com/nats-io/nats.go"
)
var (
wsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
wsClients = make(map[*websocket.Conn]bool)
wsMu sync.RWMutex
natsConn *nats.Conn
)
func subscribeToNATS(nc *nats.Conn) {
topic := envDefault("NATS_TOPIC", "agentmon.events.v1")
sub, err := nc.Subscribe(topic, func(msg *nats.Msg) {
wsMu.RLock()
var stale []*websocket.Conn
for conn := range wsClients {
err := conn.WriteMessage(websocket.TextMessage, msg.Data)
if err != nil {
conn.Close()
stale = append(stale, conn)
}
}
wsMu.RUnlock()
if len(stale) > 0 {
wsMu.Lock()
for _, conn := range stale {
delete(wsClients, conn)
}
wsMu.Unlock()
}
})
if err != nil {
log.Printf("failed to subscribe to NATS: %v", err)
return
}
log.Printf("subscribed to NATS topic: %s", topic)
_ = sub
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
wsMu.Lock()
wsClients[conn] = true
wsMu.Unlock()
log.Printf("WebSocket client connected")
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
wsMu.Lock()
delete(wsClients, conn)
wsMu.Unlock()
log.Printf("WebSocket client disconnected")
}
func main() {
addr := envDefault("AGENTMON_QUERY_ADDR", ":8081")
dsn := os.Getenv("DATABASE_URL")
natsURL := envDefault("NATS_URL", "nats://localhost:4222")
if dsn == "" {
log.Fatalf("DATABASE_URL is required")
}
@@ -28,6 +98,14 @@ func main() {
}
defer func() { _ = db.Close() }()
nc, err := nats.Connect(natsURL)
if err != nil {
log.Printf("warning: failed to connect to NATS: %v", err)
} else {
natsConn = nc
go subscribeToNATS(nc)
}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
@@ -39,9 +117,16 @@ func main() {
_, _ = w.Write([]byte("ok"))
})
r.Get("/v1/ws", wsHandler)
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)
f := postgres.EventsFilter{
Limit: limit,
EventType: r.URL.Query().Get("event_type"),
Framework: r.URL.Query().Get("framework"),
}
events, err := db.ListRecentEvents(r.Context(), f)
if err != nil {
httpx.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "db_error"})
return