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>
322 lines
22 KiB
HTML
322 lines
22 KiB
HTML
<!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>
|
|
<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:#0b1220; --card:#0B2A59; --text:#f5f5f7; --muted:#9ca3af; --accent:#70F0F9;
|
|
--border:#1e3a5f; --green:#20BD3D; --red:#DD0000; --side:#0a1730;
|
|
}
|
|
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}
|
|
|
|
.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)}
|
|
|
|
.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}
|
|
|
|
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}
|
|
|
|
.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}
|
|
|
|
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}
|
|
|
|
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)}
|
|
|
|
/* 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>
|
|
<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 cur=null; // current profile name (detail/chat)
|
|
let profilesCache=[];
|
|
let events=[]; // {profile,type,ts}
|
|
let grpObserver=false;
|
|
|
|
const $=id=>document.getElementById(id);
|
|
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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}
|
|
|
|
// ── 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);
|
|
}
|
|
|
|
// ── 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>`;
|
|
}
|
|
$('content').innerHTML=html;
|
|
}
|
|
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)}}
|
|
|
|
// ── 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();
|
|
}
|
|
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');}
|
|
|
|
// 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);
|
|
}
|
|
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();
|
|
(async()=>{await refresh();renderList();setInterval(async()=>{await refresh();if(!cur)renderList();},4000);})();
|
|
</script>
|
|
</body>
|
|
</html>
|