Rich management GUI: profile detail, address+QR, chat, edit, groups

Supervisor gains normalized helpers (get_profile/address, update_profile,
contacts, groups, history, send, create/leave/delete/join group, clear chat,
delete contact) exposed as REST. Front end rebuilt to mirror simplex-manager:
- sidebar layout; profiles list + per-profile detail page
- Profile card + Edit dialog (display name / full name / bio)
- Address card: SMP link + copy + QR (qrcode) + scan caption
- Contacts (Chat / Clear / Delete), Groups (Create, Chat / Link / Leave / Delete,
  Join when invited), Create Channel (observer link)
- in-GUI chat view: history + composer with live polling
- live Event Log per profile over the /events WebSocket

Validated via running server: address shown, profile edit persists, group create
returns link. (json/JSONResponse imports fixed.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-06-04 13:07:48 +01:00
parent d6041c1048
commit d098b1d6ce
3 changed files with 512 additions and 259 deletions

View File

@@ -13,7 +13,7 @@ import contextlib
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
from .supervisor import Supervisor
@@ -78,6 +78,100 @@ async def cmd(name: str, body: dict) -> dict:
return {"resp": await sup.send(name, body["cmd"])}
# ── High-level profile operations (normalized for the GUI) ───────────────────────
async def _json(coro):
"""Await a helper coroutine; return its result as JSON, or {error} on failure."""
try:
return JSONResponse(await coro)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=400)
@app.get("/profiles/{name}/profile")
async def get_profile(name: str):
return await _json(sup.get_profile(name))
@app.post("/profiles/{name}/profile")
async def set_profile(name: str, body: dict):
async def do():
await sup.update_profile(name, body.get("display_name", name),
body.get("full_name", ""), body.get("bio", ""))
return {"ok": True}
return await _json(do())
@app.get("/profiles/{name}/contacts")
async def contacts(name: str):
return await _json(sup.get_contacts(name))
@app.get("/profiles/{name}/groups")
async def groups(name: str):
return await _json(sup.get_groups(name))
@app.post("/profiles/{name}/groups")
async def create_group(name: str, body: dict):
return await _json(sup.create_group(name, body["name"], bool(body.get("observer"))))
@app.get("/profiles/{name}/groups/{gid}/link")
async def group_link(name: str, gid: int):
async def do():
return {"link": await sup.group_link(name, gid)}
return await _json(do())
@app.post("/profiles/{name}/groups/{gid}/leave")
async def leave_group(name: str, gid: int):
async def do():
await sup.leave_group(name, gid); return {"ok": True}
return await _json(do())
@app.post("/profiles/{name}/groups/{gid}/join")
async def join_group(name: str, gid: int):
async def do():
await sup.join_group(name, gid); return {"ok": True}
return await _json(do())
@app.delete("/profiles/{name}/groups/{gid}")
async def delete_group(name: str, gid: int):
async def do():
await sup.delete_group(name, gid); return {"ok": True}
return await _json(do())
@app.get("/profiles/{name}/chat/{chat_type}/{chat_id}/history")
async def history(name: str, chat_type: str, chat_id: int, count: int = 50):
async def do():
return {"messages": await sup.get_history(name, chat_type, chat_id, count)}
return await _json(do())
@app.post("/profiles/{name}/chat/{chat_type}/{chat_id}/send")
async def chat_send(name: str, chat_type: str, chat_id: int, body: dict):
async def do():
await sup.send_message(name, chat_type, chat_id, body["text"]); return {"ok": True}
return await _json(do())
@app.post("/profiles/{name}/chat/{chat_type}/{chat_id}/clear")
async def chat_clear(name: str, chat_type: str, chat_id: int):
async def do():
await sup.clear_chat(name, chat_type, chat_id); return {"ok": True}
return await _json(do())
@app.delete("/profiles/{name}/contacts/{contact_id}")
async def contact_delete(name: str, contact_id: int):
async def do():
await sup.delete_contact(name, contact_id); return {"ok": True}
return await _json(do())
@app.post("/profiles/{name}/stop")
async def stop(name: str) -> dict:
await sup.stop(name)

View File

