feat(gateway): add web UI with dashboard and chat interface

Refactor GatewayServer to serve HTTP and WebSocket on a shared
http.Server. Add static file serving with path traversal protection,
a dark-themed dashboard (system health, sessions, tools) and a
WebSocket chat interface with streaming tool events and markdown
rendering.
This commit is contained in:
William Valentin
2026-02-05 19:39:53 -08:00
parent f30a8bc318
commit 282a15d2b9
7 changed files with 1244 additions and 18 deletions
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flynn Dashboard</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container" style="height:100vh;overflow-y:auto">
<header>
<h1>Flynn Dashboard</h1>
<span id="status">Connecting...</span>
<a href="/chat.html">Chat</a>
</header>
<section class="dashboard" id="health">
<!-- Health cards rendered by JS -->
</section>
<section>
<h2>Sessions</h2>
<div id="sessions" class="session-list">Loading...</div>
</section>
<section>
<h2>Tools</h2>
<div id="tools" class="tool-list">Loading...</div>
</section>
</div>
<script>
// --- Request ID counter and pending callback map ---
let requestId = 0;
const pending = new Map();
// --- WebSocket connection ---
const ws = new WebSocket(`ws://${location.host}`);
const statusEl = document.getElementById('status');
const healthEl = document.getElementById('health');
const sessionsEl = document.getElementById('sessions');
const toolsEl = document.getElementById('tools');
ws.addEventListener('open', () => {
statusEl.textContent = 'Connected';
statusEl.className = 'status-ok';
// Fetch all data on connect
fetchHealth();
fetchSessions();
fetchTools();
});
ws.addEventListener('close', () => {
statusEl.textContent = 'Disconnected';
statusEl.className = 'status-error';
});
ws.addEventListener('error', () => {
statusEl.textContent = 'Connection error';
statusEl.className = 'status-error';
});
ws.addEventListener('message', (event) => {
try {
const msg = JSON.parse(event.data);
// Match response to pending request by ID
if (msg.id != null && pending.has(msg.id)) {
const callback = pending.get(msg.id);
pending.delete(msg.id);
if (msg.result) {
callback(null, msg.result);
} else if (msg.error) {
callback(msg.error, null);
}
}
} catch {
// Ignore malformed messages
}
});
// --- Auto-refresh health every 10 seconds ---
setInterval(fetchHealth, 10000);
// --- RPC helper: send a JSON-RPC request and register a callback ---
function rpcCall(method, params, callback) {
requestId++;
const id = requestId;
pending.set(id, callback);
const message = { id, method };
if (params) {
message.params = params;
}
ws.send(JSON.stringify(message));
}
// --- Uptime formatting: converts seconds to human-readable string ---
function formatUptime(totalSeconds) {
const s = Math.floor(totalSeconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
return `${h}h ${m}m ${sec}s`;
}
// --- Fetch and render system health ---
function fetchHealth() {
rpcCall('system.health', null, (err, result) => {
if (err) {
healthEl.innerHTML = '<div class="card"><h2>Error</h2><div class="value">' +
escapeHtml(err.message || 'Failed to fetch health') + '</div></div>';
return;
}
renderHealth(result);
});
}
function renderHealth(data) {
const isOk = data.status === 'ok';
const statusClass = isOk ? 'status-ok' : 'status-error';
const statusLabel = isOk ? '● Healthy' : '● Unhealthy';
healthEl.innerHTML = `
<div class="card">
<h2>Status</h2>
<div class="value ${statusClass}">${statusLabel}</div>
</div>
<div class="card">
<h2>Version</h2>
<div class="value">${escapeHtml(data.version || 'unknown')}</div>
</div>
<div class="card">
<h2>Uptime</h2>
<div class="value">${formatUptime(data.uptime || 0)}</div>
</div>
<div class="card">
<h2>Connections</h2>
<div class="value">${data.connections ?? 0}</div>
</div>
<div class="card">
<h2>Sessions</h2>
<div class="value">${data.sessions ?? 0}</div>
</div>
<div class="card">
<h2>Tools</h2>
<div class="value">${data.tools ?? 0}</div>
</div>
`;
}
// --- Fetch and render sessions list ---
function fetchSessions() {
rpcCall('sessions.list', null, (err, result) => {
if (err) {
sessionsEl.textContent = 'Error: ' + (err.message || 'Failed to fetch sessions');
return;
}
renderSessions(result.sessions || []);
});
}
function renderSessions(sessions) {
if (sessions.length === 0) {
sessionsEl.textContent = 'No active sessions';
return;
}
const rows = sessions.map(s => `
<tr>
<td>${escapeHtml(s.id)}</td>
<td>${s.messageCount ?? 0}</td>
<td>${s.lastActivity ? new Date(s.lastActivity).toLocaleString() : '—'}</td>
</tr>
`).join('');
sessionsEl.innerHTML = `
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Messages</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
}
// --- Fetch and render tools list ---
function fetchTools() {
rpcCall('tools.list', null, (err, result) => {
if (err) {
toolsEl.textContent = 'Error: ' + (err.message || 'Failed to fetch tools');
return;
}
renderTools(result.tools || []);
});
}
function renderTools(tools) {
if (tools.length === 0) {
toolsEl.textContent = 'No tools registered';
return;
}
const items = tools.map(t => `
<div class="tool-item">
<strong>${escapeHtml(t.name)}</strong>
${t.description ? '<span> — ' + escapeHtml(t.description) + '</span>' : ''}
</div>
`).join('');
toolsEl.innerHTML = items;
}
// --- XSS protection: escape HTML entities ---
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML;
}
</script>
</body>
</html>