Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
7.2 KiB
JavaScript
112 lines
7.2 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] = await Promise.all([get("/status"), get("/changesets", { status: "open", mine: 1, limit: 50 }).catch(() => [])]);
|
||
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,
|
||
);
|
||
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.")));
|
||
}
|
||
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 (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);
|
||
}
|
||
|
||
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);
|
||
}
|