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 pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, JSONResponse
from .supervisor import Supervisor from .supervisor import Supervisor
@@ -78,6 +78,100 @@ async def cmd(name: str, body: dict) -> dict:
return {"resp": await sup.send(name, body["cmd"])} 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") @app.post("/profiles/{name}/stop")
async def stop(name: str) -> dict: async def stop(name: str) -> dict:
await sup.stop(name) 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. apps/simplex-directory-service/src/Directory/Options.hs.
""" """
from __future__ import annotations
import asyncio import asyncio
import contextlib import contextlib
import json
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -32,6 +35,7 @@ class Managed:
proc: asyncio.subprocess.Process proc: asyncio.subprocess.Process
port: int | None = None port: int | None = None
client: SimplexWSClient | None = None client: SimplexWSClient | None = None
uid: int | None = None # cached active-user id for cli profiles
class Supervisor: class Supervisor:
@@ -93,6 +97,139 @@ class Supervisor:
raise RuntimeError(f"{name!r} is not a running cli profile") raise RuntimeError(f"{name!r} is not a running cli profile")
return await m.client.send_cmd(cmd) 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) ────── # ── Pattern 2: autonomous official bots (lifecycle + config-at-launch) ──────
async def start_directory(self, name: str, super_users: str, web_folder: str, async def start_directory(self, name: str, super_users: str, web_folder: str,
extra_args: tuple[str, ...] = ()) -> Managed: extra_args: tuple[str, ...] = ()) -> Managed:

View File

