| <!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); } |
| |
| |
| .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 · Firecrawl · Exa — unified key pool & 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;">↻</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">◯</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">◯</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">📋</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 — 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 & 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 & 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 & 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 & 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> |
|
|
| |
| <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> — <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> — <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> — <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> — 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> → Firecrawl, UUID → Exa, <span style="font-family:var(--mono);">tvly-*</span> / other → 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> — <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">📋</button> |
| <button class="btn-icon ok" onclick="checkKey(${k.id})" title="Test">⚡</button> |
| <button class="btn-icon danger" onclick="deleteKey(${k.id})" title="Delete">🗑</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, '"'); } |
| |
| 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">📋</button> |
| ${!t.is_admin ? `<button class="btn-icon" onclick="resetTokenUsage(${t.id})" title="Reset">↻</button>` : ''} |
| ${!t.is_admin ? `<button class="btn-icon danger" onclick="deleteToken(${t.id})" title="Delete">🗑</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> |
|
|