feat: add web UI dashboard SPA with dashboard, chat, sessions, and settings pages

- Add SPA shell with hash-based router, sidebar navigation, and WebSocket RPC client
- Add dashboard page with system health cards, channel status, and auto-refresh
- Add chat page with session selector, streaming tool events, and markdown rendering
- Add sessions page with list, history viewer, and delete functionality
- Add settings page with hook pattern editor, tool list, and config viewer
- Add backend handlers: sessions.delete, sessions.switch, system.channels, system.usage
- Wire channelRegistry into gateway server for channel status reporting
- Extend static file server with .mjs, .png, .ico, .woff2 content types
This commit is contained in:
William Valentin
2026-02-07 10:07:45 -08:00
parent f7d889e35e
commit 22230a3e3f
14 changed files with 1836 additions and 207 deletions
+292
View File
@@ -0,0 +1,292 @@
/**
* Flynn Chat Page
*
* Session selector, message input, streaming tool events,
* and markdown-rendered responses.
*/
/* global marked, hljs */
let _currentSession = null;
let _sending = false;
let _elements = {};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderMarkdown(text) {
try {
if (typeof marked !== 'undefined') {
return marked.parse(text);
}
} catch {
// Fall through to plain text
}
return `<p>${escapeHtml(text)}</p>`;
}
function highlightCode() {
if (typeof hljs !== 'undefined') {
document.querySelectorAll('.chat-messages pre code').forEach(block => {
hljs.highlightElement(block);
});
}
}
function createMessageEl(role, content) {
const div = document.createElement('div');
div.className = `message ${role}`;
if (role === 'assistant') {
div.innerHTML = renderMarkdown(content);
setTimeout(highlightCode, 0);
} else {
div.textContent = content;
}
return div;
}
function createToolEventEl(event, data) {
const group = document.createElement('div');
group.className = 'tool-event-group';
const header = document.createElement('div');
header.className = 'tool-event-header';
if (event === 'tool_start') {
header.innerHTML = `<span class="spinner"></span> <strong>${escapeHtml(data.tool)}</strong>`;
} else if (event === 'tool_end') {
const icon = data.result?.success ? '&#10003;' : '&#10007;';
const cls = data.result?.success ? 'status-ok' : 'status-error';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
header.addEventListener('click', () => {
body.classList.toggle('open');
});
const body = document.createElement('div');
body.className = 'tool-event-body';
if (event === 'tool_start' && data.args) {
body.textContent = JSON.stringify(data.args, null, 2);
} else if (event === 'tool_end' && data.result) {
body.textContent = data.result.output || data.result.error || '(no output)';
}
group.appendChild(header);
group.appendChild(body);
return group;
}
function scrollToBottom() {
const msgs = _elements.messages;
if (msgs) {
msgs.scrollTop = msgs.scrollHeight;
}
}
async function loadSessions(client) {
const select = _elements.sessionSelect;
if (!select) return;
try {
const result = await client.call('sessions.list');
const sessions = result.sessions ?? [];
// Preserve current selection
const current = _currentSession;
select.innerHTML = '';
if (sessions.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No sessions';
select.appendChild(opt);
} else {
for (const s of sessions) {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = `${s.id} (${s.messageCount} msgs)`;
if (s.id === current) opt.selected = true;
select.appendChild(opt);
}
}
// Update current session
_currentSession = select.value || null;
} catch {
// Ignore — sessions may not be available
}
}
async function loadHistory(client) {
const msgs = _elements.messages;
if (!msgs || !_currentSession) return;
msgs.innerHTML = '';
try {
const result = await client.call('sessions.history', { sessionId: _currentSession });
const messages = result.messages ?? [];
for (const msg of messages) {
const role = msg.role ?? 'assistant';
const content = msg.content ?? msg.text ?? '';
msgs.appendChild(createMessageEl(role, content));
}
scrollToBottom();
} catch {
msgs.innerHTML = '<div class="empty-state">Could not load history</div>';
}
}
async function sendMessage(client) {
const input = _elements.input;
const text = input?.value?.trim();
if (!text || _sending) return;
_sending = true;
_elements.sendBtn.disabled = true;
input.value = '';
// Show user message
_elements.messages.appendChild(createMessageEl('user', text));
scrollToBottom();
// Create placeholder for assistant response
const placeholder = document.createElement('div');
placeholder.className = 'message assistant streaming-cursor';
placeholder.innerHTML = '<span class="text-muted">Thinking...</span>';
_elements.messages.appendChild(placeholder);
scrollToBottom();
try {
const stream = client.stream('agent.send', { message: text });
stream.on('tool_start', (data) => {
const el = createToolEventEl('tool_start', data);
_elements.messages.insertBefore(el, placeholder);
scrollToBottom();
});
stream.on('tool_end', (data) => {
// Replace the last tool_start spinner with completion marker
const events = _elements.messages.querySelectorAll('.tool-event-group');
const last = events[events.length - 1];
if (last) {
const header = last.querySelector('.tool-event-header');
if (header && data.tool) {
const icon = data.result?.success !== false ? '&#10003;' : '&#10007;';
const cls = data.result?.success !== false ? 'status-ok' : 'status-error';
header.innerHTML = `<span class="${cls}">${icon}</span> <strong>${escapeHtml(data.tool)}</strong>`;
}
// Add result body
const body = last.querySelector('.tool-event-body');
if (body && data.result) {
body.textContent = data.result.output || data.result.error || '(no output)';
}
}
scrollToBottom();
});
const done = await stream.result;
// Replace placeholder with actual response
placeholder.classList.remove('streaming-cursor');
const content = done?.content ?? done?.text ?? '(no response)';
placeholder.innerHTML = renderMarkdown(content);
setTimeout(highlightCode, 0);
} catch (err) {
placeholder.classList.remove('streaming-cursor');
placeholder.className = 'message error';
placeholder.textContent = `Error: ${err.message}`;
} finally {
_sending = false;
if (_elements.sendBtn) _elements.sendBtn.disabled = false;
scrollToBottom();
}
}
export const ChatPage = {
async render(el, client) {
el.innerHTML = `
<div class="chat-layout">
<div class="chat-header">
<select id="chat-session-select"></select>
<button id="chat-new-session" class="btn btn-secondary">+ New Session</button>
<button id="chat-load-history" class="btn btn-secondary">Load History</button>
</div>
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input">
<textarea id="chat-input" placeholder="Type a message..." rows="1"></textarea>
<button id="chat-send" class="btn btn-primary">Send</button>
</div>
</div>
`;
_elements = {
sessionSelect: el.querySelector('#chat-session-select'),
messages: el.querySelector('#chat-messages'),
input: el.querySelector('#chat-input'),
sendBtn: el.querySelector('#chat-send'),
};
// Load sessions into dropdown
await loadSessions(client);
// Event: session change
_elements.sessionSelect.addEventListener('change', () => {
_currentSession = _elements.sessionSelect.value || null;
});
// Event: new session
el.querySelector('#chat-new-session').addEventListener('click', async () => {
try {
const result = await client.call('sessions.create');
_currentSession = result.sessionId;
await loadSessions(client);
_elements.messages.innerHTML = '';
} catch (err) {
_elements.messages.innerHTML = `<div class="empty-state">Failed to create session: ${err.message}</div>`;
}
});
// Event: load history
el.querySelector('#chat-load-history').addEventListener('click', () => {
loadHistory(client);
});
// Event: send message
_elements.sendBtn.addEventListener('click', () => sendMessage(client));
// Event: Enter to send (Shift+Enter for newline)
_elements.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(client);
}
});
// Auto-resize textarea
_elements.input.addEventListener('input', () => {
const ta = _elements.input;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
});
// If there's a current session, show welcome
if (!_currentSession) {
_elements.messages.innerHTML = '<div class="empty-state">Select a session or create a new one to start chatting</div>';
}
},
teardown() {
_currentSession = null;
_sending = false;
_elements = {};
},
};
+110
View File
@@ -0,0 +1,110 @@
/**
* Flynn Dashboard Page
*
* Shows system health cards, channel status, and usage stats.
* Auto-refreshes every 10 seconds.
*/
let _timer = null;
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const parts = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
parts.push(`${s}s`);
return parts.join(' ');
}
async function loadDashboard(el, client) {
let health, channels, usage;
try {
[health, channels, usage] = await Promise.all([
client.call('system.health'),
client.call('system.channels'),
client.call('system.usage'),
]);
} catch (err) {
el.innerHTML = `<div class="empty-state">Failed to load dashboard: ${err.message}</div>`;
return;
}
// Build stats grid
const stats = [
{ label: 'Status', value: health.status?.toUpperCase() ?? 'UNKNOWN', cls: health.status === 'ok' ? 'ok' : 'error' },
{ label: 'Version', value: health.version ?? '-', cls: '' },
{ label: 'Uptime', value: formatUptime(health.uptime ?? 0), cls: '' },
{ label: 'Connections', value: String(health.connections ?? 0), cls: '' },
{ label: 'Sessions', value: String(health.sessions ?? 0), cls: '' },
{ label: 'Tools', value: String(health.tools ?? 0), cls: '' },
];
const statsHtml = stats.map(s =>
`<div class="stat-card">
<div class="stat-label">${s.label}</div>
<div class="stat-value ${s.cls}">${s.value}</div>
</div>`
).join('');
// Build channels grid
const channelList = channels?.channels ?? [];
let channelsHtml = '';
if (channelList.length > 0) {
channelsHtml = channelList.map(ch =>
`<div class="channel-card">
<span class="channel-dot ${ch.status}"></span>
<span class="channel-name">${ch.name}</span>
</div>`
).join('');
} else {
channelsHtml = '<div class="text-muted text-sm">No channels registered</div>';
}
// Build usage section
const usageItems = [
{ label: 'Total Sessions', value: String(usage?.totalSessions ?? 0) },
{ label: 'Active Connections', value: String(usage?.activeConnections ?? 0) },
{ label: 'Available Tools', value: String(usage?.tools ?? 0) },
{ label: 'Uptime', value: formatUptime(usage?.uptime ?? 0) },
];
const usageHtml = usageItems.map(u =>
`<div class="stat-card">
<div class="stat-label">${u.label}</div>
<div class="stat-value">${u.value}</div>
</div>`
).join('');
el.innerHTML = `
<h1 class="page-title">Dashboard</h1>
<h2 class="section-title">System Health</h2>
<div class="stats-grid">${statsHtml}</div>
<h2 class="section-title">Channels</h2>
<div class="channels-grid">${channelsHtml}</div>
<h2 class="section-title">Usage</h2>
<div class="stats-grid">${usageHtml}</div>
`;
}
export const DashboardPage = {
async render(el, client) {
await loadDashboard(el, client);
// Auto-refresh every 10 seconds
_timer = setInterval(() => {
loadDashboard(el, client).catch(() => {});
}, 10000);
},
teardown() {
if (_timer) {
clearInterval(_timer);
_timer = null;
}
},
};
+146
View File
@@ -0,0 +1,146 @@
/**
* Flynn Sessions Page
*
* Lists all sessions, allows viewing history and deleting sessions.
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
let _client = null;
let _el = null;
async function loadSessionList() {
if (!_client || !_el) return;
const listContainer = _el.querySelector('#sessions-list');
const detailContainer = _el.querySelector('#session-detail');
if (detailContainer) detailContainer.innerHTML = '';
try {
const result = await _client.call('sessions.list');
const sessions = result.sessions ?? [];
if (sessions.length === 0) {
listContainer.innerHTML = '<div class="empty-state">No sessions found</div>';
return;
}
let html = `
<table>
<thead>
<tr>
<th>Session ID</th>
<th>Messages</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
for (const s of sessions) {
html += `
<tr>
<td><a href="#" class="session-view-link" data-id="${escapeHtml(s.id)}">${escapeHtml(s.id)}</a></td>
<td>${s.messageCount ?? 0}</td>
<td class="session-actions">
<button class="btn btn-secondary session-view-btn" data-id="${escapeHtml(s.id)}">View</button>
<button class="btn btn-danger session-delete-btn" data-id="${escapeHtml(s.id)}">Delete</button>
</td>
</tr>
`;
}
html += '</tbody></table>';
listContainer.innerHTML = html;
// Bind view buttons
listContainer.querySelectorAll('.session-view-btn, .session-view-link').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
viewSession(btn.dataset.id);
});
});
// Bind delete buttons
listContainer.querySelectorAll('.session-delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
deleteSession(btn.dataset.id);
});
});
} catch (err) {
listContainer.innerHTML = `<div class="empty-state">Failed to load sessions: ${err.message}</div>`;
}
}
async function viewSession(sessionId) {
const detailContainer = _el.querySelector('#session-detail');
if (!detailContainer) return;
detailContainer.innerHTML = '<div class="empty-state"><span class="spinner"></span> Loading...</div>';
try {
const result = await _client.call('sessions.history', { sessionId });
const messages = result.messages ?? [];
let html = `
<div class="session-detail">
<div class="session-detail-header">
<h2 class="section-title">${escapeHtml(sessionId)}</h2>
<span class="text-muted text-sm">${messages.length} messages</span>
</div>
<div class="message-history">
`;
if (messages.length === 0) {
html += '<div class="empty-state">No messages in this session</div>';
} else {
for (const msg of messages) {
const role = msg.role ?? 'system';
const content = msg.content ?? msg.text ?? '';
html += `<div class="message ${escapeHtml(role)}">${escapeHtml(content)}</div>`;
}
}
html += '</div></div>';
detailContainer.innerHTML = html;
} catch (err) {
detailContainer.innerHTML = `<div class="empty-state">Failed to load session: ${err.message}</div>`;
}
}
async function deleteSession(sessionId) {
if (!confirm(`Delete session "${sessionId}"? This will clear all message history.`)) {
return;
}
try {
await _client.call('sessions.delete', { sessionId });
await loadSessionList();
} catch (err) {
alert(`Failed to delete session: ${err.message}`);
}
}
export const SessionsPage = {
async render(el, client) {
_client = client;
_el = el;
el.innerHTML = `
<h1 class="page-title">Sessions</h1>
<div id="sessions-list"></div>
<div id="session-detail"></div>
`;
await loadSessionList();
},
teardown() {
_client = null;
_el = null;
},
};
+172
View File
@@ -0,0 +1,172 @@
/**
* Flynn Settings Page
*
* Read-only config view (redacted), editable hook patterns,
* tool list, and channel overview.
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
let _client = null;
let _el = null;
async function loadSettings() {
if (!_client || !_el) return;
let config, tools, channels;
try {
[config, tools, channels] = await Promise.all([
_client.call('config.get'),
_client.call('tools.list'),
_client.call('system.channels'),
]);
} catch (err) {
_el.innerHTML = `
<h1 class="page-title">Settings</h1>
<div class="empty-state">Failed to load settings: ${err.message}</div>
`;
return;
}
// Extract hooks from config
const hooks = config?.hooks ?? {};
const confirmPatterns = hooks.confirm ?? [];
const logPatterns = hooks.log ?? [];
const silentPatterns = hooks.silent ?? [];
// Build config view (redacted JSON)
const configJson = JSON.stringify(config, null, 2);
// Build tool list
const toolList = tools?.tools ?? [];
// Build channel list
const channelList = channels?.channels ?? [];
_el.innerHTML = `
<h1 class="page-title">Settings</h1>
<h2 class="section-title">Hook Patterns</h2>
<div class="settings-section">
<div class="hook-editor">
<div class="hook-group">
<label>Confirm (require approval)</label>
<textarea id="hooks-confirm" rows="3">${escapeHtml(confirmPatterns.join('\n'))}</textarea>
</div>
<div class="hook-group">
<label>Log (allow + log)</label>
<textarea id="hooks-log" rows="3">${escapeHtml(logPatterns.join('\n'))}</textarea>
</div>
<div class="hook-group">
<label>Silent (allow silently)</label>
<textarea id="hooks-silent" rows="3">${escapeHtml(silentPatterns.join('\n'))}</textarea>
</div>
<div>
<button id="hooks-save" class="btn btn-primary">Save Hook Patterns</button>
<span id="hooks-status" class="text-sm text-muted" style="margin-left: 12px;"></span>
</div>
</div>
</div>
<h2 class="section-title">Tools (${toolList.length})</h2>
<div class="settings-section">
${toolList.length > 0 ? `
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
</tr>
</thead>
<tbody>
${toolList.map(t => `
<tr>
<td><code>${escapeHtml(t.name)}</code></td>
<td class="text-secondary">${escapeHtml(t.description ?? '')}</td>
</tr>
`).join('')}
</tbody>
</table>
` : '<div class="empty-state">No tools available</div>'}
</div>
<h2 class="section-title">Channels</h2>
<div class="settings-section">
${channelList.length > 0 ? `
<div class="channels-grid">
${channelList.map(ch => `
<div class="channel-card">
<span class="channel-dot ${ch.status}"></span>
<span class="channel-name">${escapeHtml(ch.name)}</span>
</div>
`).join('')}
</div>
` : '<div class="text-muted text-sm">No channels registered</div>'}
</div>
<h2 class="section-title">Configuration (read-only)</h2>
<div class="settings-section">
<div class="config-view"><code>${escapeHtml(configJson)}</code></div>
</div>
`;
// Bind save hooks
_el.querySelector('#hooks-save').addEventListener('click', saveHooks);
}
async function saveHooks() {
const status = _el.querySelector('#hooks-status');
status.textContent = 'Saving...';
status.className = 'text-sm text-muted';
try {
const confirm = _el.querySelector('#hooks-confirm').value.split('\n').map(s => s.trim()).filter(Boolean);
const log = _el.querySelector('#hooks-log').value.split('\n').map(s => s.trim()).filter(Boolean);
const silent = _el.querySelector('#hooks-silent').value.split('\n').map(s => s.trim()).filter(Boolean);
const result = await _client.call('config.patch', {
patches: {
'hooks.confirm': confirm,
'hooks.log': log,
'hooks.silent': silent,
},
});
const applied = result.applied ?? [];
const rejected = result.rejected ?? [];
if (rejected.length > 0) {
status.textContent = `Partially saved. Rejected: ${rejected.join(', ')}`;
status.className = 'text-sm text-error';
} else {
status.textContent = `Saved (${applied.length} updated)`;
status.className = 'text-sm text-success';
}
} catch (err) {
status.textContent = `Error: ${err.message}`;
status.className = 'text-sm text-error';
}
// Clear status after 5s
setTimeout(() => {
if (status) status.textContent = '';
}, 5000);
}
export const SettingsPage = {
async render(el, client) {
_client = client;
_el = el;
await loadSettings();
},
teardown() {
_client = null;
_el = null;
},
};