diff --git a/Endpoints/ApiKeysEndpoint.cs b/Endpoints/ApiKeysEndpoint.cs
index 2f2e0da..ed3c92a 100644
--- a/Endpoints/ApiKeysEndpoint.cs
+++ b/Endpoints/ApiKeysEndpoint.cs
@@ -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
};
diff --git a/Models/ApiKeyRecord.cs b/Models/ApiKeyRecord.cs
index 68feec9..b7d6b38 100644
--- a/Models/ApiKeyRecord.cs
+++ b/Models/ApiKeyRecord.cs
@@ -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; }
}
diff --git a/Services/AnthropicKeyChecker.cs b/Services/AnthropicKeyChecker.cs
index 65b46e6..7fe032e 100644
--- a/Services/AnthropicKeyChecker.cs
+++ b/Services/AnthropicKeyChecker.cs
@@ -122,6 +122,46 @@ namespace Gott.Services
}
}
+ ///
+ /// Extrahiert Plan/Usage-Metadaten nur aus Response-Headern (kein eigener HTTP-Request).
+ /// Kann null zurückgeben wenn keine Usage-Header vorhanden sind.
+ ///
+ 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)
diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs
index e33fc87..fee555b 100644
--- a/Services/AnthropicService.cs
+++ b/Services/AnthropicService.cs
@@ -62,6 +62,21 @@ public class AnthropicService
_token = token ?? "";
}
+ ///
+ /// Liest Usage-Header aus einer Anthropic-Response und aktualisiert die api_keys-Tabelle.
+ /// Fire-and-forget – Fehler werden stillschweigend ignoriert.
+ ///
+ 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)
diff --git a/Services/DatabaseService.Integrations.cs b/Services/DatabaseService.Integrations.cs
index af9732d..51f9aad 100644
--- a/Services/DatabaseService.Integrations.cs
+++ b/Services/DatabaseService.Integrations.cs
@@ -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("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();
diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs
index b28b116..ce1baf7 100644
--- a/Services/DatabaseService.cs
+++ b/Services/DatabaseService.cs
@@ -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);
diff --git a/wwwroot/css/form.css b/wwwroot/css/form.css
index d45f432..d5e3786 100644
--- a/wwwroot/css/form.css
+++ b/wwwroot/css/form.css
@@ -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;
+}
diff --git a/wwwroot/js/pages/api-keys-page.js b/wwwroot/js/pages/api-keys-page.js
index caf9254..93bb445 100644
--- a/wwwroot/js/pages/api-keys-page.js
+++ b/wwwroot/js/pages/api-keys-page.js
@@ -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 = '';
+ checkBtn.innerHTML = '';
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';