feat: add static frontend with SPA routing
- Sessions list with filters (time, framework, host) - Session detail with runs table - Run detail with expandable spans - Dark theme GitHub-style UI - API proxy to query-api via /api Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+42
-22
@@ -1,47 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
resp, err := http.Get(queryAPIBase + "/v1/events?limit=100")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("query-api unreachable"))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
// 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)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Static files
|
||||
staticFS, _ := fs.Sub(staticFiles, "static")
|
||||
fileServer := http.FileServer(http.FS(staticFS))
|
||||
|
||||
payload, _ := json.Marshal(map[string]any{"query_api": queryAPIBase})
|
||||
_, _ = w.Write([]byte("<html><body><h1>agentmon</h1><p>Recent events:</p><pre id='out'>loading...</pre>"))
|
||||
_, _ = w.Write([]byte("<script>\n"))
|
||||
_, _ = w.Write([]byte("const cfg=" + string(payload) + ";\n"))
|
||||
_, _ = w.Write([]byte("fetch('/api/events').then(r=>r.json()).then(j=>{document.getElementById('out').textContent=JSON.stringify(j,null,2);}).catch(e=>{document.getElementById('out').textContent=String(e);});\n"))
|
||||
_, _ = w.Write([]byte("</script></body></html>"))
|
||||
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) {
|
||||
// Serve index.html for SPA routes
|
||||
if r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/sessions") || strings.HasPrefix(r.URL.Path, "/runs") {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
log.Printf("web-ui listening on %s", addr)
|
||||
|
||||
Reference in New Issue
Block a user