mirror of
https://github.com/whit3rabbit/anyllm-proxy.git
synced 2026-09-22 16:00:51 +00:00
BashTool: add 30s subprocess timeout and 256KB output truncation. ReadFileTool: require absolute paths, canonicalize to block traversal, enforce 1MB size limit. ToolRegistry::get returns &dyn Tool instead of &Box<dyn Tool>. Remove wasted infer_error_kind call from log_entry() since every error path overwrites via set_backend_error_kind(). Cancel pending observability refresh timer in loadDashboard to prevent duplicate concurrent fetches. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1838 lines
89 KiB
HTML
1838 lines
89 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>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{background:#0d1117;color:#c9d1d9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;font-size:14px}
|
|
a{color:#4a9eff;text-decoration:none}
|
|
.nav{display:flex;border-bottom:1px solid #30363d;background:#161b22;position:sticky;top:0;z-index:10}
|
|
.nav-item{padding:10px 20px;cursor:pointer;color:#8b949e;border-bottom:2px solid transparent}
|
|
.nav-item.active{color:#4a9eff;border-bottom-color:#4a9eff;font-weight:600}
|
|
.nav-item:hover{color:#c9d1d9}
|
|
.nav-right{margin-left:auto;padding:10px 20px;font-size:12px}
|
|
.connected{color:#3fb950}.disconnected{color:#f85149}
|
|
.tab{display:none;padding:16px;max-width:1200px;margin:0 auto}
|
|
.tab.active{display:block}
|
|
.stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:1px;background:#30363d;border-radius:6px;overflow:hidden;margin-bottom:16px}
|
|
.stat{padding:12px;background:#161b22;text-align:center}
|
|
.stat-label{color:#8b949e;font-size:10px;text-transform:uppercase;letter-spacing:0.5px}
|
|
.stat-value{font-size:24px;margin-top:4px;font-weight:600}
|
|
.backend-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:16px}
|
|
.card{padding:12px;background:#161b22;border:1px solid #30363d;border-radius:6px}
|
|
.card-header{display:flex;justify-content:space-between;align-items:center}
|
|
.card-name{color:#4a9eff;font-weight:600}
|
|
.card-body{margin-top:8px;color:#8b949e;font-size:12px}
|
|
.section-label{color:#8b949e;font-size:11px;text-transform:uppercase;margin-bottom:8px;letter-spacing:0.5px}
|
|
.feed{background:#161b22;border:1px solid #30363d;border-radius:6px;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:#1c2128;color:#8b949e;border-bottom:1px solid #30363d;position:sticky;top:0}
|
|
.feed-row{border-bottom:1px solid #21262d;cursor:pointer}
|
|
.feed-row:last-child{border-bottom:none}
|
|
.feed-row:hover{background:#1c2128}
|
|
.feed-detail{padding:12px 16px;background:#0d1117;border-bottom:1px solid #21262d;font-size:12px;display:grid;grid-template-columns:120px 1fr;gap:4px 12px}
|
|
.feed-detail .label{color:#8b949e}
|
|
.feed-detail .val{color:#c9d1d9;font-family:monospace;word-break:break-all}
|
|
.feed-detail .error-msg{color:#f85149;grid-column:1/-1;margin-top:4px;padding:6px;background:rgba(248,81,73,0.1);border-radius:4px}
|
|
.streaming-badge{font-size:9px;padding:1px 4px;border-radius:2px;background:rgba(74,158,255,0.2);color:#4a9eff;margin-left:4px}
|
|
.status-2xx{color:#3fb950}.status-4xx{color:#d29922}.status-5xx{color:#f85149}
|
|
.form-group{margin-bottom:16px;padding:12px;background:#161b22;border:1px solid #30363d;border-radius:6px}
|
|
.form-label{font-weight:600;margin-bottom:4px}
|
|
.form-hint{color:#8b949e;font-size:11px;margin-bottom:8px}
|
|
.form-row{display:flex;gap:8px;align-items:center;margin-top:8px}
|
|
input,select{background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:4px;font-size:13px}
|
|
input:focus,select:focus{outline:none;border-color:#4a9eff}
|
|
.btn{padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px;border:1px solid;font-weight:500}
|
|
.btn-primary{background:#238636;border-color:#2ea043;color:white}
|
|
.btn-secondary{background:#30363d;border-color:#484f58;color:#c9d1d9}
|
|
.btn:hover{opacity:0.9}
|
|
.badge{font-size:10px;padding:2px 6px;border-radius:3px}
|
|
.badge-override{background:rgba(245,166,35,0.2);color:#d29922}
|
|
.readonly-section{opacity:0.7;padding:12px;background:#161b22;border:1px solid #30363d;border-radius:6px}
|
|
.model-grid{display:grid;grid-template-columns:120px 1fr;gap:8px;align-items:center;margin-top:8px}
|
|
.model-grid .label{color:#8b949e}
|
|
.section-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
|
|
.warn{color:#d29922}
|
|
.error{color:#f85149}
|
|
.empty{text-align:center;padding:40px;color:#8b949e}
|
|
.pagination{display:flex;gap:8px;margin-top:12px;align-items:center}
|
|
.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:12px;margin-bottom:16px}
|
|
.chart-card{padding:12px;background:#161b22;border:1px solid #30363d;border-radius:6px;min-height:210px}
|
|
.chart-card.wide{grid-column:1/-1}
|
|
.chart-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:10px}
|
|
.chart-title{font-size:12px;text-transform:uppercase;letter-spacing:0.5px;color:#8b949e}
|
|
.chart-value{font-size:20px;font-weight:600;color:#c9d1d9}
|
|
.chart-subtitle{font-size:11px;color:#8b949e}
|
|
.chart-svg{width:100%;height:130px;display:block}
|
|
.chart-grid-line{stroke:#21262d;stroke-width:1}
|
|
.chart-line{fill:none;stroke-width:2.5;stroke-linecap:round;stroke-linejoin:round}
|
|
.chart-line.secondary{stroke-width:1.8;opacity:0.85}
|
|
.chart-area{fill-opacity:0.14}
|
|
.chart-axis{display:flex;justify-content:space-between;margin-top:8px;font-size:11px;color:#8b949e}
|
|
.failure-table{width:100%;border-collapse:collapse;font-size:12px}
|
|
.failure-table th,.failure-table td{padding:8px 10px;text-align:left;border-bottom:1px solid #21262d;vertical-align:top}
|
|
.failure-table th{color:#8b949e;font-size:10px;text-transform:uppercase;letter-spacing:0.5px}
|
|
.failure-summary{max-width:340px;color:#c9d1d9}
|
|
.timeline-list{display:flex;flex-direction:column;gap:10px}
|
|
.timeline-item{padding:10px;border:1px solid #21262d;border-radius:6px;background:#0d1117}
|
|
.timeline-item:hover{border-color:#30363d}
|
|
.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:10px;border-radius:999px;background:#161b22;overflow:hidden}
|
|
.timeline-bar{height:100%;min-width:4px;border-radius:999px}
|
|
.timeline-caption{display:flex;justify-content:space-between;gap:8px;margin-top:6px;font-size:11px;color:#8b949e}
|
|
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
/* Login overlay */
|
|
.login-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:#0d1117;display:flex;align-items:center;justify-content:center;z-index:1000}
|
|
.login-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:32px;width:320px}
|
|
.login-title{font-size:18px;font-weight:600;margin-bottom:24px;text-align:center;color:#c9d1d9}
|
|
.login-card input[type="password"]{width:100%;padding:8px 12px;margin-bottom:12px;font-size:14px;background:#0d1117;border:1px solid #30363d;color:#c9d1d9;border-radius:4px}
|
|
.login-card input[type="password"]:focus{outline:none;border-color:#4a9eff}
|
|
.login-card .btn{width:100%;padding:10px;font-size:14px}
|
|
.login-error{color:#f85149;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 #21262d}
|
|
.keys-grid th{background:#1c2128;color:#8b949e;font-size:10px;text-transform:uppercase;letter-spacing:0.5px;position:sticky;top:0}
|
|
.keys-grid tr:hover td{background:#161b22}
|
|
.badge-active{background:rgba(63,185,80,0.2);color:#3fb950;font-size:10px;padding:2px 6px;border-radius:3px}
|
|
.badge-revoked{background:rgba(248,81,73,0.2);color:#f85149;font-size:10px;padding:2px 6px;border-radius:3px}
|
|
.badge-expired{background:rgba(210,153,34,0.2);color:#d29922;font-size:10px;padding:2px 6px;border-radius:3px}
|
|
.btn-danger{background:#da3633;border-color:#f85149;color:white}
|
|
.btn-sm{padding:3px 8px;font-size:11px;border-radius:4px}
|
|
.key-result{background:#0d1117;border:1px solid #3fb950;border-radius:6px;padding:12px;margin-bottom:12px;font-family:monospace;font-size:12px;word-break:break-all}
|
|
.key-result-label{color:#3fb950;font-size:11px;margin-bottom:4px;font-weight:600}
|
|
/* Budget progress bar */
|
|
.budget-bar{height:6px;border-radius:3px;background:#30363d;margin-top:4px;overflow:hidden;min-width:60px}
|
|
.budget-bar-fill{height:100%;border-radius:3px;background:#3fb950;transition:width 0.3s}
|
|
.budget-bar-fill.warn{background:#d29922}.budget-bar-fill.danger{background:#f85149}
|
|
/* Tag chips (allowed_models) */
|
|
.tag-input-area{display:flex;flex-wrap:wrap;gap:4px;padding:4px;background:#0d1117;border:1px solid #30363d;border-radius:4px;min-height:34px;align-items:center;cursor:text}
|
|
.tag-input-area:focus-within{border-color:#4a9eff}
|
|
.chip{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;background:rgba(74,158,255,0.15);border:1px solid rgba(74,158,255,0.3);border-radius:12px;font-size:11px;color:#4a9eff}
|
|
.chip-remove{cursor:pointer;color:#8b949e;font-size:13px;line-height:1}
|
|
.chip-remove:hover{color:#f85149}
|
|
.tag-input-bare{background:none;border:none;outline:none;color:#c9d1d9;font-size:13px;min-width:80px;padding:2px 4px;flex:1}
|
|
/* Modal overlay */
|
|
.modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:100}
|
|
.modal{background:#161b22;border:1px solid #30363d;border-radius:8px;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:#c9d1d9}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="login-overlay" class="login-overlay" style="display:none">
|
|
<div class="login-card">
|
|
<div class="login-title">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-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" id="stat-streams-completed" style="color:#3fb950">0</div></div>
|
|
<div class="stat"><div class="stat-label">Failed</div><div class="stat-value" id="stat-streams-failed" style="color:#f85149">0</div></div>
|
|
<div class="stat"><div class="stat-label">Client Disconnects</div><div class="stat-value" id="stat-streams-disconnected" style="color:#d29922">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:#8b949e;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:#161b22;border:1px solid #30363d;border-radius:6px;padding:16px;margin-bottom:16px">
|
|
<div style="font-weight:600;margin-bottom:12px;color:#c9d1d9">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:#8b949e">Description:</div>
|
|
<input type="text" id="key-description" placeholder="e.g. team-ml" style="width:100%">
|
|
<div style="color:#8b949e">Role:</div>
|
|
<select id="key-role" style="width:100%">
|
|
<option value="developer">developer</option>
|
|
<option value="admin">admin</option>
|
|
</select>
|
|
<div style="color:#8b949e">RPM Limit:</div>
|
|
<input type="number" id="key-rpm" placeholder="blank = unlimited" min="1" style="width:100%">
|
|
<div style="color:#8b949e">TPM Limit:</div>
|
|
<input type="number" id="key-tpm" placeholder="blank = unlimited" min="1" style="width:100%">
|
|
<div style="color:#8b949e">Max Budget ($):</div>
|
|
<input type="number" id="key-budget" placeholder="blank = unlimited" min="0" step="0.01" style="width:100%">
|
|
<div style="color:#8b949e">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:#8b949e">Expires At:</div>
|
|
<input type="datetime-local" id="key-expires" style="width:100%">
|
|
<div style="color:#8b949e;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:#0d1117;border:1px solid #30363d;border-radius:4px;padding:5px 8px;color:#c9d1d9">
|
|
<div style="color:#8b949e;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:#8b949e;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:#8b949e">Description:</div>
|
|
<input type="text" id="edit-key-description" style="width:100%">
|
|
<div style="color:#8b949e">RPM Limit:</div>
|
|
<input type="number" id="edit-key-rpm" placeholder="blank = unlimited" min="1" style="width:100%">
|
|
<div style="color:#8b949e">TPM Limit:</div>
|
|
<input type="number" id="edit-key-tpm" placeholder="blank = unlimited" min="1" style="width:100%">
|
|
<div style="color:#8b949e">Max Budget ($):</div>
|
|
<input type="number" id="edit-key-budget" placeholder="blank = unlimited" min="0" step="0.01" style="width:100%">
|
|
<div style="color:#8b949e">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:#8b949e">Expires At:</div>
|
|
<input type="datetime-local" id="edit-key-expires" style="width:100%">
|
|
<div style="color:#8b949e;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:#0d1117;border:1px solid #30363d;border-radius:4px;padding:5px 8px;color:#c9d1d9">
|
|
</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:#161b22;border:1px solid #30363d;border-radius:6px;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:#161b22;border:1px solid #30363d;border-radius:6px;padding:16px;margin-bottom:16px">
|
|
<div style="font-weight:600;margin-bottom:12px;color:#c9d1d9">New Deployment</div>
|
|
<div class="model-grid" style="grid-template-columns:140px 1fr;gap:8px;align-items:center">
|
|
<div style="color:#8b949e">Model Name:</div>
|
|
<input type="text" id="model-name" placeholder="e.g. gpt-4o" style="width:100%">
|
|
<div style="color:#8b949e">Backend:</div>
|
|
<input type="text" id="model-backend" placeholder="e.g. openai" style="width:100%">
|
|
<div style="color:#8b949e">Actual Model:</div>
|
|
<input type="text" id="model-actual" placeholder="e.g. gpt-4o-2024-08-06" style="width:100%">
|
|
<div style="color:#8b949e">RPM (optional):</div>
|
|
<input type="number" id="model-rpm" placeholder="blank = unlimited" min="1" style="width:100%">
|
|
<div style="color:#8b949e">TPM (optional):</div>
|
|
<input type="number" id="model-tpm" placeholder="blank = unlimited" min="1" style="width:100%">
|
|
<div style="color:#8b949e">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:#8b949e;font-size:12px;margin-bottom:8px"></div>
|
|
<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;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:#161b22;border:1px solid #30363d;border-radius:6px;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>
|
|
(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:'#8b949e'}}, modelChildren),
|
|
el('div', {textContent: formatLatency(r.latency_ms)}),
|
|
el('div', {textContent: r.input_tokens ? String(r.input_tokens) : '--', style:{color:'#8b949e'}}),
|
|
el('div', {textContent: r.output_tokens ? String(r.output_tokens) : '--', style:{color:'#8b949e'}})
|
|
]);
|
|
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 ? '#f85149' : (item.status_code >= 400 ? '#d29922' : '#3fb950');
|
|
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;
|
|
function connectWs() {
|
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
wsAuthenticated = false;
|
|
ws = new WebSocket(proto + '//' + location.host + '/admin/ws');
|
|
ws.onopen = function() {
|
|
// 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;
|
|
setTimeout(connectWs, 3000);
|
|
};
|
|
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 ? '#d29922' : '#484f58';
|
|
});
|
|
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 ? '#f85149' : '#3fb950';
|
|
}
|
|
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 fetchCsrfToken() {
|
|
return fetch('/admin/csrf-token')
|
|
.then(function(r) {
|
|
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});
|
|
}
|
|
|
|
// -- API fetch --
|
|
function apiFetch(path) {
|
|
return fetch(API + path, {headers: authHeaders}).then(function(r) { 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 ? '#f85149' : '#3fb950';
|
|
}
|
|
// 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: '#4a9eff', getValue: function(point) { return point.requests_total || 0; }},
|
|
{color: '#f85149', getValue: function(point) { return point.requests_error || 0; }}
|
|
],
|
|
axisStart, axisEnd
|
|
);
|
|
|
|
renderLineChart(
|
|
'obs-chart-tokens',
|
|
series,
|
|
[
|
|
{color: '#3fb950', getValue: function(point) { return point.input_tokens || 0; }},
|
|
{color: '#d29922', getValue: function(point) { return point.output_tokens || 0; }}
|
|
],
|
|
axisStart, axisEnd
|
|
);
|
|
|
|
renderLineChart(
|
|
'obs-chart-cost',
|
|
series,
|
|
[{color: '#56d4dd', 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 && currentPage === 0) {
|
|
feed.appendChild(el('div', {className: 'empty', textContent: 'No requests yet'}));
|
|
} else {
|
|
requests.forEach(function(r) { feed.appendChild(makeRequestRow(r)); });
|
|
}
|
|
document.getElementById('page-info').textContent = 'Page ' + (currentPage + 1);
|
|
}).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:'#8b949e'}}, modelChildren),
|
|
el('div', {textContent: formatLatency(r.latency_ms)}),
|
|
el('div', {textContent: r.input_tokens ? String(r.input_tokens) : '--', style:{color:'#8b949e'}}),
|
|
el('div', {textContent: r.output_tokens ? String(r.output_tokens) : '--', style:{color:'#8b949e'}})
|
|
]);
|
|
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', {style:{color:'#4a9eff'}, 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;
|
|
}
|
|
});
|
|
|
|
fetch(API + '/config', {method:'PUT', headers: mutatingHeaders(), 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:'#c9d1d9'}})
|
|
]));
|
|
});
|
|
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 ? '#c9d1d9' : '#8b949e'}}),
|
|
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';
|
|
budgetCell = el('td', null, [
|
|
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) + '%'}})
|
|
])
|
|
]);
|
|
} else {
|
|
var spendAny = k.total_spend != null ? ('$' + Number(k.total_spend).toFixed(4)) : '--';
|
|
budgetCell = el('td', {textContent: spendAny, style:{color:'#8b949e'}});
|
|
}
|
|
|
|
// 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 || '--', style:{fontFamily:'monospace',cursor:'pointer',color:'#4a9eff'}});
|
|
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:'#8b949e'}}),
|
|
el('td', {textContent: k.role || 'developer'}),
|
|
el('td', {textContent: rpmTpm, style:{color:'#8b949e'}}),
|
|
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';
|
|
}
|
|
|
|
fetch(API + '/keys', {method: 'POST', headers: mutatingHeaders(), 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;
|
|
fetch(API + '/keys/' + id, {method: 'DELETE', headers: mutatingHeaders()})
|
|
.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...';
|
|
fetch(API + '/keys/' + editKeyId, {method: 'PUT', headers: mutatingHeaders(), 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);
|
|
if (entries.length === 0 && auditPage === 0) {
|
|
container.appendChild(el('div', {className: 'empty', textContent: 'No audit entries yet.'}));
|
|
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 || '--', style:{fontFamily:'monospace',fontSize:'11px',color:'#8b949e'}}),
|
|
el('td', {textContent: e.action || '--', style:{fontFamily:'monospace',color:'#4a9eff'}}),
|
|
el('td', {textContent: targetText, style:{color:'#8b949e'}}),
|
|
el('td', {textContent: e.detail || '--', style:{color:'#c9d1d9',maxWidth:'300px',overflow:'hidden',textOverflow:'ellipsis',whiteSpace:'nowrap'}}),
|
|
el('td', {textContent: e.source_ip || '--', style:{color:'#8b949e',fontFamily:'monospace',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:'#8b949e'}}),
|
|
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...';
|
|
|
|
fetch(API + '/models', {method: 'POST', headers: mutatingHeaders(), 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;
|
|
fetch(API + '/models/' + encodeURIComponent(name), {method: 'DELETE', headers: mutatingHeaders()})
|
|
.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.
|
|
fetchCsrfToken().then(function() { loadDashboard(); });
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|