From b1f752ad3fd2b056fa22aca63b4c03fbf1e407d4 Mon Sep 17 00:00:00 2001 From: Jordan SkyLF Date: Tue, 12 May 2026 13:03:24 -0700 Subject: [PATCH] feat: add provider quota refresh control --- static/panels.js | 60 ++++++++++++++++++++++++++++- static/style.css | 5 +++ tests/test_provider_quota_status.py | 20 +++++++++- 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/static/panels.js b/static/panels.js index 680d58ee..ba3e0e32 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5485,13 +5485,20 @@ function _buildPluginCard(plugin){ const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn} +async function _fetchProviderQuotaStatus(force=false){ + const endpoint=force?`/api/provider/quota?refresh=1&ts=${Date.now()}`:'/api/provider/quota'; + const status=await api(endpoint,{cache:'no-store'}); + if(status&&typeof status==='object') status.client_fetched_at=new Date().toISOString(); + return status; +} + async function loadProvidersPanel(){ const list=$('providersList'); const empty=$('providersEmpty'); if(!list) return; try{ const data=await api('/api/providers'); - const quota=await api('/api/provider/quota').catch(e=>({ok:false,status:'unavailable',quota:null,message:e.message||'Quota status unavailable'})); + const quota=await _fetchProviderQuotaStatus(false).catch(e=>({ok:false,status:'unavailable',quota:null,message:e.message||'Quota status unavailable',client_fetched_at:new Date().toISOString()})); const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth); list.innerHTML=''; _providerCardEls.clear(); @@ -5512,6 +5519,40 @@ async function loadProvidersPanel(){ } } +async function _refreshProviderQuota(card,button){ + if(!card) return; + if(button){ + button.disabled=true; + button.textContent='Refreshing…'; + button.setAttribute('aria-busy','true'); + } + let failed=false; + let next; + try{ + next=await _fetchProviderQuotaStatus(true); + failed=next&&next.ok===false; + }catch(e){ + failed=true; + next={ok:false,status:'unavailable',quota:null,message:e.message||'Quota status unavailable',client_fetched_at:new Date().toISOString()}; + } + try{ + const fresh=_buildProviderQuotaCard(next); + if(fresh){ + card.replaceWith(fresh); + if(typeof showToast==='function') showToast(failed?'Provider usage refresh failed':'Provider usage refreshed'); + return; + } + }catch(e){ + failed=true; + } + if(button){ + button.disabled=false; + button.textContent='Refresh usage'; + button.removeAttribute('aria-busy'); + } + if(typeof showToast==='function') showToast('Provider usage refresh failed'); +} + function _formatProviderQuotaMoney(value){ if(value===null||value===undefined||value==='') return '—'; const n=Number(value); @@ -5543,6 +5584,15 @@ function _formatProviderQuotaWindowLabel(accountLimits,w){ return raw||'Window'; } +function _formatProviderQuotaLastChecked(status){ + const accountLimits=status&&status.account_limits; + const value=(accountLimits&&accountLimits.fetched_at)||status&&status.client_fetched_at; + if(!value) return 'Last checked after refresh'; + const d=new Date(value); + if(Number.isNaN(d.getTime())) return 'Last checked after refresh'; + try{return 'Last checked '+d.toLocaleString();}catch(e){return 'Last checked '+value;} +} + function _buildProviderQuotaCard(status){ if(!status) return null; const card=document.createElement('div'); @@ -5590,11 +5640,17 @@ function _buildProviderQuotaCard(status){
Active provider quota
${esc(provider)}
+
${esc(_formatProviderQuotaLastChecked(status))}
+
+
+ ${esc(state.replace(/_/g,' '))} +
- ${esc(state.replace(/_/g,' '))}
${body}
`; + const refreshBtn=card.querySelector('[data-provider-quota-refresh]'); + if(refreshBtn) refreshBtn.addEventListener('click',()=>_refreshProviderQuota(card,refreshBtn)); return card; } diff --git a/static/style.css b/static/style.css index 84dd4649..98661718 100644 --- a/static/style.css +++ b/static/style.css @@ -2494,7 +2494,12 @@ main.main.showing-logs > #mainLogs{display:flex;} .provider-quota-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px;} .provider-quota-title{font-size:13px;font-weight:650;color:var(--text);line-height:1.2;} .provider-quota-subtitle{font-size:11px;color:var(--muted);line-height:1.3;margin-top:2px;} +.provider-quota-checked{font-size:10.5px;color:var(--muted);line-height:1.3;margin-top:2px;} +.provider-quota-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end;} .provider-quota-badge{font-size:10.5px;font-weight:650;text-transform:capitalize;padding:2px 8px;border-radius:999px;background:var(--accent-bg);color:var(--accent-text);white-space:nowrap;} +.provider-quota-refresh{appearance:none;border:1px solid var(--border);border-radius:999px;background:var(--sidebar);color:var(--text);font-size:11px;font-weight:650;line-height:1;padding:5px 9px;cursor:pointer;white-space:nowrap;} +.provider-quota-refresh:hover:not(:disabled){border-color:var(--accent);color:var(--accent);} +.provider-quota-refresh:disabled{opacity:.65;cursor:wait;} .provider-quota-body{display:flex;flex-wrap:wrap;gap:8px;} .provider-quota-metric{flex:1;min-width:88px;border:1px solid var(--border);border-radius:8px;background:var(--sidebar);padding:8px 10px;} .provider-quota-metric span{display:block;font-size:10.5px;color:var(--muted);margin-bottom:2px;} diff --git a/tests/test_provider_quota_status.py b/tests/test_provider_quota_status.py index 8da72e98..1ea6980a 100644 --- a/tests/test_provider_quota_status.py +++ b/tests/test_provider_quota_status.py @@ -541,7 +541,8 @@ def test_provider_quota_route_is_registered(): def test_provider_quota_card_is_rendered_in_providers_panel(): """The Providers panel should show active provider quota/status before cards.""" panels = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") - assert "api('/api/provider/quota')" in panels + assert "_fetchProviderQuotaStatus(false)" in panels + assert "'/api/provider/quota'" in panels assert "function _buildProviderQuotaCard" in panels assert "Active provider quota" in panels assert "provider-quota-card" in panels @@ -551,6 +552,20 @@ def test_provider_quota_card_is_rendered_in_providers_panel(): assert "5-hour limit" in panels +def test_provider_quota_card_has_manual_refresh_control(): + """The quota card should let users force an immediate fresh usage lookup.""" + panels = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") + assert "function _refreshProviderQuota" in panels + assert "function _fetchProviderQuotaStatus" in panels + assert "refresh=1" in panels + assert "cache:'no-store'" in panels + assert "data-provider-quota-refresh" in panels + assert "Refresh usage" in panels + assert "Provider usage refreshed" in panels + assert "Provider usage refresh failed" in panels + assert "Last checked" in panels + + def test_provider_quota_styles_exist(): """Quota UI should have visible supported/unavailable/invalid states.""" css = (ROOT / "static" / "style.css").read_text(encoding="utf-8") @@ -562,6 +577,9 @@ def test_provider_quota_styles_exist(): ".provider-quota-card-invalid_key", ".provider-quota-details", ".provider-quota-window", + ".provider-quota-actions", + ".provider-quota-refresh", + ".provider-quota-checked", ): assert token in css