diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dfcc13..8561313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ All notable changes to Housecarl are documented here. The format follows ### Added +- **Quota usage as a share.** What a quota used shows its percentage + (`12.3 GB of 50.0 GB used (24%)`, each address's in the quota + dialog), on the Rules page, the dashboard and in `hc quotas`. - **Reset one address's quota, or give some back.** `hc rule reset-quota ID --address ADDR` starts only that device's allowance again; `--give 5gbytes` takes that much off what was used instead (one @@ -93,6 +96,10 @@ All notable changes to Housecarl are documented here. The format follows the old entries behind. Now a total starts at the addresses' sum, wider groups add up their addresses, and what can't be carried starts again with a warning (`quota_restarts`) on the change. +- Statistics' Quotas card linked every entry to the Rules page, not to + its rule; each now opens its rule, named. Per-address quotas were + missing from it (and from the `quota:rN` series): they count their + addresses' sum. - `hc stats top devices` listed each device twice (once for what it received, once for what it sent); it is one row with both again. - `hc rule add --before/--after/--first` made two changes (create at the diff --git a/cmd/hc/firewall.go b/cmd/hc/firewall.go index 031ee83..7614ee6 100644 --- a/cmd/hc/firewall.go +++ b/cmd/hc/firewall.go @@ -322,9 +322,13 @@ func quotasCmd() *cobra.Command { if q.Quota.PerSource || q.Quota.PerDestination { of += " each" } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, humanBytes(q.Used), of, resets, next) + used := humanBytes(q.Used) + if !q.Quota.PerSource && !q.Quota.PerDestination { + used += " (" + percent(q.Used, q.Quota.Bytes) + ")" + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", q.Rule, used, of, resets, next) for _, a := range q.Addresses { - fmt.Fprintf(w, " %s\t%s\t\t\t\n", named(names, a.Address, a.Address), humanBytes(a.Used)) + fmt.Fprintf(w, " %s\t%s (%s)\t\t\t\n", named(names, a.Address, a.Address), humanBytes(a.Used), percent(a.Used, q.Quota.Bytes)) } } }) @@ -332,6 +336,17 @@ func quotasCmd() *cobra.Command { } } +// percent writes used as a share of total: "25%", "<1%" for a little. +func percent(used, total int64) string { + if total <= 0 || used <= 0 { + return "0%" + } + if used*100 < total { + return "<1%" + } + return fmt.Sprintf("%d%%", used*100/total) +} + func logCmd() *cobra.Command { var follow bool var limit int diff --git a/cmd/hc/main_test.go b/cmd/hc/main_test.go index 76cc932..b141ad0 100644 --- a/cmd/hc/main_test.go +++ b/cmd/hc/main_test.go @@ -53,6 +53,11 @@ func TestLimitAndDescribe(t *testing.T) { t.Errorf("describe(%+v, %s) = %q, want %q", c.m, c.chain, got, c.want) } } + for used, want := range map[int64]string{0: "0%", 5: "<1%", 250: "25%", 1000: "100%", 1300: "130%"} { + if got := percent(used, 1000); got != want { + t.Errorf("percent(%d, 1000) = %q, want %q", used, got, want) + } + } if got := editList([]string{"a", "b", "c"}, []string{"d"}, []string{"b"}); len(got) != 3 || got[1] != "c" || got[2] != "d" { t.Errorf("editList: %v", got) } diff --git a/internal/stats/collector.go b/internal/stats/collector.go index b76b397..0ee99ff 100644 --- a/internal/stats/collector.go +++ b/internal/stats/collector.go @@ -402,7 +402,10 @@ func (c *Collector) sample(ctx context.Context, minute int64) map[string]int64 { for set, es := range t.QuotaSets { kind := strings.TrimSuffix(strings.TrimSuffix(set, "_v4"), "_v6") for _, e := range es { - addTop(kind, e.Addr, c.table.of(set+"/"+e.Addr, e.Used)) + d := c.table.of(set+"/"+e.Addr, e.Used) + addTop(kind, e.Addr, d) + // A per-address quota's series is its addresses' sum. + add("quota:"+strings.TrimPrefix(kind, "quota_"), d) } } for set, es := range t.DeviceSets { diff --git a/internal/stats/collector_test.go b/internal/stats/collector_test.go index 4666d3d..2a5a140 100644 --- a/internal/stats/collector_test.go +++ b/internal/stats/collector_test.go @@ -85,6 +85,10 @@ func TestCollector(t *testing.T) { h.table.Counters["r1"] = nft.Counter{Packets: 10, Bytes: 1000} h.table.Counters["r2"] = nft.Counter{Packets: 100, Bytes: 9000} h.table.Quotas["quota_r5"] = 1 << 20 + if h.table.QuotaSets == nil { + h.table.QuotaSets = map[string][]render.Element{} + } + h.table.QuotaSets["quota_r6_v4"] = []render.Element{{Addr: "10.0.10.5", Used: 1000}} h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 5000}} store, err := OpenStore(":memory:") @@ -104,6 +108,7 @@ func TestCollector(t *testing.T) { h.table.Counters["r1"] = nft.Counter{Packets: 25, Bytes: 2500} h.table.Counters["p-input"] = nft.Counter{Packets: 3, Bytes: 180} // a new counter counts whole h.table.Quotas["quota_r5"] = 3 << 20 + h.table.QuotaSets["quota_r6_v4"] = []render.Element{{Addr: "10.0.10.5", Used: 2000}, {Addr: "10.0.10.6", Used: 500}} // a new entry counts whole h.table.DeviceSets["stats_up_v4"] = []render.Element{{Addr: "10.0.10.5", Bytes: 8000}} h.table.DeviceSets["stats_down_v6"] = []render.Element{{Addr: "2001:db8:1:2::", Bytes: 70000}} for range 2 { @@ -142,6 +147,7 @@ func TestCollector(t *testing.T) { check("drop:forward", sum("drop:")["forward"], 15) check("drop:input", sum("drop:")["input"], 3) check("quota:r5", sum("quota:")["r5"], 2<<20) + check("quota:r6 (per address: the sum)", sum("quota:")["r6"], 1500) s, err := q.Summary(ctx, time.Hour, 10) if err != nil { diff --git a/internal/web/static/assets/charts.js b/internal/web/static/assets/charts.js index d9afabb..b766e62 100644 --- a/internal/web/static/assets/charts.js +++ b/internal/web/static/assets/charts.js @@ -331,7 +331,7 @@ export function heatmapTable(hm, unit) { // so the list is its own table view. // extra(key), when given, adds something after an entry's label (e.g. a // link that excludes it). -export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra, format = compact } = {}) { +export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra, format = compact, share: showShare = true } = {}) { if (!entries.length) return h("div", { class: "empty" }, empty); // Entries may be in a natural order (e.g. latency buckets), not by size. const peak = Math.max(1, ...entries.map((e) => e.count)); @@ -345,7 +345,7 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + format(e.count) + " (" + share.toFixed(1) + "%)" }, h("div", { class: "bar-text" }, link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown), - h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, format(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))), + h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, format(e.count), showShare ? h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%") : null)), h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } }))); })); } diff --git a/internal/web/static/assets/pages/dashboard.js b/internal/web/static/assets/pages/dashboard.js index 3e0cff2..91369ed 100644 --- a/internal/web/static/assets/pages/dashboard.js +++ b/internal/web/static/assets/pages/dashboard.js @@ -3,7 +3,7 @@ import { h, mount, table, badge, problemsList, time, plural } from "../ui.js"; import { get, can } from "../api.js"; import { onLeave } from "../app.js"; -import { describe, hits, humanCount, fmtBytes, usageBar, lookupNames, named } from "../widgets.js"; +import { describe, hits, humanCount, fmtBytes, usageBar, lookupNames, named, pct } from "../widgets.js"; import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js"; import { bytes } from "./stats.js"; @@ -82,8 +82,8 @@ function quotaPanel(quotas, names) { { 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: "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))] : ""] - : fmtBytes(q.used) }, + ? [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) + ")"] : ""] + : fmtBytes(q.used) + " (" + pct(q.used, q.quota.bytes) + ")" }, { label: "", render: (q) => usageBar(used(q), q.quota.bytes) }, { label: "Next reset", render: (q) => q.next_reset ? time(q.next_reset) : h("span", { class: "muted" }, "by hand") }, ], quotas)); diff --git a/internal/web/static/assets/pages/rules.js b/internal/web/static/assets/pages/rules.js index 9cd8efe..fdecde2 100644 --- a/internal/web/static/assets/pages/rules.js +++ b/internal/web/static/assets/pages/rules.js @@ -2,7 +2,7 @@ import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js"; import { get, api, change, can } from "../api.js"; import { rerender } from "../app.js"; -import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar, lookupNames, named } from "../widgets.js"; +import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar, lookupNames, named, pct } from "../widgets.js"; const chains = [ ["input", "Input", "Traffic to the firewall itself."], @@ -165,10 +165,10 @@ function quotaLine(r, q) { const each = qt.per_source ? " each source" : qt.per_destination ? " each destination" : ""; 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"; + if (q && !each) used = fmtBytes(q.used) + " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")"; else if (q) { const n = (q.addresses || []).length; - used += " · " + (n === 1 ? "1 address" : n + " addresses") + (n ? ", busiest " + fmtBytes(q.addresses[0].used) : ""); + used += " · " + (n === 1 ? "1 address" : n + " addresses") + (n ? ", busiest " + fmtBytes(q.addresses[0].used) + " (" + pct(q.addresses[0].used, qt.bytes) + ")" : ""); } const text = "quota " + used + " · " + resets; return h("div", { class: "muted small" }, q && r.pending !== "new" @@ -193,12 +193,13 @@ async function quotaDialog(r, q) { perAddress ? table([ { label: "Address", render: (a) => h("span", { class: "mono" }, named(names, a.address)) }, { label: "Used", render: (a) => fmtBytes(a.used) }, + { label: "", class: "num", tdClass: "num", render: (a) => pct(a.used, qt.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 || [], { 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))), + : h("div", null, h("strong", null, fmtBytes(q.used)), " of " + fmtBytes(qt.bytes) + " used (" + pct(q.used, qt.bytes) + ")", 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 }, diff --git a/internal/web/static/assets/pages/stats.js b/internal/web/static/assets/pages/stats.js index e646d81..c240907 100644 --- a/internal/web/static/assets/pages/stats.js +++ b/internal/web/static/assets/pages/stats.js @@ -104,7 +104,7 @@ export async function render(el, params, query) { listCard("What they tried", "the logged drops' protocol and port", barList(s.blocked_ports, { empty: "No logged drops " + rangeLabel + "." })), listCard("Devices, received", "bytes from external zones, by inside address (IPv6 per /64)", s.devices_down.length ? barList(s.devices_down, { format: bytes, href: connHref }) : devHint), listCard("Devices, sent", "bytes to external zones", s.devices_up.length ? barList(s.devices_up, { format: bytes, href: connHref }) : devHint.cloneNode(true)), - d.quotas.length ? listCard("Quotas", "bytes counted against each rule's quota, " + rangeLabel, barList(d.quotas, { format: bytes, label: (k) => "rule #" + k.slice(1), href: () => "#/rules" })) : null, + d.quotas.length ? listCard("Quotas", "bytes counted against each rule's quota, " + rangeLabel, barList(d.quotas, { format: bytes, label: (k) => (d.hits.find((x) => x.key === k) || {}).name || "rule #" + k.slice(1), href: ruleHref, share: false })) : null, listCard("Rules that matched nothing", "in the last 30 days (or since statistics started): candidates to remove", unused.length ? h("div", null, h("ul", { class: "plain-list" }, unused.slice(0, 12).map((e) => h("li", null, h("a", { href: ruleHref(e.key) }, e.name || e.key)))), unused.length > 12 ? h("div", { class: "muted small" }, "and " + (unused.length - 12) + " more: hc stats rules --unused") : null) : h("div", { class: "empty" }, "Every rule matched something.")))); diff --git a/internal/web/static/assets/widgets.js b/internal/web/static/assets/widgets.js index 8fd06e1..d30c087 100644 --- a/internal/web/static/assets/widgets.js +++ b/internal/web/static/assets/widgets.js @@ -161,6 +161,13 @@ export function fmtBytes(n) { return n + " bytes"; } +// pct writes used as a share of total: "25%", "<1%" for a little. +export function pct(used, total) { + if (!total || used <= 0) return "0%"; + const p = used / total * 100; + return p < 1 ? "<1%" : Math.floor(p) + "%"; +} + // usageBar draws used out of total as a bar, red once it is all used. export function usageBar(used, total) { const seg = h("div", { class: "seg leased" }); diff --git a/scripts/e2e.sh b/scripts/e2e.sh index db86699..53060aa 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -322,7 +322,7 @@ send 2000000 >/dev/null 2>&1 run "used up: the device is cut off" 1 send 1000 run "the usage" 0 H quotas contains "e2e quota.*1.0 MB each.*by hand" -contains "$CLIENT4 (e2e-client) *1\.[0-9] MB" +contains "$CLIENT4 (e2e-client) *1\.[0-9] MB (1[0-9][0-9]%)" stop; start run "after a restart, still used up" 1 send 1000 run "the usage after a restart" 0 H quotas @@ -348,7 +348,7 @@ run "within the quota" 0 send 300000 run "to one total" 0 H rule update "$QR" --quota 1mbytes,over not_contains "quota_restarts" run "the total carried over" 0 H quotas -contains "e2e quota) *[0-9.]* KB" +contains "e2e quota) *[0-9.]* KB ([0-9]*%)" run "per destination starts again" 0 H rule update "$QR" --quota 1mbytes,over,per-destination contains "quota_restarts" run "the quota rule goes" 0 H rule delete -y "$QR"