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:
1 parent
608118a823
commit
de388806cb
8 files changed
+226
-32
No files matched your search
@@ -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
|
||||
};
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user