Files
ai-agents/dashboard/static/admin.html
T
Eric Jungbauer eac7b64a90 The Pirate — Phase 1.a: conversational read-only media agent
Ships a chat-based agent at /pirate that LLM-routes user questions to media-stack tools and returns natural-language answers grounded in real data. Foundation built on top of the existing API-tokens + dual-auth infrastructure so other apps (Open WebUI, HA voice, Synap) can consume the same Pirate API.

New subsystem (not the standard trigger/result pattern):
- pirate_conversations + pirate_messages tables
- service_configs table (admin-wide creds shared by media agents)
- /api/pirate/chat + /api/pirate/conversations/* (dual-auth: user session OR Bearer token scoped to user's pirate instance)
- /api/internal/pirate/* endpoints used by runtime subprocess
- /api/admin/services + Services tab in admin.html for cred management
- Auto-seeded service_configs on startup from Media Stack Reference defaults (never overwrite admin edits)
- Auto-seeded pirate catalog entry + per-user pirate instance on startup

Pirate package (agents/pirate/):
- prompts.py: system prompt, enforces read-only in Phase 1
- runtime.py: Anthropic-native tool-use loop (max 8 iterations, persists every turn)
- tools/_common.py: service_configs fetch + qBit session auth
- tools/sonarr.py: queue, upcoming, series_search, library_stats
- tools/radarr.py: queue, movie_search, library_stats
- tools/qbittorrent.py: torrents, transfer_stats, categories
- tools/storage.py: disk_space (via Sonarr diskspace API)
- Default model: claude-sonnet-4-5 (Haiku fumbles multi-step chains)

Dashboard:
- static/pirate.html — full chat UI with conversation sidebar, suggestion chips, inline tool-call visualization, 24h idle reset + New Chat button
- Pirate button added to main dashboard header

Wiki reorg: Agents / Developer Guides / Plans parent docs, per-agent reference docs, The Pirate doc. API Clients + Calling Agents docs moved under Developer Guides.

Working folder: PIRATE_PHASE_1A.md + NEXT_SESSION_PROMPT.md for fast bootstrap.

Smoke tested end-to-end: real tool calls against qBittorrent (13 active torrents correctly reported) and Sonarr disk-space; multi-turn conversation state preserved across follow-up questions.

On deck: Phase 1.b (Lidarr/Whisparr/Overseerr/Plex tools), then 1.d (OWUI pipeline), then 1.c (HA voice).
2026-04-20 19:01:50 +00:00

474 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin — Agent Command Center</title>
<style>
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#232733;--border:#2e3345;--text:#e4e6ed;--text-dim:#8b8fa3;--accent:#6c5ce7;--accent-hover:#7c6ef0;--green:#00b894;--red:#e17055;--yellow:#fdcb6e;--blue:#74b9ff}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:1rem 2rem;display:flex;align-items:center;justify-content:space-between}
.header h1{font-size:1.4rem;font-weight:600}
.header-right{display:flex;gap:.75rem;align-items:center}
.small-btn{background:none;border:1px solid var(--border);color:var(--text-dim);padding:.35rem .75rem;border-radius:6px;font-size:.8rem;cursor:pointer}
.small-btn:hover{border-color:var(--text-dim);color:var(--text)}
.container{max-width:1000px;margin:0 auto;padding:1.5rem 2rem}
.tabs{display:flex;gap:0;margin-bottom:1.5rem;border-bottom:1px solid var(--border)}
.tab{padding:.6rem 1.25rem;font-size:.9rem;cursor:pointer;color:var(--text-dim);border-bottom:2px solid transparent;transition:all .2s}
.tab:hover{color:var(--text)}.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.panel{display:none}.panel.active{display:block}
table{width:100%;border-collapse:collapse;background:var(--surface);border-radius:10px;overflow:hidden;border:1px solid var(--border)}
th{text-align:left;padding:.75rem 1rem;font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-dim);background:var(--surface2);border-bottom:1px solid var(--border)}
td{padding:.65rem 1rem;font-size:.85rem;border-bottom:1px solid var(--border)}
tr:last-child td{border-bottom:none}
tr:hover td{background:var(--surface2)}
.badge{display:inline-block;padding:.15rem .6rem;border-radius:12px;font-size:.75rem;font-weight:500;text-transform:uppercase}
.badge.admin{background:rgba(225,112,85,.15);color:var(--red)}
.badge.user{background:rgba(0,184,148,.15);color:var(--green)}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-bottom:.75rem}
.form-row.full{grid-template-columns:1fr}
.field label{display:block;font-size:.75rem;font-weight:500;color:var(--text-dim);text-transform:uppercase;margin-bottom:.3rem}
.field input,.field select{width:100%;padding:.5rem .65rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.85rem;outline:none}
.field input:focus,.field select:focus{border-color:var(--accent)}
.form-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:1.25rem;margin-bottom:1rem}
.form-card h3{margin-bottom:1rem;font-size:.95rem}
.btn{padding:.5rem 1.25rem;border:none;border-radius:6px;font-size:.85rem;cursor:pointer}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
.btn-danger{background:var(--red);color:#fff;opacity:.8}.btn-danger:hover{opacity:1}
.btn-sm{padding:.3rem .75rem;font-size:.8rem}
.action-btns{display:flex;gap:.5rem}
.msg{font-size:.8rem;margin-top:.5rem}
.msg.ok{color:var(--green)}.msg.err{color:var(--red)}
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1rem;margin-bottom:1.5rem}
.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:1rem;text-align:center}
.stat-card .val{font-size:1.8rem;font-weight:700;color:var(--accent)}
.stat-card .lbl{font-size:.8rem;color:var(--text-dim);margin-top:.25rem}
</style>
</head>
<body>
<div class="header">
<h1>Admin Panel</h1>
<div class="header-right">
<button class="small-btn" onclick="location.href='/'">Dashboard</button>
<button class="small-btn" onclick="logout()">Logout</button>
</div>
</div>
<div class="container">
<div class="tabs">
<div class="tab active" onclick="switchTab('users')">Users</div>
<div class="tab" onclick="switchTab('catalog')">Agent Catalog</div>
<div class="tab" onclick="switchTab('llm')">LLM Providers</div>
<div class="tab" onclick="switchTab('bridges')">Bridges</div>
<div class="tab" onclick="switchTab('api-clients')">API Clients</div>
<div class="tab" onclick="switchTab('services')">Services</div>
<div class="tab" onclick="switchTab('system')">System</div>
</div>
<!-- Users -->
<div class="panel active" id="panel-users">
<div class="form-card">
<h3>Create User</h3>
<div class="form-row">
<div class="field"><label>Username</label><input id="nu-user" placeholder="username"></div>
<div class="field"><label>Email</label><input type="email" id="nu-email" placeholder="user@example.com"></div>
</div>
<div class="form-row">
<div class="field"><label>Password</label><input type="password" id="nu-pass"></div>
<div class="field"><label>Display Name</label><input id="nu-name" placeholder="Full Name"></div>
</div>
<div class="form-row">
<div class="field"><label>Role</label><select id="nu-role"><option value="user">User</option><option value="admin">Admin</option></select></div>
<div class="field"></div>
</div>
<button class="btn btn-primary" onclick="createUser()">Create</button>
<span class="msg" id="nu-msg"></span>
</div>
<table id="users-table"><thead><tr><th>Username</th><th>Email</th><th>Display Name</th><th>Role</th><th>Agents</th><th>Actions</th></tr></thead><tbody></tbody></table>
</div>
<!-- Catalog -->
<div class="panel" id="panel-catalog">
<div class="form-card">
<h3>Add Catalog Entry</h3>
<div class="form-row">
<div class="field"><label>ID (slug)</label><input id="nc-id" placeholder="my-agent"></div>
<div class="field"><label>Name</label><input id="nc-name" placeholder="My Agent"></div>
</div>
<div class="form-row">
<div class="field"><label>Category</label><select id="nc-cat"><option value="data">Data</option><option value="briefing">Briefing</option><option value="utility">Utility</option></select></div>
<div class="field"><label>Sub-agent?</label><select id="nc-sub"><option value="false">No</option><option value="true">Yes</option></select></div>
</div>
<div class="form-row full"><div class="field"><label>Description</label><input id="nc-desc" placeholder="What this agent does"></div></div>
<button class="btn btn-primary" onclick="createCatalog()">Add</button>
<span class="msg" id="nc-msg"></span>
</div>
<table id="catalog-table"><thead><tr><th>ID</th><th>Name</th><th>Category</th><th>Type</th><th>Actions</th></tr></thead><tbody></tbody></table>
</div>
<!-- LLM Providers -->
<div class="panel" id="panel-llm">
<div class="form-card">
<h3>Add LLM Provider</h3>
<div class="form-row">
<div class="field"><label>Name</label><input id="nl-name" placeholder="Anthropic"></div>
<div class="field"><label>Type</label><select id="nl-type"><option value="anthropic">Anthropic</option><option value="openai">OpenAI</option><option value="litellm">LiteLLM</option><option value="ollama">Ollama</option></select></div>
</div>
<div class="form-row">
<div class="field"><label>API URL</label><input id="nl-url" placeholder="https://api.anthropic.com"></div>
<div class="field"><label>Default Model</label><input id="nl-model" placeholder="claude-sonnet-4-5-20250514"></div>
</div>
<div class="form-row">
<div class="field"><label>API Key</label><input type="password" id="nl-key"></div>
<div class="field"><label>Default?</label><select id="nl-default"><option value="false">No</option><option value="true">Yes</option></select></div>
</div>
<button class="btn btn-primary" onclick="createProvider()">Add</button>
<span class="msg" id="nl-msg"></span>
</div>
<table id="llm-table"><thead><tr><th>Name</th><th>Type</th><th>URL</th><th>Model</th><th>Default</th><th>Actions</th></tr></thead><tbody></tbody></table>
</div>
<!-- Bridges -->
<div class="panel" id="panel-bridges">
<table id="bridges-table"><thead><tr><th>User</th><th>Hostname</th><th>URL</th><th>Platform</th><th>Status</th><th>Last Heartbeat</th><th>Capabilities</th></tr></thead><tbody></tbody></table>
</div>
<!-- API Clients -->
<div class="panel" id="panel-api-clients">
<div class="form-card">
<h3>Create API Client</h3>
<p style="font-size:.8rem;color:var(--text-dim);margin-bottom:.75rem">
Issues a bearer token that an external app (Synap, WSIT, etc.) can use to trigger agent instances and read results.
Tokens are scoped to specific instances — see the
<a href="https://wiki.jfamily.io/doc/api-clients-token-scoping-KhtWinIzMT" target="_blank" style="color:var(--accent)">token scoping doc</a>.
</p>
<div class="form-row">
<div class="field"><label>Name</label><input id="nac-name" placeholder="Synap"></div>
<div class="field"><label>Description</label><input id="nac-desc" placeholder="What this app uses the token for"></div>
</div>
<div class="form-row full">
<div class="field">
<label>Authorized Instances</label>
<div id="nac-instances" style="max-height:200px;overflow-y:auto;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.5rem"></div>
</div>
</div>
<button class="btn btn-primary" onclick="createApiClient()">Create</button>
<span class="msg" id="nac-msg"></span>
</div>
<!-- Token reveal modal (shown once after create/rotate) -->
<div id="token-reveal" class="form-card" style="display:none;border-color:var(--yellow);background:rgba(253,203,110,.05)">
<h3 style="color:var(--yellow)">New token — copy it now</h3>
<p style="font-size:.8rem;color:var(--text-dim);margin-bottom:.75rem">
This is the only time you will see this token. Copy it into the app's environment now. If you lose it, rotate the client to issue a new one.
</p>
<div style="display:flex;gap:.5rem;align-items:center">
<code id="token-reveal-value" style="flex:1;padding:.6rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;overflow-x:auto;white-space:nowrap"></code>
<button class="btn btn-primary btn-sm" onclick="copyToken()">Copy</button>
<button class="btn btn-sm small-btn" onclick="document.getElementById('token-reveal').style.display='none'">Dismiss</button>
</div>
</div>
<table id="api-clients-table">
<thead><tr><th>Name</th><th>Prefix</th><th>Scopes</th><th>Created</th><th>Last Used</th><th>Status</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- Services (system-wide creds used by agents like The Pirate) -->
<div class="panel" id="panel-services">
<div class="form-card">
<h3>Service Credentials</h3>
<p style="font-size:.8rem;color:var(--text-dim);margin-bottom:.75rem">
Admin-level URLs + API keys shared across all users. The Pirate agent (and future media agents) use these to query Sonarr, Radarr, qBittorrent, Plex, etc.
Leaving <code>api_key</code> / <code>password</code> blank preserves the existing stored value (useful when editing just the URL).
</p>
</div>
<table id="services-table">
<thead><tr><th>Service</th><th>Base URL</th><th>API Key / Password</th><th>Updated</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- System -->
<div class="panel" id="panel-system">
<div class="stat-grid" id="sys-stats"></div>
</div>
</div>
<script>
const API='';
function switchTab(name){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.querySelector(`.tab[onclick*="${name}"]`).classList.add('active');
document.getElementById('panel-'+name).classList.add('active');
}
async function logout(){await fetch(API+'/api/logout',{method:'POST'});location.href='/login'}
function showMsg(id,text,ok){const el=document.getElementById(id);el.textContent=text;el.className='msg '+(ok?'ok':'err');setTimeout(()=>el.textContent='',3000)}
// --- Users ---
async function loadUsers(){
const res=await fetch(API+'/api/admin/users');if(res.status===403||res.status===401){location.href='/';return}
const users=await res.json();
document.querySelector('#users-table tbody').innerHTML=users.map(u=>`<tr>
<td>${u.username}</td><td style="font-size:.8rem">${u.email||'<span style="color:var(--yellow)">none</span>'}</td><td>${u.display_name}</td><td><span class="badge ${u.role}">${u.role}</span></td>
<td>${u.instance_count}</td>
<td><div class="action-btns"><button class="btn btn-danger btn-sm" onclick="deleteUser(${u.id},'${u.username}')">Delete</button></div></td>
</tr>`).join('');
}
async function createUser(){
const res=await fetch(API+'/api/admin/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
username:document.getElementById('nu-user').value,email:document.getElementById('nu-email').value,
password:document.getElementById('nu-pass').value,
display_name:document.getElementById('nu-name').value,role:document.getElementById('nu-role').value})});
if(res.ok){showMsg('nu-msg','Created',true);loadUsers();['nu-user','nu-email','nu-pass','nu-name'].forEach(id=>document.getElementById(id).value='')}
else{const e=await res.json();showMsg('nu-msg',e.detail||'Error',false)}
}
async function deleteUser(id,name){if(!confirm('Delete user '+name+'?'))return;await fetch(API+'/api/admin/users/'+id,{method:'DELETE'});loadUsers()}
// --- Catalog ---
async function loadCatalog(){
const res=await fetch(API+'/api/admin/users');// check auth
const cres=await fetch(API+'/api/catalog');
const catalog=await cres.json();
document.querySelector('#catalog-table tbody').innerHTML=catalog.map(c=>`<tr>
<td><code>${c.id}</code></td><td>${c.name}</td><td><span class="badge ${c.category}">${c.category}</span></td>
<td>${c.is_sub_agent?'Sub-agent':'Standalone'}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteCatalog('${c.id}')">Delete</button></td>
</tr>`).join('');
}
async function createCatalog(){
const res=await fetch(API+'/api/admin/catalog',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
id:document.getElementById('nc-id').value,name:document.getElementById('nc-name').value,
description:document.getElementById('nc-desc').value,category:document.getElementById('nc-cat').value,
is_sub_agent:document.getElementById('nc-sub').value==='true'})});
if(res.ok){showMsg('nc-msg','Added',true);loadCatalog()}
else{const e=await res.json();showMsg('nc-msg',e.detail||'Error',false)}
}
async function deleteCatalog(id){if(!confirm('Delete catalog entry '+id+'?'))return;await fetch(API+'/api/admin/catalog/'+id,{method:'DELETE'});loadCatalog()}
// --- LLM Providers ---
async function loadProviders(){
const res=await fetch(API+'/api/admin/llm-providers');
const providers=await res.json();
document.querySelector('#llm-table tbody').innerHTML=providers.map(p=>`<tr>
<td>${p.name}</td><td>${p.provider_type}</td><td style="font-size:.8rem">${p.api_url||'-'}</td>
<td>${p.default_model||'-'}</td><td>${p.is_default?'Yes':'-'}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteProvider(${p.id})">Delete</button></td>
</tr>`).join('');
}
async function createProvider(){
const res=await fetch(API+'/api/admin/llm-providers',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
name:document.getElementById('nl-name').value,provider_type:document.getElementById('nl-type').value,
api_url:document.getElementById('nl-url').value,api_key:document.getElementById('nl-key').value,
default_model:document.getElementById('nl-model').value,is_default:document.getElementById('nl-default').value==='true'})});
if(res.ok){showMsg('nl-msg','Added',true);loadProviders()}
else{const e=await res.json();showMsg('nl-msg',e.detail||'Error',false)}
}
async function deleteProvider(id){if(!confirm('Delete this provider?'))return;await fetch(API+'/api/admin/llm-providers/'+id,{method:'DELETE'});loadProviders()}
// --- Bridges ---
async function loadBridges(){
const res=await fetch(API+'/api/admin/bridges');
if(!res.ok)return;
const bridges=await res.json();
const tbody=document.querySelector('#bridges-table tbody');
if(!bridges.length){tbody.innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--text-dim)">No bridges connected</td></tr>';return}
tbody.innerHTML=bridges.map(b=>`<tr>
<td>${b.username}</td>
<td>${b.hostname||'-'}</td>
<td style="font-size:.8rem">${b.bridge_url||'-'}</td>
<td>${b.platform}</td>
<td><span class="badge ${b.status==='online'?'user':'admin'}">${b.status}</span></td>
<td style="font-size:.8rem">${b.last_heartbeat||'-'}</td>
<td style="font-size:.8rem">${(b.capabilities||[]).join(', ')}</td>
</tr>`).join('');
}
// --- API Clients ---
let _allInstances=[]; // cache
let _lastToken=''; // for copy button
async function loadAllInstancesForPicker(selected){
selected=selected||new Set();
const res=await fetch(API+'/api/admin/instances');
if(!res.ok)return;
_allInstances=await res.json();
const container=document.getElementById('nac-instances');
if(!_allInstances.length){container.innerHTML='<div style="color:var(--text-dim);font-size:.85rem">No instances exist yet — create one first.</div>';return}
// Group by user
const byUser={};
_allInstances.forEach(i=>{(byUser[i.username]=byUser[i.username]||[]).push(i)});
container.innerHTML=Object.keys(byUser).sort().map(un=>`
<div style="margin-bottom:.5rem"><div style="font-size:.75rem;color:var(--text-dim);text-transform:uppercase;margin-bottom:.25rem">${un}</div>
${byUser[un].map(i=>`<label style="display:flex;align-items:center;gap:.5rem;padding:.25rem 0;font-size:.85rem;cursor:pointer">
<input type="checkbox" value="${i.id}" class="nac-inst-cb" ${selected.has(i.id)?'checked':''}>
<span><code style="color:var(--accent)">${i.catalog_id}</code> · ${i.name} <span style="color:var(--text-dim)">(id ${i.id})</span></span>
</label>`).join('')}</div>`).join('');
}
async function loadApiClients(){
const res=await fetch(API+'/api/admin/api-clients');
if(!res.ok)return;
const clients=await res.json();
const tbody=document.querySelector('#api-clients-table tbody');
if(!clients.length){tbody.innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--text-dim)">No API clients yet</td></tr>';return}
tbody.innerHTML=clients.map(c=>{
const status=c.revoked?'<span class="badge admin">revoked</span>':'<span class="badge user">active</span>';
const scopeLabels=(c.instance_ids||[]).map(id=>{
const inst=_allInstances.find(i=>i.id===id);
return inst?`${inst.username}/${inst.catalog_id}(${id})`:`#${id}`;
}).join(', ')||'<span style="color:var(--red)">none</span>';
const actions=c.revoked
? `<button class="btn btn-sm small-btn" onclick="rotateApiClient(${c.id})">Rotate</button> <button class="btn btn-danger btn-sm" onclick="deleteApiClient(${c.id},'${c.name}')">Delete</button>`
: `<button class="btn btn-sm small-btn" onclick="editApiClient(${c.id})">Edit</button> <button class="btn btn-sm small-btn" onclick="rotateApiClient(${c.id})">Rotate</button> <button class="btn btn-danger btn-sm" onclick="revokeApiClient(${c.id})">Revoke</button>`;
return `<tr>
<td><strong>${c.name}</strong>${c.description?`<div style="font-size:.75rem;color:var(--text-dim)">${c.description}</div>`:''}</td>
<td><code style="font-size:.75rem">${c.token_prefix||'-'}…</code></td>
<td style="font-size:.75rem">${scopeLabels}</td>
<td style="font-size:.75rem">${c.created_at?new Date(c.created_at).toLocaleDateString():'-'}</td>
<td style="font-size:.75rem">${c.last_used_at?new Date(c.last_used_at).toLocaleString():'<span style="color:var(--text-dim)">never</span>'}</td>
<td>${status}</td>
<td><div class="action-btns">${actions}</div></td>
</tr>`;
}).join('');
}
async function createApiClient(){
const name=document.getElementById('nac-name').value.trim();
const desc=document.getElementById('nac-desc').value.trim();
const instance_ids=[...document.querySelectorAll('.nac-inst-cb:checked')].map(cb=>parseInt(cb.value));
if(!name){showMsg('nac-msg','Name is required',false);return}
if(!instance_ids.length){showMsg('nac-msg','Pick at least one instance to scope the token',false);return}
const res=await fetch(API+'/api/admin/api-clients',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({name,description:desc,instance_ids})});
if(res.ok){
const c=await res.json();
revealToken(c.token);
showMsg('nac-msg','Created',true);
document.getElementById('nac-name').value='';
document.getElementById('nac-desc').value='';
loadAllInstancesForPicker();
loadApiClients();
} else {
const e=await res.json();showMsg('nac-msg',e.detail||'Error',false);
}
}
function revealToken(token){
_lastToken=token;
document.getElementById('token-reveal-value').textContent=token;
document.getElementById('token-reveal').style.display='block';
document.getElementById('token-reveal').scrollIntoView({behavior:'smooth',block:'center'});
}
function copyToken(){
navigator.clipboard.writeText(_lastToken).then(()=>{
const btn=event.target;const orig=btn.textContent;btn.textContent='Copied!';setTimeout(()=>btn.textContent=orig,1500);
});
}
async function revokeApiClient(id){
if(!confirm('Revoke this token? The app using it will stop working immediately.'))return;
await fetch(API+'/api/admin/api-clients/'+id+'/revoke',{method:'POST'});
loadApiClients();
}
async function rotateApiClient(id){
if(!confirm('Issue a new token? The old one will stop working immediately.'))return;
const res=await fetch(API+'/api/admin/api-clients/'+id+'/rotate',{method:'POST'});
if(res.ok){
const c=await res.json();
revealToken(c.token);
loadApiClients();
}
}
async function deleteApiClient(id,name){
if(!confirm('Permanently delete client '+name+'?'))return;
await fetch(API+'/api/admin/api-clients/'+id,{method:'DELETE'});
loadApiClients();
}
async function editApiClient(id){
const res=await fetch(API+'/api/admin/api-clients');
const clients=await res.json();
const c=clients.find(x=>x.id===id);
if(!c)return;
const existing=new Set(c.instance_ids||[]);
const checkboxes=[..._allInstances].map(i=>`${i.id}: ${i.username}/${i.catalog_id} (${i.name})${existing.has(i.id)?' [currently authorized]':''}`).join('\n');
const input=prompt(`Edit scopes for "${c.name}"\n\nEnter comma-separated instance IDs this token should be allowed to access.\n\nAvailable instances:\n${checkboxes}`, [...existing].join(','));
if(input===null)return;
const ids=input.split(',').map(s=>parseInt(s.trim())).filter(n=>!isNaN(n));
const res2=await fetch(API+'/api/admin/api-clients/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify({instance_ids:ids})});
if(res2.ok)loadApiClients();
else{const e=await res2.json();alert(e.detail||'Error')}
}
// --- Services ---
async function loadServices(){
const res=await fetch(API+'/api/admin/services');
if(!res.ok)return;
const services=await res.json();
document.querySelector('#services-table tbody').innerHTML=services.map(s=>{
const needsCreds=['qbittorrent','plex'].includes(s.service_name);
const secretField=needsCreds
? `<input placeholder="password" type="password" id="svc-pw-${s.service_name}" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem">`
: `<input placeholder="${s.api_key==='set'?'(stored — leave blank to keep)':'api key'}" type="password" id="svc-key-${s.service_name}" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem">`;
const userField=needsCreds
? `<input placeholder="username" id="svc-user-${s.service_name}" value="${s.username||''}" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem;margin-bottom:.25rem">`
: '';
const statusColor=s.configured?'var(--green)':'var(--yellow)';
return `<tr>
<td><strong>${s.label||s.service_name}</strong> <span style="color:${statusColor};font-size:.7rem">${s.configured?'●':'○'}</span>
<div style="font-size:.7rem;color:var(--text-dim)">${s.description||''}</div></td>
<td><input value="${s.base_url||''}" id="svc-url-${s.service_name}" placeholder="http://host:port" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem"></td>
<td>${userField}${secretField}</td>
<td style="font-size:.7rem;color:var(--text-dim)">${s.updated_at?new Date(s.updated_at).toLocaleDateString():'-'}</td>
<td><button class="btn btn-sm btn-primary" onclick="saveService('${s.service_name}')">Save</button></td>
</tr>`;
}).join('');
}
async function saveService(slug){
const base_url=document.getElementById('svc-url-'+slug).value.trim();
const keyEl=document.getElementById('svc-key-'+slug);
const pwEl=document.getElementById('svc-pw-'+slug);
const userEl=document.getElementById('svc-user-'+slug);
const body={
service_name:slug,
base_url,
api_key:keyEl?keyEl.value:'',
username:userEl?userEl.value:'',
password:pwEl?pwEl.value:'',
extra:{},
};
const res=await fetch(API+'/api/admin/services/'+slug,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
if(res.ok){loadServices()}
else{const e=await res.json();alert(e.detail||'Error')}
}
// --- System ---
async function loadSystem(){
const[usersRes,instRes]=await Promise.all([fetch(API+'/api/admin/users'),fetch(API+'/api/health')]);
const users=await usersRes.json();const health=await instRes.json();
document.getElementById('sys-stats').innerHTML=`
<div class="stat-card"><div class="val">${users.length}</div><div class="lbl">Users</div></div>
<div class="stat-card"><div class="val">${users.reduce((s,u)=>s+u.instance_count,0)}</div><div class="lbl">Agent Instances</div></div>
<div class="stat-card"><div class="val">${health.version||'?'}</div><div class="lbl">Version</div></div>
<div class="stat-card"><div class="val" style="color:var(--green)">OK</div><div class="lbl">Status</div></div>`;
}
// Init
loadUsers();loadCatalog();loadProviders();loadBridges();loadAllInstancesForPicker().then(loadApiClients);loadServices();loadSystem();
</script>
</body>
</html>