Compare commits

...
2 Commits
Author SHA1 Message Date
abrown38andClaude Opus 5.5 4e35236dba Release 0.2.0-rc.1
release / release (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:52:51 +01:00
abrown38andClaude Opus 5.5 4af4a5908b 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>
2026-10-22 18:52:51 +01:00
5 changed files with 22 additions and 10 deletions

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
+3 -3
View File
@@ -128,14 +128,14 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status ## Status
Housecarl 0.1.1 is in testing (0.1.1-rc.13): zones, address sets and Housecarl 0.2.0 is in testing (0.2.0-rc.1): zones, address sets and
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
and IPv6 pinholes, source NAT and redirects, built-ins, probation for and IPv6 pinholes, source NAT and redirects, built-ins, probation for
network changes (confirmed by the client that made them), the ruleset network changes (confirmed by the client that made them), the ruleset
at boot with a fail-closed fallback, hits and the drop log, nftables at boot with a fail-closed fallback, hits and the drop log, nftables
import, changesets reviewed with the ruleset's diff (as in Jarl), import, changesets reviewed with the ruleset's diff (as in Jarl),
bandwidth limits and quotas, shadow mode beside another firewall, a web bandwidth limits and quotas, shadow mode beside another firewall, ban
UI, REST API and CLI. lists for Guard and fail2ban, a web UI, REST API and CLI.
## Licence ## Licence
+1 -1
View File
@@ -1 +1 @@
0.1.1-rc.13 0.2.0-rc.1
+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"