mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
The page showed a monthly BALANCE ($0.08 left) but no monthly ALLOWANCE, so
"how much do I get and how much of it is gone" had no answer — the most useful
number on a plan that is nearly exhausted.
The vendor's credits endpoint reports what is left and never the cap, so a
percentage is not derivable from it. Two things supply the missing half:
- planId from subscriptions (here individual-goat), mapped to the caps the
vendor publishes ($10 Go, $70 GOAT/Pro/Max).
- totalMonthlyCredits from usage/summary, which counts over the BILLING PERIOD
and so pairs with the monthly allowance rather than with the rolling 5h and
weekly windows.
Because the cap is a table lookup and not an API fact, it is cross-checked
against monthly_used + balance: if the two disagree by more than 2% + $0.50,
the card says the figure may be off rather than confidently printing a wrong
percentage. A planId absent from the table renders the balance with no bar at
all — inventing a denominator would be worse than admitting there isn't one.
Also surfaces what the endpoints were already returning and the page was
dropping: period_end (with a countdown), cancel_at_period_end, plan name, and
the vendor's own belowThreshold flag, which is distinct from "balance > 0" —
an account can hold $3 and still be under their threshold.
Verified on production: both accounts show 每月 100% ($69.94/$70, red bar),
5 小时 0/14, 每周 0.803/35 on one; plan goat, 周期至 2026-10-17 / 2026-10-19.
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
482 lines
22 KiB
Lua
482 lines
22 KiB
Lua
-- Command Code quota — multi-account plan balance for the gateway.
|
||
--
|
||
-- The billing plugin only ever reports what the gateway already measured, so it
|
||
-- never has to reach out. A plan balance is different: the number only exists
|
||
-- on the vendor's side, and the only way to learn it is to ask. Command Code
|
||
-- exposes /alpha/billing/credits (plus whoami / subscriptions / usage) on the
|
||
-- provider host, authenticated with the same API key the source already uses.
|
||
--
|
||
-- Accounts: one per configured source whose base_url is the Command Code API
|
||
-- host. Two keys on the same host are two separate accounts, and both are
|
||
-- shown — an operator with a spare seat needs to see both draining.
|
||
--
|
||
-- Every endpoint degrades independently. A vendor that retires one route must
|
||
-- not blank the others, and a key that has run out still returns HTTP 401 on
|
||
-- the inference path while credits reads fine, so the report has to survive
|
||
-- partial failure and say WHICH part failed rather than rendering a zero that
|
||
-- looks like "no quota left".
|
||
|
||
local PLUGIN = {}
|
||
|
||
PLUGIN.name = "ccquota"
|
||
PLUGIN.version = "1.0.0"
|
||
PLUGIN.description = "Command Code 多账户额度余量(5 小时 / 周 / 月窗口 + 余额)"
|
||
|
||
-- Command Code's API host. __llmsproxy_http only permits hosts that a
|
||
-- configured source already talks to, so this works exactly when such a source
|
||
-- exists — no separate allowlist to keep in sync, and no way to widen reach.
|
||
local API_HOST = "api.commandcode.ai"
|
||
|
||
-- These headers are required alongside the bearer token. Without them the
|
||
-- endpoints answer 401 even with a perfectly valid key, which looks identical
|
||
-- to a revoked key and costs a lot of time to tell apart.
|
||
--
|
||
-- The bearer token is NOT here on purpose: it is per-account, so fetchJSON
|
||
-- copies this table and injects it per request. An earlier version passed this
|
||
-- table straight through and every endpoint answered 401 — which reads exactly
|
||
-- like a revoked key, and sent me looking at the account instead of the plugin.
|
||
local CLI_VERSION = "1.54.2"
|
||
local REQUIRED_HEADERS = {
|
||
["x-command-code-version"] = CLI_VERSION,
|
||
["x-cli-environment"] = "production",
|
||
["User-Agent"] = "commandcode-quota/" .. CLI_VERSION,
|
||
}
|
||
|
||
-- Accounts are handed in from the page (which reads /api/sources and its own
|
||
-- key store). The kernel deliberately does NOT let a plugin read the config, so
|
||
-- this is the only channel — which also means the key never sits in Lua state
|
||
-- or in the persisted plugin file.
|
||
-- Monthly credit caps per planId.
|
||
--
|
||
-- The credits endpoint reports what is LEFT but never the cap, so a percentage
|
||
-- is not derivable from it alone. The caps below come from the vendor's own
|
||
-- pricing page: every plan costs $10/month and grants a different credit pool
|
||
-- ($10 Go, $70 GOAT, $70 Pro, $70 Max). totalMonthlyCredits + monthlyCredits
|
||
-- also sums to the cap, so this table is a CROSS-CHECK on the plan, not the only
|
||
-- source — if the two disagree, PLAN_CAPS_WAIVED shows it instead of silently
|
||
-- displaying a wrong percentage.
|
||
--
|
||
-- A planId absent from this table renders the balance with no percentage and no
|
||
-- bar. Guessing a cap for an unknown plan would be worse than showing none.
|
||
local PLAN_CAPS = {
|
||
["individual-go"] = 10,
|
||
["individual-goat"] = 70,
|
||
["individual-pro"] = 70,
|
||
["individual-max"] = 70,
|
||
["team-goat"] = 70,
|
||
}
|
||
|
||
local ACCOUNTS = {}
|
||
|
||
-- ---------------------------------------------------------------- helpers
|
||
|
||
|
||
local function tonum(v)
|
||
return tonumber(v) or 0
|
||
end
|
||
|
||
-- usd formats a dollar amount the way the vendor's panel does: two decimals,
|
||
-- but never a bare "0" when there is a real fractional balance left. A $0.0811
|
||
-- balance rounds to $0.08, and showing "$0" there would read as "nothing left".
|
||
|
||
|
||
-- fetchJSON performs one request and returns (decodedTable, nil) or
|
||
-- (nil, errorMessage). It NEVER raises: an escaping error inside a plugin hook
|
||
-- can take the whole gateway down (see the hook-guard note in the kernel), so
|
||
-- every failure has to arrive as an ordinary value.
|
||
local function fetchJSON(path, key)
|
||
-- Copy, then add the token: mutating the shared table would leak one
|
||
-- account's key into every later request.
|
||
local headers = {}
|
||
for k, v in pairs(REQUIRED_HEADERS) do headers[k] = v end
|
||
headers["Authorization"] = "Bearer " .. tostring(key or "")
|
||
local res, err = __llmsproxy_http({
|
||
url = "https://" .. API_HOST .. path,
|
||
method = "GET",
|
||
headers = headers,
|
||
timeout_ms = 12000,
|
||
})
|
||
if res == nil then
|
||
return nil, tostring(err or "request failed")
|
||
end
|
||
-- Body arrives as a JSON string; the kernel's json.decode is the only parser
|
||
-- available and it raises on malformed input, so it is pcall'd here too.
|
||
local ok, decoded = pcall(json.decode, res.body)
|
||
if not ok then
|
||
return nil, "bad JSON from " .. path
|
||
end
|
||
return decoded, nil
|
||
end
|
||
|
||
-- authFailure recognises the one error that means "this key is not usable"
|
||
-- rather than "this endpoint is unhappy". Quota exhaustion and a revoked key
|
||
-- both surface as 401 here, and they need opposite operator responses.
|
||
|
||
-- ---------------------------------------------------------------- account report
|
||
|
||
-- collectAccount gathers one account's numbers. Each endpoint is independent:
|
||
-- credits is the one that matters, and the rest enrich it. A failure is
|
||
-- recorded per-endpoint instead of aborting, because "I could not read the
|
||
-- subscription" and "the account has no quota" must not look the same.
|
||
local function collectAccount(acct)
|
||
local report = {
|
||
name = acct.name,
|
||
label = acct.label,
|
||
failures = {},
|
||
checked_at = os.time(),
|
||
}
|
||
|
||
local who, werr = fetchJSON("/alpha/whoami", acct.key)
|
||
if who == nil then
|
||
report.failures[#report.failures + 1] = "whoami: " .. tostring(werr)
|
||
elseif type(who) == "table" and type(who.user) == "table" then
|
||
report.email = who.user.email
|
||
report.user = who.user.name or who.user.userName
|
||
end
|
||
|
||
local creds, cerr = fetchJSON("/alpha/billing/credits", acct.key)
|
||
local rawStatus = 0
|
||
if creds == nil then
|
||
report.failures[#report.failures + 1] = "credits: " .. tostring(cerr)
|
||
else
|
||
local c = creds.credits or {}
|
||
local wl = creds.windowLimits or {}
|
||
report.monthly = tonum(c.monthlyCredits)
|
||
report.purchased = tonum(c.purchasedCredits)
|
||
report.free = tonum(c.freeCredits)
|
||
report.total = report.monthly + report.purchased + report.free
|
||
-- belowThreshold is the vendor's own "running low" flag, distinct from
|
||
-- whether any balance remains: an account can hold $3 and still be below
|
||
-- their configured threshold.
|
||
report.below_threshold = c.belowThreshold == true
|
||
report.threshold = tonum(c.creditThreshold)
|
||
report.limited = wl.limited == true
|
||
report.exceeded = wl.exceeded
|
||
report.fiveHour = wl.fiveHour
|
||
report.weekly = wl.weekly
|
||
rawStatus = 200
|
||
end
|
||
|
||
local subs, serr = fetchJSON("/alpha/billing/subscriptions", acct.key)
|
||
if subs == nil then
|
||
report.failures[#report.failures + 1] = "subscriptions: " .. tostring(serr)
|
||
elseif type(subs) == "table" and type(subs.data) == "table" then
|
||
report.plan_status = subs.data.status
|
||
report.plan_id = subs.data.planId
|
||
report.plan_cap = PLAN_CAPS[tostring(subs.data.planId or "")]
|
||
report.period_start = subs.data.currentPeriodStart
|
||
report.period_end = subs.data.currentPeriodEnd
|
||
report.cancel_at_period_end = subs.data.cancelAtPeriodEnd == true
|
||
local md = subs.data.metadata
|
||
if type(md) == "table" then
|
||
report.plan_name = md.plan or md.name or md.tier
|
||
end
|
||
end
|
||
|
||
local usage, uerr = fetchJSON("/alpha/usage/summary", acct.key)
|
||
if usage == nil then
|
||
report.failures[#report.failures + 1] = "usage: " .. tostring(uerr)
|
||
elseif type(usage) == "table" then
|
||
report.requests = tonum(usage.totalCount)
|
||
report.spent = tonum(usage.totalCost)
|
||
report.tokens = tonum(usage.totalTokens)
|
||
report.success_rate = tonum(usage.successRate)
|
||
-- usage/summary counts over the BILLING PERIOD, so this pairs with the
|
||
-- monthly allowance rather than with the rolling 5h/weekly windows.
|
||
report.monthly_used = tonum(usage.totalMonthlyCredits)
|
||
report.period_basis = usage.periodBasis
|
||
end
|
||
|
||
-- Cross-check the configured cap against what the vendor spent plus what is
|
||
-- left. A mismatch means PLAN_CAPS drifted from the real plan (a price change,
|
||
-- or a planId this table does not know) — surfaced rather than papered over,
|
||
-- because a confidently wrong "99% used" is the worst possible reading here.
|
||
if report.plan_cap and report.monthly_used and report.monthly_used > 0 then
|
||
local implied = report.monthly_used + report.total
|
||
local drift = math.abs(implied - report.plan_cap)
|
||
if drift > (report.plan_cap * 0.02 + 0.5) then
|
||
report.cap_mismatch = true
|
||
report.implied_cap = implied
|
||
end
|
||
end
|
||
|
||
-- An account whose every endpoint failed is an auth problem, not a data
|
||
-- problem: state it plainly so nobody reads "—" as "zero quota".
|
||
if #report.failures >= 3 and report.total == nil then
|
||
report.auth_failed = true
|
||
end
|
||
return report
|
||
end
|
||
|
||
-- ---------------------------------------------------------------- page
|
||
|
||
PLUGIN.ui = {
|
||
page = {
|
||
page_id = "ccquota",
|
||
title = "CC Quota",
|
||
order = 45,
|
||
mount = [==[
|
||
<div id="ccq-root" class="ccq-root">
|
||
<div class="ccq-head">
|
||
<div>
|
||
<div class="ccq-title">Command Code 额度</div>
|
||
<div class="ccq-sub" id="ccq-sub">点击刷新以拉取各账户余量</div>
|
||
</div>
|
||
<div class="grow"></div>
|
||
<button class="small" id="ccq-refresh">刷新</button>
|
||
<span class="ccq-auto"></span>
|
||
</div>
|
||
<div id="ccq-list"><div class="ccq-empty">尚未拉取</div></div>
|
||
</div>
|
||
<style>
|
||
#ccq-root{padding:16px;min-width:0;max-width:100%;overflow-x:auto}
|
||
.ccq-head{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;flex-wrap:wrap}
|
||
.ccq-title{font-size:15px;font-weight:600}
|
||
.ccq-sub{font-size:12px;opacity:.66;margin-top:2px}
|
||
.ccq-auto{font-size:12px;opacity:.6}
|
||
.ccq-acc{border:1px solid var(--bd,rgba(127,127,127,.25));border-radius:10px;padding:12px 14px;margin:0 0 12px;min-width:0}
|
||
.ccq-acc.err{border-color:rgba(220,80,80,.5)}
|
||
.ccq-acc-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 4px}
|
||
.ccq-acc-n{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}
|
||
.ccq-mail{font-size:12px;opacity:.66;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.ccq-pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.85;white-space:nowrap}
|
||
.ccq-pill.ok{color:#15803d;border-color:rgba(21,128,61,.45)}
|
||
.ccq-pill.low{color:#b45309;border-color:rgba(180,83,9,.45)}
|
||
.ccq-pill.out{color:#b91c1c;border-color:rgba(185,28,28,.45)}
|
||
.ccq-bal{display:flex;align-items:baseline;gap:8px;margin:6px 0 10px;flex-wrap:wrap}
|
||
.ccq-bal-n{font-size:20px;font-weight:600}
|
||
.ccq-bal-l{font-size:12px;opacity:.66}
|
||
.ccq-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 18px}
|
||
.ccq-win{min-width:0}
|
||
.ccq-win-h{display:flex;justify-content:space-between;gap:6px;font-size:12px;opacity:.85}
|
||
.ccq-win-n{font-variant-numeric:tabular-nums;font-weight:600}
|
||
.ccq-bar{height:6px;border-radius:3px;background:rgba(127,127,127,.22);overflow:hidden;margin:4px 0 2px}
|
||
.ccq-bar-fill{height:100%;background:#3b82f6;border-radius:3px}
|
||
.ccq-bar-fill.warn{background:#d97706}
|
||
.ccq-bar-fill.over{background:#dc2626}
|
||
.ccq-win-d{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
|
||
.ccq-reset{opacity:.6;margin-left:4px}
|
||
.ccq-meta{display:flex;gap:14px;flex-wrap:wrap;font-size:11.5px;opacity:.7;margin-top:10px}
|
||
.ccq-fail{font-size:11.5px;color:#b91c1c;margin-top:8px;word-break:break-word}
|
||
.ccq-empty{opacity:.6;font-size:13px;padding:8px 0}
|
||
.ccq-win-nodat{opacity:.5}
|
||
.ccq-warn-line{font-size:11px;color:#b45309;margin-top:3px}
|
||
</style>
|
||
<script>
|
||
(function(){
|
||
var HOST = "api.commandcode.ai";
|
||
var lastReports = [];
|
||
|
||
function escH(s){
|
||
return String(s==null?"":s).replace(/[&<>"]/g,function(c){
|
||
return c==="&"?"&":c==="<"?"<":c===">"?">":""";
|
||
});
|
||
}
|
||
function usd(v){
|
||
v = Number(v)||0;
|
||
if (v<=0) return "$0.00";
|
||
return "$"+(v<0.01?v.toFixed(4):v.toFixed(2));
|
||
}
|
||
function stateOf(r){
|
||
if (r.auth_failed) return {cls:"err", txt:"认证失败"};
|
||
if (r.exceeded) return {cls:"out", txt:"已超额"};
|
||
if (r.below_threshold) return {cls:"low", txt:"低于阈值"};
|
||
var t = Number(r.total)||0;
|
||
if (t<=0) return {cls:"out", txt:"无余额"};
|
||
if (t<5) return {cls:"low", txt:"余额偏低"};
|
||
return {cls:"ok", txt:r.plan_status||"正常"};
|
||
}
|
||
// month renders the monthly allowance. Unlike the rolling windows this one is
|
||
// NOT capped by the vendor: credits reports what is left and usage reports what
|
||
// was spent, so the cap comes from the plan table (see PLAN_CAPS) and is
|
||
// cross-checked server-side. With no cap the balance is shown without a bar,
|
||
// because an invented denominator would be worse than none.
|
||
function month(r){
|
||
var used=Number(r.monthly_used)||0, cap=Number(r.plan_cap)||0;
|
||
var left=Number(r.total)||0;
|
||
var h='<div class="ccq-win"><div class="ccq-win-h"><span>每月';
|
||
if(r.period_end){
|
||
var d=Math.floor((new Date(r.period_end)-Date.now())/86400000);
|
||
if(d>0) h+=' <span class="ccq-reset" title="'+escH(r.period_end)+'">↻'+d+'d</span>';
|
||
}
|
||
h+='</span><span class="ccq-win-n">';
|
||
if(!(cap>0)){ h+='<span class="ccq-win-nodat" title="未知套餐额度">—</span></span>'; }
|
||
else{
|
||
var pctv=Math.floor(used/cap*100+0.5);
|
||
h+=pctv+'%</span></div>'+
|
||
'<div class="ccq-bar"><div class="ccq-bar-fill'+(pctv>=100?" over":(pctv>=80?" warn":""))+
|
||
'" style="width:'+Math.min(100,pctv)+'%"></div></div>'+
|
||
'<div class="ccq-win-d">$'+used.toFixed(2)+' / $'+cap.toFixed(0)+
|
||
' · 剩 '+usd(left)+'</div>';
|
||
if(r.cap_mismatch){
|
||
h+='</div><div class="ccq-warn-line" title="已用+剩余与套餐额度不符,比例仅供参考">⚠ 额度口径可能有变</div>';
|
||
} else { h+='</div>'; }
|
||
return h;
|
||
}
|
||
return h+'<div class="ccq-win-d">已用 $'+used.toFixed(2)+' · 剩 '+usd(left)+'</div></div>';
|
||
}
|
||
function win(label,w){
|
||
if(!w) return "";
|
||
var used=Number(w.used)||0, cap=Number(w.cap)||0;
|
||
var p = cap>0 ? Math.floor(used/cap*100+0.5) : null;
|
||
if(p===null) return "";
|
||
var over = w.exceeded===true;
|
||
if(over) p=100;
|
||
var cls = "ccq-bar-fill"+(over||p>=100?" over":(p>=80?" warn":""));
|
||
var reset="";
|
||
if(Number(w.resetAt)>0){
|
||
var s=Math.floor(w.resetAt/1000-Date.now()/1000);
|
||
if(s>0){
|
||
var d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
|
||
reset=(d>0?d+"d "+h+"h":(h>0?h+"h "+m+"m":m+"m"));
|
||
}
|
||
}
|
||
return '<div class="ccq-win"><div class="ccq-win-h"><span>'+escH(label)+
|
||
(reset?' <span class="ccq-reset">↻'+escH(reset)+'</span>':'')+
|
||
'</span><span class="ccq-win-n">'+p+'%</span></div>'+
|
||
'<div class="ccq-bar"><div class="'+cls+'" style="width:'+p+'%"></div></div>'+
|
||
'<div class="ccq-win-d">'+used.toLocaleString()+' / '+cap.toLocaleString()+'</div></div>';
|
||
}
|
||
function card(r){
|
||
var st = stateOf(r);
|
||
var h = '<div class="ccq-acc'+((r.auth_failed||(r.failures&&r.failures.length===4))?" err":"")+'">'+
|
||
'<div class="ccq-acc-h"><span class="ccq-acc-n">'+escH(r.name||"")+'</span>'+
|
||
(r.email?'<span class="ccq-mail">'+escH(r.email)+'</span>':'')+
|
||
'<span class="grow"></span>'+
|
||
'<span class="ccq-pill '+st.cls+'">'+escH(st.txt)+'</span></div>'+
|
||
'<div class="ccq-bal"><span class="ccq-bal-n">'+usd(r.total)+
|
||
'</span><span class="ccq-bal-l">月度余额'+
|
||
(Number(r.purchased)>0?'(含充值 $'+Number(r.purchased).toFixed(2)+')':'')+
|
||
'</span></div>'+
|
||
'<div class="ccq-grid">'+
|
||
month(r)+win("5 小时", r.fiveHour)+win("每周", r.weekly)+
|
||
'</div>';
|
||
var meta=[];
|
||
if(r.plan_id) meta.push("套餐 "+r.plan_id.replace(/^individual-/,""));
|
||
if(r.cancel_at_period_end) meta.push("到期不续费");
|
||
if(r.period_end) meta.push("周期至 "+String(r.period_end).slice(0,10));
|
||
if(r.requests) meta.push(r.requests.toLocaleString()+" 次请求");
|
||
if(r.spent) meta.push("累计 "+usd(r.spent));
|
||
if(r.tokens) meta.push((r.tokens/1e9).toFixed(2)+"B tokens");
|
||
if(r.success_rate) meta.push("成功率 "+r.success_rate.toFixed(2)+"%");
|
||
if(meta.length) h+='<div class="ccq-meta">'+meta.map(escH).join(" · ")+'</div>';
|
||
if(r.failures&&r.failures.length){
|
||
h+='<div class="ccq-fail">部分接口失败:'+r.failures.map(escH).join(";")+'</div>';
|
||
}
|
||
return h+"</div>";
|
||
}
|
||
function render(reports){
|
||
lastReports = reports||[];
|
||
var host=document.getElementById("ccq-list");
|
||
if(!host) return;
|
||
if(!lastReports.length){host.innerHTML='<div class="ccq-empty">没有可用的 Command Code 源</div>';return;}
|
||
host.innerHTML=lastReports.map(card).join("");
|
||
var sub=document.getElementById("ccq-sub");
|
||
if(sub) sub.textContent = lastReports.length+" 个账户 · "+
|
||
lastReports.filter(function(r){return r.auth_failed;}).length+" 个认证失败";
|
||
}
|
||
async function refresh(){
|
||
var srcs;
|
||
try{
|
||
var r=await fetch("/api/sources",{credentials:"same-origin"});
|
||
srcs=(await r.json()).sources||[];
|
||
}catch(e){
|
||
var s0=document.getElementById("ccq-sub"); if(s0) s0.textContent="拉取源列表失败: "+e.message;
|
||
render([]); return;
|
||
}
|
||
// Every source on the Command Code host is one account, keyed by its own
|
||
// API key — two keys on that host are two seats, and both must be listed.
|
||
var accts=srcs.filter(function(s){
|
||
try{ return new URL(s.base_url).hostname===HOST; }catch(e){ return false; }
|
||
});
|
||
if(!accts.length){ render([]); return; }
|
||
var btn=document.getElementById("ccq-refresh");
|
||
if(btn) btn.disabled=true;
|
||
var auto=document.getElementById("ccq-auto")||document.querySelector(".ccq-auto");
|
||
if(auto) auto.textContent="拉取中…";
|
||
if(!window.pluginAPI || typeof pluginAPI.call!=="function"){
|
||
var sb=document.getElementById("ccq-sub");
|
||
if(sb) sb.textContent="pluginAPI.call 不可用(内核版本过旧)";
|
||
render([]); return;
|
||
}
|
||
var out=[];
|
||
var errs=0;
|
||
// One account at a time: the endpoints are independent, and a sequential
|
||
// loop means a hung vendor endpoint delays only its own row instead of
|
||
// blocking the whole page behind one promise.
|
||
for (var i=0;i<accts.length;i++){
|
||
var a=accts[i];
|
||
try{
|
||
var rep = await pluginAPI.call("ccquota","collect_account",
|
||
{name:a.name,label:a.name,key:a.api_key});
|
||
out.push(rep||{name:a.name,failures:["no result"]});
|
||
}catch(e){
|
||
errs++;
|
||
out.push({name:a.name,failures:[String(e.message||e)],auth_failed:true});
|
||
}
|
||
}
|
||
render(out);
|
||
if(btn) btn.disabled=false;
|
||
if(auto) auto.textContent="更新于 "+new Date().toLocaleTimeString();
|
||
try{ await pluginAPI.postState("ccquota",{reports:out,updated_at:Date.now()}); }catch(e){}
|
||
}
|
||
// The mount is injected long after DOMContentLoaded has already fired, so
|
||
// listening for that event here registered a handler for an event that had
|
||
// already been dispatched — the page rendered but stayed empty until a manual
|
||
// refresh. Wiring the button and fetching immediately is what makes the page
|
||
// populate on first open.
|
||
// The plugin mount is injected by the kernel and its <script> evaluated after
|
||
// injection, but that still happens during the UI's own boot — and the page
|
||
// body is rebuilt whenever the tab is opened. Two consequences:
|
||
//
|
||
// - A DOMContentLoaded listener never fires (the event already passed), so
|
||
// the page rendered empty until a manual refresh.
|
||
// - Wiring the refresh button once at script-eval time silently loses the
|
||
// handler when the tab's DOM is replaced.
|
||
//
|
||
// pluginAPI.onTabShown is the kernel's answer to both: the callback runs when
|
||
// the tab is actually shown, with the DOM in place. It also runs once for the
|
||
// already-visible tab at boot, so this single registration covers first paint
|
||
// AND every later re-entry.
|
||
function mount(){
|
||
var b=document.getElementById("ccq-refresh");
|
||
// The tab's DOM is rebuilt on re-entry, so a handler wired against the old
|
||
// node is gone. The flag lives on the NODE, not in a closure variable, which
|
||
// is what makes re-wiring safe.
|
||
if(b && !b.__ccqWired){ b.__ccqWired = true; b.addEventListener("click",refresh); }
|
||
}
|
||
// Run once immediately — the billing page does the same, and waiting only for
|
||
// onTabShown leaves the page blank when the tab is already open at boot.
|
||
mount();
|
||
refresh();
|
||
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(mount);
|
||
window.__ccqRender = render;
|
||
window.__ccqRefresh = refresh;
|
||
})();
|
||
</script>
|
||
]==],
|
||
},
|
||
}
|
||
|
||
-- exports is what makes collect_account reachable from the plugin's own page.
|
||
-- Without this list the call endpoint refuses it: the endpoint is an HTTP
|
||
-- surface into the Lua state, so "which functions may it reach" has to be the
|
||
-- plugin's explicit answer rather than a default.
|
||
PLUGIN.exports = { "collect_account" }
|
||
|
||
-- collect_account gathers one account's report. It returns the report TABLE (not
|
||
-- a string) so the page receives structured numbers and booleans instead of
|
||
-- having to parse anything back out of a formatted blob.
|
||
PLUGIN.collect_account = function(acct)
|
||
if type(acct) ~= "table" then return nil, "expected account table" end
|
||
if acct.key == nil or acct.key == "" then
|
||
return { name = tostring(acct.name or ""), failures = { "missing api key" },
|
||
auth_failed = true }
|
||
end
|
||
local rep = collectAccount({
|
||
name = acct.name, label = acct.label, key = acct.key,
|
||
})
|
||
return rep, nil
|
||
end
|
||
|
||
return PLUGIN |