Files
simplex-orchestrate/webui/index.html
Jon d098b1d6ce 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>
2026-06-04 13:07:48 +01:00

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=>({'&':'&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}
// ── 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>