282a15d2b9
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.
220 lines
6.3 KiB
HTML
220 lines
6.3 KiB
HTML
<!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>
|