tavily / manager /static /index.html
ohmyapi's picture
refactor: simplify manager to Tavily-only, harden registrars
0207319
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Search Key Manager</title>
<link rel="icon" type="image/png" href="/static/icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #08090e; --bg2: #0d0e15; --surface: #12131c; --surface2: #181924;
--border: #1f2133; --border2: #2d2f48;
--fg: #eef0ff; --fg2: #c8ccef; --fg3: #8a8ebf; --fg4: #5c60a0; --fg5: #3d4070;
--r: 8px; --rl: 12px; --r2: 16px;
--font: 'Outfit', -apple-system, sans-serif;
--mono: 'JetBrains Mono', 'SF Mono', monospace;
--tavily: #0ea5a0; --tavily-bg: rgba(14,165,160,.12); --tavily-dim: #0d8a86;
--firecrawl: #e8772e; --firecrawl-bg: rgba(232,119,46,.12); --firecrawl-dim: #c46320;
--exa: #4d8ef7; --exa-bg: rgba(77,142,247,.12); --exa-dim: #3a73d5;
--ok: #22c55e; --ok-bg: rgba(34,197,94,.1);
--err: #ef4444; --err-bg: rgba(239,68,68,.1);
--warn: #f59e0b; --warn-bg: rgba(245,158,11,.1);
--accent: var(--tavily); --accent-bg: var(--tavily-bg);
}
html { font-size: 15px; }
body { background: var(--bg); color: var(--fg); font-family: var(--font); line-height: 1.6; min-height: 100vh; -webkit-font-smoothing: antialiased; }
::selection { background: var(--accent); color: #fff; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 3px; }
.login-wrap {
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px;
background:
radial-gradient(ellipse 60% 40% at 30% -5%, rgba(14,165,160,.15) 0%, transparent 50%),
radial-gradient(ellipse 50% 35% at 70% 110%, rgba(232,119,46,.1) 0%, transparent 45%),
radial-gradient(ellipse 40% 30% at 90% 30%, rgba(77,142,247,.08) 0%, transparent 40%),
var(--bg);
}
.login-card { width: 100%; max-width: 380px; animation: rise .5s ease both; }
.login-hdr { text-align: center; margin-bottom: 36px; }
.login-hdr .logo { width: 72px; height: 72px; margin: 0 auto 20px; display: flex; align-items: center; justify-content: center; position: relative; }
.logo-bg { position: absolute; inset: 0; background: linear-gradient(135deg, #1a1b30, #111220); border-radius: 20px; border: 1px solid rgba(255,255,255,.08); box-shadow: 0 12px 40px rgba(0,0,0,.5), 0 0 0 1px rgba(14,165,160,.12); }
.logo-img { position: relative; z-index: 1; width: 44px; height: 44px; object-fit: contain; }
.login-hdr h1 { font-size: 1.5rem; font-weight: 800; letter-spacing: -.03em; }
.login-hdr p { font-size: .85rem; color: var(--fg4); margin-top: 6px; }
.login-err { margin-top: 14px; padding: 10px 14px; background: var(--err-bg); border: 1px solid rgba(239,68,68,.2); border-radius: var(--r); color: var(--err); font-size: .8rem; text-align: center; }
.inp { width: 100%; height: 44px; padding: 0 14px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); color: var(--fg); font-family: var(--font); font-size: .9rem; outline: none; transition: border-color .15s, box-shadow .15s; }
.inp::placeholder { color: var(--fg5); }
.inp:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-bg); }
.inp-mono { font-family: var(--mono); font-size: .8rem; }
textarea.inp { height: auto; padding: 12px 14px; resize: vertical; font-family: var(--mono); font-size: .8rem; line-height: 1.6; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 36px; padding: 0 16px; border: none; border-radius: var(--r); font-family: var(--font); font-size: .85rem; font-weight: 600; cursor: pointer; transition: all .15s; outline: none; white-space: nowrap; }
.btn:active { transform: scale(.97); }
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn-p { background: var(--accent); color: #fff; }
.btn-p:hover { filter: brightness(1.15); }
.btn-o { background: transparent; color: var(--fg3); border: 1px solid var(--border); }
.btn-o:hover { border-color: var(--fg4); color: var(--fg); }
.btn-d { background: var(--err-bg); color: var(--err); border: 1px solid rgba(239,68,68,.15); }
.btn-d:hover { background: rgba(239,68,68,.18); }
.btn-lg { height: 44px; padding: 0 24px; font-size: .95rem; }
.btn-icon { width: 32px; height: 32px; padding: 0; display: inline-flex; align-items: center; justify-content: center; border-radius: var(--r); background: transparent; color: var(--fg4); border: none; cursor: pointer; font-size: .9rem; transition: all .12s; }
.btn-icon:hover { color: var(--fg2); background: var(--surface2); }
.btn-icon.danger:hover { color: var(--err); background: var(--err-bg); }
.btn-icon.ok:hover { color: var(--ok); background: var(--ok-bg); }
.btn-ok { background: var(--ok-bg); color: var(--ok); border: 1px solid rgba(34,197,94,.15); }
.btn-ok:hover { background: rgba(34,197,94,.18); }
.nav { display: flex; align-items: center; justify-content: space-between; height: 56px; padding: 0 24px; background: rgba(12,13,20,.9); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 40; backdrop-filter: blur(16px); }
.nav-left { display: flex; align-items: center; gap: 20px; height: 100%; }
.nav-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: .95rem; }
.nav-brand .mark { width: 28px; height: 28px; background: linear-gradient(135deg, #1a1b30, #111220); border: 1px solid rgba(255,255,255,.08); border-radius: 7px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.nav-brand .mark img { width: 100%; height: 100%; object-fit: contain; }
.nav-tabs { display: flex; gap: 0; height: 100%; }
.nav-tab { display: flex; align-items: center; padding: 0 14px; font-size: .82rem; font-weight: 500; color: var(--fg4); cursor: pointer; border-bottom: 2px solid transparent; transition: all .15s; }
.nav-tab:hover { color: var(--fg2); }
.nav-tab.active { color: var(--fg); border-bottom-color: var(--accent); }
.nav-actions { display: flex; align-items: center; gap: 10px; }
.nav-meta { font-size: .72rem; color: var(--fg4); padding: 3px 10px; background: var(--surface); border-radius: 999px; border: 1px solid var(--border); font-family: var(--mono); }
/* Service switcher */
.svc-bar { display: flex; gap: 10px; margin-bottom: 24px; animation: rise .3s ease both; }
.svc-card {
flex: 1; padding: 16px 20px; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--rl); cursor: pointer; transition: all .2s; position: relative; overflow: hidden;
}
.svc-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; opacity: 0; transition: opacity .2s; }
.svc-card:hover { border-color: var(--border2); }
.svc-card.active { border-color: var(--accent); }
.svc-card.active::before { opacity: 1; }
.svc-card[data-svc="tavily"]::before { background: var(--tavily); }
.svc-card[data-svc="firecrawl"]::before { background: var(--firecrawl); }
.svc-card[data-svc="exa"]::before { background: var(--exa); }
.svc-card .svc-name { font-weight: 700; font-size: .95rem; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
.svc-card .svc-name .svc-dot { width: 8px; height: 8px; border-radius: 50%; }
.svc-card[data-svc="tavily"] .svc-dot { background: var(--tavily); }
.svc-card[data-svc="firecrawl"] .svc-dot { background: var(--firecrawl); }
.svc-card[data-svc="exa"] .svc-dot { background: var(--exa); }
.svc-card .svc-stat { font-size: .75rem; color: var(--fg4); font-family: var(--mono); }
.stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 20px; }
@media (max-width: 800px) { .stats { grid-template-columns: repeat(2, 1fr); } }
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--rl); padding: 14px 16px; transition: border-color .2s; }
.stat:hover { border-color: var(--border2); }
.stat-label { font-size: .7rem; color: var(--fg4); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; }
.stat-val { font-size: 1.8rem; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; }
.dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 5px; vertical-align: middle; position: relative; top: -1px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--rl); overflow: hidden; }
.tbl-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: separate; border-spacing: 0; }
thead th { padding: 0 14px; height: 38px; font-size: .68rem; font-weight: 600; color: var(--fg4); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; text-transform: uppercase; letter-spacing: .06em; }
tbody td { padding: 8px 14px; border-bottom: 1px solid rgba(31,33,51,.6); font-size: .82rem; color: var(--fg2); vertical-align: middle; }
tbody tr { transition: background .1s; }
tbody tr:hover { background: rgba(255,255,255,.015); }
tbody tr:last-child td { border-bottom: none; }
tbody tr.selected { background: var(--accent-bg); }
.c-id { color: var(--fg5); font-family: var(--mono); font-size: .72rem; }
.c-key { font-family: var(--mono); font-size: .72rem; color: var(--fg3); background: var(--bg2); padding: 3px 8px; border-radius: 5px; display: inline-block; border: 1px solid var(--border); }
.c-date { color: var(--fg5); font-size: .72rem; }
.c-acts { display: flex; gap: 2px; }
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 9px; border-radius: 999px; font-size: .68rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.badge::before { content: ''; width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.badge-active { background: var(--ok-bg); color: var(--ok); }
.badge-active::before { background: var(--ok); }
.badge-inactive { background: var(--err-bg); color: var(--err); }
.badge-inactive::before { background: var(--err); }
.badge-exhausted { background: var(--warn-bg); color: var(--warn); }
.badge-exhausted::before { background: var(--warn); }
.badge-svc { font-size: .65rem; padding: 2px 7px; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
.badge-svc-tavily { background: var(--tavily-bg); color: var(--tavily); }
.badge-svc-firecrawl { background: var(--firecrawl-bg); color: var(--firecrawl); }
.badge-svc-exa { background: var(--exa-bg); color: var(--exa); }
.chk { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
.batch-bar { display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: var(--surface2); border: 1px solid var(--accent); border-radius: var(--r); margin-bottom: 14px; animation: rise .2s ease both; box-shadow: 0 0 16px var(--accent-bg); }
.batch-bar .batch-count { font-size: .82rem; font-weight: 700; background: var(--accent); color: #fff; padding: 2px 10px; border-radius: 999px; }
.empty { text-align: center; padding: 52px 24px; }
.empty .e-icon { font-size: 2rem; margin-bottom: 12px; opacity: .2; }
.empty p { color: var(--fg4); font-size: .88rem; }
.empty p+p { color: var(--fg5); margin-top: 4px; font-size: .78rem; }
.modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.6); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 100; padding: 24px; opacity: 0; visibility: hidden; transition: opacity .2s, visibility .2s; }
.modal-bg.open { opacity: 1; visibility: visible; }
.modal { width: 100%; max-width: 440px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r2); padding: 24px; box-shadow: 0 20px 50px rgba(0,0,0,.5); transform: scale(.95); transition: transform .2s cubic-bezier(.16,1,.3,1); }
.modal-bg.open .modal { transform: scale(1); }
.modal-title { font-size: 1.05rem; font-weight: 700; margin-bottom: 18px; }
.modal-field { margin-bottom: 14px; }
.modal-field label { display: block; font-size: .78rem; font-weight: 600; color: var(--fg3); margin-bottom: 5px; }
.modal-field .hint { color: var(--fg5); font-weight: 400; }
.modal-acts { display: flex; gap: 10px; margin-top: 22px; justify-content: flex-end; }
.toast-c { position: fixed; top: 14px; right: 14px; z-index: 200; display: flex; flex-direction: column; gap: 7px; }
.toast { display: flex; align-items: center; gap: 9px; padding: 11px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); font-size: .82rem; font-weight: 500; color: var(--fg2); max-width: 380px; animation: toastIn .3s cubic-bezier(.16,1,.3,1) both; box-shadow: 0 6px 20px rgba(0,0,0,.3); }
.toast.out { animation: toastOut .2s ease both; }
.toast-icon { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 11px; flex-shrink: 0; }
.toast.success .toast-icon { background: var(--ok-bg); color: var(--ok); }
.toast.error .toast-icon { background: var(--err-bg); color: var(--err); }
.toast.info .toast-icon { background: var(--accent-bg); color: var(--accent); }
.filter-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; }
.filter-tags { display: flex; gap: 5px; flex-wrap: wrap; }
.filter-tag { display: inline-flex; align-items: center; gap: 5px; padding: 4px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 999px; font-family: var(--font); font-size: .78rem; font-weight: 600; color: var(--fg3); cursor: pointer; transition: all .15s; outline: none; }
.filter-tag:hover { border-color: var(--border2); color: var(--fg2); }
.filter-tag.active { background: var(--fg); color: var(--bg); border-color: var(--fg); }
.filter-tag-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.filter-tag-count { font-family: var(--mono); font-size: .72rem; opacity: .8; }
.agrp { display: flex; gap: 7px; flex-wrap: wrap; }
.proxy-info { background: var(--accent-bg); border: 1px solid rgba(14,165,160,.12); border-radius: var(--rl); padding: 14px 18px; margin-bottom: 18px; font-size: .82rem; }
.proxy-info strong { color: var(--accent); }
.proxy-info code { font-family: var(--mono); font-size: .72rem; background: var(--surface2); padding: 2px 6px; border-radius: 4px; border: 1px solid var(--border); color: var(--fg2); cursor: pointer; }
.quota-pill { font-family: var(--mono); font-size: .68rem; color: var(--fg4); background: var(--surface2); padding: 2px 7px; border-radius: 4px; border: 1px solid var(--border); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.cfg-section { margin-bottom: 32px; }
.cfg-section h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: 3px; }
.cfg-section .cfg-desc { font-size: .82rem; color: var(--fg4); margin-bottom: 20px; }
.cfg-row { margin-bottom: 20px; }
.cfg-row label { display: block; font-size: .82rem; font-weight: 600; margin-bottom: 3px; }
.cfg-row .cfg-hint { font-size: .72rem; color: var(--fg5); margin-bottom: 7px; }
.cfg-row .cfg-input-row { display: flex; gap: 10px; align-items: center; }
.cfg-row .cfg-input-row .inp { flex: 1; }
.toggle { position: relative; width: 44px; height: 24px; background: var(--border2); border-radius: 12px; cursor: pointer; transition: background .2s; border: none; }
.toggle.on { background: var(--accent); }
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transition: transform .2s; }
.toggle.on::after { transform: translateX(20px); }
.docs { max-width: 720px; }
.docs h2 { font-size: 1.2rem; font-weight: 700; margin: 32px 0 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.docs h2:first-child { margin-top: 0; }
.docs h3 { font-size: .95rem; font-weight: 600; margin: 18px 0 8px; color: var(--fg2); }
.docs p { margin-bottom: 12px; color: var(--fg3); font-size: .88rem; }
.docs code { background: var(--surface2); padding: 2px 6px; border-radius: 4px; font-family: var(--mono); font-size: .78rem; border: 1px solid var(--border); color: var(--accent); }
.docs pre { background: var(--bg); color: var(--fg2); padding: 16px 20px; border-radius: var(--rl); border: 1px solid var(--border); overflow-x: auto; margin-bottom: 16px; font-family: var(--mono); font-size: .78rem; line-height: 1.7; }
.docs pre code { background: none; border: none; padding: 0; color: inherit; }
.docs table { width: 100%; margin-bottom: 16px; }
.docs table th { background: var(--surface); }
.docs .info-box { background: var(--accent-bg); border: 1px solid rgba(14,165,160,.15); border-radius: var(--rl); padding: 14px 18px; margin-bottom: 16px; font-size: .82rem; color: var(--fg2); }
.docs .info-box strong { color: var(--accent); }
.main { max-width: 1160px; margin: 0 auto; padding: 24px 24px 48px; }
.hidden { display: none !important; }
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes toastIn { from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: translateX(0); } }
@keyframes toastOut { from { opacity: 1; } to { opacity: 0; transform: translateX(16px); } }
@media (max-width: 768px) {
.nav { padding: 0 12px; }
.main { padding: 14px 12px 32px; }
.stats { grid-template-columns: repeat(2, 1fr); }
.svc-bar { flex-direction: column; }
.agrp { width: 100%; }
.agrp .btn { flex: 1; font-size: .72rem; padding: 0 8px; }
.nav-meta { display: none; }
.nav-tab { padding: 0 8px; font-size: .75rem; }
}
</style>
</head>
<body>
<div class="toast-c" id="toasts"></div>
<div id="login-screen">
<div class="login-wrap">
<div class="login-card">
<div class="login-hdr">
<div class="logo"><div class="logo-bg"></div><img src="/static/icon.png" alt="" class="logo-img"></div>
<h1>Search Key Manager</h1>
<p>Tavily &middot; Firecrawl &middot; Exa &mdash; unified key pool &amp; proxy</p>
</div>
<div style="margin-bottom:16px;"><input id="pw-in" type="password" class="inp" placeholder="Admin password" onkeydown="if(event.key==='Enter')login()" autofocus></div>
<button onclick="login()" class="btn btn-p btn-lg" style="width:100%;">Sign in</button>
<div id="login-err" class="login-err hidden">Invalid credentials</div>
</div>
</div>
</div>
<div id="app" class="hidden">
<nav class="nav">
<div class="nav-left">
<div class="nav-brand"><div class="mark"><img src="/static/icon.png" alt=""></div>Keys</div>
<div class="nav-tabs">
<div class="nav-tab active" data-tab="keys">Keys</div>
<div class="nav-tab" data-tab="tokens">Tokens</div>
<div class="nav-tab" data-tab="config">Config</div>
<div class="nav-tab" data-tab="docs">Docs</div>
</div>
</div>
<div class="nav-actions">
<span id="nav-count" class="nav-meta"></span>
<button onclick="refresh()" class="btn-icon" title="Refresh" style="font-size:1rem;">&#8635;</button>
<button onclick="logout()" class="btn btn-o" style="height:28px;font-size:.72rem;padding:0 10px;">Sign out</button>
</div>
</nav>
<div class="main">
<div id="tab-keys" class="tab-content active">
<div class="svc-bar">
<div class="svc-card active" data-svc="tavily" onclick="switchService('tavily',this)">
<div class="svc-name"><span class="svc-dot"></span>Tavily</div>
<div class="svc-stat" id="svc-stat-tavily">-</div>
</div>
<div class="svc-card" data-svc="firecrawl" onclick="switchService('firecrawl',this)">
<div class="svc-name"><span class="svc-dot"></span>Firecrawl</div>
<div class="svc-stat" id="svc-stat-firecrawl">-</div>
</div>
<div class="svc-card" data-svc="exa" onclick="switchService('exa',this)">
<div class="svc-name"><span class="svc-dot"></span>Exa</div>
<div class="svc-stat" id="svc-stat-exa">-</div>
</div>
</div>
<div class="stats" style="animation:rise .35s ease .05s both;">
<div class="stat"><div class="stat-label">Total</div><div class="stat-val" id="s-total">-</div></div>
<div class="stat"><div class="stat-label"><span class="dot" style="background:var(--ok);"></span>Active</div><div class="stat-val" id="s-active">-</div></div>
<div class="stat"><div class="stat-label"><span class="dot" style="background:var(--err);"></span>Inactive</div><div class="stat-val" id="s-inactive">-</div></div>
<div class="stat"><div class="stat-label"><span class="dot" style="background:var(--warn);"></span>Exhausted</div><div class="stat-val" id="s-exhausted">-</div></div>
<div class="stat"><div class="stat-label">Quota</div><div class="stat-val" id="s-quota" style="font-size:1.2rem;">-</div></div>
</div>
<div class="proxy-info" id="proxy-info" style="animation:rise .35s ease .1s both;"></div>
<div id="batch-bar" class="batch-bar hidden">
<span style="color:var(--fg3)">Selected</span>
<span class="batch-count" id="batch-count">0</span>
<span style="color:var(--fg4);font-size:.82rem;">items</span>
<div style="flex:1;"></div>
<button onclick="batchCheck()" class="btn btn-o" style="height:28px;font-size:.72rem;">Check</button>
<button onclick="batchDisable()" class="btn btn-o" style="height:28px;font-size:.72rem;">Disable</button>
<button onclick="batchEnable()" class="btn btn-o" style="height:28px;font-size:.72rem;">Enable</button>
<button onclick="batchDelete()" class="btn btn-d" style="height:28px;font-size:.72rem;">Delete</button>
</div>
<div class="filter-bar" style="animation:rise .35s ease .12s both;">
<div class="filter-tags">
<button class="filter-tag active" data-filter="all" onclick="filterKeys('all',this)">All<span class="filter-tag-count" id="ft-all">0</span></button>
<button class="filter-tag" data-filter="active" onclick="filterKeys('active',this)"><span class="filter-tag-dot" style="background:var(--ok);"></span>Active<span class="filter-tag-count" id="ft-active">0</span></button>
<button class="filter-tag" data-filter="inactive" onclick="filterKeys('inactive',this)"><span class="filter-tag-dot" style="background:var(--err);"></span>Inactive<span class="filter-tag-count" id="ft-inactive">0</span></button>
<button class="filter-tag" data-filter="exhausted" onclick="filterKeys('exhausted',this)"><span class="filter-tag-dot" style="background:var(--warn);"></span>Exhausted<span class="filter-tag-count" id="ft-exhausted">0</span></button>
</div>
<div class="agrp">
<button onclick="showModal('add')" class="btn btn-p">Add Key</button>
<button onclick="showModal('import')" class="btn btn-o">Import</button>
<button onclick="exportKeys()" class="btn btn-o">Export</button>
<button onclick="healthcheckAll()" id="btn-hc" class="btn btn-ok">Test All</button>
<button onclick="deleteInactive()" class="btn btn-d">Purge</button>
</div>
</div>
<div class="card" style="animation:rise .35s ease .15s both;">
<div class="tbl-wrap">
<table>
<thead><tr>
<th style="width:34px;"><input type="checkbox" class="chk" id="chk-all" onchange="toggleAll(this)"></th>
<th style="width:40px;">ID</th>
<th>Email</th>
<th>API Key</th>
<th>Service</th>
<th>Status</th>
<th>Quota</th>
<th>Created</th>
<th>Checked</th>
<th>Uses</th>
<th style="width:80px;text-align:right;">Actions</th>
</tr></thead>
<tbody id="keys-tbl"></tbody>
</table>
</div>
<div id="empty-keys" class="empty hidden">
<div class="e-icon">&#9711;</div>
<p>No API keys in the pool</p>
<p>Add keys manually or run the registration workflow</p>
</div>
</div>
</div>
<div id="tab-tokens" class="tab-content">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<div style="font-size:1.05rem;font-weight:700;">Access Tokens</div>
<button onclick="showModal('add-token')" class="btn btn-p">Add Token</button>
</div>
<div class="card">
<div class="tbl-wrap">
<table>
<thead><tr>
<th style="width:40px;">ID</th><th>Token</th><th>Name</th><th>Type</th><th>Quota</th><th>Used</th><th>Status</th><th>Expires</th><th>Last Used</th><th style="width:80px;text-align:right;">Actions</th>
</tr></thead>
<tbody id="tokens-tbl"></tbody>
</table>
</div>
<div id="empty-tokens" class="empty hidden"><div class="e-icon">&#9711;</div><p>No access tokens</p><p>Create tokens for users to access the proxy</p></div>
</div>
</div>
<div id="tab-config" class="tab-content">
<div class="cfg-section">
<h3>Configuration</h3>
<p class="cfg-desc">Authentication, proxy behavior, and system settings.</p>
<div class="cfg-row"><label>Admin Token</label><div class="cfg-hint">Default admin access token.</div><div class="cfg-input-row"><input id="cfg-admin-token" type="text" class="inp inp-mono" readonly><button onclick="copyVal('cfg-admin-token')" class="btn-icon" title="Copy">&#128203;</button></div></div>
<div class="cfg-row"><label>Admin Password</label><div class="cfg-hint">Dashboard login password.</div><div class="cfg-input-row"><input id="cfg-admin-pw" type="text" class="inp"><button onclick="saveConfig('admin_password','cfg-admin-pw')" class="btn btn-p" style="height:44px;">Save</button></div></div>
<div class="cfg-row"><label>Free Mode</label><div class="cfg-hint">Accept requests without a valid token.</div><div style="display:flex;align-items:center;gap:12px;margin-top:8px;"><button id="cfg-free-mode" class="toggle" onclick="toggleFreeMode()"></button><span id="cfg-free-label" style="font-size:.82rem;color:var(--fg4);">Off</span></div></div>
<div class="cfg-row"><label>Default Quota</label><div class="cfg-hint">Monthly request limit for new tokens.</div><div class="cfg-input-row"><input id="cfg-default-quota" type="number" class="inp" style="max-width:180px;"><button onclick="saveConfig('default_quota','cfg-default-quota')" class="btn btn-p" style="height:44px;">Save</button></div></div>
</div>
</div>
<div id="tab-docs" class="tab-content">
<div class="docs">
<h2>Multi-Service Search Proxy</h2>
<p>Unified proxy for <code>Tavily</code>, <code>Firecrawl</code>, and <code>Exa</code> APIs. Manages key pools with round-robin selection, automatic key rotation on 401/429, and per-user quota management.</p>
<div class="info-box"><strong>Base URL:</strong> <code id="doc-base-url"></code></div>
<h2>Authentication</h2>
<p>All proxy endpoints accept: <code>Authorization: Bearer sk-your-token</code> header.</p>
<p>Tavily endpoints also accept <code>api_key</code> in the request body for SDK compatibility. Firecrawl and Exa use the standard Bearer token &mdash; the proxy replaces it with a pool key server-side, so downstream callers only need the manager token.</p>
<h2>Tavily Endpoints</h2>
<table><thead><tr><th>Method</th><th>Path</th><th>Description</th><th>Timeout</th></tr></thead><tbody>
<tr><td><code>POST</code></td><td><code>/search</code></td><td>Web search</td><td>30s</td></tr>
<tr><td><code>POST</code></td><td><code>/extract</code></td><td>Content extraction</td><td>60s</td></tr>
<tr><td><code>POST</code></td><td><code>/crawl</code></td><td>Website crawling</td><td>180s</td></tr>
<tr><td><code>POST</code></td><td><code>/map</code></td><td>Site mapping</td><td>180s</td></tr>
<tr><td><code>POST</code></td><td><code>/research</code></td><td>Research reports</td><td>300s</td></tr>
<tr><td><code>GET</code></td><td><code>/research/{id}</code></td><td>Research status</td><td>30s</td></tr>
<tr><td><code>GET</code></td><td><code>/usage</code></td><td>Quota info</td><td>10s</td></tr>
</tbody></table>
<p>All paths work with or without <code>/v1</code> prefix.</p>
<h2>Firecrawl Endpoints</h2>
<p>All Firecrawl endpoints accept both <code>/v1/</code> and <code>/v2/</code> prefixes. Internally, requests are always forwarded to the upstream v2 API.</p>
<h3>Scrape &amp; Search</h3>
<table><thead><tr><th>Method</th><th>Path</th><th>Description</th><th>Timeout</th></tr></thead><tbody>
<tr><td><code>POST</code></td><td><code>/firecrawl/v1/scrape</code></td><td>Scrape a single URL</td><td>60s</td></tr>
<tr><td><code>POST</code></td><td><code>/firecrawl/v1/search</code></td><td>Web search + scrape</td><td>30s</td></tr>
</tbody></table>
<h3>Crawl</h3>
<table><thead><tr><th>Method</th><th>Path</th><th>Description</th><th>Timeout</th></tr></thead><tbody>
<tr><td><code>POST</code></td><td><code>/firecrawl/v1/crawl</code></td><td>Start crawl job</td><td>180s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/crawl/{id}</code></td><td>Crawl status</td><td>60s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v2/crawl/{id}/status</code></td><td>Crawl status (alias)</td><td>60s</td></tr>
<tr><td><code>DELETE</code></td><td><code>/firecrawl/v1/crawl/{id}</code></td><td>Cancel crawl</td><td>60s</td></tr>
<tr><td><code>DELETE</code></td><td><code>/firecrawl/v2/crawl/{id}/cancel</code></td><td>Cancel crawl (alias)</td><td>60s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/crawl/{id}/errors</code></td><td>Crawl errors</td><td>60s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/crawl/active</code></td><td>Active crawls</td><td>60s</td></tr>
<tr><td><code>POST</code></td><td><code>/firecrawl/v1/crawl/params-preview</code></td><td>Preview crawl params</td><td>60s</td></tr>
</tbody></table>
<h3>Map &amp; Extract</h3>
<table><thead><tr><th>Method</th><th>Path</th><th>Description</th><th>Timeout</th></tr></thead><tbody>
<tr><td><code>POST</code></td><td><code>/firecrawl/v1/map</code></td><td>List site URLs</td><td>180s</td></tr>
<tr><td><code>POST</code></td><td><code>/firecrawl/v1/extract</code></td><td>Structured extraction</td><td>120s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/extract/{id}</code></td><td>Extract status</td><td>120s</td></tr>
</tbody></table>
<h3>Batch Scrape</h3>
<table><thead><tr><th>Method</th><th>Path</th><th>Description</th><th>Timeout</th></tr></thead><tbody>
<tr><td><code>POST</code></td><td><code>/firecrawl/v1/batch-scrape</code></td><td>Start batch scrape</td><td>60s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/batch-scrape/{id}</code></td><td>Batch status</td><td>60s</td></tr>
<tr><td><code>DELETE</code></td><td><code>/firecrawl/v1/batch-scrape/{id}</code></td><td>Cancel batch</td><td>60s</td></tr>
<tr><td><code>DELETE</code></td><td><code>/firecrawl/v2/batch-scrape/{id}/cancel</code></td><td>Cancel batch (alias)</td><td>60s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/batch-scrape/{id}/errors</code></td><td>Batch errors</td><td>60s</td></tr>
</tbody></table>
<h3>Agent &amp; Browser</h3>
<table><thead><tr><th>Method</th><th>Path</th><th>Description</th><th>Timeout</th></tr></thead><tbody>
<tr><td><code>POST</code></td><td><code>/firecrawl/v1/agent</code></td><td>Create agent job</td><td>120s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/agent/{id}</code></td><td>Agent job status</td><td>120s</td></tr>
<tr><td><code>DELETE</code></td><td><code>/firecrawl/v1/agent/{id}</code></td><td>Cancel agent job</td><td>120s</td></tr>
<tr><td><code>POST</code></td><td><code>/firecrawl/v1/browser</code></td><td>Create browser session</td><td>120s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/browser</code></td><td>List sessions</td><td>120s</td></tr>
<tr><td><code>POST</code></td><td><code>/firecrawl/v1/browser/{id}/execute</code></td><td>Execute in session</td><td>120s</td></tr>
<tr><td><code>DELETE</code></td><td><code>/firecrawl/v1/browser/{id}</code></td><td>Close session</td><td>120s</td></tr>
</tbody></table>
<h3>Team &amp; Usage</h3>
<table><thead><tr><th>Method</th><th>Path</th><th>Description</th><th>Timeout</th></tr></thead><tbody>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/credit-usage</code></td><td>Credit usage (legacy alias)</td><td>10s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/team/credit-usage</code></td><td>Credit usage</td><td>10s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/team/token-usage</code></td><td>Token usage</td><td>10s</td></tr>
<tr><td><code>GET</code></td><td><code>/firecrawl/v1/team/queue-status</code></td><td>Queue status</td><td>10s</td></tr>
</tbody></table>
<h2>Exa Endpoints</h2>
<p>The proxy auto-injects <code>"type": "auto"</code> for <code>/exa/search</code> if not specified in the body.</p>
<table><thead><tr><th>Method</th><th>Path</th><th>Description</th><th>Timeout</th></tr></thead><tbody>
<tr><td><code>POST</code></td><td><code>/exa/search</code></td><td>Neural / keyword / auto search</td><td>60s</td></tr>
<tr><td><code>POST</code></td><td><code>/exa/contents</code></td><td>Get page contents by ID</td><td>30s</td></tr>
<tr><td><code>POST</code></td><td><code>/exa/answer</code></td><td>LLM answer with citations</td><td>60s</td></tr>
<tr><td><code>POST</code></td><td><code>/exa/findSimilar</code></td><td>Find similar links to a URL</td><td>30s</td></tr>
</tbody></table>
<h2>Examples</h2>
<h3>Tavily Search</h3>
<pre><code>curl -X POST BASE_URL/search \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-your-token" \
-d '{"query": "latest AI news", "max_results": 5}'</code></pre>
<h3>Firecrawl Scrape</h3>
<pre><code>curl -X POST BASE_URL/firecrawl/v1/scrape \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-your-token" \
-d '{"url": "https://example.com"}'</code></pre>
<h3>Firecrawl Crawl + Status</h3>
<pre><code># Start crawl
curl -X POST BASE_URL/firecrawl/v1/crawl \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-your-token" \
-d '{"url": "https://example.com", "limit": 10}'
# Check status
curl BASE_URL/firecrawl/v2/crawl/{id}/status \
-H "Authorization: Bearer sk-your-token"
# Cancel
curl -X DELETE BASE_URL/firecrawl/v2/crawl/{id}/cancel \
-H "Authorization: Bearer sk-your-token"</code></pre>
<h3>Exa Search</h3>
<pre><code>curl -X POST BASE_URL/exa/search \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-your-token" \
-d '{"query": "machine learning frameworks", "numResults": 5}'</code></pre>
<p><em>Note: <code>"type": "auto"</code> is injected automatically if not provided.</em></p>
<h2>Import Guide</h2>
<p>The import dialog accepts multiple formats. Service is auto-detected from key prefixes:</p>
<table><thead><tr><th>Prefix</th><th>Service</th></tr></thead><tbody>
<tr><td><code>tvly-*</code></td><td>Tavily</td></tr>
<tr><td><code>fc-*</code></td><td>Firecrawl</td></tr>
<tr><td>UUID format</td><td>Exa</td></tr>
</tbody></table>
<h3>Registrar CSV</h3>
<pre><code>firecrawl@example.com,password,fc-abc123456789
exa@example.com,EMAIL_OTP_ONLY,550e8400-e29b-41d4-a716-446655440000</code></pre>
<h3>Key-only (one per line)</h3>
<pre><code>fc-abc123456789012345678
550e8400-e29b-41d4-a716-446655440000
tvly-abcdef1234567890</code></pre>
<p>Key-only imports auto-generate placeholder emails. Duplicates are silently skipped (ON CONFLICT DO NOTHING).</p>
</div>
</div>
</div>
</div>
<!-- Modals -->
<div id="modal-add" class="modal-bg" onclick="if(event.target===this)closeModals()">
<div class="modal">
<div class="modal-title">Add API Key</div>
<div class="modal-field"><label>Service</label><select id="add-svc" class="inp" style="height:40px;"><option value="tavily">Tavily</option><option value="firecrawl">Firecrawl</option><option value="exa">Exa</option></select></div>
<div class="modal-field"><label>Email</label><input id="add-email" type="text" class="inp" placeholder="user@example.com"></div>
<div class="modal-field"><label>Password <span class="hint">(optional)</span></label><input id="add-pw" type="text" class="inp" placeholder="Account password"></div>
<div class="modal-field"><label>API Key</label><input id="add-key" type="text" class="inp inp-mono" placeholder="tvly-... / fc-... / uuid"></div>
<div class="modal-acts"><button onclick="closeModals()" class="btn btn-o">Cancel</button><button onclick="addKey()" class="btn btn-p">Add</button></div>
</div>
</div>
<div id="modal-import" class="modal-bg" onclick="if(event.target===this)closeModals()">
<div class="modal" style="max-width:560px;">
<div class="modal-title">Batch Import</div>
<div class="modal-field"><label>Default Service <span class="hint">(fallback when auto-detection is ambiguous)</span></label><select id="import-svc" class="inp" style="height:40px;"><option value="">Auto detect</option><option value="tavily">Tavily</option><option value="firecrawl">Firecrawl</option><option value="exa">Exa</option></select></div>
<div style="margin-bottom:14px;padding:12px 14px;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);font-size:.72rem;color:var(--fg4);line-height:1.7;">
<div style="color:var(--fg3);font-weight:600;margin-bottom:4px;">Supported formats</div>
<div><code style="font-size:.68rem;background:var(--surface2);padding:1px 5px;border-radius:3px;border:1px solid var(--border);color:var(--accent);">JSON</code> &mdash; <span style="font-family:var(--mono);">[{"email":"...", "api_key":"...", "service":"firecrawl"}]</span></div>
<div><code style="font-size:.68rem;background:var(--surface2);padding:1px 5px;border-radius:3px;border:1px solid var(--border);color:var(--accent);">CSV</code> &mdash; <span style="font-family:var(--mono);">email,password,api_key</span> (header optional)</div>
<div><code style="font-size:.68rem;background:var(--surface2);padding:1px 5px;border-radius:3px;border:1px solid var(--border);color:var(--accent);">Registrar</code> &mdash; <span style="font-family:var(--mono);">email,password,key</span> (one per line)</div>
<div><code style="font-size:.68rem;background:var(--surface2);padding:1px 5px;border-radius:3px;border:1px solid var(--border);color:var(--accent);">Key-only</code> &mdash; one API key per line (<span style="font-family:var(--mono);">tvly-*</span>, <span style="font-family:var(--mono);">fc-*</span>, UUID)</div>
<div style="margin-top:4px;color:var(--fg5);">Auto-detect: <span style="font-family:var(--mono);">fc-*</span> &rarr; Firecrawl, UUID &rarr; Exa, <span style="font-family:var(--mono);">tvly-*</span> / other &rarr; Tavily</div>
</div>
<div class="modal-field">
<label>Import Data</label>
<textarea id="import-json" class="inp" rows="8" placeholder='Paste JSON, CSV, registrar output, or one key per line...
fc-abc123456789012345678
550e8400-e29b-41d4-a716-446655440000
user@example.com,pass,tvly-abc123'></textarea>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px;"><label class="btn btn-o" style="cursor:pointer;">Upload JSON/CSV<input type="file" accept=".json,.csv,.txt" style="display:none;" onchange="loadFile(this)"></label><button onclick="fillImportExample()" class="btn btn-o" type="button">Example</button></div>
<div id="import-result" class="hidden" style="margin-bottom:12px;padding:10px 14px;border-radius:var(--r);font-size:.82rem;font-weight:500;"></div>
<div class="modal-acts"><button onclick="closeModals()" class="btn btn-o">Cancel</button><button onclick="importKeys()" class="btn btn-p">Import</button></div>
</div>
</div>
<div id="modal-add-token" class="modal-bg" onclick="if(event.target===this)closeModals()">
<div class="modal">
<div class="modal-title">Create Access Token</div>
<div class="modal-field"><label>Name <span class="hint">(optional)</span></label><input id="tk-name" type="text" class="inp" placeholder="e.g. User A"></div>
<div class="modal-field"><label>Token <span class="hint">(blank = auto)</span></label><input id="tk-token" type="text" class="inp inp-mono" placeholder="sk-..."></div>
<div class="modal-field"><label>Monthly Quota</label><input id="tk-quota" type="number" class="inp" value="1000" style="max-width:180px;"></div>
<div class="modal-field"><label>Expires <span class="hint">(optional)</span></label><input id="tk-expires" type="date" class="inp" style="max-width:200px;"></div>
<div class="modal-acts"><button onclick="closeModals()" class="btn btn-o">Cancel</button><button onclick="createToken()" class="btn btn-p">Create</button></div>
</div>
</div>
<script>
(() => {
let TOKEN = '';
const API = '';
let selectedIds = new Set();
let allKeys = [];
let currentFilter = 'all';
let currentService = '';
const $ = id => document.getElementById(id);
const hdr = () => ({ 'Authorization': `Bearer ${TOKEN}`, 'Content-Type': 'application/json' });
function toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = `toast ${type}`;
const icons = { success: '\u2713', error: '\u2717', info: 'i' };
el.innerHTML = `<span class="toast-icon">${icons[type]||icons.info}</span><span>${msg}</span>`;
$('toasts').appendChild(el);
setTimeout(() => { el.classList.add('out'); setTimeout(() => el.remove(), 200); }, 3500);
}
const svcColors = { tavily: '#0ea5a0', firecrawl: '#e8772e', exa: '#4d8ef7' };
const svcProxyInfo = {
tavily: { paths: '/search, /extract, /crawl, /map, /research, /usage', note: 'Drop-in for api.tavily.com. Supports api_key in body or Authorization header.' },
firecrawl: { paths: '/firecrawl/v1/* aliases + /firecrawl/v2/team/*, /agent, /browser', note: 'Targets Firecrawl v2 upstream while preserving v1-style aliases for existing clients.' },
exa: { paths: '/exa/search, /contents, /answer, /findSimilar', note: 'Compatible with official Exa HTTP endpoints. Use Authorization: Bearer <proxy token>; upstream key stays server-side.' },
};
function updateProxyInfo() {
const svc = currentService || 'tavily';
const info = svcProxyInfo[svc];
const base = window.location.origin;
$('proxy-info').innerHTML = `<strong>${svc.charAt(0).toUpperCase()+svc.slice(1)} Proxy</strong> &mdash; <span style="color:var(--fg3)">${info.note}</span><br><span style="color:var(--fg4);font-size:.75rem;">Endpoints: <code onclick="navigator.clipboard.writeText('${base}');toast('Copied','success')">${base}</code>${info.paths}</span>`;
$('proxy-info').style.borderColor = (svcColors[svc] || svcColors.tavily) + '22';
$('proxy-info').style.background = (svcColors[svc] || svcColors.tavily) + '11';
}
window.switchService = function(svc, el) {
currentService = svc === currentService ? '' : svc;
document.querySelectorAll('.svc-card').forEach(c => c.classList.toggle('active', currentService ? c.dataset.svc === currentService : false));
if (!currentService) document.querySelectorAll('.svc-card').forEach(c => c.classList.remove('active'));
const color = currentService ? svcColors[currentService] : svcColors.tavily;
document.documentElement.style.setProperty('--accent', color);
document.documentElement.style.setProperty('--accent-bg', color + '1a');
selectedIds.clear(); updateBatchBar();
updateProxyInfo();
loadKeys(); loadStats();
};
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
$('tab-' + tab.dataset.tab).classList.add('active');
if (tab.dataset.tab === 'config') loadConfig();
if (tab.dataset.tab === 'tokens') loadTokens();
});
});
window.login = async function() {
TOKEN = $('pw-in').value.trim();
if (!TOKEN) return;
try {
const r = await fetch(`${API}/api/stats`, { headers: hdr() });
if (!r.ok) throw 0;
$('login-screen').classList.add('hidden');
$('app').classList.remove('hidden');
localStorage.setItem('tkm_token', TOKEN);
refresh();
} catch {
$('login-err').classList.remove('hidden');
setTimeout(() => $('login-err').classList.add('hidden'), 3000);
}
};
window.logout = function() {
TOKEN = '';
localStorage.removeItem('tkm_token');
$('app').classList.add('hidden');
$('login-screen').classList.remove('hidden');
$('pw-in').value = '';
};
window.refresh = async function() {
selectedIds.clear(); updateBatchBar();
await Promise.all([loadStats(), loadKeys()]);
};
async function loadStats() {
try {
const r = await fetch(`${API}/api/stats`, { headers: hdr() });
const d = await r.json();
const bs = d.by_service || {};
for (const svc of ['tavily','firecrawl','exa']) {
const s = bs[svc];
const el = $(`svc-stat-${svc}`);
if (el) el.textContent = s ? `${s.active_keys}/${s.total_keys} active` : 'no keys';
}
const src = currentService ? (bs[currentService] || {total_keys:0,active_keys:0,inactive_keys:0,exhausted_keys:0,total_quota_remaining:null}) : d;
$('s-total').textContent = src.total_keys;
$('s-active').textContent = src.active_keys;
$('s-inactive').textContent = src.inactive_keys;
$('s-exhausted').textContent = src.exhausted_keys;
$('s-quota').textContent = src.total_quota_remaining != null ? src.total_quota_remaining.toLocaleString() : '\u2014';
$('nav-count').textContent = `${d.active_keys}/${d.total_keys}`;
updateProxyInfo();
} catch { toast('Failed to load stats', 'error'); }
}
async function loadKeys() {
try {
let url = `${API}/api/keys`;
const params = [];
if (currentService) params.push(`service=${currentService}`);
if (params.length) url += '?' + params.join('&');
const r = await fetch(url, { headers: hdr() });
const d = await r.json();
allKeys = d.keys || [];
updateFilterCounts();
renderKeys();
} catch { toast('Failed to load keys', 'error'); }
}
function updateFilterCounts() {
$('ft-all').textContent = allKeys.length;
$('ft-active').textContent = allKeys.filter(k => k.status === 'active').length;
$('ft-inactive').textContent = allKeys.filter(k => k.status === 'inactive').length;
$('ft-exhausted').textContent = allKeys.filter(k => k.status === 'exhausted').length;
}
window.filterKeys = function(filter, el) {
currentFilter = filter;
document.querySelectorAll('.filter-tag').forEach(t => t.classList.remove('active'));
el.classList.add('active');
selectedIds.clear(); updateBatchBar();
renderKeys();
};
function renderKeys() {
const filtered = currentFilter === 'all' ? allKeys : allKeys.filter(k => k.status === currentFilter);
const tbody = $('keys-tbl');
if (!filtered.length) { tbody.innerHTML = ''; $('empty-keys').classList.remove('hidden'); return; }
$('empty-keys').classList.add('hidden');
tbody.innerHTML = filtered.map(k => {
const ks = k.api_key.length > 18 ? k.api_key.substring(0,8)+'\u2026'+k.api_key.slice(-6) : k.api_key;
const cr = k.created_at ? new Date(k.created_at).toLocaleDateString('en-CA') : '\u2014';
const ch = k.last_checked ? new Date(k.last_checked).toLocaleDateString('en-CA') : '\u2014';
const sel = selectedIds.has(k.id) ? 'checked' : '';
const qr = k.quota_remaining != null ? k.quota_remaining : '\u2014';
const svc = k.service || 'tavily';
return `<tr class="${selectedIds.has(k.id)?'selected':''}">
<td><input type="checkbox" class="chk" data-id="${k.id}" ${sel} onchange="toggleSel(${k.id},this.checked)"></td>
<td class="c-id">${k.id}</td>
<td style="color:var(--fg3);font-size:.82rem;">${esc(k.email)}</td>
<td><span class="c-key">${esc(ks)}</span></td>
<td><span class="badge-svc badge-svc-${svc}">${svc}</span></td>
<td><span class="badge badge-${k.status}">${k.status}</span></td>
<td><span class="quota-pill">${qr}</span></td>
<td class="c-date">${cr}</td>
<td class="c-date">${ch}</td>
<td class="c-id">${k.use_count||0}</td>
<td><div class="c-acts" style="justify-content:flex-end;">
<button class="btn-icon" onclick="copyKey('${escA(k.api_key)}')" title="Copy">&#128203;</button>
<button class="btn-icon ok" onclick="checkKey(${k.id})" title="Test">&#9889;</button>
<button class="btn-icon danger" onclick="deleteKey(${k.id})" title="Delete">&#128465;</button>
</div></td></tr>`;
}).join('');
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function escA(s) { return s.replace(/'/g, "\\'").replace(/"/g, '&quot;'); }
window.toggleSel = function(id, checked) {
if (checked) selectedIds.add(id); else selectedIds.delete(id);
updateBatchBar();
const row = document.querySelector(`input[data-id="${id}"]`)?.closest('tr');
if (row) row.classList.toggle('selected', checked);
};
window.toggleAll = function(el) {
document.querySelectorAll('#keys-tbl .chk').forEach(c => {
c.checked = el.checked;
const id = parseInt(c.dataset.id);
if (el.checked) selectedIds.add(id); else selectedIds.delete(id);
c.closest('tr').classList.toggle('selected', el.checked);
});
updateBatchBar();
};
function updateBatchBar() {
const bar = $('batch-bar');
if (selectedIds.size > 0) { bar.classList.remove('hidden'); $('batch-count').textContent = selectedIds.size; }
else { bar.classList.add('hidden'); }
}
window.batchDelete = async function() {
if (!selectedIds.size || !confirm(`Delete ${selectedIds.size} selected keys?`)) return;
try {
await fetch(`${API}/api/keys/batch-delete`, { method: 'POST', headers: hdr(), body: JSON.stringify({ ids: [...selectedIds] }) });
toast(`Deleted ${selectedIds.size} keys`, 'success'); selectedIds.clear(); refresh();
} catch { toast('Batch delete failed', 'error'); }
};
window.batchCheck = async function() {
if (!selectedIds.size) return;
toast('Checking...', 'info');
try {
const r = await fetch(`${API}/api/keys/batch-check`, { method: 'POST', headers: hdr(), body: JSON.stringify({ ids: [...selectedIds] }) });
const d = await r.json();
toast(`Checked ${d.checked} \u2014 ${d.active} active`, 'success'); refresh();
} catch { toast('Check failed', 'error'); }
};
window.batchDisable = async function() {
if (!selectedIds.size) return;
try {
await fetch(`${API}/api/keys/batch-status`, { method: 'POST', headers: hdr(), body: JSON.stringify({ ids: [...selectedIds], status: 'inactive' }) });
toast(`Disabled ${selectedIds.size}`, 'success'); selectedIds.clear(); refresh();
} catch { toast('Failed', 'error'); }
};
window.batchEnable = async function() {
if (!selectedIds.size) return;
try {
await fetch(`${API}/api/keys/batch-status`, { method: 'POST', headers: hdr(), body: JSON.stringify({ ids: [...selectedIds], status: 'active' }) });
toast(`Enabled ${selectedIds.size}`, 'success'); selectedIds.clear(); refresh();
} catch { toast('Failed', 'error'); }
};
window.showModal = function(name) {
document.querySelectorAll('.modal-bg').forEach(m => m.classList.remove('open'));
$(`modal-${name}`).classList.add('open');
if (name === 'add' && currentService) $('add-svc').value = currentService;
};
window.closeModals = function() { document.querySelectorAll('.modal-bg').forEach(m => m.classList.remove('open')); };
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModals(); });
window.addKey = async function() {
const email = $('add-email').value.trim(), pw = $('add-pw').value.trim();
const key = $('add-key').value.trim(), svc = $('add-svc').value;
if (!email || !key) { toast('Email and key required', 'error'); return; }
try {
const r = await fetch(`${API}/api/keys`, { method: 'POST', headers: hdr(), body: JSON.stringify({ email, password: pw, api_key: key, service: svc }) });
if (!r.ok) throw new Error(await r.text());
toast('Key added', 'success'); closeModals(); $('add-email').value=''; $('add-pw').value=''; $('add-key').value=''; refresh();
} catch (e) { toast(`Failed: ${e.message}`, 'error'); }
};
window.fillImportExample = function() {
$('import-json').value = '[{"email":"firecrawl@example.com","password":"StrongPass123!","api_key":"fc-example-key"},\n {"email":"exa@example.com","password":"EMAIL_OTP_ONLY","api_key":"550e8400-e29b-41d4-a716-446655440000"}]';
$('import-svc').value = '';
};
window.importKeys = async function() {
const resultEl = $('import-result');
resultEl.classList.add('hidden');
try {
const text = $('import-json').value.trim();
const service = $('import-svc').value || null;
if (!text) throw new Error('Import content is empty');
const isJson = text.startsWith('[') || text.startsWith('{');
const url = isJson ? `${API}/api/keys/import` : `${API}/api/keys/import-text`;
const body = isJson
? (() => {
let keys = JSON.parse(text);
if (!Array.isArray(keys)) keys = [keys];
return { keys };
})()
: { text, service };
const r = await fetch(url, { method: 'POST', headers: hdr(), body: JSON.stringify(body) });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
const skipped = d.skipped || 0;
const msg = skipped > 0
? `Imported ${d.imported} new, skipped ${skipped} duplicates (${d.total} total)`
: `Imported ${d.imported} of ${d.total} keys`;
resultEl.textContent = msg;
resultEl.style.background = d.imported > 0 ? 'var(--ok-bg)' : 'var(--warn-bg)';
resultEl.style.color = d.imported > 0 ? 'var(--ok)' : 'var(--warn)';
resultEl.style.border = `1px solid ${d.imported > 0 ? 'rgba(34,197,94,.2)' : 'rgba(245,158,11,.2)'}`;
resultEl.classList.remove('hidden');
toast(msg, d.imported > 0 ? 'success' : 'info');
$('import-json').value=''; $('import-svc').value=''; refresh();
} catch (e) { toast(`Import failed: ${e.message}`, 'error'); }
};
window.loadFile = function(input) {
const file = input.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = e => { $('import-json').value = e.target.result; };
reader.readAsText(file);
};
window.deleteKey = async function(id) {
if (!confirm('Delete this key?')) return;
try { await fetch(`${API}/api/keys/${id}`, { method: 'DELETE', headers: hdr() }); toast('Deleted', 'success'); refresh(); }
catch { toast('Failed', 'error'); }
};
window.checkKey = async function(id) {
try {
const r = await fetch(`${API}/api/keys/${id}/check`, { method: 'POST', headers: hdr() });
const d = await r.json();
toast(`${d.status}: ${d.message}`, d.status === 'active' ? 'success' : 'error'); refresh();
} catch { toast('Check failed', 'error'); }
};
window.healthcheckAll = async function() {
const btn = $('btn-hc'); const orig = btn.textContent;
btn.textContent = 'Checking\u2026'; btn.disabled = true;
try {
let url = `${API}/api/keys/healthcheck`;
if (currentService) url += `?service=${currentService}`;
const r = await fetch(url, { method: 'POST', headers: hdr() });
const d = await r.json();
toast(`Checked ${d.checked} \u2014 ${d.active} active`, 'success'); refresh();
} catch { toast('Failed', 'error'); }
finally { btn.textContent = orig; btn.disabled = false; }
};
window.deleteInactive = async function() {
if (!confirm('Delete ALL inactive/exhausted keys?')) return;
try {
const r = await fetch(`${API}/api/keys/inactive`, { method: 'DELETE', headers: hdr() });
const d = await r.json();
toast(`Deleted ${d.deleted}`, 'success'); refresh();
} catch { toast('Failed', 'error'); }
};
window.copyKey = async function(key) {
try { await navigator.clipboard.writeText(key); toast('Copied', 'success'); } catch { toast('Copy failed', 'error'); }
};
window.exportKeys = async function() {
try {
const r = await fetch(`${API}/api/keys/export`, { headers: hdr() });
const data = await r.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'keys-export.json'; a.click();
URL.revokeObjectURL(url);
toast(`Exported ${data.length} keys`, 'success');
} catch (e) { toast(e.message, 'error'); }
};
async function loadConfig() {
try {
const r = await fetch(`${API}/api/config`, { headers: hdr() });
const cfg = await r.json();
$('cfg-admin-token').value = cfg.admin_token || '';
$('cfg-admin-pw').value = cfg.admin_password || '';
$('cfg-default-quota').value = cfg.default_quota || '1000';
const fm = (cfg.free_mode || 'false') === 'true';
$('cfg-free-mode').classList.toggle('on', fm);
$('cfg-free-label').textContent = fm ? 'On' : 'Off';
} catch { toast('Failed to load config', 'error'); }
}
window.saveConfig = async function(key, inputId) {
const val = $(inputId).value;
try {
await fetch(`${API}/api/config`, { method: 'PATCH', headers: hdr(), body: JSON.stringify({ configs: { [key]: val } }) });
toast('Saved', 'success');
if (key === 'admin_password' && val) { TOKEN = val; localStorage.setItem('tkm_token', TOKEN); }
} catch { toast('Save failed', 'error'); }
};
window.toggleFreeMode = async function() {
const btn = $('cfg-free-mode');
const newVal = btn.classList.contains('on') ? 'false' : 'true';
try {
await fetch(`${API}/api/config`, { method: 'PATCH', headers: hdr(), body: JSON.stringify({ configs: { free_mode: newVal } }) });
btn.classList.toggle('on', newVal === 'true');
$('cfg-free-label').textContent = newVal === 'true' ? 'On' : 'Off';
toast(`Free mode ${newVal === 'true' ? 'enabled' : 'disabled'}`, 'success');
} catch { toast('Failed', 'error'); }
};
window.copyVal = async function(id) {
try { await navigator.clipboard.writeText($(id).value); toast('Copied', 'success'); } catch {}
};
async function loadTokens() {
try {
const r = await fetch(`${API}/api/tokens`, { headers: hdr() });
const tokens = await r.json();
const tbody = $('tokens-tbl');
if (!tokens.length) { tbody.innerHTML = ''; $('empty-tokens').classList.remove('hidden'); return; }
$('empty-tokens').classList.add('hidden');
const now = new Date();
tbody.innerHTML = tokens.map(t => {
const ts = t.token.length > 18 ? t.token.substring(0,8)+'\u2026'+t.token.slice(-6) : t.token;
const lu = t.last_used ? new Date(t.last_used).toLocaleDateString('en-CA') : '\u2014';
const type = t.is_admin ? '<span class="badge badge-active">admin</span>' : '<span class="badge badge-exhausted">user</span>';
let expDisp = '\u2014';
let expired = false;
if (t.expires_at) {
const ed = new Date(t.expires_at);
expDisp = ed.toLocaleDateString('en-CA');
if (ed < now) { expired = true; expDisp = `<span style="color:var(--err)">${expDisp}</span>`; }
}
return `<tr${expired?' style="opacity:.5"':''}>
<td class="c-id">${t.id}</td>
<td><span class="c-key">${esc(ts)}</span></td>
<td style="color:var(--fg3)">${esc(t.name||'\u2014')}</td>
<td>${type}</td>
<td class="c-id">${t.is_admin ? '\u221e' : t.quota_limit}</td>
<td class="c-id">${t.is_admin ? '\u2014' : t.quota_used}</td>
<td><span class="badge badge-${t.status}">${t.status}</span></td>
<td class="c-date">${expDisp}</td>
<td class="c-date">${lu}</td>
<td><div class="c-acts" style="justify-content:flex-end;">
<button class="btn-icon" onclick="copyKey('${escA(t.token)}')" title="Copy">&#128203;</button>
${!t.is_admin ? `<button class="btn-icon" onclick="resetTokenUsage(${t.id})" title="Reset">&#8635;</button>` : ''}
${!t.is_admin ? `<button class="btn-icon danger" onclick="deleteToken(${t.id})" title="Delete">&#128465;</button>` : ''}
</div></td></tr>`;
}).join('');
} catch { toast('Failed to load tokens', 'error'); }
}
window.createToken = async function() {
const name = $('tk-name').value.trim(), token = $('tk-token').value.trim();
const quota = parseInt($('tk-quota').value) || 1000;
const expiresRaw = $('tk-expires').value;
const expires_at = expiresRaw ? new Date(expiresRaw + 'T23:59:59Z').toISOString() : null;
try {
const r = await fetch(`${API}/api/tokens`, { method: 'POST', headers: hdr(), body: JSON.stringify({ name, token, quota_limit: quota, expires_at }) });
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
toast(`Created: ${d.token.substring(0,14)}\u2026`, 'success');
closeModals(); $('tk-name').value=''; $('tk-token').value=''; $('tk-expires').value=''; loadTokens();
} catch (e) { toast(`Failed: ${e.message}`, 'error'); }
};
window.deleteToken = async function(id) {
if (!confirm('Delete this token?')) return;
try { await fetch(`${API}/api/tokens/${id}`, { method: 'DELETE', headers: hdr() }); toast('Deleted', 'success'); loadTokens(); }
catch { toast('Failed', 'error'); }
};
window.resetTokenUsage = async function(id) {
try { await fetch(`${API}/api/tokens/${id}/reset`, { method: 'POST', headers: hdr() }); toast('Reset', 'success'); loadTokens(); }
catch { toast('Failed', 'error'); }
};
const baseUrl = window.location.origin;
$('doc-base-url').textContent = baseUrl;
document.querySelectorAll('#tab-docs pre code').forEach(el => {
el.textContent = el.textContent.replace(/BASE_URL/g, baseUrl);
});
updateProxyInfo();
const saved = localStorage.getItem('tkm_token');
if (saved) {
TOKEN = saved;
fetch(`${API}/api/stats`, { headers: hdr() })
.then(r => { if (r.ok) { $('login-screen').classList.add('hidden'); $('app').classList.remove('hidden'); refresh(); } })
.catch(() => {});
}
})();
</script>
</body>
</html>