diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json
index 21f79ad..b233b9f 100644
--- a/cmd/gui/package-lock.json
+++ b/cmd/gui/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "modelrouter-gui",
- "version": "1.8.0",
+ "version": "1.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modelrouter-gui",
- "version": "1.8.0",
+ "version": "1.9.0",
"license": "Proprietary",
"devDependencies": {
"electron": "^33.0.0",
diff --git a/cmd/gui/package.json b/cmd/gui/package.json
index a3e7392..2f1d068 100644
--- a/cmd/gui/package.json
+++ b/cmd/gui/package.json
@@ -1,6 +1,6 @@
{
"name": "modelrouter-gui",
- "version": "1.8.0",
+ "version": "1.9.0",
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
"author": "ModelRouter",
"main": "main.js",
diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js
index 553a30b..563d5bc 100644
--- a/cmd/gui/renderer/app.js
+++ b/cmd/gui/renderer/app.js
@@ -226,6 +226,7 @@ async function loadPlugins() {
return;
}
const rows = j.on_disk || [];
+ window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag
const active = rows.filter((p) => p.loaded && !p.disabled).length;
const broken = rows.filter((p) => !p.loaded).length;
hint.textContent =
@@ -250,11 +251,30 @@ async function loadPlugins() {
const builtin = p.builtin
? '内置'
: "";
+ // Edit / view source / delete are the operations an operator
+ // actually needs after enable/disable: a plugin that cannot be
+ // read back cannot be fixed, and one that cannot be removed cannot
+ // be rolled back.
+ const acts = [
+ btn,
+ ``,
+ builtin
+ ? ""
+ : ``,
+ ]
+ .filter(Boolean)
+ .join("");
+ const pages = Array.isArray(p.pages) ? p.pages : [];
return `
${esc(p.name)}${builtin}${label}
${p.description ? `
${esc(p.description)}
` : ""}
+ ${pages.length ? `
页面:${esc(pages.join(", "))}
` : ""}
${p.error ? `
${esc(String(p.error).slice(0, 160))}
` : ""}
-
${btn}
+
${acts}
`;
})
.join("")
@@ -262,6 +282,160 @@ async function loadPlugins() {
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
});
+ list.querySelectorAll('button[data-act="code"]').forEach((b) => {
+ b.onclick = () => editPlugin(b.dataset.name);
+ });
+ list.querySelectorAll('button[data-act="del"]').forEach((b) => {
+ b.onclick = () => deletePlugin(b.dataset.name);
+ });
+}
+
+// editPlugin shows the plugin source in a modal. Built-in plugins are
+// read-only: their file ships inside the binary, so a save would either be
+// ignored or silently do nothing — showing an editable text area that
+// discards on save is worse than saying so up front.
+function editPlugin(name) {
+ // The plugin list is the only place `builtin` is reported; keep the last
+ // fetch so the modal does not need a second round trip (and cannot guess).
+ const row = (window.__plRows || []).find((p) => p.name === name);
+ const isBuiltin = !!(row && row.builtin);
+ const wrap = document.createElement("div");
+ wrap.className = "overlay";
+ wrap.innerHTML = `
+
${esc(name)}${isBuiltin ? "(内置,只读)" : ""}
+ ${isBuiltin
+ ? `
内置插件随内核发布,源码在二进制内,不可编辑。
`
+ : `
+ `}
+
+ ${isBuiltin ? "" : ``}
+
+
+
`;
+ document.body.appendChild(wrap);
+ wrap.addEventListener("click", (e) => {
+ if (e.target === wrap) wrap.remove();
+ });
+ $("#pl-code-close").onclick = () => wrap.remove();
+ const save = $("#pl-code-save");
+ if (save) {
+ save.onclick = async () => {
+ save.disabled = true;
+ try {
+ // Saving writes through POST /api/plugins — the same install path the
+ // installer uses, which validates the name, compiles the source and
+ // hot-reloads. PUT /{name} only toggles enable/disable, so pointing the
+ // save there would silently disable nothing and store nothing.
+ await window.modelrouter.plugins.request("POST", "/api/plugins", {
+ name,
+ code: $("#pl-code").value,
+ });
+ toast(`${name} 已保存`);
+ wrap.remove();
+ await loadPlugins();
+ } catch (e) {
+ $("#pl-code-err").textContent = e.message;
+ save.disabled = false;
+ }
+ };
+ }
+ if (isBuiltin) return;
+ (async () => {
+ const ta = $("#pl-code");
+ try {
+ const j = await window.modelrouter.plugins.request(
+ "GET",
+ `/api/plugins/${encodeURIComponent(name)}`,
+ );
+ ta.value = j.code || "";
+ const err = $("#pl-code-err");
+ if (err && j.parse_error) err.textContent = String(j.parse_error);
+ } catch (e) {
+ $("#pl-code-err").textContent = e.message;
+ }
+ })();
+}
+
+function deletePlugin(name) {
+ const wrap = document.createElement("div");
+ wrap.className = "overlay";
+ wrap.innerHTML = `
+
删除插件
+
确定删除 ${esc(name)}?插件目录中的文件会被移除,核心将不再加载它。
+
+
+
+
+
`;
+ document.body.appendChild(wrap);
+ wrap.addEventListener("click", (e) => {
+ if (e.target === wrap) wrap.remove();
+ });
+ $("#pl-del-no").onclick = () => wrap.remove();
+ $("#pl-del-ok").onclick = async () => {
+ try {
+ await window.modelrouter.plugins.request(
+ "DELETE",
+ `/api/plugins/${encodeURIComponent(name)}`,
+ );
+ toast(`已删除 ${name}`);
+ wrap.remove();
+ await loadPlugins();
+ } catch (e) {
+ toast(e.message, true);
+ }
+ };
+}
+
+// installPlugin creates a new plugin from source. The name becomes the file
+// name, so it is validated client-side to save a round trip on the obvious
+// mistakes (empty, path separators, non-portable characters).
+function installPlugin() {
+ const wrap = document.createElement("div");
+ wrap.className = "overlay";
+ wrap.innerHTML = `
diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css
index 05dd59c..822aa3f 100644
--- a/cmd/gui/renderer/style.css
+++ b/cmd/gui/renderer/style.css
@@ -674,3 +674,51 @@ html[data-theme="dark"] .overlay {
color: var(--muted);
padding: 10px 0;
}
+
+/* ---- plugin editor / installer --------------------------------------------
+ The plugin source editor and the install dialog reuse the settings overlay
+ (overlay / overlay-card) rather than a second popup system. They need three
+ things the settings card does not: a wider column for code, a monospace
+ textarea, and a destructive button for delete. */
+.overlay-card.pl-wide {
+ width: 760px;
+}
+.pl-wide textarea {
+ width: 100%;
+ min-height: 380px;
+ resize: vertical;
+ padding: 10px 12px;
+ border-radius: 10px;
+ border: 1px solid var(--line-strong);
+ background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
+ color: inherit;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12.5px;
+ line-height: 1.55;
+ tab-size: 2;
+}
+.pl-wide textarea:focus {
+ outline: none;
+ border-color: var(--primary);
+}
+.pl-wide .row input {
+ flex: 1;
+ padding: 7px 10px;
+ border-radius: 9px;
+ border: 1px solid var(--line-strong);
+ background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
+ color: inherit;
+}
+.pl-editor .hint,
+.pl-wide .hint {
+ min-height: 16px;
+ color: var(--muted, #888);
+}
+button.danger {
+ color: #e5484d;
+ border-color: rgba(229, 72, 77, 0.4);
+}
+button.danger:hover {
+ border-color: #e5484d;
+ color: #e5484d;
+}
diff --git a/internal/gateway/stats_period_test.go b/internal/gateway/stats_period_test.go
index ae86ae7..389a688 100644
--- a/internal/gateway/stats_period_test.go
+++ b/internal/gateway/stats_period_test.go
@@ -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")
+ }
+}
diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html
index 5c8c740..6f58989 100644
--- a/internal/gateway/ui/index.html
+++ b/internal/gateway/ui/index.html
@@ -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 @@
-
+
+
${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
? `ⓘ ${esc(t("periodTruncated"))}`
: "";
+ // 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 = `
+
+
`;
}
+// 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 = `
`;
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 = `
${esc(
- t("kAutoChainEmpty")
- )}
`;
- } 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 ``;
- })
- .join("");
- el.innerHTML = `
-
- `;
- $("#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) {
diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go
index fd02d35..2755b42 100644
--- a/internal/gateway/ui_key_auto_test.go
+++ b/internal/gateway/ui_key_auto_test.go
@@ -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)
+ }
}
\ No newline at end of file
diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go
index ce3f5c8..1e03b2d 100644
--- a/internal/lua/plugins.go
+++ b/internal/lua/plugins.go
@@ -779,6 +779,34 @@ type DiskEntry struct {
Version string `json:"version,omitempty"`
Description string `json:"description,omitempty"`
Hooks int `json:"hooks"`
+ // Pages lists the UI page ids the plugin contributes. The desktop panel
+ // shows it so an operator can tell at a glance that a plugin actually
+ // renders something, without opening its source.
+ Pages []string `json:"pages,omitempty"`
+}
+
+// pageNames returns the plugin's contributed UI page ids, de-duplicated.
+// A plugin may declare `page` or `pages` or both, and the same id can appear
+// twice; the loader's merge keeps the first, so this mirrors that order.
+func pageNames(p *Plugin) []string {
+ if p == nil || p.UI == nil {
+ return nil
+ }
+ declared := make([]*UIPage, 0, len(p.UI.Pages)+1)
+ if p.UI.Page != nil {
+ declared = append(declared, p.UI.Page)
+ }
+ declared = append(declared, p.UI.Pages...)
+ var out []string
+ seen := map[string]bool{}
+ for _, pg := range declared {
+ if pg == nil || seen[pg.PageID] {
+ continue
+ }
+ seen[pg.PageID] = true
+ out = append(out, pg.PageID)
+ }
+ return out
}
// OnDisk lists every .lua file in the plugin directory with its load state.
@@ -810,6 +838,10 @@ func (ps *Plugins) OnDisk() []DiskEntry {
de.Version = p.Info.Version
de.Description = p.Info.Description
de.Hooks = len(p.Hooks)
+ // A plugin may declare a single page or several; both forms end
+ // up in Pages (see the merge at load time), so reporting that one
+ // field covers either.
+ de.Pages = pageNames(p)
} else {
// On disk but not in the running set: either it failed so badly
// that LoadSource never produced a record, or the dir was written
diff --git a/internal/lua/plugins_test.go b/internal/lua/plugins_test.go
index 5d3d26b..7fdc0f4 100644
--- a/internal/lua/plugins_test.go
+++ b/internal/lua/plugins_test.go
@@ -375,3 +375,73 @@ return p
t.Error("a plugin registered for chain_step is not reported as such")
}
}
+
+// TestOnDiskReportsPages pins that the disk listing carries the plugin's UI
+// page ids. The desktop plugin panel reads on_disk (not List) and shows the
+// pages so an operator can see at a glance that a plugin renders something;
+// without this field the GUI reads p.pages === undefined and the line simply
+// never appears, which is the kind of omission nobody reports as a bug.
+func TestOnDiskReportsPages(t *testing.T) {
+ _, ps, _ := newPluginVM(t)
+
+ code := `
+local p = { name = "paged", version = "1.0.0" }
+p.ui = { pages = {} }
+table.insert(p.ui.pages, { page_id = "alpha", title = "Alpha", mount = "a" })
+table.insert(p.ui.pages, { page_id = "beta", title = "Beta", mount = "b" })
+return p
+`
+ if err := loadPlugin(t, ps, "paged", code); err != nil {
+ t.Fatalf("load: %v", err)
+ }
+
+ var found *DiskEntry
+ for _, de := range ps.OnDisk() {
+ if de.Name == "paged" {
+ d := de
+ found = &d
+ }
+ }
+ if found == nil {
+ t.Fatal("paged plugin missing from OnDisk")
+ }
+ if len(found.Pages) != 2 {
+ t.Fatalf("Pages = %v, want [alpha beta]", found.Pages)
+ }
+ if found.Pages[0] != "alpha" || found.Pages[1] != "beta" {
+ t.Fatalf("Pages = %v, want [alpha beta] in declaration order", found.Pages)
+ }
+
+ // The field must survive JSON too — the desktop panel reads the HTTP body,
+ // not the Go struct.
+ b, err := json.Marshal(found)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(b), `"pages":["alpha","beta"]`) {
+ t.Fatalf("json omits pages: %s", b)
+ }
+}
+
+// The single-page form (`ui.page`) must also surface, since a plugin using it
+// is just as rendered as one using `ui.pages`.
+func TestOnDiskReportsSinglePage(t *testing.T) {
+ _, ps, _ := newPluginVM(t)
+ code := `
+local p = { name = "single", version = "1.0.0" }
+p.ui = { page = { page_id = "solo", title = "Solo", mount = "x" } }
+return p
+`
+ if err := loadPlugin(t, ps, "single", code); err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, de := range ps.OnDisk() {
+ if de.Name == "single" {
+ if len(de.Pages) != 1 || de.Pages[0] != "solo" {
+ t.Fatalf("Pages = %v, want [solo]", de.Pages)
+ }
+ return
+ }
+ }
+ t.Fatal("single plugin missing from OnDisk")
+}