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:
@@ -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 ? '✓' : '✗';
|
||||
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 ? '✓' : '✗';
|
||||
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 = {};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user