feat: GitHub Key Scanner mit automatischem Intervall und manueller UI
- KeyScannerService als BackgroundService (Timer + manueller Trigger) - GitHub Code Search: sucht nach sk-ant-oat01-* Keys in Dotenv-Dateien - AnthropicKeyChecker: shared Key-Check-Logik (ersetzt Duplikat in ApiKeysEndpoint) - ScannedKeyRecord Model mit Soft-Delete und Adopt-Verknüpfung - Scan-Endpoints: trigger, status, list, recheck, adopt, delete - API-Keys-Page: Scanner-UI mit Status-Indikator und Scanned-Keys-Liste - GitHub-URL pro gefundenem Key (html_url aus Search API) - Server-Settings: GitHubToken + ScanIntervalHours konfigurierbar - DB-Migration: scanned_keys Tabelle + api_keys Meta-Spalten (status, plan, usage) - ApiKeyRecord erweitert um Status/Plan/Usage-Felder mit UpdateApiKeyMeta()
This commit is contained in:
1 parent
db1a7c7f64
commit
006ef223bf
15 files changed
+996
-107
No files matched your search
@@ -48,6 +48,15 @@ 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');
|
||||
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);
|
||||
@@ -145,6 +154,7 @@ registerPage('api-keys', {
|
||||
} else {
|
||||
statusLine.textContent = '❌ ' + (result.message || 'Unbekannter Fehler');
|
||||
}
|
||||
await loadKeys();
|
||||
} catch (err) {
|
||||
statusLine.style.opacity = '1';
|
||||
statusLine.textContent = '❌ ' + err.message;
|
||||
@@ -247,7 +257,172 @@ registerPage('api-keys', {
|
||||
}));
|
||||
|
||||
await loadKeys();
|
||||
|
||||
// ── Scanner UI ──
|
||||
|
||||
var scanBtn = document.getElementById('scanTriggerBtn');
|
||||
var scanStatusEl = document.getElementById('scanStatus');
|
||||
var scannedList = document.getElementById('scannedKeysList');
|
||||
var scannedKeys = [];
|
||||
var pollTimer = null;
|
||||
|
||||
function renderScannedKeys() {
|
||||
scannedList.innerHTML = '';
|
||||
if (scannedKeys.length === 0) {
|
||||
var empty = document.createElement('div');
|
||||
empty.className = 'admin-user-meta';
|
||||
empty.style.padding = '1rem 0';
|
||||
empty.textContent = 'Keine gescannten Keys vorhanden.';
|
||||
scannedList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
scannedKeys.forEach(function(sk) {
|
||||
var isAdopted = sk.adoptedApiKeyId != null;
|
||||
|
||||
var row = document.createElement('div');
|
||||
row.className = 'admin-user-row';
|
||||
if (isAdopted) row.style.opacity = '0.5';
|
||||
|
||||
var info = document.createElement('div');
|
||||
info.className = 'admin-user-info';
|
||||
info.style.minWidth = '0';
|
||||
|
||||
if (sk.sourceUrl) {
|
||||
var nameSpan = document.createElement('a');
|
||||
nameSpan.className = 'admin-user-name';
|
||||
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);
|
||||
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 actions = document.createElement('div');
|
||||
actions.className = 'admin-user-actions';
|
||||
|
||||
if (!isAdopted) {
|
||||
// Recheck
|
||||
var recheckBtn = document.createElement('button');
|
||||
recheckBtn.className = 'icon-btn';
|
||||
recheckBtn.title = 'Erneut prüfen';
|
||||
recheckBtn.innerHTML = '<i class="fa fa-refresh"></i>';
|
||||
recheckBtn.addEventListener('click', async function() {
|
||||
recheckBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
|
||||
recheckBtn.disabled = true;
|
||||
try {
|
||||
await apiRecheckScannedKey(sk.scannedKeyId);
|
||||
await loadScannedKeys();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
finally { recheckBtn.innerHTML = '<i class="fa fa-refresh"></i>'; recheckBtn.disabled = false; }
|
||||
});
|
||||
actions.appendChild(recheckBtn);
|
||||
|
||||
// Übernehmen (nur bei valid)
|
||||
if (sk.status === 'valid') {
|
||||
var adoptBtn = document.createElement('button');
|
||||
adoptBtn.className = 'icon-btn';
|
||||
adoptBtn.title = 'Als API-Key übernehmen';
|
||||
adoptBtn.innerHTML = '<i class="fa fa-plus-circle" style="color:var(--accent)"></i>';
|
||||
adoptBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
await apiAdoptScannedKey(sk.scannedKeyId);
|
||||
showToast('Key übernommen');
|
||||
await loadScannedKeys();
|
||||
await loadKeys();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
});
|
||||
actions.appendChild(adoptBtn);
|
||||
}
|
||||
|
||||
// Löschen
|
||||
var delBtn = document.createElement('button');
|
||||
delBtn.className = 'icon-btn';
|
||||
delBtn.title = 'Ignorieren';
|
||||
delBtn.innerHTML = '<i class="fa fa-trash"></i>';
|
||||
delBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
await apiDeleteScannedKey(sk.scannedKeyId);
|
||||
await loadScannedKeys();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
});
|
||||
actions.appendChild(delBtn);
|
||||
}
|
||||
|
||||
row.appendChild(info);
|
||||
row.appendChild(actions);
|
||||
scannedList.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadScannedKeys() {
|
||||
try {
|
||||
scannedKeys = await apiGetScannedKeys();
|
||||
renderScannedKeys();
|
||||
} catch (err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function updateScanStatus() {
|
||||
try {
|
||||
var s = await apiScanStatus();
|
||||
if (s.running) {
|
||||
scanStatusEl.textContent = '⏳ Scan läuft…';
|
||||
scanBtn.disabled = true;
|
||||
scanBtn.innerHTML = '<i class="fa fa-spinner fa-spin"></i> Läuft…';
|
||||
// Polling starten wenn noch nicht aktiv
|
||||
if (!pollTimer) {
|
||||
pollTimer = setInterval(async function() {
|
||||
var st = await apiScanStatus();
|
||||
if (!st.running) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.innerHTML = '<i class="fa fa-search"></i> Jetzt scannen';
|
||||
scanStatusEl.textContent = st.lastResult ? '✅ ' + st.lastResult : '';
|
||||
await loadScannedKeys();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
} else {
|
||||
scanBtn.disabled = false;
|
||||
scanBtn.innerHTML = '<i class="fa fa-search"></i> Jetzt scannen';
|
||||
if (s.lastResult) {
|
||||
var timeStr = s.lastRun ? new Date(s.lastRun).toLocaleString() + ' – ' : '';
|
||||
scanStatusEl.textContent = timeStr + s.lastResult;
|
||||
}
|
||||
}
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
|
||||
scanBtn.addEventListener('click', async function() {
|
||||
try {
|
||||
await apiTriggerScan();
|
||||
showToast('Scan gestartet');
|
||||
await updateScanStatus();
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
await loadScannedKeys();
|
||||
await updateScanStatus();
|
||||
},
|
||||
|
||||
destroy() {}
|
||||
destroy() {
|
||||
// Polling stoppen wenn Seite verlassen wird
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user