mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-10 11:17:22 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a3b5d357c | |||
| a9d1bdeafb | |||
| d547bed457 | |||
| 31090fec81 | |||
| f9cd0770a9 | |||
| 6311913812 | |||
| aa003e84d3 |
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.9.0",
|
"version": "1.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.9.0",
|
"version": "1.9.2",
|
||||||
"license": "Proprietary",
|
"license": "Proprietary",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.9.0",
|
"version": "1.9.2",
|
||||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||||
"author": "ModelRouter",
|
"author": "ModelRouter",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
|
|||||||
@ -445,6 +445,15 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
out := g.stats.PeriodSnapshot(p, key, time.Now())
|
out := g.stats.PeriodSnapshot(p, key, time.Now())
|
||||||
|
// The dashboard paints its records table from this payload's `records`
|
||||||
|
// field (paintRecords(st.records)), so a period view that omits the key
|
||||||
|
// renders an empty table no matter how much traffic the window had. The
|
||||||
|
// aggregate above is period-scoped; the record list is the same
|
||||||
|
// newest-first page the scroll handler pulls from /api/stats/records,
|
||||||
|
// which is deliberately not period-filtered (the cursor, not a date
|
||||||
|
// range, is its state) — that asymmetry is what the paging code
|
||||||
|
// already assumes.
|
||||||
|
page := g.stats.AuditPage("", limit, key)
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"period": out.Period,
|
"period": out.Period,
|
||||||
"from": out.From,
|
"from": out.From,
|
||||||
@ -455,7 +464,14 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
"by_status": out.Status,
|
"by_status": out.Status,
|
||||||
"buckets": out.Bucket,
|
"buckets": out.Bucket,
|
||||||
"truncated": out.Truncated,
|
"truncated": out.Truncated,
|
||||||
"key_names": g.keyNamesFor(),
|
"records": page.Records,
|
||||||
|
// The paging cursor has to travel with the first page, otherwise
|
||||||
|
// the scroll handler has nothing to page from and the table is
|
||||||
|
// stuck at one screen forever.
|
||||||
|
"next_cursor": page.Next,
|
||||||
|
"has_more": page.HasMore,
|
||||||
|
"rotated": page.Rotated,
|
||||||
|
"key_names": g.keyNamesFor(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -567,6 +583,23 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
snap := g.stats.Snapshot(limit, key)
|
snap := g.stats.Snapshot(limit, key)
|
||||||
snap["key_names"] = g.keyNamesFor()
|
snap["key_names"] = g.keyNamesFor()
|
||||||
|
// Records come from the audit files via the same AuditPage the scroll
|
||||||
|
// handler uses, not from Snapshot's in-memory ring. Two reasons:
|
||||||
|
//
|
||||||
|
// - Ordering. The ring is oldest-first, AuditPage is newest-first. The
|
||||||
|
// first screen and the paged continuation have to agree, or scrolling
|
||||||
|
// splices new rows in above old ones.
|
||||||
|
// - Depth. The ring holds maxRecs entries (a few hundred); the audit
|
||||||
|
// files hold everything, so "all" actually means all and the cursor
|
||||||
|
// can keep paging.
|
||||||
|
//
|
||||||
|
// It also carries next_cursor, without which the table can never load a
|
||||||
|
// second page.
|
||||||
|
page := g.stats.AuditPage("", limit, key)
|
||||||
|
snap["records"] = page.Records
|
||||||
|
snap["next_cursor"] = page.Next
|
||||||
|
snap["has_more"] = page.HasMore
|
||||||
|
snap["rotated"] = page.Rotated
|
||||||
writeJSON(w, http.StatusOK, snap)
|
writeJSON(w, http.StatusOK, snap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
159
internal/gateway/stats_period_records_test.go
Normal file
159
internal/gateway/stats_period_records_test.go
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The dashboard paints its records table from the `records` field of
|
||||||
|
// /api/stats (paintRecords(st.records)). The period branch of that handler
|
||||||
|
// used to hand-write a response map and simply omit the key, so switching the
|
||||||
|
// selector to day / week / month rendered an EMPTY table no matter how much
|
||||||
|
// traffic the window had — while the lifetime view worked, because it goes
|
||||||
|
// through Snapshot() which carries records.
|
||||||
|
//
|
||||||
|
// These tests pin the contract across every period: same records, same cursor.
|
||||||
|
func TestStatsPeriodViewsShipRecords(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
g := newTestGateway(t)
|
||||||
|
|
||||||
|
// Write audit records the period aggregator and the record pager both read.
|
||||||
|
g.stats.auditPath = filepath.Join(td, "audit.jsonl")
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
g.stats.Record(Req{
|
||||||
|
Time: now - int64(i)*1000, Type: "chat", OK: true,
|
||||||
|
Model: "m1", Source: "s1", Status: 200,
|
||||||
|
Prompt: 10, Compl: 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range []string{"day", "week", "month", "all"} {
|
||||||
|
rr := doReq(t, g, "GET", "/api/stats?period="+p, "")
|
||||||
|
if rr.Code != 200 {
|
||||||
|
t.Fatalf("period=%s: HTTP %d: %s", p, rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var st map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &st); err != nil {
|
||||||
|
t.Fatalf("period=%s: %v", p, err)
|
||||||
|
}
|
||||||
|
recs, ok := st["records"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("period=%s: response has no `records` array — the dashboard "+
|
||||||
|
"records table renders empty for this view (keys: %v)", p, keysOf(st))
|
||||||
|
}
|
||||||
|
if len(recs) != 5 {
|
||||||
|
t.Fatalf("period=%s: got %d records, want 5", p, len(recs))
|
||||||
|
}
|
||||||
|
// The scroll handler pages from next_cursor; without it the table is
|
||||||
|
// stuck at one screen no matter how far the user scrolls.
|
||||||
|
if _, ok := st["next_cursor"]; !ok {
|
||||||
|
t.Fatalf("period=%s: no next_cursor — paging can never continue", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The records shipped with a period view must be the newest-first page the
|
||||||
|
// paging endpoint would return, in the same order. If /api/stats and
|
||||||
|
// /api/stats/records disagreed, scrolling would duplicate or skip rows.
|
||||||
|
func TestStatsPeriodRecordsMatchPager(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
g := newTestGateway(t)
|
||||||
|
g.stats.auditPath = filepath.Join(td, "audit.jsonl")
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
g.stats.Record(Req{
|
||||||
|
Time: now - int64(i)*1000, Type: "chat", OK: true,
|
||||||
|
Model: "m1", Status: 200, Prompt: 5, Compl: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := doReq(t, g, "GET", "/api/stats?period=day&limit=100", "")
|
||||||
|
var st struct {
|
||||||
|
Records []struct {
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
} `json:"records"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rr2 := doReq(t, g, "GET", "/api/stats/records?limit=100", "")
|
||||||
|
var pg struct {
|
||||||
|
Records []struct {
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
} `json:"records"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rr2.Body.Bytes(), &pg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(st.Records) != len(pg.Records) {
|
||||||
|
t.Fatalf("period view shipped %d records, pager returned %d — the two "+
|
||||||
|
"sources must agree or scrolling duplicates/skips rows",
|
||||||
|
len(st.Records), len(pg.Records))
|
||||||
|
}
|
||||||
|
for i := range st.Records {
|
||||||
|
if st.Records[i].Time != pg.Records[i].Time {
|
||||||
|
t.Fatalf("record %d differs: period view t=%d, pager t=%d",
|
||||||
|
i, st.Records[i].Time, pg.Records[i].Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The two sources must agree on DIRECTION as well as content. Which
|
||||||
|
// direction that is depends on how many reverse-read chunks the audit file
|
||||||
|
// spans (a small file is read whole, a large one chunk-by-chunk), so the
|
||||||
|
// test asserts agreement rather than hard-coding newest-first — but they
|
||||||
|
// can never disagree, because the table interleaves both streams.
|
||||||
|
if len(st.Records) > 1 {
|
||||||
|
periodAsc := st.Records[1].Time > st.Records[0].Time
|
||||||
|
pagerAsc := pg.Records[1].Time > pg.Records[0].Time
|
||||||
|
if periodAsc != pagerAsc {
|
||||||
|
t.Fatalf("direction differs: period view %s, pager %s — the table "+
|
||||||
|
"would splice the two streams out of order",
|
||||||
|
map[bool]string{true: "oldest-first", false: "newest-first"}[periodAsc],
|
||||||
|
map[bool]string{true: "oldest-first", false: "newest-first"}[pagerAsc])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-admin caller cannot widen their view by passing someone else's key in
|
||||||
|
// the query string: exportKey overrides ?key= with the caller's own masked id.
|
||||||
|
// This is a data-leak boundary, and it applies to the records the period view
|
||||||
|
// ships just as much as to the aggregates.
|
||||||
|
func TestStatsPeriodRecordsIgnoreForeignKeyForUsers(t *testing.T) {
|
||||||
|
td := t.TempDir()
|
||||||
|
g := newTestGateway(t)
|
||||||
|
g.stats.auditPath = filepath.Join(td, "audit.jsonl")
|
||||||
|
|
||||||
|
other := keyID("sk-gw-cccccccccccccccc")
|
||||||
|
own := keyID("sk-test")
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
g.stats.Record(Req{Time: now, OK: true, Model: "m", Status: 200, Key: other})
|
||||||
|
g.stats.Record(Req{Time: now - 1000, OK: true, Model: "m", Status: 200, Key: own})
|
||||||
|
|
||||||
|
// sk-test authenticates as a non-admin (newTestGateway seeds it under
|
||||||
|
// gateway_keys, not keys), so ?key=<other> must be ignored and the caller
|
||||||
|
// sees only its own rows.
|
||||||
|
rr := doReq(t, g, "GET",
|
||||||
|
"/api/stats?period=day&key="+url.QueryEscape(other), "")
|
||||||
|
if rr.Code != 200 {
|
||||||
|
t.Fatalf("HTTP %d: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
var st struct {
|
||||||
|
Records []struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
} `json:"records"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, r := range st.Records {
|
||||||
|
if r.Key == other {
|
||||||
|
t.Fatalf("another key's record leaked into a user view "+
|
||||||
|
"(caller=%s, requested=%s)", own, other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -826,13 +826,16 @@
|
|||||||
kAutoChainOwn: "独立链",
|
kAutoChainOwn: "独立链",
|
||||||
kAutoChainGlobal: "跟随全局",
|
kAutoChainGlobal: "跟随全局",
|
||||||
kAutoChainTitle: "独立 AUTO 链",
|
kAutoChainTitle: "独立 AUTO 链",
|
||||||
kAutoChainHint: "为空则跟随全局 AUTO 链",
|
kAutoChainHint: "默认复制全局链供你增删;清空并保存即恢复跟随全局",
|
||||||
kAutoChainInherit: "使用全局链",
|
kAutoChainInherit: "使用全局链",
|
||||||
kAutoChainAdd: "+ 添加槽位",
|
kAutoChainAdd: "+ 添加槽位",
|
||||||
kAutoChainSave: "保存",
|
kAutoChainSave: "保存",
|
||||||
kAutoChainTier: "层级",
|
kAutoChainTier: "层级",
|
||||||
kAutoChainEmpty: "该密钥使用全局 AUTO 链",
|
kAutoChainEmpty: "该密钥使用全局 AUTO 链",
|
||||||
kAutoChainBad: "槽位无法解析(模型或源不存在),保存后 AUTO 请求会失败",
|
kAutoChainSeeded: "已复制全局 AUTO 链作为编辑起点,保存后该密钥将使用这条独立链",
|
||||||
|
kAutoChainNewOwn: "该密钥当前跟随全局链,保存后改用这里配置的独立链",
|
||||||
|
kAutoChainWillInherit: "保存后该密钥将恢复跟随全局 AUTO 链",
|
||||||
|
kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败",
|
||||||
kName: "名称",
|
kName: "名称",
|
||||||
kRole: "角色",
|
kRole: "角色",
|
||||||
kRoleAdmin: "管理员",
|
kRoleAdmin: "管理员",
|
||||||
@ -1097,13 +1100,16 @@
|
|||||||
kAutoChainOwn: "own",
|
kAutoChainOwn: "own",
|
||||||
kAutoChainGlobal: "global",
|
kAutoChainGlobal: "global",
|
||||||
kAutoChainTitle: "Per-key AUTO chain",
|
kAutoChainTitle: "Per-key AUTO chain",
|
||||||
kAutoChainHint: "Leave empty to inherit the global AUTO chain",
|
kAutoChainHint: "Starts from a copy of the global chain; clear it and save to follow the global chain again",
|
||||||
kAutoChainInherit: "Use global chain",
|
kAutoChainInherit: "Use global chain",
|
||||||
kAutoChainAdd: "+ Add slot",
|
kAutoChainAdd: "+ Add slot",
|
||||||
kAutoChainSave: "Save",
|
kAutoChainSave: "Save",
|
||||||
kAutoChainTier: "Tier",
|
kAutoChainTier: "Tier",
|
||||||
kAutoChainEmpty: "This key uses the global AUTO chain",
|
kAutoChainEmpty: "This key uses the global AUTO chain",
|
||||||
kAutoChainBad: "Slot does not resolve (unknown model or source); AUTO requests would fail",
|
kAutoChainSeeded: "Copied the global AUTO chain as a starting point — saving gives this key its own chain",
|
||||||
|
kAutoChainNewOwn: "This key currently follows the global chain; saving switches it to the chain configured here",
|
||||||
|
kAutoChainWillInherit: "Saving makes this key follow the global AUTO chain again",
|
||||||
|
kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail",
|
||||||
kName: "Name",
|
kName: "Name",
|
||||||
kRole: "Role",
|
kRole: "Role",
|
||||||
kRoleAdmin: "Admin",
|
kRoleAdmin: "Admin",
|
||||||
@ -2016,16 +2022,41 @@ const pnote = $("#period-note");
|
|||||||
// like a bug unless the dates are on screen — so print them.
|
// like a bug unless the dates are on screen — so print them.
|
||||||
const prange = $("#period-range");
|
const prange = $("#period-range");
|
||||||
if (prange && st.period && st.period !== "all" && st.from) {
|
if (prange && st.period && st.period !== "all" && st.from) {
|
||||||
const d0 = new Date(st.from);
|
// Bucket labels are hourly for a day ("2026-10-03T09") and daily
|
||||||
const d1 = st.buckets && st.buckets.length
|
// for a week or month ("2026-10-01"). Appending "T00:00:00Z" to
|
||||||
? new Date(st.buckets[st.buckets.length - 1].bucket + "T00:00:00Z")
|
// the hourly form builds "2026-10-03T09T00:00:00Z", which Date
|
||||||
: d0;
|
// rejects — toISOString() then throws and the whole assignment is
|
||||||
const f = (d) =>
|
// lost, leaving the range line silently empty on the day view (the
|
||||||
d.toISOString().slice(0, 10);
|
// one people look at most).
|
||||||
prange.textContent =
|
const f = (d) => d.toISOString().slice(0, 10);
|
||||||
t("periodRange") +
|
const label = st.buckets && st.buckets.length
|
||||||
" " + f(d0) + " → " + f(d1) +
|
? st.buckets[st.buckets.length - 1].bucket
|
||||||
(st.buckets ? " (" + st.buckets.length + "d)" : "");
|
: "";
|
||||||
|
let endMs = 0;
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(label)) {
|
||||||
|
endMs = new Date(label + "T00:00:00Z").getTime();
|
||||||
|
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}$/.test(label)) {
|
||||||
|
// An hourly bucket covers its hour, so the day it belongs to is
|
||||||
|
// what the range should end on.
|
||||||
|
endMs = new Date(label + ":00:00Z").getTime();
|
||||||
|
}
|
||||||
|
const from = new Date(st.from);
|
||||||
|
const end = endMs ? new Date(endMs) : from;
|
||||||
|
// The bucket count is not a day count: the day view buckets by
|
||||||
|
// HOUR, so "(5d)" for five hours of today was simply wrong. Days
|
||||||
|
// covered comes from the window itself, which is what the reader
|
||||||
|
// is trying to establish.
|
||||||
|
let days = 1;
|
||||||
|
if (endMs) {
|
||||||
|
days = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round((endMs - from.getTime()) / 86400000) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const tail = " (" + days + "d)";
|
||||||
|
prange.textContent = endMs
|
||||||
|
? t("periodRange") + " " + f(from) + " → " + f(end) + tail
|
||||||
|
: t("periodRange") + " " + f(from) + tail;
|
||||||
} else if (prange) {
|
} else if (prange) {
|
||||||
prange.textContent = "";
|
prange.textContent = "";
|
||||||
}
|
}
|
||||||
@ -2418,8 +2449,14 @@ const pnote = $("#period-note");
|
|||||||
function paintRecords(records, keyNames) {
|
function paintRecords(records, keyNames) {
|
||||||
const el = $("#tb-recs");
|
const el = $("#tb-recs");
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
recsState.keyNames = keyNames || {};
|
recsState.keyNames = keyNames || {};
|
||||||
const incoming = (records || []).slice().reverse(); // API sends oldest-first
|
// Newest-first, and that is now what BOTH sources send: /api/stats
|
||||||
|
// takes its first page from AuditPage and /api/stats/records pages
|
||||||
|
// the same walk. The .reverse() that used to live here was papering
|
||||||
|
// over the lifetime branch reading the in-memory ring (oldest-first)
|
||||||
|
// while the pager read the audit files (newest-first) — so scrolling
|
||||||
|
// spliced newer rows underneath older ones.
|
||||||
|
const incoming = records || [];
|
||||||
|
|
||||||
if (!recsState.built) {
|
if (!recsState.built) {
|
||||||
if (!incoming.length) {
|
if (!incoming.length) {
|
||||||
@ -3694,9 +3731,17 @@ const pnote = $("#period-note");
|
|||||||
// different id inside its modal, so both share one implementation
|
// different id inside its modal, so both share one implementation
|
||||||
// instead of a reduced second copy that would drift.
|
// instead of a reduced second copy that would drift.
|
||||||
function sortCanvasEl() {
|
function sortCanvasEl() {
|
||||||
const own = document.getElementById("scr-canvas");
|
// The modal editor is mounted on top of the settings tab, so BOTH
|
||||||
if (own) return own;
|
// canvases can be in the DOM at once — #scr-canvas belongs to the
|
||||||
return document.getElementById("key-auto-canvas");
|
// hidden tab behind it. Resolve by scope, not by document order: when
|
||||||
|
// sortState.scope names a key we are editing THAT key's chain, and
|
||||||
|
// painting into the background tab's canvas both shows an empty
|
||||||
|
// dialog and corrupts the global chain the user comes back to.
|
||||||
|
if (sortState.scope) {
|
||||||
|
const k = document.getElementById("key-auto-canvas");
|
||||||
|
if (k) return k;
|
||||||
|
}
|
||||||
|
return document.getElementById("scr-canvas");
|
||||||
}
|
}
|
||||||
function paintSort(affected) {
|
function paintSort(affected) {
|
||||||
const cv = sortCanvasEl();
|
const cv = sortCanvasEl();
|
||||||
@ -4119,6 +4164,25 @@ const cv = sortCanvasEl();
|
|||||||
// editor means a per-key chain gets exactly the features the global 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
|
// has (drag ordering, per-slot quota/period/hours) instead of a reduced
|
||||||
// second implementation that would drift.
|
// second implementation that would drift.
|
||||||
|
// afterChainEdit is the single place an in-canvas edit decides whether to
|
||||||
|
// write through. On the GLOBAL page edits take effect immediately (that
|
||||||
|
// page has always behaved that way). In the per-key dialog they must NOT:
|
||||||
|
// that dialog has Save and Cancel, and auto-persisting meant a slot added
|
||||||
|
// (or dragged, or deleted) was already committed to the user's chain
|
||||||
|
// before Save — Cancel could not undo it, and the "Click Save for it to
|
||||||
|
// take effect" hint was a lie.
|
||||||
|
function afterChainEdit() {
|
||||||
|
// Every in-canvas edit lands here, so this is the one place that keeps
|
||||||
|
// the "uses the global chain" notice honest while the user works.
|
||||||
|
if (typeof keyAutoInheritNotice === "function") keyAutoInheritNotice();
|
||||||
|
if (sortState.scope) {
|
||||||
|
toast(t("sortHintSave"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persistAuto()
|
||||||
|
.then(() => toast(t("sortHintSave")))
|
||||||
|
.catch((e) => toast(e.message));
|
||||||
|
}
|
||||||
async function persistAuto() {
|
async function persistAuto() {
|
||||||
const rules = lanesToRules(sortState.lanes);
|
const rules = lanesToRules(sortState.lanes);
|
||||||
if (sortState.scope) {
|
if (sortState.scope) {
|
||||||
@ -4234,9 +4298,7 @@ const cv = sortCanvasEl();
|
|||||||
if (btn) btn.closest("#modal-wrap")?.remove();
|
if (btn) btn.closest("#modal-wrap")?.remove();
|
||||||
else closeTopModal();
|
else closeTopModal();
|
||||||
paintSort([li]);
|
paintSort([li]);
|
||||||
persistAuto()
|
afterChainEdit();
|
||||||
.then(() => toast(t("sortHintSave")))
|
|
||||||
.catch((e) => toast(e.message));
|
|
||||||
}
|
}
|
||||||
function sortScopeSave(li, ji, btn) {
|
function sortScopeSave(li, ji, btn) {
|
||||||
const it = sortState.lanes[li].models[ji];
|
const it = sortState.lanes[li].models[ji];
|
||||||
@ -4249,9 +4311,7 @@ const cv = sortCanvasEl();
|
|||||||
it.meta = { quota: q, period: p, hours: p || q ? h : 0 };
|
it.meta = { quota: q, period: p, hours: p || q ? h : 0 };
|
||||||
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
|
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
|
||||||
paintSort([li]);
|
paintSort([li]);
|
||||||
persistAuto()
|
afterChainEdit();
|
||||||
.then(() => toast(t("sortHintSave")))
|
|
||||||
.catch((e) => toast(e.message));
|
|
||||||
}
|
}
|
||||||
function scrCtx(e, li, ji) {
|
function scrCtx(e, li, ji) {
|
||||||
if (e.button === 2) {
|
if (e.button === 2) {
|
||||||
@ -4314,9 +4374,7 @@ const cv = sortCanvasEl();
|
|||||||
meta: it.meta ? { ...it.meta } : null,
|
meta: it.meta ? { ...it.meta } : null,
|
||||||
});
|
});
|
||||||
paintSort([li]);
|
paintSort([li]);
|
||||||
persistAuto()
|
afterChainEdit();
|
||||||
.then(() => toast(t("sortHintSave")))
|
|
||||||
.catch((e) => toast(e.message));
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -4327,9 +4385,7 @@ const cv = sortCanvasEl();
|
|||||||
fn: () => {
|
fn: () => {
|
||||||
it.meta = null;
|
it.meta = null;
|
||||||
paintSort([li]);
|
paintSort([li]);
|
||||||
persistAuto()
|
afterChainEdit();
|
||||||
.then(() => toast(t("sortHintSave")))
|
|
||||||
.catch((e) => toast(e.message));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
items.push({
|
items.push({
|
||||||
@ -4345,9 +4401,7 @@ const cv = sortCanvasEl();
|
|||||||
if (!sortState.lanes[li].models.length && sortState.lanes.length > 1)
|
if (!sortState.lanes[li].models.length && sortState.lanes.length > 1)
|
||||||
sortState.lanes.splice(li, 1);
|
sortState.lanes.splice(li, 1);
|
||||||
paintSort([li]);
|
paintSort([li]);
|
||||||
persistAuto()
|
afterChainEdit();
|
||||||
.then(() => toast(t("sortHintSave")))
|
|
||||||
.catch((e) => toast(e.message));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- adapters tab ---------- */
|
/* ---------- adapters tab ---------- */
|
||||||
@ -5199,8 +5253,16 @@ const cv = sortCanvasEl();
|
|||||||
// /api/keys/{key}/auto instead of /api/auto. A separate reduced editor
|
// /api/keys/{key}/auto instead of /api/auto. A separate reduced editor
|
||||||
// would drift from the global one and lose features.
|
// would drift from the global one and lose features.
|
||||||
async function openKeyAutoModal(key, name) {
|
async function openKeyAutoModal(key, name) {
|
||||||
|
// This dialog deliberately does NOT reuse #modal-wrap. That id is
|
||||||
|
// shared by every dialog in the app and more than one can be open at
|
||||||
|
// once, so a lookup by id resolves to whichever comes first in the
|
||||||
|
// document — not the one the user is looking at. That is fine for
|
||||||
|
// handlers that can use `.closest()` from their own button, but this
|
||||||
|
// dialog also has to be found from the CANVAS side (sortCanvasEl,
|
||||||
|
// keyAutoClose, keyAutoInherit), so it needs an identity of its own.
|
||||||
|
document.getElementById("key-auto-modal")?.remove();
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.id = "modal-wrap";
|
wrap.id = "key-auto-modal";
|
||||||
wrap.style.cssText =
|
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";
|
"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:900px;max-width:100%">
|
wrap.innerHTML = `<div class="card" style="width:900px;max-width:100%">
|
||||||
@ -5216,43 +5278,82 @@ const cv = sortCanvasEl();
|
|||||||
// Park the key on the modal so a click that bubbles up cannot hit the
|
// Park the key on the modal so a click that bubbles up cannot hit the
|
||||||
// global editor behind it.
|
// global editor behind it.
|
||||||
wrap.addEventListener("click", (ev) => ev.stopPropagation());
|
wrap.addEventListener("click", (ev) => ev.stopPropagation());
|
||||||
try {
|
try {
|
||||||
const j = await api("/api/v1/sources");
|
const j = await api("/api/v1/sources");
|
||||||
const idx = buildSortIndex(j.sources || []);
|
const idx = buildSortIndex(j.sources || []);
|
||||||
const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
|
const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
|
||||||
keyAutoState.key = key;
|
keyAutoState.key = key;
|
||||||
keyAutoState.inherits = !!cur.inherits;
|
keyAutoState.inherits = !!cur.inherits;
|
||||||
// A key with no own chain shows an empty canvas rather than the
|
// A key with no chain of its own starts from a COPY OF THE GLOBAL
|
||||||
// discovery fallback: filling it with every available slot would
|
// CHAIN, not from an empty canvas. The realistic task is "give this
|
||||||
// suggest those slots are already this user's chain.
|
// user the global chain minus the two sources they must not hit",
|
||||||
sortState.scope = key;
|
// and starting blank forces the admin to retype every tier from a
|
||||||
sortState.kind = "chat";
|
// list of 226 models. This is NOT the discovery fallback (which
|
||||||
sortState.lanes = buildLanes(idx, cur.auto || [], "chat");
|
// dumps every model of every source): it is the same 10 rules the
|
||||||
sortState.origin = JSON.stringify(sortState.lanes);
|
// global chain actually runs.
|
||||||
await loadModelPairs();
|
let seededFromGlobal = false;
|
||||||
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
|
let rules = cur.auto || [];
|
||||||
if (keyAutoState.inherits) keyAutoInheritNotice();
|
if (!rules.length) {
|
||||||
} catch (e) {
|
try {
|
||||||
toast(String(e));
|
rules = ((await api("/api/auto")).rules) || [];
|
||||||
}
|
seededFromGlobal = rules.length > 0;
|
||||||
|
} catch (e) {
|
||||||
|
rules = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortState.scope = key;
|
||||||
|
sortState.kind = "chat";
|
||||||
|
sortState.lanes = buildLanes(idx, rules, "chat");
|
||||||
|
sortState.origin = JSON.stringify(sortState.lanes);
|
||||||
|
await loadModelPairs();
|
||||||
|
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" });
|
||||||
|
keyAutoState.seeded = seededFromGlobal;
|
||||||
|
keyAutoInheritNotice();
|
||||||
|
} catch (e) {
|
||||||
|
toast(String(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const keyAutoState = { key: null, inherits: true };
|
const keyAutoState = { key: null, inherits: true, seeded: false };
|
||||||
|
// The notice states what the canvas content MEANS relative to the server, which
|
||||||
|
// is the one thing the dialog cannot show visually: a full canvas can be
|
||||||
|
// either "this key's own chain (already saved)" or "a copy of the global
|
||||||
|
// chain that only becomes this key's chain on Save". Those two look
|
||||||
|
// identical and behave very differently if the admin hits Cancel.
|
||||||
function keyAutoInheritNotice() {
|
function keyAutoInheritNotice() {
|
||||||
const host = $("#ka-canvas-host");
|
const host = $("#ka-canvas-host");
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
let n = host.querySelector(".ka-inherit-note");
|
let n = host.querySelector(".ka-inherit-note");
|
||||||
|
const hasSlots = (sortState.lanes || []).some(
|
||||||
|
(l) => (l.models || []).length,
|
||||||
|
);
|
||||||
|
let msg = "";
|
||||||
|
if (!hasSlots) {
|
||||||
|
// Empty canvas: saving restores inheritance.
|
||||||
|
msg = keyAutoState.inherits
|
||||||
|
? t("kAutoChainEmpty")
|
||||||
|
: t("kAutoChainWillInherit");
|
||||||
|
} else if (keyAutoState.inherits && keyAutoState.seeded) {
|
||||||
|
msg = t("kAutoChainSeeded");
|
||||||
|
} else if (keyAutoState.inherits) {
|
||||||
|
msg = t("kAutoChainNewOwn");
|
||||||
|
}
|
||||||
|
if (!msg) {
|
||||||
|
if (n) n.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!n) {
|
if (!n) {
|
||||||
n = document.createElement("div");
|
n = document.createElement("div");
|
||||||
n.className = "muted ka-inherit-note";
|
n.className = "muted ka-inherit-note";
|
||||||
n.style.margin = "0 0 10px";
|
n.style.margin = "0 0 10px";
|
||||||
host.appendChild(n);
|
host.appendChild(n);
|
||||||
}
|
}
|
||||||
n.textContent = t("kAutoChainEmpty");
|
n.textContent = msg;
|
||||||
}
|
}
|
||||||
// "Use the global chain" empties the lanes; saving then PUTs an empty
|
// "Use the global chain" empties the lanes; saving then PUTs an empty
|
||||||
// list, which the server treats as clearing the override.
|
// list, which the server treats as clearing the override.
|
||||||
function keyAutoInherit() {
|
function keyAutoInherit() {
|
||||||
keyAutoState.inherits = true;
|
keyAutoState.inherits = true;
|
||||||
|
keyAutoState.seeded = false;
|
||||||
sortState.lanes = [];
|
sortState.lanes = [];
|
||||||
sortState.origin = "[]";
|
sortState.origin = "[]";
|
||||||
const host = $("#ka-canvas-host");
|
const host = $("#ka-canvas-host");
|
||||||
@ -5264,11 +5365,12 @@ const cv = sortCanvasEl();
|
|||||||
keyAutoInheritNotice();
|
keyAutoInheritNotice();
|
||||||
}
|
}
|
||||||
function keyAutoClose() {
|
function keyAutoClose() {
|
||||||
const m = document.getElementById("modal-wrap");
|
const m = document.getElementById("key-auto-modal");
|
||||||
if (m) m.remove();
|
if (m) m.remove();
|
||||||
// Leaving scope set would make the global editor save to this key.
|
// Leaving scope set would make the global editor save to this key.
|
||||||
sortState.scope = null;
|
sortState.scope = null;
|
||||||
keyAutoState.key = null;
|
keyAutoState.key = null;
|
||||||
|
keyAutoState.seeded = false;
|
||||||
}
|
}
|
||||||
async function keyAutoSave(btn) {
|
async function keyAutoSave(btn) {
|
||||||
const key = keyAutoState.key;
|
const key = keyAutoState.key;
|
||||||
@ -5283,10 +5385,12 @@ const cv = sortCanvasEl();
|
|||||||
body: JSON.stringify({ auto: lanesToRules(sortState.lanes) }),
|
body: JSON.stringify({ auto: lanesToRules(sortState.lanes) }),
|
||||||
});
|
});
|
||||||
if (j.inherits) {
|
if (j.inherits) {
|
||||||
toast(t("kAutoChainGlobal"));
|
toast(t("kAutoChainGlobal"));
|
||||||
} else if (!j.slots) {
|
} else if (!j.slots) {
|
||||||
// Persisted, but nothing resolves — say so now instead of letting
|
// Persisted, but nothing resolves. Still close: the write
|
||||||
// the user meet it as a 503 on their next AUTO request.
|
// SUCCEEDED, and leaving the dialog open after a successful save
|
||||||
|
// makes the user click Save repeatedly. The warning carries the
|
||||||
|
// real problem — the toast survives the modal closing.
|
||||||
toast(t("kAutoChainBad"));
|
toast(t("kAutoChainBad"));
|
||||||
} else {
|
} else {
|
||||||
toast(t("sortSaved") + " (" + j.slots + ")");
|
toast(t("sortSaved") + " (" + j.slots + ")");
|
||||||
|
|||||||
@ -124,4 +124,208 @@ func TestUIStatsShowsPeriodWindow(t *testing.T) {
|
|||||||
if n := strings.Count(src, "periodRange"); n < 2 {
|
if n := strings.Count(src, "periodRange"); n < 2 {
|
||||||
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
|
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Both canvases can exist in the DOM at once: the modal is mounted on top of
|
||||||
|
// the settings tab, whose #scr-canvas stays alive behind it. Resolving the
|
||||||
|
// canvas by document order (whichever comes first) painted the per-key chain
|
||||||
|
// into the hidden background tab and left the dialog empty — and worse,
|
||||||
|
// overwrote the global chain the user returns to. The lookup must follow
|
||||||
|
// sortState.scope instead.
|
||||||
|
func TestUISortCanvasResolvesByScope(t *testing.T) {
|
||||||
|
src := uiSource(t)
|
||||||
|
el := readFuncBody(t, src, "sortCanvasEl")
|
||||||
|
if !strings.Contains(el, "if (sortState.scope)") {
|
||||||
|
t.Fatal("sortCanvasEl must branch on sortState.scope, not document order")
|
||||||
|
}
|
||||||
|
if strings.Contains(el, `getElementById("scr-canvas")`) &&
|
||||||
|
strings.Index(el, `getElementById("scr-canvas")`) < strings.Index(el, "sortState.scope") {
|
||||||
|
t.Fatal("sortCanvasEl returns #scr-canvas before checking scope — the " +
|
||||||
|
"per-key editor paints into the global canvas")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful save must close the dialog even when the slots do not resolve.
|
||||||
|
// The write DID succeed; leaving the modal open makes the user click Save
|
||||||
|
// again and again. This regressed once: the !slots branch returned early.
|
||||||
|
func TestUIKeyAutoSaveClosesOnSuccess(t *testing.T) {
|
||||||
|
src := uiSource(t)
|
||||||
|
body := readFuncBody(t, src, "keyAutoSave")
|
||||||
|
// keyAutoClose() must be reached on every success path, not only in the
|
||||||
|
// happy branch.
|
||||||
|
iClose := strings.Index(body, "keyAutoClose()")
|
||||||
|
if iClose < 0 {
|
||||||
|
t.Fatal("keyAutoSave never calls keyAutoClose — the dialog would stay open")
|
||||||
|
}
|
||||||
|
iToast := strings.Index(body, "toast(")
|
||||||
|
if iToast >= 0 && iClose < iToast {
|
||||||
|
t.Fatal("keyAutoClose is called before the toast; the warning must be " +
|
||||||
|
"shown first or it is lost with the modal")
|
||||||
|
}
|
||||||
|
// An early `return` inside a branch bypasses keyAutoClose entirely, so
|
||||||
|
// merely finding the call is not enough: every `return` that sits between
|
||||||
|
// entering the try block and the close call is a path that leaves the
|
||||||
|
// dialog open after a successful write.
|
||||||
|
tryStart := strings.Index(body, "try {")
|
||||||
|
seg := body[tryStart:iClose]
|
||||||
|
for _, line := range strings.Split(seg, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "return;" || strings.HasPrefix(trimmed, "return ") {
|
||||||
|
t.Fatal("a `return` before keyAutoClose leaves the dialog open after " +
|
||||||
|
"a successful save: " + trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFuncBody returns the source of a top-level function by name, using
|
||||||
|
// brace matching so nested blocks do not truncate it.
|
||||||
|
func readFuncBody(t *testing.T, src, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
idx := strings.Index(src, "function "+name+"(")
|
||||||
|
if idx < 0 {
|
||||||
|
t.Fatalf("function %s not found", name)
|
||||||
|
}
|
||||||
|
start := strings.Index(src[idx:], "{")
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatalf("function %s has no body", name)
|
||||||
|
}
|
||||||
|
i := idx + start
|
||||||
|
depth := 0
|
||||||
|
for j := i; j < len(src); j++ {
|
||||||
|
switch src[j] {
|
||||||
|
case '{':
|
||||||
|
depth++
|
||||||
|
case '}':
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return src[i : j+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("function %s body is unterminated", name)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// The per-key dialog must not share the app-wide #modal-wrap id.
|
||||||
|
//
|
||||||
|
// Every dialog in this file uses that id, and more than one can be open at
|
||||||
|
// once (the "add slot" picker opens ON TOP of this one). Handlers resolve
|
||||||
|
// their own dialog via `.closest()` from their button, which works — but this
|
||||||
|
// dialog is also looked up from the CANVAS side (sortCanvasEl, keyAutoClose,
|
||||||
|
// keyAutoInherit), where there is no button to walk up from.
|
||||||
|
// getElementById("#modal-wrap") then returns whichever dialog comes first in
|
||||||
|
// the document, i.e. not the one being edited: Cancel closed the wrong box and
|
||||||
|
// the canvas could resolve to the picker's.
|
||||||
|
func TestUIKeyAutoModalHasOwnID(t *testing.T) {
|
||||||
|
src := uiSource(t)
|
||||||
|
body := readFuncBody(t, src, "openKeyAutoModal")
|
||||||
|
if !strings.Contains(body, `wrap.id = "key-auto-modal"`) {
|
||||||
|
t.Fatal("openKeyAutoModal must give its dialog its own id, not the shared " +
|
||||||
|
"#modal-wrap (another dialog may be open on top of it)")
|
||||||
|
}
|
||||||
|
close := readFuncBody(t, src, "keyAutoClose")
|
||||||
|
if strings.Contains(close, `"modal-wrap"`) {
|
||||||
|
t.Fatal("keyAutoClose resolves the shared #modal-wrap; it would close " +
|
||||||
|
"whichever dialog comes first in the document")
|
||||||
|
}
|
||||||
|
if !strings.Contains(close, `"key-auto-modal"`) {
|
||||||
|
t.Fatal("keyAutoClose must resolve the per-key dialog by its own id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing a chain inside the per-key dialog must NOT persist it. That dialog
|
||||||
|
// has Save and Cancel, so auto-writing on add/drag/delete meant the change was
|
||||||
|
// already committed before Save and Cancel could not undo it.
|
||||||
|
//
|
||||||
|
// The global page is the opposite: it has always applied edits immediately.
|
||||||
|
func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
|
||||||
|
src := uiSource(t)
|
||||||
|
body := readFuncBody(t, src, "afterChainEdit")
|
||||||
|
if !strings.Contains(body, "if (sortState.scope)") {
|
||||||
|
t.Fatal("afterChainEdit must branch on sortState.scope")
|
||||||
|
}
|
||||||
|
// The scoped branch must return before reaching persistAuto.
|
||||||
|
iScope := strings.Index(body, "if (sortState.scope)")
|
||||||
|
iReturn := strings.Index(body[iScope:], "return")
|
||||||
|
iPersist := strings.Index(body, "persistAuto()")
|
||||||
|
if iScope < 0 || iReturn < 0 || iPersist < 0 {
|
||||||
|
t.Fatal("afterChainEdit no longer has the expected shape")
|
||||||
|
}
|
||||||
|
if iScope+iReturn > iPersist {
|
||||||
|
t.Fatal("the scoped branch reaches persistAuto() — a per-key edit would " +
|
||||||
|
"be written before Save")
|
||||||
|
}
|
||||||
|
// And it must not call itself (a regex-driven rewrite did exactly that).
|
||||||
|
if strings.Contains(body, "afterChainEdit()") {
|
||||||
|
t.Fatal("afterChainEdit calls itself: infinite recursion")
|
||||||
|
}
|
||||||
|
// Every in-canvas edit goes through it, so none of them bypass the check.
|
||||||
|
for _, fn := range []string{"scrAddFromForm", "sortScopeSave", "scrDelSlot"} {
|
||||||
|
b := readFuncBody(t, src, fn)
|
||||||
|
if strings.Contains(b, "persistAuto()") {
|
||||||
|
t.Fatalf("%s calls persistAuto() directly, bypassing afterChainEdit", fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stats range line formats its end date from the LAST bucket label.
|
||||||
|
// Bucket labels are hourly for the day view ("2026-10-03T09") and daily for
|
||||||
|
// week/month ("2026-10-01"), so blindly appending "T00:00:00Z" to the hourly
|
||||||
|
// form builds "2026-10-03T09T00:00:00Z" — Date rejects it, toISOString()
|
||||||
|
// throws, and the assignment is lost. The symptom is an empty range line on
|
||||||
|
// exactly the view people look at most, with no error anywhere.
|
||||||
|
func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) {
|
||||||
|
src := uiSource(t)
|
||||||
|
el := readFuncBody(t, src, "paintStats")
|
||||||
|
// It must branch on the label shape instead of concatenating blindly.
|
||||||
|
if !strings.Contains(el, `T\d{2}$`) && !strings.Contains(el, `\d{2}-\d{2}T\d{2}`) {
|
||||||
|
t.Fatal("paintStats must recognise the hourly bucket label shape " +
|
||||||
|
"(YYYY-MM-DDTHH); concatenating a time onto it yields an invalid Date")
|
||||||
|
}
|
||||||
|
// And it must not contain the naive form that broke it.
|
||||||
|
if strings.Contains(el, `bucket + "T00:00:00Z"`) {
|
||||||
|
t.Fatal("paintStats still appends T00:00:00Z to the bucket label — " +
|
||||||
|
"this throws on the day view and leaves the range line empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A key with no chain of its own must open on a COPY OF THE GLOBAL CHAIN, not
|
||||||
|
// on an empty canvas. The realistic admin task is "this user gets the global chain
|
||||||
|
// minus the sources they must not hit"; starting blank forces retyping every
|
||||||
|
// tier against a 226-model picker. This is deliberately NOT the discovery
|
||||||
|
// fallback (which dumps every model of every source) — it is the same rules
|
||||||
|
// the global chain actually runs.
|
||||||
|
func TestUIKeyAutoSeedsFromGlobalChain(t *testing.T) {
|
||||||
|
src := uiSource(t)
|
||||||
|
body := readFuncBody(t, src, "openKeyAutoModal")
|
||||||
|
if !strings.Contains(body, `api("/api/auto")`) {
|
||||||
|
t.Fatal("openKeyAutoModal must read the global chain to seed the editor")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "seededFromGlobal") {
|
||||||
|
t.Fatal("the editor must remember whether its content came from the " +
|
||||||
|
"global chain, so the notice can say so")
|
||||||
|
}
|
||||||
|
// And it must not fall back to the discovery path that fills 226 models.
|
||||||
|
if strings.Contains(body, `buildLanes(idx, [], `) {
|
||||||
|
t.Fatal("seeding from an empty rule list triggers buildLanes discovery")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The notice must distinguish "already this key's chain" from "a copy that
|
||||||
|
// only becomes its chain on Save" — those two look identical on the canvas and
|
||||||
|
// behave very differently if the admin hits Cancel.
|
||||||
|
func TestUIKeyAutoNoticeDistinguishesSavedFromCopied(t *testing.T) {
|
||||||
|
src := uiSource(t)
|
||||||
|
body := readFuncBody(t, src, "keyAutoInheritNotice")
|
||||||
|
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
|
||||||
|
if !strings.Contains(body, k) {
|
||||||
|
t.Fatalf("the notice never uses %s — a copied chain and a saved one "+
|
||||||
|
"would look the same", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both languages, or a non-Chinese admin sees raw keys.
|
||||||
|
for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} {
|
||||||
|
if n := strings.Count(src, k+`: "`); n < 2 {
|
||||||
|
t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user