mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
feat(webui): 密钥配额表单 + 修复弹窗关闭错对象
功能:让 per-key 配额在 WebUI 里可配置可见,之前的实现只有 API 与
config.yaml 能配。
- 密钥卡片头部显示配额徽标(token / 请求数 + 重置窗口),admin key
不显示编辑入口(服务端本就永不受限,给入口只会让人以为配了会生效)。
- 新增「配额」编辑弹窗:token 配额、请求数配额、重置周期(复用既有
的 period 词表与 n-hour 联动),预填从 canvas 的 data-* 读。
- 创建密钥弹窗同步加配额字段;选 admin 角色时自动禁用(同样因为服务端
忽略 admin 的配额)。
- 「我的密钥」页新增 KEY-WIDE QUOTA 列,用户能看到自己这把 key 的预算。
修一个真 bug:保存弹窗用 $("#modal-wrap") 关闭自己,而全站弹窗共用这个
id、且可以叠加(seed key 提示就盖在密钥页上)。实测(共享 Chromium
CDP,seed 提示与配额弹窗共存)确认:保存后被移除的是 seed 提示,配额表单
反而留在屏幕上 —— 症状是「保存了但弹窗没关」,指向的方向完全错。改为用
点击的按钮 btn.closest("#modal-wrap") 解析自己的弹窗。createKey 有同样
问题,一并修。既有文件里另有 7 处同样写法,未动(不在本次范围,且新判据
只对本次改的两处断言,避免误伤)。
判据新增 internal/gateway/ui_quota_contract_test.go(6 例):
- 两个表单必须用 .closest 解析自己的弹窗
- putScope 必须带上 4 个配额字段(API 视其为指针,省略=清空预算)
- 创建请求必须真的发出配额字段
- **数据流判据**:徽标要真读 k.token_quota 等、编辑表单要真读
canvas 写的 data-kquota 等。只查字面量存在会漏 —— 字段躺在死分支里
判据照样通过(这是本轮实际踩到的:keyCapBadges 经 keyPeriodSuffix
间接读 k.period,被判据抓到后我把读取显式化而不是放宽判据)
- 弹窗扫描先剥注释,否则修复说明里引用的字面量会被当成违规
- 复用既有 ui_contract_test.go 的 jsFunctionBody(大括号配平);
自己第一版用 2000 字符固定窗口,被长注释顶开后仍在窗口外命中后面
函数的同名字段,读起来像通过 —— 窗口法在这里是假判据
7 个变异全部被抓(unsafe 关闭、putScope 丢字段、createKey 丢字段、
徽标不读字段、canvas 不写 data-*、kq-hours 改名、周期词表缺项)。
浏览器实测(共享 Chromium CDP,真实进程 + 加密配置):
- 徽标渲染 1.0K·1h / 5×·1h;编辑框预填 1000/5/hour,hours 框按周期联动
- 保存后回读 250000/77/nhour/6,徽标更新为 250.0K·6h,toast Saved
- 零 JS 异常
- **关键回归**:编辑模型 scope 后配额仍是 250000/77/nhour,未被清空
- 创建带配额的 key,服务端确认 {t:50000,r:300,p:week,role:user}
- user 视角「我的密钥」显示 777·1h 与 9×·1h
文档:README.md / README_EN.md 补「密钥用量配额」小节(配置示例、
周期词表、429 语义、admin 豁免、整点分桶最晚晚 1 小时释放、PUT 的
省略 vs 0 语义、429 响应样例),特性列表各加一条。
(cherry picked from commit ce66c7f6c2)
This commit is contained in:
@ -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 = `
|
||||
<div class="card"><h2>${t("kMeTitle")}</h2>
|
||||
<div class="tbl-wrap"><table><tr><th>${t("kName")}</th><th>${t("kMeRole")}</th><th>${t("kKey")}</th><th>${t("kMeModels")}</th></tr>
|
||||
<div class="tbl-wrap"><table><tr><th>${t("kName")}</th><th>${t("kMeRole")}</th><th>${t("kKey")}</th><th>${t("kKeyQuota")}</th><th>${t("kMeModels")}</th></tr>
|
||||
<tr><td><b>${esc(me.name || "—")}</b></td><td>${roleTag(me.role)}</td>
|
||||
<td><span class="kr-key">${esc(me.key)}</span>
|
||||
<button class="ghost small" onclick="copyText('${escAttr(me.key)}')">${t("kCopy")}</button></td>
|
||||
<td>${keyCapBadges(me)}</td>
|
||||
<td>${
|
||||
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 `
|
||||
<div class="key-canvas" data-key="${escAttr(k.key)}">
|
||||
<div class="key-canvas" data-key="${escAttr(k.key)}" ${caps}>
|
||||
<div class="kc-head">
|
||||
<b>${esc(k.name || "—")}</b>
|
||||
${roleTag(k.role)}
|
||||
<span class="kr-key">${esc(maskKey(k.key))}</span>
|
||||
<button class="ghost small" onclick="copyText('${escAttr(k.key)}')">${t("kCopy")}</button>
|
||||
<span class="kc-caps" title="${escAttr(t("kKeyQuotaHint"))}">${keyCapBadges(k)}</span>
|
||||
${k.role === "admin" ? "" : `<button class="ghost small" title="${escAttr(t("kKeyQuotaEdit"))}"
|
||||
onclick="keyQuotaEdit('${escAttr(k.key)}')">${t("kKeyQuotaEdit")}</button>`}
|
||||
<span class="grow"></span>
|
||||
<span class="muted">${fmtCreated(k.created_at)}</span>
|
||||
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || "")}')">${t("kDel")}</button>
|
||||
@ -4292,6 +4324,27 @@
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
// 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(
|
||||
`<span class="mb-quota" title="${escAttr(t("kKeyQuotaHint"))}">${esc(fmtQuota(k.token_quota))}${esc(suffix)}</span>`,
|
||||
);
|
||||
}
|
||||
if (+k.req_quota > 0) {
|
||||
out.push(
|
||||
`<span class="mb-quota" title="${escAttr(t("kKeyReqQuotaHint"))}">${esc(fmtQuota(k.req_quota))}×${esc(suffix)}</span>`,
|
||||
);
|
||||
}
|
||||
if (!out.length) {
|
||||
return `<span class="muted">${t("kKeyQuotaNone")}</span>`;
|
||||
}
|
||||
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 = `<div class="card" style="width:400px;max-width:100%"><h2>${t("kKeyQuotaEdit")}</h2>
|
||||
<label>${t("kKeyQuota")} <span class="muted">${t("kKeyQuotaHint")}</span></label>
|
||||
<input id="kq-tokens" type="number" min="0" step="1"
|
||||
placeholder="${escAttr(t("kKeyQuotaNone"))}" value="${cur.token_quota || ""}">
|
||||
<label>${t("kKeyReqQuota")} <span class="muted">${t("kKeyReqQuotaHint")}</span></label>
|
||||
<input id="kq-reqs" type="number" min="0" step="1"
|
||||
placeholder="${escAttr(t("kKeyQuotaNone"))}" value="${cur.req_quota || ""}">
|
||||
<label>${t("kPeriodB")}</label>
|
||||
<select id="kq-period">
|
||||
<option value="" ${!cur.period ? "selected" : ""}>${t("kPerNothing")}</option>
|
||||
<option value="hour" ${cur.period === "hour" ? "selected" : ""}>${t("kPerHour")}</option>
|
||||
<option value="week" ${cur.period === "week" ? "selected" : ""}>${t("kPerWeek")}</option>
|
||||
<option value="month" ${cur.period === "month" ? "selected" : ""}>${t("kPerMonth")}</option>
|
||||
<option value="nhour" ${cur.period === "nhour" ? "selected" : ""}>${t("kPerHours")}</option>
|
||||
</select>
|
||||
<div id="kq-hours-box" style="display:none"><label>${t("kPerNHint")}</label>
|
||||
<input id="kq-hours" type="number" min="1" step="1" value="${cur.hours || 24}"></div>
|
||||
<p class="muted" style="font-size:12px">${t("kKeyQuotaAdmin")}</p>
|
||||
<p><button onclick="keyQuotaSave('${escAttr(key)}', this)">${t("kSaveScope")}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
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 @@
|
||||
<option value="user">${t("kRoleUser")}</option>
|
||||
<option value="admin">${t("kRoleAdmin")}</option>
|
||||
</select>
|
||||
<label>${t("kKeyQuota")} <span class="muted">${t("kKeyQuotaHint")}</span></label>
|
||||
<input id="kc-tokens" type="number" min="0" step="1" placeholder="${escAttr(t("kKeyQuotaNone"))}">
|
||||
<label>${t("kKeyReqQuota")} <span class="muted">${t("kKeyReqQuotaHint")}</span></label>
|
||||
<input id="kc-reqs" type="number" min="0" step="1" placeholder="${escAttr(t("kKeyQuotaNone"))}">
|
||||
<label>${t("kPeriodB")}</label>
|
||||
<select id="kc-period">
|
||||
<option value="" selected>${t("kPerNothing")}</option>
|
||||
<option value="hour">${t("kPerHour")}</option>
|
||||
<option value="week">${t("kPerWeek")}</option>
|
||||
<option value="month">${t("kPerMonth")}</option>
|
||||
<option value="nhour">${t("kPerHours")}</option>
|
||||
</select>
|
||||
<div id="kc-hours-box" style="display:none"><label>${t("kPerNHint")}</label>
|
||||
<input id="kc-hours" type="number" min="1" step="1" value="24"></div>
|
||||
<label>${t("kNote")}</label>
|
||||
<input id="kc-note">
|
||||
<p class="muted" style="font-size:12px">${t("kKeyQuotaAdmin")}</p>
|
||||
<p><button onclick="createKey(this)">${t("kCreateBtn")}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="key-canvas" style="background:var(--card2)">
|
||||
|
||||
Reference in New Issue
Block a user