Dashboard: a per-address quota's bar counts all its addresses

One address that used up its allowance filled the bar (100%). The bar
is now all the addresses together, and the dashboard and the rule's
quota line say how many used theirs up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-22 18:52:51 +01:00
1 parent 500bfc7bdd
commit 4af4a5908b
3 files changed
+18 -6

No files matched your search

+4
View File
@@ -114,6 +114,10 @@ All notable changes to Housecarl are documented here. The format follows
### Fixed ### Fixed
- The dashboard's bar for a per-address quota showed only the busiest
address, so one address that used up its allowance filled it (100%).
The bar is now all the addresses together, and the dashboard and the
rule's quota line say how many addresses used theirs up.
- Changing how a quota counts lost or muddled what it used: per address - Changing how a quota counts lost or muddled what it used: per address
to a total started at nothing, per source to per destination kept the to a total started at nothing, per source to per destination kept the
sources' use as if it were the destinations', and other groups left sources' use as if it were the destinations', and other groups left
+11 -4
View File
@@ -72,19 +72,26 @@ export async function render(el) {
} }
// quotaPanel lists every rule's quota with what it used: the total, or // quotaPanel lists every rule's quota with what it used: the total, or
// for a per-address quota the busiest address (the rest are on the rule). // for a per-address quota how many addresses used theirs up and the
// busiest (the rest are on the rule). A per-address quota's bar is all
// its addresses together, so one address alone can't fill it.
function quotaPanel(quotas, names) { function quotaPanel(quotas, names) {
const perAddress = (q) => q.quota.per_source || q.quota.per_destination; const perAddress = (q) => q.quota.per_source || q.quota.per_destination;
const used = (q) => perAddress(q) ? ((q.addresses || [])[0] || { used: 0 }).used : q.used; const addrs = (q) => q.addresses || [];
const usedUp = (q) => addrs(q).filter((a) => a.used >= q.quota.bytes).length;
const bar = (q) => perAddress(q)
? usageBar(addrs(q).reduce((n, a) => n + Math.min(a.used, q.quota.bytes), 0), addrs(q).length * q.quota.bytes)
: usageBar(q.used, q.quota.bytes);
return h("div", { class: "panel" }, return h("div", { class: "panel" },
h("div", { class: "panel-head" }, h("h2", null, "Quotas"), h("a", { class: "button small", href: "#/stats" }, "Statistics")), h("div", { class: "panel-head" }, h("h2", null, "Quotas"), h("a", { class: "button small", href: "#/stats" }, "Statistics")),
table([ table([
{ label: "Rule", render: (q) => h("a", { href: "#/rules?rule=" + q.rule_id }, q.rule) }, { label: "Rule", render: (q) => h("a", { href: "#/rules?rule=" + q.rule_id }, q.rule) },
{ label: "Allowance", render: (q) => fmtBytes(q.quota.bytes) + (q.quota.per_source ? " each source" : q.quota.per_destination ? " each destination" : "") }, { label: "Allowance", render: (q) => fmtBytes(q.quota.bytes) + (q.quota.per_source ? " each source" : q.quota.per_destination ? " each destination" : "") },
{ label: "Used", render: (q) => perAddress(q) { label: "Used", render: (q) => perAddress(q)
? [plural((q.addresses || []).length, "address", "addresses"), q.addresses && q.addresses.length ? [", busiest ", named(names, q.addresses[0].address), " " + fmtBytes(used(q)) + " (" + pct(used(q), q.quota.bytes) + ")"] : ""] ? [plural(addrs(q).length, "address", "addresses"), usedUp(q) ? ", " + usedUp(q) + " used up" : "",
addrs(q).length ? [", busiest ", named(names, addrs(q)[0].address), " " + fmtBytes(addrs(q)[0].used) + " (" + pct(addrs(q)[0].used, q.quota.bytes) + ")"] : ""]
: fmtBytes(q.used) + " (" + pct(q.used, q.quota.bytes) + ")" }, : fmtBytes(q.used) + " (" + pct(q.used, q.quota.bytes) + ")" },
{ label: "", render: (q) => usageBar(used(q), q.quota.bytes) }, { label: "", render: (q) => addrs(q).length || !perAddress(q) ? bar(q) : "" },
{ label: "Next reset", render: (q) => q.next_reset ? time(q.next_reset) : h("span", { class: "muted" }, "by hand") }, { label: "Next reset", render: (q) => q.next_reset ? time(q.next_reset) : h("span", { class: "muted" }, "by hand") },
], quotas)); ], quotas));
} }
+3 -2
View File
@@ -167,8 +167,9 @@ function quotaLine(r, q) {
let used = fmtBytes(qt.bytes) + each; let used = fmtBytes(qt.bytes) + each;
if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")"; if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")";
else if (q) { else if (q) {
const n = (q.addresses || []).length; const a = q.addresses || [], n = a.length;
used += " · " + (n === 1 ? "1 address" : n + " addresses") + (n ? ", busiest " + fmtBytes(q.addresses[0].used) + " (" + pct(q.addresses[0].used, qt.bytes) + ")" : ""); const up = a.filter((x) => x.used >= qt.bytes).length;
used += " · " + (n === 1 ? "1 address" : n + " addresses") + (up ? ", " + up + " used up" : "") + (n ? ", busiest " + fmtBytes(a[0].used) + " (" + pct(a[0].used, qt.bytes) + ")" : "");
} }
const text = "quota " + used + " · " + resets; const text = "quota " + used + " · " + resets;
return h("div", { class: "muted small" }, q && r.pending !== "new" return h("div", { class: "muted small" }, q && r.pending !== "new"