@@ -4,296 +4,318 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SimpleX Orchestrate</title> <title>SimpleX Orchestrate</title>
<script src="https://cdn.jsdelivr.net/npm/qrcode/build/qrcode.min.js"></script>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
:root { --bg:#0b1220; --card:#0B2A59; --text:#f5f5f7; --muted:#9ca3af; --accent:#70F0F9;
--bg:#f5f5f7; --card-bg:#fff; --text:#1d1d1f; --muted:#6e6e73; --border:#1e3a5f; --green:#20BD3D; --red:#DD0000; --side:#0a1730;
--accent:#0053D0; --border:#e0e0e5; --shadow:0 20px 30px rgba(0,0,0,0.10);
--green:#20BD3D; --red:#DD0000;
} }
@media (prefers-color-scheme: dark) { body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;
:root { background:var(--bg);color:var(--text);min-height:100vh}
--bg:#111827; --card-bg:#0B2A59; --text:#f5f5f7; --muted:#9ca3af; a{color:var(--accent);text-decoration:none}
--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); .app{display:flex;min-height:100vh}
padding:14px 24px; position:sticky; top:0; z-index:10; } .sidebar{width:230px;flex-shrink:0;background:var(--side);border-right:1px solid var(--border);
.header-inner { max-width:900px; margin:0 auto; display:flex; align-items:center; gap:10px; } display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
.logo-text { font-size:18px; font-weight:700; color:var(--accent); letter-spacing:-0.5px; } .brand{padding:18px;font-size:17px;font-weight:700;color:var(--accent);border-bottom:1px solid var(--border)}
.conn { margin-left:auto; font-size:12px; color:var(--muted); display:flex; align-items:center; gap:6px; } .nav{display:flex;flex-direction:column;padding:8px 0}
.dot { width:9px; height:9px; border-radius:50%; background:var(--muted); } .nav a{display:flex;gap:12px;padding:11px 18px;color:var(--muted);font-weight:600;font-size:14px}
.dot.on { background:var(--green); box-shadow:0 0 6px var(--green); } .nav a:hover{color:var(--text);background:rgba(255,255,255,.03)}
.dot.off { background:var(--red); } .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; } .main{flex:1;min-width:0;display:flex;flex-direction:column}
h1 { font-size:clamp(24px,4vw,32px); font-weight:700; color:var(--accent); margin-bottom:18px; } .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; } h1{font-size:26px;font-weight:700;margin-bottom:18px}
.sec-btn { padding:10px 22px; background:none; border:none; border-bottom:3px solid transparent; h2{font-size:17px;font-weight:600}
margin-bottom:-2px; font:600 15px inherit; color:var(--muted); cursor:pointer; .grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
transition:color .15s,border-color .15s; display:flex; align-items:center; gap:8px; } @media(max-width:820px){.grid2{grid-template-columns:1fr}}
.sec-btn:hover { color:var(--accent); } .card{background:var(--card);border:1px solid var(--border);border-radius:12px;padding:18px;margin-bottom:16px}
.sec-btn.active { color:var(--accent); border-bottom-color:var(--accent); } .flex{display:flex;align-items:center;gap:10px}
.tab-count { font-size:11px; font-weight:600; background:var(--border); color:var(--muted); .between{display:flex;align-items:center;justify-content:space-between}
border-radius:10px; padding:1px 7px; } .muted{color:var(--muted);font-size:13px}
.sec-btn.active .tab-count { background:var(--accent); color:#fff; } .mono{font-family:monospace;font-size:12px;word-break:break-all}
@media (prefers-color-scheme: dark){ .sec-btn.active .tab-count { color:#000; } }
.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; label{display:block;font-size:13px;font-weight:600;color:var(--muted);margin-bottom:4px}
padding:18px; margin-bottom:16px; box-shadow:var(--shadow); } input,select,textarea{width:100%;padding:9px 12px;font:14px inherit;color:var(--text);
.card h2 { font-size:16px; margin-bottom:14px; } 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; } dialog{background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:12px;padding:24px;max-width:460px;width:92%}
input, select, textarea { width:100%; padding:9px 12px; font:14px inherit; color:var(--text); dialog::backdrop{background:rgba(0,0,0,.55)}
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; /* chat */
color:#fff; background:var(--accent); } .chat-wrap{display:flex;flex-direction:column;height:calc(100vh - 220px);min-height:380px;
@media (prefers-color-scheme: dark){ .btn { color:#000; } } background:var(--card);border:1px solid var(--border);border-radius:12px;overflow:hidden}
.btn:hover { opacity:.88; } .chat-head{padding:13px 16px;border-bottom:1px solid var(--border);font-weight:700}
.btn-ghost { background:transparent; border:1px solid var(--border); color:var(--text); } .chat-log{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:7px}
.btn-danger { background:var(--red); color:#fff; } .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}
.btn-sm { padding:6px 13px; font-size:13px; } .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}
.prof { display:flex; align-items:center; gap:12px; } .bubble.in{align-self:flex-start;background:var(--bg);border:1px solid var(--border)}
.prof .avatar { width:42px; height:42px; border-radius:50%; flex-shrink:0; display:flex; .bubble.out{align-self:flex-end;background:var(--accent);color:#001}
align-items:center; justify-content:center; font-weight:700; font-size:18px; .chat-compose{display:flex;gap:8px;padding:11px;border-top:1px solid var(--border)}
background:var(--border); color:var(--muted); } .chat-compose textarea{resize:none;height:40px}
.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> </style>
</head> </head>
<body> <body>
<header> <div class="app">
<div class="header-inner"> <aside class="sidebar">
<span class="logo-text">◆ SimpleX Orchestrate</span> <div class="brand">◆ SimpleX Orchestrate</div>
<span class="conn"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting…</span></span> <nav class="nav"><a class="active" href="#" onclick="go('list');return false">🤖 Profiles</a></nav>
</div> <div class="side-foot">
</header> <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 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>
</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>
</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> <script>
const API = location.origin; const API=location.origin;
let profiles = []; let cur=null; // current profile name (detail/chat)
let evCount = 0; let profilesCache=[];
let events=[]; // {profile,type,ts}
let grpObserver=false;
// ── tabs ────────────────────────────────────────────────────── const $=id=>document.getElementById(id);
document.querySelectorAll('.sec-btn').forEach(b => b.onclick = () => { const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
document.querySelectorAll('.sec-btn').forEach(x => x.classList.remove('active')); function copy(t){navigator.clipboard.writeText(t)}
document.querySelectorAll('.panel').forEach(x => x.classList.remove('active')); function post(b){return{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}}
b.classList.add('active'); 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}
document.getElementById('tab-' + b.dataset.tab).classList.add('active');
});
function onKind() { // ── router ───────────────────────────────────────────────
const k = document.getElementById('new-kind').value; function go(view,name,arg){ cur=name||cur;
document.getElementById('f-directory').style.display = k === 'directory' ? 'block' : 'none'; if(view==='list')return renderList();
document.getElementById('f-broadcast').style.display = k === 'broadcast' ? 'block' : 'none'; if(view==='detail')return renderDetail(name);
if(view==='chat')return renderChat(name,arg);
} }
// ── REST helpers ────────────────────────────────────────────── // ── list ─────────────────────────────────────────────────
async function api(path, opts) { async function renderList(){
const r = await fetch(API + path, opts); cur=null;
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); let html=`<h1>Profiles</h1>
return r.json(); <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>
async function refresh() { <option value="directory">Directory service</option>
try { <option value="broadcast">Broadcast bot</option></select></div>
const d = await api('/profiles'); <div class="field"><label>Name</label><input id="nn" placeholder="alice"></div>
profiles = d.profiles || []; <div id="nf-directory" style="display:none">
renderProfiles(); <div class="field"><label>Super-users (ID:NAME,…)</label><input id="nsu" placeholder="1:admin"></div>
} catch (e) { /* server down */ } <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>
function renderProfiles() { <div class="field"><label>Publishers (ID:NAME,…)</label><input id="npb"></div></div>
document.getElementById('count-prof').textContent = profiles.length; <button class="btn" onclick="createProfile()">Start</button> <span class="muted" id="nr"></span>
const el = document.getElementById('prof-list'); </div>`;
if (!profiles.length) { el.innerHTML = '<div class="card empty">No profiles running. Create one above.</div>'; } if(!profilesCache.length){html+=`<div class="card muted" style="text-align:center;padding:36px">No profiles yet.</div>`;}
else { else for(const p of profilesCache){
el.innerHTML = profiles.map(p => ` html+=`<div class="card between">
<div class="card"> <div class="flex"><div class="avatar">${esc((p.name[0]||'?').toUpperCase())}</div>
<div class="prof"> <div><div style="font-weight:700">${esc(p.name)} <span class="tag">${esc(p.kind)}</span></div>
<div class="avatar">${(p.name[0] || '?').toUpperCase()}</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="meta"> <div class="flex">${p.kind==='cli'?`<button class="btn btn-ghost btn-sm" onclick="go('detail','${esc(p.name)}')">Open</button>`:''}
<div class="name">${esc(p.name)} <span class="tag">${p.kind}</span></div> <button class="btn btn-danger btn-sm" onclick="stopProfile('${esc(p.name)}')">Stop</button></div></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) $('content').innerHTML=html;
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;
} }
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() { async function createProfile(){
const kind = document.getElementById('new-kind').value; const k=$('nk').value,name=$('nn').value.trim(),r=$('nr');if(!name){r.textContent='name required';return}
const name = document.getElementById('new-name').value.trim(); r.textContent='starting…';
const res = document.getElementById('new-result'); try{
if (!name) { res.textContent = 'name required'; return; } if(k==='cli')await api(`/profiles/${name}/start-cli`,post());
res.textContent = 'starting…'; else if(k==='directory')await api(`/profiles/${name}/start-directory`,post({super_users:$('nsu').value.trim(),web_folder:$('nwf').value.trim()}));
try { else await api(`/profiles/${name}/start-broadcast`,post({display_name:$('ndn').value.trim(),publishers:$('npb').value.trim()}));
if (kind === 'cli') { await refresh();renderList();
await api(`/profiles/${name}/start-cli`, { method: 'POST' }); }catch(e){r.textContent='✗ '+e.message}
} 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(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) { // ── detail ───────────────────────────────────────────────
if (!confirm('Stop "' + name + '"?')) return; async function renderDetail(name){
try { await api(`/profiles/${name}/stop`, { method: 'POST' }); refresh(); } cur=name;
catch (e) { alert('Failed: ' + e.message); } $('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();
} }
function groupRow(name,g){
async function sendConsole() { const invited=g.status==='invited',owner=g.role==='owner';
const name = document.getElementById('con-prof').value; let actions;
const cmd = document.getElementById('con-cmd').value.trim(); if(invited) actions=`<button class="pill" onclick="joinGroup('${esc(name)}',${g.groupId})">Join</button>`;
if (!name || !cmd) return; else{ actions=`<button class="pill" onclick="go('chat','${esc(name)}',{type:'group',id:${g.groupId},title:'${esc(g.name)}'})">💬 Chat</button>
const out = document.getElementById('con-resp'); <button class="pill" onclick="grpLink('${esc(name)}',${g.groupId},this)">Link</button>
out.style.display = 'block'; out.textContent = 'sending…'; <button class="pill red" onclick="leaveGroup('${esc(name)}',${g.groupId},'${esc(g.name)}')">Leave</button>`;
try { if(owner)actions+=`<button class="pill red" onclick="delGroup('${esc(name)}',${g.groupId},'${esc(g.name)}')">Delete</button>`;}
const d = await api(`/profiles/${name}/cmd`, post({ cmd })); return `<tr><td>${esc(g.name)}</td><td>${invited?'<span class="tag">invited</span>':g.members}</td>
out.textContent = JSON.stringify(d.resp, null, 2); <td style="text-align:right"><div class="flex" style="justify-content:flex-end">${actions}</div></td></tr>`;
} catch (e) { out.textContent = '✗ ' + e.message; }
} }
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) { // profile edit
return { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; 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])); } async function loadChat(force){
try{const d=await api(`/profiles/${cur}/chat/${chatRef.type}/${chatRef.id}/history?count=80`);
// ── live events over WebSocket ──────────────────────────────── const msgs=d.messages||[];const sig=msgs.map(m=>m.id).join(',');if(sig===lastSig&&!force)return;lastSig=sig;
function clearEvents() { document.getElementById('ev-log').innerHTML = ''; evCount = 0; document.getElementById('count-ev').textContent = 0; } 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>`:'';
function connectWS() { return `<div class="bubble ${m.outgoing?'out':'in'}">${who}${esc(m.text)}<div class="ts">${fmtTs(m.ts)}</div></div>`}).join('')
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; :'<div class="muted" style="margin:auto">No messages yet.</div>';
const ws = new WebSocket(`${proto}://${location.host}/events`); if(atBottom||force)log.scrollTop=log.scrollHeight;
const dot = document.getElementById('conn-dot'), txt = document.getElementById('conn-text'); }catch(e){}
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;
};
} }
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(); connectWS();
refresh(); (async()=>{await refresh();renderList();setInterval(async()=>{await refresh();if(!cur)renderList();},4000);})();
setInterval(refresh, 3000);
</script> </script>
</body> </body>
</html> </html>