fix(plugins): 插件 Lua 报错不再拖垮网关(生产事故修复)

## 事故

13:37 部署后线上 6 次 SIGSEGV 崩溃循环,8081 完全不可用,用户报大量
connect error。崩溃点固定在 internal/lua/plugins.go:invoke → L.Call →
golua StackTrace 里的 lua_getinfo。

## 根因(不是并发/GC/锁)

golua 的 callEx 在**任何** pcall 失败后无条件执行 L.StackTrace(),而
StackTrace 调 lua_getinfo,这个 LuaJIT 构建在栈够深时(带 AUTO 链轨迹的
request_end payload 正好够深)直接段错误。这是 C 层信号,Go 无法 recover,
所以一个插件的脚本错误就能带走整个进程和所有在途请求。

触发错误来自我上一轮加的 billing 日级维度:

    add(bucket(bucket(bucket(s.by_day_src, dk), payload.source)), ...)

三个 bucket( 只对应两个 ),最外层 bucket() 只收到一个参数,k=nil,于是
billing.lua:141 `tbl[k] = b` 抛 "table index is nil",**每个请求都抛**。

同时还有第二个 bug:中间层用了 bucket()(返回 emptyBucket,含 cost/requests
字段)当作嵌套容器,结构也是错的。改为 dayMap() 返回纯表。

## 修法

1. billing.lua:修正括号,多层容器改用 dayMap()。
2. **pcall 守卫**(真正的架构修复):在 setupGlobals 里注册
   __llmsproxy_call_hook,钩子改为经它调用。

       function __llmsproxy_call_hook(fn, payload)
         local ok, res = pcall(fn, payload)
         if not ok then return nil, tostring(res) end
         return res, nil
       end

   Lua 侧 pcall 在 golua 看到非零 pcall 状态之前就拦下错误,C 栈回溯路径
   永远进不去。错误变成普通返回值 (nil, msg),Go 侧记进 hook_errors 并跳过
   ——"插件出错不影响请求转发"这条承诺对脚本错误也终于成立,而不只是对 Go panic。

## 这同时修掉了那个查了很久的间歇崩溃

同一个机制解释了此前 8/20 复现、却查不出根因的 SIGSEGV(怀疑过 janitor 竞态、
GC、LuaJIT 全局状态、VM 释放时序,全部排除)。实测对比:

  TestBillingPrecedence   修复前 8/20 崩溃 → 修复后 0/20
  并发建 16 个 VM 的探针   修复前 3/3  崩溃 → 修复后 0/6
  全量 ./...              连跑 5 次全绿

那些崩溃本来就是一个 Lua 钩子错误在栈深时炸掉 StackTrace,时机随机所以看着
像并发问题。

## 判据

TestHookThatRaisesDoesNotCrashTheProcess:装一个每请求必崩的插件,连打 50 次,
断言进程存活 + 错误被记录 + 同状态里健康的 billing 插件照常工作。
3 个变异(守卫不 pcall / 守卫名写错 / 守卫未注册)全部被捕获,其中第一个直接
让 SIGSEGV 重现,说明守卫就是唯一防线。

## 线上验证

往生产插件目录放一个每请求必然报错的插件,连打 30 个真实流式请求:

  30× HTTP 200,SIGSEGV 0 次
  hook_errors 记录 count=44 且指名 zbroken-test(可观测)
  billing 照常累计(2999 请求 / $0.5668)

测试插件已移除。

回滚点:/usr/local/bin/llmsproxy.bak-real-<TS>、billing.lua.bak-real-<TS>。
This commit is contained in:
JianFeeeee
2026-10-02 14:07:44 +08:00
parent fe0764e375
commit d9652f479a
8 changed files with 937 additions and 38 deletions

View File

