diff --git a/CHANGELOG.md b/CHANGELOG.md index 33079b6..f3092a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to Housecarl are documented here. The format follows ### Added +- **Quotas on the dashboard.** With any rule holding a quota, the + dashboard lists them all: the allowance, what is used (for a + per-address quota, how many addresses and the busiest) with a bar, and + the next reset. Each opens its rule. - **Links to a rule open it.** A rule named in the matrix, the log, the tracer, statistics or the dashboard's busiest rules opens the Rules page on its chain, scrolled to it and highlighted (`#/rules?rule=ID`). diff --git a/docs/rules.md b/docs/rules.md index 20ab948..b86603f 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -188,6 +188,10 @@ hc quotas # what each used, and the next reset hc rule reset-quota 31 # start again now ``` +In the web UI each quota's use is on its rule (with **Reset**), all of +them together on the dashboard, and their history under **Traffic → +Statistics**. + | | | |---|---| | `per-source`, `per-destination` | each address its own allowance; without, one allowance for everything the rule matches | diff --git a/internal/web/static/assets/pages/dashboard.js b/internal/web/static/assets/pages/dashboard.js index d7d9041..df2b7c1 100644 --- a/internal/web/static/assets/pages/dashboard.js +++ b/internal/web/static/assets/pages/dashboard.js @@ -1,9 +1,9 @@ -// Dashboard: the ruleset's state, problems, interfaces, the busiest rules -// and the latest drops. -import { h, mount, table, badge, problemsList, time } from "../ui.js"; +// Dashboard: the ruleset's state, problems, interfaces, the busiest rules, +// the quotas and the latest drops. +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 } from "../widgets.js"; +import { describe, hits, humanCount, fmtBytes, usageBar } from "../widgets.js"; import { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js"; import { bytes } from "./stats.js"; @@ -11,12 +11,13 @@ export async function render(el) { const draw = async () => { const st = await get("/status"); const config = can("config", false), logs = can("log", false); - const [rules, cs, log, tl, zones] = await Promise.all([ + const [rules, cs, log, tl, zones, quotas] = await Promise.all([ config ? get("/rules") : [], logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {}, logs ? get("/log", { limit: 8 }).catch(() => []) : [], logs ? get("/stats/timeline", { since: "24h", step: "1h", keys: "zrx:*,ztx:*,drops" }).catch(() => null) : null, config ? get("/zones").catch(() => []) : [], + logs ? get("/quotas").catch(() => []) : [], ]); const busiest = rules.filter((r) => cs["r" + r.id] && cs["r" + r.id].packets > 0) .sort((a, b) => cs["r" + b.id].packets - cs["r" + a.id].packets).slice(0, 8); @@ -54,6 +55,7 @@ export async function render(el) { { label: "Hits", render: (r) => hits(cs, "r" + r.id) }, { label: "Comment", render: (r) => r.comment }, ], busiest)) : null, + quotas.length ? quotaPanel(quotas) : null, log.length ? h("div", { class: "panel" }, h("div", { class: "panel-head" }, h("h2", null, "Latest logged packets"), h("a", { class: "button small", href: "#/log" }, "The log")), table([ @@ -69,6 +71,24 @@ export async function render(el) { onLeave(() => clearInterval(t)); } +// 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). +function quotaPanel(quotas) { + const perAddress = (q) => q.quota.per_source || q.quota.per_destination; + const used = (q) => perAddress(q) ? ((q.addresses || [])[0] || { used: 0 }).used : q.used; + return h("div", { class: "panel" }, + h("div", { class: "panel-head" }, h("h2", null, "Quotas"), h("a", { class: "button small", href: "#/stats" }, "Statistics")), + table([ + { label: "Rule", render: (q) => h("a", { href: "#/rules?rule=" + q.rule_id }, "#" + 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 " + fmtBytes(used(q)) : ""] + : fmtBytes(q.used) }, + { 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)); +} + // statsCharts: the last 24 hours' traffic with the internet and drops, // per hour (the Statistics page has the rest). function statsCharts(tl, zones) { diff --git a/internal/web/static/assets/pages/rules.js b/internal/web/static/assets/pages/rules.js index 5ae1058..912ad86 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 } from "../widgets.js"; +import { names, matchEditor, describe, hits, counters, listEditor, rowClass, live, fmtBytes, quotas, usageBar } from "../widgets.js"; const chains = [ ["input", "Input", "Traffic to the firewall itself."], @@ -184,16 +184,10 @@ async function usageDialog(r, q) { table([ { label: "Address", render: (a) => h("span", { class: "mono" }, a.address) }, { label: "Used", render: (a) => fmtBytes(a.used) }, - { label: "", render: (a) => h("div", { class: "usage" }, h("div", { class: "usage-bar" + (a.used >= r.quota.bytes ? " full" : "") }, bar(a.used / r.quota.bytes))) }, + { label: "", render: (a) => usageBar(a.used, r.quota.bytes) }, ], q.addresses)), [{ label: "Close", value: null }], { wide: true }); } -function bar(f) { - const seg = h("div", { class: "seg leased" }); - seg.style.width = Math.min(100, f * 100) + "%"; - return seg; -} - async function resetQuota(r) { if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" + (r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return; diff --git a/internal/web/static/assets/widgets.js b/internal/web/static/assets/widgets.js index 08ebc93..a35f2f4 100644 --- a/internal/web/static/assets/widgets.js +++ b/internal/web/static/assets/widgets.js @@ -156,6 +156,13 @@ export function fmtBytes(n) { return n + " bytes"; } +// 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" }); + seg.style.width = Math.min(100, used / total * 100) + "%"; + return h("div", { class: "usage" }, h("div", { class: "usage-bar" + (used >= total ? " full" : "") }, seg)); +} + // quotas loads what the rules' quotas used, by rule ID ({} without // log:read). export async function quotas() {