fix(ui): per-key 链复用全局 AUTO 画布;统计周期显示实际窗口

两件事。

一、per-key AUTO 链的 UI 之前自己写了一套表格,只有 model + tier 两列,
比全局 AUTO 编辑器弱:没有拖拽排序、没有每槽位的 quota/period/hours。
这是重复实现且必然漂移。改为复用同一套泳道画布:

- 抽出 buildSortIndex / buildLanes / lanesToRules / renderSortEditor,
  全局编辑器与 per-key 编辑器共用。
- sortState.scope 决定保存目标(null=全局,key=该密钥),persistAuto
  按此分支;空 lane 列表即"恢复继承",与后端空 PUT 等价。
- 画布查找走 sortCanvasEl(),两个 id 都能解析,否则拖拽在弹窗里静默失效。
- keyAutoClose 必须清 scope,否则关掉弹窗后全局编辑器会存到这个 key。
- 删掉旧的 keyAutoRow/keyAutoAddRow/keyAutoClear。

判据改为断言"复用"而非断言具体实现,并新增两条:
- 关闭弹窗不清 scope(全局编辑器会存错目标)
- per-key 编辑器不再挂载共享画布

二、"周视图总量大于月视图"不是 bug:日历周期并非嵌套。"本周"从周一开始,
所以月初会回溯到上月末几天,本周总量合法地大于本月。实测今天 2026-10-03
(周六):周窗口 09-28→10-03(6 天,9.2B tokens),月窗口 10-01→10-03
(3 天,4.0B tokens)。

真正的缺陷是看不见这一点。统计页新增"统计区间:YYYY-MM-DD → YYYY-MM-DD
(Nd)",让周 > 月 自解释。判据钉住这个事实,并做变异验证:把周窗口钳制到
月内(一个看起来很自然的"修复")会被两条判据同时抓住。

CDP 实测:周视图显示"2026-09-28 → 2026-10-03 (6d)",月视图显示
"2026-10-01 → 2026-10-03 (3d)";per-key 编辑器渲染出 36 条泳道 / 226 个
块,与全局编辑器同款。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
JianFeeeee
2026-10-03 08:26:57 +08:00
parent 9dc2b64013
commit 13e2397e9c
3 changed files with 399 additions and 180 deletions

View File

