Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f24ae0676 | ||
|
|
1ccca33a30 | ||
|
|
aeaada60ac | ||
|
|
05c811bddc |
No files matched your search
@@ -8,6 +8,10 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
|
|
||||||
### Added
|
### 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
|
- **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
|
tracer, statistics or the dashboard's busiest rules opens the Rules
|
||||||
page on its chain, scrolled to it and highlighted (`#/rules?rule=ID`).
|
page on its chain, scrolled to it and highlighted (`#/rules?rule=ID`).
|
||||||
@@ -58,6 +62,11 @@ All notable changes to Housecarl are documented here. The format follows
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- `hc rule add --before/--after/--first` made two changes (create at the
|
||||||
|
end, then move), so two history entries, two probations, and the rule
|
||||||
|
briefly in force at the end of its chain; staged in a changeset, the
|
||||||
|
place was lost. The create now takes the place (`POST
|
||||||
|
/rules?before=ID`, `after`, `first`) and makes one change.
|
||||||
- `hc matrix` in a terminal: the colours' escape codes threw the columns
|
- `hc matrix` in a terminal: the colours' escape codes threw the columns
|
||||||
out of line.
|
out of line.
|
||||||
- `hc counters` (`GET /counters`) kept listing the objects of a
|
- `hc counters` (`GET /counters`) kept listing the objects of a
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ 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.4): 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
|
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
|
||||||
|
|||||||
+14
-18
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -340,25 +341,20 @@ output (from it) or early (before connection tracking: cheap drops).
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var resp api.MutationResponse
|
// Placed in the same change: one history entry, and never in
|
||||||
if err := c.Do(ctx(), "POST", "/rules", nil, r, &resp); err != nil {
|
// force at the end of the chain.
|
||||||
return err
|
q := url.Values{}
|
||||||
|
switch {
|
||||||
|
case rf.first:
|
||||||
|
q.Set("first", "1")
|
||||||
|
case rf.before != 0:
|
||||||
|
q.Set("before", fmt.Sprint(rf.before))
|
||||||
|
case rf.after != 0:
|
||||||
|
q.Set("after", fmt.Sprint(rf.after))
|
||||||
}
|
}
|
||||||
// Then place it.
|
var resp api.MutationResponse
|
||||||
if resp.Applied && (rf.first || rf.before != 0 || rf.after != 0) {
|
if err := c.Do(ctx(), "POST", "/rules", q, r, &resp); err != nil {
|
||||||
var created model.Rule
|
return err
|
||||||
if err := jsonUnmarshal(resp.Object, &created); err == nil {
|
|
||||||
mv := api.MoveInput{Before: rf.before, After: rf.after}
|
|
||||||
if rf.first {
|
|
||||||
mv.To = "start"
|
|
||||||
}
|
|
||||||
var moved api.MutationResponse
|
|
||||||
if err := c.Do(ctx(), "POST", objPath("rules", fmt.Sprint(created.ID))+"/move", nil, mv, &moved); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
resp.Changes = append(resp.Changes, moved.Changes...)
|
|
||||||
resp.ConfirmBy = moved.ConfirmBy
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return report(&resp)
|
return report(&resp)
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -114,7 +114,7 @@ share one pattern, `/{kind}` with kind `zones`, `sets`, `services`,
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /{kind}` | all of them, in order; `GET /rules?chain=input` for one chain; `GET /services` includes the built-in ones |
|
| `GET /{kind}` | all of them, in order; `GET /rules?chain=input` for one chain; `GET /services` includes the built-in ones |
|
||||||
| `POST /{kind}` | create; rules, forwards and NAT rules go last unless given a `position` |
|
| `POST /{kind}[?before=ID\|after=ID\|first=1]` | create; rules, forwards and NAT rules go last unless given a `position`, or placed before or after another of their chain, or first, in the same change (one history entry; staged in a changeset, the place goes with it) |
|
||||||
| `GET /{kind}/{ref}` | by ID, or name for zones, sets, services and forwards |
|
| `GET /{kind}/{ref}` | by ID, or name for zones, sets, services and forwards |
|
||||||
| `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) |
|
| `PATCH /{kind}/{ref}` | change the fields given (a JSON merge: `null` clears) |
|
||||||
| `DELETE /{kind}/{ref}` | refused while something uses it |
|
| `DELETE /{kind}/{ref}` | refused while something uses it |
|
||||||
|
|||||||
@@ -188,6 +188,10 @@ hc quotas # what each used, and the next reset
|
|||||||
hc rule reset-quota 31 # start again now
|
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 |
|
| `per-source`, `per-destination` | each address its own allowance; without, one allowance for everything the rule matches |
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ type Op struct {
|
|||||||
ID int64 `json:"id,omitempty"`
|
ID int64 `json:"id,omitempty"`
|
||||||
// Data is the object (create), the fields to change (update,
|
// Data is the object (create), the fields to change (update,
|
||||||
// settings) or where to move it (move).
|
// settings) or where to move it (move).
|
||||||
Data json.RawMessage `json:"data,omitempty"`
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
// Place is where a create puts the object in its order (a move's
|
||||||
|
// data); none: at the end.
|
||||||
|
Place json.RawMessage `json:"place,omitempty"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
AddedAt time.Time `json:"added_at"`
|
AddedAt time.Time `json:"added_at"`
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
udiff "github.com/aymanbagabas/go-udiff"
|
udiff "github.com/aymanbagabas/go-udiff"
|
||||||
@@ -310,7 +311,19 @@ func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
|||||||
if _, err := decodeObject(kind, raw); err != nil {
|
if _, err := decodeObject(kind, raw); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.change(w, r, p, model.Op{Op: model.OpCreate, Kind: kind, Data: raw})
|
op := model.Op{Op: model.OpCreate, Kind: kind, Data: raw}
|
||||||
|
// ?before=ID, ?after=ID or ?first=1: where it goes in its order.
|
||||||
|
q := r.URL.Query()
|
||||||
|
var place api.MoveInput
|
||||||
|
place.Before, _ = strconv.ParseInt(q.Get("before"), 10, 64)
|
||||||
|
place.After, _ = strconv.ParseInt(q.Get("after"), 10, 64)
|
||||||
|
if queryBool(r, "first") {
|
||||||
|
place.To = "start"
|
||||||
|
}
|
||||||
|
if place.Before != 0 || place.After != 0 || place.To != "" {
|
||||||
|
op.Place, _ = json.Marshal(place)
|
||||||
|
}
|
||||||
|
return s.change(w, r, p, op)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
|||||||
+11
-1
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"housecarl/internal/api"
|
"housecarl/internal/api"
|
||||||
@@ -63,7 +64,16 @@ func createOp(st *model.State, op *model.Op) (int64, error) {
|
|||||||
*pos = lastPosition(st, op.Kind, chainOf(o))
|
*pos = lastPosition(st, op.Kind, chainOf(o))
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(o)
|
data, _ := json.Marshal(o)
|
||||||
return op.ID, st.Put(op.Kind, op.ID, data)
|
if err := st.Put(op.Kind, op.ID, data); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
// Placed in the same change, so it is never in force anywhere else.
|
||||||
|
if len(op.Place) > 0 {
|
||||||
|
if _, err := moveOp(st, &model.Op{Op: model.OpMove, Kind: op.Kind, Ref: strconv.FormatInt(op.ID, 10), Data: op.Place}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return op.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateOp(st *model.State, op *model.Op) (int64, error) {
|
func updateOp(st *model.State, op *model.Op) (int64, error) {
|
||||||
|
|||||||
@@ -1184,3 +1184,49 @@ func (e *env) text(path string) string {
|
|||||||
b, _ := io.ReadAll(resp.Body)
|
b, _ := io.ReadAll(resp.Body)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreatePlaced: a rule created ?before= another is placed in the same
|
||||||
|
// change (one history entry), also when staged in a changeset.
|
||||||
|
func TestCreatePlaced(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
var a, b, c api.MutationResponse
|
||||||
|
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"ssh"}}}, &a)
|
||||||
|
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"dns"}}}, &b)
|
||||||
|
var first model.Rule
|
||||||
|
json.Unmarshal(a.Object, &first)
|
||||||
|
if code := e.call(e.token, "POST", "/rules?before="+itoa(first.ID), model.Rule{Chain: "input", Match: model.Match{Services: []string{"http"}}, Comment: "placed"}, &c); code != 201 {
|
||||||
|
t.Fatalf("create placed: %d %+v", code, c)
|
||||||
|
}
|
||||||
|
order := func() string {
|
||||||
|
var out []string
|
||||||
|
for _, r := range e.eng.State().Rules {
|
||||||
|
out = append(out, strings.Join(r.Services, ""))
|
||||||
|
}
|
||||||
|
return strings.Join(out, ",")
|
||||||
|
}
|
||||||
|
if o := order(); o != "http,ssh,dns" {
|
||||||
|
t.Errorf("order: %s", o)
|
||||||
|
}
|
||||||
|
var hs []model.HistoryEntry
|
||||||
|
e.call(e.token, "GET", "/history", nil, &hs)
|
||||||
|
if len(hs) != 3 || len(hs[0].Changes) != 1 || hs[0].Changes[0].Action() != "create" {
|
||||||
|
t.Errorf("history: %d entries, newest %+v", len(hs), hs[0])
|
||||||
|
}
|
||||||
|
// Staged, the place goes with it.
|
||||||
|
var cs model.Changeset
|
||||||
|
e.call(e.token, "POST", "/changesets", nil, &cs)
|
||||||
|
e.call(e.token, "POST", "/rules?first=1&changeset="+itoa(cs.ID), model.Rule{Chain: "input", Match: model.Match{Services: []string{"ntp"}}}, nil)
|
||||||
|
if code := e.call(e.token, "POST", "/changesets/"+itoa(cs.ID)+"/apply", nil, nil); code != 200 {
|
||||||
|
t.Fatalf("apply: %d", code)
|
||||||
|
}
|
||||||
|
if o := order(); o != "ntp,http,ssh,dns" {
|
||||||
|
t.Errorf("order after the changeset: %s", o)
|
||||||
|
}
|
||||||
|
// A bad place: refused, nothing created.
|
||||||
|
if code := e.call(e.token, "POST", "/rules?before=99999", model.Rule{Chain: "input"}, nil); code != 400 {
|
||||||
|
t.Errorf("before a rule that isn't there: %d", code)
|
||||||
|
}
|
||||||
|
if n := len(e.eng.State().Rules); n != 4 {
|
||||||
|
t.Errorf("%d rules", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// Dashboard: the ruleset's state, problems, interfaces, the busiest rules
|
// Dashboard: the ruleset's state, problems, interfaces, the busiest rules,
|
||||||
// and the latest drops.
|
// the quotas and the latest drops.
|
||||||
import { h, mount, table, badge, problemsList, time } from "../ui.js";
|
import { h, mount, table, badge, problemsList, time, plural } from "../ui.js";
|
||||||
import { get, can } from "../api.js";
|
import { get, can } from "../api.js";
|
||||||
import { onLeave } from "../app.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 { lineChart, chartCard, legend, timeTable, stepSeconds } from "../charts.js";
|
||||||
import { bytes } from "./stats.js";
|
import { bytes } from "./stats.js";
|
||||||
|
|
||||||
@@ -11,12 +11,13 @@ export async function render(el) {
|
|||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
const st = await get("/status");
|
const st = await get("/status");
|
||||||
const config = can("config", false), logs = can("log", false);
|
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") : [],
|
config ? get("/rules") : [],
|
||||||
logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {},
|
logs ? get("/counters").then((c) => c.counters).catch(() => ({})) : {},
|
||||||
logs ? get("/log", { limit: 8 }).catch(() => []) : [],
|
logs ? get("/log", { limit: 8 }).catch(() => []) : [],
|
||||||
logs ? get("/stats/timeline", { since: "24h", step: "1h", keys: "zrx:*,ztx:*,drops" }).catch(() => null) : null,
|
logs ? get("/stats/timeline", { since: "24h", step: "1h", keys: "zrx:*,ztx:*,drops" }).catch(() => null) : null,
|
||||||
config ? get("/zones").catch(() => []) : [],
|
config ? get("/zones").catch(() => []) : [],
|
||||||
|
logs ? get("/quotas").catch(() => []) : [],
|
||||||
]);
|
]);
|
||||||
const busiest = rules.filter((r) => cs["r" + r.id] && cs["r" + r.id].packets > 0)
|
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);
|
.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: "Hits", render: (r) => hits(cs, "r" + r.id) },
|
||||||
{ label: "Comment", render: (r) => r.comment },
|
{ label: "Comment", render: (r) => r.comment },
|
||||||
], busiest)) : null,
|
], busiest)) : null,
|
||||||
|
quotas.length ? quotaPanel(quotas) : null,
|
||||||
log.length ? h("div", { class: "panel" },
|
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")),
|
h("div", { class: "panel-head" }, h("h2", null, "Latest logged packets"), h("a", { class: "button small", href: "#/log" }, "The log")),
|
||||||
table([
|
table([
|
||||||
@@ -69,6 +71,24 @@ export async function render(el) {
|
|||||||
onLeave(() => clearInterval(t));
|
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,
|
// statsCharts: the last 24 hours' traffic with the internet and drops,
|
||||||
// per hour (the Statistics page has the rest).
|
// per hour (the Statistics page has the rest).
|
||||||
function statsCharts(tl, zones) {
|
function statsCharts(tl, zones) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { h, mount, table, modal, errorBox, field, input, select, checkbox, confirmDialog, badge, textarea, toast } from "../ui.js";
|
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 { get, api, change, can } from "../api.js";
|
||||||
import { rerender } from "../app.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 = [
|
const chains = [
|
||||||
["input", "Input", "Traffic to the firewall itself."],
|
["input", "Input", "Traffic to the firewall itself."],
|
||||||
@@ -184,16 +184,10 @@ async function usageDialog(r, q) {
|
|||||||
table([
|
table([
|
||||||
{ label: "Address", render: (a) => h("span", { class: "mono" }, a.address) },
|
{ label: "Address", render: (a) => h("span", { class: "mono" }, a.address) },
|
||||||
{ label: "Used", render: (a) => fmtBytes(a.used) },
|
{ 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 });
|
], 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) {
|
async function resetQuota(r) {
|
||||||
if (!await confirmDialog("Reset the quota", "Start the quota of " + (r.comment || "rule #" + r.id) + " again now" +
|
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;
|
(r.quota.per_source || r.quota.per_destination ? ", for every address" : "") + "? This is done at once, not staged.", "Reset")) return;
|
||||||
|
|||||||
@@ -156,6 +156,13 @@ export function fmtBytes(n) {
|
|||||||
return n + " bytes";
|
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
|
// quotas loads what the rules' quotas used, by rule ID ({} without
|
||||||
// log:read).
|
// log:read).
|
||||||
export async function quotas() {
|
export async function quotas() {
|
||||||
|
|||||||
Reference in new issue
Block a user