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 @@ - + +
`; } +// 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 = `
-

${esc(t("kAutoChainTitle"))} · ${esc(name || key)}

+ wrap.innerHTML = `
+

${esc(t("kAutoChainTitle"))} \u00b7 ${esc(name || key)}

${esc(t("kAutoChainHint"))}
-
-

-

-

-

+
+

+ + +

`; 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