mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 20:27:23 +00:00
feat(plugins): 访问控制在插件页配置 + 修复 active 仪表在周期视图显示 0
## 插件管理页(用户要求:权限不要只放 config.yaml)
插件页新增两列(可见性、端点)和一块访问控制编辑器,可直接改:
- 每个插件的整体可见性(跟随插件 / 仅 admin / 仅 user / admin+user / 所有人)
- 每个注册端点的可访问角色与密钥(admin, user, @homeagent, public)
原先这些能力后端齐全但 WebUI 零入口,唯一改法是手改 config.yaml。
## 修掉的三个真缺陷
1. endpointAccess 读错了 override 的键
SetEndpointAccess 按 plugin#path 存,endpointAccess 却读 plugin 级。
后果:改了权限后 UI 仍显示插件声明的角色,再保存一次就把限制悄悄撤销。
鉴权一直是对的,只有显示和往返是坏的 —— 所以之前没被发现。
2. override 只在内存里
内存 map 让"billing 仅 admin"看着生效,直到下次重启限制凭空消失,
日志里没有任何线索。现在落盘到 plugin_dir/.access.json,
插件升级覆盖 .state.json 也不会连带放宽访问。删掉最后一个 override 就删文件,
"没有覆盖"用文件不存在来诚实表达。
3. 可见性列表泄漏内部键
端点 override 与插件可见性共用一个 map,UI 因此会看到 "probe#hello"
并可能把它当 audience 存回去。现在按已注册端点集合过滤。
另外把整体可见性接到同一条 /access 通路(path 用 *),并修了可见性下拉的
集合比较:服务端原样存 "admin, user",而选项是 "user,admin",不相等就会
显示"跟随插件",用户一保存就把访问范围放宽回插件声明 —— 比显示错更糟。
## 活跃请求:三个视图显示 0
active 是实时仪表,没有日历周期可过滤,但周期响应压根没带这个字段。
前端 `st.active || 0` 于是把"字段缺失"变成了一句自信的"0 活跃",
而 all 视图(响应里有该字段)显示真值 —— 三个视图 0、总视图正常。
现在周期响应带上 active,前端去掉 || 0 回退:缺失显示 — 而不是假装是 0。
判据:四个 period 都断言 active 存在且为数字;变异去掉字段即变红。
生产实测 day/week/month/all 均为 4。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit 120ba9e957)
This commit is contained in:
@ -815,8 +815,21 @@
|
||||
plTitle: "插件",
|
||||
plState: "状态",
|
||||
plStages: "阶段",
|
||||
plActive: "启用中",
|
||||
plDisabled: "已禁用",
|
||||
|
||||
plVisibility: "可见性",
|
||||
plEndpoints: "端点",
|
||||
plAccessTitle: "访问控制",
|
||||
plAccessHint: "填写允许访问的角色或密钥(@名称)。留空=仅 admin。填 public=任何人。指定 @密钥 时只有该密钥能访问,不受角色规则影响。",
|
||||
plWholePlugin: "以下端点",
|
||||
plKeyHint: "可用密钥名",
|
||||
plAccessSave: "保存访问控制", plActive: "启用中",
|
||||
|
||||
tVisDefault: "跟随插件",
|
||||
tVisAdmin: "仅 admin",
|
||||
tVisRoles: "admin + user",
|
||||
tVisAll: "所有人",
|
||||
plOverridden: "已覆盖插件声明",
|
||||
plEpsN: "%d 个端点", plDisabled: "已禁用",
|
||||
plBroken: "加载失败",
|
||||
plBuiltin: "内置",
|
||||
plHooks: "个阶段",
|
||||
@ -1090,8 +1103,21 @@
|
||||
plTitle: "Plugins",
|
||||
plState: "State",
|
||||
plStages: "Stages",
|
||||
plActive: "Active",
|
||||
plDisabled: "Disabled",
|
||||
|
||||
plVisibility: "Visibility",
|
||||
plEndpoints: "Endpoints",
|
||||
plAccessTitle: "Access control",
|
||||
plAccessHint: "Roles or keys (@name) allowed to reach each endpoint. Empty = admin only. public = anyone. Listing @keys replaces the role rules entirely.",
|
||||
plWholePlugin: "endpoints below",
|
||||
plKeyHint: "Key names",
|
||||
plAccessSave: "Save access", plActive: "Active",
|
||||
|
||||
tVisDefault: "follow plugin",
|
||||
tVisAdmin: "admin only",
|
||||
tVisRoles: "admin + user",
|
||||
tVisAll: "everyone",
|
||||
plOverridden: "overrides plugin",
|
||||
plEpsN: "%d endpoints", plDisabled: "Disabled",
|
||||
plBroken: "Failed to load",
|
||||
plBuiltin: "Built-in",
|
||||
plHooks: "stages",
|
||||
@ -1994,7 +2020,12 @@
|
||||
const e = $(sel);
|
||||
if (e) e.textContent = txt;
|
||||
};
|
||||
kset('[data-kpi="active"]', st.active || 0);
|
||||
// No `|| 0` fallback: that fallback is what turned a MISSING field
|
||||
// into a confident "0 active" on three of four views. If the server
|
||||
// ever stops sending it again, an em dash says "unknown" and the
|
||||
// regression is visible, instead of the gauge quietly claiming the
|
||||
// gateway is idle while it serves traffic.
|
||||
kset('[data-kpi="active"]', st.active === undefined ? "—" : fmtN(st.active));
|
||||
kset('[data-kpi="reqs"]', fmtN(tot.reqs));
|
||||
const rsub = $('[data-kpi="reqs-sub"]');
|
||||
if (rsub)
|
||||
@ -4473,6 +4504,8 @@ function afterChainEdit() {
|
||||
<td><b>${esc(p.name)}</b>${builtin}<div class="muted">${esc(p.description || p.version || "")}</div>${err}</td>
|
||||
<td>${state}</td>
|
||||
<td>${hooks}</td>
|
||||
<td>${plVisCell(j, p)}</td>
|
||||
<td>${plEpsCell(j, p)}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="small" onclick="editPlugin('${escAttr(p.name)}')">${t("plEdit")}</button>
|
||||
${toggle}
|
||||
@ -4494,8 +4527,9 @@ function afterChainEdit() {
|
||||
$("#tab-plugins").innerHTML = `
|
||||
<div class="card"><h2>${t("plTitle")} (${(j.on_disk || []).length})</h2>
|
||||
<div class="tbl-wrap"><table><tr>
|
||||
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th></th>
|
||||
</tr>${rows || `<tr><td colspan="4" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
|
||||
<th>${t("tName")}</th><th>${t("plState")}</th><th>${t("plStages")}</th><th>${t("plVisibility")}</th><th>${t("plEndpoints")}</th><th></th>
|
||||
</tr>${rows || `<tr><td colspan="6" class="empty">${t("plEmpty")}</td></tr>`}</table></div>
|
||||
<div id="pl-access"></div>
|
||||
${errList ? `<p class="muted" style="color:var(--danger)">${t("plHookErr")}</p><ul style="font-size:12px">${errList}</ul>` : ""}
|
||||
<p class="muted">${t("plDir")} <code>${esc(j.plugin_dir)}</code></p>
|
||||
</div>
|
||||
@ -4508,6 +4542,161 @@ function afterChainEdit() {
|
||||
<p><button onclick="installPlugin()">${t("plInstallBtn")}</button></p>
|
||||
</div>`;
|
||||
bindDropzone("pl-dz", "pl-file", "#pl-name", "#pl-code");
|
||||
renderPluginAccess(j);
|
||||
}
|
||||
|
||||
|
||||
const PL_VIS = [
|
||||
["", "tVisDefault"],
|
||||
["admin", "tVisAdmin"],
|
||||
["user,admin", "tVisRoles"],
|
||||
["all", "tVisAll"],
|
||||
];
|
||||
|
||||
// plVisCell shows the plugin's own declaration next to any operator
|
||||
// override, because "admin-only" means something different depending on
|
||||
// whether the plugin chose it or the deployment imposed it. Editing goes
|
||||
// through the same access API as endpoints so there is one place where
|
||||
// visibility is stored.
|
||||
function plVisCell(j, p) {
|
||||
const decl = (p.ui && p.ui.visibility) || "";
|
||||
const ov = (j.visibility || {})[p.name] || "";
|
||||
const cur = ov || decl;
|
||||
// Compare as SETS. The server stores whatever order the operator typed
|
||||
// and we round-trip it verbatim, so "admin, user" must still light up a
|
||||
// choice — otherwise the selector silently shows "follow plugin", and
|
||||
// saving from there writes an empty override that WIDENS access back to
|
||||
// the plugin's own declaration.
|
||||
const norm = (v) =>
|
||||
(v || "")
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(",");
|
||||
const curN = norm(cur);
|
||||
// An audience with no matching choice (one naming a @key, say) is offered
|
||||
// as itself so the value in force is never hidden from the operator.
|
||||
const opts = PL_VIS.some((c) => norm(c[0]) === curN)
|
||||
? PL_VIS
|
||||
: PL_VIS.concat([[cur, "plCustomVis"]]);
|
||||
return `<select data-plvis="${escAttr(p.name)}" style="font-size:11.5px;max-width:180px">
|
||||
${opts
|
||||
.map(
|
||||
(c) =>
|
||||
`<option value="${c[0]}"${norm(c[0]) === curN ? " selected" : ""}>${
|
||||
c[1] === "plCustomVis" ? "自定义: " + cur : t(c[1])
|
||||
}</option>`,
|
||||
)
|
||||
.join("")}
|
||||
</select>${
|
||||
ov && decl && ov !== decl
|
||||
? `<div class="muted" style="font-size:10.5px" title="${esc(decl)}">${t("plOverridden")}</div>`
|
||||
: ""
|
||||
}`;
|
||||
}
|
||||
|
||||
function plEpsCell(j, p) {
|
||||
const n = (j.http_access || []).filter((e) => e.plugin === p.name).length;
|
||||
return n
|
||||
? `<a href="#" onclick="document.getElementById('pl-access').scrollIntoView({behavior:'smooth'});return false">${t("plEpsN").replace("%d", n)}</a>`
|
||||
: `<span class="muted">—</span>`;
|
||||
}
|
||||
|
||||
// ---- visibility & endpoint access ----
|
||||
//
|
||||
// The plugin page is where an operator edits who can see what. The
|
||||
// alternatives are editing a bundled .lua — overwritten on the next
|
||||
// upgrade — or hand-editing config.yaml, with no feedback about what is
|
||||
// actually in force. Each row shows the rule next to whether it was
|
||||
// DECLARED by the plugin or OVERRIDDEN here, because "admin-only" means
|
||||
// something different depending on which.
|
||||
function renderPluginAccess(j) {
|
||||
const host = $("#pl-access");
|
||||
if (!host) return;
|
||||
const eps = j.http_access || [];
|
||||
if (!eps.length) { host.innerHTML = ""; return; }
|
||||
const keys = (window.__gwKeyRows || []).map((k) => k.name).filter(Boolean);
|
||||
const byPlugin = {};
|
||||
eps.forEach((e) => { (byPlugin[e.plugin] = byPlugin[e.plugin] || []).push(e); });
|
||||
let h = `<div class="card" style="margin-top:12px"><h2>${t("plAccessTitle")}</h2>
|
||||
<p class="muted" style="font-size:12px">${t("plAccessHint")}</p>`;
|
||||
Object.keys(byPlugin).forEach((pn) => {
|
||||
h += `<div style="margin:0 0 14px">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px">
|
||||
<b style="font-size:13px">${esc(pn)}</b>
|
||||
<span class="muted" style="font-size:11.5px">${t("plWholePlugin")}</span>
|
||||
</div>`;
|
||||
(byPlugin[pn] || []).forEach((e) => {
|
||||
const ovr = (e.override_keys && e.override_keys.length) ||
|
||||
(e.override_roles && e.override_roles.length);
|
||||
const cur = ovr
|
||||
? [...(e.override_keys || []).map((k) => "@" + k), ...(e.override_roles || [])].join(",")
|
||||
: [...(e.keys || []).map((k) => "@" + k), ...(e.roles || []), e.public ? "public" : ""]
|
||||
.filter(Boolean).join(",") || "admin";
|
||||
h += `<div style="margin:6px 0 0;padding-left:10px;border-left:2px solid var(--bd)">
|
||||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
<code style="font-size:11.5px">${esc(e.method)} ${esc(e.full_path)}</code>
|
||||
<span class="muted" style="font-size:11px">→ ${esc(e.handler)}()</span>
|
||||
${e.description ? `<span class="muted" style="font-size:11px">${esc(e.description)}</span>` : ""}
|
||||
<span class="grow"></span>
|
||||
<input data-ep="${esc(pn)}|${esc(e.path)}" value="${esc(cur)}"
|
||||
style="max-width:340px;font-size:11.5px"
|
||||
placeholder="admin, user, @homeagent, public">
|
||||
</div>
|
||||
${keys.length ? `<div style="font-size:11px;opacity:.7;margin-top:3px">${t("plKeyHint")}: ${esc(keys.join(", "))}</div>` : ""}
|
||||
</div>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
});
|
||||
h += `<p><button id="pl-access-save" onclick="savePluginAccess(this)">${t("plAccessSave")}</button></p></div>`;
|
||||
host.innerHTML = h;
|
||||
}
|
||||
|
||||
async function savePluginAccess(btn) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
// Whole-plugin visibility first. Sent through the SAME access API as
|
||||
// endpoints (path "*") so visibility has exactly one storage path.
|
||||
const viss = Array.prototype.slice.call(
|
||||
document.querySelectorAll("[data-plvis]"));
|
||||
for (const sel of viss) {
|
||||
const pn = sel.getAttribute("data-plvis");
|
||||
const url =
|
||||
"/api/plugins/" + encodeURIComponent(pn) + "/http/*/access";
|
||||
const v = sel.value;
|
||||
if (v) {
|
||||
await api(url, { method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ access: v }) });
|
||||
} else {
|
||||
await api(url, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
const inputs = Array.prototype.slice.call(
|
||||
document.querySelectorAll("#pl-access [data-ep]"));
|
||||
for (const inp of inputs) {
|
||||
const raw = inp.getAttribute("data-ep");
|
||||
const sep = raw.indexOf("|");
|
||||
const pn = raw.slice(0, sep), ep = raw.slice(sep + 1);
|
||||
const url = "/api/plugins/" + encodeURIComponent(pn) + "/http/" +
|
||||
encodeURIComponent(ep) + "/access";
|
||||
const v = inp.value.trim();
|
||||
if (v) {
|
||||
await api(url, { method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ access: v }) });
|
||||
} else {
|
||||
await api(url, { method: "DELETE" });
|
||||
}
|
||||
}
|
||||
toast(t("toastSaved"));
|
||||
renderPlugins();
|
||||
} catch (e) {
|
||||
toast(tFmt("toastSaveFail", e.message));
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(name, disabled) {
|
||||
|
||||
Reference in New Issue
Block a user