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:
@@ -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
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from .supervisor import Supervisor
|
||||
|
||||
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()
|
||||
|
||||
|
||||
|
||||
299
webui/index.html
Normal file
299
webui/index.html
Normal 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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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>
|
||||
Reference in New Issue
Block a user