Add web management front end (served by the supervisor)

Single-page UI styled after simplex-manager/web/index.html (palette, header,
section-tabs, cards). Talks to the supervisor over REST (control) + WebSocket
(/events live stream):
- Profiles tab: create cli/directory/broadcast, list with status, stop
- Console tab: send raw chat commands to a cli profile, see the response
- Events tab: live event feed from all profiles

server.py serves webui/index.html at /. Validated end to end with curl:
GET / , GET /profiles, start-cli, cmd /user (activeUser), stop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-06-04 12:45:49 +01:00
parent 38ff96c576
commit d6041c1048
2 changed files with 308 additions and 1 deletions

299
webui/index.html Normal file
View File

@@ -0,0 +1,299 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleX Orchestrate</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg:#f5f5f7; --card-bg:#fff; --text:#1d1d1f; --muted:#6e6e73;
--accent:#0053D0; --border:#e0e0e5; --shadow:0 20px 30px rgba(0,0,0,0.10);
--green:#20BD3D; --red:#DD0000;
}
@media (prefers-color-scheme: dark) {
:root {
--bg:#111827; --card-bg:#0B2A59; --text:#f5f5f7; --muted:#9ca3af;
--accent:#70F0F9; --border:#1e3a5f; --shadow:none;
}
}
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
background:var(--bg); color:var(--text); min-height:100vh; }
header { background:var(--card-bg); border-bottom:1px solid var(--border);
padding:14px 24px; position:sticky; top:0; z-index:10; }
.header-inner { max-width:900px; margin:0 auto; display:flex; align-items:center; gap:10px; }
.logo-text { font-size:18px; font-weight:700; color:var(--accent); letter-spacing:-0.5px; }
.conn { margin-left:auto; font-size:12px; color:var(--muted); display:flex; align-items:center; gap:6px; }
.dot { width:9px; height:9px; border-radius:50%; background:var(--muted); }
.dot.on { background:var(--green); box-shadow:0 0 6px var(--green); }
.dot.off { background:var(--red); }
.container { max-width:900px; margin:0 auto; padding:28px 20px 60px; }
h1 { font-size:clamp(24px,4vw,32px); font-weight:700; color:var(--accent); margin-bottom:18px; }
.section-tabs { display:flex; border-bottom:2px solid var(--border); margin-bottom:22px; }
.sec-btn { padding:10px 22px; background:none; border:none; border-bottom:3px solid transparent;
margin-bottom:-2px; font:600 15px inherit; color:var(--muted); cursor:pointer;
transition:color .15s,border-color .15s; display:flex; align-items:center; gap:8px; }
.sec-btn:hover { color:var(--accent); }
.sec-btn.active { color:var(--accent); border-bottom-color:var(--accent); }
.tab-count { font-size:11px; font-weight:600; background:var(--border); color:var(--muted);
border-radius:10px; padding:1px 7px; }
.sec-btn.active .tab-count { background:var(--accent); color:#fff; }
@media (prefers-color-scheme: dark){ .sec-btn.active .tab-count { color:#000; } }
.panel { display:none; } .panel.active { display:block; }
.card { background:var(--card-bg); border:1px solid var(--border); border-radius:14px;
padding:18px; margin-bottom:16px; box-shadow:var(--shadow); }
.card h2 { font-size:16px; margin-bottom:14px; }
label { display:block; font-size:13px; font-weight:600; color:var(--muted); margin-bottom:4px; }
input, select, textarea { width:100%; padding:9px 12px; font:14px inherit; color:var(--text);
background:var(--bg); border:1px solid var(--border); border-radius:9px; outline:none; }
input:focus, select:focus, textarea:focus { border-color:var(--accent); }
.field { margin-bottom:12px; }
.row { display:flex; gap:10px; flex-wrap:wrap; }
.row > * { flex:1; min-width:140px; }
.btn { padding:9px 18px; border:none; border-radius:9px; font:600 14px inherit; cursor:pointer;
color:#fff; background:var(--accent); }
@media (prefers-color-scheme: dark){ .btn { color:#000; } }
.btn:hover { opacity:.88; }
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); }
.btn-danger { background:var(--red); color:#fff; }
.btn-sm { padding:6px 13px; font-size:13px; }
.prof { display:flex; align-items:center; gap:12px; }
.prof .avatar { width:42px; height:42px; border-radius:50%; flex-shrink:0; display:flex;
align-items:center; justify-content:center; font-weight:700; font-size:18px;
background:var(--border); color:var(--muted); }
.prof .meta { min-width:0; flex:1; }
.prof .name { font-weight:700; }
.tag { display:inline-block; font-size:11px; font-weight:600; padding:2px 8px; border-radius:8px;
background:var(--border); color:var(--muted); }
.badge { font-size:12px; font-weight:600; padding:2px 9px; border-radius:10px; }
.badge.run { background:#d1fae5; color:#065f46; } .badge.stop { background:#fee2e2; color:#991b1b; }
@media (prefers-color-scheme: dark){ .badge.run{background:#064e3b;color:#6ee7b7;} .badge.stop{background:#7f1d1d;color:#fca5a5;} }
.muted { color:var(--muted); font-size:13px; }
.empty { text-align:center; color:var(--muted); padding:40px; }
.log { background:#0a0a0f; color:#70F0F9; border-radius:10px; padding:12px; font:12px monospace;
height:360px; overflow-y:auto; white-space:pre-wrap; }
.log .ev-prof { color:#ffd166; } .log .ev-type { color:#9af0a0; }
pre.resp { background:var(--bg); border:1px solid var(--border); border-radius:9px; padding:12px;
font:12px monospace; max-height:240px; overflow:auto; white-space:pre-wrap; margin-top:10px; }
</style>
</head>
<body>
<header>
<div class="header-inner">
<span class="logo-text">◆ SimpleX Orchestrate</span>
<span class="conn"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting…</span></span>
</div>
</header>
<div class="container">
<h1>Manager</h1>
<div class="section-tabs">
<button class="sec-btn active" data-tab="profiles">Profiles <span class="tab-count" id="count-prof">0</span></button>
<button class="sec-btn" data-tab="console">Console</button>
<button class="sec-btn" data-tab="events">Events <span class="tab-count" id="count-ev">0</span></button>
</div>
<!-- ── Profiles ─────────────────────────────────────────── -->
<div class="panel active" id="tab-profiles">
<div class="card">
<h2>New profile</h2>
<div class="field">
<label>Kind</label>
<select id="new-kind" onchange="onKind()">
<option value="cli">CLI account / custom bot (WebSocket-driven)</option>
<option value="directory">Directory service (autonomous)</option>
<option value="broadcast">Broadcast bot (autonomous)</option>
</select>
</div>
<div class="field"><label>Name</label><input id="new-name" placeholder="alice"></div>
<div id="f-directory" style="display:none;">
<div class="field"><label>Super-users (CONTACT_ID:NAME, …)</label><input id="new-super" placeholder="1:admin"></div>
<div class="field"><label>Web folder</label><input id="new-web" placeholder="web/mydir"></div>
</div>
<div id="f-broadcast" style="display:none;">
<div class="field"><label>Display name</label><input id="new-disp" placeholder="Status"></div>
<div class="field"><label>Publishers (CONTACT_ID:NAME, …)</label><input id="new-pub" placeholder="1:admin"></div>
</div>
<button class="btn" onclick="createProfile()">Start</button>
<span id="new-result" class="muted" style="margin-left:10px;"></span>
</div>
<div id="prof-list"></div>
</div>
<!-- ── Console ──────────────────────────────────────────── -->
<div class="panel" id="tab-console">
<div class="card">
<h2>Send command (cli profiles)</h2>
<div class="row" style="margin-bottom:10px;">
<select id="con-prof"></select>
<input id="con-cmd" placeholder="/user or /_groups 1" style="flex:3;"
onkeydown="if(event.key==='Enter')sendConsole()">
<button class="btn" style="flex:0 0 auto;" onclick="sendConsole()">Send</button>
</div>
<p class="muted">Raw chat commands — same as the app's Chat Console (e.g. <code>/user</code>, <code>/_contacts 1</code>, <code>/_groups 1</code>).</p>
<pre class="resp" id="con-resp" style="display:none;"></pre>
</div>
</div>
<!-- ── Events ───────────────────────────────────────────── -->
<div class="panel" id="tab-events">
<div class="card">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;">
<h2 style="margin:0;">Live events</h2>
<button class="btn btn-ghost btn-sm" onclick="clearEvents()">Clear</button>
</div>
<div class="log" id="ev-log"></div>
</div>
</div>
</div>
<script>
const API = location.origin;
let profiles = [];
let evCount = 0;
// ── tabs ──────────────────────────────────────────────────────
document.querySelectorAll('.sec-btn').forEach(b => b.onclick = () => {
document.querySelectorAll('.sec-btn').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.panel').forEach(x => x.classList.remove('active'));
b.classList.add('active');
document.getElementById('tab-' + b.dataset.tab).classList.add('active');
});
function onKind() {
const k = document.getElementById('new-kind').value;
document.getElementById('f-directory').style.display = k === 'directory' ? 'block' : 'none';
document.getElementById('f-broadcast').style.display = k === 'broadcast' ? 'block' : 'none';
}
// ── REST helpers ──────────────────────────────────────────────
async function api(path, opts) {
const r = await fetch(API + path, opts);
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
return r.json();
}
async function refresh() {
try {
const d = await api('/profiles');
profiles = d.profiles || [];
renderProfiles();
} catch (e) { /* server down */ }
}
function renderProfiles() {
document.getElementById('count-prof').textContent = profiles.length;
const el = document.getElementById('prof-list');
if (!profiles.length) { el.innerHTML = '<div class="card empty">No profiles running. Create one above.</div>'; }
else {
el.innerHTML = profiles.map(p => `
<div class="card">
<div class="prof">
<div class="avatar">${(p.name[0] || '?').toUpperCase()}</div>
<div class="meta">
<div class="name">${esc(p.name)} <span class="tag">${p.kind}</span></div>
<div class="muted">${p.port ? 'ws port ' + p.port : 'autonomous'} ·
<span class="badge ${p.running ? 'run' : 'stop'}">${p.running ? 'running' : 'stopped'}</span></div>
</div>
<button class="btn btn-danger btn-sm" onclick="stopProfile('${esc(p.name)}')">Stop</button>
</div>
</div>`).join('');
}
// console profile dropdown (cli only)
const sel = document.getElementById('con-prof');
const cur = sel.value;
const cli = profiles.filter(p => p.kind === 'cli');
sel.innerHTML = cli.map(p => `<option>${esc(p.name)}</option>`).join('') || '<option disabled>no cli profiles</option>';
if (cli.some(p => p.name === cur)) sel.value = cur;
}
async function createProfile() {
const kind = document.getElementById('new-kind').value;
const name = document.getElementById('new-name').value.trim();
const res = document.getElementById('new-result');
if (!name) { res.textContent = 'name required'; return; }
res.textContent = 'starting…';
try {
if (kind === 'cli') {
await api(`/profiles/${name}/start-cli`, { method: 'POST' });
} else if (kind === 'directory') {
await api(`/profiles/${name}/start-directory`, post({
super_users: document.getElementById('new-super').value.trim(),
web_folder: document.getElementById('new-web').value.trim(),
}));
} else {
await api(`/profiles/${name}/start-broadcast`, post({
display_name: document.getElementById('new-disp').value.trim(),
publishers: document.getElementById('new-pub').value.trim(),
}));
}
res.textContent = '✓ started';
document.getElementById('new-name').value = '';
refresh();
} catch (e) { res.textContent = '✗ ' + e.message; }
setTimeout(() => res.textContent = '', 2500);
}
async function stopProfile(name) {
if (!confirm('Stop "' + name + '"?')) return;
try { await api(`/profiles/${name}/stop`, { method: 'POST' }); refresh(); }
catch (e) { alert('Failed: ' + e.message); }
}
async function sendConsole() {
const name = document.getElementById('con-prof').value;
const cmd = document.getElementById('con-cmd').value.trim();
if (!name || !cmd) return;
const out = document.getElementById('con-resp');
out.style.display = 'block'; out.textContent = 'sending…';
try {
const d = await api(`/profiles/${name}/cmd`, post({ cmd }));
out.textContent = JSON.stringify(d.resp, null, 2);
} catch (e) { out.textContent = '✗ ' + e.message; }
}
function post(body) {
return { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
}
function esc(s) { return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }
// ── live events over WebSocket ────────────────────────────────
function clearEvents() { document.getElementById('ev-log').innerHTML = ''; evCount = 0; document.getElementById('count-ev').textContent = 0; }
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/events`);
const dot = document.getElementById('conn-dot'), txt = document.getElementById('conn-text');
ws.onopen = () => { dot.className = 'dot on'; txt.textContent = 'connected'; };
ws.onclose = () => { dot.className = 'dot off'; txt.textContent = 'disconnected — retrying'; setTimeout(connectWS, 2000); };
ws.onmessage = (m) => {
let d; try { d = JSON.parse(m.data); } catch { return; }
const t = (d.event && d.event.type) || '?';
const log = document.getElementById('ev-log');
const line = document.createElement('div');
const ts = new Date().toLocaleTimeString();
line.innerHTML = `${ts} <span class="ev-prof">[${esc(d.profile)}]</span> <span class="ev-type">${esc(t)}</span>`;
log.prepend(line);
while (log.childElementCount > 300) log.lastChild.remove();
evCount++; document.getElementById('count-ev').textContent = evCount;
};
}
connectWS();
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>