mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +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 层过滤意味着其它消费者照旧全量泄漏。
角色可见性回答不了"哪一部分"。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 正是这次要堵的泄露。
有些东西值得从浏览器**外面**查(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>
(cherry picked from commit a6c6e6e5c7)
(cherry picked from commit 5eef5ad635)
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,368 +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.
|
||||
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
|
||||
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_price = subs.data.metadata and nil
|
||||
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)
|
||||
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}
|
||||
</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:"已超额"};
|
||||
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||"正常"};
|
||||
}
|
||||
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">'+
|
||||
win("5 小时", r.fiveHour)+win("每周", r.weekly)+
|
||||
'</div>';
|
||||
var meta=[];
|
||||
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){ 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="拉取中…";
|
||||
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){}
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded",function(){
|
||||
var b=document.getElementById("ccq-refresh");
|
||||
if(b) b.addEventListener("click",refresh);
|
||||
refresh();
|
||||
});
|
||||
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