diff --git a/cmd/web-ui/main.go b/cmd/web-ui/main.go index 90416c0..893dc32 100644 --- a/cmd/web-ui/main.go +++ b/cmd/web-ui/main.go @@ -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("

agentmon

Recent events:

loading...
")) - _, _ = w.Write([]byte("")) + 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) diff --git a/cmd/web-ui/static/app.js b/cmd/web-ui/static/app.js new file mode 100644 index 0000000..1aa36d9 --- /dev/null +++ b/cmd/web-ui/static/app.js @@ -0,0 +1,272 @@ +(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 = '

Page not found

'; + } + } + + 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 ''; + if (status === 'error') return ''; + return ''; + } + + // Sessions list + let sessionsState = { sessions: [], cursor: null, filters: {} }; + + async function renderSessions() { + app.innerHTML = ` +
+ + + + +
+ + + + + + + + + + + +
SessionFrameworkHostRunsTime
+ + `; + + // 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 => ` + + ${s.session_id.substring(0, 12)}... + ${s.framework || '-'} + ${s.host || '-'} + ${s.run_count} + ${relativeTime(s.started_at)} + + `).join('') || 'No sessions found'; + + 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 = ` + ← Back to Sessions + +

Runs (${runs.length})

+ + + + + + + + + + + + ${runs.map(r => { + const dur = r.ended_at + ? formatDuration(new Date(r.ended_at) - new Date(r.started_at)) + : '-'; + return ` + + + + + + + + `; + }).join('') || ''} + +
Run IDStatusSpansDurationStarted
${r.run_id.substring(0, 12)}...${statusIcon(r.status)} ${r.status}${r.span_count}${dur}${new Date(r.started_at).toLocaleTimeString()}
No runs
+ `; + + 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 = ` + ← Back to Session + +

Spans (${spans.length})

+ + + + + + + + + + + ${spans.map((sp, i) => ` + + + + + + + + + + `).join('') || ''} + +
NameKindStatusDuration
No spans
+ `; + + 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(); +})(); diff --git a/cmd/web-ui/static/index.html b/cmd/web-ui/static/index.html new file mode 100644 index 0000000..ca2de47 --- /dev/null +++ b/cmd/web-ui/static/index.html @@ -0,0 +1,18 @@ + + + + + + agentmon + + + +
+

agentmon

+
+
+

Loading...

+
+ + + diff --git a/cmd/web-ui/static/style.css b/cmd/web-ui/static/style.css new file mode 100644 index 0000000..3fa3c38 --- /dev/null +++ b/cmd/web-ui/static/style.css @@ -0,0 +1,128 @@ +* { 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; +}