diff --git a/README.md b/README.md index 65372f4..985af10 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ ### 强大的多租户调度能力 - **多密钥多租户**:支持无限密钥,每个密钥独立角色、模型范围、Token 配额、重置周期 +- **密钥用量配额**:每把 key 单独配总 token 配额 + 请求数配额与重置周期(小时/周/月/自定义 N 小时),跨模型共享预算;耗尽返 429 + `Retry-After` 可自动恢复,admin key 永不受限 - **AUTO 智能调度**:基于优先级档位的分级调度,同优先级源自动轮询负载均衡,故障自动毫秒级故障转移 - **自愈冷却**:冷却上限 5 分钟,过半后放行 1 个探测请求,上游/额度恢复即刻回归轮询,无需等满冷却窗口 - **Token 配额管理**:精确到模型级别的 Token 配额控制,支持小时/周/月/自定义小时周期自动重置 @@ -178,6 +179,50 @@ sources: - 客户端用任意一个已授权的密钥明文作为 Bearer(`Authorization: Bearer `)。 - 删除密钥即从运行时存储移除,立即失效。 +#### 密钥用量配额 + +每个密钥可单独限制用量与用量重置周期,两级配额同时生效: + +```yaml +keys: + - key: sk-gw- + role: user + name: agent-alice + # ---- 整钥配额(跳模型)---- + token_quota: 5000000 # 本周期内这把 key 的总 token 预算,0 = 无限 + req_quota: 20000 # 本周期内的请求次数,0 = 无限 + period: nhour # "" | hour | week | month | nhour + hours: 6 # 仅 nhour:每 6 小时重置 + # ---- 模型范围(可选,逐模型配额)---- + models: + - model: m1 + token_quota: 1000000 # 本周期内该模型(该 key)的 token 预算 + period: hour + - model: AUTO +``` + +- `period` 词表:空 = 永不过期(累计总量),`hour` / `week` / `month` = 固定窗口, + `nhour` + `hours` = 自定义小时数。**拼错的周期在写入时就被拒**,不会静默变成 + 永不过期。 +- 整钥配额跨该 key 所有模型共享一份预算;`models[]` 里的配额则是逐模型独立计数。 + 两者都按 key 隔离,A key 的用量不会消耗 B key 的额度。 +- 配额统计含聊天、流式、生图,跨重启从审计日志回放(保留 40 天,覆盖最长的 + month 窗口)。 +- 配额耗尽返回 **429 + `Retry-After`**(`rate_limit_exceeded`),客户端可等窗口 + 重置后自动恢复;模型越权才是 403。**admin 密钥永不受配额限制**, + 避免把管理员锁在门外。 +- 窗口用量按整点小时分桶统计,实际释放比配置窗口最多晚 1 小时(配额宁可晚释放 + 也不超发)。 +- `PUT /api/keys/{key}` 的配额字段是可选的:省略 = 保留原值,显式 `0` = 解除限制。 + 只改模型范围不会清空已配置的预算。 + +```bash +# 配额耗尽时客户端看到 +HTTP/1.1 429 Too Many Requests +Retry-After: 2100 +{"error":{"type":"rate_limit_exceeded","message":"key token quota exceeded (5000000/5000000, resets every 6h)"}} +``` + ### 模型路由 `/v1/chat/completions` 的 `model` 解析顺序: diff --git a/README_EN.md b/README_EN.md index f623c85..6f1af61 100644 --- a/README_EN.md +++ b/README_EN.md @@ -38,6 +38,10 @@ Extracted and independently evolved from the multi-source LLM adapter layer of `reasoning_content`, `tool_calls`, `usage`). - **Image generation**: `POST /v1/images/generations`, routed to models with `kind: image`. +- **Per-key usage quota**: each key carries its own token and request caps plus a + reset period (hour/week/month/custom N hours), shared across every model that + key may use. Exhaustion answers 429 + `Retry-After` so a client resumes when + the window rolls over; admin keys are never capped. - **Multimodal**: `content` arrays (`image_url` etc.) pass through losslessly; Anthropic/Gemini/Ollama are translated automatically. - **LuaJIT VM**: golua-binding LuaJIT; each adapter has its own VM + worker @@ -176,6 +180,54 @@ under the `keys` field of the runtime file (encrypted at rest): `Authorization: Bearer `. - Deleting a key removes it from the store immediately. +#### Per-key usage quota + +Each key can cap its own spend and reset period. Two levels apply at once: + +```yaml +keys: + - key: sk-gw- + role: user + name: agent-alice + # ---- key-wide (across every model) ---- + token_quota: 5000000 # total token budget for this window, 0 = unlimited + req_quota: 20000 # requests per window, 0 = unlimited + period: nhour # "" | hour | week | month | nhour + hours: 6 # n-hour only: resets every 6 hours + # ---- per-model scope (optional) ---- + models: + - model: m1 + token_quota: 1000000 + period: hour + - model: AUTO +``` + +- `period`: empty = never resets (lifetime total); `hour` / `week` / `month` = + fixed windows; `nhour` + `hours` = a custom hour count. **A misspelled + period is rejected at write time** rather than silently becoming a + never-resetting quota. +- The key-wide cap is one budget shared by every model the key may use; + quotas under `models[]` are counted per model. Both are isolated per key — + one key's traffic never drains another's budget. +- Usage counts chat, streaming and image requests, and survives a restart by + replaying the audit log (40 days retained, covering the longest `month` + window). +- An exhausted quota returns **429 + `Retry-After`** + (`rate_limit_exceeded`) so a client resumes when the window rolls over; a + model the key may not use stays 403. **Admin keys are never capped**, so a + cap can never lock the operator out. +- Buckets are whole unix hours, so a window frees up at most an hour late + (deliberately freeing late rather than overspending). +- On `PUT /api/keys/{key}` the quota fields are optional: omitting them keeps + the stored caps, sending `0` explicitly lifts a cap. Editing only the model + scope never clears a budget that was already set. + +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 2100 +{"error":{"type":"rate_limit_exceeded","message":"key token quota exceeded (5000000/5000000, resets every 6h)"}} +``` + ### Model routing `/v1/chat/completions` `model` resolution order: diff --git a/internal/gateway/key_quota_api_test.go b/internal/gateway/key_quota_api_test.go index fe8d9d4..b27ad99 100644 --- a/internal/gateway/key_quota_api_test.go +++ b/internal/gateway/key_quota_api_test.go @@ -228,3 +228,32 @@ func TestAutoScopeQuotaHonoursWindow(t *testing.T) { } var _ = fmt.Sprintf + +// A user must be able to see their own budget: /api/keys/me is the only key +// view a non-admin gets, so a cap missing from it is invisible to the very +// client it constrains. +func TestKeyMeExposesOwnQuota(t *testing.T) { + g := adminGateway(t, + config.GWKey{Key: "sk-admin", Role: "admin"}, + config.GWKey{Key: "sk-u", Role: "user", Name: "agent", + TokenQuota: 123456, ReqQuota: 42, Period: "week", Hours: 0}, + ) + req, _ := http.NewRequest("GET", "/api/keys/me", nil) + req.Header.Set("Authorization", "Bearer sk-u") + rr := httptest.NewRecorder() + g.Handler().ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("GET /api/keys/me: %d %s", rr.Code, rr.Body.String()) + } + // the endpoint wraps the record: {"key": {...}} + var wrap struct { + Key config.GWKey `json:"key"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &wrap); err != nil { + t.Fatalf("decode: %v (%s)", err, rr.Body.String()) + } + me := wrap.Key + if me.TokenQuota != 123456 || me.ReqQuota != 42 || me.Period != "week" { + t.Errorf("own quota not visible to the key's owner: %+v", me) + } +} diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 94704de..02d5cfd 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -339,6 +339,9 @@ .key-canvas{border:1px solid var(--line);border-radius:16px;padding:14px;margin-bottom:14px;background:var(--card); backdrop-filter:blur(var(--glass));box-shadow:var(--sh-sm)} .kc-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap} + /* key-wide caps sit inline in the head: they belong to the key, not to + any one model brick, and must not be draggable with one. */ + .kc-caps{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap} .kc-blocks{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:12px;background:var(--card2); border:1px dashed var(--line);border-radius:12px;padding:14px;min-height:64px} .kc-blocks.ovh{outline:2px dashed var(--primary);outline-offset:2px} @@ -818,6 +821,15 @@ kAnySrc: "任意源", kQuotaB: "Token 配额", kQuotaHintB: "0 / 留空 = 无限", + kKeyQuota: "密钥总配额", + kKeyQuotaHint: + "限制这把密钥在重置周期内的总用量(跳模型)。0 / 留空 = 无限。", + kKeyReqQuota: "请求数配额", + kKeyReqQuotaHint: "限制周期内的请求次数。0 / 留空 = 无限。", + kKeyQuotaAdmin: + "admin 密钥永不受配额限制(避免把管理员锁在门外)。", + kKeyQuotaEdit: "配额", + kKeyQuotaNone: "无限", kPeriodB: "重置周期", kPerNothing: "不限", kPerHour: "每 小时", @@ -1043,6 +1055,16 @@ kAnySrc: "any source", kQuotaB: "Token quota", kQuotaHintB: "0 / empty = unlimited", + kKeyQuota: "Key-wide quota", + kKeyQuotaHint: + "Caps this key's total spend per reset window, across every model it may use. 0 / empty = unlimited.", + kKeyReqQuota: "Request quota", + kKeyReqQuotaHint: + "Caps requests per window. 0 / empty = unlimited.", + kKeyQuotaAdmin: + "Admin keys are never capped — a cap could lock the operator out.", + kKeyQuotaEdit: "Quota", + kKeyQuotaNone: "unlimited", kPeriodB: "Reset period", kPerNothing: "Never", kPerHour: "Every hour", @@ -4232,10 +4254,11 @@ async function renderKeysUser(me) { $("#tab-keys").innerHTML = `

