From ff03e76925dce47b0d7781e940dd436c1f9ecff7 Mon Sep 17 00:00:00 2001 From: abrown38 Date: Wed, 21 Oct 2026 11:50:04 +0100 Subject: [PATCH] Rules: a quota is one line, the rest in its dialog A rule's quota shows on its row as one line (what it used, how it resets) that opens a dialog with the rest: the allowance, last and next reset, usage (each address's, with Reset and Give back), and Reset and Give back for everyone. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +-- docs/rules.md | 5 +- internal/web/static/assets/pages/rules.js | 57 ++++++++++++++--------- 3 files changed, 41 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb14fa4..aecf884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,10 @@ All notable changes to Housecarl are documented here. The format follows reset-quota ID --address ADDR` starts only that device's allowance again; `--give 5gbytes` takes that much off what was used instead (one address's, or everyone's): extra allowance until the next reset, - without changing the rule. In the web UI: **Give back…** beside a - quota's Reset, and Reset and Give back for each address in its usage - dialog. `POST /rules/{ref}/reset-quota` takes `address` and `give`. + without changing the rule. In the web UI, a rule's quota is one + line (what it used, how it resets) that opens a dialog with the rest: + usage per address, last and next reset, and Reset and Give back for + everyone or one address. `POST /rules/{ref}/reset-quota` takes `address` and `give`. - **Names beside inside addresses.** Connections, the log, quota usage, the dashboard's quotas and the statistics' devices and blocked sources show each inside address's name, found by a reverse lookup: diff --git a/docs/rules.md b/docs/rules.md index 76ea5e1..45d8907 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -192,8 +192,9 @@ hc rule reset-quota 31 --give 5gbytes --address 192.168.1.23 `--give` is a top-up: it takes that much off what was used (never below zero), so the device gets 5 GB more until the quota's next reset, and the -rule stays as it is. The web UI has **Reset** and **Give back…** on the -rule, and for each address in its usage dialog. Like a reset, these are +rule stays as it is. In the web UI, the quota's line under the rule +opens its dialog: usage (each address's), resets, and **Reset** and +**Give back…** for everyone or one address. Like a reset, these are done at once (audited, not staged or on probation); Housecarl puts the ruleset in force again with the new figure. diff --git a/internal/web/static/assets/pages/rules.js b/internal/web/static/assets/pages/rules.js index 216d6c3..9cd8efe 100644 --- a/internal/web/static/assets/pages/rules.js +++ b/internal/web/static/assets/pages/rules.js @@ -158,42 +158,53 @@ function extras(r, q) { return out; } -// quotaLine shows a quota, and what it used: in total, or per address -// (the busiest, and the rest in a dialog). +// quotaLine sums up a quota on the rule's row: what it used (in total, +// or how many addresses and the busiest); the rest is in its dialog. function quotaLine(r, q) { const qt = r.quota; const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : ""; - const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "by hand"; - let used = null; - if (q && !qt.per_source && !qt.per_destination) { - used = [h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used"]; - } else if (q) { + const resets = qt.reset === "monthly" ? "monthly" : qt.reset || "reset by hand"; + let used = fmtBytes(qt.bytes) + each; + if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used"; + else if (q) { const n = (q.addresses || []).length; - used = [n === 1 ? "1 address" : n + " addresses", n ? [", busiest ", h("strong", null, fmtBytes(q.addresses[0].used))] : null, " ", - n ? h("button", { class: "small", onclick: () => usageDialog(r, q) }, "Usage…") : null]; + used += " · " + (n === 1 ? "1 address" : n + " addresses") + (n ? ", busiest " + fmtBytes(q.addresses[0].used) : ""); } - return h("div", { class: "muted small" }, (qt.over ? "quota over " : "quota ") + fmtBytes(qt.bytes) + each + ", resets " + resets, - used ? [" · ", used] : null, - q && q.next_reset ? " · next reset " + new Date(q.next_reset).toLocaleString() : null, - can("config", true) && q && r.pending !== "new" ? [" ", h("button", { class: "small", onclick: () => resetQuota(r) }, "Reset"), - " ", h("button", { class: "small", onclick: () => giveBack(r) }, "Give back…")] : null); + const text = "quota " + used + " · " + resets; + return h("div", { class: "muted small" }, q && r.pending !== "new" + ? h("button", { class: "link small", title: "Usage, reset and give back", onclick: () => quotaDialog(r, q) }, text) : text); } -async function usageDialog(r, q) { - const names = await lookupNames(q.addresses.map((a) => a.address)); - // A row's action closes this dialog first: it opens one of its own. - const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button").click(); f(); }; +// quotaDialog shows a quota in full: what it used (each address's, for a +// per-address one), its resets, and resetting or giving back. +async function quotaDialog(r, q) { + const qt = r.quota; + const perAddress = qt.per_source || qt.per_destination; const write = can("config", true); - await modal("Quota usage of " + (r.comment || "rule #" + r.id), h("div", null, - h("p", { class: "muted" }, "Each " + (r.quota.per_source ? "source" : "destination") + " has " + fmtBytes(r.quota.bytes) + "."), - table([ + const resets = qt.reset === "monthly" ? "monthly on day " + (qt.day || 1) : qt.reset || "only by hand"; + const when = (t) => t ? new Date(t).toLocaleString() : "–"; + const names = perAddress ? await lookupNames((q.addresses || []).map((a) => a.address)) : {}; + // A row's action closes this dialog first: it opens one of its own. + const then = (f) => (e) => { e.target.closest(".modal").querySelector(".buttons button:last-child").click(); f(); }; // Close + const v = await modal("Quota of " + (r.comment || "rule #" + r.id), h("div", null, + h("p", null, fmtBytes(qt.bytes) + (perAddress ? (qt.per_source ? " for each source" : " for each destination") : " in all") + "; the rule matches " + + (qt.over ? "once it is used up" : "while it lasts") + ". Resets " + resets + "."), + h("p", { class: "muted small" }, "Last reset " + when(q.last_reset) + " · next reset " + when(q.next_reset)), + perAddress ? table([ { label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) }, { label: "Used", render: (a) => fmtBytes(a.used) }, - { label: "", render: (a) => usageBar(a.used, r.quota.bytes) }, + { label: "", render: (a) => usageBar(a.used, qt.bytes) }, write ? { label: "", render: (a) => h("span", { class: "nowrap" }, h("button", { class: "small", onclick: then(() => resetQuota(r, a.address)) }, "Reset"), " ", h("button", { class: "small", onclick: then(() => giveBack(r, a.address)) }, "Give back…")) } : null, - ].filter(Boolean), q.addresses)), [{ label: "Close", value: null }], { wide: true }); + ].filter(Boolean), q.addresses || [], { empty: "No address has used it since the last reset." }) + : h("div", null, h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used", usageBar(q.used, qt.bytes))), + [ + ...(write ? [{ label: perAddress ? "Reset all" : "Reset", value: "reset" }, { label: perAddress ? "Give back to all…" : "Give back…", value: "give" }] : []), + { label: "Close", value: null }, + ], { wide: perAddress }); + if (v === "reset") resetQuota(r); + if (v === "give") giveBack(r); } // giveBack takes an amount off what a quota used (one address's, or