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
+195
View File
@@ -0,0 +1,195 @@
/**
* Flynn WebSocket RPC Client
*
* Promise-based JSON-RPC client with auto-reconnect, event streaming,
* and connection lifecycle management.
*/
export class FlynnClient {
constructor(url) {
this._url = url || `ws://${location.host}`;
this._ws = null;
this._requestId = 0;
this._pending = new Map(); // id -> { resolve, reject }
this._listeners = new Map(); // id -> { events: Map<event, callback[]> }
this._reconnectDelay = 1000;
this._maxReconnectDelay = 30000;
this._onStatusChange = null;
this._status = 'disconnected';
this._autoReconnect = true;
}
get status() { return this._status; }
onStatusChange(callback) {
this._onStatusChange = callback;
}
connect() {
this._autoReconnect = true;
this._doConnect();
}
disconnect() {
this._autoReconnect = false;
if (this._ws) {
this._ws.close();
this._ws = null;
}
this._setStatus('disconnected');
}
_doConnect() {
this._setStatus('connecting');
// Build URL with token from URL search params if present
let wsUrl = this._url;
const urlParams = new URLSearchParams(window.location.search);
const token = urlParams.get('token');
if (token) {
const sep = wsUrl.includes('?') ? '&' : '?';
wsUrl = `${wsUrl}${sep}token=${encodeURIComponent(token)}`;
}
this._ws = new WebSocket(wsUrl);
this._ws.onopen = () => {
this._setStatus('connected');
this._reconnectDelay = 1000;
};
this._ws.onmessage = (event) => {
this._handleMessage(event.data);
};
this._ws.onclose = () => {
this._ws = null;
this._setStatus('disconnected');
// Reject all pending requests
for (const [id, pending] of this._pending) {
pending.reject(new Error('WebSocket closed'));
}
this._pending.clear();
this._listeners.clear();
if (this._autoReconnect) {
setTimeout(() => this._doConnect(), this._reconnectDelay);
this._reconnectDelay = Math.min(this._reconnectDelay * 2, this._maxReconnectDelay);
}
};
this._ws.onerror = () => {
// Error is always followed by close
};
}
_setStatus(status) {
if (this._status !== status) {
this._status = status;
this._onStatusChange?.(status);
}
}
_handleMessage(raw) {
let msg;
try {
msg = JSON.parse(raw);
} catch {
return;
}
// Streamed event (has 'event' field)
if (msg.event && msg.id != null) {
const listener = this._listeners.get(msg.id);
if (listener) {
const callbacks = listener.events.get(msg.event) || [];
for (const cb of callbacks) {
cb(msg.data);
}
// Also fire wildcard listeners
const wildcards = listener.events.get('*') || [];
for (const cb of wildcards) {
cb(msg.event, msg.data);
}
}
return;
}
// Response or error (matches pending request)
if (msg.id != null && this._pending.has(msg.id)) {
const pending = this._pending.get(msg.id);
this._pending.delete(msg.id);
if (msg.error) {
pending.reject(new Error(msg.error.message));
} else {
pending.resolve(msg.result);
}
}
}
/**
* Send an RPC call and return a promise for the result.
* For streaming methods (like agent.send), use stream() instead.
*/
async call(method, params) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected');
}
const id = ++this._requestId;
return new Promise((resolve, reject) => {
this._pending.set(id, { resolve, reject });
this._ws.send(JSON.stringify({ id, method, params }));
});
}
/**
* Send a streaming RPC call. Returns an object with:
* - on(event, callback): listen for streaming events
* - result: promise that resolves when 'done' event fires or rejects on 'error'
*/
stream(method, params) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
throw new Error('Not connected');
}
const id = ++this._requestId;
const events = new Map();
this._listeners.set(id, { events });
const handle = {
on(event, callback) {
if (!events.has(event)) events.set(event, []);
events.get(event).push(callback);
return handle;
},
result: new Promise((resolve, reject) => {
// Auto-wire done/error to resolve/reject the promise
if (!events.has('done')) events.set('done', []);
events.get('done').push((data) => {
this._listeners.delete(id);
resolve(data);
});
if (!events.has('error')) events.set('error', []);
events.get('error').push((data) => {
this._listeners.delete(id);
reject(new Error(data.message || 'Agent error'));
});
}),
};
this._ws.send(JSON.stringify({ id, method, params }));
return handle;
}
}
// Singleton instance
let _instance = null;
export function getClient() {
if (!_instance) {
_instance = new FlynnClient();
_instance.connect();
}
return _instance;
}