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)
|
||||
|
||||
@@ -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 = '<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();
|
||||
})();
|
||||
@@ -0,0 +1,18 @@
|
||||
<!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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user