"The query log holds 7 answers (stats.retention.queries)" read like 7 days' worth. The dashboard and the Statistics page now say how many answers it holds and how long each is kept; the dashboard's storage info has query_log_retention. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
194 lines
13 KiB
JavaScript
194 lines
13 KiB
JavaScript
import { h, mount, time, fmtTime, relTime, problemsList, badge, plural } from "../ui.js";
|
||
import { get, state } from "../api.js";
|
||
|
||
export async function render(el) {
|
||
const [st, mine, exposure] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => []), get("/exposure").catch(() => null)]);
|
||
const s = st.engine || {};
|
||
// The user's own changesets with something in them: what the Changes page shows.
|
||
const pending = mine.filter((c) => (c.ops || []).length).length;
|
||
const n = s.named;
|
||
const cards = h("div", { class: "cards" },
|
||
card("named", n ? (n.running ? badge("running", "ok") : badge("unknown", "warn")) : badge(s.named_error ? "unreachable" : "not checked", s.named_error ? "err" : ""),
|
||
n ? [n.version, h("br"), "last configured " + (n.last_configured || "?")] : (s.named_error || "")),
|
||
card("Jarl", s.adopted ? badge("active", "ok") : badge("not adopted", "warn"),
|
||
s.adopted ? "named loads Jarl's configuration" : "named does not load Jarl's configuration yet — run 'jarld adopt' on the server"),
|
||
card("Zones", (s.counts || {}).zones ?? "–", plural((s.counts || {}).records || 0, "record") + " · " + plural((s.counts || {}).views || 0, "view")),
|
||
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")),
|
||
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
|
||
st.backup ? backupCard(st.backup) : null,
|
||
exposure ? exposureCard(exposure) : null,
|
||
st.certificates ? certCard(st.certificates) : null,
|
||
st.storage ? storageCard(st.storage) : null,
|
||
st.encrypted_dns ? h("a", { class: "card link", href: "#/encrypted-dns" }, h("div", { class: "label" }, "Encrypted DNS"),
|
||
h("div", { class: "value" }, st.encrypted_dns.ok ? badge("working", "ok") : badge("not working", "err")),
|
||
h("div", { class: "sub" }, "checked ", time(st.encrypted_dns.checked))) : null,
|
||
);
|
||
const last = s.last_applied;
|
||
const parts = [h("div", { class: "page-head" },
|
||
h("div", { class: "grow" }, h("h1", null, "Dashboard"), h("div", { class: "muted" }, "Signed in as " + state.me.user.username + " · " + (state.me.permissions || []).join(", "))),
|
||
h("div", { class: "actions" },
|
||
h("a", { class: "button primary", href: "#/zones?new=1" }, "New zone"),
|
||
h("a", { class: "button", href: "#/changes" }, "Pending changes" + (pending ? " (" + pending + " open)" : "")))),
|
||
cards];
|
||
if (s.drift && s.drift.length) {
|
||
parts.push(h("div", { class: "msg warn" },
|
||
h("strong", null, "Files changed outside Jarl: "),
|
||
s.drift.map((d) => d.path + " (" + d.reason + ")").join(", "),
|
||
h("div", { class: "small" }, "The next apply refuses to overwrite them unless forced. An administrator can rewrite them with 'jarld resync' (or System → Resync).")));
|
||
}
|
||
const b = st.backup;
|
||
if (b && b.stale) {
|
||
parts.push(h("div", { class: "msg warn" },
|
||
h("strong", null, "The newest backup is " + relTime(b.last_at).replace(" ago", "") + " old. "),
|
||
"Backups should run daily: check 'systemctl status jarl-backup.timer' and 'journalctl -u jarl-backup' on the server."));
|
||
}
|
||
if (b && b.last_error) {
|
||
parts.push(h("div", { class: "msg err" },
|
||
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
|
||
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
|
||
}
|
||
for (const p of (st.encrypted_dns || {}).problems || []) {
|
||
parts.push(h("div", { class: "msg err" }, h("strong", null, "Encrypted DNS: "), p, " ", h("a", { href: "#/encrypted-dns" }, "Encrypted DNS")));
|
||
}
|
||
for (const c of (st.certificates || {}).problems || []) {
|
||
parts.push(h("div", { class: "msg " + (c.state === "expiring" ? "warn" : "err") },
|
||
h("strong", null, "Certificate " + c.name + " " + (c.state === "expired" ? "has expired" : c.state === "expiring" ? "expires soon" : "can't be renewed") + ": "), c.message, " ",
|
||
h("a", { href: "#/certificates" }, "Certificates")));
|
||
}
|
||
if (st.blocking_paused_until) {
|
||
parts.push(h("div", { class: "msg info" },
|
||
h("strong", null, "Blocking is paused until " + fmtTime(st.blocking_paused_until) + "."), " It resumes by itself. ", h("a", { href: "#/blocking" }, "Blocking")));
|
||
}
|
||
if (st.blocking_paused_indefinitely) {
|
||
parts.push(h("div", { class: "msg warn" },
|
||
h("strong", null, "Blocking is paused until someone resumes it."), " Every name is answered normally. ", h("a", { href: "#/blocking" }, "Blocking")));
|
||
}
|
||
for (const e of st.blocklist_errors || []) {
|
||
parts.push(h("div", { class: "msg warn" },
|
||
h("strong", null, "Blocklist " + e.name + " couldn't be updated: "), e.error + ". ",
|
||
e.updated_at ? ["named keeps using the version from ", time(e.updated_at), ". "] : "It isn't in use yet. ",
|
||
"jarld tries again within the hour. ", h("a", { href: "#/blocking" }, "Blocking")));
|
||
}
|
||
const tf = st.totp;
|
||
if (tf && tf.missing && tf.missing.length) {
|
||
parts.push(h("div", { class: "msg warn" },
|
||
h("strong", null, "Two-factor login is required (" + tf.policy + ") but not set up by: "), tf.missing.join(", "), ". ",
|
||
"They can only set it up when they next log in. ", h("a", { href: "#/users" }, "Users")));
|
||
}
|
||
if (tf && tf.shared_admin) {
|
||
parts.push(h("div", { class: "msg info" },
|
||
h("strong", null, "The first account, " + tf.shared_admin + ", is still active "), "next to personal administrator accounts. If it is shared, disable it under ",
|
||
h("a", { href: "#/users" }, "Users"), ": everyone then logs in as themselves, with their own two-factor login, and the audit log shows who did what. Root on the server can always get back in with 'jarld user passwd'."));
|
||
}
|
||
const exp = st.expiring_tokens || [];
|
||
if (exp.length) {
|
||
const now = new Date();
|
||
parts.push(h("div", { class: "msg warn" },
|
||
h("strong", null, exp.length === 1 ? "An API token expires soon: " : "API tokens expire soon: "),
|
||
exp.map((t, i) => [i ? ", " : "", h("a", { href: state.me.admin ? "#/users" : "#/account" }, t.username + "/" + t.name), " ",
|
||
new Date(t.expires_at) < now ? h("span", null, "expired ", time(t.expires_at)) : h("span", null, "expires ", time(t.expires_at))]),
|
||
h("div", { class: "small" }, "Rotate a token to give it a new lifetime and secret, keeping its permissions (API tokens → Rotate, or 'jarl token rotate'). Then update whatever uses it.")));
|
||
}
|
||
parts.push(h("div", { class: "panel" },
|
||
h("h2", null, "Last change"),
|
||
last ? h("div", null,
|
||
h("a", { href: "#/history/" + last.id }, "#" + last.id + " " + (last.description || "")), " ",
|
||
h("span", { class: "muted" }, "by " + last.username + " via " + last.via + ", "), time(last.applied_at))
|
||
: h("div", { class: "muted" }, "Nothing applied yet.")));
|
||
const problems = s.problems || [];
|
||
const real = problems.filter((p) => p.severity !== "info");
|
||
parts.push(h("div", { class: "panel" },
|
||
h("h2", null, "Configuration checks"),
|
||
real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."),
|
||
problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null));
|
||
if (st.storage) parts.push(storagePanel(st.storage));
|
||
if (s.paths) {
|
||
parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" },
|
||
h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"),
|
||
h("dt", null, "Jarl's named.conf"), h("dd", { class: "mono" }, s.paths.config_dir + "/named.conf"),
|
||
h("dt", null, "Zone files"), h("dd", { class: "mono" }, s.paths.zone_dir),
|
||
h("dt", null, "BIND tools"), h("dd", null, s.bind_version || "–"))));
|
||
}
|
||
mount(el, parts);
|
||
}
|
||
|
||
function backupCard(b) {
|
||
let value;
|
||
if (b.last_error) value = badge("failed", "err");
|
||
else if (!b.last_at) value = badge("none yet", "");
|
||
else value = badge(relTime(b.last_at), b.stale ? "warn" : "ok");
|
||
const sub = b.last_at ? h("span", { class: "mono small", title: b.last_file }, b.last_file.split("/").pop()) : "jarl-backup.timer writes to " + b.dir;
|
||
return card("Backup", value, sub);
|
||
}
|
||
|
||
// exposureCard sums up the internet exposure page.
|
||
function exposureCard(r) {
|
||
const pub = r.scopes.filter((s) => s.public);
|
||
let warn = r.server.filter((f) => f.severity === "warning").length;
|
||
for (const s of r.scopes) warn += s.findings.filter((f) => f.severity === "warning").length;
|
||
const value = !pub.length ? badge("nothing public", "ok") : warn ? badge(plural(warn, "warning"), "warn") : badge("hardened", "ok");
|
||
const sub = pub.length ? "public: " + pub.map((s) => s.view || "the server").join(", ") : "no view is reachable from the internet";
|
||
return h("a", { class: "card link", href: "#/exposure" }, h("div", { class: "label" }, "Exposure"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||
}
|
||
|
||
// certCard sums up the certificates Jarl manages.
|
||
function certCard(c) {
|
||
const bad = c.problems.length;
|
||
const value = bad ? badge(bad === 1 ? "1 needs attention" : bad + " need attention", c.problems.some((p) => p.state !== "expiring") ? "err" : "warn") : badge(c.total === 1 ? "1 current" : c.total + " current", "ok");
|
||
const sub = c.next ? ["next to expire: " + c.next + " ", time(c.next_after)] : "none obtained yet";
|
||
return h("a", { class: "card link", href: "#/certificates" }, h("div", { class: "label" }, "Certificates"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||
}
|
||
|
||
function card(label, value, sub) {
|
||
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
||
}
|
||
|
||
// storageCard: the databases' size, and the fullest disk Jarl uses.
|
||
function storageCard(sg) {
|
||
const fullest = sg.disks.reduce((a, d) => (!a || used(d) > used(a) ? d : a), null);
|
||
const value = h("span", null, bytes(sg.total));
|
||
const sub = fullest ? [h("span", { class: used(fullest) > 0.9 ? "warn-text" : "" }, Math.round(used(fullest) * 100) + "% full"), " · " + fullest.path] : "databases";
|
||
return h("a", { class: "card link", href: "#storage" , onclick: (e) => { e.preventDefault(); document.getElementById("storage")?.scrollIntoView({ behavior: "smooth" }); } },
|
||
h("div", { class: "label" }, "Databases"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||
}
|
||
|
||
// storagePanel shows each database's size and, per disk Jarl uses, a bar:
|
||
// the databases, everything else, and what is free.
|
||
function storagePanel(sg) {
|
||
const dbRows = sg.databases.map((d) => [h("dt", null, d.label),
|
||
h("dd", null, h("strong", null, bytes(d.bytes)), " ", h("span", { class: "mono small muted" }, d.path),
|
||
d.query_log_rows != null ? h("div", { class: "small muted" }, "query log: " + d.query_log_rows.toLocaleString() + (d.query_log_rows === 1 ? " answer" : " answers") + " so far, each kept for " + retentionWords(d.query_log_retention) + " (stats.retention.queries)") : null)]);
|
||
return h("div", { class: "panel", id: "storage" },
|
||
h("h2", null, "Storage"),
|
||
h("dl", { class: "kv" }, dbRows, h("dt", null, "Together"), h("dd", null, h("strong", null, bytes(sg.total)))),
|
||
sg.disks.map((d) => {
|
||
const other = Math.max(0, d.total - d.free - d.databases);
|
||
const pctOf = (n) => (d.total ? (n / d.total) * 100 : 0) + "%";
|
||
const u = used(d);
|
||
return h("div", { class: "disk" },
|
||
h("div", { class: "disk-head" }, h("span", { class: "mono" }, d.path), h("span", { class: "muted small" }, " · " + d.holds.join(", ")),
|
||
h("span", { class: "grow" }), h("span", { class: u > 0.9 ? "warn-text" : "muted" }, bytes(d.free) + " free of " + bytes(d.total) + " (" + Math.round(u * 100) + "% used)")),
|
||
h("div", { class: "disk-bar", role: "img", "aria-label": Math.round(u * 100) + "% used, Jarl's databases " + bytes(d.databases) },
|
||
d.databases ? h("div", { class: "seg db", title: "Jarl's databases: " + bytes(d.databases), style: { width: pctOf(d.databases) } }) : null,
|
||
h("div", { class: "seg other" + (u > 0.9 ? " full" : ""), title: "Everything else: " + bytes(other), style: { width: pctOf(other) } })),
|
||
d.databases ? h("div", { class: "small muted" }, h("span", { class: "swatch db" }), " Jarl's databases " + bytes(d.databases), " ", h("span", { class: "swatch other" }), " everything else") : null);
|
||
}));
|
||
}
|
||
|
||
function used(d) { return d.total ? (d.total - d.free) / d.total : 0; }
|
||
|
||
function bytes(n) {
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let i = 0;
|
||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||
return (n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)) + " " + units[i];
|
||
}
|
||
|
||
// retentionWords: "7d" -> "7 days".
|
||
function retentionWords(r) {
|
||
const m = /^(\d+)([dhm])$/.exec(r || "");
|
||
if (!m) return r || "a while";
|
||
const word = { d: "day", h: "hour", m: "minute" }[m[2]];
|
||
return m[1] + " " + word + (m[1] === "1" ? "" : "s");
|
||
}
|