@ -319,3 +319,85 @@ func TestPeriodFromQuery(t *testing.T) {
}
}
}
// TestPeriodWeekMayStartBeforeMonth pins a fact that looks like a bug and
// gets reported as one: the calendar periods are NOT nested.
//
// On 2026-10-03 (a Saturday) the ISO week runs Mon 2026-09-28 .. Sat
// 10-03, while the month window is 10-01 .. 10-03. The week therefore reaches
// back into the previous month and its total is legitimately LARGER than the
// month's. Anyone reading only the totals will conclude the aggregator is
// broken; it is not, and this test exists so a future "fix" that makes the
// windows nested (e.g. clamping the week to the month) is caught as the
// regression it would be.
func TestPeriodWeekMayStartBeforeMonth(t *testing.T) {
// Saturday 2026-10-03, UTC.
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
weekFrom := periodStart(PeriodWeek, now)
monthFrom := periodStart(PeriodMonth, now)
weekFromD := time.UnixMilli(weekFrom).UTC()
monthFromD := time.UnixMilli(monthFrom).UTC()
if got := weekFromD.Format("2006-01-02"); got != "2026-09-28" {
t.Fatalf("week window start = %s, want 2026-09-28 (the Monday)", got)
}
if got := monthFromD.Format("2006-01-02"); got != "2026-10-01" {
t.Fatalf("month window start = %s, want 2026-10-01", got)
}
if !weekFromD.Before(monthFromD) {
t.Fatal("this test only means anything when the week starts before the month")
}
// The week must reach strictly further back than the month window does,
// otherwise the nesting intuition would hold and the UI note would be noise.
if back := monthFromD.Sub(weekFromD).Hours() / 24; back <= 0 {
t.Fatalf("week should start at least a day before the month window, got %v days back", back)
}
}
// The same overlap must be visible in the aggregate numbers, not just in the
// window bounds: a week total larger than a month total is the exact symptom
// an operator reported.
func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) {
dir := t.TempDir()
audit := filepath.Join(dir, "audit.jsonl")
// Two records before the month window (inside this ISO week) and one
// inside October.
pre := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC).UnixMilli()
in1 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC).UnixMilli()
in2 := time.Date(2026, 10, 3, 10, 0, 0, 0, time.UTC).UnixMilli()
auditPeriod(t, audit,
Req{Time: pre, OK: true, Prompt: 100, Compl: 10},
Req{Time: pre + 1000, OK: true, Prompt: 100, Compl: 10},
Req{Time: in1, OK: true, Prompt: 50, Compl: 5},
Req{Time: in2, OK: true, Prompt: 50, Compl: 5},
)
s := periodStats(t, audit)
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
week := s.PeriodSnapshot(PeriodWeek, "", now)
month := s.PeriodSnapshot(PeriodMonth, "", now)
if week.Total.Reqs != 4 {
t.Fatalf("week reqs = %d, want 4 (both September records fall in this ISO week)", week.Total.Reqs)
}
if month.Total.Reqs != 2 {
t.Fatalf("month reqs = %d, want 2", month.Total.Reqs)
}
if week.Total.Tokens <= month.Total.Tokens {
t.Fatalf("week tokens (%d) must exceed month tokens (%d): the week reaches into September",
week.Total.Tokens, month.Total.Tokens)
}
// Day must be a strict subset of both, which is the only nesting that holds.
day := s.PeriodSnapshot(PeriodDay, "", now)
if day.Total.Reqs != 1 {
t.Fatalf("day reqs = %d, want 1", day.Total.Reqs)
}
if day.Total.Tokens >= month.Total.Tokens || day.Total.Tokens >= week.Total.Tokens {
t.Fatal("today must be contained in both the week and the month totals")
}
}

View File

