<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>YantrikDB Studio</title>
<style>
:root {
--bg:#f6f7f9; --panel:#fff; --ink:#1a1d21; --muted:#6b7280; --line:#e5e7eb;
--accent:#4f46e5; --ok:#16a34a; --warn:#d97706; --bad:#dc2626; --leader:#4f46e5;
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
}
@media (prefers-color-scheme:dark){:root{
--bg:#0d1117; --panel:#161b22; --ink:#e6edf3; --muted:#8b949e; --line:#21262d;
--accent:#7c8cff; --ok:#3fb950; --warn:#d29922; --bad:#f85149; --leader:#7c8cff;}}
*{box-sizing:border-box}
body{margin:0;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--ink);-webkit-font-smoothing:antialiased}
header{display:flex;align-items:center;gap:1rem;flex-wrap:wrap;padding:.9rem 1.5rem;border-bottom:1px solid var(--line);background:var(--panel)}
header h1{font-size:1.05rem;margin:0;font-weight:650;letter-spacing:-.01em}
nav{display:flex;gap:.2rem;flex-wrap:wrap}
nav button{font:inherit;font-size:.82rem;border:0;background:transparent;color:var(--muted);padding:.35rem .7rem;border-radius:8px;cursor:pointer}
nav button.active{background:color-mix(in srgb,var(--accent) 14%,transparent);color:var(--accent);font-weight:600}
header .right{margin-left:auto;display:flex;align-items:center;gap:.6rem;font-size:.8rem;color:var(--muted)}
.who b{color:var(--ink)}
.pill{font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:.1rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent) 16%,transparent);color:var(--accent)}
.wrap{max-width:1080px;margin:0 auto;padding:1.5rem}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:1rem 1.1rem;overflow:hidden}
.card.leader{border-color:var(--leader);box-shadow:0 0 0 1px var(--leader) inset}
.card .top{display:flex;align-items:center;gap:.5rem;margin-bottom:.5rem}
.node-id{font-family:var(--mono);font-weight:650}
.badge{font-size:.66rem;font-weight:700;padding:.1rem .45rem;border-radius:999px;text-transform:uppercase;letter-spacing:.03em}
.badge.leader{background:color-mix(in srgb,var(--leader) 18%,transparent);color:var(--leader)}
.badge.follower{background:color-mix(in srgb,var(--muted) 20%,transparent);color:var(--muted)}
.badge.witness{background:color-mix(in srgb,var(--warn) 20%,transparent);color:var(--warn)}
.badge.candidate,.badge.quarantined{background:color-mix(in srgb,var(--bad) 18%,transparent);color:var(--bad)}
.dot{width:8px;height:8px;border-radius:50%;margin-left:auto}
.dot.ok{background:var(--ok)}.dot.bad{background:var(--bad)}.dot.warn{background:var(--warn)}
.rows{display:grid;grid-template-columns:auto 1fr;gap:.2rem .7rem;font-size:.8rem;margin-top:.4rem}
.rows dt{color:var(--muted)}.rows dd{margin:0;font-family:var(--mono);text-align:right}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:1.1rem;margin-bottom:1.1rem}
.panel h2{font-size:.82rem;margin:0 0 .8rem;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
table{width:100%;border-collapse:collapse;font-size:.85rem}
th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--line);vertical-align:middle}
th{font-size:.72rem;text-transform:uppercase;letter-spacing:.03em;color:var(--muted);font-weight:600}
td .mono{font-family:var(--mono);font-size:.78rem;color:var(--muted)}
tr:last-child td{border-bottom:0}
input,select{font:inherit;font-size:.85rem;padding:.4rem .55rem;border-radius:8px;border:1px solid var(--line);background:var(--bg);color:var(--ink);max-width:100%}
input:focus,select:focus{outline:2px solid color-mix(in srgb,var(--accent) 40%,transparent)}
button.btn{font:inherit;font-size:.8rem;font-weight:600;border:0;cursor:pointer;background:var(--accent);color:#fff;padding:.42rem .85rem;border-radius:8px}
button.btn.ghost{background:color-mix(in srgb,var(--muted) 16%,transparent);color:var(--ink)}
button.btn.sm{padding:.25rem .55rem;font-size:.74rem}
button.btn.danger{background:color-mix(in srgb,var(--bad) 16%,transparent);color:var(--bad)}
button.btn.warn{background:color-mix(in srgb,var(--warn) 18%,transparent);color:var(--warn)}
button.btn:disabled{opacity:.5;cursor:not-allowed}
.form{display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;margin-top:.8rem}
.form input,.form select{flex:1;min-width:120px}
.rowacts{display:flex;gap:.35rem;justify-content:flex-end}
.note{font-size:.74rem;color:var(--muted);margin-top:.5rem}
.empty{color:var(--muted);padding:1.5rem;text-align:center}
.reveal{font-family:var(--mono);font-size:.8rem;background:color-mix(in srgb,var(--ok) 12%,transparent);border:1px solid var(--ok);border-radius:8px;padding:.6rem .7rem;margin:.6rem 0;word-break:break-all;display:flex;gap:.5rem;align-items:center;justify-content:space-between}
.toast{position:fixed;bottom:1rem;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:.6rem 1rem;font-size:.82rem;box-shadow:0 6px 24px rgba(0,0,0,.18);max-width:90vw;z-index:50}
.toast.bad{border-color:var(--bad);color:var(--bad)}.toast.ok{border-color:var(--ok)}
.hidden{display:none!important}
.login{max-width:340px;margin:8vh auto;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:1.6rem}
.login h2{margin:0 0 .3rem;font-size:1.1rem}
.login .sub{color:var(--muted);font-size:.82rem;margin-bottom:1.2rem}
.login label{display:block;font-size:.76rem;color:var(--muted);margin:.6rem 0 .25rem}
.login input{width:100%}
.login button{width:100%;margin-top:1.1rem;padding:.55rem}
.rolegate{display:contents}
</style>
</head>
<body>
<header class="hidden" id="appheader">
<h1>YantrikDB <span style="color:var(--accent)">Studio</span></h1>
<nav id="nav"></nav>
<div class="right">
<span class="who" id="who"></span>
<button class="btn ghost sm" onclick="logout()">Sign out</button>
</div>
</header>
<div id="loginview">
<div class="login">
<div id="signinbox">
<h2>Sign in</h2>
<div class="sub">Enterprise control console</div>
<label for="lu">Username</label>
<input id="lu" autocomplete="username" />
<label for="lp">Password</label>
<input id="lp" type="password" autocomplete="current-password" onkeydown="if(event.key==='Enter')doLogin()" />
<button class="btn" onclick="doLogin()">Sign in</button>
<div class="note" style="text-align:center"><a href="#" onclick="showBootstrap(true);return false" style="color:var(--accent)">First-time setup →</a></div>
</div>
<div id="bootstrapbox" class="hidden">
<h2>First-time setup</h2>
<div class="sub">Create the first <b>owner</b> account using the cluster master token. Do this once; afterwards, sign in with the account.</div>
<label for="bt">Cluster master token</label>
<input id="bt" type="password" placeholder="ydb_cluster_…" />
<label for="bu">Owner username</label>
<input id="bu" autocomplete="off" />
<label for="bp">Owner password (≥8)</label>
<input id="bp" type="password" autocomplete="new-password" onkeydown="if(event.key==='Enter')doBootstrap()" />
<button class="btn" onclick="doBootstrap()">Create owner</button>
<div class="note" style="text-align:center"><a href="#" onclick="showBootstrap(false);return false" style="color:var(--accent)">← Back to sign in</a></div>
</div>
</div>
</div>
<div class="wrap hidden" id="appview"></div>
<div id="toast" class="toast hidden"></div>
<script>
(function(){
const $=id=>document.getElementById(id);
const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
const num=v=>(v==null?'—':v);
let toastT; function toast(m,k){const t=$('toast');t.textContent=m;t.className='toast '+(k||'');clearTimeout(toastT);toastT=setTimeout(()=>t.classList.add('hidden'),4200);}
const tok=()=>sessionStorage.getItem('ydb_sess')||'';
let me={username:'',role:'readonly'};
const rank={readonly:0,admin:1,owner:2};
const can=r=>rank[me.role]>=rank[r];
async function api(path,opts){
const o=opts||{}; o.headers=Object.assign({},o.headers||{});
if(tok()) o.headers['Authorization']='Bearer '+tok();
if(o.body) o.headers['Content-Type']='application/json';
const r=await fetch(path,o);
if(r.status===401){ logout(); throw new Error('session expired — sign in again'); }
let b={}; try{b=await r.json();}catch(_){}
if(!r.ok) throw new Error(b.message||b.error||('HTTP '+r.status));
return b;
}
window.doLogin=async function(){
const username=$('lu').value.trim(), password=$('lp').value;
if(!username||!password) return;
try{
const r=await api('/v1/admin/session',{method:'POST',body:JSON.stringify({username,password})});
sessionStorage.setItem('ydb_sess',r.token); $('lp').value='';
await boot();
}catch(e){ toast(e.message,'bad'); }
};
window.logout=function(){ sessionStorage.removeItem('ydb_sess'); me={username:'',role:'readonly'};
$('appheader').classList.add('hidden'); $('appview').classList.add('hidden'); $('loginview').classList.remove('hidden'); };
window.showBootstrap=function(on){ $('signinbox').classList.toggle('hidden',on); $('bootstrapbox').classList.toggle('hidden',!on); };
window.doBootstrap=async function(){
const master=$('bt').value.trim(), username=$('bu').value.trim(), password=$('bp').value;
if(!master||!username||password.length<8){ toast('master token + username + password ≥8','bad'); return; }
try{
const r=await fetch('/v1/admin/users',{method:'POST',headers:{'Authorization':'Bearer '+master,'Content-Type':'application/json'},
body:JSON.stringify({username,password,role:'owner'})});
if(!r.ok){ const b=await r.json().catch(()=>({})); throw new Error(b.message||b.error||('HTTP '+r.status)); }
toast('Owner created — signing in…','ok');
const s=await api('/v1/admin/session',{method:'POST',body:JSON.stringify({username,password})});
sessionStorage.setItem('ydb_sess',s.token); showBootstrap(false); await boot();
}catch(e){ toast(e.message,'bad'); }
};
const TABS=[
{id:'cluster',label:'Cluster',min:null},
{id:'databases',label:'Databases',min:'readonly'},
{id:'tokens',label:'Tokens',min:'readonly'},
{id:'packs',label:'Packs',min:'readonly'},
{id:'users',label:'Users',min:'owner'},
{id:'audit',label:'Audit',min:'admin'},
];
let cur='cluster';
function renderNav(){
$('nav').innerHTML=TABS.filter(t=>!t.min||can(t.min)).map(t=>
`<button class="${t.id===cur?'active':''}" onclick="go('${t.id}')">${t.label}</button>`).join('');
$('who').innerHTML=`<b>${esc(me.username)}</b> <span class="pill">${esc(me.role)}</span>`;
}
window.go=function(id){ cur=id; renderNav(); render(); };
async function boot(){
try{ me=await api('/v1/admin/me'); }
catch(e){ logout(); return; }
$('loginview').classList.add('hidden'); $('appheader').classList.remove('hidden'); $('appview').classList.remove('hidden');
if(cur==='users'&&!can('owner')) cur='cluster';
renderNav(); render();
}
function render(){ ({cluster:renderCluster,databases:renderDatabases,tokens:renderTokens,packs:renderPacks,users:renderUsers,audit:renderAudit}[cur]||renderCluster)(); }
let topoTimer=null;
function renderCluster(){
if(topoTimer)clearInterval(topoTimer);
$('appview').innerHTML=`<div id="topo" class="grid"></div><div id="topomsg" class="empty">Loading topology…</div>`;
const roleClass=r=>({leader:'leader',follower:'follower',witness:'witness',candidate:'candidate',quarantined:'quarantined'}[r]||'follower');
async function poll(){
try{
const r=await fetch('/v1/cluster/topology',{cache:'no-store'}); const d=await r.json();
const nodes=(d&&d.nodes)||[];
if(!d||d.raft_mode!=='yrp'){ $('topo').innerHTML=''; $('topomsg').textContent='Not in cluster (yrp) mode.'; return; }
$('topomsg').style.display=nodes.length?'none':'block';
$('topo').innerHTML=nodes.map(n=>{const role=(n.role||(n.reachable?'unknown':'unreachable'))+'',rc=roleClass(role),reach=n.reachable!==false,healthy=n.healthy===true;
return `<div class="card ${rc==='leader'?'leader':''}"><div class="top"><span class="node-id">node ${num(n.node_id)}</span>
<span class="badge ${rc}">${reach?esc(role):'unreachable'}</span><span class="dot ${!reach?'bad':(healthy?'ok':'warn')}"></span></div>
<div class="mono" style="font-size:.72rem;word-break:break-all">${esc(n.addr||'')}${n.self?' · this node':''}</div>
<dl class="rows"><dt>term</dt><dd>${num(n.term)}</dd><dt>applied</dt><dd>${num(n.last_applied_index)}</dd><dt>lag</dt><dd>${num(n.replication_lag)}</dd></dl></div>`;}).join('');
}catch(e){ if($('topomsg'))$('topomsg').textContent='Cannot reach server.'; }
}
poll(); topoTimer=setInterval(()=>{ if(cur==='cluster')poll(); else clearInterval(topoTimer); },3000);
}
async function renderDatabases(){
$('appview').innerHTML=`<div class="panel"><h2>Databases</h2><div id="dblist" class="empty">Loading…</div>
${can('admin')?`<div class="form"><input id="dbname" placeholder="new database name"/><button class="btn" onclick="createDb()">Create</button></div>
<div class="note">Replicated through YRP — created on every node, survives failover.</div>`:''}</div>`;
try{
const {databases}=await api('/v1/admin/databases');
if(!databases.length){ $('dblist').innerHTML='<div class="empty">No databases yet.</div>'; return; }
$('dblist').innerHTML=`<table><thead><tr><th>Name</th><th>ID</th><th>Tokens</th><th>Created</th>${can('admin')?'<th></th>':''}</tr></thead><tbody>${
databases.map(d=>`<tr><td><b>${esc(d.name)}</b></td><td class="mono">${d.id}</td><td>${d.token_count}</td><td class="mono">${esc((d.created_at||'').slice(0,19))}</td>
${can('admin')?`<td class="rowacts"><button class="btn ghost sm" onclick="editQuota(${d.id},'${esc(d.name)}')">Quota</button></td>`:''}</tr>`).join('')}</tbody></table>`;
}catch(e){ $('dblist').innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
}
window.createDb=async function(){ const name=$('dbname').value.trim(); if(!name)return;
try{ const r=await api('/v1/admin/databases',{method:'POST',body:JSON.stringify({name})}); toast(`Database "${name}" created (id ${r.id})`,'ok'); renderDatabases(); }catch(e){toast(e.message,'bad');} };
window.editQuota=async function(id,name){
try{ const q=await api('/v1/admin/databases/'+id+'/quota');
const mm=prompt(`Quota for "${name}" — max_memories`,q.max_memories); if(mm===null)return;
const rps=prompt('max_rps',q.max_rps); if(rps===null)return;
await api('/v1/admin/databases/'+id+'/quota',{method:'PUT',body:JSON.stringify({max_memories:Number(mm),max_rps:Number(rps)})});
toast('Quota updated','ok');
}catch(e){toast(e.message,'bad');}
};
async function renderTokens(){
$('appview').innerHTML=`<div class="panel"><h2>Tokens</h2><div id="reveal"></div><div id="toklist" class="empty">Loading…</div>
${can('admin')?`<div class="form"><select id="tokdb"></select><input id="toklabel" placeholder="label (optional)" style="flex:.7"/><button class="btn" onclick="mintTok()">Mint</button></div>
<div class="note">The plaintext token is shown once. Only its hash is stored + replicated.</div>`:''}</div>`;
try{
const [{tokens},{databases}]=await Promise.all([api('/v1/admin/tokens'),api('/v1/admin/databases')]);
const dbn=id=>(databases.find(d=>d.id===id)||{}).name||('#'+id);
$('toklist').innerHTML=tokens.length?`<table><thead><tr><th>Label</th><th>Database</th><th>Hash</th><th>Created</th>${can('admin')?'<th></th>':''}</tr></thead><tbody>${
tokens.map(t=>`<tr><td><b>${esc(t.label||'—')}</b></td><td>${esc(dbn(t.database_id))}</td><td class="mono">${esc(t.hash_prefix)}…</td><td class="mono">${esc((t.created_at||'').slice(0,19))}</td>
${can('admin')?`<td class="rowacts"><button class="btn warn sm" onclick="rotateTok('${esc(t.hash)}')">Rotate</button><button class="btn danger sm" onclick="revokeTok('${esc(t.hash)}')">Revoke</button></td>`:''}</tr>`).join('')}</tbody></table>`
:'<div class="empty">No active tokens.</div>';
if(can('admin')) $('tokdb').innerHTML=databases.map(d=>`<option value="${d.id}">${esc(d.name)}</option>`).join('');
}catch(e){ $('toklist').innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
}
window.mintTok=async function(){ const database_id=Number($('tokdb').value); if(!database_id){toast('Create a database first','bad');return;}
const label=$('toklabel').value.trim();
try{ const r=await api('/v1/admin/tokens',{method:'POST',body:JSON.stringify({database_id,label})});
showReveal('New token — copy now, shown once',r.token); renderTokensKeepReveal(); }catch(e){toast(e.message,'bad');} };
function showReveal(title,val){ sessionStorage.setItem('ydb_reveal',JSON.stringify({title,val})); }
function renderTokensKeepReveal(){ renderTokens().then(()=>{ const r=sessionStorage.getItem('ydb_reveal'); if(r&&$('reveal')){const {title,val}=JSON.parse(r);
$('reveal').innerHTML=`<div class="reveal"><span><b>${esc(title)}</b><br>${esc(val)}</span><button class="btn ghost sm" onclick="copyReveal()">Copy</button></div>`; window._reveal=val; sessionStorage.removeItem('ydb_reveal'); } }); }
window.copyReveal=function(){ navigator.clipboard&&navigator.clipboard.writeText(window._reveal||''); toast('Copied','ok'); };
window.rotateTok=async function(hash){ if(!confirm('Rotate this token? The old one stops working; a new one is issued.'))return;
try{ const r=await api('/v1/admin/tokens/rotate',{method:'POST',body:JSON.stringify({hash})}); showReveal('Rotated token — copy now',r.token); renderTokensKeepReveal(); }
catch(e){toast(e.message,'bad');} };
window.revokeTok=async function(hash){ if(!confirm('Revoke this token cluster-wide?'))return;
try{ await api('/v1/admin/tokens/revoke',{method:'POST',body:JSON.stringify({hash})}); toast('Revoked','ok'); renderTokens(); }
catch(e){toast(e.message,'bad');} };
async function renderUsers(){
if(!can('owner')){ $('appview').innerHTML='<div class="empty">Owner role required.</div>'; return; }
$('appview').innerHTML=`<div class="panel"><h2>Admin users</h2><div id="userlist" class="empty">Loading…</div>
<div class="form"><input id="uname" placeholder="username"/><input id="upass" type="password" placeholder="password (≥8)"/>
<select id="urole"><option value="readonly">readonly</option><option value="admin">admin</option><option value="owner">owner</option></select>
<button class="btn" onclick="createUser()">Create</button></div>
<div class="note">Passwords are argon2id-hashed; only the hash replicates. The last owner cannot be disabled or demoted.</div></div>`;
try{
const {users}=await api('/v1/admin/users');
$('userlist').innerHTML=`<table><thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead><tbody>${
users.map(u=>`<tr><td><b>${esc(u.username)}</b></td>
<td><select onchange="setRole('${esc(u.username)}',this.value)" ${u.disabled?'disabled':''}>
${['readonly','admin','owner'].map(r=>`<option value="${r}" ${u.role===r?'selected':''}>${r}</option>`).join('')}</select></td>
<td>${u.disabled?'<span class="badge quarantined">disabled</span>':'<span class="badge follower">active</span>'}</td>
<td class="mono">${esc((u.created_at||'').slice(0,19))}</td>
<td class="rowacts"><button class="btn ghost sm" onclick="resetPass('${esc(u.username)}')">Reset pw</button>
${u.disabled?'':`<button class="btn danger sm" onclick="disableUser('${esc(u.username)}')">Disable</button>`}</td></tr>`).join('')}</tbody></table>`;
}catch(e){ $('userlist').innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
}
window.createUser=async function(){ const username=$('uname').value.trim(),password=$('upass').value,role=$('urole').value;
if(!username||password.length<8){toast('username + password ≥8 chars','bad');return;}
try{ await api('/v1/admin/users',{method:'POST',body:JSON.stringify({username,password,role})}); toast(`User "${username}" created`,'ok'); renderUsers(); }catch(e){toast(e.message,'bad');} };
window.setRole=async function(u,role){ try{ const r=await api('/v1/admin/users/'+encodeURIComponent(u)+'/role',{method:'POST',body:JSON.stringify({role})});
if(r.role!==role) toast('Refused (owner-floor): role unchanged','bad'); else toast(`${u} → ${role}`,'ok'); renderUsers(); }catch(e){toast(e.message,'bad');renderUsers();} };
window.resetPass=async function(u){ const p=prompt(`New password for ${u} (≥8 chars)`); if(!p)return; if(p.length<8){toast('≥8 chars','bad');return;}
try{ await api('/v1/admin/users/'+encodeURIComponent(u)+'/password',{method:'POST',body:JSON.stringify({password:p})}); toast('Password rotated — the user’s sessions are now invalid','ok'); }catch(e){toast(e.message,'bad');} };
window.disableUser=async function(u){ if(!confirm(`Disable ${u}? Their sessions die immediately.`))return;
try{ const r=await api('/v1/admin/users/'+encodeURIComponent(u)+'/disable',{method:'POST',body:JSON.stringify({})});
if(!r.disabled) toast('Refused (owner-floor): cannot disable the last owner','bad'); else toast(`${u} disabled`,'ok'); renderUsers(); }catch(e){toast(e.message,'bad');} };
async function renderPacks(){
$('appview').innerHTML=`<div class="panel"><h2>Uploaded packs</h2><div id="packlist" class="empty">Loading…</div>
${can('admin')?`<div class="form"><input type="file" id="packfile" accept=".ydbpack"/><button class="btn" onclick="uploadPack()">Upload</button></div>
<div class="note">A pack is a sealed .ydbpack a database mounts to gain knowledge. Files replicate to every node; mounts survive failover.</div>`:''}</div>
<div class="panel"><h2>Mount into a database</h2>
<div class="form"><select id="pack-db" onchange="loadDbPacks()"></select></div>
<div id="dbpacks" class="empty" style="margin-top:.8rem">Select a database.</div>
</div>`;
try{
const [{packs},{databases}]=await Promise.all([api('/v1/admin/packs'),api('/v1/admin/databases')]);
window._packs=packs; window._dbs=databases;
$('packlist').innerHTML=packs.length?`<table><thead><tr><th>Digest</th><th>Size</th></tr></thead><tbody>${
packs.map(p=>`<tr><td class="mono">${esc(p.digest.slice(0,16))}…</td><td class="mono">${(p.size/1024|0)} KB</td></tr>`).join('')}</tbody></table>`
:'<div class="empty">No packs uploaded yet.</div>';
$('pack-db').innerHTML=`<option value="">— choose database —</option>`+databases.map(d=>`<option value="${d.id}">${esc(d.name)}</option>`).join('');
}catch(e){ $('packlist').innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
}
window.uploadPack=async function(){
const f=$('packfile').files[0]; if(!f){toast('Choose a .ydbpack file','bad');return;}
try{
const buf=await f.arrayBuffer();
const r=await fetch('/v1/admin/packs',{method:'POST',headers:{'Authorization':'Bearer '+tok(),'Content-Type':'application/octet-stream'},body:buf});
const b=await r.json().catch(()=>({})); if(!r.ok) throw new Error(b.message||b.error||('HTTP '+r.status));
toast(`Uploaded (${b.digest.slice(0,12)}…)`,'ok'); renderPacks();
}catch(e){toast(e.message,'bad');}
};
window.loadDbPacks=async function(){
const id=Number($('pack-db').value); if(!id){$('dbpacks').innerHTML='<div class="empty">Select a database.</div>';return;}
try{
const {manifest,status}=await api('/v1/admin/databases/'+id+'/packs');
const state=d=>status.mounted.includes(d)?'<span class="badge follower">mounted</span>'
:status.poisoned.includes(d)?'<span class="badge quarantined">poisoned</span>':'<span class="badge witness">pending</span>';
const mountedDigests=new Set(manifest.map(m=>m.pack_digest));
const avail=(window._packs||[]).filter(p=>!mountedDigests.has(p.digest));
$('dbpacks').innerHTML=`${manifest.length?`<table><thead><tr><th>Pack</th><th>Digest</th><th>State</th>${can('admin')?'<th></th>':''}</tr></thead><tbody>${
manifest.map(m=>`<tr><td><b>${esc(m.pack_name)}</b></td><td class="mono">${esc(m.pack_digest.slice(0,12))}…</td><td>${state(m.pack_digest)}</td>
${can('admin')?`<td class="rowacts"><button class="btn danger sm" onclick="unmountPack(${id},'${esc(m.pack_digest)}')">Unmount</button></td>`:''}</tr>`).join('')}</tbody></table>`
:'<div class="empty">No packs mounted in this database.</div>'}
${can('admin')&&avail.length?`<div class="form" style="margin-top:.8rem"><select id="mount-pick">${avail.map(p=>`<option value="${p.digest}">${esc(p.digest.slice(0,16))}…</option>`).join('')}</select>
<input id="mount-name" placeholder="label (optional)" style="flex:.7"/><button class="btn" onclick="mountPack(${id})">Mount</button></div>`:''}`;
}catch(e){ $('dbpacks').innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
};
window.mountPack=async function(id){ const digest=$('mount-pick').value; const name=$('mount-name').value.trim();
try{ await api('/v1/admin/databases/'+id+'/packs',{method:'POST',body:JSON.stringify(name?{digest,name}:{digest})});
toast('Mount accepted — reconciling across the cluster','ok'); loadDbPacks(); }catch(e){toast(e.message,'bad');} };
window.unmountPack=async function(id,digest){ if(!confirm('Unmount this pack?'))return;
try{ await api('/v1/admin/databases/'+id+'/packs/'+digest,{method:'DELETE'}); toast('Unmount accepted','ok'); loadDbPacks(); }catch(e){toast(e.message,'bad');} };
async function renderAudit(){
$('appview').innerHTML=`<div class="panel"><h2>Audit trail <span class="note" style="text-transform:none">(replicated across all nodes · quorum-durable)</span></h2><div id="auditlist" class="empty">Loading…</div></div>`;
try{
const {audit}=await api('/v1/admin/audit?limit=200');
$('auditlist').innerHTML=audit.length?`<table><thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th><th>#</th></tr></thead><tbody>${
audit.map(a=>`<tr><td class="mono">${esc((a.at||'').slice(0,19))}</td><td><b>${esc(a.actor)}</b></td><td>${esc(a.action)}</td><td>${esc(a.target)}</td><td class="mono">${a.yrp_index}</td></tr>`).join('')}</tbody></table>`
:'<div class="empty">No audit entries yet.</div>';
}catch(e){ $('auditlist').innerHTML=`<div class="empty">${esc(e.message)}</div>`; }
}
if(tok()) boot(); else $('loginview').classList.remove('hidden');
})();
</script>
</body>
</html>