Files
anyllm-proxy/crates/proxy/admin-ui/index.html
T
2026-04-05 15:28:55 -05:00

1929 lines
94 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Proxy Admin</title>
<style nonce="__CSP_NONCE__">
@import url('https://fonts.bunny.net/css?family=dm-sans:wght@400;500;600&family=dm-mono:wght@400;500&display=swap');
:root{
--bg-base:#131516;--bg-raised:#1a1d1f;--bg-sunken:#0e1011;--bg-hover:#22272a;
--border:#2a2f33;--border-sub:#1f2428;
--text-1:#e8e3d9;--text-2:#7a8088;--text-3:#4e545b;
--accent:#e8a030;--accent-dim:rgba(232,160,48,0.12);--accent-bdr:rgba(232,160,48,0.35);
--ok:#4caf6e;--ok-dim:rgba(76,175,110,0.12);
--warn:#d4922b;--warn-dim:rgba(212,146,43,0.12);
--err:#e05252;--err-dim:rgba(224,82,82,0.12);
--font-ui:"DM Sans",ui-sans-serif,system-ui,sans-serif;
--font-mono:"DM Mono","JetBrains Mono",ui-monospace,SFMono-Regular,monospace;
--r:2px;--rm:3px;
}
*{margin:0;padding:0;box-sizing:border-box}
body{background:var(--bg-base);color:var(--text-1);font-family:var(--font-ui);font-size:14px}
a{color:var(--accent);text-decoration:none}
/* Nav */
.nav{display:flex;align-items:stretch;border-bottom:1px solid var(--border);background:var(--bg-raised);position:sticky;top:0;z-index:10}
.nav-brand{padding:0 16px 0 14px;font-family:var(--font-mono);font-size:13px;font-weight:600;color:var(--accent);border-right:1px solid var(--border);display:flex;align-items:center;letter-spacing:0.02em}
.nav-item{padding:11px 16px;cursor:pointer;color:var(--text-2);border-bottom:2px solid transparent;font-size:13px;font-weight:500;white-space:nowrap}
.nav-item.active{color:var(--text-1);border-bottom-color:var(--accent);font-weight:600}
.nav-item:hover{color:var(--text-1)}
.nav-right{margin-left:auto;padding:0 16px;font-size:12px;display:flex;align-items:center}
/* WS status dot */
#ws-status::before{content:'';display:inline-block;width:5px;height:5px;border-radius:50%;margin-right:6px;vertical-align:middle}
.connected{color:var(--ok)}.connected::before{background:var(--ok);animation:pulse 2s ease-in-out infinite}
.disconnected{color:var(--err)}.disconnected::before{background:var(--err)}
@keyframes pulse{0%,100%{opacity:0.9}50%{opacity:0.4}}
/* Layout */
.tab{display:none;padding:14px 20px;max-width:1400px;margin:0 auto}
.tab.active{display:block}
/* Stats */
.stats-row{display:flex;border:1px solid var(--border);border-radius:var(--r);overflow:hidden;margin-bottom:14px;background:var(--bg-raised)}
.stat{flex:1;padding:12px 14px;border-right:1px solid var(--border)}
.stat:last-child{border-right:none}
.stat-label{color:var(--text-2);font-size:10px;text-transform:uppercase;letter-spacing:0.07em}
.stat-value{font-family:var(--font-mono);font-size:22px;margin-top:3px;font-weight:500}
/* Backend cards */
.backend-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin-bottom:14px}
.card{padding:12px;background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--rm)}
.card-header{display:flex;justify-content:space-between;align-items:center}
.card-name{color:var(--accent);font-weight:600;font-family:var(--font-mono);font-size:13px}
.card-body{margin-top:8px;color:var(--text-2);font-size:12px}
/* Section */
.section-label{color:var(--text-2);font-size:10px;text-transform:uppercase;margin-bottom:8px;letter-spacing:0.07em;font-weight:500}
.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
/* Feed */
.feed{background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;max-height:400px;overflow-y:auto}
.feed-header,.feed-row{display:grid;grid-template-columns:140px 50px 80px 1fr 65px 55px 55px;padding:6px 12px;font-size:12px}
.feed-header{background:var(--bg-hover);color:var(--text-2);border-bottom:1px solid var(--border);position:sticky;top:0;font-size:10px;text-transform:uppercase;letter-spacing:0.06em}
.feed-row{border-bottom:1px solid var(--border-sub);cursor:pointer}
.feed-row:last-child{border-bottom:none}
.feed-row:hover{background:var(--bg-hover)}
.feed-detail{padding:12px 16px;background:var(--bg-sunken);border-bottom:1px solid var(--border-sub);border-left:2px solid var(--accent);font-size:12px;display:grid;grid-template-columns:120px 1fr;gap:4px 12px}
.feed-detail .label{color:var(--text-2);font-size:10px;text-transform:uppercase;letter-spacing:0.05em}
.feed-detail .val{color:var(--text-1);font-family:var(--font-mono);word-break:break-all}
.feed-detail .error-msg{color:var(--err);grid-column:1/-1;margin-top:4px;padding:6px;background:var(--err-dim);border-left:2px solid var(--err);border-radius:var(--r)}
.streaming-badge{font-size:9px;padding:1px 5px;border-radius:1px;background:var(--accent-dim);color:var(--accent);border:1px solid var(--accent-bdr);margin-left:4px}
.status-2xx{color:var(--ok)}.status-4xx{color:var(--warn)}.status-5xx{color:var(--err)}
/* Forms */
.form-group{margin-bottom:14px;padding:12px;background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--rm)}
.form-label{font-weight:600;margin-bottom:4px}
.form-hint{color:var(--text-2);font-size:11px;margin-bottom:8px}
.form-row{display:flex;gap:8px;align-items:center;margin-top:8px}
input,select{background:var(--bg-sunken);border:1px solid var(--border);color:var(--text-1);padding:6px 10px;border-radius:var(--r);font-size:13px;font-family:var(--font-ui)}
input:focus,select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-dim)}
button[disabled]{opacity:0.55;cursor:not-allowed}
/* Buttons */
.btn{padding:7px 14px;border-radius:var(--r);cursor:pointer;font-size:13px;border:1px solid;font-weight:500;font-family:var(--font-ui);transition:none}
.btn-primary{background:transparent;border-color:var(--accent);color:var(--accent)}
.btn-primary:hover{background:var(--accent-dim)}
.btn-secondary{background:transparent;border-color:var(--border);color:var(--text-2)}
.btn-secondary:hover{border-color:var(--text-2);color:var(--text-1)}
.btn-danger{background:transparent;border-color:var(--err);color:var(--err)}
.btn-danger:hover{background:var(--err-dim)}
.btn-sm{padding:3px 7px;font-size:11px}
/* Badges */
.badge{font-size:10px;padding:2px 5px;border-radius:1px;font-family:var(--font-mono)}
.badge-override{background:var(--warn-dim);color:var(--warn)}
.badge-active{background:var(--ok-dim);color:var(--ok)}
.badge-revoked{background:var(--err-dim);color:var(--err)}
.badge-expired{background:var(--warn-dim);color:var(--warn)}
/* Misc */
.readonly-section{opacity:0.75;padding:12px;background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--rm)}
.model-grid{display:grid;grid-template-columns:120px 1fr;gap:8px;align-items:center;margin-top:8px}
.model-grid .label{color:var(--text-2)}
.warn{color:var(--warn)}
.error{color:var(--err)}
.empty{text-align:center;padding:40px;color:var(--text-2)}
.pagination{display:flex;gap:8px;margin-top:12px;align-items:center;font-size:13px;color:var(--text-2)}
/* Operator / Charts */
.operator-controls{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap}
.operator-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1px;margin-bottom:14px;background:var(--border);border:1px solid var(--border);border-radius:var(--r);overflow:hidden}
.chart-card{padding:12px;background:var(--bg-raised);min-height:210px}
.chart-card.wide{grid-column:1/-1;border-top:1px solid var(--border)}
.chart-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px}
.chart-title{font-size:10px;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-2);font-weight:500}
.chart-value{font-size:20px;font-weight:500;color:var(--text-1);font-family:var(--font-mono)}
.chart-subtitle{font-size:11px;color:var(--text-2)}
.chart-svg{width:100%;height:130px;display:block}
.chart-grid-line{stroke:var(--border-sub);stroke-width:1}
.chart-line{fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.chart-line.secondary{stroke-width:1.5;opacity:0.8}
.chart-area{fill-opacity:0.08}
.chart-axis{display:flex;justify-content:space-between;margin-top:6px;font-size:10px;color:var(--text-3);font-family:var(--font-mono)}
/* Failure table */
.failure-table{width:100%;border-collapse:collapse;font-size:12px}
.failure-table th,.failure-table td{padding:7px 10px;text-align:left;border-bottom:1px solid var(--border-sub);vertical-align:top}
.failure-table th{color:var(--text-2);font-size:10px;text-transform:uppercase;letter-spacing:0.06em;font-weight:500}
.failure-summary{max-width:340px;color:var(--text-1)}
/* Timeline */
.timeline-list{display:flex;flex-direction:column;gap:8px}
.timeline-item{padding:10px;border:1px solid var(--border-sub);border-radius:var(--r);background:var(--bg-sunken)}
.timeline-item:hover{border-color:var(--border)}
.timeline-meta{display:grid;grid-template-columns:90px 52px 88px 1fr 70px;gap:8px;align-items:center;font-size:12px;margin-bottom:8px}
.timeline-track{height:6px;border-radius:0;background:var(--border);overflow:hidden}
.timeline-bar{height:100%;min-width:4px;border-radius:0}
.timeline-caption{display:flex;justify-content:space-between;gap:8px;margin-top:6px;font-size:11px;color:var(--text-2)}
/* Utility */
.mono{font-family:var(--font-mono)}
.dim{color:var(--text-2)}
.accent{color:var(--accent)}
.ok{color:var(--ok)}
/* Login */
.login-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:var(--bg-base);display:flex;align-items:center;justify-content:center;z-index:1000}
.login-card{background:var(--bg-raised);border:1px solid var(--border);border-top:2px solid var(--accent);border-radius:var(--rm);padding:32px;width:320px}
.login-title{font-family:var(--font-mono);font-size:15px;font-weight:500;margin-bottom:24px;color:var(--text-1)}
.login-title .prompt{color:var(--accent)}
.login-card input[type="password"]{width:100%;padding:8px 12px;margin-bottom:12px;font-size:14px;background:var(--bg-sunken);border:1px solid var(--border);color:var(--text-1);border-radius:var(--r)}
.login-card input[type="password"]:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-dim)}
.login-card .btn{width:100%;padding:10px;font-size:14px}
.login-error{color:var(--err);font-size:12px;margin-top:8px;min-height:16px}
/* Keys tab */
.keys-grid{width:100%;border-collapse:collapse;font-size:12px}
.keys-grid th,.keys-grid td{padding:8px 10px;text-align:left;border-bottom:1px solid var(--border-sub)}
.keys-grid th{background:var(--bg-hover);color:var(--text-2);font-size:10px;text-transform:uppercase;letter-spacing:0.06em;font-weight:500;position:sticky;top:0}
.keys-grid tr:hover td{background:var(--bg-hover)}
/* Budget bar */
.budget-bar{height:4px;border-radius:0;background:var(--border);margin-top:4px;overflow:hidden;min-width:60px}
.budget-bar-fill{height:100%;background:var(--ok);transition:width 0.3s}
.budget-bar-fill.warn{background:var(--warn)}.budget-bar-fill.danger{background:var(--err)}
/* Tag chips */
.tag-input-area{display:flex;flex-wrap:wrap;gap:4px;padding:4px;background:var(--bg-sunken);border:1px solid var(--border);border-radius:var(--r);min-height:34px;align-items:center;cursor:text}
.tag-input-area:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-dim)}
.chip{display:inline-flex;align-items:center;gap:4px;padding:2px 7px;background:var(--bg-hover);border:1px solid var(--border);border-radius:var(--r);font-size:11px;color:var(--text-1)}
.chip-remove{cursor:pointer;color:var(--text-3);font-size:13px;line-height:1}
.chip-remove:hover{color:var(--err)}
.tag-input-bare{background:none;border:none;outline:none;color:var(--text-1);font-size:13px;min-width:80px;padding:2px 4px;flex:1;font-family:var(--font-ui)}
/* Key result */
.key-result{background:var(--bg-sunken);border:1px solid var(--border);border-left:3px solid var(--ok);border-radius:var(--r);padding:12px;margin-bottom:12px;font-family:var(--font-mono);font-size:12px;word-break:break-all}
.key-result-label{color:var(--ok);font-size:11px;margin-bottom:4px;font-weight:600}
/* Modal */
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:100}
.modal{background:var(--bg-raised);border:1px solid var(--border);border-top:2px solid var(--accent);border-radius:var(--rm);padding:20px;width:460px;max-width:95vw;max-height:90vh;overflow-y:auto}
.modal-title{font-weight:600;font-size:15px;margin-bottom:16px;color:var(--text-1)}
</style>
</head>
<body>
<div id="login-overlay" class="login-overlay" style="display:none">
<div class="login-card">
<div class="login-title"><span class="prompt">&gt;&nbsp;</span>proxy admin</div>
<form id="login-form">
<input type="password" id="login-token" placeholder="Admin token" autocomplete="current-password">
<button type="submit" class="btn btn-primary" id="login-submit">Sign in</button>
</form>
<div class="login-error" id="login-error"></div>
</div>
</div>
<div class="nav">
<div class="nav-brand">anyllm</div>
<div class="nav-item active" data-tab="dashboard">Dashboard</div>
<div class="nav-item" data-tab="requests">Request Log</div>
<div class="nav-item" data-tab="settings">Settings</div>
<div class="nav-item" data-tab="backends">Backends</div>
<div class="nav-item" data-tab="keys">Access Control</div>
<div class="nav-item" data-tab="models">Models</div>
<div class="nav-item" data-tab="audit">Audit</div>
<div class="nav-right"><span id="ws-status" class="disconnected">Disconnected</span></div>
</div>
<div id="tab-dashboard" class="tab active">
<div class="stats-row">
<div class="stat"><div class="stat-label">Requests/min</div><div class="stat-value" id="stat-rpm">--</div></div>
<div class="stat"><div class="stat-label">Error Rate</div><div class="stat-value" id="stat-error-rate">--</div></div>
<div class="stat"><div class="stat-label">P50 Latency</div><div class="stat-value" id="stat-p50">--</div></div>
<div class="stat"><div class="stat-label">P95 Latency</div><div class="stat-value" id="stat-p95">--</div></div>
<div class="stat"><div class="stat-label">Total Requests</div><div class="stat-value" id="stat-total">0</div></div>
</div>
<div class="stats-row" style="margin-bottom:16px">
<div class="stat"><div class="stat-label">Streams Started</div><div class="stat-value" id="stat-streams-started">0</div></div>
<div class="stat"><div class="stat-label">Completed</div><div class="stat-value ok" id="stat-streams-completed">0</div></div>
<div class="stat"><div class="stat-label">Failed</div><div class="stat-value" style="color:var(--err)" id="stat-streams-failed">0</div></div>
<div class="stat"><div class="stat-label">Client Disconnects</div><div class="stat-value" style="color:var(--warn)" id="stat-streams-disconnected">0</div></div>
</div>
<div class="operator-controls">
<div class="section-label" style="margin-bottom:0">Operator View</div>
<div class="form-row" style="flex-wrap:wrap;gap:6px;margin-top:0">
<select id="obs-window">
<option value="1">Last 1 hour</option>
<option value="6" selected>Last 6 hours</option>
<option value="24">Last 24 hours</option>
</select>
<select id="obs-backend"><option value="">All backends</option></select>
</div>
</div>
<div class="stats-row">
<div class="stat"><div class="stat-label">Input Tokens</div><div class="stat-value" id="obs-input-total">0</div></div>
<div class="stat"><div class="stat-label">Output Tokens</div><div class="stat-value" id="obs-output-total">0</div></div>
<div class="stat"><div class="stat-label">Window Failures</div><div class="stat-value" id="obs-failure-total">0</div></div>
<div class="stat"><div class="stat-label">Window Cost</div><div class="stat-value" id="obs-cost-total">$0.00</div></div>
</div>
<div class="operator-grid">
<div class="chart-card">
<div class="chart-header">
<div>
<div class="chart-title">Request Volume</div>
<div class="chart-subtitle" id="obs-requests-subtitle">Rolling request count and errors</div>
</div>
<div class="chart-value" id="obs-requests-value">0</div>
</div>
<div id="obs-chart-requests" class="chart-subtitle">Loading...</div>
</div>
<div class="chart-card">
<div class="chart-header">
<div>
<div class="chart-title">Tokens</div>
<div class="chart-subtitle" id="obs-tokens-subtitle">Input and output usage</div>
</div>
<div class="chart-value" id="obs-tokens-value">0</div>
</div>
<div id="obs-chart-tokens" class="chart-subtitle">Loading...</div>
</div>
<div class="chart-card">
<div class="chart-header">
<div>
<div class="chart-title">Estimated Cost</div>
<div class="chart-subtitle" id="obs-cost-subtitle">USD by minute bucket</div>
</div>
<div class="chart-value" id="obs-cost-value">$0.00</div>
</div>
<div id="obs-chart-cost" class="chart-subtitle">Loading...</div>
</div>
<div class="chart-card wide">
<div class="chart-header">
<div>
<div class="chart-title">Failure Analysis</div>
<div class="chart-subtitle">Grouped by backend, model, status, and normalized error text</div>
</div>
</div>
<div id="obs-failures">Loading...</div>
</div>
<div class="chart-card wide">
<div class="chart-header">
<div>
<div class="chart-title">Request Timeline</div>
<div class="chart-subtitle">Recent requests sized by latency, newest first</div>
</div>
</div>
<div id="obs-timeline">Loading...</div>
</div>
</div>
<div class="backend-cards" id="backend-cards"></div>
<div class="section-header">
<div class="section-label">Live Request Feed</div>
<div class="form-row">
<select id="live-filter-status"><option value="">All</option><option value="2">2xx</option><option value="4">4xx</option><option value="5">5xx</option></select>
<button class="btn btn-secondary" id="btn-pause-feed">Pause</button>
</div>
</div>
<div class="feed" id="live-feed">
<div class="feed-header"><div>Time</div><div>Status</div><div>Backend</div><div>Model</div><div>Latency</div><div>In</div><div>Out</div></div>
</div>
</div>
<div id="tab-requests" class="tab">
<div class="section-header">
<div class="section-label">Request Log</div>
<div class="form-row" style="flex-wrap:wrap;gap:6px">
<select id="filter-backend"><option value="">All backends</option></select>
<select id="filter-status"><option value="">All statuses</option><option value="2xx">2xx</option><option value="4xx">4xx</option><option value="5xx">5xx</option></select>
<select id="filter-key"><option value="">All keys</option></select>
<input type="datetime-local" id="filter-since" title="Since" style="font-size:12px;padding:5px 8px">
<input type="datetime-local" id="filter-until" title="Until" style="font-size:12px;padding:5px 8px">
<button class="btn btn-secondary" id="btn-refresh">Refresh</button>
</div>
</div>
<div class="feed" id="request-log" style="max-height:600px">
<div class="feed-header" style="grid-template-columns:130px 50px 80px 80px 1fr 65px 55px 55px"><div>Time</div><div>Status</div><div>Backend</div><div>Key</div><div>Model</div><div>Latency</div><div>In</div><div>Out</div></div>
</div>
<div class="pagination">
<button class="btn btn-secondary" id="prev-page">Prev</button>
<span id="page-info">Page 1</span>
<button class="btn btn-secondary" id="next-page">Next</button>
</div>
</div>
<div id="tab-settings" class="tab">
<div class="section-header">
<div class="section-label">Mutable Settings <span class="warn">(changes take effect immediately)</span></div>
</div>
<div id="settings-form"></div>
<div style="display:flex;gap:8px;margin:16px 0">
<button class="btn btn-primary" id="btn-save-settings">Save Changes</button>
<button class="btn btn-secondary" id="btn-reload-config">Reload</button>
<button class="btn btn-secondary" id="btn-export-env" title="Download .anyllm.env template">Export .env</button>
</div>
<div class="section-label" style="margin-top:24px">Environment <span class="error">(requires restart to change)</span></div>
<div class="readonly-section" id="readonly-config">
<div style="color:var(--text-2);font-size:12px">Loading...</div>
</div>
</div>
<div id="tab-backends" class="tab">
<div class="section-label">Configured Backends</div>
<div id="backends-detail"></div>
</div>
<div id="tab-keys" class="tab">
<div class="section-header">
<div class="section-label">Virtual API Keys</div>
<button class="btn btn-primary" id="btn-create-key">Create Key</button>
</div>
<div id="key-create-form" style="display:none;background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--rm);padding:16px;margin-bottom:16px">
<div style="font-weight:600;margin-bottom:12px;color:var(--text-1)">New Virtual Key</div>
<div id="key-result-area" style="display:none">
<div class="key-result-label">Key created. Copy it now -- it will not be shown again.</div>
<div class="key-result" id="key-result-value"></div>
<button class="btn btn-secondary btn-sm" id="btn-copy-key" style="margin-bottom:12px">Copy</button>
</div>
<div class="model-grid" style="grid-template-columns:140px 1fr;gap:8px;align-items:center">
<div style="color:var(--text-2)">Description:</div>
<input type="text" id="key-description" placeholder="e.g. team-ml" style="width:100%">
<div style="color:var(--text-2)">Role:</div>
<select id="key-role" style="width:100%">
<option value="developer">developer</option>
<option value="admin">admin</option>
</select>
<div style="color:var(--text-2)">RPM Limit:</div>
<input type="number" id="key-rpm" placeholder="blank = unlimited" min="1" style="width:100%">
<div style="color:var(--text-2)">TPM Limit:</div>
<input type="number" id="key-tpm" placeholder="blank = unlimited" min="1" style="width:100%">
<div style="color:var(--text-2)">Max Budget ($):</div>
<input type="number" id="key-budget" placeholder="blank = unlimited" min="0" step="0.01" style="width:100%">
<div style="color:var(--text-2)">Budget Period:</div>
<select id="key-budget-period" style="width:100%">
<option value="">lifetime</option>
<option value="daily">daily</option>
<option value="monthly">monthly</option>
</select>
<div style="color:var(--text-2)">Expires At:</div>
<input type="datetime-local" id="key-expires" style="width:100%">
<div style="color:var(--text-2);align-self:start;padding-top:6px">Allowed Models:</div>
<div>
<div id="key-allowlist-area" class="tag-input-area" onclick="document.getElementById('key-allowlist-input').focus()"></div>
<input type="text" id="key-allowlist-input" class="tag-input-bare" placeholder="e.g. gpt-4o or claude-* then Enter" style="display:block;width:100%;margin-top:4px;background:var(--bg-sunken);border:1px solid var(--border);border-radius:var(--r);padding:5px 8px;color:var(--text-1)">
<div style="color:var(--text-2);font-size:11px;margin-top:3px">Press Enter to add. Supports exact names and prefix/* wildcards. Blank = all models allowed.</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn btn-primary" id="btn-submit-key">Create</button>
<button class="btn btn-secondary" id="btn-cancel-key">Cancel</button>
</div>
</div>
<!-- Edit key modal -->
<div id="key-edit-modal" style="display:none" class="modal-backdrop">
<div class="modal">
<div class="modal-title">Edit Key</div>
<div style="font-size:11px;color:var(--text-2);margin-bottom:12px">Role is immutable. Revoke and recreate to change role.</div>
<div class="model-grid" style="grid-template-columns:140px 1fr;gap:8px;align-items:center">
<div style="color:var(--text-2)">Description:</div>
<input type="text" id="edit-key-description" style="width:100%">
<div style="color:var(--text-2)">RPM Limit:</div>
<input type="number" id="edit-key-rpm" placeholder="blank = unlimited" min="1" style="width:100%">
<div style="color:var(--text-2)">TPM Limit:</div>
<input type="number" id="edit-key-tpm" placeholder="blank = unlimited" min="1" style="width:100%">
<div style="color:var(--text-2)">Max Budget ($):</div>
<input type="number" id="edit-key-budget" placeholder="blank = unlimited" min="0" step="0.01" style="width:100%">
<div style="color:var(--text-2)">Budget Period:</div>
<select id="edit-key-budget-period" style="width:100%">
<option value="">lifetime</option>
<option value="daily">daily</option>
<option value="monthly">monthly</option>
</select>
<div style="color:var(--text-2)">Expires At:</div>
<input type="datetime-local" id="edit-key-expires" style="width:100%">
<div style="color:var(--text-2);align-self:start;padding-top:6px">Allowed Models:</div>
<div>
<div id="edit-key-allowlist-area" class="tag-input-area" onclick="document.getElementById('edit-key-allowlist-input').focus()"></div>
<input type="text" id="edit-key-allowlist-input" class="tag-input-bare" placeholder="model name then Enter" style="display:block;width:100%;margin-top:4px;background:var(--bg-sunken);border:1px solid var(--border);border-radius:var(--r);padding:5px 8px;color:var(--text-1)">
</div>
</div>
<div style="display:flex;gap:8px;margin-top:16px">
<button class="btn btn-primary" id="btn-submit-edit-key">Save</button>
<button class="btn btn-secondary" id="btn-cancel-edit-key">Cancel</button>
</div>
</div>
</div>
<div style="background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--rm);overflow:auto">
<div id="keys-table-container">
<div class="empty" id="keys-empty">Loading...</div>
</div>
</div>
</div>
<div id="tab-models" class="tab">
<div class="section-header">
<div class="section-label">Model Routing</div>
<button class="btn btn-primary" id="btn-add-deployment">Add Deployment</button>
</div>
<div id="model-add-form" style="display:none;background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--rm);padding:16px;margin-bottom:16px">
<div style="font-weight:600;margin-bottom:12px;color:var(--text-1)">New Deployment</div>
<div class="model-grid" style="grid-template-columns:140px 1fr;gap:8px;align-items:center">
<div style="color:var(--text-2)">Model Name:</div>
<input type="text" id="model-name" placeholder="e.g. gpt-4o" style="width:100%">
<div style="color:var(--text-2)">Backend:</div>
<input type="text" id="model-backend" placeholder="e.g. openai" style="width:100%">
<div style="color:var(--text-2)">Actual Model:</div>
<input type="text" id="model-actual" placeholder="e.g. gpt-4o-2024-08-06" style="width:100%">
<div style="color:var(--text-2)">RPM (optional):</div>
<input type="number" id="model-rpm" placeholder="blank = unlimited" min="1" style="width:100%">
<div style="color:var(--text-2)">TPM (optional):</div>
<input type="number" id="model-tpm" placeholder="blank = unlimited" min="1" style="width:100%">
<div style="color:var(--text-2)">Weight:</div>
<input type="number" id="model-weight" value="1" min="1" style="width:100%">
</div>
<div style="display:flex;gap:8px;margin-top:12px">
<button class="btn btn-primary" id="btn-submit-model">Add</button>
<button class="btn btn-secondary" id="btn-cancel-model">Cancel</button>
</div>
</div>
<div id="models-strategy" style="color:var(--text-2);font-size:12px;margin-bottom:8px"></div>
<div style="background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--rm);overflow:auto">
<div id="models-table-container">
<div class="empty" id="models-empty">Loading...</div>
</div>
</div>
</div>
<div id="tab-audit" class="tab">
<div class="section-header">
<div class="section-label">Audit Log</div>
<div class="form-row" style="flex-wrap:wrap;gap:6px">
<select id="audit-filter-action">
<option value="">All actions</option>
<option value="key_created">key_created</option>
<option value="key_updated">key_updated</option>
<option value="key_revoked">key_revoked</option>
<option value="config_changed">config_changed</option>
</select>
<select id="audit-filter-target">
<option value="">All targets</option>
<option value="virtual_key">virtual_key</option>
<option value="config">config</option>
</select>
<input type="datetime-local" id="audit-filter-since" title="Since" style="font-size:12px;padding:5px 8px">
<input type="datetime-local" id="audit-filter-until" title="Until" style="font-size:12px;padding:5px 8px">
<button class="btn btn-secondary" id="btn-audit-refresh">Refresh</button>
</div>
</div>
<div style="background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--rm);overflow:auto">
<div id="audit-table-container">
<div class="empty" id="audit-empty">Loading...</div>
</div>
</div>
<div class="pagination">
<button class="btn btn-secondary" id="audit-prev-page">Prev</button>
<span id="audit-page-info">Page 1</span>
<button class="btn btn-secondary" id="audit-next-page">Next</button>
</div>
</div>
<script nonce="__CSP_NONCE__">
(function() {
'use strict';
var TOKEN = sessionStorage.getItem('admin_token');
if (!TOKEN) {
// Show login overlay, hide main UI until authenticated.
document.getElementById('login-overlay').style.display = 'flex';
document.querySelector('.nav').style.display = 'none';
document.querySelectorAll('.tab').forEach(function(t) { t.style.display = 'none'; });
document.getElementById('login-form').addEventListener('submit', function(e) {
e.preventDefault();
var candidate = document.getElementById('login-token').value.trim();
var errEl = document.getElementById('login-error');
if (!candidate) { errEl.textContent = 'Token is required.'; return; }
errEl.textContent = '';
// Validate by hitting a known admin endpoint.
fetch('/admin/api/metrics', {headers: {'Authorization': 'Bearer ' + candidate}})
.then(function(r) {
if (r.ok) {
sessionStorage.setItem('admin_token', candidate);
window.location.reload();
} else {
errEl.textContent = 'Invalid token.';
}
})
.catch(function() { errEl.textContent = 'Request failed. Is the server running?'; });
});
document.getElementById('login-token').focus();
return;
}
var API = '/admin/api';
var authHeaders = {'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json'};
// -- Helpers: safe DOM construction --
function el(tag, attrs, children) {
var e = document.createElement(tag);
if (attrs) Object.keys(attrs).forEach(function(k) {
if (k === 'className') e.className = attrs[k];
else if (k === 'textContent') e.textContent = attrs[k];
else if (k === 'style') Object.assign(e.style, attrs[k]);
else e.setAttribute(k, attrs[k]);
});
if (children) children.forEach(function(c) { if (c) e.appendChild(c); });
return e;
}
function text(t) { return document.createTextNode(t); }
function clearChildren(parent, keepFirst) {
while (parent.children.length > (keepFirst ? 1 : 0)) parent.removeChild(parent.lastChild);
}
function statusClass(code) {
if (code >= 500) return 'status-5xx';
if (code >= 400) return 'status-4xx';
return 'status-2xx';
}
function formatTime(ts) {
if (!ts) return '--';
var d = new Date(ts);
return d.toLocaleTimeString('en-US', {hour12:false}) + '.' + String(d.getMilliseconds()).padStart(3,'0');
}
function formatLatency(ms) {
if (ms == null) return '--';
if (ms >= 1000) return (ms/1000).toFixed(1) + 's';
return ms + 'ms';
}
function formatCount(value) {
return (value || 0).toLocaleString();
}
function formatCurrency(value) {
var amount = value || 0;
var decimals = amount >= 1 ? 2 : (amount >= 0.01 ? 3 : 4);
return '$' + amount.toFixed(decimals);
}
function formatShortDate(ts) {
if (!ts) return '--';
var d = new Date(ts);
return d.toLocaleString('en-US', {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', hour12:false});
}
function svgEl(tag, attrs) {
var e = document.createElementNS('http://www.w3.org/2000/svg', tag);
Object.keys(attrs || {}).forEach(function(key) { e.setAttribute(key, attrs[key]); });
return e;
}
function makeFeedRow(r, clickable) {
var modelText = r.model_mapped || r.model_requested || '--';
var modelChildren = [text(modelText)];
if (r.is_streaming) {
modelChildren.push(el('span', {className: 'streaming-badge', textContent: 'SSE'}));
}
var row = el('div', {className: 'feed-row'}, [
el('div', {textContent: formatTime(r.timestamp)}),
el('div', {className: statusClass(r.status_code), textContent: String(r.status_code)}),
el('div', {textContent: r.backend || '--'}),
el('div', {style:{color:'var(--text-2)'}}, modelChildren),
el('div', {textContent: formatLatency(r.latency_ms)}),
el('div', {textContent: r.input_tokens ? String(r.input_tokens) : '--', style:{color:'var(--text-2)'}}),
el('div', {textContent: r.output_tokens ? String(r.output_tokens) : '--', style:{color:'var(--text-2)'}})
]);
if (clickable && r.request_id) {
row.addEventListener('click', function() { toggleRequestDetail(row, r.request_id); });
}
return row;
}
function syncSelectOptions(selectId, items, getValue, getLabel) {
var select = document.getElementById(selectId);
var currentValue = select.value;
while (select.options.length > 1) select.remove(1);
(items || []).forEach(function(item) {
var option = document.createElement('option');
option.value = getValue(item);
option.textContent = getLabel(item);
select.appendChild(option);
});
if (currentValue) select.value = currentValue;
}
function setEmptyState(containerId, message) {
var container = document.getElementById(containerId);
container.textContent = '';
container.appendChild(el('div', {className: 'empty', textContent: message}));
}
function renderLineChart(containerId, series, lines, axisStart, axisEnd) {
if (!series || series.length === 0) {
setEmptyState(containerId, 'No request data in this window');
return;
}
var container = document.getElementById(containerId);
container.textContent = '';
var width = 640;
var height = 130;
var padding = 10;
var maxValue = 0;
lines.forEach(function(line) {
series.forEach(function(point) {
var value = line.getValue(point) || 0;
if (value > maxValue) maxValue = value;
});
});
maxValue = Math.max(maxValue, 1);
var svg = svgEl('svg', {viewBox: '0 0 ' + width + ' ' + height, class: 'chart-svg', preserveAspectRatio: 'none'});
[0.25, 0.5, 0.75].forEach(function(ratio) {
var y = padding + (height - padding * 2) * ratio;
svg.appendChild(svgEl('line', {x1: 0, x2: width, y1: y, y2: y, class: 'chart-grid-line'}));
});
function pointFor(index, total, value) {
var x = total <= 1 ? width / 2 : (index * (width - padding * 2) / (total - 1)) + padding;
var y = height - padding - ((value / maxValue) * (height - padding * 2));
return {x: x, y: y};
}
lines.forEach(function(line, idx) {
var points = series.map(function(point, index) {
var coord = pointFor(index, series.length, line.getValue(point) || 0);
return coord.x + ',' + coord.y;
}).join(' ');
if (idx === 0) {
var areaPoints = '0,' + (height - padding) + ' ' + points + ' ' + width + ',' + (height - padding);
svg.appendChild(svgEl('polygon', {points: areaPoints, class: 'chart-area', fill: line.color}));
}
svg.appendChild(svgEl('polyline', {
points: points,
class: 'chart-line' + (idx > 0 ? ' secondary' : ''),
stroke: line.color
}));
});
container.appendChild(svg);
container.appendChild(el('div', {className: 'chart-axis'}, [
el('span', {textContent: axisStart || '--'}),
el('span', {textContent: axisEnd || '--'})
]));
}
function renderFailureTable(items) {
if (!items || items.length === 0) {
setEmptyState('obs-failures', 'No failures in this window');
return;
}
var container = document.getElementById('obs-failures');
container.textContent = '';
var thead = el('thead', null, [
el('tr', null, [
el('th', {textContent: 'Count'}),
el('th', {textContent: 'Kind'}),
el('th', {textContent: 'Backend'}),
el('th', {textContent: 'Status'}),
el('th', {textContent: 'Model'}),
el('th', {textContent: 'Avg Latency'}),
el('th', {textContent: 'Latest Seen'}),
el('th', {textContent: 'Summary'})
])
]);
var tbody = el('tbody', null, null);
items.forEach(function(item) {
tbody.appendChild(el('tr', null, [
el('td', {textContent: formatCount(item.count)}),
el('td', {textContent: item.error_kind || '--', className: 'mono'}),
el('td', {textContent: item.backend || '--'}),
el('td', {className: statusClass(item.status_code), textContent: String(item.status_code)}),
el('td', {textContent: item.model || '--', className: 'mono'}),
el('td', {textContent: formatLatency(item.avg_latency_ms)}),
el('td', {textContent: formatShortDate(item.latest_seen)}),
el('td', {textContent: item.summary || '--', className: 'failure-summary'})
]));
});
container.appendChild(el('table', {className: 'failure-table'}, [thead, tbody]));
}
function renderTimeline(items) {
if (!items || items.length === 0) {
setEmptyState('obs-timeline', 'No recent requests in this window');
return;
}
var container = document.getElementById('obs-timeline');
container.textContent = '';
var maxLatency = 1;
items.forEach(function(item) { if ((item.latency_ms || 0) > maxLatency) maxLatency = item.latency_ms || 0; });
var list = el('div', {className: 'timeline-list'}, null);
items.forEach(function(item) {
var widthPct = Math.max(4, Math.round(((item.latency_ms || 0) / maxLatency) * 100));
var barColor = item.status_code >= 500 ? '#e05252' : (item.status_code >= 400 ? '#d4922b' : '#4caf6e');
var timelineItem = el('div', {className: 'timeline-item'}, [
el('div', {className: 'timeline-meta'}, [
el('div', {textContent: formatTime(item.finished_at)}),
el('div', {className: statusClass(item.status_code), textContent: String(item.status_code)}),
el('div', {textContent: item.backend || '--'}),
el('div', {textContent: item.model || '--', className: 'mono', style:{overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}),
el('div', {textContent: formatLatency(item.latency_ms)})
]),
el('div', {className: 'timeline-track'}, [
el('div', {className: 'timeline-bar', style:{width: widthPct + '%', background: barColor}})
]),
el('div', {className: 'timeline-caption'}, [
el('span', {textContent: formatTime(item.started_at) + ' -> ' + formatTime(item.finished_at)}),
el('span', {textContent: (item.input_tokens != null ? item.input_tokens : '--') + ' / ' + (item.output_tokens != null ? item.output_tokens : '--') + ' tok'})
])
]);
list.appendChild(timelineItem);
});
container.appendChild(list);
}
function toggleRequestDetail(row, requestId) {
var next = row.nextElementSibling;
if (next && next.classList.contains('feed-detail')) {
next.parentNode.removeChild(next);
return;
}
apiFetch('/requests/' + encodeURIComponent(requestId)).then(function(data) {
var detail = el('div', {className: 'feed-detail'}, [
el('span', {className: 'label', textContent: 'Request ID'}),
el('span', {className: 'val', textContent: data.request_id || '--'}),
el('span', {className: 'label', textContent: 'Timestamp'}),
el('span', {className: 'val', textContent: data.timestamp || '--'}),
el('span', {className: 'label', textContent: 'Backend'}),
el('span', {className: 'val', textContent: data.backend || '--'}),
el('span', {className: 'label', textContent: 'Model Requested'}),
el('span', {className: 'val', textContent: data.model_requested || '--'}),
el('span', {className: 'label', textContent: 'Model Mapped'}),
el('span', {className: 'val', textContent: data.model_mapped || '--'}),
el('span', {className: 'label', textContent: 'Status'}),
el('span', {className: 'val', textContent: data.status_code != null ? String(data.status_code) : '--'}),
el('span', {className: 'label', textContent: 'Latency'}),
el('span', {className: 'val', textContent: formatLatency(data.latency_ms)}),
el('span', {className: 'label', textContent: 'Streaming'}),
el('span', {className: 'val', textContent: data.is_streaming ? 'Yes' : 'No'}),
el('span', {className: 'label', textContent: 'Input Tokens'}),
el('span', {className: 'val', textContent: data.input_tokens != null ? String(data.input_tokens) : '--'}),
el('span', {className: 'label', textContent: 'Output Tokens'}),
el('span', {className: 'val', textContent: data.output_tokens != null ? String(data.output_tokens) : '--'})
]);
if (data.cost_usd != null) {
detail.appendChild(el('span', {className: 'label', textContent: 'Cost'}));
detail.appendChild(el('span', {className: 'val', textContent: '$' + data.cost_usd.toFixed(6)}));
}
if (data.error_kind) {
detail.appendChild(el('span', {className: 'label', textContent: 'Error Kind'}));
detail.appendChild(el('span', {className: 'val', textContent: data.error_kind}));
}
if (data.error_message) {
detail.appendChild(el('div', {className: 'error-msg', textContent: data.error_message}));
}
row.parentNode.insertBefore(detail, row.nextSibling);
}).catch(function(e) { console.error('Request detail failed:', e); });
}
// -- Tab switching --
document.querySelectorAll('.nav-item').forEach(function(navEl) {
navEl.addEventListener('click', function() {
document.querySelectorAll('.nav-item').forEach(function(n) { n.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
navEl.classList.add('active');
document.getElementById('tab-' + navEl.dataset.tab).classList.add('active');
if (navEl.dataset.tab === 'dashboard') loadDashboard();
if (navEl.dataset.tab === 'requests') { populateKeyFilter(); loadRequests(); }
if (navEl.dataset.tab === 'settings') { loadConfig(); loadEnv(); }
if (navEl.dataset.tab === 'backends') loadBackends();
if (navEl.dataset.tab === 'keys') loadKeys();
if (navEl.dataset.tab === 'models') loadModels();
if (navEl.dataset.tab === 'audit') loadAudit();
});
});
// -- WebSocket --
var ws;
var wsAuthenticated = false;
var wsRetries = 0;
var WS_MAX_RETRIES = 10;
var WS_BASE_DELAY_MS = 1000;
function scheduleWsReconnect() {
if (wsRetries >= WS_MAX_RETRIES) {
console.warn('WebSocket: max retries reached, giving up');
return;
}
var delay = Math.min(WS_BASE_DELAY_MS * Math.pow(2, wsRetries), 60000);
wsRetries++;
setTimeout(connectWs, delay);
}
function connectWs() {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
wsAuthenticated = false;
ws = new WebSocket(proto + '//' + location.host + '/admin/ws');
ws.onopen = function() {
wsRetries = 0;
// Authenticate via first message instead of query parameter.
ws.send(JSON.stringify({token: TOKEN}));
};
ws.onclose = function() {
document.getElementById('ws-status').textContent = 'Disconnected';
document.getElementById('ws-status').className = 'disconnected';
wsAuthenticated = false;
scheduleWsReconnect();
};
ws.onmessage = function(e) {
try {
var data = JSON.parse(e.data);
if (!wsAuthenticated) {
if (data.status === 'authenticated') {
wsAuthenticated = true;
document.getElementById('ws-status').textContent = 'Connected';
document.getElementById('ws-status').className = 'connected';
}
return;
}
handleEvent(data);
} catch(err) {}
};
}
connectWs();
var feedRows = [];
var MAX_FEED = 100;
var totalRequests = 0;
var feedPaused = false;
var feedFilter = '';
var observabilityRefreshTimer = null;
document.getElementById('btn-pause-feed').addEventListener('click', function() {
feedPaused = !feedPaused;
this.textContent = feedPaused ? 'Resume' : 'Pause';
this.style.borderColor = feedPaused ? 'var(--warn)' : 'var(--border)';
});
document.getElementById('obs-window').addEventListener('change', loadObservability);
document.getElementById('obs-backend').addEventListener('change', loadObservability);
document.getElementById('live-filter-status').addEventListener('change', function() {
feedFilter = this.value;
renderFeed();
});
function handleEvent(event) {
if (event.type === 'request_completed') {
feedRows.unshift(event.data);
if (feedRows.length > MAX_FEED) feedRows.pop();
if (!feedPaused) renderFeed();
totalRequests++;
document.getElementById('stat-total').textContent = totalRequests.toLocaleString();
scheduleObservabilityRefresh();
} else if (event.type === 'metrics_snapshot') {
updateDashboardMetrics(event.data);
}
}
function renderFeed() {
var feed = document.getElementById('live-feed');
clearChildren(feed, true);
feedRows.forEach(function(r) {
if (feedFilter && String(r.status_code).charAt(0) !== feedFilter) return;
feed.appendChild(makeFeedRow(r, true));
});
}
function updateDashboardMetrics(data) {
if (data.latency_p50_ms != null) document.getElementById('stat-p50').textContent = formatLatency(data.latency_p50_ms);
if (data.latency_p95_ms != null) document.getElementById('stat-p95').textContent = formatLatency(data.latency_p95_ms);
if (data.error_rate != null) {
var pct = (data.error_rate * 100).toFixed(1) + '%';
var errEl = document.getElementById('stat-error-rate');
errEl.textContent = pct;
errEl.style.color = data.error_rate > 0.05 ? 'var(--err)' : 'var(--ok)';
}
if (data.requests_per_second != null) {
document.getElementById('stat-rpm').textContent = (data.requests_per_second * 60).toFixed(0);
}
// Streaming metrics (from metrics_snapshot total or loadDashboard)
var t = data.total || data;
if (t.streams_started != null) document.getElementById('stat-streams-started').textContent = t.streams_started.toLocaleString();
if (t.streams_completed != null) document.getElementById('stat-streams-completed').textContent = t.streams_completed.toLocaleString();
if (t.streams_failed != null) document.getElementById('stat-streams-failed').textContent = t.streams_failed.toLocaleString();
if (t.streams_client_disconnected != null) document.getElementById('stat-streams-disconnected').textContent = t.streams_client_disconnected.toLocaleString();
}
// -- CSRF token --
// Fetched once on load and stored here. The admin SPA reads the cookie value
// returned by GET /admin/csrf-token and echoes it in X-CSRF-Token on mutating requests.
var _csrfToken = '';
function handleUnauthorized() {
sessionStorage.removeItem('admin_token');
TOKEN = '';
authHeaders = {};
document.getElementById('login-overlay').style.display = 'flex';
// Hide main UI the same way the logout button does (no #app element exists).
document.querySelector('.nav').style.display = 'none';
document.querySelectorAll('.tab').forEach(function(t) { t.style.display = 'none'; });
}
function fetchCsrfToken() {
return fetch('/admin/csrf-token', {headers: authHeaders})
.then(function(r) {
if (r.status === 401) { handleUnauthorized(); return Promise.reject(new Error('Unauthorized')); }
if (r.ok) return r.json();
})
.then(function(data) {
if (data && data.csrf_token) _csrfToken = data.csrf_token;
})
.catch(function(e) { console.warn('Failed to fetch CSRF token:', e); });
}
// Returns authHeaders extended with X-CSRF-Token for POST/PUT/DELETE requests.
function mutatingHeaders() {
return Object.assign({}, authHeaders, {'X-CSRF-Token': _csrfToken});
}
// Always fetches a fresh CSRF token before the mutating request so the
// one-time-use server token is never stale.
function mutatingFetch(url, opts) {
return fetchCsrfToken().then(function() {
if (!_csrfToken) return Promise.reject(new Error('Failed to obtain CSRF token'));
var reqOpts = Object.assign({}, opts, {headers: Object.assign({}, opts.headers || {}, mutatingHeaders())});
return fetch(url, reqOpts);
});
}
// -- API fetch --
function apiFetch(path) {
return fetch(API + path, {headers: authHeaders}).then(function(r) {
if (r.status === 401) { handleUnauthorized(); return Promise.reject(new Error('Unauthorized')); }
return r.json();
});
}
// -- Dashboard --
function loadDashboard() {
apiFetch('/metrics').then(function(metrics) {
if (metrics.total) {
totalRequests = metrics.total.requests_total || 0;
document.getElementById('stat-total').textContent = totalRequests.toLocaleString();
}
if (metrics.latency_p50_ms != null) document.getElementById('stat-p50').textContent = formatLatency(metrics.latency_p50_ms);
if (metrics.latency_p95_ms != null) document.getElementById('stat-p95').textContent = formatLatency(metrics.latency_p95_ms);
if (metrics.error_rate != null) {
var pct = (metrics.error_rate * 100).toFixed(1) + '%';
var errEl = document.getElementById('stat-error-rate');
errEl.textContent = pct;
errEl.style.color = metrics.error_rate > 0.05 ? 'var(--err)' : 'var(--ok)';
}
// Streaming metrics from the total aggregate
updateDashboardMetrics(metrics);
}).catch(function(e) { console.error('Dashboard load failed:', e); });
apiFetch('/backends').then(function(data) {
var container = document.getElementById('backend-cards');
clearChildren(container, false);
(data.backends || []).forEach(function(b) {
var card = el('div', {className: 'card'}, [
el('div', {className: 'card-header'}, [el('span', {className: 'card-name', textContent: b.name})]),
el('div', {className: 'card-body', textContent: b.big_model + ' / ' + b.small_model + ' | ' + (b.metrics.requests_total||0) + ' reqs | ' + (b.metrics.requests_error||0) + ' errs'})
]);
container.appendChild(card);
});
syncSelectOptions('obs-backend', data.backends || [], function(item) { return item.name; }, function(item) { return item.name; });
}).catch(function(e) { console.error('Backends load failed:', e); });
// Cancel any pending debounced refresh before firing immediately
if (observabilityRefreshTimer != null) {
clearTimeout(observabilityRefreshTimer);
observabilityRefreshTimer = null;
}
loadObservability();
}
function scheduleObservabilityRefresh() {
if (observabilityRefreshTimer != null) return;
observabilityRefreshTimer = setTimeout(function() {
observabilityRefreshTimer = null;
loadObservability();
}, 1500);
}
function loadObservability() {
var hours = document.getElementById('obs-window').value || '6';
var backend = document.getElementById('obs-backend').value;
var path = '/observability/overview?hours=' + encodeURIComponent(hours) + '&timeline_limit=30&failure_limit=12';
if (backend) path += '&backend=' + encodeURIComponent(backend);
apiFetch(path).then(function(data) {
var totals = data.totals || {};
var series = data.series || [];
var failures = data.failures || [];
var timeline = data.timeline || [];
var latestBucket = series.length ? series[series.length - 1] : null;
document.getElementById('obs-input-total').textContent = formatCount(totals.input_tokens);
document.getElementById('obs-output-total').textContent = formatCount(totals.output_tokens);
document.getElementById('obs-failure-total').textContent = formatCount(totals.requests_error);
document.getElementById('obs-cost-total').textContent = formatCurrency(totals.cost_usd);
document.getElementById('obs-requests-value').textContent = formatCount(totals.requests_total);
document.getElementById('obs-tokens-value').textContent = formatCount((totals.input_tokens || 0) + (totals.output_tokens || 0));
document.getElementById('obs-cost-value').textContent = formatCurrency(totals.cost_usd);
document.getElementById('obs-requests-subtitle').textContent = latestBucket ? ('Latest bucket: ' + formatCount(latestBucket.requests_total) + ' requests, ' + formatCount(latestBucket.requests_error) + ' errors') : 'No requests in this window';
document.getElementById('obs-tokens-subtitle').textContent = 'Input ' + formatCount(totals.input_tokens) + ', output ' + formatCount(totals.output_tokens);
document.getElementById('obs-cost-subtitle').textContent = 'Window error rate ' + (((totals.error_rate || 0) * 100).toFixed(1)) + '%';
var axisStart = series.length ? formatTime(series[0].bucket_start) : '--';
var axisEnd = series.length ? formatTime(series[series.length - 1].bucket_start) : '--';
renderLineChart(
'obs-chart-requests',
series,
[
{color: '#e8a030', getValue: function(point) { return point.requests_total || 0; }},
{color: '#e05252', getValue: function(point) { return point.requests_error || 0; }}
],
axisStart, axisEnd
);
renderLineChart(
'obs-chart-tokens',
series,
[
{color: '#4caf6e', getValue: function(point) { return point.input_tokens || 0; }},
{color: '#6eb5c0', getValue: function(point) { return point.output_tokens || 0; }}
],
axisStart, axisEnd
);
renderLineChart(
'obs-chart-cost',
series,
[{color: '#c87dd4', getValue: function(point) { return point.cost_usd || 0; }}],
axisStart, axisEnd
);
renderFailureTable(failures);
renderTimeline(timeline);
}).catch(function(e) {
console.error('Observability load failed:', e);
setEmptyState('obs-chart-requests', 'Failed to load request volume');
setEmptyState('obs-chart-tokens', 'Failed to load tokens');
setEmptyState('obs-chart-cost', 'Failed to load cost');
setEmptyState('obs-failures', 'Failed to load failures');
setEmptyState('obs-timeline', 'Failed to load timeline');
});
}
// -- Request Log --
var currentPage = 0;
var PAGE_SIZE = 50;
// Key map: id -> prefix+description for display
var keyMap = {};
function populateKeyFilter() {
return apiFetch('/keys').then(function(data) {
var sel = document.getElementById('filter-key');
var cur = sel.value;
while (sel.options.length > 1) sel.remove(1);
keyMap = {};
(data.keys || []).forEach(function(k) {
var label = k.key_prefix + (k.description ? ' - ' + k.description : '');
keyMap[k.id] = label;
var opt = document.createElement('option');
opt.value = k.id;
opt.textContent = label + ' (' + (k.status || 'active') + ')';
sel.appendChild(opt);
});
if (cur) sel.value = cur;
}).catch(function() {});
}
function loadRequests() {
var backend = document.getElementById('filter-backend').value;
var status = document.getElementById('filter-status').value;
var keyId = document.getElementById('filter-key').value;
var since = document.getElementById('filter-since').value;
var until = document.getElementById('filter-until').value;
var path = '/requests?limit=' + PAGE_SIZE + '&offset=' + (currentPage * PAGE_SIZE);
if (backend) path += '&backend=' + encodeURIComponent(backend);
if (status) path += '&status=' + encodeURIComponent(status);
if (keyId) path += '&key_id=' + encodeURIComponent(keyId);
if (since) path += '&since=' + encodeURIComponent(new Date(since).toISOString());
if (until) path += '&until=' + encodeURIComponent(new Date(until).toISOString());
apiFetch(path).then(function(data) {
var feed = document.getElementById('request-log');
clearChildren(feed, true);
var requests = data.requests || [];
if (requests.length === 0) {
var msg = currentPage === 0 ? 'No requests yet' : 'No results on this page';
feed.appendChild(el('div', {className: 'empty', textContent: msg}));
} else {
requests.forEach(function(r) { feed.appendChild(makeRequestRow(r)); });
}
document.getElementById('page-info').textContent = 'Page ' + (currentPage + 1);
document.getElementById('next-page').disabled = !data.has_more;
}).catch(function(e) { console.error('Requests load failed:', e); });
}
function makeRequestRow(r) {
var modelText = r.model_mapped || r.model_requested || '--';
var modelChildren = [text(modelText)];
if (r.is_streaming) {
modelChildren.push(el('span', {className: 'streaming-badge', textContent: 'SSE'}));
}
var keyLabel = r.key_id != null ? (keyMap[r.key_id] || ('id:' + r.key_id)) : '--';
var row = el('div', {className: 'feed-row', style:{gridTemplateColumns:'130px 50px 80px 80px 1fr 65px 55px 55px'}}, [
el('div', {textContent: formatTime(r.timestamp)}),
el('div', {className: statusClass(r.status_code), textContent: String(r.status_code)}),
el('div', {textContent: r.backend || '--'}),
el('div', {textContent: keyLabel, style:{color:'#8b949e',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}),
el('div', {style:{color:'var(--text-2)'}}, modelChildren),
el('div', {textContent: formatLatency(r.latency_ms)}),
el('div', {textContent: r.input_tokens ? String(r.input_tokens) : '--', style:{color:'var(--text-2)'}}),
el('div', {textContent: r.output_tokens ? String(r.output_tokens) : '--', style:{color:'var(--text-2)'}})
]);
if (r.request_id) {
row.addEventListener('click', function() { toggleRequestDetail(row, r.request_id); });
}
return row;
}
document.getElementById('btn-refresh').addEventListener('click', function() { currentPage = 0; loadRequests(); });
document.getElementById('prev-page').addEventListener('click', function() { currentPage = Math.max(0, currentPage - 1); loadRequests(); });
document.getElementById('next-page').addEventListener('click', function() { currentPage++; loadRequests(); });
// -- Settings --
var currentConfig = {};
function loadConfig() {
apiFetch('/config').then(function(data) {
currentConfig = data;
var form = document.getElementById('settings-form');
clearChildren(form, false);
var overrideKeys = data.overridden_keys || [];
function overrideBadge(key) {
// Returns a badge element if this config key is overridden, null otherwise.
if (overrideKeys.indexOf(key) !== -1) {
return el('span', {className: 'badge badge-override', textContent: 'overridden', style:{marginLeft:'6px'}});
}
return null;
}
// Log level
var levels = ['error','warn','info','debug','trace'];
var logSelect = el('select', {id: 'cfg-log-level'});
levels.forEach(function(l) {
var opt = el('option', {value: l, textContent: l});
if (l === data.log_level) opt.selected = true;
logSelect.appendChild(opt);
});
var logLabelChildren = [text('Log Level')];
var logBadge = overrideBadge('log_level');
if (logBadge) logLabelChildren.push(logBadge);
var logGroup = el('div', {className: 'form-group'}, [
el('div', {className: 'form-label'}, logLabelChildren),
el('div', {className: 'form-hint', textContent: 'RUST_LOG equivalent'}),
logSelect
]);
form.appendChild(logGroup);
// Log bodies
var bodiesSelect = el('select', {id: 'cfg-log-bodies'});
var offOpt = el('option', {value: 'false', textContent: 'Off'});
var onOpt = el('option', {value: 'true', textContent: 'On'});
if (!data.log_bodies) offOpt.selected = true; else onOpt.selected = true;
bodiesSelect.appendChild(offOpt);
bodiesSelect.appendChild(onOpt);
var bodiesLabelChildren = [text('Log Bodies')];
var bodiesBadge = overrideBadge('log_bodies');
if (bodiesBadge) bodiesLabelChildren.push(bodiesBadge);
var bodiesGroup = el('div', {className: 'form-group'}, [
el('div', {className: 'form-label'}, bodiesLabelChildren),
el('div', {className: 'form-hint', textContent: 'LOG_BODIES. Privacy-sensitive.'}),
bodiesSelect
]);
form.appendChild(bodiesGroup);
// Per-backend model mappings
Object.keys(data.backends || {}).forEach(function(name) {
var mapping = data.backends[name];
var bigInput = el('input', {id: 'cfg-' + name + '-big', value: mapping.big_model || ''});
var smallInput = el('input', {id: 'cfg-' + name + '-small', value: mapping.small_model || ''});
var bigBadge = overrideBadge(name + '.big_model');
var smallBadge = overrideBadge(name + '.small_model');
var bigLabel = bigBadge ? el('div', {className: 'label'}, [text('BIG_MODEL:'), bigBadge]) : el('div', {className: 'label', textContent: 'BIG_MODEL:'});
var smallLabel = smallBadge ? el('div', {className: 'label'}, [text('SMALL_MODEL:'), smallBadge]) : el('div', {className: 'label', textContent: 'SMALL_MODEL:'});
var grid = el('div', {className: 'model-grid'}, [bigLabel, bigInput, smallLabel, smallInput]);
var nameBadge = (overrideKeys.some(function(k) { return k.startsWith(name + '.'); }))
? el('span', {className: 'badge badge-override', textContent: 'overridden', style:{marginLeft:'6px'}}) : null;
var nameEl = el('div', {className: 'form-label'}, [el('span', {className:'accent mono', textContent: name})].concat(nameBadge ? [nameBadge] : []));
var group = el('div', {className: 'form-group'}, [nameEl, grid]);
form.appendChild(group);
});
}).catch(function(e) { console.error('Config load failed:', e); });
}
function saveSettings() {
var body = {
log_level: (document.getElementById('cfg-log-level') || {}).value,
log_bodies: (document.getElementById('cfg-log-bodies') || {}).value === 'true',
backends: {}
};
Object.keys(currentConfig.backends || {}).forEach(function(name) {
var big = document.getElementById('cfg-' + name + '-big');
var small = document.getElementById('cfg-' + name + '-small');
if (big || small) {
body.backends[name] = {};
if (big) body.backends[name].big_model = big.value;
if (small) body.backends[name].small_model = small.value;
}
});
mutatingFetch(API + '/config', {method:'PUT', body: JSON.stringify(body)})
.then(function(r) { return r.json(); })
.then(function(result) {
alert('Updated ' + (result.updated || 0) + ' settings');
loadConfig();
})
.catch(function(e) { alert('Failed to save: ' + e.message); });
}
document.getElementById('btn-save-settings').addEventListener('click', saveSettings);
document.getElementById('btn-reload-config').addEventListener('click', function() { loadConfig(); loadEnv(); });
document.getElementById('btn-export-env').addEventListener('click', exportEnvFile);
// -- Environment variables (read-only, requires restart to change) --
var ENV_GROUPS = [
{ label: 'Core', keys: ['BACKEND','LISTEN_PORT','BIG_MODEL','SMALL_MODEL','RUST_LOG','LOG_BODIES','PROXY_CONFIG'] },
{ label: 'OpenAI / Compatible', keys: ['OPENAI_BASE_URL','OPENAI_API_FORMAT','OPENAI_API_KEY'] },
{ label: 'Vertex AI', keys: ['VERTEX_PROJECT','VERTEX_REGION','VERTEX_API_KEY'] },
{ label: 'Gemini', keys: ['GEMINI_BASE_URL','GEMINI_API_KEY'] },
{ label: 'Azure OpenAI', keys: ['AZURE_OPENAI_ENDPOINT','AZURE_OPENAI_DEPLOYMENT','AZURE_OPENAI_API_KEY','AZURE_OPENAI_API_VERSION'] },
{ label: 'AWS Bedrock', keys: ['AWS_REGION','AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY','AWS_SESSION_TOKEN'] },
{ label: 'Auth & TLS', keys: ['PROXY_API_KEYS','PROXY_OPEN_RELAY','TLS_CLIENT_CERT_P12','TLS_CA_CERT'] },
{ label: 'Network', keys: ['IP_ALLOWLIST','TRUST_PROXY_HEADERS','WEBHOOK_URLS','RATE_LIMIT_FAIL_POLICY'] },
{ label: 'Admin', keys: ['ADMIN_PORT','ADMIN_DB_PATH','ADMIN_LOG_RETENTION_DAYS'] },
];
var ENV_SECRET_KEYS = {'OPENAI_API_KEY':1,'VERTEX_API_KEY':1,'GEMINI_API_KEY':1,'PROXY_API_KEYS':1,'AZURE_OPENAI_API_KEY':1,'AWS_SECRET_ACCESS_KEY':1,'AWS_SESSION_TOKEN':1};
function loadEnv() {
apiFetch('/env').then(function(data) {
var container = document.getElementById('readonly-config');
clearChildren(container, false);
ENV_GROUPS.forEach(function(group) {
var hasAny = group.keys.some(function(k) { return data[k] != null; });
if (!hasAny) return;
var rows = [];
group.keys.forEach(function(k) {
var val = data[k];
if (val == null) return;
rows.push(el('div', {className: 'model-grid', style:{marginTop:'4px'}}, [
el('div', {className: 'label', textContent: k + ':'}),
el('div', {textContent: String(val), style:{fontFamily:'monospace',fontSize:'12px',color:'var(--text-1)'}})
]));
});
if (rows.length === 0) return;
var header = el('div', {style:{color:'#8b949e',fontSize:'11px',textTransform:'uppercase',letterSpacing:'0.5px',marginTop:'10px',marginBottom:'4px'}, textContent: group.label});
container.appendChild(header);
rows.forEach(function(r) { container.appendChild(r); });
});
if (!container.children.length) {
container.appendChild(el('div', {style:{color:'#8b949e',fontSize:'12px'}, textContent: 'No environment variables set (using defaults).'}));
}
// Security summary box
var secDiv = document.getElementById('settings-security');
if (!secDiv) {
secDiv = el('div', {id: 'settings-security', style:{marginTop:'20px'}});
var parentEl = document.getElementById('readonly-config').parentNode;
parentEl.insertBefore(secDiv, document.getElementById('readonly-config'));
}
clearChildren(secDiv, false);
secDiv.appendChild(el('div', {className: 'section-label', style:{marginBottom:'8px'}, textContent: 'Security'}));
var secCard = el('div', {className: 'readonly-section'});
var ipVal = data['IP_ALLOWLIST'] || null;
var failPolicy = data['RATE_LIMIT_FAIL_POLICY'] || 'open (default)';
secCard.appendChild(el('div', {className: 'model-grid', style:{marginTop:'0'}}, [
el('div', {className: 'label', textContent: 'IP Allowlist:'}),
el('div', {textContent: ipVal || 'not set (all IPs allowed)', style:{fontFamily:'monospace',fontSize:'12px',color: ipVal ? 'var(--text-1)' : 'var(--text-2)'}}),
el('div', {className: 'label', textContent: 'Rate Limit Fail Policy:'}),
el('div', {textContent: failPolicy, style:{fontFamily:'monospace',fontSize:'12px'}})
]));
secDiv.appendChild(secCard);
}).catch(function(e) {
var container = document.getElementById('readonly-config');
container.textContent = 'Failed to load environment variables.';
console.error('Env load failed:', e);
});
}
// -- Export .env file --
// Generates a .anyllm.env template the user can load with --env-file.
// Secret values (API keys) are masked server-side, so they appear as empty
// placeholders the user must fill in manually.
function exportEnvFile() {
apiFetch('/env').then(function(data) {
var lines = [
'# anyllm-proxy configuration',
'# Load with: anyllm_proxy --env-file .anyllm.env',
'# Docker: docker run --env-file .anyllm.env anyllm-proxy',
'#',
'# Secret values (API keys) are intentionally left blank for security.',
'# Fill them in before use.',
'',
];
ENV_GROUPS.forEach(function(section) {
var hasAny = section.keys.some(function(k) { return data[k] != null || k in ENV_SECRET_KEYS; });
if (!hasAny) return;
lines.push('# --- ' + section.label + ' ---');
section.keys.forEach(function(k) {
if (k in ENV_SECRET_KEYS) {
// Always emit secret keys as empty placeholders.
lines.push(k + '=');
} else if (data[k] != null) {
lines.push(k + '=' + String(data[k]));
}
// Omit unset non-secret keys entirely to keep the file minimal.
});
lines.push('');
});
var blob = new Blob([lines.join('\n')], {type: 'text/plain'});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = '.anyllm.env';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
}).catch(function(e) {
alert('Failed to fetch env: ' + e.message);
});
}
// -- Backends detail --
function loadBackends() {
apiFetch('/backends').then(function(data) {
var container = document.getElementById('backends-detail');
clearChildren(container, false);
(data.backends || []).forEach(function(b) {
var grid = el('div', {className: 'model-grid', style:{marginTop:'0'}}, [
el('div', {className: 'label', textContent: 'BIG_MODEL:'}), el('div', {textContent: b.big_model}),
el('div', {className: 'label', textContent: 'SMALL_MODEL:'}), el('div', {textContent: b.small_model}),
el('div', {className: 'label', textContent: 'Total:'}), el('div', {textContent: String(b.metrics.requests_total || 0)}),
el('div', {className: 'label', textContent: 'Success:'}), el('div', {textContent: String(b.metrics.requests_success || 0)}),
el('div', {className: 'label', textContent: 'Errors:'}), el('div', {textContent: String(b.metrics.requests_error || 0)})
]);
var card = el('div', {className: 'card', style:{marginBottom:'12px'}}, [
el('div', {className: 'card-header'}, [el('span', {className: 'card-name', textContent: b.name})]),
el('div', {className: 'card-body'}, [grid])
]);
container.appendChild(card);
});
}).catch(function(e) { console.error('Backends load failed:', e); });
}
// -- Virtual Keys --
function loadKeys() {
apiFetch('/keys').then(function(data) {
var container = document.getElementById('keys-table-container');
clearChildren(container, false);
var keys = data.keys || [];
if (keys.length === 0) {
container.appendChild(el('div', {className: 'empty', textContent: 'No keys yet. Click Create Key to add one.'}));
return;
}
var thead = el('thead', null, [
el('tr', null, [
el('th', {textContent: 'Prefix'}),
el('th', {textContent: 'Description'}),
el('th', {textContent: 'Role'}),
el('th', {textContent: 'RPM / TPM'}),
el('th', {textContent: 'Budget / Spend'}),
el('th', {textContent: 'Requests'}),
el('th', {textContent: 'Allowed Models'}),
el('th', {textContent: 'Status'}),
el('th', {textContent: 'Actions'})
])
]);
var tbody = el('tbody', null, null);
keys.forEach(function(k) {
var badgeClass = 'badge-' + (k.status || 'active');
var rpmTpm = (k.rpm_limit != null ? k.rpm_limit : '--') + ' / ' + (k.tpm_limit != null ? k.tpm_limit : '--');
// Budget + spend cell with optional progress bar
var budgetCell;
if (k.max_budget_usd != null) {
var periodSpend = k.period_spend_usd || 0;
var pct = Math.min(100, (periodSpend / k.max_budget_usd) * 100);
var barClass = pct >= 95 ? 'danger' : (pct >= 80 ? 'warn' : '');
var budgetLabel = '$' + Number(k.max_budget_usd).toFixed(2) + (k.budget_duration ? ' / ' + k.budget_duration : '');
var spendLabel = '$' + Number(periodSpend).toFixed(4) + ' used';
var budgetChildren = [
el('div', {textContent: budgetLabel, style:{color:'#8b949e',fontSize:'11px'}}),
el('div', {textContent: spendLabel, style:{fontSize:'11px'}}),
el('div', {className: 'budget-bar'}, [
el('div', {className: 'budget-bar-fill ' + barClass, style:{width: pct.toFixed(1) + '%'}})
])
];
if (k.period_reset_at) {
var resetDate = new Date(k.period_reset_at);
var resetStr = resetDate.toLocaleDateString(undefined, {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'});
budgetChildren.push(el('div', {style:{fontSize:'11px',color:'#8b949e',marginTop:'2px'}, textContent: 'Resets ' + resetStr}));
}
budgetCell = el('td', null, budgetChildren);
} else {
var spendAny = k.total_spend != null ? ('$' + Number(k.total_spend).toFixed(4)) : '--';
budgetCell = el('td', {textContent: spendAny, style:{color:'var(--text-2)'}});
}
// Allowed models chips
var modelsCell;
if (k.allowed_models && k.allowed_models.length > 0) {
var chips = k.allowed_models.map(function(m) {
return el('span', {className: 'chip', style:{marginBottom:'2px'}, textContent: m});
});
modelsCell = el('td', {style:{maxWidth:'160px'}}, chips);
} else {
modelsCell = el('td', {textContent: 'all', style:{color:'#8b949e',fontSize:'11px'}});
}
// Prefix as link → filter requests
var prefixLink = el('a', {textContent: k.key_prefix || '--', className:'mono accent', style:{cursor:'pointer'}});
prefixLink.addEventListener('click', (function(kid) {
return function() {
// Switch to Request Log tab with this key pre-filtered
document.querySelectorAll('.nav-item').forEach(function(n) { n.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
var reqNav = document.querySelector('.nav-item[data-tab="requests"]');
if (reqNav) reqNav.classList.add('active');
document.getElementById('tab-requests').classList.add('active');
populateKeyFilter().then(function() {
var sel = document.getElementById('filter-key');
sel.value = String(kid);
currentPage = 0;
loadRequests();
});
};
}(k.id)));
var actionTd = el('td', null, null);
if (k.status === 'active') {
var editBtn = el('button', {className: 'btn btn-secondary btn-sm', textContent: 'Edit'}, null);
editBtn.addEventListener('click', (function(key) {
return function() { openEditModal(key); };
}(k)));
actionTd.appendChild(editBtn);
var revokeBtn = el('button', {className: 'btn btn-danger btn-sm', textContent: 'Revoke', style:{marginLeft:'4px'}}, null);
revokeBtn.addEventListener('click', (function(id) {
return function() { revokeKey(id); };
}(k.id)));
actionTd.appendChild(revokeBtn);
}
var spendBtn = el('button', {className: 'btn btn-secondary btn-sm', textContent: 'Spend', style:{marginLeft:'4px'}}, null);
spendBtn.addEventListener('click', (function(id, prefix) {
return function() { showKeySpend(id, prefix); };
}(k.id, k.key_prefix)));
actionTd.appendChild(spendBtn);
var row = el('tr', null, [
el('td', null, [prefixLink]),
el('td', {textContent: k.description || '--', style:{color:'var(--text-2)'}}),
el('td', {textContent: k.role || 'developer'}),
el('td', {textContent: rpmTpm, style:{color:'var(--text-2)'}}),
budgetCell,
el('td', {textContent: String(k.total_requests || 0)}),
modelsCell,
el('td', null, [el('span', {className: 'badge ' + badgeClass, textContent: k.status || 'active'})]),
actionTd
]);
tbody.appendChild(row);
});
var table = el('table', {className: 'keys-grid'}, [thead, tbody]);
container.appendChild(table);
}).catch(function(e) {
var container = document.getElementById('keys-table-container');
clearChildren(container, false);
container.appendChild(el('div', {className: 'empty error', textContent: 'Failed to load keys: ' + e.message}));
});
}
// -- Tag input (allowed_models) helpers --
var createAllowlist = [];
var editAllowlist = [];
function renderChips(areaId, chips) {
var area = document.getElementById(areaId);
clearChildren(area, false);
chips.forEach(function(model, idx) {
var chip = el('span', {className: 'chip'}, [
text(model),
el('span', {className: 'chip-remove', textContent: '\u00d7'})
]);
chip.querySelector('.chip-remove').addEventListener('click', (function(i, arr, aid) {
return function(e) { e.stopPropagation(); arr.splice(i, 1); renderChips(aid, arr); };
}(idx, chips, areaId)));
area.appendChild(chip);
});
}
function wireTagInput(inputId, areaId, chipArray) {
var input = document.getElementById(inputId);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
var val = input.value.trim().replace(/,$/, '');
if (val && chipArray.indexOf(val) === -1) {
chipArray.push(val);
renderChips(areaId, chipArray);
}
input.value = '';
}
});
}
// Wire up create form allowlist input
wireTagInput('key-allowlist-input', 'key-allowlist-area', createAllowlist);
function createKey() {
var description = document.getElementById('key-description').value.trim();
var role = document.getElementById('key-role').value;
var rpmVal = document.getElementById('key-rpm').value;
var tpmVal = document.getElementById('key-tpm').value;
var budgetVal = document.getElementById('key-budget').value;
var budgetPeriod = document.getElementById('key-budget-period').value;
var expiresVal = document.getElementById('key-expires').value;
var body = {};
if (description) body.description = description;
if (role) body.role = role;
body.rpm_limit = rpmVal ? parseInt(rpmVal, 10) : null;
body.tpm_limit = tpmVal ? parseInt(tpmVal, 10) : null;
body.max_budget_usd = budgetVal ? parseFloat(budgetVal) : null;
body.budget_duration = budgetPeriod || null;
body.expires_at = expiresVal ? new Date(expiresVal).toISOString() : null;
body.allowed_models = createAllowlist.length > 0 ? createAllowlist.slice() : null;
var submitBtn = document.getElementById('btn-submit-key');
submitBtn.disabled = true;
submitBtn.textContent = 'Creating...';
function resetSubmitBtn() {
submitBtn.disabled = false;
submitBtn.textContent = 'Create';
}
mutatingFetch(API + '/keys', {method: 'POST', body: JSON.stringify(body)})
.then(function(r) {
if (!r.ok) { return r.json().then(function(e) { throw new Error(e.error || ('HTTP ' + r.status)); }); }
return r.json();
})
.then(function(result) {
var resultArea = document.getElementById('key-result-area');
var resultValue = document.getElementById('key-result-value');
resultValue.textContent = result.key;
resultArea.style.display = 'block';
// Reset form fields but keep the form open so user can copy the key.
document.getElementById('key-description').value = '';
document.getElementById('key-rpm').value = '';
document.getElementById('key-tpm').value = '';
document.getElementById('key-budget').value = '';
document.getElementById('key-expires').value = '';
document.getElementById('key-role').value = 'developer';
document.getElementById('key-budget-period').value = '';
document.getElementById('key-allowlist-input').value = '';
createAllowlist.length = 0;
renderChips('key-allowlist-area', createAllowlist);
resetSubmitBtn();
loadKeys();
})
.catch(function(e) {
alert('Failed to create key: ' + e.message);
resetSubmitBtn();
});
}
function revokeKey(id) {
if (!confirm('Revoke this key? This cannot be undone.')) return;
mutatingFetch(API + '/keys/' + id, {method: 'DELETE'})
.then(function(r) {
if (!r.ok) { return r.json().then(function(e) { throw new Error(e.error || ('HTTP ' + r.status)); }); }
return r.json();
})
.then(function() { loadKeys(); })
.catch(function(e) { alert('Failed to revoke key: ' + e.message); });
}
function showKeySpend(id, prefix) {
apiFetch('/keys/' + id + '/spend').then(function(data) {
var lines = [
'Key: ' + (prefix || id),
'Total cost: $' + Number(data.total_cost_usd || 0).toFixed(4),
'Input tokens: ' + (data.total_input_tokens || 0),
'Output tokens: ' + (data.total_output_tokens || 0),
'Requests: ' + (data.request_count || 0),
];
if (data.budget_duration && data.period_cost_usd != null) {
lines.push('Period cost (' + data.budget_duration + '): $' + Number(data.period_cost_usd).toFixed(4));
}
if (data.max_budget_usd != null) {
lines.push('Budget limit: $' + Number(data.max_budget_usd).toFixed(2));
}
alert(lines.join('\n'));
}).catch(function(e) { alert('Failed to fetch spend: ' + e.message); });
}
// -- Edit key modal --
var editKeyId = null;
var editOriginalBudgetDuration = null;
// Wire up edit modal allowlist input
wireTagInput('edit-key-allowlist-input', 'edit-key-allowlist-area', editAllowlist);
function openEditModal(k) {
editKeyId = k.id;
editOriginalBudgetDuration = k.budget_duration || '';
document.getElementById('edit-key-description').value = k.description || '';
document.getElementById('edit-key-rpm').value = k.rpm_limit != null ? k.rpm_limit : '';
document.getElementById('edit-key-tpm').value = k.tpm_limit != null ? k.tpm_limit : '';
document.getElementById('edit-key-budget').value = k.max_budget_usd != null ? k.max_budget_usd : '';
document.getElementById('edit-key-budget-period').value = k.budget_duration || '';
document.getElementById('edit-key-expires').value = k.expires_at
? (function(s) { var d = new Date(s); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0,16); }(k.expires_at))
: '';
editAllowlist.length = 0;
if (k.allowed_models && k.allowed_models.length > 0) {
k.allowed_models.forEach(function(m) { editAllowlist.push(m); });
}
renderChips('edit-key-allowlist-area', editAllowlist);
document.getElementById('key-edit-modal').style.display = 'flex';
}
function submitEditKey() {
if (!editKeyId) return;
var rpmVal = document.getElementById('edit-key-rpm').value;
var tpmVal = document.getElementById('edit-key-tpm').value;
var budgetVal = document.getElementById('edit-key-budget').value;
var expiresVal = document.getElementById('edit-key-expires').value;
var newBudgetDuration = document.getElementById('edit-key-budget-period').value || '';
var body = {
description: document.getElementById('edit-key-description').value.trim() || null,
rpm_limit: rpmVal ? parseInt(rpmVal, 10) : null,
tpm_limit: tpmVal ? parseInt(tpmVal, 10) : null,
max_budget_usd: budgetVal ? parseFloat(budgetVal) : null,
expires_at: expiresVal ? new Date(expiresVal).toISOString() : null,
allowed_models: editAllowlist.length > 0 ? editAllowlist.slice() : null
};
// Only send budget_duration if it changed, to avoid unnecessary period reset.
if (newBudgetDuration !== editOriginalBudgetDuration) {
body.budget_duration = newBudgetDuration || null;
}
var saveBtn = document.getElementById('btn-submit-edit-key');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
mutatingFetch(API + '/keys/' + editKeyId, {method: 'PUT', body: JSON.stringify(body)})
.then(function(r) {
if (!r.ok) { return r.json().then(function(e) { throw new Error(e.error || ('HTTP ' + r.status)); }); }
return r.json();
})
.then(function() {
document.getElementById('key-edit-modal').style.display = 'none';
editKeyId = null;
loadKeys();
})
.catch(function(e) { alert('Failed to update key: ' + e.message); })
.finally(function() { saveBtn.disabled = false; saveBtn.textContent = 'Save'; });
}
document.getElementById('btn-submit-edit-key').addEventListener('click', submitEditKey);
document.getElementById('btn-cancel-edit-key').addEventListener('click', function() {
document.getElementById('key-edit-modal').style.display = 'none';
editKeyId = null;
});
// Close modal on backdrop click
document.getElementById('key-edit-modal').addEventListener('click', function(e) {
if (e.target === this) { this.style.display = 'none'; editKeyId = null; }
});
// -- Audit Log --
var auditPage = 0;
var AUDIT_PAGE_SIZE = 50;
function loadAudit() {
var action = document.getElementById('audit-filter-action').value;
var target = document.getElementById('audit-filter-target').value;
var since = document.getElementById('audit-filter-since').value;
var until = document.getElementById('audit-filter-until').value;
var path = '/audit?limit=' + AUDIT_PAGE_SIZE + '&offset=' + (auditPage * AUDIT_PAGE_SIZE);
if (action) path += '&action=' + encodeURIComponent(action);
if (target) path += '&target_type=' + encodeURIComponent(target);
if (since) path += '&since=' + encodeURIComponent(new Date(since).toISOString());
if (until) path += '&until=' + encodeURIComponent(new Date(until).toISOString());
apiFetch(path).then(function(data) {
var container = document.getElementById('audit-table-container');
clearChildren(container, false);
var entries = data.entries || [];
document.getElementById('audit-page-info').textContent = 'Page ' + (auditPage + 1);
document.getElementById('audit-next-page').disabled = !data.has_more;
if (entries.length === 0) {
var auditMsg = auditPage === 0 ? 'No audit entries yet.' : 'No results on this page';
container.appendChild(el('div', {className: 'empty', textContent: auditMsg}));
return;
}
var thead = el('thead', null, [
el('tr', null, [
el('th', {textContent: 'Timestamp'}),
el('th', {textContent: 'Action'}),
el('th', {textContent: 'Target'}),
el('th', {textContent: 'Detail'}),
el('th', {textContent: 'Source IP'})
])
]);
var tbody = el('tbody', null, null);
entries.forEach(function(e) {
var targetText = e.target_type + (e.target_id ? ' #' + e.target_id : '');
tbody.appendChild(el('tr', null, [
el('td', {textContent: e.timestamp || '--', className:'mono dim', style:{fontSize:'11px'}}),
el('td', {textContent: e.action || '--', className:'mono accent'}),
el('td', {textContent: targetText, className:'dim'}),
el('td', {textContent: e.detail || '--', style:{maxWidth:'300px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}),
el('td', {textContent: e.source_ip || '--', className:'mono dim', style:{fontSize:'11px'}})
]));
});
container.appendChild(el('table', {className: 'keys-grid'}, [thead, tbody]));
}).catch(function(e) {
var container = document.getElementById('audit-table-container');
clearChildren(container, false);
container.appendChild(el('div', {className: 'empty error', textContent: 'Failed to load audit log: ' + e.message}));
});
}
document.getElementById('btn-audit-refresh').addEventListener('click', function() { auditPage = 0; loadAudit(); });
document.getElementById('audit-prev-page').addEventListener('click', function() { auditPage = Math.max(0, auditPage - 1); loadAudit(); });
document.getElementById('audit-next-page').addEventListener('click', function() { auditPage++; loadAudit(); });
// Keys tab event wiring
document.getElementById('btn-create-key').addEventListener('click', function() {
var form = document.getElementById('key-create-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
document.getElementById('key-result-area').style.display = 'none';
});
document.getElementById('btn-cancel-key').addEventListener('click', function() {
document.getElementById('key-create-form').style.display = 'none';
document.getElementById('key-result-area').style.display = 'none';
});
document.getElementById('btn-submit-key').addEventListener('click', createKey);
document.getElementById('btn-copy-key').addEventListener('click', function() {
var val = document.getElementById('key-result-value').textContent;
if (navigator.clipboard) {
navigator.clipboard.writeText(val).then(function() {
document.getElementById('btn-copy-key').textContent = 'Copied!';
setTimeout(function() { document.getElementById('btn-copy-key').textContent = 'Copy'; }, 1500);
}).catch(function() { alert('Copy failed. Select the key text manually.'); });
} else {
// Fallback for older browsers.
var ta = document.createElement('textarea');
ta.value = val;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
document.getElementById('btn-copy-key').textContent = 'Copied!';
setTimeout(function() { document.getElementById('btn-copy-key').textContent = 'Copy'; }, 1500);
}
});
// -- Model Routing --
function loadModels() {
apiFetch('/models').then(function(data) {
var strategyEl = document.getElementById('models-strategy');
var container = document.getElementById('models-table-container');
clearChildren(container, false);
if (data.note) {
strategyEl.textContent = '';
container.appendChild(el('div', {className: 'empty', textContent: data.note}));
return;
}
strategyEl.textContent = data.strategy ? ('Strategy: ' + data.strategy) : '';
var models = data.models || [];
if (models.length === 0) {
container.appendChild(el('div', {className: 'empty', textContent: 'No model deployments configured.'}));
return;
}
var thead = el('thead', null, [
el('tr', null, [
el('th', {textContent: 'Model Name'}),
el('th', {textContent: 'Deployments'}),
el('th', {textContent: 'Actions'})
])
]);
var tbody = el('tbody', null, null);
models.forEach(function(m) {
var removeBtn = el('button', {className: 'btn btn-danger btn-sm', textContent: 'Remove'}, null);
removeBtn.addEventListener('click', (function(name) {
return function() { removeModel(name); };
}(m.model_name)));
var actionTd = el('td', null, [removeBtn]);
var row = el('tr', null, [
el('td', {textContent: m.model_name, style:{fontFamily:'monospace'}}),
el('td', {textContent: String(m.deployments || 0), style:{color:'var(--text-2)'}}),
actionTd
]);
tbody.appendChild(row);
});
var table = el('table', {className: 'keys-grid'}, [thead, tbody]);
container.appendChild(table);
}).catch(function(e) {
var container = document.getElementById('models-table-container');
clearChildren(container, false);
container.appendChild(el('div', {className: 'empty error', textContent: 'Failed to load models: ' + e.message}));
});
}
function addModel() {
var modelName = document.getElementById('model-name').value.trim();
var backendName = document.getElementById('model-backend').value.trim();
var actualModel = document.getElementById('model-actual').value.trim();
var rpmVal = document.getElementById('model-rpm').value;
var tpmVal = document.getElementById('model-tpm').value;
var weightVal = document.getElementById('model-weight').value;
if (!modelName || !backendName || !actualModel) {
alert('Model Name, Backend, and Actual Model are required.');
return;
}
var body = {
model_name: modelName,
backend_name: backendName,
actual_model: actualModel,
weight: weightVal ? parseInt(weightVal, 10) : 1
};
if (rpmVal) body.rpm = parseInt(rpmVal, 10);
if (tpmVal) body.tpm = parseInt(tpmVal, 10);
var submitBtn = document.getElementById('btn-submit-model');
submitBtn.disabled = true;
submitBtn.textContent = 'Adding...';
mutatingFetch(API + '/models', {method: 'POST', body: JSON.stringify(body)})
.then(function(r) {
if (!r.ok) { return r.json().then(function(e) { throw new Error(e.error || ('HTTP ' + r.status)); }); }
return r.json();
})
.then(function() {
document.getElementById('model-add-form').style.display = 'none';
document.getElementById('model-name').value = '';
document.getElementById('model-backend').value = '';
document.getElementById('model-actual').value = '';
document.getElementById('model-rpm').value = '';
document.getElementById('model-tpm').value = '';
document.getElementById('model-weight').value = '1';
submitBtn.disabled = false;
submitBtn.textContent = 'Add';
loadModels();
})
.catch(function(e) {
alert('Failed to add deployment: ' + e.message);
submitBtn.disabled = false;
submitBtn.textContent = 'Add';
});
}
function removeModel(name) {
if (!confirm('Remove model "' + name + '"? This cannot be undone.')) return;
mutatingFetch(API + '/models/' + encodeURIComponent(name), {method: 'DELETE'})
.then(function(r) {
if (!r.ok) { return r.json().then(function(e) { throw new Error(e.error || ('HTTP ' + r.status)); }); }
return r.json();
})
.then(function() { loadModels(); })
.catch(function(e) { alert('Failed to remove model: ' + e.message); });
}
// Models tab event wiring
document.getElementById('btn-add-deployment').addEventListener('click', function() {
var form = document.getElementById('model-add-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
});
document.getElementById('btn-cancel-model').addEventListener('click', function() {
document.getElementById('model-add-form').style.display = 'none';
});
document.getElementById('btn-submit-model').addEventListener('click', addModel);
// Initial load: fetch CSRF token first so all subsequent mutating requests are protected.
loadDashboard();
})();
</script>
</body>
</html>