eac7b64a90
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).
474 lines
27 KiB
HTML
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>
|