HuggingClaw / key-rotator-manager.html
anurag008w's picture
Add sticky_pick/task_affinity_pick options to event-log filter
2c706a6
Raw
History Blame Contribute Delete
25.6 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>HuggingClaw · API Key Rotator</title>
<style>
:root{color-scheme:dark;--bg:#070711;--panel:#111120;--panel2:#17172a;--line:#292945;--text:#f8f7ff;--muted:#9892b8;--soft:#c7c2e6;--good:#22c55e;--warn:#f5c542;--bad:#fb7185;--blue:#60a5fa;--violet:#a78bfa}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,#25145a 0,#070711 34%,#070711 100%);font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--text);font-size:13px}main{width:min(1280px,calc(100% - 28px));margin:0 auto;padding:28px 0 44px}.top{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;margin-bottom:18px}.eyebrow{font-size:.7rem;letter-spacing:.18em;text-transform:uppercase;color:var(--muted);font-weight:900}h1{margin:6px 0 8px;font-size:clamp(1.6rem,4vw,2.7rem);line-height:1}.sub{color:var(--soft);max-width:860px;line-height:1.55}.actions{display:flex;gap:10px;flex-wrap:wrap}.btn{border:1px solid var(--line);background:rgba(255,255,255,.06);color:var(--text);border-radius:11px;padding:10px 14px;text-decoration:none;font-weight:850;cursor:pointer}.btn.primary{background:#fff;color:#050510}.btn:hover{filter:brightness(1.08)}.grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin:18px 0}.card{background:linear-gradient(180deg,rgba(255,255,255,.055),rgba(255,255,255,.025));border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:0 18px 45px rgba(0,0,0,.22)}.metric-title{color:var(--muted);text-transform:uppercase;letter-spacing:.16em;font-size:.66rem;font-weight:900}.metric-value{font-size:1.65rem;font-weight:950;margin-top:8px}.metric-detail{color:var(--muted);margin-top:6px;line-height:1.45}.ok{color:var(--good)}.warn{color:var(--warn)}.bad{color:var(--bad)}.blue{color:var(--blue)}.layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:14px}.panel-title{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px}.panel-title h2{font-size:1rem;margin:0}.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:999px;padding:5px 9px;color:var(--soft);background:rgba(255,255,255,.035);font-size:.72rem;font-weight:850}.dot{width:7px;height:7px;border-radius:50%;background:var(--muted)}.dot.live{background:var(--good);box-shadow:0 0 15px var(--good)}.providers{display:flex;flex-direction:column;gap:9px}.provider{border:1px solid var(--line);border-radius:14px;background:rgba(0,0,0,.12);padding:12px;cursor:pointer}.provider.active{border-color:var(--blue);box-shadow:0 0 0 1px rgba(96,165,250,.25)}.provider-top{display:flex;justify-content:space-between;align-items:center;gap:12px}.provider-name{font-weight:950}.provider-meta{color:var(--muted);font-size:.78rem;margin-top:5px}.bar{height:8px;background:#22223a;border-radius:999px;overflow:hidden;margin-top:10px}.bar>span{display:block;height:100%;background:linear-gradient(90deg,var(--violet),var(--blue));border-radius:999px}.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}.input{flex:1;min-width:180px;border:1px solid var(--line);background:#0c0c18;color:var(--text);border-radius:11px;padding:10px 12px;outline:none}.select{border:1px solid var(--line);background:#0c0c18;color:var(--text);border-radius:11px;padding:10px 12px}.toggle{display:flex;align-items:center;gap:7px;color:var(--soft);font-weight:800}.events{display:flex;flex-direction:column;gap:9px;max-height:620px;overflow:auto;padding-right:4px}.event{border:1px solid var(--line);border-left-width:4px;border-radius:14px;background:rgba(0,0,0,.16);padding:12px;display:grid;grid-template-columns:155px minmax(0,1fr) auto;gap:12px}.event.pick,.event.sticky_pick,.event.pick_retry_fresh,.event.model_detected,.event.request_started{border-left-color:var(--blue)}.event.success{border-left-color:var(--good)}.event.rate_limited,.event.auth_failed,.event.transport_aborted,.event.transport_unknown,.event.all_suspended_pick,.event.all_suspended_withheld,.event.client_error,.event.provider_error{border-left-color:var(--warn)}.event.network_retryable,.event.transient_status,.event.saturated_reuse,.event.sticky_saturated_reuse,.event.sticky_saturated_rotate{border-left-color:var(--bad)}.event.inflight_timeout,.event.inflight_lease_expired{border-left-color:var(--muted)}.time{color:var(--muted);font-variant-numeric:tabular-nums}.etype{font-weight:950}.msg{color:var(--soft);line-height:1.45;word-break:break-word}.key,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#e9d5ff}.empty{padding:34px;text-align:center;color:var(--muted);border:1px dashed var(--line);border-radius:16px}.foot{color:var(--muted);margin-top:16px;line-height:1.55}.kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#23233a;border:1px solid #363653;border-radius:7px;padding:2px 6px;color:var(--text)}.detail-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;margin-bottom:12px}.mini{border:1px solid var(--line);border-radius:13px;padding:11px;background:rgba(0,0,0,.12)}.mini b{display:block;font-size:1.15rem;margin-top:4px}.key-table-wrap{overflow:auto}.key-table{width:100%;border-collapse:separate;border-spacing:0 8px;min-width:780px}.key-table th{color:var(--muted);font-size:.68rem;text-align:left;text-transform:uppercase;letter-spacing:.12em}.key-table td{background:rgba(0,0,0,.15);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:10px;vertical-align:top}.key-table td:first-child{border-left:1px solid var(--line);border-radius:12px 0 0 12px}.key-table td:last-child{border-right:1px solid var(--line);border-radius:0 12px 12px 0}.status{font-weight:900}.status.used{color:var(--good)}.status.unused{color:var(--muted)}.model-chip{display:inline-flex;margin:2px 4px 2px 0;padding:4px 7px;border:1px solid var(--line);border-radius:999px;color:var(--soft);background:rgba(255,255,255,.035);font-size:.72rem}.section{margin-top:14px}.session-note{border:1px solid rgba(96,165,250,.28);background:rgba(96,165,250,.08);border-radius:14px;padding:12px;color:var(--soft);margin-bottom:12px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:1050px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}.detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.layout{grid-template-columns:1fr}.top{flex-direction:column}.event{grid-template-columns:1fr}.events{max-height:none}}@media(max-width:620px){.grid,.detail-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<main>
<section class="top">
<div>
<div class="eyebrow">HuggingClaw observability</div>
<h1>🔑 API Key Rotation Manager</h1>
<div class="sub">Gateway-token protected production view for every provider pool. Inspect masked key slots, unused keys, rate limits, retries, and Gemini per-model usage without exposing raw API keys.</div>
</div>
<div class="actions">
<span class="pill">🔒 GATEWAY_TOKEN protected</span>
<a class="btn" href="/dashboard">← Dashboard</a>
<button id="pause" class="btn" type="button">Pause</button>
<button id="export" class="btn" type="button">Export JSON</button>
<button id="refresh" class="btn primary" type="button">Refresh</button>
</div>
</section>
<section class="grid" aria-label="Key rotator summary">
<div class="card"><div class="metric-title">Configured keys</div><div id="mKeys" class="metric-value">0</div><div class="metric-detail">Across active provider pools</div></div>
<div class="card"><div class="metric-title">Used keys</div><div id="mUsed" class="metric-value blue">0</div><div class="metric-detail">Keys with pick/success events</div></div>
<div class="card"><div class="metric-title">Unused keys</div><div id="mUnused" class="metric-value">0</div><div class="metric-detail">Configured but not picked yet</div></div>
<div class="card"><div class="metric-title">Rate limits</div><div id="mRate" class="metric-value warn">0</div><div class="metric-detail">429/402 events observed</div></div>
<div class="card"><div class="metric-title">Last update</div><div id="mUpdate" class="metric-value" style="font-size:1.05rem"></div><div class="metric-detail"><span class="pill"><span id="liveDot" class="dot live"></span><span id="liveText">Live</span></span></div></div>
</section>
<section class="layout">
<aside class="card">
<div class="panel-title"><h2>Provider sessions</h2><span id="providerCount" class="pill">0</span></div>
<div id="providers" class="providers" role="listbox" aria-label="Provider key pools"></div>
<p class="foot">Click a provider to open its isolated session view. Use <span class="kbd">GEMINI_API_KEYS</span> or <span class="kbd">GOOGLE_API_KEYS</span> with comma/newline-separated keys.</p>
</aside>
<section class="card">
<div class="panel-title"><h2 id="detailTitle">Provider details</h2><span id="source" class="pill">/tmp</span></div>
<div id="details"><div class="empty">Select a provider to see key/session usage.</div></div>
<div class="section">
<div class="panel-title"><h2>Rotation event log</h2><span id="eventCount" class="pill">0</span></div>
<div class="toolbar">
<input id="q" class="input" placeholder="Filter provider, key, model, event…" aria-label="Filter events">
<select id="type" class="select" aria-label="Event type"><option value="">All events</option><option value="pick">Picks</option><option value="sticky_pick">Sticky picks</option><option value="task_affinity_pick">Affinity picks</option><option value="success">Success</option><option value="rate_limited">Rate limits</option><option value="network_retryable">Network retries</option><option value="pick_retry_fresh">Retry picks</option><option value="auth_failed">Auth failures</option></select>
<label class="toggle"><input id="scope" type="checkbox" checked> selected provider only</label>
</div>
<div id="events" class="events"><div class="empty">Loading rotator events…</div></div>
</div>
</section>
</section>
</main>
<script>
let state={events:[],providers:[],runtime:{routes:[]},paused:false,selected:localStorage.getItem('hc.keyRotator.provider')||''};
const $=id=>document.getElementById(id), HTML_ESCAPE={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
const PICK_TYPES=['pick','sticky_pick','task_affinity_pick','pick_retry_fresh','all_suspended_pick','saturated_reuse','sticky_saturated_reuse'];
const START_TYPES=['request_started'];
const STALE_PENDING_MS=35000;
const OUTCOME_TYPES=['success','rate_limited','network_retryable','auth_failed','transient_status','transport_aborted','transport_unknown','client_error','provider_error','inflight_lease_expired','inflight_timeout'];
const FAIL_TYPES=['rate_limited','network_retryable','auth_failed','transient_status','transport_unknown','client_error','provider_error'];
function esc(v){return String(v??'').replace(/[&<>"']/g,ch=>HTML_ESCAPE[ch]);}
function eventClass(v){return String(v||'event').replace(/[^a-z0-9_-]/gi,'_');}
function fmtTime(ts){const d=new Date(ts);return isNaN(d)?'—':d.toLocaleString();}
function isKeyEvent(e){return e.provider&&e.slot&&(e.key||e.type==='all_suspended_withheld');}
function keyIds(provider,slot,key,kid,includeSlot){const safeKey=key&&key!=='***'?key:'';const vals=[kid,safeKey,(includeSlot||!kid)&&slot?`slot:${slot}`:''].filter(Boolean);return [...new Set(vals.map(v=>`${provider}::${v}`))];}
function keyId(provider,slot,key,kid){return keyIds(provider,slot,key,kid)[0]||`${provider}::`;}
function eventText(e){const bits=[];if(e.provider)bits.push(e.provider);if(e.slot)bits.push(`#${e.slot}/${e.total}`);if(e.key)bits.push(e.key);if(e.model)bits.push(`model=${e.model}`);if(e.status)bits.push(`status=${e.status}`);if(e.errorStatus)bits.push(`error=${e.errorStatus}`);if(e.errorReason)bits.push(`reason=${e.errorReason}`);if(e.errorType)bits.push(`type=${e.errorType}`);if(e.source)bits.push(`source=${e.source}`);if(e.name)bits.push(`name=${e.name}`);if(e.waitMs)bits.push(`wait=${Math.round(e.waitMs/1000)}s`);if(e.code)bits.push(`code=${e.code}`);if(e.errorCode)bits.push(`errorCode=${e.errorCode}`);if(e.message)bits.push(`message=${e.message}`);if(e.requestId)bits.push(`req=${e.requestId}`);if(e.inflight)bits.push(`inflight=${e.inflight}/${e.maxInflight||'?'}`);return bits.join(' · ');}
function emptyStat(provider,slot,total,key){return{provider,slot,total,key,picks:0,pending:0,pendingIds:new Set(),success:0,fail:0,rate:0,retry:0,auth:0,transient:0,abort:0,other:0,lease:0,last:'',lastPick:'',models:new Map()};}
function emptyScopedStat(){return{picks:0,pending:0,pendingIds:new Set(),success:0,fail:0,rate:0,retry:0,auth:0,transient:0,abort:0,other:0,lease:0,last:'',lastPick:'',observed:false};}
function bumpPending(v,e){v.pending++;if(e.requestId)v.pendingIds.add(e.requestId);v.lastPick=e.ts||v.lastPick;v.last=e.ts||v.last;}
function closePending(v,e){if(e.requestId){if(!v.pendingIds.has(e.requestId))return false;v.pendingIds.delete(e.requestId);v.pending=Math.max(0,(v.pending||0)-1);return true;}if((v.pending||0)>0){v.pending--;return true;}return false;}
function bumpOutcome(v,type,isLease,ts){if(type==='success')v.success++;if(FAIL_TYPES.includes(type))v.fail++;if(type==='rate_limited')v.rate++;if(type==='network_retryable')v.retry++;if(type==='auth_failed')v.auth++;if(type==='transport_aborted')v.abort++;if(type==='transport_unknown'||type==='client_error'||type==='provider_error')v.other++;if(isLease)v.lease++;if(type==='transient_status')v.transient++;v.last=ts||v.last;}
function bumpPick(v,ts){v.picks++;v.lastPick=ts||v.lastPick;v.last=ts||v.last;}
function buildStats(){const stats=new Map(),events=state.events||[],hasRequestStarted=events.some(e=>START_TYPES.includes(e.type));for(const p of state.providers){for(const k of (p.keys||[])){const s=emptyStat(p.name,k.slot,k.total,k.key);for(const id of keyIds(p.name,k.slot,k.key,k.kid,true))if(!stats.has(id))stats.set(id,s);}}for(const e of events){if(!isKeyEvent(e))continue;const ids=keyIds(e.provider,e.slot,e.key,e.kid,false);let s=ids.map(id=>stats.get(id)).find(Boolean);if(!s)s=emptyStat(e.provider,e.slot,e.total,e.key||'***');for(const id of ids)if(!stats.has(id))stats.set(id,s);const isPick=PICK_TYPES.includes(e.type),isStart=START_TYPES.includes(e.type),isLegacyStart=!hasRequestStarted&&isPick;const isLease=e.type==='inflight_timeout'||e.type==='inflight_lease_expired',isOutcome=OUTCOME_TYPES.includes(e.type)||isLease;if(isPick)bumpPick(s,e.ts);if(isStart||isLegacyStart)bumpPending(s,e);if(!isPick&&!isStart)s.last=e.ts||s.last;if(e.model){let m=s.models.get(e.model);if(!m)m=emptyScopedStat();if(e.type==='model_detected')m.observed=true;if(isPick)bumpPick(m,e.ts);if(isStart||isLegacyStart)bumpPending(m,e);else if(isOutcome){closePending(m,e);bumpOutcome(m,e.type,isLease,e.ts);}else m.last=e.ts||m.last;s.models.set(e.model,m);if(isOutcome&&!isPick&&!isStart&&s.unscoped&&s.unscoped.pending>0){if(closePending(s.unscoped,e))bumpOutcome(s.unscoped,e.type,isLease,e.ts);}}else if(isPick||isStart||isLegacyStart||isOutcome){s.unscoped=s.unscoped||emptyScopedStat();if(isPick)bumpPick(s.unscoped,e.ts);if(isStart||isLegacyStart)bumpPending(s.unscoped,e);else if(isOutcome){closePending(s.unscoped,e);bumpOutcome(s.unscoped,e.type,isLease,e.ts);}}if(isOutcome&&!isPick&&!isStart)closePending(s,e);if(isOutcome)bumpOutcome(s,e.type,isLease,e.ts);}const now=Date.now();for(const s of uniqueStats(stats)){ageStalePending(s,now);for(const m of s.models.values())ageStalePending(m,now);if(s.unscoped)ageStalePending(s.unscoped,now);}return stats;}
function ageStalePending(v,now){const pending=pendingCount(v);if(!pending)return;const ts=Date.parse(v.lastPick||v.last||'');if(Number.isFinite(ts)&&now-ts>STALE_PENDING_MS){v.stalePending=(v.stalePending||0)+pending;}}
function providerRows(provider,stats){return (provider?.keys||[]).map(k=>keyIds(provider.name,k.slot,k.key,k.kid,true).map(id=>stats.get(id)).find(Boolean)||emptyStat(provider.name,k.slot,k.total,k.key));}
function uniqueStats(stats){return [...new Set(stats.values())];}
function pendingCount(v){return Math.max(0,v.pending||0);}
function modelChips(row){const entries=[...row.models.entries()];const un=row.unscoped||{picks:0,success:0,fail:0,rate:0,retry:0,auth:0,transient:0,abort:0,other:0,lease:0};const unPending=pendingCount(un);if(!entries.length){const total=row.picks+row.success+(row.fail||0)+(row.abort||0)+(row.lease||0);return total?`<span class="model-chip">unscoped · p:${row.picks} ok:${row.success} fail:${row.fail||0} pending:${pendingCount(row)} rl:${row.rate} retry:${row.retry+row.transient}${row.abort?` abort:${row.abort}`:''}${row.other?` other:${row.other}`:''}${row.lease?` lease:${row.lease}`:''}</span>`:'<span class="model-chip">no model events yet</span>';}const chips=entries.map(([m,v])=>{const onlyObserved=v.observed&&!v.picks&&!v.success&&!(v.fail||0)&&!v.rate&&!v.retry&&!v.transient&&!v.auth&&!v.abort&&!v.other&&!v.lease;return `<span class="model-chip">${esc(m)} · ${onlyObserved?'observed':`p:${v.picks} ok:${v.success} fail:${v.fail||0} pending:${pendingCount(v)} rl:${v.rate} retry:${v.retry+v.transient}${v.abort?` abort:${v.abort}`:''}${v.other?` other:${v.other}`:''}${v.lease?` lease:${v.lease}`:''}`}</span>`;});if(un.picks||un.success||(un.fail||0)||un.rate||un.retry||un.transient||un.auth||un.abort||un.other||un.lease)chips.push(`<span class="model-chip">unscoped totals · p:${un.picks} ok:${un.success} fail:${un.fail||0} pending:${unPending} rl:${un.rate} retry:${un.retry+un.transient}${un.abort?` abort:${un.abort}`:''}${un.other?` other:${un.other}`:''}${un.lease?` lease:${un.lease}`:''}</span>`);return chips.join('');}
function routeNote(provider){const routes=(state.runtime&&state.runtime.routes)||[],hits=routes.filter(r=>r.provider===provider.name);if(!routes.length)return '<div class="session-note">Current LLM route is unknown to this view. Usage appears only after OpenClaw sends a request to this provider hostname.</div>';if(hits.length)return `<div class="session-note"><b>Active route:</b> ${hits.map(r=>`${esc(r.role)} ${esc(r.model)}`).join(' · ')}. New messages using this route should create pick/success events below.</div>`;return `<div class="session-note bad"><b>Not on current model route.</b> Current route is ${routes.map(r=>`${esc(r.role)} ${esc(r.model)}${esc(r.provider)}`).join(' · ')}. ${esc(provider.name)} keys stay unused until you select a ${esc(provider.name)} model or a fallback reaches it.</div>`;}
function renderProviderDetails(provider,stats){if(!provider){$('detailTitle').textContent='Provider details';$('details').innerHTML='<div class="empty">Select a provider to see key/session usage.</div>';return;}const rows=providerRows(provider,stats),used=rows.filter(r=>r.picks||r.success).length,successes=rows.reduce((a,r)=>a+r.success,0),fails=rows.reduce((a,r)=>a+(r.fail||0),0),rates=rows.reduce((a,r)=>a+r.rate,0),retries=rows.reduce((a,r)=>a+r.retry+r.transient,0),auths=rows.reduce((a,r)=>a+r.auth,0),aborts=rows.reduce((a,r)=>a+(r.abort||0),0),pending=rows.reduce((a,r)=>a+pendingCount(r),0);$('detailTitle').textContent=`${provider.name} · provider session`;const modelSet=new Set();rows.forEach(r=>r.models.forEach((_,m)=>modelSet.add(m)));const table=rows.map(r=>{const usedKey=(r.picks||r.success)>0,pending=pendingCount(r),stale=r.stalePending||0;return `<tr><td><b class="mono">#${r.slot}/${r.total}</b><div class="key">${esc(r.key)}</div></td><td><span class="status ${usedKey?'used':'unused'}">${usedKey?'USED':'UNUSED'}</span><div class="metric-detail">last: ${esc(r.last?fmtTime(r.last):'never')}</div>${pending?`<div class="metric-detail warn">pending/no response observed: ${pending}${stale?` (stale ${stale})`:''}</div>`:''}</td><td>pick ${r.picks}<br>success ${r.success}<br>fail ${r.fail||0}<br>pending ${pending}<br>rate ${r.rate}</td><td>retry ${r.retry}<br>transient ${r.transient}<br>auth ${r.auth}<br>abort ${r.abort||0}<br>other ${r.other||0}<br>lease cleanup ${r.lease||0}</td><td>${modelChips(r)}</td></tr>`;}).join('');$('details').innerHTML=`${routeNote(provider)}<div class="session-note">Session: <b>${esc(provider.name)}</b>. Raw keys are never returned here; this page only shows masked keys and slot numbers from the configured pool.</div><div class="detail-grid"><div class="mini"><span class="metric-title">Total keys</span><b>${provider.total||0}</b></div><div class="mini"><span class="metric-title">Used / unused</span><b>${used}/${Math.max(0,(provider.total||0)-used)}</b></div><div class="mini"><span class="metric-title">Success / fail</span><b>${successes}/${fails}</b></div><div class="mini"><span class="metric-title">Rate / pending</span><b>${rates}/${pending}</b></div><div class="mini"><span class="metric-title">Retry/auth/abort</span><b>${retries}/${auths}/${aborts}</b></div></div>${provider.name==='gemini'?`<p class="foot">Gemini quota is tracked per key + model. Models seen: ${modelSet.size?[...modelSet].map(m=>`<span class="model-chip">${esc(m)}</span>`).join(''):'<span class="kbd">none yet — new requests will show per-model once the rotator can read the request body</span>'}</p>`:''}<div class="key-table-wrap"><table class="key-table"><thead><tr><th>Hidden key slot</th><th>Status</th><th>Usage</th><th>Failures / aborts</th><th>Per-model / details</th></tr></thead><tbody>${table||'<tr><td colspan="5">No keys configured</td></tr>'}</tbody></table></div>`;}
function render(){const q=$('q').value.trim().toLowerCase(),type=$('type').value,providers=state.providers||[],events=state.events||[],stats=buildStats();if(!providers.some(p=>p.name===state.selected)&&providers[0])state.selected=providers[0].name;const totalKeys=providers.reduce((a,p)=>a+(Number(p.total)||0),0),usedKeyCount=providers.reduce((a,p)=>a+providerRows(p,stats).filter(s=>s.picks||s.success).length,0);$('mKeys').textContent=totalKeys;$('mUsed').textContent=usedKeyCount;$('mUnused').textContent=Math.max(0,totalKeys-usedKeyCount);$('mRate').textContent=events.filter(e=>e.type==='rate_limited').length;$('mUpdate').textContent=new Date().toLocaleTimeString();$('providerCount').textContent=`${providers.length} active`;$('eventCount').textContent=`${events.length} events`;$('providers').innerHTML=providers.length?providers.map(p=>{const rows=providerRows(p,stats),used=rows.filter(r=>r.picks||r.success).length,total=Number(p.total)||0,aliases=p.aliases?' · '+esc(p.aliases):'';return `<button class="provider ${p.name===state.selected?'active':''}" data-provider="${esc(p.name)}" type="button"><div class="provider-top"><div class="provider-name">${esc(p.name)}</div><span class="pill">${used}/${total} used</span></div><div class="provider-meta">${esc(p.env||'configured pool')}${aliases}</div><div class="bar"><span style="width:${total?Math.round((used/total)*100):0}%"></span></div></button>`;}).join(''):'<div class="empty">No key pools detected in this process env.</div>';$('providers').querySelectorAll('[data-provider]').forEach(el=>el.onclick=()=>{state.selected=el.dataset.provider;localStorage.setItem('hc.keyRotator.provider',state.selected);render();});renderProviderDetails(providers.find(p=>p.name===state.selected)||providers[0],stats);let filtered=events.slice().reverse().filter(e=>(!type||e.type===type));if($('scope').checked&&state.selected)filtered=filtered.filter(e=>!e.provider||e.provider==='system'||e.provider===state.selected);if(q)filtered=filtered.filter(e=>JSON.stringify(e).toLowerCase().includes(q));$('events').innerHTML=filtered.length?filtered.map(e=>`<article class="event ${eventClass(e.type)}"><div class="time">${esc(fmtTime(e.ts))}</div><div><div class="etype">${esc(e.type||'event')}</div><div class="msg">${esc(eventText(e)||'system event')}</div></div><div class="key">${esc(e.key||'')}</div></article>`).join(''):'<div class="empty">No matching events yet. Send a message or disable provider scope to view other providers.</div>';}
async function load(){if(state.paused)return;try{const r=await fetch('/api/key-rotator/logs?limit=1000',{cache:'no-store'});if(r.status===401){location.href='/login?next='+encodeURIComponent('/key-rotator');return;}const d=await r.json();state.events=d.events||[];state.providers=d.providers||[];state.runtime=d.runtime||{routes:[]};state.lastPayload=d;const log=d.log||{};$('source').textContent=log.exists?`${d.file||'event log'} · ${Math.round((log.size||0)/1024)} KiB`:`${d.file||'event log'} · waiting`;render();}catch(e){$('events').innerHTML='<div class="empty bad">Could not load rotator logs: '+esc(e.message)+'</div>';}}
$('q').addEventListener('input',render);$('type').addEventListener('change',render);$('scope').addEventListener('change',render);$('refresh').onclick=load;$('pause').onclick=()=>{state.paused=!state.paused;$('pause').textContent=state.paused?'Resume':'Pause';$('liveDot').classList.toggle('live',!state.paused);$('liveText').textContent=state.paused?'Paused':'Live';};$('export').onclick=()=>{const data=JSON.stringify(state.lastPayload||{providers:state.providers,events:state.events},null,2),blob=new Blob([data],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='huggingclaw-key-rotator.json';a.click();URL.revokeObjectURL(url);};load();setInterval(load,2500);
</script>
</body>
</html>