diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua
index 7148279..191d62c 100644
--- a/internal/lua/plugins/quota.lua
+++ b/internal/lua/plugins/quota.lua
@@ -389,8 +389,8 @@ local function collectSource(src, budget)
-- A streak is a host that has failed repeatedly. Skip it outright until its
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
if backoffSeconds(seen, known.streak) then
- return { name = src.name, host = host, skipped = true,
- reason = "backing off after " .. tostring(known.streak) .. " failed probes" }
+ return { name = src.name, host = host, skipped = true, backing_off = true,
+ reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
end
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
@@ -577,8 +577,12 @@ PLUGIN.due_sources = function(payload)
local base = (s.base_url:gsub("/+$", ""))
local known = HOST_MEMO[hostOf(base)]
if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then
- backing[#backing + 1] = { name = s.name, host = hostOf(base),
- reason = "backing off after " .. tostring(known.streak) .. " failed probes" }
+ -- skipped matters: the page filters on it. Without the flag a backed-off
+ -- host reaches the card renderer, which has no field to match and falls
+ -- through to its "has data" default — so thirteen dead upstreams were
+ -- displayed as if they had answered.
+ backing[#backing + 1] = { name = s.name, host = hostOf(base), skipped = true, backing_off = true,
+ reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
else
due[#due + 1] = s
end
@@ -824,7 +828,14 @@ PLUGIN.ui = {
}
if(r.hard_limit!==undefined && r.hard_limit!==null)
return {cls:"ok", pill:'按量计费'};
- return {cls:"", pill:'有数据'};
+ // Nothing here matched. Say so.
+ //
+ // The old default was a neutral "有数据", which is a claim: it says the probe
+ // answered. A record that reached this point answered with no field the page
+ // knows — a back-off notice, an error shape we do not recognise, or a vendor
+ // that added a new response. Calling that "has data" is how thirteen dead
+ // upstreams ended up rendered as healthy. Absence of evidence is not evidence.
+ return {cls:"unknown", pill:'未探测'};
}
function card(r){
var st=state(r);
@@ -837,6 +848,13 @@ PLUGIN.ui = {
h+='
'+escH(r.error)+'
';
return h;
}
+ // A record with nothing but a reason has no balance, no month, no windows —
+ // the card renders as a bare header with an empty body. Say why instead.
+ if(r.reason && !r.error && (r.balance===undefined||r.balance===null)
+ && (r.hard_limit===undefined||r.hard_limit===null)){
+ h+=''+escH(r.reason)+'
';
+ return h+'';
+ }
h+=balLine(r);
var rows="";
rows+=month(r);
@@ -861,7 +879,10 @@ PLUGIN.ui = {
var rs = (st && st.reports) || [];
if(!rs.length) return false;
var real = rs.filter(function(r){ return r && !r.skipped; });
- var skipped = rs.filter(function(r){ return r && r.skipped; }).map(function(r){ return r.name; });
+ // Records, not names — same reason as the refresh path. Both call sites were
+ // mapping to names while render() was changed to group on the flag; fixing
+ // one and shipping it is what produced a footer with thirteen blank entries.
+ var skipped = rs.filter(function(r){ return r && r.skipped; });
render(real, skipped);
var sub=document.getElementById("qa-sub");
if(sub && st.updated_at){
@@ -878,10 +899,22 @@ PLUGIN.ui = {
host.innerHTML='没有源提供可读的余额接口
';
} else {
host.innerHTML=real.map(card).join("");
- if(skipped && skipped.length){
- host.innerHTML+='未列出 '+skipped.length+
- ' 个源(未暴露余额接口):'+escH(skipped.join("、"))+'
';
- }
+ if(skipped && skipped.length){
+ // Two reasons a source has no card, and they are not interchangeable.
+ // "This upstream exposes no balance endpoint" and "this upstream is
+ // backing off after failing" are different facts; collapsing them into
+ // one list tells the operator their dead host simply has no dashboard.
+ var backing = skipped.filter(function(r){ return r.backing_off===true; });
+ var silent = skipped.filter(function(r){ return r.backing_off!==true; });
+ if(backing.length){
+ host.innerHTML+=''+backing.length+
+ ' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'
';
+ }
+ if(silent.length){
+ host.innerHTML+='未列出 '+silent.length+
+ ' 个源(未暴露余额接口):'+escH(silent.map(function(r){return r.name;}).join("、"))+'
';
+ }
+ }
}
var sub=document.getElementById("qa-sub");
if(sub) sub.textContent = real.length+" 个源 · "+bad.length+" 个读取失败";
@@ -924,8 +957,12 @@ PLUGIN.ui = {
// re-probed on every refresh forever. Measured before this split: fanout left
// the memo at 0 entries and every refresh cost the full 12s.
var plan = await pluginAPI.call("quota","due_sources",{sources:srcs});
- var due = (plan && plan.due) || [];
- var out = (plan && plan.backing_off) || [];
+ // An EMPTY Lua table decodes as {} on the Go side, not [] — and {} is truthy,
+ // so `|| []` does not replace it. An empty object reaching out.concat()
+ // throws, and the whole refresh reports a failure. Array.isArray is the check
+ // that matches what the plugin actually returns.
+ var due = (plan && Array.isArray(plan.due)) ? plan.due : [];
+ var out = (plan && Array.isArray(plan.backing_off)) ? plan.backing_off : [];
if (due.length) {
out = out.concat(await pluginAPI.fanout("quota","collect_source",due.map(function(s){
@@ -946,8 +983,10 @@ PLUGIN.ui = {
await pluginAPI.call("quota","memo_record",{results:(out||[]).map(function(r){
if(!r) return null;
// A host that is merely BACKING OFF has not just failed; recording it as a
- // failure would grow the streak on a probe that never happened.
- if(r.reason && String(r.reason).indexOf("backing off")===0) return null;
+ // failure would grow the streak on a probe that never happened. Keyed on the
+ // flag rather than the wording, so translating the message cannot silently
+ // turn every skipped host into an ever-growing streak.
+ if(r.backing_off===true) return null;
if(r.error) return {host:r.host};
if(r.skipped) return {host:r.host};
return {host:r.host, vendor:r.vendor};
@@ -959,7 +998,11 @@ PLUGIN.ui = {
}
var all=out||[];
var real=all.filter(function(r){return !r.skipped;});
- var skipped=all.filter(function(r){return r.skipped;}).map(function(r){return r.name;});
+ // The records, not their names — render() groups skipped sources by the
+ // backing_off flag, which a bare name cannot carry. This call site was left
+ // mapping to names while the other was fixed, and the mismatch showed up as
+ // a footer listing thirteen unnamed sources.
+ var skipped=all.filter(function(r){return r.skipped;});
// real may already contain errors; collect_all keeps them for visibility.
render(real, skipped);
if(btn) btn.disabled=false;
diff --git a/internal/lua/quota_backoff_test.go b/internal/lua/quota_backoff_test.go
index c26d34c..dc252b4 100644
--- a/internal/lua/quota_backoff_test.go
+++ b/internal/lua/quota_backoff_test.go
@@ -27,9 +27,16 @@ func TestQuotaBackoffIsAppliedInSource(t *testing.T) {
t.Error("quota.lua never consults backoffSeconds for a host with a streak — " +
"the backoff is computed but not applied")
}
- if !strings.Contains(src, `reason = "backing off after "`) {
- t.Error("the skip path does not say why it skipped; a silently skipped host " +
- "looks exactly like a host that simply has no balance endpoint")
+ // The reason is required, but matched on the FLAG rather than the wording:
+ // the message is localised, and grouping by text would merge "backing off"
+ // into "exposes no balance endpoint" the moment someone translates it.
+ if strings.Count(src, "reason = \"连续探测失败 ") < 2 {
+ t.Error("both skip paths (collectSource and due_sources) must carry a reason; " +
+ "a silently skipped host looks exactly like a host with no balance endpoint")
+ }
+ if strings.Count(src, "backing_off = true") < 2 {
+ t.Error("both skip paths must set the explicit backing_off flag; the page " +
+ "groups skipped sources by it, not by the message text")
}
// The recording guard has to REPLACE the memo, not merge into it. The bug this
// guards was `if rep == nil and not HOST_MEMO[host]`, which is true only on a
@@ -554,3 +561,90 @@ func hostOnly(baseURL string) string {
}
return u
}
+
+// TestQuotaBackoffRecordIsSkippedAndFlagged pins the shape the page depends on.
+//
+// Two things went wrong in the same field, and both were silent:
+//
+// 1. The back-off record had no `skipped` flag, so the page's
+// `filter(r => !r.skipped)` let it through to the card renderer. That
+// renderer matches on known fields, and a record carrying only a reason
+// matches none — so it fell through to a default that said "有数据".
+// Thirteen dead upstreams were rendered as though they had answered.
+// 2. Once it did carry `skipped`, the page grouped skipped sources by reading
+// the REASON TEXT. Translating that string would have silently merged
+// "backing off" into "exposes no balance endpoint" — a different claim
+// about the upstream. The grouping keys on an explicit flag.
+func TestQuotaBackoffRecordIsSkippedAndFlagged(t *testing.T) {
+ srv := newCountingServer(t)
+ ps := newQuotaPlugins(t, srv.URL)
+
+ // One failure, enough to put the host inside its window.
+ if _, err := ps.Call("quota", "memo_record", map[string]interface{}{
+ "results": []interface{}{map[string]interface{}{"host": srv.URL + "/v1"}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ out, err := ps.Call("quota", "due_sources", map[string]interface{}{
+ "sources": []interface{}{map[string]interface{}{
+ "name": "dead", "base_url": srv.URL + "/v1", "api_key": "k",
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ m, _ := out.(map[string]interface{})
+ backing, _ := m["backing_off"].([]interface{})
+ if len(backing) != 1 {
+ t.Fatalf("backing_off = %#v, want one record", m["backing_off"])
+ }
+ rec, _ := backing[0].(map[string]interface{})
+ if rec["skipped"] != true {
+ t.Errorf("back-off record has skipped=%v — the page filters cards on this "+
+ "field, and without it the record is rendered as a source with data",
+ rec["skipped"])
+ }
+ if rec["backing_off"] != true {
+ t.Errorf("back-off record has no explicit backing_off flag; the page groups " +
+ "by it instead of by reason text, so translating the message cannot " +
+ "merge this with 'no balance endpoint'")
+ }
+ if s, _ := rec["reason"].(string); s == "" {
+ t.Error("back-off record carries no reason; the card renders empty")
+ }
+}
+
+// TestQuotaRenderCallSitesPassRecordsNotNames guards a bug that only appeared
+// because ONE of two identical call sites was fixed.
+//
+// render() groups skipped sources by the backing_off flag. A call site that maps
+// the records to their names first ("skipped.map(r => r.name)") therefore hands
+// render an array of strings: the flag is gone, every entry falls into the
+// "exposes no balance endpoint" bucket, and the footer lists N unnamed sources.
+//
+// The first fix changed only the refresh path. The snapshot path kept mapping to
+// names, and the symptom was a footer reading "未列出 13 个源:、、、、…" — which
+// reads like a rendering bug rather than a contract mismatch between two call
+// sites of one function.
+func TestQuotaRenderCallSitesPassRecordsNotNames(t *testing.T) {
+ src := readShippedPlugin(t, "quota.lua")
+ if strings.Contains(src, ".map(function(r){ return r.name; })") ||
+ strings.Contains(src, ".map(function(r){return r.name;})") {
+ // A benign occurrence would be fine, but there is no other reason for a
+ // skipped list to be reduced to names, so treat any as the bug.
+ for _, line := range strings.Split(src, "\n") {
+ if strings.Contains(line, "skipped") && strings.Contains(line, ".map(") &&
+ strings.Contains(line, "r.name") {
+ t.Errorf("a skipped list is mapped to names before reaching render(): %s\n"+
+ "render() groups on the backing_off flag, which a name cannot carry",
+ strings.TrimSpace(line))
+ }
+ }
+ }
+ // Both call sites must exist and both must hand over records.
+ if n := strings.Count(src, ".filter(function(r){return r.skipped;})") +
+ strings.Count(src, ".filter(function(r){ return r && r.skipped; })"); n < 2 {
+ t.Errorf("found %d skipped-list filters, want 2 (snapshot and refresh paths)", n)
+ }
+}