API-Keys: Source-URL, einheitliches Status-Schema, passive Usage-Erfassung, Usage-Bars

- SourceUrl-Feld in api_keys: GitHub-URL bei adoptierten Scan-Keys gespeichert
- HandleAdopt: Name = user/repo (aus URL extrahiert), SourceUrl = vollständige URL
- Status-Schema vereinheitlicht: überall 'valid' statt 'ok'/'usage_limited'
- AnthropicKeyChecker.ExtractMetaFromHeaders(): passive Header-Auswertung
- AnthropicService: TryUpdateKeyMeta() nach jedem Chat-Request (fire-and-forget)
- DatabaseService: UpdateApiKeyMetaByToken() für passive Updates per Token
- Frontend: formatKeyStatus() zentrale Status-Labels für API-Keys + Scanned-Keys
- Frontend: createUsageBadge() zweizeiliger Balken (Sitzung/Woche) mit Farbstufen
- CSS: Usage-Badge mit Track/Fill, warn (>=70%) und danger (>=90%) Klassen
This commit is contained in:
Meleki committed 2026-03-12 18:33:37 +01:00
1 parent 608118a823
commit de388806cb
8 files changed
+226 -32

No files matched your search

+16 -7
View File
@@ -49,6 +49,7 @@ public static class ApiKeysEndpoint
k.Usage7d,
k.Usage5h,
k.CheckedAt,
k.SourceUrl,
k.CreatedAt,
k.UpdatedAt
});
@@ -205,15 +206,16 @@ public static class ApiKeysEndpoint
}
var result = await AnthropicKeyChecker.CheckKeyAsync(httpFactory, existing.ApiKey);
var dbStatus = (result.Status == "ok" || result.Status == "usage_limited") ? "valid" : result.Status;
// Metadaten in DB speichern
await db.UpdateApiKeyMeta(keyId, result.Status, result.Plan, result.Usage7d, result.Usage5h);
await db.UpdateApiKeyMeta(keyId, dbStatus, result.Plan, result.Usage7d, result.Usage5h);
await ctx.Response.WriteAsJsonAsync(new
{
status = (result.Status == "ok" || result.Status == "usage_limited") ? "ok" : "error",
status = dbStatus,
message = result.Message,
keyType = result.Plan,
plan = result.Plan,
result.IsSubscription,
result.Usage7d,
result.Usage5h,
@@ -324,10 +326,16 @@ public static class ApiKeysEndpoint
return;
}
// URL als Titel verwenden, Metadaten übertragen
var title = !string.IsNullOrWhiteSpace(sk.SourceUrl)
? sk.SourceUrl.Replace("https://github.com/", "")
: $"Scan: {sk.Plan}";
// Name: user/repo aus URL extrahieren, SourceUrl: vollständige URL
var sourceUrl = !string.IsNullOrWhiteSpace(sk.SourceUrl) ? sk.SourceUrl : null;
var title = $"Scan: {sk.Plan ?? "Unknown"}";
if (sourceUrl != null)
{
var path = sourceUrl.Replace("https://github.com/", "");
var parts = path.Split('/');
if (parts.Length >= 2)
title = parts[0] + "/" + parts[1];
}
var now = DateTime.UtcNow.ToString("o");
var record = new ApiKeyRecord
@@ -342,6 +350,7 @@ public static class ApiKeysEndpoint
Usage7d = sk.Usage7d,
Usage5h = sk.Usage5h,
CheckedAt = sk.CheckedAt,
SourceUrl = sourceUrl,
CreatedAt = now,
UpdatedAt = now
};
+1
View File
@@ -13,6 +13,7 @@ public class ApiKeyRecord
public double? Usage7d { get; set; }
public double? Usage5h { get; set; }
public string CheckedAt { get; set; } // Wann zuletzt geprüft
public string SourceUrl { get; set; } // GitHub-URL (bei adoptierten Scan-Keys)
public string CreatedAt { get; set; }
public string UpdatedAt { get; set; }
}
+40
View File
@@ -122,6 +122,46 @@ namespace Gott.Services
}
}
/// <summary>
/// Extrahiert Plan/Usage-Metadaten nur aus Response-Headern (kein eigener HTTP-Request).
/// Kann null zurückgeben wenn keine Usage-Header vorhanden sind.
/// </summary>
public static KeyCheckResult ExtractMetaFromHeaders(HttpResponseHeaders headers)
{
var d7Util = GetHeader(headers, "anthropic-ratelimit-unified-7d-utilization", "unified-7d-utilization");
var d5hUtil = GetHeader(headers, "anthropic-ratelimit-unified-5h-utilization", "unified-5h-utilization");
if (d7Util == null && d5hUtil == null)
return null; // Keine Usage-Header → nichts zu updaten
var overageUtil = GetHeader(headers, "anthropic-ratelimit-unified-overage-utilization", "unified-overage-utilization");
var fallbackPct = GetHeader(headers, "anthropic-ratelimit-unified-fallback-percentage", "unified-fallback-percentage");
string plan;
if (overageUtil != null && double.TryParse(overageUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var ov) && ov > 0)
plan = "Max";
else if (fallbackPct == null)
plan = "Pro";
else
plan = "Pro/Max";
var result = new KeyCheckResult
{
Status = "valid",
Plan = plan,
IsSubscription = true
};
if (d7Util != null && double.TryParse(d7Util, NumberStyles.Float, CultureInfo.InvariantCulture, out var d7))
result.Usage7d = Math.Round(d7 * 100, 1);
if (d5hUtil != null && double.TryParse(d5hUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var d5))
result.Usage5h = Math.Round(d5 * 100, 1);
if (overageUtil != null && double.TryParse(overageUtil, NumberStyles.Float, CultureInfo.InvariantCulture, out var ovVal))
result.Overage = Math.Round(ovVal * 100, 1);
return result;
}
private static string GetHeader(HttpResponseHeaders headers, params string[] names)
{
foreach (var name in names)
+21
View File
@@ -62,6 +62,21 @@ public class AnthropicService
_token = token ?? "";
}
/// <summary>
/// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle.
/// Fire-and-forget – Fehler werden stillschweigend ignoriert.
/// </summary>
private void TryUpdateKeyMeta(HttpResponseMessage httpResp)
{
try
{
var meta = AnthropicKeyChecker.ExtractMetaFromHeaders(httpResp.Headers);
if (meta == null || string.IsNullOrEmpty(_token)) return;
_ = _db.UpdateApiKeyMetaByToken(_token, meta.Status, meta.Plan, meta.Usage7d, meta.Usage5h);
}
catch { /* silent */ }
}
public async Task StreamChat(
HttpResponse response,
string modelId,
@@ -175,6 +190,8 @@ public class AnthropicService
workCt
);
TryUpdateKeyMeta(httpResp);
if (!httpResp.IsSuccessStatusCode)
{
var errBody = await httpResp.Content.ReadAsStringAsync(workCt);
@@ -815,6 +832,8 @@ public class AnthropicService
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent },
cancellationToken: workCt);
TryUpdateKeyMeta(httpResp);
var respBody = await httpResp.Content.ReadAsStringAsync(workCt);
if (!httpResp.IsSuccessStatusCode)
@@ -1034,6 +1053,8 @@ public class AnthropicService
using var httpResp = await client.SendAsync(
new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent });
TryUpdateKeyMeta(httpResp);
var respBody = await httpResp.Content.ReadAsStringAsync();
if (!httpResp.IsSuccessStatusCode)
+11 -2
View File
@@ -372,8 +372,8 @@ public partial class DatabaseService
{
using var conn = CreateConnection();
await conn.ExecuteAsync(@"
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, status, plan, usage_7d, usage_5h, checked_at, created_at, updated_at)
VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @Status, @Plan, @Usage7d, @Usage5h, @CheckedAt, @CreatedAt, @UpdatedAt)",
INSERT INTO api_keys (key_id, provider, name, api_key, is_active, status, plan, usage_7d, usage_5h, checked_at, source_url, created_at, updated_at)
VALUES (@KeyId, @Provider, @Name, @ApiKey, @IsActive, @Status, @Plan, @Usage7d, @Usage5h, @CheckedAt, @SourceUrl, @CreatedAt, @UpdatedAt)",
record);
return await conn.ExecuteScalarAsync<long>("SELECT last_insert_rowid()");
}
@@ -404,6 +404,15 @@ public partial class DatabaseService
new { keyId, status, plan, usage7d, usage5h, now = DateTime.UtcNow.ToString("o") });
}
public async Task UpdateApiKeyMetaByToken(string apiKey, string status, string plan, double? usage7d, double? usage5h)
{
using var conn = CreateConnection();
await conn.ExecuteAsync(@"
UPDATE api_keys SET status = @status, plan = @plan, usage_7d = @usage7d, usage_5h = @usage5h, checked_at = @now, updated_at = @now
WHERE api_key = @apiKey AND is_active = 1",
new { apiKey, status, plan, usage7d, usage5h, now = DateTime.UtcNow.ToString("o") });
}
public async Task SetActiveApiKey(string keyId, string provider)
{
using var conn = CreateConnection();
+1
View File
@@ -336,6 +336,7 @@ public partial class DatabaseService
TryAddColumn(conn, "api_keys", "usage_7d", "REAL");
TryAddColumn(conn, "api_keys", "usage_5h", "REAL");
TryAddColumn(conn, "api_keys", "checked_at", "TEXT");
TryAddColumn(conn, "api_keys", "source_url", "TEXT");
// Migration: Bestehende PNG/JPEG-Avatare/Images zu WebP konvertieren
MigrateImagesToWebP(conn);
+61
View File
@@ -597,6 +597,13 @@ label.skill-item {
opacity: var(--semi-transparent);
}
a.admin-user-meta {
opacity: unset;
font-size: var(--font-size);
overflow: hidden;
text-overflow: ellipsis;
}
.admin-user-actions {
display: flex;
gap: var(--badge-spacing);
@@ -665,3 +672,57 @@ label.skill-item {
font-size: 0.85em;
padding: 0.4em 0.8em;
}
/* ── Usage Bars (API Keys) ── */
.usage-badge {
display: inline-flex;
flex-direction: column;
padding: var(--badge-padding);
background: var(--f3-bg);
border: var(--border-width) solid var(--f2-border);
border-radius: var(--border-radius);
font-size: var(--font-size);
width: 20em;
margin-top: var(--badge-spacing);
}
.usage-row {
display: flex;
align-items: center;
}
.usage-label {
width: 5em;
opacity: var(--semi-transparent);
flex-shrink: 0;
}
.usage-track {
flex: 1;
height: 6px;
background: var(--f1-bg);
border-radius: 3px;
overflow: hidden;
}
.usage-fill {
height: 100%;
border-radius: 3px;
background: var(--secondary);
transition: width .3s;
}
.usage-fill.warn {
background: var(--warning);
}
.usage-fill.danger {
background: var(--dangerous);
}
.usage-pct {
width: 3em;
text-align: right;
flex-shrink: 0;
}
+75 -23
View File
@@ -1,5 +1,56 @@
// ── API Keys Page (Admin) ──
function formatKeyStatus(status) {
var map = {
'valid': '✅ Gültig',
'invalid': '❌ Ungültig',
'rate_limited': '⚠️ Rate Limited',
'quota_exceeded': '⚠️ Quota Exceeded',
'error': '⚠️ Fehler',
'unchecked': '⏳ Ungeprüft'
};
return map[status] || status;
}
function createUsageBadge(usage5h, usage7d) {
if (usage5h == null && usage7d == null) return null;
var badge = document.createElement('div');
badge.className = 'usage-badge';
function addRow(label, pct) {
if (pct == null) return;
var row = document.createElement('div');
row.className = 'usage-row';
var lbl = document.createElement('span');
lbl.className = 'usage-label';
lbl.textContent = label;
var track = document.createElement('div');
track.className = 'usage-track';
var fill = document.createElement('div');
fill.className = 'usage-fill';
if (pct >= 90) fill.className += ' danger';
else if (pct >= 70) fill.className += ' warn';
fill.style.width = Math.min(pct, 100) + '%';
track.appendChild(fill);
var val = document.createElement('span');
val.className = 'usage-pct';
val.textContent = pct + '%';
row.appendChild(lbl);
row.appendChild(track);
row.appendChild(val);
badge.appendChild(row);
}
addRow('Sitzung', usage5h);
addRow('Woche', usage7d);
return badge;
}
registerPage('api-keys', {
async init() {
if (!currentUser || !currentUser.isAdmin) {
@@ -48,19 +99,24 @@ registerPage('api-keys', {
metaSpan.className = 'admin-user-meta';
var parts = [key.provider];
if (key.isActive) parts.push('✅ Aktiv');
if (key.status === 'ok') parts.push('✅ Gültig');
else if (key.status === 'invalid') parts.push('❌ Ungültig');
else if (key.status === 'rate_limited') parts.push('⚠️ Rate Limited');
else if (key.status === 'quota_exceeded') parts.push('⚠️ Quota');
else if (key.status === 'error') parts.push('⚠️ Fehler');
parts.push(formatKeyStatus(key.status));
if (key.plan) parts.push(key.plan);
if (key.usage7d != null) parts.push('7d: ' + key.usage7d + '%');
if (key.usage5h != null) parts.push('5h: ' + key.usage5h + '%');
if (key.checkedAt) parts.push(new Date(key.checkedAt).toLocaleString());
metaSpan.textContent = parts.join(' · ');
info.appendChild(nameSpan);
if (key.sourceUrl) {
var urlSpan = document.createElement('a');
urlSpan.className = 'admin-user-meta';
urlSpan.href = key.sourceUrl;
urlSpan.target = '_blank';
urlSpan.rel = 'noopener';
urlSpan.textContent = key.sourceUrl.replace('https://github.com/', '');
info.appendChild(urlSpan);
}
info.appendChild(metaSpan);
var usageBadge = createUsageBadge(key.usage5h, key.usage7d);
if (usageBadge) info.appendChild(usageBadge);
var actions = document.createElement('div');
actions.className = 'admin-user-actions';
@@ -75,7 +131,7 @@ registerPage('api-keys', {
var checkBtn = document.createElement('button');
checkBtn.className = 'icon-btn';
checkBtn.title = 'Key prüfen';
checkBtn.innerHTML = '<i class="fa fa-heartbeat"></i>';
checkBtn.innerHTML = '<i class="fa fa-refresh"></i>';
checkBtn.addEventListener('click', function() { checkKey(key, checkBtn, statusLine); });
actions.appendChild(checkBtn);
}
@@ -140,17 +196,15 @@ registerPage('api-keys', {
try {
var result = await apiCheckApiKey(key.keyId);
statusLine.style.opacity = '1';
if (result.status === 'ok') {
var parts = ['✅ Gültig', result.keyType];
if (result.usage5h != null) parts.push('5h: ' + result.usage5h + '%');
if (result.usage7d != null) {
var s7d = '7d: ' + result.usage7d + '%';
if (result.status7d === 'allowed_warning') s7d += ' ⚠️';
else if (result.status7d === 'exceeded') s7d += ' 🛑';
parts.push(s7d);
}
statusLine.innerHTML = '';
if (result.status === 'valid') {
var text = document.createElement('span');
var parts = [formatKeyStatus('valid'), result.plan];
if (result.overage != null && result.overage > 0) parts.push('Overage: ' + result.overage + '%');
statusLine.textContent = parts.join(' · ');
text.textContent = parts.join(' · ');
statusLine.appendChild(text);
var badge = createUsageBadge(result.usage5h, result.usage7d);
if (badge) { badge.style.marginTop = '4px'; statusLine.appendChild(badge); }
} else {
statusLine.textContent = '❌ ' + (result.message || 'Unbekannter Fehler');
}
@@ -294,23 +348,21 @@ registerPage('api-keys', {
nameSpan.href = sk.sourceUrl;
nameSpan.target = '_blank';
nameSpan.rel = 'noopener';
nameSpan.style.cssText = 'word-break:break-all;font-size:.85em';
nameSpan.textContent = sk.sourceUrl.replace('https://github.com/', '');
info.appendChild(nameSpan);
}
var metaSpan = document.createElement('span');
metaSpan.className = 'admin-user-meta';
var statusIcon = sk.status === 'valid' ? '✅' : sk.status === 'invalid' ? '❌' : sk.status === 'unchecked' ? '⏳' : '⚠️';
var metaParts = isAdopted ? ['📥 Übernommen'] : [];
metaParts.push(statusIcon + ' ' + sk.status);
metaParts.push(formatKeyStatus(sk.status));
if (sk.plan && sk.plan !== 'Unknown') metaParts.push(sk.plan);
if (sk.usage7d != null) metaParts.push('7d: ' + sk.usage7d + '%');
if (sk.usage5h != null) metaParts.push('5h: ' + sk.usage5h + '%');
if (sk.checkedAt) metaParts.push(new Date(sk.checkedAt).toLocaleString());
metaSpan.textContent = metaParts.join(' · ');
info.appendChild(metaSpan);
var skUsageBadge = createUsageBadge(sk.usage5h, sk.usage7d);
if (skUsageBadge) info.appendChild(skUsageBadge);
var actions = document.createElement('div');
actions.className = 'admin-user-actions';