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

View File

@@ -9,15 +9,23 @@ This process sits between the website and the SimpleX binaries; it is the only
thing that touches the binaries. Run: uvicorn supervisor.server:app thing that touches the binaries. Run: uvicorn supervisor.server:app
""" """
import asyncio
import contextlib import contextlib
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from .supervisor import Supervisor from .supervisor import Supervisor
app = FastAPI(title="SimpleX Orchestrate") app = FastAPI(title="SimpleX Orchestrate")
WEBUI = Path(__file__).resolve().parent.parent / "webui" / "index.html"
@app.get("/")
async def index() -> FileResponse:
return FileResponse(WEBUI)
_browser_clients: set[WebSocket] = set() _browser_clients: set[WebSocket] = set()

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>