Retheme admin page, improve save feedback, add monitor nav link

Admin UI:
- Full retro-brutalist theme matching main UI
- JetBrains Mono headings, amber accent, 2px borders
- Animated dot-grid background + scanlines
- Square toggles (was rounded)
- Backdrop-filter blur on cards
- Nav bar with links to Team, Lab, Logs, Monitor

Save feedback:
- Every save now verifies the API response (checks d.ok)
- Toast shows what was saved: "ollama provider saved / Enabled"
- Toast shows details: "Cloud models saved / 3 models configured"
- Toast shows timeout details: "Timeouts saved / Global: 300s, 2 overrides"
- Failed saves show red toast with error message
- Toast fade-out animation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root 2026-03-26 02:44:58 -05:00
parent 344e11f4b2
commit 9af071df6c

View File

@ -2046,71 +2046,90 @@ ADMIN_HTML = r"""
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Team - Admin</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0c10; --surface: #151820; --surface2: #1c2030; --border: #272d3f;
--text: #e4e4e7; --text2: #a1a1aa; --accent: #6366f1; --accent2: #818cf8;
--green: #22c55e; --orange: #f59e0b; --red: #ef4444; --blue: #3b82f6;
--glow: rgba(99,102,241,0.12);
--bg: #08090c; --surface: rgba(14,16,22,0.82); --surface2: rgba(20,22,30,0.7); --border: #2a2d35;
--text: #e8e6e3; --text2: #7a7872; --accent: #e2b55a; --accent2: #f0cc74;
--green: #4ade80; --orange: #f59e0b; --red: #e05252; --blue: #5b9cf5;
--glow: rgba(226,181,90,0.06);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
.container { max-width: 1100px; margin: 0 auto; padding: 16px 24px; }
header { display: flex; align-items: center; gap: 14px; padding: 16px 0; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
header h1 { font-size: 22px; font-weight: 700; letter-spacing: -0.5px; }
header h1 span { background: linear-gradient(135deg, var(--accent2), #a78bfa); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
header a { color: var(--accent2); text-decoration: none; font-size: 13px; margin-left: auto; }
header a:hover { text-decoration: underline; }
.tabs { display: flex; gap: 4px; margin-bottom: 20px; }
.tab { padding: 8px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; }
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; overflow-x: hidden; }
canvas#bg-grid { position: fixed; inset: 0; z-index: 0; pointer-events: none; }
.scanlines { position: fixed; inset: 0; z-index: 1; pointer-events: none; background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.02) 2px, rgba(0,0,0,0.02) 4px); }
.container { max-width: 1100px; margin: 0 auto; padding: 16px 28px; position: relative; z-index: 10; }
header { display: flex; align-items: center; gap: 14px; padding: 18px 0; border-bottom: 2px solid var(--border); margin-bottom: 22px; }
header h1 { font-family: 'JetBrains Mono', monospace; font-size: 20px; font-weight: 700; letter-spacing: -0.5px; }
header h1 span { color: var(--accent); }
.tabs { display: flex; gap: 4px; margin-bottom: 20px; flex-wrap: wrap; }
.tab { padding: 8px 16px; background: transparent; border: 2px solid var(--border); border-radius: 2px; color: var(--text2); cursor: pointer; font-size: 11px; font-weight: 600; transition: all 0.15s; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; }
.tab:hover { border-color: var(--accent); color: var(--text); }
.tab.active { border-color: var(--accent); background: var(--glow); color: var(--accent2); }
.tab.active { border-color: var(--accent); background: var(--glow); color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 18px; margin-bottom: 12px; }
.card h3 { font-size: 15px; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.card h3 .prov-dot { width: 8px; height: 8px; border-radius: 50%; }
.card { background: var(--surface); border: 2px solid var(--border); border-radius: 2px; padding: 20px; margin-bottom: 12px; backdrop-filter: blur(16px); position: relative; }
.card::before { content: ''; position: absolute; top: -1px; left: 16px; right: 16px; height: 1px; background: linear-gradient(90deg, transparent, rgba(226,181,90,0.15), transparent); }
.card h3 { font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.card h3 .prov-dot { width: 8px; height: 8px; border-radius: 2px; }
.row { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; font-size: 13px; }
.row label { width: 100px; color: var(--text2); flex-shrink: 0; font-weight: 500; }
.row input, .row select { flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 5px; padding: 7px 10px; font-size: 13px; }
.row input:focus, .row select:focus { outline: none; border-color: var(--accent); }
.row label { width: 100px; color: var(--text2); flex-shrink: 0; font-weight: 600; font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.row input, .row select { flex: 1; background: rgba(0,0,0,0.4); border: 2px solid var(--border); color: var(--text); border-radius: 2px; padding: 8px 10px; font-size: 13px; }
.row input:focus, .row select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.toggle { position: relative; width: 40px; height: 22px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle .slider { position: absolute; inset: 0; background: var(--border); border-radius: 22px; cursor: pointer; transition: 0.2s; }
.toggle .slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--text2); border-radius: 50%; transition: 0.2s; }
.toggle .slider { position: absolute; inset: 0; background: var(--border); border-radius: 2px; cursor: pointer; transition: 0.2s; }
.toggle .slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; bottom: 3px; background: var(--text2); border-radius: 2px; transition: 0.2s; }
.toggle input:checked + .slider { background: var(--accent); }
.toggle input:checked + .slider::before { transform: translateX(18px); background: white; }
.btn { padding: 7px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface2); color: var(--text); cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; }
.btn:hover { border-color: var(--accent); color: var(--accent2); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: white; }
.btn-primary:hover { filter: brightness(1.15); }
.btn-sm { padding: 4px 10px; font-size: 11px; }
.btn-green { background: rgba(34,197,94,0.15); border-color: var(--green); color: var(--green); }
.btn-red { background: rgba(239,68,68,0.1); border-color: var(--red); color: var(--red); }
.toast { position: fixed; top: 20px; right: 20px; padding: 10px 16px; border-radius: 8px; font-size: 13px; z-index: 100; animation: fadeIn 0.2s; }
.toast.ok { background: rgba(34,197,94,0.15); border: 1px solid var(--green); color: var(--green); }
.toast.err { background: rgba(239,68,68,0.1); border: 1px solid var(--red); color: var(--red); }
.toggle input:checked + .slider::before { transform: translateX(18px); background: #08090c; }
.btn { padding: 7px 14px; border: 2px solid var(--border); border-radius: 2px; background: transparent; color: var(--text); cursor: pointer; font-size: 10px; font-weight: 700; transition: all 0.15s; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #08090c; }
.btn-primary:hover { background: var(--accent2); }
.btn-sm { padding: 4px 10px; font-size: 9px; }
.btn-g,.btn-green { border-color: rgba(74,222,128,0.3); color: var(--green); }
.btn-g:hover,.btn-green:hover { border-color: var(--green); background: rgba(74,222,128,0.06); }
.btn-r,.btn-red { border-color: rgba(224,82,82,0.3); color: var(--red); }
.btn-r:hover,.btn-red:hover { border-color: var(--red); background: rgba(224,82,82,0.06); }
.toast { position: fixed; top: 20px; right: 20px; padding: 10px 18px; border-radius: 2px; font-size: 11px; z-index: 100; animation: fadeIn 0.2s; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; border-width: 2px; border-style: solid; backdrop-filter: blur(16px); }
.toast.ok { background: rgba(74,222,128,0.1); border-color: var(--green); color: var(--green); box-shadow: 0 0 16px rgba(74,222,128,0.1); }
.toast.err { background: rgba(224,82,82,0.1); border-color: var(--red); color: var(--red); box-shadow: 0 0 16px rgba(224,82,82,0.1); }
.toast .toast-detail { font-size: 9px; opacity: 0.7; margin-top: 2px; text-transform: none; letter-spacing: 0; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; } }
.model-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; margin-bottom: 4px; font-size: 13px; }
.model-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: rgba(0,0,0,0.25); border: 2px solid var(--border); border-radius: 2px; margin-bottom: 4px; font-size: 13px; }
.model-row .name { flex: 1; font-weight: 500; }
.model-row .meta { color: var(--text2); font-size: 11px; }
.search-input { width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; margin-bottom: 12px; }
.search-input:focus { outline: none; border-color: var(--accent); }
.model-row .meta { color: var(--text2); font-size: 10px; font-family: 'JetBrains Mono', monospace; }
.search-input { width: 100%; padding: 8px 12px; background: rgba(0,0,0,0.4); border: 2px solid var(--border); border-radius: 2px; color: var(--text); font-size: 13px; margin-bottom: 12px; }
.search-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.or-list { max-height: 500px; overflow-y: auto; }
.or-list::-webkit-scrollbar { width: 4px; }
.or-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.timeout-row { display: grid; grid-template-columns: 1fr 100px; gap: 10px; align-items: center; padding: 6px 0; font-size: 13px; border-bottom: 1px solid var(--border); }
.or-list::-webkit-scrollbar { width: 3px; }
.or-list::-webkit-scrollbar-thumb { background: rgba(226,181,90,0.15); border-radius: 0; }
.timeout-row { display: grid; grid-template-columns: 1fr 100px; gap: 10px; align-items: center; padding: 8px 0; font-size: 13px; border-bottom: 1px solid var(--border); }
.timeout-row:last-child { border: none; }
.timeout-row input { width: 80px; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 4px; padding: 4px 8px; font-size: 12px; text-align: center; }
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--text2); margin: 16px 0 10px; font-weight: 600; }
.empty { text-align: center; padding: 30px; color: var(--text2); font-size: 13px; }
.timeout-row input { width: 80px; background: rgba(0,0,0,0.4); border: 2px solid var(--border); color: var(--text); border-radius: 2px; padding: 4px 8px; font-size: 12px; text-align: center; font-family: 'JetBrains Mono', monospace; }
.section-title { font-family: 'JetBrains Mono', monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 2px; color: var(--accent); margin: 16px 0 10px; font-weight: 600; }
.empty { text-align: center; padding: 30px; color: var(--text2); font-size: 12px; font-family: 'JetBrains Mono', monospace; }
.nav-link { color: var(--text2); text-decoration: none; font-size: 10px; font-family: 'JetBrains Mono', monospace; text-transform: uppercase; letter-spacing: 0.5px; padding: 5px 10px; border: 2px solid var(--border); border-radius: 2px; }
.nav-link:hover { border-color: var(--accent); color: var(--accent); }
.nav-link.green { color: var(--green); border-color: rgba(74,222,128,0.2); }
.nav-link.orange { color: var(--orange); border-color: rgba(245,158,11,0.2); }
@media (max-width: 768px) { .tabs { gap: 3px; } .tab { padding: 6px 10px; font-size: 9px; } .card { padding: 14px; } }
</style>
</head>
<body>
<canvas id="bg-grid"></canvas>
<div class="scanlines"></div>
<div class="container">
<header>
<h1><span>LLM</span> Team Admin</h1>
<nav style="margin-left:auto;display:flex;gap:8px;align-items:center"><a href="/">Team UI</a><a href="/lab" style="color:var(--green)">Lab</a><a href="/logs" style="color:var(--orange)">Logs</a><span style="opacity:0.3">|</span><a href="/logout" style="opacity:0.5;font-size:11px">Logout</a></nav>
<nav style="margin-left:auto;display:flex;gap:6px;align-items:center">
<a class="nav-link" href="/">Team</a>
<a class="nav-link green" href="/lab">Lab</a>
<a class="nav-link orange" href="/logs">Logs</a>
<a class="nav-link" href="/admin/monitor">Monitor</a>
<span style="width:2px;height:16px;background:var(--border);margin:0 2px"></span>
<a class="nav-link" href="/logout" style="opacity:0.4">Logout</a>
</nav>
</header>
<div class="tabs">
<div class="tab active" onclick="switchTab('providers')">Providers</div>
@ -2290,19 +2309,23 @@ function renderTimeouts() {
}
async function updateProvider(name) {
const prov = {};
const en = document.getElementById(name+'-enabled');
var prov = {};
var en = document.getElementById(name+'-enabled');
if (en) prov.enabled = en.checked;
const url = document.getElementById(name+'-url');
var url = document.getElementById(name+'-url');
if (url) prov.base_url = url.value;
const to = document.getElementById(name+'-timeout');
var to = document.getElementById(name+'-timeout');
if (to) prov.timeout = parseInt(to.value) || 120;
const key = document.getElementById(name+'-key');
var key = document.getElementById(name+'-key');
if (key && key.value) prov.api_key = key.value;
const body = {providers: {}};
var body = {providers: {}};
body.providers[name] = prov;
await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
toast('Saved');
try {
var r = await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
var d = await r.json();
if (d.ok) toast(name + ' provider saved', true, en ? (prov.enabled ? 'Enabled' : 'Disabled') : '');
else toast('Save failed: ' + (d.error || 'unknown'), false);
} catch(e) { toast('Save failed: ' + e.message, false); }
}
async function testProvider(name) {
@ -2317,13 +2340,17 @@ async function testProvider(name) {
async function toggleOllama(name, enabled) {
config.disabled_models = config.disabled_models || [];
if (enabled) {
config.disabled_models = config.disabled_models.filter(m => m !== name);
config.disabled_models = config.disabled_models.filter(function(m) { return m !== name; });
} else {
if (!config.disabled_models.includes(name)) config.disabled_models.push(name);
}
await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({disabled_models: config.disabled_models})});
toast('Model ' + (enabled ? 'enabled' : 'disabled'));
try {
var r = await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({disabled_models: config.disabled_models})});
var d = await r.json();
if (d.ok) toast(name + ' ' + (enabled ? 'enabled' : 'disabled'), true);
else toast('Failed to save model state', false);
} catch(e) { toast('Save error: ' + e.message, false); }
}
function toggleCloud(idx, enabled) {
@ -2338,9 +2365,13 @@ function removeCloud(idx) {
}
async function saveCloudModels() {
await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({cloud_models: config.cloud_models})});
toast('Saved');
try {
var r = await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({cloud_models: config.cloud_models})});
var d = await r.json();
if (d.ok) toast('Cloud models saved', true, (config.cloud_models||[]).length + ' models configured');
else toast('Save failed', false);
} catch(e) { toast('Save error: ' + e.message, false); }
}
function showAddCloud() { document.getElementById('add-cloud-modal').style.display = ''; }
@ -2400,12 +2431,17 @@ async function addOR(id, name) {
}
async function saveTimeouts() {
const g = parseInt(document.getElementById('global-timeout').value) || 300;
var g = parseInt(document.getElementById('global-timeout').value) || 300;
config.timeouts = config.timeouts || {};
config.timeouts.global = g;
await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({timeouts: config.timeouts})});
toast('Saved');
try {
var r = await fetch('/api/admin/config', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({timeouts: config.timeouts})});
var d = await r.json();
var perCount = Object.keys((config.timeouts||{}).per_model||{}).length;
if (d.ok) toast('Timeouts saved', true, 'Global: ' + g + 's' + (perCount ? ', ' + perCount + ' overrides' : ''));
else toast('Save failed', false);
} catch(e) { toast('Save error: ' + e.message, false); }
}
function setModelTimeout(name, val) {
@ -2478,15 +2514,24 @@ async function removeAllowIP(ip) {
toast('Removed ' + ip);
}
function toast(msg, ok=true) {
const t = document.createElement('div');
function toast(msg, ok=true, detail) {
var t = document.createElement('div');
t.className = 'toast ' + (ok ? 'ok' : 'err');
t.textContent = msg;
t.textContent = ok ? '' + msg : '' + msg;
if (detail) {
var d = document.createElement('div');
d.className = 'toast-detail';
d.textContent = detail;
t.appendChild(d);
}
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
setTimeout(function() { t.style.opacity = '0'; t.style.transition = 'opacity 0.3s'; setTimeout(function() { t.remove(); }, 300); }, 3000);
}
loadConfig();
// Background grid
!function(){var c=document.getElementById('bg-grid');if(!c)return;var x=c.getContext('2d');function resize(){c.width=window.innerWidth;c.height=window.innerHeight}resize();window.addEventListener('resize',resize);var t=0;function draw(){x.clearRect(0,0,c.width,c.height);var s=50,ox=(t*0.2)%s,oy=(t*0.1)%s;x.fillStyle='rgba(226,181,90,0.025)';for(var gx=-s+ox;gx<c.width+s;gx+=s)for(var gy=-s+oy;gy<c.height+s;gy+=s){x.beginPath();x.arc(gx,gy,0.7,0,Math.PI*2);x.fill()}t++;requestAnimationFrame(draw)}draw()}();
</script>
</body>
</html>