@ -143,6 +143,23 @@ local function bucket(tbl, k)
return b
end
-- dayMap returns the plain per-day SUB-TABLE of a nested dimension map, e.g.
-- by_day_src[day] -> { source -> bucket }.
--
-- bucket() cannot be reused for this level. It returns a BUCKET, so the
-- per-day container would come back carrying cost/requests/prompt_tokens keys
-- of its own, with the real source entries mixed in beside them. The period
-- view reads by_day_src[day] as "name -> bucket" and would then fold the
-- container's own fields as if they were sources.
local function dayMap(tbl, dk)
local m = tbl[dk]
if m == nil then
m = {}
tbl[dk] = m
end
return m
end
local function add(b, cost, prompt, completion, ok, cacheHit, cacheReported)
b.cost = b.cost + cost
b.requests = b.requests + 1
@ -469,13 +486,13 @@ function plugin.on_request_end(payload)
local dk = dayKey(ts)
add(bucket(s.by_day, dk), cost, prompt, completion, ok, C, R)
if payload.source ~= nil and payload.source ~= "" then
add(bucket(bucket(bucket(s.by_day_src, dk), payload.source)), cost, prompt, completion, ok, C, R)
add(bucket(dayMap(s.by_day_src, dk), payload.source), cost, prompt, completion, ok, C, R)
end
if payload.model ~= nil and payload.model ~= "" then
add(bucket(bucket(bucket(s.by_day_model, dk), payload.model)), cost, prompt, completion, ok, C, R)
add(bucket(dayMap(s.by_day_model, dk), payload.model), cost, prompt, completion, ok, C, R)
end
if payload.key ~= nil and payload.key ~= "" then
add(bucket(bucket(bucket(s.by_day_key, dk), payload.key)), cost, prompt, completion, ok, C, R)
add(bucket(dayMap(s.by_day_key, dk), payload.key), cost, prompt, completion, ok, C, R)
end
end
return nil -- last stage: nobody downstream would read a return value
@ -899,4 +916,521 @@ plugin.ui = {
},
}
-- 第二页:计费规则编辑。
--
-- 与统计页分开,而不是塞进同一页的标签里:规则是可编辑的配置,统计是只读的
-- 结果,混在一个页面里会让"改数字"和"看数字"的边界变模糊。
-- pages is built by APPENDING. Writing plugin.ui.pages[2] instead makes the
-- table sparse (index 2 with no 1, 2), and Lua's tojson/JSON conversion then
-- emits an OBJECT {"2": {...}} instead of an array — which the Go side decodes
-- to nothing at all. The plugin then loaded with UI == nil and no error
-- anywhere: the totals page, the status tile and this editor all silently
-- vanished. First-wins on page_id is enforced Go-side, so appending is safe.
plugin.ui.pages = plugin.ui.pages or {}
table.insert(plugin.ui.pages, {
page_id = "billing-rules",
title = "Billing rules",
icon = [==[<svg viewBox="0 0 24 24"><path d="M4 7h10M18 7h2M4 12h4M12 12h8M4 17h8M16 17h4"/><path d="M14 5l2 2-2 2M10 10l-2 2 2 2M12 15l2 2-2 2"/></svg>]==],
order = 41,
mount = [==[
<div id="br-root" style="padding:16px;min-width:0;max-width:100%">
<div id="br-body"><div class="muted">…</div></div>
</div>
<script>
(function () {
var API = "/api/plugins/billing/rules";
var STR = {
en: {
title: "Billing rules", profile: "Profile", newProfile: "New profile",
addRule: "Add rule", delRule: "Delete", exportYaml: "Export YAML",
url: "Source URL", mode: "Mode", prompt: "Prompt $/M",
completion: "Completion $/M", currency: "Currency",
monthly: "Monthly fee", peakMult: "Peak multiplier",
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
models: "Per-model prices", addModel: "+ model",
mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced",
noProfile: "No profile yet — create one to start pricing.",
noRules: "No rules in this profile",
warnNoSource: "matches no configured source",
active: "ACTIVE", setActive: "Set active",
saving: "saving…", saved: "Saved", rejected: "Rejected", save: "Save",
newProfileId: "New profile id",
needUrl: "a rule needs a URL", needPrices: "every model needs both prices",
badPeakHours: "peak hours look like 1-4,6-10 (UTC, 0-23)",
badPeakDays: "peak days are UTC weekday numbers 0-6",
peakNeedsMult: "peak hours need a multiplier",
adminOnly: "Billing rules are admin-only.",
activePrices: "Prices now in effect",
},
zh: {
title: "计费规则", profile: "方案", newProfile: "新建方案",
addRule: "新增规则", delRule: "删除", exportYaml: "导出 YAML",
url: "源 URL", mode: "计费模式", prompt: "输入 $/M",
completion: "输出 $/M", currency: "币种",
monthly: "月费", peakMult: "峰段倍数",
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
models: "按模型价格", addModel: "+ 模型",
mFree: "free 免费", mToken: "token 按量",
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
noProfile: "还没有方案 —— 新建一个开始定价。",
noRules: "该方案暂无规则",
warnNoSource: "匹配不到已配置的源",
active: "生效中", setActive: "设为生效",
saving: "保存中…", saved: "已保存", rejected: "被拒绝", save: "保存",
newProfileId: "新方案 id",
needUrl: "规则必须有 URL",
needPrices: "每个模型都要填输入和输出价格",
badPeakHours: "峰段小时应形如 1-4,6-10(UTC,0-23)",
badPeakDays: "峰段星期应为 UTC 星期编号 0-6",
peakNeedsMult: "填了峰段小时就要同时填倍数",
adminOnly: "计费规则仅限管理员。",
activePrices: "当前生效的价格",
},
};
function L() {
var lang = (window.pluginAPI && pluginAPI.lang) === "en" ? "en" : "zh";
return STR[lang] || STR.zh;
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
function call(method, body) {
return fetch(API, {
method: method,
credentials: "same-origin",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
}).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (j) {
if (!r.ok) throw new Error((j.error && j.error.message) || ("HTTP " + r.status));
return j;
});
});
}
var DATA = null;
// ---- peak window parsing -------------------------------------------------
//
// Parsed here as well as server-side so the operator is told WHICH row is
// wrong instead of getting a 400 that names a field. A malformed peak window
// that is silently dropped would bill peak traffic at off-peak rates, which
// is the failure this whole page exists to make impossible.
function parseHours(txt) {
txt = String(txt || "").trim();
if (!txt) return [];
var out = [];
var parts = txt.split(",");
for (var i = 0; i < parts.length; i++) {
var m = String(parts[i]).trim().match(/^(\d{1,2})\s*-\s*(\d{1,2})$/);
if (!m) return null;
var lo = parseInt(m[1], 10), hi = parseInt(m[2], 10);
if (lo < 0 || hi > 23 || lo > hi) return null;
out.push([lo, hi]);
}
return out;
}
function parseDays(txt) {
txt = String(txt || "").trim();
if (!txt) return [];
var out = [];
var parts = txt.split(",");
for (var i = 0; i < parts.length; i++) {
var n = parseInt(String(parts[i]).trim(), 10);
if (isNaN(n) || n < 0 || n > 6) return null;
out.push(n);
}
return out;
}
function hoursText(p) {
if (!p || !p.hours) return "";
return p.hours.map(function (h) { return h[0] + "-" + h[1]; }).join(",");
}
function daysText(p) {
if (!p || !p.weekdays || !p.weekdays.length) return "";
return p.weekdays.join(",");
}
// ---- row rendering -------------------------------------------------------
function modelBlock(models, T) {
var keys = Object.keys(models || {});
var rows = keys.map(function (m) {
var t = models[m] || {};
return "<div class='row' style='gap:6px;margin:3px 0'>" +
"<input class='m-name' value='" + esc(m) + "' style='flex:1;min-width:80px'>" +
"<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) +
"' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
"' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
"<button class='ghost small m-del' title='" + esc(T.delRule) + "'>&times;</button></div>";
}).join("");
return "<div class='m-rows'>" + rows + "</div>" +
"<button class='ghost small m-add'>" + esc(T.addModel) + "</button>";
}
function ruleRow(rule, known, T) {
var unknown = rule.url !== "*" && known.indexOf(rule.url) < 0;
var opts = [["free", T.mFree], ["token", T.mToken], ["subscription", T.mSub], ["unpriced", T.mUnpriced]]
.map(function (o) {
return "<option value='" + o[0] + "'" + (rule.mode === o[0] ? " selected" : "") + ">" + esc(o[1]) + "</option>";
}).join("");
var p = rule.peak;
return "<tr class='br-rule' data-url='" + esc(rule.url || "") + "'>" +
"<td><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%'>" +
(unknown ? "<div style='color:#e0a33e;font-size:11px'>&#9888; " + esc(T.warnNoSource) + "</div>" : "") +
"</td>" +
"<td><select class='r-mode'>" + opts + "</select></td>" +
"<td style='width:74px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD'></td>" +
"<td style='width:92px'><input class='r-sub' type='number' step='0.01' value='" +
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "'></td>" +
"<td style='width:64px'><input class='r-mult' type='number' step='0.1' value='" +
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "'></td>" +
"<td style='width:104px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10'></td>" +
"<td style='width:74px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3'></td>" +
"<td style='min-width:210px'>" + modelBlock(rule.models, T) + "</td>" +
"<td><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>";
}
// ---- collecting one rule back out of the DOM ----------------------------
function collectRule(tr, T) {
var url = tr.querySelector(".r-url").value.trim();
if (!url) throw new Error(T.needUrl);
var mode = tr.querySelector(".r-mode").value;
var out = { url: url, mode: mode };
var cur = tr.querySelector(".r-cur").value.trim();
if (cur) out.currency = cur;
if (mode === "subscription") {
var sub = parseFloat(tr.querySelector(".r-sub").value);
if (!isNaN(sub)) out.subscription = sub;
}
var hoursTxt = tr.querySelector(".r-hours").value.trim();
var daysTxt = tr.querySelector(".r-days").value.trim();
var mult = parseFloat(tr.querySelector(".r-mult").value);
if (hoursTxt || daysTxt || (!isNaN(mult) && mult > 0)) {
if (isNaN(mult) || mult <= 0) throw new Error(T.peakNeedsMult);
var hours = parseHours(hoursTxt);
var days = parseDays(daysTxt);
if (hours === null) throw new Error(T.badPeakHours);
if (days === null) throw new Error(T.badPeakDays);
out.peak = { multiplier: mult, weekdays: days, hours: hours };
}
if (mode === "token") {
var models = {};
var bad = null;
tr.querySelectorAll(".m-rows .row").forEach(function (r) {
if (bad) return;
var n = r.querySelector(".m-name").value.trim();
if (!n) return;
var pv = r.querySelector(".m-p").value.trim();
var cv = r.querySelector(".m-c").value.trim();
// Strings, not floats: the price must round-trip through YAML and the
// config file unchanged. 0.15 stored as a float can serialize as
// 0.15000000000000002 and the operator would not recognize their own
// price on the next visit.
if (!pv || !cv || isNaN(parseFloat(pv)) || isNaN(parseFloat(cv))) {
bad = n;
return;
}
models[n] = { prompt: pv, completion: cv };
});
if (bad) throw new Error(T.needPrices + ": " + bad);
if (Object.keys(models).length) out.models = models;
}
return out;
}
function collectProfile(pEl, T) {
var rules = [];
pEl.querySelectorAll("tr.br-rule").forEach(function (tr) {
rules.push(collectRule(tr, T));
});
return rules;
}
// ---- YAML export ---------------------------------------------------------
//
// Hand-rolled because the export target is a config.yaml the operator will
// paste into (or diff against) the live file. Only the shapes this page owns
// are emitted: maps, string/number scalars, and arrays of maps.
function yamlValue(v, indent) {
var pad = new Array(indent + 1).join(" ");
if (Array.isArray(v)) {
if (!v.length) return "";
var out = [pad + " []"];
var body = [];
v.forEach(function (item) {
if (item && typeof item === "object") {
var keys = Object.keys(item);
keys.forEach(function (k, idx) {
var val = item[k];
var prefix = idx === 0 ? pad + " - " : pad + " ";
if (val && typeof val === "object") {
body.push(prefix + k + ":");
body.push(yamlValue(val, indent + 3));
} else {
body.push(prefix + k + ": " + scalar(val));
}
});
} else {
body.push(pad + " - " + scalar(item));
}
});
return body.filter(Boolean).join("\n");
}
if (v && typeof v === "object") {
var keys = Object.keys(v);
if (!keys.length) return pad + " {}";
var res = [];
keys.forEach(function (k) {
var val = v[k];
if (val === undefined || val === null) return;
if (val && typeof val === "object") {
res.push(pad + k + ":");
res.push(yamlValue(val, indent + 1));
} else {
res.push(pad + k + ": " + scalar(val));
}
});
return res.filter(Boolean).join("\n");
}
return pad + scalar(v);
}
function scalar(v) {
if (typeof v === "number") return String(v);
if (typeof v === "boolean") return v ? "true" : "false";
// Quote every string: prices are strings by contract, but quoting the
// numeric-looking ones too means a hand-edited file can never turn "0.15"
// into 0.15 and lose the exact representation on the next round-trip.
return JSON.stringify(String(v));
}
function exportText() {
var dsl = { profiles: [] };
var body = document.getElementById("br-body");
var T = L();
body.querySelectorAll("[data-profile]").forEach(function (pEl) {
var id = pEl.dataset.profile;
var rules = collectProfile(pEl, T);
dsl.profiles.push({ id: id, rules: rules });
});
dsl.active = DATA && DATA.active ? DATA.active : (dsl.profiles[0] || {}).id;
return "billing:\n" + yamlValue(dsl, 1) + "\n";
}
// ---- render --------------------------------------------------------------
function say(msg, err) {
var el = document.getElementById("br-msg");
if (!el) return;
el.textContent = msg || "";
el.style.color = err ? "#e07a7a" : "rgba(200,180,220,.75)";
}
function render() {
var T = L();
var body = document.getElementById("br-body");
if (!body) return;
var dsl = (DATA && DATA.billing) || null;
var profiles = (dsl && dsl.profiles) || [];
var active = (DATA && DATA.active) || (profiles[0] && profiles[0].id) || "";
var known = (DATA && DATA.urls) || [];
var warn = "";
if (DATA && DATA.warnings && DATA.warnings.length) {
warn = "<div style='color:#e0a33e;font-size:12px;margin:0 0 10px'>&#9888; " +
esc(DATA.warnings.join(" &middot; ")) + "</div>";
}
var dl = known.map(function (u) { return "<option value='" + esc(u) + "'>"; }).join("");
if (!profiles.length) {
body.innerHTML = warn + "<div class='muted' style='padding:10px 0'>" + esc(T.noProfile) + "</div>" +
"<p><button class='ghost' data-act='newprofile'>+ " + esc(T.newProfile) + "</button></p>";
wire();
return;
}
var html = warn + "<datalist id='br-urls'>" + dl + "</datalist>";
profiles.forEach(function (p) {
var rules = p.rules || [];
html += "<div data-profile='" + esc(p.id) + "' style='margin:0 0 18px'>" +
"<div class='row' style='align-items:center;gap:8px;margin:0 0 6px'>" +
"<strong style='font-size:14px'>" + esc(p.id) + "</strong>" +
(p.id === active ? "<span class='okc' style='font-size:11px'>" + esc(T.active) + "</span>"
: "<button class='ghost small p-active' data-pid='" + esc(p.id) + "'>" + esc(T.setActive) + "</button>") +
"<span class='grow'></span>" +
"<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" +
"</div>" +
"<div style='overflow-x:auto'><table style='width:100%;border-collapse:collapse;font-size:12px;min-width:900px'>" +
"<tr style='text-align:left;opacity:.65'>" +
"<th>" + esc(T.url) + "</th><th>" + esc(T.mode) + "</th><th>" + esc(T.currency) + "</th>" +
"<th>" + esc(T.monthly) + "</th><th>" + esc(T.peakMult) + "</th><th>" + esc(T.peakHours) + "</th>" +
"<th>" + esc(T.peakDays) + "</th><th>" + esc(T.models) + "</th><th></th></tr>" +
(rules.length
? rules.map(function (r) { return ruleRow(r, known, T); }).join("")
: "<tr><td colspan='9' class='muted' style='padding:8px 0'>" + esc(T.noRules) + "</td></tr>") +
"</table></div></div>";
});
html += "<p style='margin:6px 0 0'>" +
"<button class='ghost' data-act='newprofile'>+ " + esc(T.newProfile) + "</button> " +
"<button class='ghost' data-act='export'>" + esc(T.exportYaml) + "</button> " +
"<button data-act='save'>" + esc(T.save) + "</button></p>";
body.innerHTML = html;
wire();
}
function wire() {
var body = document.getElementById("br-body");
if (!body) return;
body.querySelectorAll(".m-add").forEach(function (b) {
b.onclick = function () {
var box = this.closest("td").querySelector(".m-rows");
var T = L();
var row = document.createElement("div");
row.className = "row";
row.style.cssText = "gap:6px;margin:3px 0";
row.innerHTML =
"<input class='m-name' placeholder='model' style='flex:1;min-width:80px'>" +
"<input class='m-p' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
"<input class='m-c' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
"<button class='ghost small m-del'>&times;</button>";
box.appendChild(row);
};
});
body.querySelectorAll(".m-del").forEach(function (b) {
b.onclick = function () { this.closest(".row").remove(); };
});
body.querySelectorAll(".r-del").forEach(function (b) {
b.onclick = function () {
var tr = this.closest("tr");
var pEl = tr.closest("[data-profile]");
var dsl = collectAll(pEl);
var idx = -1;
for (var i = 0; i < dsl.profiles.length; i++) {
if (dsl.profiles[i].id === pEl.dataset.profile) idx = i;
}
if (idx < 0) return;
dsl.profiles[idx].rules = dsl.profiles[idx].rules.filter(function (r) {
return r.url !== tr.dataset.url;
});
save(dsl);
};
});
body.querySelectorAll(".p-add").forEach(function (b) {
b.onclick = function () {
var pEl = b.closest("[data-profile]");
var dsl = collectAll(null);
var idx = -1;
for (var i = 0; i < dsl.profiles.length; i++) {
if (dsl.profiles[i].id === b.dataset.pid) idx = i;
}
if (idx < 0) return;
// Seed with the first configured URL: an empty url row would fail
// validation on save and the operator would not know why.
var first = ((DATA && DATA.urls) || [])[0] || "";
dsl.profiles[idx].rules.push({ url: first, mode: "free" });
save(dsl);
};
});
body.querySelectorAll(".p-active").forEach(function (b) {
b.onclick = function () {
var dsl = collectAll(null);
dsl.active = b.dataset.pid;
save(dsl);
};
});
var np = body.querySelector("[data-act='newprofile']");
if (np) np.onclick = newProfile;
var ex = body.querySelector("[data-act='export']");
if (ex) ex.onclick = doExport;
var sv = body.querySelector("[data-act='save']");
if (sv) sv.onclick = function () { save(collectAll(null)); };
}
function collectAll() {
var T = L();
var body = document.getElementById("br-body");
var dsl = { profiles: [] };
body.querySelectorAll("[data-profile]").forEach(function (pEl) {
dsl.profiles.push({ id: pEl.dataset.profile, rules: collectProfile(pEl, T) });
});
dsl.active = (DATA && DATA.active) || (dsl.profiles[0] || {}).id || "";
return dsl;
}
function save(dsl) {
var T = L();
say(T.saving);
call("PUT", { billing: dsl })
.then(function (j) {
DATA.billing = j.billing;
DATA.active = j.active;
DATA.warnings = j.warnings || [];
say(T.saved);
render();
// The billing page's totals come from these prices; refresh it so the
// operator sees the effect of the edit without hunting for the tab.
if (window.__billingRefresh) window.__billingRefresh();
})
.catch(function (e) { say(T.rejected + ": " + e.message, true); });
}
function newProfile() {
var dsl = collectAll();
var n = dsl.profiles.length + 1;
var id = "p" + n;
while (dsl.profiles.some(function (p) { return p.id === id; })) {
n++;
id = "p" + n;
}
dsl.profiles.push({ id: id, rules: [] });
if (!dsl.active) dsl.active = id;
save(dsl);
}
function doExport() {
var text = exportText();
var box = document.createElement("pre");
box.className = "configbox";
box.style.cssText = "margin-top:10px;max-height:46vh;overflow:auto;white-space:pre";
box.textContent = text;
var holder = document.getElementById("br-body");
var old = document.getElementById("br-export");
if (old) old.remove();
box.id = "br-export";
holder.appendChild(box);
var T = L();
var p = document.createElement("p");
p.style.margin = "6px 0 0";
p.innerHTML = "<button class='ghost small' id='br-dl'>" + esc(T.exportYaml) + " (.yaml)</button>";
holder.appendChild(p);
document.getElementById("br-dl").onclick = function () {
var blob = new Blob([text], { type: "text/yaml" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "billing-rules.yaml";
a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 2000);
};
}
function load() {
call("GET")
.then(function (j) {
DATA = { billing: j.billing || null, active: j.active, urls: j.urls || [], warnings: j.warnings || [] };
render();
})
.catch(function (e) {
var body = document.getElementById("br-body");
if (body) body.innerHTML = "<div class='muted'>" + esc(e.message) + "</div>";
});
}
render();
load();
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(load);
})();
</script>
]==],
})
return plugin