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:
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user