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 = `
+
+
+
+
+
+
+
+
+
+ | Session |
+ Framework |
+ Host |
+ Runs |
+ Time |
+
+
+
+
+
+ `;
+
+ // 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})
+
+
+
+ | Run ID |
+ Status |
+ Spans |
+ Duration |
+ Started |
+
+
+
+ ${runs.map(r => {
+ const dur = r.ended_at
+ ? formatDuration(new Date(r.ended_at) - new Date(r.started_at))
+ : '-';
+ return `
+
+ | ${r.run_id.substring(0, 12)}... |
+ ${statusIcon(r.status)} ${r.status} |
+ ${r.span_count} |
+ ${dur} |
+ ${new Date(r.started_at).toLocaleTimeString()} |
+
+ `;
+ }).join('') || '| 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})
+
+
+
+ | Name |
+ Kind |
+ Status |
+ Duration |
+
+
+
+ ${spans.map((sp, i) => `
+
+ | ▶${sp.name} |
+ ${sp.kind} |
+ ${statusIcon(sp.status)} |
+ ${formatDuration(sp.duration_ms)} |
+
+
+ |
+ ${JSON.stringify(sp.payload, null, 2)}
+ |
+
+ `).join('') || '| 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
+
+
+
+
+
+ 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;
+}