@@ -12,8 +12,11 @@ NOTE: exact flag spellings for the autonomous bots should be confirmed with
apps/simplex-directory-service/src/Directory/Options.hs.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
@@ -32,6 +35,7 @@ class Managed:
proc: asyncio.subprocess.Process
port: int | None = None
client: SimplexWSClient | None = None
uid: int | None = None # cached active-user id for cli profiles
class Supervisor:
@@ -93,6 +97,139 @@ class Supervisor:
raise RuntimeError(f"{name!r} is not a running cli profile")
return await m.client.send_cmd(cmd)
# ── High-level helpers (normalize the binary's responses for the GUI) ───────
def _cli(self, name: str) -> "Managed":
m = self._procs.get(name)
if not m or not m.client:
raise RuntimeError(f"{name!r} is not a running cli profile")
return m
async def _uid(self, name: str) -> int:
m = self._cli(name)
if m.uid is None:
r = await m.client.send_cmd("/user")
m.uid = r["user"]["userId"]
return m.uid
async def get_profile(self, name: str) -> dict:
m = self._cli(name)
u = (await m.client.send_cmd("/user")).get("user", {})
p = u.get("profile", {})
return {
"displayName": p.get("displayName", name),
"fullName": p.get("fullName", ""),
"bio": p.get("shortDescr", ""),
"image": p.get("image", ""),
"address": await self.get_address(name),
}
async def get_address(self, name: str) -> str:
m = self._cli(name)
uid = await self._uid(name)
r = await m.client.send_cmd(f"/_show_address {uid}")
if r.get("type") == "userContactLink":
link = r["contactLink"]["connLinkContact"]
else:
rc = await m.client.send_cmd(f"/_address {uid}")
link = rc.get("connLinkContact", {}) if rc.get("type") == "userContactLinkCreated" else {}
# auto-accept incoming contact requests so the address is usable
settings = {"businessAddress": False, "autoAccept": {"acceptIncognito": False}}
await m.client.send_cmd(f"/_address_settings {uid} " + json.dumps(settings))
return link.get("connShortLink") or link.get("connFullLink", "")
async def update_profile(self, name: str, display_name: str, full_name: str, bio: str) -> bool:
m = self._cli(name)
uid = await self._uid(name)
profile = {"displayName": display_name, "fullName": full_name}
if bio:
profile["shortDescr"] = bio
await m.client.send_cmd(f"/_profile {uid} " + json.dumps(profile))
return True
async def get_contacts(self, name: str) -> list[dict]:
m = self._cli(name)
uid = await self._uid(name)
r = await m.client.send_cmd(f"/_contacts {uid}")
return [
{"contactId": c["contactId"], "name": c["localDisplayName"]}
for c in r.get("contacts", [])
]
async def get_groups(self, name: str) -> list[dict]:
m = self._cli(name)
uid = await self._uid(name)
r = await m.client.send_cmd(f"/_groups {uid}")
out = []
for g in r.get("groups", []):
mem = g.get("membership", {})
out.append({
"groupId": g["groupId"],
"name": g["groupProfile"]["displayName"],
"members": g.get("groupSummary", {}).get("currentMembers", 0),
"role": mem.get("memberRole", ""),
"status": mem.get("memberStatus", ""),
})
return out
async def get_history(self, name: str, chat_type: str, chat_id: int, count: int = 50) -> list[dict]:
m = self._cli(name)
ref = ("@" if chat_type == "direct" else "#") + str(chat_id)
r = await m.client.send_cmd(f"/_get chat {ref} count={count}")
items = r.get("chat", {}).get("chatItems", []) if r.get("type") == "apiChat" else []
out = []
for ci in items:
meta = ci.get("meta", {})
d = ci.get("chatDir", {}).get("type", "")
text = meta.get("itemText") or ci.get("content", {}).get("msgContent", {}).get("text", "")
sender = ci.get("chatDir", {}).get("groupMember", {}).get("localDisplayName", "") if d == "groupRcv" else ""
out.append({"id": meta.get("itemId"), "ts": meta.get("itemTs", ""),
"text": text, "outgoing": d.endswith("Snd"), "sender": sender})
return out
async def send_message(self, name: str, chat_type: str, chat_id: int, text: str) -> bool:
m = self._cli(name)
ref = ("@" if chat_type == "direct" else "#") + str(chat_id)
msgs = [{"msgContent": {"type": "text", "text": text}, "mentions": {}}]
await m.client.send_cmd(f"/_send {ref} json " + json.dumps(msgs))
return True
async def create_group(self, name: str, group_name: str, observer: bool = False) -> dict:
m = self._cli(name)
uid = await self._uid(name)
gi = await m.client.send_cmd(f"/_group {uid} " + json.dumps({"displayName": group_name, "fullName": ""}))
gid = gi["groupInfo"]["groupId"]
role = "observer" if observer else "member"
lk = await m.client.send_cmd(f"/_create link #{gid} {role}")
link = lk.get("groupLink", {}).get("connLinkContact", {}) if lk.get("type") == "groupLinkCreated" else {}
return {"groupId": gid, "link": link.get("connShortLink") or link.get("connFullLink", "")}
async def group_link(self, name: str, gid: int) -> str:
m = self._cli(name)
r = await m.client.send_cmd(f"/_get link #{gid}")
link = r.get("groupLink", {}).get("connLinkContact", {}) if r.get("type") == "groupLink" else {}
return link.get("connShortLink") or link.get("connFullLink", "")
async def leave_group(self, name: str, gid: int) -> bool:
await self._cli(name).client.send_cmd(f"/_leave #{gid}")
return True
async def delete_group(self, name: str, gid: int) -> bool:
await self._cli(name).client.send_cmd(f"/_delete #{gid} full")
return True
async def join_group(self, name: str, gid: int) -> bool:
await self._cli(name).client.send_cmd(f"/_join #{gid}")
return True
async def clear_chat(self, name: str, chat_type: str, chat_id: int) -> bool:
ref = ("@" if chat_type == "direct" else "#") + str(chat_id)
await self._cli(name).client.send_cmd(f"/_delete {ref} messages")
return True
async def delete_contact(self, name: str, contact_id: int) -> bool:
await self._cli(name).client.send_cmd(f"/_delete @{contact_id} full")
return True
# ── Pattern 2: autonomous official bots (lifecycle + config-at-launch) ──────
async def start_directory(self, name: str, super_users: str, web_folder: str,
extra_args: tuple[str, ...] = ()) -> Managed:

