This commit is contained in:
2026-07-22 23:55:46 +02:00
parent 34f76aad65
commit 77f043640e
23 changed files with 993 additions and 11 deletions
+123 -1
View File
@@ -11,6 +11,10 @@ export class GatewayAdmin {
this.modelForm = root.querySelector("#gw-model-form");
this.providerSelects = root.querySelectorAll("[data-provider-select]");
this.providers = [];
this.quotaRulesBody = root.querySelector("#gw-quota-rules");
this.quotaForm = root.querySelector("#gw-quota-form");
this.quotaCancelEdit = root.querySelector("#gw-quota-cancel-edit");
this.quotaRules = [];
}
async start() {
@@ -46,6 +50,12 @@ export class GatewayAdmin {
this.modelForm.kind.addEventListener("change", () => this.syncKindFields());
this.providersBody.addEventListener("click", (event) => this.onProviderClick(event));
this.modelsBody.addEventListener("click", (event) => this.onModelClick(event));
this.quotaForm.addEventListener("submit", (event) => {
event.preventDefault();
this.saveQuotaRule();
});
this.quotaRulesBody.addEventListener("click", (event) => this.onQuotaRuleClick(event));
this.quotaCancelEdit.addEventListener("click", () => this.resetQuotaForm());
}
notify(message, type) {
@@ -57,9 +67,10 @@ export class GatewayAdmin {
async reload() {
const providerCount = await this.loadProviders();
const modelCount = await this.loadModels();
const quotaCount = await this.loadQuotaRules();
const count = this.root.querySelector("#gw-count");
if (count) {
count.textContent = `${providerCount} providers, ${modelCount} routes`;
count.textContent = `${providerCount} providers, ${modelCount} routes, ${quotaCount} quota rules`;
}
}
@@ -336,4 +347,115 @@ export class GatewayAdmin {
this.notify(err.message || "Delete failed", "error");
}
}
async loadQuotaRules() {
const data = await Http.getJson("/admin/gateway/quota-rules");
this.quotaRules = data.rules || [];
this.renderQuotaDefaults(data.defaults || {});
this.renderQuotaRules();
return this.quotaRules.length;
}
renderQuotaDefaults(defaults) {
const el = this.root.querySelector("#gw-quota-defaults");
if (!el) return;
const fmt = (v) => (Number(v) > 0 ? `$${Number(v).toFixed(2)}/24h` : "unlimited");
el.innerHTML = `
<strong>global defaults</strong> (from <a href="/admin/services">Services config</a>, apply per caller with no matching rule):
member <code class="gw-code">${fmt(defaults.user)}</code>,
admin <code class="gw-code">${fmt(defaults.admin)}</code>,
guest <code class="gw-code">${fmt(defaults.guest)}</code>,
internal <code class="gw-code">${fmt(defaults.internal)}</code>,
access key <code class="gw-code">${fmt(defaults.key)}</code>`;
}
scopeLabel(rule) {
const parts = [];
parts.push(rule.owner_kind ? `role=${rule.owner_kind}` : "role=any");
parts.push(rule.owner_id ? `user=${rule.owner_id}` : "user=any");
parts.push(rule.app_reference ? `app=${rule.app_reference}` : "app=any");
return parts.join(", ");
}
renderQuotaRules() {
if (!this.quotaRules.length) {
this.quotaRulesBody.innerHTML = `<tr><td colspan="6" class="admin-empty">No quota rules. Every caller is capped by the global defaults above.</td></tr>`;
return;
}
this.quotaRulesBody.innerHTML = this.quotaRules
.map((r) => {
const limit = Number(r.limit_usd) > 0 ? `$${Number(r.limit_usd).toFixed(2)}` : "unlimited";
const spent = `$${Number(r.spent_24h_usd || 0).toFixed(4)}`;
return `<tr>
<td><code class="gw-code">${this.escape(this.scopeLabel(r))}</code></td>
<td>${limit}</td>
<td>${spent}</td>
<td>${r.is_active ? "yes" : "no"}</td>
<td>${this.escape(r.label || "")}</td>
<td class="gw-actions">
<button class="admin-btn admin-btn-sm" data-edit-quota='${this.attr(JSON.stringify(r))}'>Edit</button>
<button class="admin-btn admin-btn-sm admin-btn-danger" data-del-quota="${this.attr(r.uid)}">Delete</button>
</td>
</tr>`;
})
.join("");
}
async saveQuotaRule() {
const values = this.formValues(this.quotaForm);
const payload = {
owner_kind: values.owner_kind || null,
owner_id: values.owner_id || null,
app_reference: values.app_reference || null,
limit_usd: parseFloat(values.limit_usd) || 0,
is_active: values.is_active === "1",
label: values.label || "",
};
if (values.uid) payload.uid = values.uid;
try {
await Http.postJson("/admin/gateway/quota-rules", payload);
this.resetQuotaForm();
this.notify("Quota rule saved", "success");
await this.reload();
} catch (err) {
this.notify(err.message || "Save failed", "error");
}
}
fillQuotaForm(rule) {
const form = this.quotaForm;
form.uid.value = rule.uid || "";
form.owner_kind.value = rule.owner_kind || "";
form.owner_id.value = rule.owner_id || "";
form.app_reference.value = rule.app_reference || "";
form.limit_usd.value = rule.limit_usd || 0;
form.is_active.value = rule.is_active ? "1" : "0";
form.label.value = rule.label || "";
this.quotaCancelEdit.hidden = false;
form.owner_kind.scrollIntoView({ block: "center" });
}
resetQuotaForm() {
this.quotaForm.reset();
this.quotaForm.uid.value = "";
this.quotaCancelEdit.hidden = true;
}
onQuotaRuleClick(event) {
const editRaw = event.target.dataset.editQuota;
const delUid = event.target.dataset.delQuota;
if (editRaw) this.fillQuotaForm(JSON.parse(editRaw));
if (delUid) this.deleteQuotaRule(delUid);
}
async deleteQuotaRule(uid) {
if (!(await this.confirmDelete("Delete this quota rule?"))) return;
try {
await this.remove(`/admin/gateway/quota-rules/${encodeURIComponent(uid)}`);
this.notify("Quota rule deleted", "success");
await this.reload();
} catch (err) {
this.notify(err.message || "Delete failed", "error");
}
}
}