Files
flynn/src/gateway/ui/pages/settings.js
T
2026-02-15 22:03:21 -08:00

197 lines
6.1 KiB
JavaScript

/**
* 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;
let services;
try {
[config, tools, services] = await Promise.all([
_client.call('config.get'),
_client.call('tools.list'),
_client.call('system.services'),
]);
} 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 services list
const serviceList = services?.services ?? [];
_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">Services</h2>
<div class="settings-section">
${serviceList.length > 0 ? `
<div class="services-grid">
${serviceList.map(svc => {
const typeIcon = svc.type === 'channel' ? '📡' : svc.type === 'automation' ? '⚙️' : '🔧';
const statusClass = svc.status === 'connected'
? 'connected'
: svc.status === 'configured'
? 'configured'
: svc.status === 'error'
? 'error'
: svc.status === 'not_configured'
? 'not-configured'
: 'disconnected';
const itemCount = svc.itemCount ? ` (${svc.itemCount})` : '';
return `
<div class="service-card service-${statusClass}">
<span class="service-type-icon">${typeIcon}</span>
<span class="service-name">${escapeHtml(svc.name)}${itemCount}</span>
<span class="service-status">${escapeHtml(svc.status)}</span>
<span class="service-description text-muted text-xs">${escapeHtml(svc.description ?? '')}</span>
</div>
`;
}).join('')}
</div>
` : '<div class="text-muted text-sm">No services found</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 ?? [];
const persisted = result.persisted === true;
const persistError = result.persistError;
if (rejected.length > 0) {
status.textContent = `Partially saved. Rejected: ${rejected.join(', ')}`;
status.className = 'text-sm text-error';
} else if (persistError) {
status.textContent = `Save failed: ${persistError}`;
status.className = 'text-sm text-error';
} else if (!persisted) {
status.textContent = `Saved in runtime only (${applied.length} updated)`;
status.className = 'text-sm text-muted';
} 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;
},
};