${t("kMeTitle")}

-
+
${t("kName")}${t("kMeRole")}${t("kKey")}${t("kMeModels")}
+
${t("kName")}${t("kMeRole")}${t("kKey")}${t("kKeyQuota")}${t("kMeModels")}
${esc(me.name || "—")}${roleTag(me.role)} ${esc(me.key)} ${keyCapBadges(me)} ${ me.models && me.models.length ? me.models @@ -4273,13 +4296,22 @@ } function keyCanvasHtml(k) { const scopes = k.models || []; + // Key-wide caps live on the canvas, not on a brick: they are a budget + // the whole key shares, so they must not be dragged around with one + // model. data-* carries them so a quota edit can round-trip them + // through the same PUT that saves the model scope. + const caps = `data-kquota="${k.token_quota || 0}" data-kreqquota="${k.req_quota || 0}" + data-kperiod="${escAttr(k.period || "")}" data-khours="${k.hours || 0}"`; return ` -
+
${esc(k.name || "—")} ${roleTag(k.role)} ${esc(maskKey(k.key))} + ${keyCapBadges(k)} + ${k.role === "admin" ? "" : ``} ${fmtCreated(k.created_at)} @@ -4292,6 +4324,27 @@
`; } + // keyCapBadges renders the key-wide caps. A cap with no reset period is + // flagged as such, because "1M tokens, never resets" and "1M tokens per + // hour" are very different promises and the badge must not blur them. + function keyCapBadges(k) { + const out = []; + const suffix = periodText(k.period || "", k.hours || 0); + if (+k.token_quota > 0) { + out.push( + `${esc(fmtQuota(k.token_quota))}${esc(suffix)}`, + ); + } + if (+k.req_quota > 0) { + out.push( + `${esc(fmtQuota(k.req_quota))}×${esc(suffix)}`, + ); + } + if (!out.length) { + return `${t("kKeyQuotaNone")}`; + } + return out.join(" "); + } function scopeHtml(key, m) { const qt = fmtQuota(m.token_quota); const comb = scopeComb(m); @@ -4355,12 +4408,116 @@ }); } async function putScope(key, scopes) { + // The key-wide caps ride along with every scope write. The API reads + // them as pointers, so sending them back unchanged is a no-op, while + // omitting them would be indistinguishable from "clear the budget" to + // a future reader. Round-tripping them here means editing a model's + // scope can never silently drop a key's quota. + const canvas = document.querySelector( + `.key-canvas[data-key="${CSS.escape(key)}"]`, + ); + const body = { models: scopes }; + if (canvas) { + body.token_quota = parseInt(canvas.dataset.kquota) || 0; + body.req_quota = parseInt(canvas.dataset.kreqquota) || 0; + body.period = canvas.dataset.kperiod || ""; + body.hours = parseInt(canvas.dataset.khours) || 0; + } await api("/api/keys/" + encodeURIComponent(key), { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ models: scopes }), + body: JSON.stringify(body), }); } + // keyQuotaEdit opens the key-wide budget form. + function keyQuotaEdit(key) { + const canvas = document.querySelector( + `.key-canvas[data-key="${CSS.escape(key)}"]`, + ); + if (!canvas) return; + const cur = { + token_quota: parseInt(canvas.dataset.kquota) || 0, + req_quota: parseInt(canvas.dataset.kreqquota) || 0, + period: canvas.dataset.kperiod || "", + hours: parseInt(canvas.dataset.khours) || 0, + }; + 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 = `

${t("kKeyQuotaEdit")}

+ + + + + + + +

${t("kKeyQuotaAdmin")}

+

+

+
`; + document.body.appendChild(wrap); + const toggle = () => { + $("#kq-hours-box").style.display = + $("#kq-period").value === "nhour" ? "block" : "none"; + }; + $("#kq-period").addEventListener("change", toggle); + toggle(); + $("#kq-tokens").focus(); + } + async function keyQuotaSave(key, btn) { + // Resolve our own dialog from the button that was clicked, so closing + // it can never remove a different #modal-wrap that happens to come + // first in the document. + const wrap = btn ? btn.closest("#modal-wrap") : null; + let tokens = parseInt($("#kq-tokens").value); + if (isNaN(tokens) || tokens < 0) tokens = 0; + let reqs = parseInt($("#kq-reqs").value); + if (isNaN(reqs) || reqs < 0) reqs = 0; + let hours = parseInt($("#kq-hours").value); + if (isNaN(hours) || hours < 1) hours = 1; + const period = $("#kq-period").value; + // Catch the "nhour picked but hours never filled in" case locally: the + // API rejects it too, but a round trip for a form-level mistake is + // needless. + if (period === "nhour" && hours < 1) { + toast(t("kPerNHint")); + return; + } + if (btn) btn.disabled = true; + try { + await api("/api/keys/" + encodeURIComponent(key), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + token_quota: tokens, + req_quota: reqs, + period, + hours, + }), + }); + // Close THIS modal, not whichever #modal-wrap comes first in the + // document: another dialog (e.g. the seed-key notice) may already be + // open, and $("#modal-wrap") would remove that one and leave this + // form stranded on screen. + if (wrap) wrap.remove(); + toast(t("kSaved")); + await loadKeys(); + } catch (e) { + toast(e.message); + if (btn) btn.disabled = false; + } + } async function scopePush(key) { const canvas = document.querySelector( `.key-canvas[data-key="${CSS.escape(key)}"]`, @@ -4661,12 +4818,41 @@ + + + + + + + +

${t("kKeyQuotaAdmin")}

`; document.body.appendChild(wrap); + $("#kc-role").addEventListener("change", () => { + const admin = $("#kc-role").value === "admin"; + // An admin key ignores its caps server-side; hiding the fields + // avoids the operator setting one and wondering why it never trips. + $("#kc-tokens").disabled = admin; + $("#kc-reqs").disabled = admin; + $("#kc-period").disabled = admin; + $("#kc-hours-box").style.display = + !admin && $("#kc-period").value === "nhour" ? "block" : "none"; + }); + $("#kc-period").addEventListener("change", () => { + $("#kc-hours-box").style.display = + $("#kc-period").value === "nhour" ? "block" : "none"; + }); $("#kc-name").focus(); } async function createKey(btn) { @@ -4675,6 +4861,17 @@ toast(t("kName")); return; } + const role = $("#kc-role").value; + // An admin key is never capped; send the fields anyway (the server + // ignores them) rather than special-casing the request shape. + const readNum = (sel) => { + const el = $(sel); + if (el.disabled) return 0; + const n = parseInt(el.value); + return isNaN(n) || n < 0 ? 0 : n; + }; + let hours = parseInt($("#kc-hours").value); + if (isNaN(hours) || hours < 1) hours = 1; if (btn) btn.disabled = true; let j; try { @@ -4683,8 +4880,12 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, - role: $("#kc-role").value, + role, note: $("#kc-note").value.trim(), + token_quota: readNum("#kc-tokens"), + req_quota: readNum("#kc-reqs"), + period: role === "admin" ? "" : $("#kc-period").value, + hours: role === "admin" ? 0 : hours, }), }); } catch (e) { @@ -4692,7 +4893,10 @@ if (btn) btn.disabled = false; return; } - const w = $("#modal-wrap"); + // Close THIS dialog (resolved from the clicked button), not whichever + // #modal-wrap comes first: the seed-key notice may already be open and + // would otherwise be the one that gets removed. + const w = btn ? btn.closest("#modal-wrap") : null; if (w) w.remove(); $("#k-newbox").innerHTML = `
diff --git a/internal/gateway/ui_quota_contract_test.go b/internal/gateway/ui_quota_contract_test.go new file mode 100644 index 0000000..93c14c3 --- /dev/null +++ b/internal/gateway/ui_quota_contract_test.go @@ -0,0 +1,209 @@ +package gateway + +import ( + "regexp" + "strings" + "testing" +) + +// The WebUI dialogs all share the id "modal-wrap", and more than one can be +// open at the same time (the seed-key notice sits on top of the keys page). +// A save handler that closes "the" modal via $("#modal-wrap") therefore removes +// whichever one comes FIRST in the document — which is the wrong dialog: the +// form the user just submitted stays on screen while an unrelated dialog +// vanishes. +// +// This is exactly the class of bug the api() contract test below was written +// for: reviewing inline JS by eye does not catch it, and the visible symptom +// ("the dialog did not close") points away from the cause. It is pinned here. + +// modalCloseRe finds every `$(...)`-style lookup of the shared modal id. +var modalCloseRe = regexp.MustCompile(`\$\("#modal-wrap"\)`) + +// closestModalRe finds the safe form: resolve the dialog from the clicked +// button instead of from the document. +var closestModalRe = regexp.MustCompile(`\.closest\("#modal-wrap"\)`) + +func TestUIDialogClosesItselfNotTheFirstModal(t *testing.T) { + src := uiSource(t) + // Strip comments first: prose that *names* the unsafe pattern (as the fix's + // own comment does) would otherwise be flagged as a violation. + code := stripJSComments(src) + + for _, m := range modalCloseRe.FindAllStringIndex(code, -1) { + after := code[m[1]:] + stmtEnd := strings.Index(after, ";") + if stmtEnd < 0 || stmtEnd > 200 { + continue + } + stmt := after[:stmtEnd] + if strings.Contains(stmt, ".remove()") { + line := 1 + strings.Count(code[:m[0]], "\n") + t.Errorf("line %d closes the first #modal-wrap in the document, not its own dialog:\n\t%s", + line, strings.TrimSpace(stmt)) + } + } +} + +// stripJSComments removes // line comments and /* block */ comments from JS +// embedded in the UI document. It is deliberately simple (no string/regex +// awareness beyond skipping quoted spans on the same line): the document is +// our own source, and a false negative here only means the check is silent. +func stripJSComments(src string) string { + var out strings.Builder + lines := strings.Split(src, "\n") + inBlock := false + for _, ln := range lines { + trimmed := strings.TrimSpace(ln) + if inBlock { + if strings.Contains(ln, "*/") { + inBlock = false + } + continue + } + if strings.HasPrefix(trimmed, "/*") { + if !strings.Contains(ln, "*/") { + inBlock = true + } + continue + } + if i := strings.Index(ln, "//"); i >= 0 { + // keep code before the comment when the // is not inside a string + before := ln[:i] + if strings.Count(before, `"`)%2 == 0 && strings.Count(before, "'")%2 == 0 { + ln = before + } + } + out.WriteString(ln) + out.WriteString("\n") + } + return out.String() +} + +// TestUIKeyQuotaDialogsResolveOwnModal pins the dialog-closing rule for the +// two forms this change added. +func TestUIKeyQuotaDialogsResolveOwnModal(t *testing.T) { + src := uiSource(t) + for _, fn := range []string{"keyQuotaSave", "createKey"} { + body, ok := jsFunctionBody(src, fn) + if !ok { + t.Errorf("%s not found in the UI source", fn) + continue + } + if !closestModalRe.MatchString(body) { + t.Errorf("%s does not resolve its own dialog via .closest(\"#modal-wrap\");\n"+ + "with another dialog open it would close that one instead and leave this form stranded", fn) + } + } +} + +// The quota editor must read and write the key-wide caps, and putScope must +// carry them along: the API treats the quota fields as pointers, so dropping +// them on a scope-only write is indistinguishable from "clear the budget". +func TestUIPutScopeCarriesKeyQuota(t *testing.T) { + body, ok := jsFunctionBody(uiSource(t), "putScope") + if !ok { + t.Fatal("putScope not found") + } + // Scan the code with comments removed, or a comment that merely *names* a + // field would satisfy the check while the field is never sent. + code := stripJSComments(body) + for _, field := range []string{"token_quota", "req_quota", "period", "hours"} { + if !strings.Contains(code, field) { + t.Errorf("putScope does not send %q — editing a model scope would clear the key's quota", field) + } + } +} + +// A key's caps are rendered from the API record and shown on the canvas, so +// the badge and the data attributes must not drift from the field names. The +// create form must send them too, or a key would only be cappable after an +// extra round of edits. +func TestUIKeyQuotaRendersFromAPIFields(t *testing.T) { + src := uiSource(t) + for _, token := range []string{ + "keyCapBadges", // shared renderer + "kq-tokens", "kq-reqs", "kq-period", "kq-hours", // editor fields + "kc-tokens", "kc-reqs", "kc-period", "kc-hours", // create form fields + } { + if !strings.Contains(src, token) { + t.Errorf("UI never references %q — the quota form is not wired up", token) + } + } + // the create request must actually carry the caps + full, ok := jsFunctionBody(src, "createKey") + if !ok { + t.Fatal("createKey not found") + } + body := stripJSComments(full) + for _, field := range []string{"token_quota", "req_quota", "period"} { + if !strings.Contains(body, field) { + t.Errorf("createKey does not send %q — a new key could never be created with a budget", field) + } + } +} + +// Existence of the strings is not enough: the badge has to READ the API +// fields, and the editor has to read the canvas data attributes it writes. +// A field can be present in the source and still never reach the screen — +// e.g. left in a dead branch, or read from a name the writer never sets. +func TestUIKeyQuotaDataflowIsLive(t *testing.T) { + src := uiSource(t) + + badge, ok := jsFunctionBody(src, "keyCapBadges") + if !ok { + t.Fatal("keyCapBadges not found") + } + badgeCode := stripJSComments(badge) + for _, field := range []string{"k.token_quota", "k.req_quota", "k.period"} { + if !strings.Contains(badgeCode, field) { + t.Errorf("keyCapBadges does not read %q — the cap would never show on the key card", field) + } + } + + // the editor must read back what keyCanvasHtml wrote + canvas, ok := jsFunctionBody(src, "keyCanvasHtml") + if !ok { + t.Fatal("keyCanvasHtml not found") + } + editor, ok := jsFunctionBody(src, "keyQuotaEdit") + if !ok { + t.Fatal("keyQuotaEdit not found") + } + canvasCode, editorCode := stripJSComments(canvas), stripJSComments(editor) + for _, ds := range []string{"kquota", "kreqquota", "kperiod", "khours"} { + // written as data- on the canvas + if !strings.Contains(canvasCode, "data-"+ds+"=") { + t.Errorf("keyCanvasHtml does not write data-%s, so the editor has nothing to prefill", ds) + } + // read back as dataset. by the editor + if !strings.Contains(editorCode, "dataset."+ds) { + t.Errorf("keyQuotaEdit does not read dataset.%s — the form would open blank and save zeros", ds) + } + } +} + +// The quota period vocabulary must match the server's, or the UI can offer a +// value the API rejects. +func TestUIQuotaPeriodsMatchServer(t *testing.T) { + src := uiSource(t) + // the shared period select options, as rendered in both forms + for _, p := range []string{`value=""`, `value="hour"`, `value="week"`, `value="month"`, `value="nhour"`} { + if !strings.Contains(src, p) { + t.Errorf("UI period select is missing %s", p) + } + } + // the server's accepted vocabulary + for _, p := range []string{`"hour"`, `"week"`, `"month"`, `"nhour"`} { + if !strings.Contains(src, `if (p === `+p+`)`) && !strings.Contains(src, `=== `+p+`)`) { + t.Errorf("periodText() does not describe %s, so a badge would omit the window", p) + } + } +} + +func max(a, b int) int { + if a > b { + return a + } + return b +}