@ -1054,6 +1054,7 @@
periodWeek: "本周",
periodMonth: "本月",
periodAll: "全部",
periodRange: "统计区间:",
periodTruncated:
"统计周期可能不完整(审计日志已轮转),数值为下限。",
recsPartial:
@ -1328,6 +1329,7 @@
periodWeek: "This week",
periodMonth: "This month",
periodAll: "All time",
periodRange: "Window:",
periodTruncated:
"This window may be incomplete (audit log rotated); figures are a lower bound.",
recsPartial:
@ -1739,9 +1741,10 @@
<option value="month">${t("periodMonth")}</option>
<option value="all">${t("periodAll")}</option>
</select>
<span class="grow"></span>
<span class="grow"></span>
<span id="period-note"></span>
</div>
<div class="muted" id="period-range" style="margin:-6px 0 12px"></div>
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
5,
)
@ -2001,11 +2004,31 @@
// window may be incomplete the numbers are a lower bound, and saying
// so next to them is the whole point — a confidently wrong zero for a
// rotated-away week is indistinguishable from a quiet week.
const pnote = $("#period-note");
const pnote = $("#period-note");
if (pnote)
pnote.innerHTML = st.truncated
? `<span class="muted" title="${escAttr(t("periodTruncated"))}">ⓘ ${esc(t("periodTruncated"))}</span>`
: "";
// Show the window the numbers actually cover. The calendar periods
// are not nested: "this week" runs from its Monday, so early in a
// month it legitimately reaches back into the previous month and
// its total EXCEEDS "this month". That is correct, but it looks
// like a bug unless the dates are on screen — so print them.
const prange = $("#period-range");
if (prange && st.period && st.period !== "all" && st.from) {
const d0 = new Date(st.from);
const d1 = st.buckets && st.buckets.length
? new Date(st.buckets[st.buckets.length - 1].bucket + "T00:00:00Z")
: d0;
const f = (d) =>
d.toISOString().slice(0, 10);
prange.textContent =
t("periodRange") +
" " + f(d0) + " → " + f(d1) +
(st.buckets ? " (" + st.buckets.length + "d)" : "");
} else if (prange) {
prange.textContent = "";
}
const rex = $("#rec-exit");
if (rex)
rex.innerHTML = statsKeyF
@ -3457,8 +3480,96 @@
// the toggle switches which chain the canvas edits
const sortLanes = { chat: [], image: [] };
let sortStateMap = new Map();
// renderSortEditor paints the shared lane canvas into `mount` for whichever
// chain sortState currently describes. Both the global editor and the
// per-key modal call it, so a key's chain gets the identical canvas
// (drag ordering, per-slot quota/period/hours) rather than a trimmed copy.
function renderSortEditor(mount, opts) {
const o = opts || {};
mount.innerHTML = `
<div class="sort-canvas" id="${o.canvasId}"></div>
<div class="scr-hint">
${
sortState.lanes
.map((l) => l.models[0])
.map((it) => {
const c = srcColor(it.src);
return `<span><i style="background:linear-gradient(135deg,${c[0]},${c[1]})"></i>${esc(it.src)}</span>`;
})
.filter((v, i, a) => a.indexOf(v) === i)
.join("") || ""
}
</div>`;
paintSort();
}
// sortIndex holds the model/source lookups buildLanes needs. It is built once
// per editor open so both the global chain and a per-key chain can compile
// rules into the same lane structure.
function buildSortIndex(sources) {
const byModel = new Map();
const byPair = new Map();
const sourceRows = new Map();
(sources || []).forEach((s) =>
(s.models || []).forEach((m) => {
const it = { src: s.name, id: m.id, prio: m.priority || 0, kind: m.kind };
byPair.set(m.id + "|" + s.name, it);
if (!byModel.has(m.id)) byModel.set(m.id, it);
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
}),
);
return { byModel, byPair, sourceRows };
}
// buildLanes compiles saved rules into the tier-lane structure the canvas
// paints. With no rules it falls back to discovery, grouped by source
// priority, so an unconfigured chain still shows every available slot.
// Shared by the global editor and the per-key editor.
function buildLanes(idx, rules, kind) {
const { byModel, byPair, sourceRows } = idx;
if (rules.length) {
const tiers = [];
rules.forEach((r, i) => {
const src = normSrc(r.source);
const pair = src ? byPair.get(r.model + "|" + src) : null;
if (src && !pair) return;
const ref = pair || byModel.get(r.model) || { src: "*", id: r.model };
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
if (!tiers[ti]) tiers[ti] = [];
tiers[ti].push({
src: ref.src || "*",
id: r.model,
uid: nexUid(),
meta: {
quota: r.token_quota || 0,
period: r.period || "",
hours: r.hours || 0,
},
});
});
return tiers
.filter(Boolean)
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
}
return [...sourceRows.entries()]
.sort((a, b) => b[0] - a[0])
.map(([prio, models]) => {
const same = models.filter((m) => {
const it = byPair.get(m.id + "|" + m.src);
const k = it ? it.kind : "chat";
return k === kind;
});
if (!same.length) return null;
same.sort((x, y) => (x.src < y.src ? -1 : x.src > y.src ? 1 : 0));
same.forEach((m) => {
m.uid = nexUid();
m.meta = null;
});
return { prio, models: same };
})
.filter(Boolean);
}
async function renderSort() {
const j = await api("/api/v1/sources");
const j = await api("/api/v1/sources");
let autoR = [];
let autoImg = [];
try {
@ -3472,65 +3583,12 @@
]),
);
} catch (e) {}
const byModel = new Map();
const byPair = new Map();
const sourceRows = new Map();
j.sources.forEach((s) =>
(s.models || []).forEach((m) => {
const it = { src: s.name, id: m.id, prio: m.priority || 0, kind: m.kind };
byPair.set(m.id + "|" + s.name, it);
if (!byModel.has(m.id)) byModel.set(m.id, it);
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
}),
);
const sortIdx = buildSortIndex(j.sources);
// build a lane structure for one kind from its saved rules; when
// rules are empty, fall back to discovery from sourceRows filtered by
// kind so an unconfigured chain still shows all available slots
const buildLanes = (rules, kind) => {
if (rules.length) {
const tiers = [];
rules.forEach((r, i) => {
const src = normSrc(r.source);
const pair = src ? byPair.get(r.model + "|" + src) : null;
if (src && !pair) return;
const ref = pair ||
byModel.get(r.model) || { src: "*", id: r.model };
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
if (!tiers[ti]) tiers[ti] = [];
tiers[ti].push({
src: ref.src || "*",
id: r.model,
uid: nexUid(),
meta: {
quota: r.token_quota || 0,
period: r.period || "",
hours: r.hours || 0,
},
});
});
return tiers
.filter(Boolean)
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
}
// discovery: group slots of this kind by their source priority
return [...sourceRows.entries()]
.sort((a, b) => b[0] - a[0])
.map(([prio, models]) => {
const same = models.filter((m) => {
const it = byPair.get(m.id + "|" + m.src);
const k = it ? it.kind : "chat";
return k === kind;
});
if (!same.length) return null;
same.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
same.forEach((m) => { m.uid = nexUid(); m.meta = null; });
return { prio, models: same };
})
.filter(Boolean);
};
sortLanes.chat = buildLanes(autoR, "chat");
sortLanes.image = buildLanes(autoImg, "image");
sortLanes.chat = buildLanes(sortIdx, autoR, "chat");
sortLanes.image = buildLanes(sortIdx, autoImg, "image");
sortState.lanes = sortLanes[sortState.kind] || [];
// model picker for the "add slot" control: one option per (source, model)
// pair so identical model ids on different sources stay distinguishable
@ -3542,22 +3600,11 @@
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>${kindToggle}
<button class="ghost small" onclick="scrAddModal()">+ ${t("sortAdd")}</button>
<button class="ghost small" onclick="sortReset()">${t("sortReset")}</button>
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
<div class="sort-canvas" id="scr-canvas"></div>
<div class="scr-hint">
${
sortState.lanes
.map((l) => l.models[0])
.map((it) => {
const c = srcColor(it.src);
return `<span><i style="background:linear-gradient(135deg,${c[0]},${c[1]})"></i>${esc(it.src)}</span>`;
})
.filter((v, i, a) => a.indexOf(v) === i)
.join("") || ""
}
</div>
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
</div>`;
paintSort();
renderSortEditor($("#tab-sort").querySelector(".card"), {
canvasId: "scr-canvas",
});
}
function switchSortKind(k) {
if (sortState.kind === k) return;
@ -3642,9 +3689,18 @@
<span class="scr-grip"><i></i><i></i><i></i></span>
</div>`;
}
// sortCanvasEl resolves the canvas the editor paints into. The global editor
// owns #scr-canvas; the per-key editor renders the same canvas under a
// different id inside its modal, so both share one implementation
// instead of a reduced second copy that would drift.
function sortCanvasEl() {
const own = document.getElementById("scr-canvas");
if (own) return own;
return document.getElementById("key-auto-canvas");
}
function paintSort(affected) {
const cv = $("#scr-canvas");
if (!cv) return;
const cv = sortCanvasEl();
if (!cv) return;
// compute the next lane-leaders so we can retract the outgoing ones first
const nextFirst = new Set();
sortState.lanes.forEach((lane) => {
@ -3696,8 +3752,8 @@
}
paintSortNow(affected);
}
function paintSortNow(affected) {
const cv = $("#scr-canvas");
function paintSortNow(affected) {
const cv = sortCanvasEl();
if (!cv) return;
// FLIP: capture old positions of lanes and blocks before repaint
const prev = new Map();
@ -3844,9 +3900,9 @@
});
}
function onSortMove(e) {
const d = sortState.drag;
const d = sortState.drag;
if (!d) return;
const cv = document.getElementById("scr-canvas");
const cv = sortCanvasEl();
if (!cv) return;
const laneEls = [...cv.querySelectorAll(".scr-lane")];
const gapEls = [...cv.querySelectorAll(".scr-gap")];
@ -3955,7 +4011,8 @@
if (!d) return;
const line = document.getElementById("scr-blockline");
if (line) line.remove();
const cv = document.getElementById("scr-canvas");
const cv = sortCanvasEl();
if (!cv) return;
cv.querySelectorAll(".scr-gap, .scr-lane").forEach((el) =>
el.classList.remove("hover", "drophere"),
);
@ -4055,10 +4112,42 @@
}
/* ---------- AUTO quota editing on sort blocks ---------- */
// sortState.scope decides where saveSort() persists: null/undefined =
// the global chain, or a gateway key = that key's own chain. The canvas,
// drag/drop and slot metadata all work unchanged in both modes; only the
// persistence endpoint and the chain that gets loaded differ. Keeping one
// editor means a per-key chain gets exactly the features the global one
// has (drag ordering, per-slot quota/period/hours) instead of a reduced
// second implementation that would drift.
async function persistAuto() {
const rules = lanesToRules(sortState.lanes);
if (sortState.scope) {
// Empty rules mean "inherit the global chain" server-side, so the
// same PUT both saves a chain and clears the override.
await api("/api/keys/" + encodeURIComponent(sortState.scope) + "/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ auto: rules }),
});
return;
}
// each kind persists to its own chain; the other kind's saved rules
// are left untouched (omitting the field means "no change")
const body = { rules: undefined, image_rules: undefined };
if (sortState.kind === "image") body.image_rules = rules;
else body.rules = rules;
await api("/api/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
// lanesToRules flattens the tier lanes into the wire shape shared by
// /api/auto and /api/keys/{key}/auto.
function lanesToRules(lanes) {
const rules = [];
sortState.lanes.forEach((lane, li) =>
lane.models.forEach((it) => {
(lanes || []).forEach((lane, li) =>
(lane.models || []).forEach((it) => {
const m = it.meta;
rules.push({
model: it.id,
@ -4070,16 +4159,7 @@
});
}),
);
// each kind persists to its own chain; the other kind's saved rules
// are left untouched (omitting the field means "no change")
const body = { rules: undefined, image_rules: undefined };
if (sortState.kind === "image") body.image_rules = rules;
else body.rules = rules;
await api("/api/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return rules;
}
function scrAddModal() {
const cur = { quota: 0, period: "", hours: 0 };
@ -5113,104 +5193,112 @@
// slots rather than a drag canvas, because an admin usually either
// mirrors the global chain or deletes a few tiers, and the canvas layout
// for the model scope list would be overkill here.
// Per-key AUTO chain editor. It renders the SAME lane canvas as the
// global editor (drag ordering, per-slot quota/period/hours) by pointing
// sortState.scope at this key: persistAuto then saves to
// /api/keys/{key}/auto instead of /api/auto. A separate reduced editor
// would drift from the global one and lose features.
async function openKeyAutoModal(key, name) {
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
wrap.innerHTML = `<div class="card" style="width:720px;max-width:100%">
<h2>${esc(t("kAutoChainTitle"))} · ${esc(name || key)}</h2>
wrap.innerHTML = `<div class="card" style="width:900px;max-width:100%">
<h2>${esc(t("kAutoChainTitle"))} \u00b7 ${esc(name || key)}</h2>
<div class="muted" style="margin-bottom:10px">${esc(t("kAutoChainHint"))}</div>
<div id="ka-rows"></div>
<p><button class="ghost" onclick="keyAutoAddRow()">${esc(t("kAutoChainAdd"))}</button>
<button class="ghost" onclick="keyAutoClear(this)">${esc(t("kAutoChainInherit"))}</button></p>
<p><button onclick="keyAutoSave(this,'${escAttr(key)}')">${esc(t("kAutoChainSave"))}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${esc(t("mCancel"))}</button></p>
<div id="ka-canvas-host"></div>
<p><button class="ghost small" onclick="scrAddModal()">+ ${esc(t("sortAdd"))}</button>
<button class="ghost small" onclick="keyAutoInherit()">${esc(t("kAutoChainInherit"))}</button>
<button class="small" onclick="keyAutoSave(this)">${esc(t("sortSave"))}</button>
<button class="ghost" onclick="keyAutoClose()">${esc(t("mCancel"))}</button></p>
</div>`;
document.body.appendChild(wrap);
// Park the key on the modal so a click that bubbles up cannot hit the
// global editor behind it.
wrap.addEventListener("click", (ev) => ev.stopPropagation());
try {
const j = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
const rows = $("#ka-rows");
if (!j.auto || !j.auto.length) {
rows.innerHTML = `<div class="muted" style="padding:8px 0">${esc(
t("kAutoChainEmpty")
)}</div>`;
} else {
j.auto.forEach((r) => keyAutoRow(r));
}
const j = await api("/api/v1/sources");
const idx = buildSortIndex(j.sources || []);
const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
keyAutoState.key = key;
keyAutoState.inherits = !!cur.inherits;
// A key with no own chain shows an empty canvas rather than the
// discovery fallback: filling it with every available slot would
// suggest those slots are already this user's chain.
sortState.scope = key;
sortState.kind = "chat";
sortState.lanes = buildLanes(idx, cur.auto || [], "chat");
sortState.origin = JSON.stringify(sortState.lanes);
await loadModelPairs();
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
if (keyAutoState.inherits) keyAutoInheritNotice();
} catch (e) {
toast(String(e));
}
}
function keyAutoRow(r) {
const el = document.createElement("div");
el.className = "ka-row";
el.style.cssText =
"display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap";
const opts = (sel) =>
(allModels || [])
.map((p) => {
// loadModelPairs builds {key,label}; the /api/status fallback
// only guarantees an id, so derive a comb key from whatever is
// present rather than emitting an empty option value.
const val = p.key || (p.id ? p.id + (p.source ? "|" + p.source : "") : "");
const lab = p.label || p.id || val;
return `<option value="${escAttr(val)}"${
val === sel ? " selected" : ""
}>${esc(lab)}</option>`;
})
.join("");
el.innerHTML = `<select class="ka-model" style="flex:2 1 220px;min-width:0">${opts(
r ? scopeComb(r) : ""
)}</select>
<label class="muted" style="flex:0 0 auto">${esc(
t("kAutoChainTier")
)}<input class="ka-tier" type="number" min="1" value="${
(r && r.tier) || 1
}" style="width:64px;margin-left:6px"></label>
<button class="ghost small errc" onclick="this.closest('.ka-row').remove()">×</button>`;
$("#ka-rows").appendChild(el);
const keyAutoState = { key: null, inherits: true };
function keyAutoInheritNotice() {
const host = $("#ka-canvas-host");
if (!host) return;
let n = host.querySelector(".ka-inherit-note");
if (!n) {
n = document.createElement("div");
n.className = "muted ka-inherit-note";
n.style.margin = "0 0 10px";
host.appendChild(n);
}
n.textContent = t("kAutoChainEmpty");
}
function keyAutoAddRow() {
keyAutoRow(null);
// "Use the global chain" empties the lanes; saving then PUTs an empty
// list, which the server treats as clearing the override.
function keyAutoInherit() {
keyAutoState.inherits = true;
sortState.lanes = [];
sortState.origin = "[]";
const host = $("#ka-canvas-host");
if (host) {
const c = document.getElementById("key-auto-canvas");
if (c) c.innerHTML = "";
}
paintSort();
keyAutoInheritNotice();
}
function keyAutoClear() {
$("#ka-rows").innerHTML = "";
function keyAutoClose() {
const m = document.getElementById("modal-wrap");
if (m) m.remove();
// Leaving scope set would make the global editor save to this key.
sortState.scope = null;
keyAutoState.key = null;
}
async function keyAutoSave(btn, key) {
const rules = [];
document.querySelectorAll("#ka-rows .ka-row").forEach((row) => {
const comb = row.querySelector(".ka-model").value;
if (!comb) return;
const { model, source } = splitCombKey(comb);
const tier = parseInt(row.querySelector(".ka-tier").value, 10) || 1;
rules.push({ model, source, tier });
});
async function keyAutoSave(btn) {
const key = keyAutoState.key;
if (!key) return;
btn.disabled = true;
try {
// An empty rule set means "inherit global" server-side, so one PUT
// covers both clearing the override and saving a chain.
// An empty lane list means "inherit global" server-side, so the same
// PUT both saves a chain and clears the override.
const j = await api("/api/keys/" + encodeURIComponent(key) + "/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ auto: rules }),
body: JSON.stringify({ auto: lanesToRules(sortState.lanes) }),
});
if (j.inherits) {
toast(t("kAutoChainGlobal"));
} else if (!j.slots) {
// Persisted, but nothing resolves — say so instead of letting the
// user discover it as a 503 on their next AUTO request.
// Persisted, but nothing resolves — say so now instead of letting
// the user meet it as a 503 on their next AUTO request.
toast(t("kAutoChainBad"));
} else {
toast(t("kAutoChainSave") + " (" + j.slots + ")");
toast(t("sortSaved") + " (" + j.slots + ")");
}
document.getElementById("modal-wrap").remove();
keyAutoClose();
loadKeys();
} catch (e) {
toast(String(e));
btn.disabled = false;
}
}
async function createKey(btn) {
const name = $("#kc-name").value.trim();
if (!name) {

View File

@ -5,13 +5,14 @@ import (
"testing"
)
// Per-key AUTO chain UI contract. The feature spans three layers (API handler,
// config field, WebUI) and the WebUI part is plain inline JS, so a rename or a
// dropped button fails silently at runtime: the admin simply finds no way to
// configure a key's chain and has no error to chase.
// Per-key AUTO chain UI contract.
//
// These checks pin the wiring that is easy to break by refactor.
// The per-key editor deliberately REUSES the global lane canvas instead of
// shipping a second, simpler editor. That is a design choice with teeth: a
// reduced copy would lack drag ordering and per-slot quota/period/hours, and
// the two would drift apart silently. These checks pin the sharing, because
// the only symptom of un-sharing is "the per-key editor got worse" — no error,
// just a smaller feature set that nobody notices until they use it.
func TestUIPerKeyAutoWiring(t *testing.T) {
src := uiSource(t)
@ -27,38 +28,71 @@ func TestUIPerKeyAutoWiring(t *testing.T) {
if kcEnd < 0 {
t.Fatal("could not delimit keyCanvasHtml")
}
kcBody := src[kcStart : kcStart+kcEnd]
if !strings.Contains(kcBody, "openKeyAutoModal(") {
if !strings.Contains(src[kcStart:kcStart+kcEnd], "openKeyAutoModal(") {
t.Fatal("per-key AUTO chain button is missing from the key card header")
}
for _, fn := range []string{
"function openKeyAutoModal",
"function keyAutoRow",
"function keyAutoAddRow",
"function keyAutoClear",
"function keyAutoInherit",
"function keyAutoSave",
"function keyAutoClose",
} {
if !strings.Contains(src, fn) {
t.Fatalf("UI is missing %s — the editor cannot function", fn)
}
}
}
// The editor must talk to the endpoint the gateway actually serves.
if !strings.Contains(src, "/auto\"") {
t.Fatal("UI never calls the /api/keys/{key}/auto endpoint")
// The per-key editor must render the SAME canvas the global editor uses.
// If this regresses, the per-key chain silently loses drag ordering and
// per-slot quota/period/hours while still looking like it works.
func TestUIPerKeyAutoReusesGlobalCanvas(t *testing.T) {
src := uiSource(t)
// One canvas implementation, two mount ids.
if !strings.Contains(src, "function renderSortEditor(") {
t.Fatal("no shared canvas renderer — the per-key editor must reuse renderSortEditor")
}
if !strings.Contains(src, `canvasId: "scr-canvas"`) {
t.Fatal("the global editor no longer mounts the shared canvas")
}
if !strings.Contains(src, `canvasId: "key-auto-canvas"`) {
t.Fatal("the per-key editor does not mount the shared canvas")
}
// Canvas lookup must consider both ids, or drag/drop silently no-ops on
// whichever surface is not #scr-canvas.
if !strings.Contains(src, "function sortCanvasEl(") ||
!strings.Contains(src, `getElementById("key-auto-canvas")`) {
t.Fatal("sortCanvasEl must resolve both canvas ids")
}
// The rules<->lanes conversion is shared too, so the per-key editor sends
// exactly the fields the server understands (quota/period/hours included).
for _, fn := range []string{"function buildLanes(", "function lanesToRules("} {
if !strings.Contains(src, fn) {
t.Fatalf("%s must be shared between the global and per-key editors", fn)
}
}
}
// The editor's empty-row path sends {"auto":[]} and relies on the server
// treating that as "clear the override". If the UI instead sent a sentinel,
// a future server change would silently persist a broken empty chain.
func TestUIPerKeyAutoSendsEmptyListToClear(t *testing.T) {
// Saving must route through persistAuto's scope switch, not a hand-rolled
// request, or the two editors will serialize rules differently.
func TestUIPerKeyAutoSavesThroughScope(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "JSON.stringify({ auto: rules })") {
t.Fatal("keyAutoSave must PUT the collected rule list verbatim")
if !strings.Contains(src, "sortState.scope = key") {
t.Fatal("openKeyAutoModal must set sortState.scope so the save targets the key")
}
if !strings.Contains(src, "keyAutoClear") {
t.Fatal("no way to clear a key's own chain from the UI")
if !strings.Contains(src, "sortState.scope = null") {
t.Fatal("closing the modal must clear sortState.scope, or the GLOBAL editor would save to this key")
}
if !strings.Contains(src, "if (sortState.scope)") {
t.Fatal("persistAuto must branch on sortState.scope")
}
if !strings.Contains(src, `"/auto"`) {
t.Fatal("per-key editor must call the /api/keys/{key}/auto endpoint")
}
// An empty lane list is how "inherit the global chain" is expressed.
if !strings.Contains(src, "JSON.stringify({ auto: lanesToRules(sortState.lanes) })") {
t.Fatal("keyAutoSave must PUT lanesToRules(sortState.lanes) verbatim")
}
}
@ -68,11 +102,26 @@ func TestUIPerKeyAutoHasBothLanguages(t *testing.T) {
src := uiSource(t)
for _, k := range []string{
"kAutoChainTitle", "kAutoChainHint", "kAutoChainInherit",
"kAutoChainAdd", "kAutoChainSave", "kAutoChainEmpty",
"kAutoChainEmpty", "kAutoChainGlobal", "kAutoChainBad",
} {
n := strings.Count(src, k+`: "`)
if n < 2 {
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
}
}
}
// The statistics dashboard must print the window it actually covers. Calendar
// periods are not nested — "this week" starts on its Monday and therefore
// reaches into the previous month early in a month, making its total LARGER
// than "this month". That is correct arithmetic, but without the dates on
// screen it reads as a bug, and it has been reported as one.
func TestUIStatsShowsPeriodWindow(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, `id="period-range"`) {
t.Fatal("the stats dashboard has no element for the period window range")
}
if n := strings.Count(src, "periodRange"); n < 2 {
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
}
}