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