View File

@@ -4,296 +4,318 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleX Orchestrate</title>
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
<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;
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0b1220; --card:#0B2A59; --text:#f5f5f7; --muted:#9ca3af; --accent:#70F0F9;
--border:#1e3a5f; --green:#20BD3D; --red:#DD0000; --side:#0a1730;
}
@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; }
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;
background:var(--bg);color:var(--text);min-height:100vh}
a{color:var(--accent);text-decoration:none}
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); }
.app{display:flex;min-height:100vh}
.sidebar{width:230px;flex-shrink:0;background:var(--side);border-right:1px solid var(--border);
display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
.brand{padding:18px;font-size:17px;font-weight:700;color:var(--accent);border-bottom:1px solid var(--border)}
.nav{display:flex;flex-direction:column;padding:8px 0}
.nav a{display:flex;gap:12px;padding:11px 18px;color:var(--muted);font-weight:600;font-size:14px}
.nav a:hover{color:var(--text);background:rgba(255,255,255,.03)}
.nav a.active{color:var(--accent);border-left:3px solid var(--accent)}
.side-foot{margin-top:auto;padding:14px 18px;border-top:1px solid var(--border);font-size:12px;color:var(--muted)}
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--muted);margin-right:6px}
.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; }
.main{flex:1;min-width:0;display:flex;flex-direction:column}
.content{flex:1;max-width:1080px;width:100%;margin:0 auto;padding:26px 22px}
.foot{text-align:center;padding:16px;border-top:1px solid var(--border);color:var(--muted);font-size:12px}
.foot a{font-weight:600}
.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; } }
h1{font-size:26px;font-weight:700;margin-bottom:18px}
h2{font-size:17px;font-weight:600}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:820px){.grid2{grid-template-columns:1fr}}
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px;margin-bottom:16px}
.flex{display:flex;align-items:center;gap:10px}
.between{display:flex;align-items:center;justify-content:space-between}
.muted{color:var(--muted);font-size:13px}
.mono{font-family:monospace;font-size:12px;word-break:break-all}
.panel { display:none; } .panel.active { display:block; }
.btn{padding:8px 16px;border:none;border-radius:8px;font:600 13px inherit;cursor:pointer;color:#001;background:var(--accent)}
.btn:hover{opacity:.88}
.btn-ghost{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn-danger{background:var(--red);color:#fff}
.btn-green{background:var(--green);color:#001}
.btn-sm{padding:5px 12px;font-size:12px}
.tag{font-size:11px;font-weight:600;padding:2px 8px;border-radius:7px;background:var(--border);color:var(--muted)}
.badge{font-size:12px;font-weight:600;padding:2px 9px;border-radius:10px}
.badge.run{background:#064e3b;color:#6ee7b7} .badge.stop{background:#7f1d1d;color:#fca5a5}
.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:8px;outline:none}
input:focus,textarea:focus,select:focus{border-color:var(--accent)}
.field{margin-bottom:12px}
table{width:100%;border-collapse:collapse;font-size:14px}
th{text-align:left;color:var(--muted);font-size:12px;font-weight:600;padding:6px 8px;border-bottom:1px solid var(--border)}
td{padding:8px;border-bottom:1px solid var(--border)} tr:last-child td{border-bottom:none}
.avatar{width:48px;height:48px;border-radius:50%;background:var(--border);display:flex;align-items:center;
justify-content:center;font-weight:700;font-size:20px;color:var(--muted);flex-shrink:0}
.pill{padding:4px 10px;font-size:12px;border-radius:6px;border:1px solid var(--border);background:transparent;
color:var(--accent);cursor:pointer;font-weight:600}
.pill:hover{background:var(--accent);color:#001}
.pill.red{color:#fca5a5;border-color:#7f1d1d} .pill.red:hover{background:var(--red);color:#fff}
.qr-wrap{text-align:center;padding:12px} .qr-wrap canvas{border-radius:8px}
.log{background:#06101f;color:#70F0F9;border-radius:8px;padding:10px;font:12px monospace;height:230px;overflow-y:auto;white-space:pre-wrap}
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; }
dialog{background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:460px;width:92%}
dialog::backdrop{background:rgba(0,0,0,.55)}
.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; }
/* chat */
.chat-wrap{display:flex;flex-direction:column;height:calc(100vh - 220px);min-height:380px;
background:var(--card);border:1px solid var(--border);border-radius:12px;overflow:hidden}
.chat-head{padding:13px 16px;border-bottom:1px solid var(--border);font-weight:700}
.chat-log{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:7px}
.bubble{max-width:72%;padding:8px 12px;border-radius:13px;font-size:14px;line-height:1.4;white-space:pre-wrap;word-wrap:break-word}
.bubble .who{font-size:11px;font-weight:700;opacity:.7;margin-bottom:2px}
.bubble .ts{font-size:10px;opacity:.5;margin-top:3px;text-align:right}
.bubble.in{align-self:flex-start;background:var(--bg);border:1px solid var(--border)}
.bubble.out{align-self:flex-end;background:var(--accent);color:#001}
.chat-compose{display:flex;gap:8px;padding:11px;border-top:1px solid var(--border)}
.chat-compose textarea{resize:none;height:40px}
</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 class="app">
<aside class="sidebar">
<div class="brand">◆ SimpleX Orchestrate</div>
<nav class="nav"><a class="active" href="#" onclick="go('list');return false">🤖 Profiles</a></nav>
<div class="side-foot">
<div><span class="dot" id="conn-dot"></span><span id="conn-text">connecting…</span></div>
<div style="margin-top:4px;" id="foot-count">0 profiles</div>
</div>
</aside>
<div class="main">
<div class="content" id="content"></div>
<div class="foot">© Bournemouth Technology Ltd · built on © SimpleX Network ·
<a href="https://simplex.chat/downloads/" target="_blank" rel="noopener">Get SimpleX App</a></div>
</div>
</div>
<!-- edit profile dialog -->
<dialog id="edit-dlg">
<h2 style="margin-bottom:16px;">Edit profile</h2>
<div class="field"><label>Display name</label><input id="ed-name"></div>
<div class="field"><label>Full name</label><input id="ed-full"></div>
<div class="field"><label>Bio</label><textarea id="ed-bio" rows="2"></textarea></div>
<div class="between" style="margin-top:8px;">
<span class="muted" id="ed-msg"></span>
<div class="flex"><button class="btn btn-ghost" onclick="edit_dlg.close()">Cancel</button>
<button class="btn" onclick="saveProfile()">Save</button></div>
</div>
</dialog>
<!-- create group/channel dialog -->
<dialog id="grp-dlg">
<h2 id="grp-title" style="margin-bottom:16px;">New group</h2>
<div class="field"><label>Name</label><input id="grp-name"></div>
<div id="grp-link-wrap" class="field" style="display:none;"><label>Join link</label>
<div class="flex"><input id="grp-link" readonly class="mono"><button class="pill" onclick="copy(grp_link.value)">copy</button></div></div>
<div class="between" style="margin-top:8px;">
<span class="muted" id="grp-msg"></span>
<div class="flex"><button class="btn btn-ghost" onclick="grp_dlg.close();go('detail',cur)">Close</button>
<button class="btn" id="grp-create" onclick="doCreateGroup()">Create</button></div>
</div>
</dialog>
<script>
const API = location.origin;
let profiles = [];
let evCount = 0;
const API=location.origin;
let cur=null; // current profile name (detail/chat)
let profilesCache=[];
let events=[]; // {profile,type,ts}
let grpObserver=false;
// ── 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');
});
const $=id=>document.getElementById(id);
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function copy(t){navigator.clipboard.writeText(t)}
function post(b){return{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}}
async function api(p,o){const r=await fetch(API+p,o);const d=await r.json().catch(()=>({}));if(!r.ok||d.error)throw new Error(d.error||d.detail||r.statusText);return d}
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';
// ── router ───────────────────────────────────────────────
function go(view,name,arg){ cur=name||cur;
if(view==='list')return renderList();
if(view==='detail')return renderDetail(name);
if(view==='chat')return renderChat(name,arg);
}
// ── 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('');
// ── list ─────────────────────────────────────────────────
async function renderList(){
cur=null;
let html=`<h1>Profiles</h1>
<div class="card"><h2 style="margin-bottom:12px;">New profile</h2>
<div class="field"><label>Kind</label><select id="nk" onchange="onKind()">
<option value="cli">CLI account / custom bot</option>
<option value="directory">Directory service</option>
<option value="broadcast">Broadcast bot</option></select></div>
<div class="field"><label>Name</label><input id="nn" placeholder="alice"></div>
<div id="nf-directory" style="display:none">
<div class="field"><label>Super-users (ID:NAME,…)</label><input id="nsu" placeholder="1:admin"></div>
<div class="field"><label>Web folder</label><input id="nwf" placeholder="web/mydir"></div></div>
<div id="nf-broadcast" style="display:none">
<div class="field"><label>Display name</label><input id="ndn"></div>
<div class="field"><label>Publishers (ID:NAME,…)</label><input id="npb"></div></div>
<button class="btn" onclick="createProfile()">Start</button> <span class="muted" id="nr"></span>
</div>`;
if(!profilesCache.length){html+=`<div class="card muted" style="text-align:center;padding:36px">No profiles yet.</div>`;}
else for(const p of profilesCache){
html+=`<div class="card between">
<div class="flex"><div class="avatar">${esc((p.name[0]||'?').toUpperCase())}</div>
<div><div style="font-weight:700">${esc(p.name)} <span class="tag">${esc(p.kind)}</span></div>
<div class="muted">${p.port?'ws '+p.port:'autonomous'} · <span class="badge ${p.running?'run':'stop'}">${p.running?'running':'stopped'}</span></div></div></div>
<div class="flex">${p.kind==='cli'?`<button class="btn btn-ghost btn-sm" onclick="go('detail','${esc(p.name)}')">Open</button>`:''}
<button class="btn btn-danger btn-sm" onclick="stopProfile('${esc(p.name)}')">Stop</button></div></div>`;
}
// 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;
$('content').innerHTML=html;
}
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);
function onKind(){const k=$('nk').value;$('nf-directory').style.display=k==='directory'?'block':'none';$('nf-broadcast').style.display=k==='broadcast'?'block':'none';}
async function createProfile(){
const k=$('nk').value,name=$('nn').value.trim(),r=$('nr');if(!name){r.textContent='name required';return}
r.textContent='starting…';
try{
if(k==='cli')await api(`/profiles/${name}/start-cli`,post());
else if(k==='directory')await api(`/profiles/${name}/start-directory`,post({super_users:$('nsu').value.trim(),web_folder:$('nwf').value.trim()}));
else await api(`/profiles/${name}/start-broadcast`,post({display_name:$('ndn').value.trim(),publishers:$('npb').value.trim()}));
await refresh();renderList();
}catch(e){r.textContent='✗ '+e.message}
}
async function stopProfile(n){if(!confirm('Stop '+n+'?'))return;try{await api(`/profiles/${n}/stop`,post());await refresh();renderList();}catch(e){alert(e.message)}}
async function stopProfile(name) {
if (!confirm('Stop "' + name + '"?')) return;
try { await api(`/profiles/${name}/stop`, { method: 'POST' }); refresh(); }
catch (e) { alert('Failed: ' + e.message); }
// ── detail ───────────────────────────────────────────────
async function renderDetail(name){
cur=name;
$('content').innerHTML=`<div class="muted">Loading ${esc(name)}…</div>`;
let prof,contacts=[],groups=[];
try{ prof=await api(`/profiles/${name}/profile`);}catch(e){$('content').innerHTML=`<div class="card">Error: ${esc(e.message)}</div>`;return;}
try{ contacts=(await api(`/profiles/${name}/contacts`));}catch(e){}
try{ groups=(await api(`/profiles/${name}/groups`));}catch(e){}
const p=profilesCache.find(x=>x.name===name)||{};
$('content').innerHTML=`
<div class="between" style="margin-bottom:18px">
<div class="flex"><a href="#" onclick="go('list');return false" class="muted">← Profiles</a>
<strong>${esc(name)}</strong> <span class="tag">${esc(p.kind||'cli')}</span>
<span class="badge ${p.running?'run':'stop'}">${p.running?'running':'stopped'}</span></div>
<div class="flex"><button class="btn btn-danger" onclick="stopProfile('${esc(name)}')">Stop</button></div>
</div>
<div class="grid2"><div>
<div class="card"><div class="between" style="margin-bottom:12px"><h2>Profile</h2>
<button class="btn btn-ghost btn-sm" onclick="openEdit()">Edit</button></div>
<div class="flex" style="align-items:flex-start">
<div class="avatar">${esc((prof.displayName[0]||'?').toUpperCase())}</div>
<div><div style="font-weight:700;font-size:16px">${esc(prof.displayName)}</div>
${prof.fullName?`<div class="muted">${esc(prof.fullName)}</div>`:''}
<div style="margin-top:5px">${prof.bio?esc(prof.bio):'<span class="muted">No bio set.</span>'}</div></div></div></div>
<div class="card"><h2 style="margin-bottom:12px">Address</h2>
${prof.address?`<div class="flex"><button class="pill" onclick="copy('${esc(prof.address)}')">📋</button>
<a class="mono" href="${esc(prof.address)}" target="_blank" rel="noopener">${esc(prof.address)}</a></div>
<div class="qr-wrap"><canvas id="qr"></canvas><div class="muted" style="margin-top:8px">Scan QR code from mobile app to start a chat</div></div>`
:'<p class="muted">No address.</p>'}</div>
</div><div>
<div class="card"><h2 style="margin-bottom:12px">Contacts (${contacts.length})</h2>
${contacts.length?`<table><tr><th>Name</th><th></th></tr>${contacts.map(c=>`<tr>
<td><strong>${esc(c.name)}</strong></td><td style="text-align:right"><div class="flex" style="justify-content:flex-end">
<button class="pill" onclick="go('chat','${esc(name)}',{type:'direct',id:${c.contactId},title:'${esc(c.name)}'})">💬 Chat</button>
<button class="pill" onclick="clearChat('${esc(name)}','direct',${c.contactId},'${esc(c.name)}')">🧹 Clear</button>
<button class="pill red" onclick="delContact('${esc(name)}',${c.contactId},'${esc(c.name)}')">🗑 Delete</button>
</div></td></tr>`).join('')}</table>`:'<p class="muted">No contacts yet.</p>'}</div>
<div class="card"><div class="between" style="margin-bottom:12px"><h2>Groups (${groups.length})</h2>
<button class="btn btn-sm" onclick="openGroup(false)">+ Create Group</button></div>
${groups.length?`<table><tr><th>Name</th><th>Members</th><th></th></tr>${groups.map(g=>groupRow(name,g)).join('')}</table>`:'<p class="muted">No groups yet.</p>'}
<div style="margin-top:10px"><button class="btn btn-ghost btn-sm" onclick="openGroup(true)">+ Create Channel (broadcast)</button></div></div>
<div class="card"><div class="between" style="margin-bottom:10px"><h2>Event Log</h2>
<button class="btn btn-ghost btn-sm" onclick="renderEvLog()">Refresh</button></div>
<div class="log" id="evlog"></div></div>
</div></div>`;
if(prof.address&&window.QRCode)QRCode.toCanvas($('qr'),prof.address,{width:200},()=>{});
renderEvLog();
}
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 groupRow(name,g){
const invited=g.status==='invited',owner=g.role==='owner';
let actions;
if(invited) actions=`<button class="pill" onclick="joinGroup('${esc(name)}',${g.groupId})">Join</button>`;
else{ actions=`<button class="pill" onclick="go('chat','${esc(name)}',{type:'group',id:${g.groupId},title:'${esc(g.name)}'})">💬 Chat</button>
<button class="pill" onclick="grpLink('${esc(name)}',${g.groupId},this)">Link</button>
<button class="pill red" onclick="leaveGroup('${esc(name)}',${g.groupId},'${esc(g.name)}')">Leave</button>`;
if(owner)actions+=`<button class="pill red" onclick="delGroup('${esc(name)}',${g.groupId},'${esc(g.name)}')">Delete</button>`;}
return `<tr><td>${esc(g.name)}</td><td>${invited?'<span class="tag">invited</span>':g.members}</td>
<td style="text-align:right"><div class="flex" style="justify-content:flex-end">${actions}</div></td></tr>`;
}
function renderEvLog(){const el=$('evlog');if(!el)return;
el.innerHTML=events.filter(e=>e.profile===cur).slice(-100).reverse().map(e=>`${e.ts} [${esc(e.type)}]`).join('\n');}
function post(body) {
return { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
// profile edit
function openEdit(){api(`/profiles/${cur}/profile`).then(p=>{$('ed-name').value=p.displayName;$('ed-full').value=p.fullName;$('ed-bio').value=p.bio;$('ed-msg').textContent='';edit_dlg.showModal();});}
async function saveProfile(){$('ed-msg').textContent='saving…';
try{await api(`/profiles/${cur}/profile`,post({display_name:$('ed-name').value.trim(),full_name:$('ed-full').value.trim(),bio:$('ed-bio').value.trim()}));edit_dlg.close();renderDetail(cur);}
catch(e){$('ed-msg').textContent='✗ '+e.message}}
// groups/channels
function openGroup(observer){grpObserver=observer;$('grp-title').textContent=observer?'New channel (broadcast)':'New group';
$('grp-name').value='';$('grp-msg').textContent='';$('grp-link-wrap').style.display='none';$('grp-create').style.display='';grp_dlg.showModal();}
async function doCreateGroup(){const nm=$('grp-name').value.trim();if(!nm)return;$('grp-msg').textContent='creating…';
try{const d=await api(`/profiles/${cur}/groups`,post({name:nm,observer:grpObserver}));
$('grp-link').value=d.link||'';$('grp-link-wrap').style.display='block';$('grp-msg').textContent='✓ created';$('grp-create').style.display='none';}
catch(e){$('grp-msg').textContent='✗ '+e.message}}
async function grpLink(name,gid,btn){btn.textContent='…';try{const d=await api(`/profiles/${name}/groups/${gid}/link`);if(d.link){copy(d.link);btn.textContent='✓ copied';}else btn.textContent='no link';}catch(e){btn.textContent='err'}setTimeout(()=>btn.textContent='Link',1800);}
async function joinGroup(name,gid){try{await api(`/profiles/${name}/groups/${gid}/join`,post());renderDetail(name);}catch(e){alert(e.message)}}
async function leaveGroup(name,gid,t){if(!confirm('Leave "'+t+'"?'))return;try{await api(`/profiles/${name}/groups/${gid}/leave`,post());renderDetail(name);}catch(e){alert(e.message)}}
async function delGroup(name,gid,t){if(!confirm('Delete "'+t+'" for everyone?'))return;try{await api(`/profiles/${name}/groups/${gid}`,{method:'DELETE'});renderDetail(name);}catch(e){alert(e.message)}}
async function clearChat(name,ty,id,t){if(!confirm('Clear conversation with '+t+'?'))return;try{await api(`/profiles/${name}/chat/${ty}/${id}/clear`,post());alert('Cleared');}catch(e){alert(e.message)}}
async function delContact(name,id,t){if(!confirm('Delete contact '+t+'?'))return;try{await api(`/profiles/${name}/contacts/${id}`,{method:'DELETE'});renderDetail(name);}catch(e){alert(e.message)}}
// ── chat ─────────────────────────────────────────────────
let chatRef=null,chatTimer=null,lastSig='';
async function renderChat(name,ref){
cur=name;chatRef=ref;lastSig='';
$('content').innerHTML=`<div class="flex" style="margin-bottom:14px"><a href="#" onclick="go('detail','${esc(name)}');return false" class="muted">← ${esc(name)}</a>
<span class="muted">/</span><strong>${esc(ref.title)}</strong> <span class="tag">${ref.type}</span></div>
<div class="chat-wrap"><div class="chat-head">${esc(ref.title)}</div>
<div class="chat-log" id="clog"><div class="muted" style="margin:auto">Loading…</div></div>
<div class="chat-compose"><textarea id="cmsg" placeholder="Type a message…"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendChat()}"></textarea>
<button class="btn" onclick="sendChat()">Send</button></div></div>`;
loadChat(true); if(chatTimer)clearInterval(chatTimer); chatTimer=setInterval(()=>{if(chatRef)loadChat(false)},3000);
}
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;
};
async function loadChat(force){
try{const d=await api(`/profiles/${cur}/chat/${chatRef.type}/${chatRef.id}/history?count=80`);
const msgs=d.messages||[];const sig=msgs.map(m=>m.id).join(',');if(sig===lastSig&&!force)return;lastSig=sig;
const log=$('clog');if(!log)return;const atBottom=log.scrollHeight-log.scrollTop-log.clientHeight<60;
log.innerHTML=msgs.length?msgs.map(m=>{const who=(!m.outgoing&&m.sender)?`<div class="who">${esc(m.sender)}</div>`:'';
return `<div class="bubble ${m.outgoing?'out':'in'}">${who}${esc(m.text)}<div class="ts">${fmtTs(m.ts)}</div></div>`}).join('')
:'<div class="muted" style="margin:auto">No messages yet.</div>';
if(atBottom||force)log.scrollTop=log.scrollHeight;
}catch(e){}
}
function fmtTs(s){const d=new Date(s);return isNaN(d)?'':d.toLocaleString([],{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
async function sendChat(){const t=$('cmsg').value.trim();if(!t)return;$('cmsg').value='';
try{await api(`/profiles/${cur}/chat/${chatRef.type}/${chatRef.id}/send`,post({text:t}));setTimeout(()=>loadChat(true),250);}
catch(e){$('cmsg').value=t;alert('Send failed: '+e.message)}}
// ── data + events ────────────────────────────────────────
async function refresh(){try{const d=await api('/profiles');profilesCache=d.profiles||[];$('foot-count').textContent=profilesCache.length+' profiles';}catch(e){}}
function connectWS(){const proto=location.protocol==='https:'?'wss':'ws';const ws=new WebSocket(`${proto}://${location.host}/events`);
ws.onopen=()=>{$('conn-dot').className='dot on';$('conn-text').textContent='supervisor connected';};
ws.onclose=()=>{$('conn-dot').className='dot off';$('conn-text').textContent='disconnected';setTimeout(connectWS,2000);};
ws.onmessage=m=>{let d;try{d=JSON.parse(m.data)}catch{return}
events.push({profile:d.profile,type:(d.event&&d.event.type)||'?',ts:new Date().toLocaleTimeString()});
if(events.length>1000)events=events.slice(-1000);
if(cur&&$('evlog'))renderEvLog();};
}
connectWS();
refresh();
setInterval(refresh, 3000);
(async()=>{await refresh();renderList();setInterval(async()=>{await refresh();if(!cur)renderList();},4000);})();
</script>
</body>
</html>