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';