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
+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;
},
};