Compare commits

...
Author SHA1 Message Date
abrown38andClaude Opus 5.5 3f24ae0676 Release 0.1.1-rc.6
release / release (push) Successful in 1m5s
Housecarl 0.1.1's sixth release candidate. Since rc.5: the dashboard
lists the rules' quotas. make test passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:22:22 +01:00
abrown38andClaude Opus 5.5 1ccca33a30 Dashboard: list the rules' quotas
A Quotas panel with each quota's allowance, use (or, per address, the
count and the busiest), a usage bar and the next reset. The usage bar
moves to widgets.js and the Rules page's usage dialog shares it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:20:04 +01:00
7 changed files with 44 additions and 15 deletions

No files matched your search

+4
View File
@@ -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`).
+1 -1
View File
@@ -123,7 +123,7 @@ The web UI is on `http://127.0.0.1:8070/` (see `listen` in
## Status
Housecarl 0.1.1 is in testing (0.1.1-rc.5): zones, address sets and
Housecarl 0.1.1 is in testing (0.1.1-rc.6): zones, address sets and
services with ordered rules, IPv4 and IPv6, port forwards with hairpin NAT
and IPv6 pinholes, source NAT and redirects, built-ins, probation for
network changes (confirmed by the client that made them), the ruleset
+1 -1
View File
@@ -1 +1 @@
0.1.1-rc.5
0.1.1-rc.6
+4
View File
@@ -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 |
+25 -5
View File
@@ -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) {
+2 -8
View File
@@ -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;
+7
View File
@@ -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() {