Files
jarl/internal/web/static/assets/pages/dashboard.js
T
adamandClaude Opus 5.5 9fc23310bd Add blocking: blocklists and block/allow rules with BIND response policy zones
jarld downloads blocklists (Adblock, hosts or plain domains), checks them
daily and reloads named only when a list changed; your own block and
allow rules take precedence, and the most specific rule wins. Lists and
rules apply per view (default: every view that offers recursion). Web UI
Blocking page with name checks, jarl blocklist and jarl block, the
/blocking API, blocked counts in the statistics. Database schema 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:24:19 +00:00

103 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { h, mount, time, 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") + " · " + plural((s.counts || {}).options || 0, "option")),
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.")));
}
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);
}