mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
feat(plugins): 可见性、state 分区、注册式 HTTP 端点 + 按密钥授权
插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。
## 可见性:插件声明默认值,网关可覆盖
UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。
覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。
过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。
## state 分区:让"看自己的账单"成为可能
角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。
不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。
## 注册式 HTTP 端点:Go 监听,Lua 处理
有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。
插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。
## 按密钥名授权
roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。
**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。
写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。
## 这个文件里第三次踩同一个坑
readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。
readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。
## 变异与判据
去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。
生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
@ -503,6 +503,24 @@ end
|
||||
-- A whole page. The kernel injects this HTML and evaluates the <script> after
|
||||
-- the DOM exists, and exposes `pluginAPI` for talking to the gateway.
|
||||
plugin.ui = {
|
||||
-- Readable by any role, because the state is SPLIT below: a user key sees
|
||||
-- its own spend and nothing else. Marking the page admin-only would make the
|
||||
-- split pointless; marking it "all" without the split is exactly the leak this
|
||||
-- closes — a user key pulled the whole 18KB ledger (16871 requests, $8.86,
|
||||
-- every source name) straight out of GET /state. The gateway's audience
|
||||
-- override can tighten this per plugin without editing this file.
|
||||
visibility = "all",
|
||||
-- Split the state: global figures are deployment data and are withheld from
|
||||
-- non-admin callers, while the per-key buckets describe ONE caller.
|
||||
-- depth: by_key is {maskedKey: bucket}; by_day_key is {day: {maskedKey: …}}.
|
||||
state_scope = {
|
||||
global = {
|
||||
"total", "by_source", "by_model", "by_day", "by_day_src",
|
||||
"by_day_model", "by_tier_served", "skip_reasons", "degraded_reqs",
|
||||
"unpriced_models", "unpriced_reqs", "started",
|
||||
},
|
||||
depth = { by_key = 1, by_day_key = 2 },
|
||||
},
|
||||
page = {
|
||||
page_id = "billing",
|
||||
title = "Billing",
|
||||
|
||||
@ -1,482 +0,0 @@
|
||||
-- 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
|
||||
@ -484,6 +484,11 @@ end
|
||||
-- ---------------------------------------------------------------- page
|
||||
|
||||
PLUGIN.ui = {
|
||||
-- Every row names an upstream host and what it has spent. That is deployment
|
||||
-- information, not per-caller information, so it is admin-only by default; an
|
||||
-- operator who wants a shared read-only view can widen it from config without
|
||||
-- editing this file.
|
||||
visibility = "admin",
|
||||
page = {
|
||||
page_id = "quota",
|
||||
title = "Quota",
|
||||
|
||||
Reference in New Issue
Block a user