feat(admin): team-focused UI overhaul with key editing and audit log

Backend:
- PUT /admin/api/keys/{id}: update virtual key fields (role immutable),
  refreshes DashMap, emits key_updated audit entry
- update_virtual_key(): resets spend period when budget_duration changes
- query_audit_log(): action, target_type, since, until filter params
- query_request_log(): add until filter param; wire through RequestsQuery
- GET /admin/api/metrics: include streaming counters (started/completed/failed/disconnected)
- GET /admin/api/env: include RATE_LIMIT_FAIL_POLICY

UI:
- Dashboard: streaming metrics stat row
- Request Log: key filter dropdown, since/until date pickers, Key column
- Access Control tab: allowed_models tag input, edit modal, budget progress
  bars, key prefix link navigates to filtered request log
- Settings: override badges from overridden_keys, Security section
  (IP allowlist, rate limit fail policy)
- Audit tab: action/target/date filters, paginated table

Tests:
- 7 unit tests for update_virtual_key and query_audit_log filters
- 4 integration tests for PUT /admin/api/keys/{id} lifecycle

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
whit3rabbit
2026-03-29 18:08:34 -05:00
co-authored by Claude Opus 4.6
parent 29badbf639
commit 5c73adfaea
7 changed files with 1082 additions and 63 deletions
+440 -30
View File
@@ -78,6 +78,21 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
.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>
@@ -96,8 +111,9 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
<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">Keys</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>
@@ -109,6 +125,12 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
<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="backend-cards" id="backend-cards"></div>
<div class="section-header">
<div class="section-label">Live Request Feed</div>
@@ -125,14 +147,17 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
<div id="tab-requests" class="tab">
<div class="section-header">
<div class="section-label">Request Log</div>
<div class="form-row">
<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"><div>Time</div><div>Status</div><div>Backend</div><div>Model</div><div>Latency</div><div>In</div><div>Out</div></div>
<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>
@@ -196,12 +221,52 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
</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>
@@ -243,6 +308,39 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
</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';
@@ -383,11 +481,12 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
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 === 'requests') loadRequests();
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();
});
});
@@ -475,6 +574,12 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
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 --
@@ -518,9 +623,8 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
errEl.textContent = pct;
errEl.style.color = metrics.error_rate > 0.05 ? '#f85149' : '#3fb950';
}
if (metrics.requests_per_second != null) {
document.getElementById('stat-rpm').textContent = (metrics.requests_per_second * 60).toFixed(0);
}
// Streaming metrics from the total aggregate
updateDashboardMetrics(metrics);
}).catch(function(e) { console.error('Dashboard load failed:', e); });
apiFetch('/backends').then(function(data) {
@@ -539,13 +643,39 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
// -- 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');
@@ -554,13 +684,36 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
if (requests.length === 0 && currentPage === 0) {
feed.appendChild(el('div', {className: 'empty', textContent: 'No requests yet'}));
} else {
requests.forEach(function(r) { feed.appendChild(makeFeedRow(r, true)); });
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); });
}
document.getElementById('btn-refresh').addEventListener('click', loadRequests);
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(); });
@@ -573,6 +726,15 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
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'});
@@ -581,8 +743,11 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
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', textContent: 'Log Level'}),
el('div', {className: 'form-label'}, logLabelChildren),
el('div', {className: 'form-hint', textContent: 'RUST_LOG equivalent'}),
logSelect
]);
@@ -595,8 +760,11 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
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', textContent: 'Log Bodies'}),
el('div', {className: 'form-label'}, bodiesLabelChildren),
el('div', {className: 'form-hint', textContent: 'LOG_BODIES. Privacy-sensitive.'}),
bodiesSelect
]);
@@ -607,14 +775,15 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
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 grid = el('div', {className: 'model-grid'}, [
el('div', {className: 'label', textContent: 'BIG_MODEL:'}), bigInput,
el('div', {className: 'label', textContent: 'SMALL_MODEL:'}), smallInput
]);
var group = el('div', {className: 'form-group'}, [
el('div', {className: 'form-label', textContent: name, style:{color:'#4a9eff'}}),
grid
]);
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); });
@@ -658,7 +827,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
{ 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'] },
{ 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};
@@ -687,6 +856,26 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
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.';
@@ -777,9 +966,9 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
el('th', {textContent: 'Description'}),
el('th', {textContent: 'Role'}),
el('th', {textContent: 'RPM / TPM'}),
el('th', {textContent: 'Budget'}),
el('th', {textContent: 'Spend'}),
el('th', {textContent: 'Budget / Spend'}),
el('th', {textContent: 'Requests'}),
el('th', {textContent: 'Allowed Models'}),
el('th', {textContent: 'Status'}),
el('th', {textContent: 'Actions'})
])
@@ -787,14 +976,66 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
var tbody = el('tbody', null, null);
keys.forEach(function(k) {
var badgeClass = 'badge-' + (k.status || 'active');
var budgetText = k.max_budget_usd != null
? ('$' + Number(k.max_budget_usd).toFixed(2) + (k.budget_duration ? ' / ' + k.budget_duration : ''))
: '--';
var spendText = k.total_spend != null ? ('$' + Number(k.total_spend).toFixed(4)) : '--';
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 revokeBtn = el('button', {className: 'btn btn-danger btn-sm', textContent: 'Revoke'}, null);
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)));
@@ -806,13 +1047,13 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
}(k.id, k.key_prefix)));
actionTd.appendChild(spendBtn);
var row = el('tr', null, [
el('td', {textContent: k.key_prefix || '--', style:{fontFamily:'monospace'}}),
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'}}),
el('td', {textContent: budgetText, style:{color:'#8b949e'}}),
el('td', {textContent: spendText}),
budgetCell,
el('td', {textContent: String(k.total_requests || 0)}),
modelsCell,
el('td', null, [el('span', {className: 'badge ' + badgeClass, textContent: k.status || 'active'})]),
actionTd
]);
@@ -827,6 +1068,43 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
});
}
// -- 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;
@@ -844,6 +1122,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
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;
@@ -872,6 +1151,9 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
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();
})
@@ -911,6 +1193,134 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
}).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');
+280 -23
View File
@@ -246,12 +246,14 @@ impl StatusFilter {
}
}
#[allow(clippy::too_many_arguments)]
pub fn query_request_log(
conn: &Connection,
limit: u32,
offset: u32,
backend: Option<&str>,
since: Option<&str>,
until: Option<&str>,
status_filter: Option<&str>,
key_id: Option<i64>,
) -> rusqlite::Result<Vec<RequestLogEntry>> {
@@ -271,6 +273,10 @@ pub fn query_request_log(
sql.push_str(" AND timestamp >= ?");
param_values.push(Box::new(s.to_string()));
}
if let Some(u) = until {
sql.push_str(" AND timestamp <= ?");
param_values.push(Box::new(u.to_string()));
}
if let Some(sf) = status_filter {
if let Some(parsed) = StatusFilter::parse(sf) {
parsed.apply_to_query(&mut sql, &mut param_values);
@@ -634,6 +640,56 @@ pub fn revoke_virtual_key(conn: &Connection, id: i64) -> rusqlite::Result<Option
stmt.query_row(params![id], |row| Ok(Some(row_to_virtual_key(row)?)))
}
/// Parameters for updating an existing virtual key (all fields are optional; None = clear).
pub struct UpdateVirtualKeyParams<'a> {
pub description: Option<&'a str>,
pub expires_at: Option<&'a str>,
pub rpm_limit: Option<u32>,
pub tpm_limit: Option<u32>,
pub max_budget_usd: Option<f64>,
pub budget_duration: Option<&'a str>,
pub allowed_models: Option<String>,
}
/// Update an existing virtual key. Returns the updated row, or None if not found / revoked.
/// When `budget_duration` is provided, the budget period is reset (period_start = NULL,
/// period_spend_usd = 0) so the new window starts fresh.
pub fn update_virtual_key(
conn: &Connection,
id: i64,
p: &UpdateVirtualKeyParams,
) -> rusqlite::Result<Option<VirtualKeyRow>> {
// When changing budget_duration, reset the spend period so the new window starts clean.
let mut sql = String::from(
"UPDATE virtual_api_key
SET description = ?2, expires_at = ?3, rpm_limit = ?4, tpm_limit = ?5,
max_budget_usd = ?6, budget_duration = ?7, allowed_models = ?8",
);
if p.budget_duration.is_some() {
sql.push_str(", period_start = NULL, period_spend_usd = 0.0");
}
sql.push_str(" WHERE id = ?1 AND revoked_at IS NULL");
let updated = conn.execute(
&sql,
params![
id,
p.description,
p.expires_at,
p.rpm_limit.map(|v| v as i64),
p.tpm_limit.map(|v| v as i64),
p.max_budget_usd,
p.budget_duration,
p.allowed_models,
],
)?;
if updated == 0 {
return Ok(None);
}
let sql = format!("SELECT {VIRTUAL_KEY_COLUMNS} FROM virtual_api_key WHERE id = ?1");
let mut stmt = conn.prepare(&sql)?;
stmt.query_row(params![id], |row| Ok(Some(row_to_virtual_key(row)?)))
}
/// Load all active (non-revoked, non-expired) virtual keys from the database.
pub fn load_active_virtual_keys(conn: &Connection) -> rusqlite::Result<Vec<VirtualKeyRow>> {
let now = now_iso8601();
@@ -688,12 +744,41 @@ pub fn query_audit_log(
conn: &Connection,
limit: u32,
offset: u32,
action: Option<&str>,
target_type: Option<&str>,
since: Option<&str>,
until: Option<&str>,
) -> rusqlite::Result<Vec<AuditEntry>> {
let mut stmt = conn.prepare(
let mut sql = String::from(
"SELECT id, timestamp, action, target_type, target_id, detail, source_ip
FROM audit_log ORDER BY id DESC LIMIT ?1 OFFSET ?2",
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
FROM audit_log WHERE 1=1",
);
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(a) = action {
sql.push_str(" AND action = ?");
param_values.push(Box::new(a.to_string()));
}
if let Some(t) = target_type {
sql.push_str(" AND target_type = ?");
param_values.push(Box::new(t.to_string()));
}
if let Some(s) = since {
sql.push_str(" AND timestamp >= ?");
param_values.push(Box::new(s.to_string()));
}
if let Some(u) = until {
sql.push_str(" AND timestamp <= ?");
param_values.push(Box::new(u.to_string()));
}
sql.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?");
param_values.push(Box::new(limit));
param_values.push(Box::new(offset));
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|v| v.as_ref()).collect();
let rows = stmt.query_map(param_refs.as_slice(), |row| {
Ok(AuditEntry {
id: Some(row.get(0)?),
timestamp: Some(row.get(1)?),
@@ -756,7 +841,7 @@ mod tests {
let entry = sample_entry();
insert_request_log(&conn, &entry).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, None, None).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, None, None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].request_id, "test-123");
assert_eq!(results[0].status_code, 200);
@@ -774,7 +859,7 @@ mod tests {
entry2.backend = "gemini".into();
insert_request_log(&conn, &entry2).unwrap();
let results = query_request_log(&conn, 10, 0, Some("gemini"), None, None, None).unwrap();
let results = query_request_log(&conn, 10, 0, Some("gemini"), None, None, None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].backend, "gemini");
}
@@ -789,11 +874,11 @@ mod tests {
err_entry.status_code = 500;
insert_request_log(&conn, &err_entry).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, Some("5xx"), None).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, None, Some("5xx"), None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].status_code, 500);
let results = query_request_log(&conn, 10, 0, None, None, Some("2xx"), None).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, None, Some("2xx"), None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].status_code, 200);
}
@@ -807,13 +892,13 @@ mod tests {
insert_request_log(&conn, &entry).unwrap();
}
let page1 = query_request_log(&conn, 2, 0, None, None, None, None).unwrap();
let page1 = query_request_log(&conn, 2, 0, None, None, None, None, None).unwrap();
assert_eq!(page1.len(), 2);
let page2 = query_request_log(&conn, 2, 2, None, None, None, None).unwrap();
let page2 = query_request_log(&conn, 2, 2, None, None, None, None, None).unwrap();
assert_eq!(page2.len(), 2);
let page3 = query_request_log(&conn, 2, 4, None, None, None, None).unwrap();
let page3 = query_request_log(&conn, 2, 4, None, None, None, None, None).unwrap();
assert_eq!(page3.len(), 1);
}
@@ -877,7 +962,7 @@ mod tests {
let purged = purge_old_logs(&conn, 1).unwrap();
assert_eq!(purged, 1);
let remaining = query_request_log(&conn, 10, 0, None, None, None, None).unwrap();
let remaining = query_request_log(&conn, 10, 0, None, None, None, None, None).unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].request_id, "test-123");
}
@@ -915,18 +1000,18 @@ mod tests {
insert_request_log(&conn, &entry).unwrap();
// Query without key_id filter returns all.
let results = query_request_log(&conn, 10, 0, None, None, None, None).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, None, None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].key_id, Some(42));
assert!((results[0].cost_usd.unwrap() - 0.0075).abs() < 1e-12);
// Query with matching key_id filter.
let results = query_request_log(&conn, 10, 0, None, None, None, Some(42)).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, None, None, Some(42)).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].request_id, "test-123");
// Query with non-matching key_id filter.
let results = query_request_log(&conn, 10, 0, None, None, None, Some(99)).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, None, None, Some(99)).unwrap();
assert!(results.is_empty());
// get_request_by_id also returns the new fields.
@@ -941,7 +1026,7 @@ mod tests {
let conn = in_memory_db();
insert_request_log(&conn, &sample_entry()).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, None, None).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, None, None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].key_id, None);
assert_eq!(results[0].cost_usd, None);
@@ -971,7 +1056,7 @@ mod tests {
insert_audit_entry(&conn, &entry1).unwrap();
insert_audit_entry(&conn, &entry2).unwrap();
let results = query_audit_log(&conn, 50, 0).unwrap();
let results = query_audit_log(&conn, 50, 0, None, None, None, None).unwrap();
assert_eq!(results.len(), 2);
// Reverse chronological: most recent first.
assert_eq!(results[0].action, "key_revoked");
@@ -990,7 +1075,7 @@ mod tests {
#[test]
fn audit_log_empty_returns_empty_vec() {
let conn = in_memory_db();
let results = query_audit_log(&conn, 50, 0).unwrap();
let results = query_audit_log(&conn, 50, 0, None, None, None, None).unwrap();
assert!(results.is_empty());
}
@@ -1012,11 +1097,11 @@ mod tests {
)
.unwrap();
}
let page1 = query_audit_log(&conn, 2, 0).unwrap();
let page1 = query_audit_log(&conn, 2, 0, None, None, None, None).unwrap();
assert_eq!(page1.len(), 2);
let page2 = query_audit_log(&conn, 2, 2).unwrap();
let page2 = query_audit_log(&conn, 2, 2, None, None, None, None).unwrap();
assert_eq!(page2.len(), 2);
let page3 = query_audit_log(&conn, 2, 4).unwrap();
let page3 = query_audit_log(&conn, 2, 4, None, None, None, None).unwrap();
assert_eq!(page3.len(), 1);
}
@@ -1049,7 +1134,7 @@ mod tests {
insert_request_log(&conn, &err_entry).unwrap();
// Exact code filter should match only the 404 entry.
let results = query_request_log(&conn, 10, 0, None, None, Some("404"), None).unwrap();
let results = query_request_log(&conn, 10, 0, None, None, None, Some("404"), None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].status_code, 404);
}
@@ -1061,10 +1146,182 @@ mod tests {
// Invalid filter should be silently ignored, returning all rows.
let results =
query_request_log(&conn, 10, 0, None, None, Some("garbage"), None).unwrap();
query_request_log(&conn, 10, 0, None, None, None, Some("garbage"), None).unwrap();
assert_eq!(results.len(), 1);
}
// --- update_virtual_key tests ---
fn sample_key_params() -> InsertVirtualKeyParams<'static> {
InsertVirtualKeyParams {
key_hash: "hash-abc",
key_prefix: "sk-vk-test",
description: Some("test key"),
expires_at: None,
rpm_limit: Some(100),
tpm_limit: None,
spend_limit: None,
role: "user",
max_budget_usd: Some(10.0),
budget_duration: Some("monthly"),
allowed_models: None,
}
}
#[test]
fn update_virtual_key_returns_updated_row() {
let conn = in_memory_db();
let id = insert_virtual_key(&conn, &sample_key_params()).unwrap();
let params = UpdateVirtualKeyParams {
description: Some("updated desc"),
expires_at: None,
rpm_limit: Some(200),
tpm_limit: None,
max_budget_usd: None,
budget_duration: None,
allowed_models: None,
};
let row = update_virtual_key(&conn, id, &params).unwrap();
assert!(row.is_some());
let row = row.unwrap();
assert_eq!(row.description.as_deref(), Some("updated desc"));
assert_eq!(row.rpm_limit, Some(200));
}
#[test]
fn update_virtual_key_on_revoked_returns_none() {
let conn = in_memory_db();
let id = insert_virtual_key(&conn, &sample_key_params()).unwrap();
revoke_virtual_key(&conn, id).unwrap();
let params = UpdateVirtualKeyParams {
description: Some("should not apply"),
expires_at: None,
rpm_limit: None,
tpm_limit: None,
max_budget_usd: None,
budget_duration: None,
allowed_models: None,
};
let row = update_virtual_key(&conn, id, &params).unwrap();
assert!(row.is_none());
}
#[test]
fn update_virtual_key_allowed_models_roundtrip() {
let conn = in_memory_db();
let id = insert_virtual_key(&conn, &sample_key_params()).unwrap();
let models_json = serde_json::to_string(&["gpt-4o", "claude-*"]).unwrap();
let params = UpdateVirtualKeyParams {
description: None,
expires_at: None,
rpm_limit: None,
tpm_limit: None,
max_budget_usd: None,
budget_duration: None,
allowed_models: Some(models_json),
};
let row = update_virtual_key(&conn, id, &params).unwrap().unwrap();
// row.allowed_models is parsed from JSON into Vec<String>.
assert_eq!(
row.allowed_models,
Some(vec!["gpt-4o".to_string(), "claude-*".to_string()])
);
}
#[test]
fn update_virtual_key_budget_duration_resets_period() {
let conn = in_memory_db();
// Insert with a non-null period_spend_usd to verify reset.
let id = insert_virtual_key(&conn, &sample_key_params()).unwrap();
conn.execute(
"UPDATE virtual_api_key SET period_spend_usd = 5.0, period_start = '2020-01-01' WHERE id = ?1",
params![id],
)
.unwrap();
let params = UpdateVirtualKeyParams {
description: None,
expires_at: None,
rpm_limit: None,
tpm_limit: None,
max_budget_usd: None,
budget_duration: Some("daily"),
allowed_models: None,
};
update_virtual_key(&conn, id, &params).unwrap();
// period_spend_usd should be reset to 0, period_start to NULL.
let (spend, start): (f64, Option<String>) = conn
.query_row(
"SELECT period_spend_usd, period_start FROM virtual_api_key WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(spend, 0.0);
assert!(start.is_none());
}
// --- query_audit_log filter tests ---
fn insert_audit(conn: &Connection, action: &str, target_type: &str, ts: &str) {
conn.execute(
"INSERT INTO audit_log (timestamp, action, source_ip, target_type, target_id, detail) \
VALUES (?1, ?2, '127.0.0.1', ?3, NULL, NULL)",
params![ts, action, target_type],
)
.unwrap();
}
#[test]
fn audit_filter_by_action() {
let conn = in_memory_db();
insert_audit(&conn, "key_created", "virtual_key", "2099-01-01T00:00:00Z");
insert_audit(&conn, "key_revoked", "virtual_key", "2099-01-02T00:00:00Z");
let results =
query_audit_log(&conn, 10, 0, Some("key_created"), None, None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].action, "key_created");
}
#[test]
fn audit_filter_by_target_type() {
let conn = in_memory_db();
insert_audit(&conn, "key_created", "virtual_key", "2099-01-01T00:00:00Z");
insert_audit(&conn, "config_changed", "config", "2099-01-02T00:00:00Z");
let results =
query_audit_log(&conn, 10, 0, None, Some("config"), None, None).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].target_type, "config");
}
#[test]
fn audit_filter_since_until() {
let conn = in_memory_db();
insert_audit(&conn, "key_created", "virtual_key", "2099-01-01T00:00:00Z");
insert_audit(&conn, "key_revoked", "virtual_key", "2099-01-03T00:00:00Z");
insert_audit(&conn, "key_updated", "virtual_key", "2099-01-05T00:00:00Z");
// since + until window should return only the middle entry.
let results = query_audit_log(
&conn,
10,
0,
None,
None,
Some("2099-01-02T00:00:00Z"),
Some("2099-01-04T00:00:00Z"),
)
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].action, "key_revoked");
}
#[test]
fn count_requests_since_returns_zero_on_empty_log() {
let conn = in_memory_db();
+158 -8
View File
@@ -9,7 +9,7 @@ use axum::{
http::StatusCode,
middleware,
response::IntoResponse,
routing::{delete, get, post},
routing::{delete, get, post, put},
Json, Router,
};
use dashmap::DashMap;
@@ -211,6 +211,9 @@ async fn get_csrf_token() -> axum::response::Response {
.header("content-type", "application/json")
// SameSite=Strict prevents the cookie being sent on cross-site requests.
// Not httpOnly so the admin SPA JS can read and send it back as a header.
// Secure flag intentionally omitted: admin binds to 127.0.0.1 over plain HTTP,
// so setting Secure would prevent the browser from sending the cookie at all.
// If TLS is added to the admin server, Secure must be added here.
.header(
"set-cookie",
format!("csrf_token={token}; Path=/admin; SameSite=Strict; Max-Age=86400"),
@@ -245,7 +248,7 @@ pub fn admin_router(shared: SharedState, token: Arc<String>) -> Router {
.route("/admin/api/requests/{id}", get(get_request_by_id))
.route("/admin/api/backends", get(get_backends))
.route("/admin/api/keys", post(create_key).get(list_keys))
.route("/admin/api/keys/{id}", delete(revoke_key))
.route("/admin/api/keys/{id}", put(update_key).delete(revoke_key))
.route(
"/admin/api/keys/{id}/spend",
get(super::spend::get_key_spend),
@@ -368,10 +371,11 @@ async fn get_env() -> Json<serde_json::Value> {
// TLS
"TLS_CLIENT_CERT_P12": plain("TLS_CLIENT_CERT_P12"),
"TLS_CA_CERT": plain("TLS_CA_CERT"),
// Network
"IP_ALLOWLIST": plain("IP_ALLOWLIST"),
"TRUST_PROXY_HEADERS": plain("TRUST_PROXY_HEADERS"),
"WEBHOOK_URLS": plain("WEBHOOK_URLS"),
// Network / security
"IP_ALLOWLIST": plain("IP_ALLOWLIST"),
"TRUST_PROXY_HEADERS": plain("TRUST_PROXY_HEADERS"),
"WEBHOOK_URLS": plain("WEBHOOK_URLS"),
"RATE_LIMIT_FAIL_POLICY": plain("RATE_LIMIT_FAIL_POLICY"),
// Admin
"ADMIN_PORT": plain("ADMIN_PORT"),
"ADMIN_DB_PATH": plain("ADMIN_DB_PATH"),
@@ -692,6 +696,10 @@ async fn get_metrics(State(shared): State<SharedState>) -> Json<serde_json::Valu
aggregate.requests_total += snap.requests_total;
aggregate.requests_success += snap.requests_success;
aggregate.requests_error += snap.requests_error;
aggregate.streams_started += snap.streams_started;
aggregate.streams_completed += snap.streams_completed;
aggregate.streams_failed += snap.streams_failed;
aggregate.streams_client_disconnected += snap.streams_client_disconnected;
backends.insert(
name.clone(),
serde_json::to_value(&snap).unwrap_or_default(),
@@ -708,6 +716,10 @@ async fn get_metrics(State(shared): State<SharedState>) -> Json<serde_json::Valu
"requests_total": aggregate.requests_total,
"requests_success": aggregate.requests_success,
"requests_error": aggregate.requests_error,
"streams_started": aggregate.streams_started,
"streams_completed": aggregate.streams_completed,
"streams_failed": aggregate.streams_failed,
"streams_client_disconnected": aggregate.streams_client_disconnected,
},
"latency_p50_ms": p50,
"latency_p95_ms": p95,
@@ -761,6 +773,7 @@ struct RequestsQuery {
offset: Option<u32>,
backend: Option<String>,
since: Option<String>,
until: Option<String>,
status: Option<String>,
key_id: Option<i64>,
}
@@ -775,6 +788,7 @@ async fn get_requests(
let backend = params.backend;
let since = params.since;
let until = params.until;
let status = params.status;
let key_id = params.key_id;
match crate::admin::state::with_db(&shared.db, move |conn| {
@@ -784,6 +798,7 @@ async fn get_requests(
offset,
backend.as_deref(),
since.as_deref(),
until.as_deref(),
status.as_deref(),
key_id,
)
@@ -943,7 +958,10 @@ async fn create_key(
super::keys::VirtualKeyMeta {
id,
description: body.description.clone(),
expires_at: None,
expires_at: body.expires_at.as_deref().and_then(|s| {
crate::integrations::langfuse::iso8601_to_epoch(s)
.and_then(|e| i64::try_from(e).ok())
}),
rpm_limit: body.rpm_limit,
tpm_limit: body.tpm_limit,
rate_state: std::sync::Arc::new(super::keys::RateLimitState::new()),
@@ -1029,6 +1047,7 @@ async fn list_keys(State(shared): State<SharedState>) -> axum::response::Respons
"max_budget_usd": k.max_budget_usd,
"budget_duration": k.budget_duration,
"period_spend_usd": k.period_spend_usd,
"allowed_models": k.allowed_models,
})
})
.collect();
@@ -1042,6 +1061,121 @@ async fn list_keys(State(shared): State<SharedState>) -> axum::response::Respons
}
}
/// Request body for PUT /admin/api/keys/{id}.
/// All fields are optional: absent = clear (set to NULL); role is immutable after creation.
#[derive(serde::Deserialize)]
struct UpdateKeyRequest {
description: Option<String>,
expires_at: Option<String>,
rpm_limit: Option<u32>,
tpm_limit: Option<u32>,
max_budget_usd: Option<f64>,
budget_duration: Option<String>,
allowed_models: Option<Vec<String>>,
}
/// PUT /admin/api/keys/{id} -- update an existing virtual key (except role).
async fn update_key(
State(shared): State<SharedState>,
Path(id): Path<i64>,
Json(body): Json<UpdateKeyRequest>,
) -> axum::response::Response {
let allowed_models_json = body
.allowed_models
.as_ref()
.and_then(|v| serde_json::to_string(v).ok());
let desc = body.description.clone();
let exp = body.expires_at.clone();
let rpm = body.rpm_limit;
let tpm = body.tpm_limit;
let max_budget = body.max_budget_usd;
let budget_dur = body.budget_duration.clone();
let result = super::state::with_db(&shared.db, move |conn| {
super::db::update_virtual_key(
conn,
id,
&super::db::UpdateVirtualKeyParams {
description: desc.as_deref(),
expires_at: exp.as_deref(),
rpm_limit: rpm,
tpm_limit: tpm,
max_budget_usd: max_budget,
budget_duration: budget_dur.as_deref(),
allowed_models: allowed_models_json,
},
)
})
.await;
match result {
Some(Ok(Some(row))) => {
// Refresh the DashMap entry so in-flight auth sees updated limits.
if let Some(hash_bytes) = super::keys::hash_from_hex(&row.key_hash) {
shared.virtual_keys.entry(hash_bytes).and_modify(|meta| {
meta.description = body.description.clone();
meta.expires_at = body.expires_at.as_deref().and_then(|s| {
crate::integrations::langfuse::iso8601_to_epoch(s)
.and_then(|e| i64::try_from(e).ok())
});
meta.rpm_limit = body.rpm_limit;
meta.tpm_limit = body.tpm_limit;
meta.max_budget_usd = body.max_budget_usd;
if body.budget_duration.is_some() {
meta.budget_duration = body
.budget_duration
.as_deref()
.and_then(super::keys::BudgetDuration::parse);
// Reset spend period to match db-layer reset.
meta.period_start = None;
meta.period_spend_usd = 0.0;
}
meta.allowed_models = body.allowed_models.clone();
});
}
emit_audit(
&shared,
crate::admin::db::AuditEntry {
id: None,
timestamp: None,
action: "key_updated".into(),
target_type: "virtual_key".into(),
target_id: Some(id.to_string()),
detail: Some(format!("prefix={}", row.key_prefix)),
source_ip: None,
},
);
(
StatusCode::OK,
Json(serde_json::json!({
"id": row.id,
"key_prefix": row.key_prefix,
"description": row.description,
"expires_at": row.expires_at,
"rpm_limit": row.rpm_limit,
"tpm_limit": row.tpm_limit,
"role": row.role,
"max_budget_usd": row.max_budget_usd,
"budget_duration": row.budget_duration,
"allowed_models": row.allowed_models,
"status": row.status(),
})),
)
.into_response()
}
Some(Ok(None)) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({"error": "Key not found or already revoked"})),
)
.into_response(),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Failed to update key"})),
)
.into_response(),
}
}
/// DELETE /admin/api/keys/{id} -- revoke a virtual key.
async fn revoke_key(
State(shared): State<SharedState>,
@@ -1250,6 +1384,10 @@ async fn remove_model(
struct AuditQuery {
limit: Option<u32>,
offset: Option<u32>,
action: Option<String>,
target_type: Option<String>,
since: Option<String>,
until: Option<String>,
}
/// GET /admin/api/audit -- paginated audit log.
@@ -1259,8 +1397,20 @@ async fn get_audit_log(
) -> Json<serde_json::Value> {
let limit = params.limit.unwrap_or(50).min(1000);
let offset = params.offset.unwrap_or(0);
let action = params.action;
let target_type = params.target_type;
let since = params.since;
let until = params.until;
match crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::query_audit_log(conn, limit, offset)
crate::admin::db::query_audit_log(
conn,
limit,
offset,
action.as_deref(),
target_type.as_deref(),
since.as_deref(),
until.as_deref(),
)
})
.await
{
+1 -1
View File
@@ -166,7 +166,7 @@ pub(crate) fn base64_encode(input: &[u8]) -> String {
/// Parse ISO 8601 UTC timestamp ("2026-03-27T10:15:30Z") to Unix epoch seconds.
/// Returns None on parse failure.
pub(crate) fn iso8601_to_epoch(s: &str) -> Option<u64> {
pub fn iso8601_to_epoch(s: &str) -> Option<u64> {
if s.len() < 20 {
return None;
}
+4 -1
View File
@@ -279,7 +279,10 @@ async fn main() {
admin::keys::VirtualKeyMeta {
id: key_row.id,
description: key_row.description.clone(),
expires_at: None,
expires_at: key_row.expires_at.as_deref().and_then(|s| {
anyllm_proxy::integrations::langfuse::iso8601_to_epoch(s)
.and_then(|e| i64::try_from(e).ok())
}),
rpm_limit: key_row.rpm_limit,
tpm_limit: key_row.tpm_limit,
rate_state: Arc::new(admin::keys::RateLimitState::new()),
+21
View File
@@ -264,6 +264,27 @@ pub async fn validate_auth(
.and_then(|h| map.get_mut(&h))
.or_else(|| map.get_mut(&credential_hash));
if let Some(mut meta) = vk_lookup {
// Reject expired virtual keys at auth time (lazy eviction).
if let Some(exp) = meta.expires_at {
let now_secs = (now_ms() / 1000) as i64;
if now_secs >= exp {
drop(meta);
// Remove expired key from cache so future lookups skip it.
if let Some(h) = hmac_hash {
map.remove(&h);
} else {
map.remove(&credential_hash);
}
let err_body = serde_json::json!({
"error": {
"type": "authentication_error",
"message": "Virtual key has expired."
}
});
return Err((StatusCode::UNAUTHORIZED, Json(err_body)).into_response());
}
}
// RBAC: developer keys cannot access admin endpoints
if meta.role == KeyRole::Developer {
let path = request.uri().path();
+178
View File
@@ -199,6 +199,184 @@ async fn revoke_nonexistent_key_returns_404() {
assert_eq!(resp.status(), 404);
}
// ---------------------------------------------------------------------------
// Update key (PUT /admin/api/keys/{id}) tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn update_key_returns_200_with_updated_fields() {
let (app, _state) = test_admin_router();
// Create a key first.
let create_req = Request::post("/admin/api/keys")
.header("host", "localhost:9090")
.header("authorization", "Bearer test-admin-token")
.header("content-type", "application/json")
.header("x-csrf-token", TEST_CSRF_TOKEN)
.header("cookie", TEST_CSRF_COOKIE)
.body(Body::from(
serde_json::to_string(&json!({"description": "update-test", "rpm_limit": 10})).unwrap(),
))
.unwrap();
let resp = app.clone().oneshot(create_req).await.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap(),
)
.unwrap();
let id = body["id"].as_i64().unwrap();
// Update description and rpm_limit.
let update_req = Request::put(format!("/admin/api/keys/{id}"))
.header("host", "localhost:9090")
.header("authorization", "Bearer test-admin-token")
.header("content-type", "application/json")
.header("x-csrf-token", TEST_CSRF_TOKEN)
.header("cookie", TEST_CSRF_COOKIE)
.body(Body::from(
serde_json::to_string(&json!({
"description": "updated-desc",
"rpm_limit": 200,
"allowed_models": ["gpt-4o", "claude-*"]
}))
.unwrap(),
))
.unwrap();
let resp = app.clone().oneshot(update_req).await.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap(),
)
.unwrap();
assert_eq!(body["description"], "updated-desc");
assert_eq!(body["rpm_limit"], 200);
let models = body["allowed_models"].as_array().unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0], "gpt-4o");
assert_eq!(models[1], "claude-*");
}
#[tokio::test]
async fn update_nonexistent_key_returns_404() {
let (app, _state) = test_admin_router();
let req = Request::put("/admin/api/keys/99999")
.header("host", "localhost:9090")
.header("authorization", "Bearer test-admin-token")
.header("content-type", "application/json")
.header("x-csrf-token", TEST_CSRF_TOKEN)
.header("cookie", TEST_CSRF_COOKIE)
.body(Body::from(
serde_json::to_string(&json!({"description": "no-such-key"})).unwrap(),
))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn update_revoked_key_returns_404() {
let (app, _state) = test_admin_router();
// Create then revoke.
let create_req = Request::post("/admin/api/keys")
.header("host", "localhost:9090")
.header("authorization", "Bearer test-admin-token")
.header("content-type", "application/json")
.header("x-csrf-token", TEST_CSRF_TOKEN)
.header("cookie", TEST_CSRF_COOKIE)
.body(Body::from(
serde_json::to_string(&json!({"description": "revoke-then-update"})).unwrap(),
))
.unwrap();
let resp = app.clone().oneshot(create_req).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap(),
)
.unwrap();
let id = body["id"].as_i64().unwrap();
let revoke_req = Request::delete(format!("/admin/api/keys/{id}"))
.header("host", "localhost:9090")
.header("authorization", "Bearer test-admin-token")
.header("x-csrf-token", TEST_CSRF_TOKEN)
.header("cookie", TEST_CSRF_COOKIE)
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(revoke_req).await.unwrap();
assert_eq!(resp.status(), 200);
// Update should fail with 404.
let update_req = Request::put(format!("/admin/api/keys/{id}"))
.header("host", "localhost:9090")
.header("authorization", "Bearer test-admin-token")
.header("content-type", "application/json")
.header("x-csrf-token", TEST_CSRF_TOKEN)
.header("cookie", TEST_CSRF_COOKIE)
.body(Body::from(
serde_json::to_string(&json!({"description": "should-fail"})).unwrap(),
))
.unwrap();
let resp = app.oneshot(update_req).await.unwrap();
assert_eq!(resp.status(), 404);
}
#[tokio::test]
async fn update_key_refreshes_dashmap() {
let (app, state) = test_admin_router();
// Create key.
let create_req = Request::post("/admin/api/keys")
.header("host", "localhost:9090")
.header("authorization", "Bearer test-admin-token")
.header("content-type", "application/json")
.header("x-csrf-token", TEST_CSRF_TOKEN)
.header("cookie", TEST_CSRF_COOKIE)
.body(Body::from(
serde_json::to_string(&json!({"description": "dashmap-update-test", "rpm_limit": 10}))
.unwrap(),
))
.unwrap();
let resp = app.clone().oneshot(create_req).await.unwrap();
assert_eq!(resp.status(), 201);
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap(),
)
.unwrap();
let id = body["id"].as_i64().unwrap();
let raw_key = body["key"].as_str().unwrap().to_string();
// Update rpm_limit via PUT.
let update_req = Request::put(format!("/admin/api/keys/{id}"))
.header("host", "localhost:9090")
.header("authorization", "Bearer test-admin-token")
.header("content-type", "application/json")
.header("x-csrf-token", TEST_CSRF_TOKEN)
.header("cookie", TEST_CSRF_COOKIE)
.body(Body::from(
serde_json::to_string(&json!({"rpm_limit": 500})).unwrap(),
))
.unwrap();
let resp = app.oneshot(update_req).await.unwrap();
assert_eq!(resp.status(), 200);
// Verify DashMap entry was updated.
let hash = admin::keys::hmac_hash_key(&raw_key, &state.hmac_secret);
let hash_bytes = admin::keys::hash_from_hex(&hash).unwrap();
let meta = state
.virtual_keys
.get(&hash_bytes)
.expect("key should exist in DashMap");
assert_eq!(meta.rpm_limit, Some(500));
}
// ---------------------------------------------------------------------------
// Helpers for proxy-level tests
// ---------------------------------------------------------------------------