Server statistics, multi-view record commands, and rc.3 fixes

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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 18:03:05 +01:00
1 parent b097e10bef
commit 7be0762f48
48 files changed
+1346 -128

No files matched your search

+30 -16
View File
@@ -125,10 +125,19 @@ export function legend(series) {
h("li", null, h("span", { class: "key line s" + s.slot }), s.label)));
}
// endLabelWidth is the room lineChart needs for the end labels of series.
export function endLabelWidth(series) {
return series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8;
}
// lineChart draws series over time. series: [{label, values, slot}] (slot
// = categorical color 1..3); times: bucket starts (Unix seconds); step in
// seconds. With area, the first series gets a 10% wash under its line.
export function lineChart({ series, times, step, area = false, height = 220, endLabels = true }) {
// gutter {left, right} sets the least room for the axis labels and the end
// labels: charts shown one above the other pass the same gutter, so their
// time axes line up. format formats values (axis and readout); a null
// value is a gap in the line (no data for that time).
export function lineChart({ series, times, step, area = false, height = 220, endLabels = true, gutter = {}, format = compact }) {
const root = h("div", { class: "chart", tabindex: "0", "aria-label": series.map((s) => s.label).join(", ") + " over time" });
const tip = tooltip();
root.append(tip);
@@ -139,12 +148,12 @@ export function lineChart({ series, times, step, area = false, height = 220, end
if (!w || w === width) return;
width = w;
root.querySelector("svg")?.remove();
const max = Math.max(1, ...series.flatMap((s) => s.values));
const max = Math.max(1, ...series.flatMap((s) => s.values).filter((v) => v != null));
const yTicks = niceTicks(max, 4);
const yMax = yTicks[yTicks.length - 1];
const labelW = Math.max(...yTicks.map((t) => compact(t).length)) * 7 + 10;
const endW = endLabels && series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8;
const m = { top: 10, right: endW, bottom: 24, left: labelW };
const labelW = Math.max(...yTicks.map((t) => format(t).length)) * 7 + 10;
const endW = endLabels ? endLabelWidth(series) : 8;
const m = { top: 10, right: Math.max(endW, gutter.right || 0), bottom: 24, left: Math.max(labelW, gutter.left || 0) };
const pw = Math.max(40, w - m.left - m.right), ph = height - m.top - m.bottom;
const t0 = times[0], t1 = times[times.length - 1] + step;
const x = (t) => m.left + ((t - t0) / Math.max(1, t1 - t0)) * pw;
@@ -154,7 +163,7 @@ export function lineChart({ series, times, step, area = false, height = 220, end
// Grid and axes.
for (const t of yTicks) {
s.append(svg("line", { class: t === 0 ? "axis" : "grid", x1: m.left, x2: m.left + pw, y1: y(t), y2: y(t) }));
s.append(svg("text", { class: "tick", x: m.left - 6, y: y(t) + 4, "text-anchor": "end" }, compact(t)));
s.append(svg("text", { class: "tick", x: m.left - 6, y: y(t) + 4, "text-anchor": "end" }, format(t)));
}
const { ticks, step: tstep } = timeTicks(t0, t1, Math.max(2, Math.floor(pw / 90)));
for (const t of ticks) {
@@ -162,17 +171,21 @@ export function lineChart({ series, times, step, area = false, height = 220, end
}
// Series.
series.forEach((sr, k) => {
const pts = sr.values.map((v, i) => [cx(i), y(v)]);
const d = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join("");
if (area && k === 0 && pts.length) {
// A gap (null) starts a new piece of line.
const pts = sr.values.map((v, i) => (v == null ? null : [cx(i), y(v)]));
const d = pts.map((p, i) => (p ? (i && pts[i - 1] ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1) : "")).join("");
if (area && k === 0 && pts.length && pts.every(Boolean)) {
s.append(svg("path", { class: "area s" + sr.slot, d: d + `L${pts[pts.length - 1][0].toFixed(1)} ${y(0)}L${pts[0][0].toFixed(1)} ${y(0)}Z` }));
}
s.append(svg("path", { class: "line s" + sr.slot, d }));
if (pts.length === 1) s.append(svg("circle", { class: "dot s" + sr.slot, cx: pts[0][0], cy: pts[0][1], r: 4 }));
// A point without neighbours has no line: show it as a dot.
pts.forEach((p, i) => {
if (p && !pts[i - 1] && !pts[i + 1]) s.append(svg("circle", { class: "dot s" + sr.slot, cx: p[0], cy: p[1], r: 3 }));
});
});
// Direct end labels when there is room and they don't collide.
if (m.right > 8) {
const ends = series.map((sr) => ({ label: sr.label, y: y(sr.values[sr.values.length - 1] || 0) })).sort((a, b) => a.y - b.y);
const ends = series.map((sr) => ({ label: sr.label, y: y(sr.values.findLast((v) => v != null) || 0) })).sort((a, b) => a.y - b.y);
if (ends.every((e, i) => i === 0 || e.y - ends[i - 1].y >= 13)) {
for (const e of ends) s.append(svg("text", { class: "end-label", x: m.left + pw + 6, y: e.y + 4 }, e.label));
}
@@ -205,13 +218,13 @@ export function lineChart({ series, times, step, area = false, height = 220, end
geom.cross.setAttribute("visibility", "visible");
series.forEach((sr, k) => {
geom.dots[k].setAttribute("cx", xpos);
geom.dots[k].setAttribute("cy", geom.y(sr.values[i]));
geom.dots[k].setAttribute("visibility", "visible");
geom.dots[k].setAttribute("cy", geom.y(sr.values[i] ?? 0));
geom.dots[k].setAttribute("visibility", sr.values[i] == null ? "hidden" : "visible");
});
tip.replaceChildren(
h("div", { class: "tip-head" }, bucketLabel(times[i], step)),
...series.map((sr) => h("div", { class: "tip-row" },
h("span", { class: "key line s" + sr.slot }), h("strong", null, sr.values[i].toLocaleString()), " ", h("span", { class: "muted" }, sr.label))));
h("span", { class: "key line s" + sr.slot }), h("strong", null, sr.values[i] == null ? "–" : format === compact ? sr.values[i].toLocaleString() : format(sr.values[i])), " ", h("span", { class: "muted" }, sr.label))));
tip.hidden = false;
// Keep the readout inside the chart, on the far side of the pointer.
const left = xpos > root.clientWidth / 2 ? xpos - tip.offsetWidth - 12 : xpos + 12;
@@ -242,12 +255,13 @@ export function lineChart({ series, times, step, area = false, height = 220, end
// timeTable is the table view of a line chart (busiest buckets first is
// not useful here: keep time order, newest first).
export function timeTable(series, times, step) {
export function timeTable(series, times, step, format) {
const cell = (v) => (v == null ? "–" : format ? format(v) : v.toLocaleString());
const rows = times.map((t, i) => ({ t, i })).reverse();
return h("div", { class: "table-wrap scroll" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Time"), series.map((s) => h("th", { class: "num" }, s.label)))),
h("tbody", null, rows.map((r) => h("tr", null, h("td", null, bucketLabel(r.t, step)),
series.map((s) => h("td", { class: "num" }, s.values[r.i].toLocaleString())))))));
series.map((s) => h("td", { class: "num" }, cell(s.values[r.i]))))))));
}
// heatmap draws average activity by weekday (rows, Monday first) and hour
+10 -8
View File
@@ -39,15 +39,15 @@ export async function render(el, params, query) {
} catch (e) { mount(checkOut, errorBox(e)); }
};
const paused = status.paused_until;
const paused = status.paused_until || status.paused_indefinitely;
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Blocking"),
h("div", { class: "muted" }, "Answer \"doesn't exist\" for ads, trackers and malware, for the devices that use this server to resolve names. Your rules come first, then the blocklists.")),
writable && !paused && (lists.length || rules.length) ? pauseControls() : null),
paused ? h("div", { class: "msg warn" },
h("strong", null, "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ": "),
"every name is answered normally. It resumes by itself. ",
h("strong", null, status.paused_indefinitely ? "Blocking is paused: " : "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) + ": "),
status.paused_indefinitely ? "every name is answered normally until you resume it. " : "every name is answered normally. It resumes by itself. ",
writable ? h("button", { class: "small", onclick: () => pause(0) }, "Resume now") : null) : null,
h("div", { class: "panel" },
h("form", { class: "actions", onsubmit: runCheck },
@@ -61,20 +61,22 @@ export async function render(el, params, query) {
// show it when it happens.
let wait = 0;
if (lists.some((l) => l.state === "waiting")) wait = 4000;
if (paused) wait = Math.max(1000, Math.min(wait || Infinity, new Date(paused) - Date.now() + 3000));
if (status.paused_until) wait = Math.max(1000, Math.min(wait || Infinity, new Date(status.paused_until) - Date.now() + 3000));
if (wait) refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, wait);
}
function pauseControls() {
const length = select([["5", "5 minutes"], ["15", "15 minutes"], ["60", "1 hour"], ["240", "4 hours"]], "15");
const length = select([["5", "5 minutes"], ["15", "15 minutes"], ["60", "1 hour"], ["240", "4 hours"], ["-1", "Until I resume"]], "15");
return h("div", { class: "actions" }, length, h("button", { title: "Answer every name normally for a while, to see whether blocking is what breaks something", onclick: () => pause(Number(length.value)) }, "Pause blocking"));
}
// pause pauses blocking for minutes, or resumes it (0). It applies at
// once, also with a draft open: it's for trying something now.
// pause pauses blocking for minutes (-1: until resumed), or resumes it
// (0). It applies at once, also with a draft open: it's for trying
// something now.
async function pause(minutes) {
try {
await api("POST", minutes ? "/blocking/pause" : "/blocking/resume", minutes ? { body: { minutes } } : {});
const body = minutes < 0 ? { indefinitely: true } : { minutes };
await api("POST", minutes ? "/blocking/pause" : "/blocking/resume", minutes ? { body } : {});
toast(minutes ? "Blocking paused" : "Blocking resumed");
rerender();
} catch (e) { toast(e.message, "err", 8000); }
@@ -13,7 +13,8 @@ export async function render(el) {
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")),
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;
@@ -44,6 +45,10 @@ export async function render(el) {
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 + ". ",
+3 -2
View File
@@ -32,8 +32,9 @@ function valueWidget(stmt, value, ctx) {
// Statements such as allow-transfer and listen-on take an address
// match list after optional parameters: suggest names at the cursor.
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
if (takesList && !value) t.placeholder = "{ homelab; key xfer-key; }";
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim() };
if (takesList && !value) t.placeholder = "homelab; key xfer-key";
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(),
hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null };
}
const i = input({ value: value || "", class: "mono" });
return { el: i, get: () => i.value.trim() };
+71 -7
View File
@@ -1,6 +1,6 @@
import { h, mount, fmtTime, errorBox } from "../ui.js";
import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
import { get } from "../api.js";
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds } from "../charts.js";
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
@@ -65,15 +65,18 @@ export async function render(el, params, query) {
const qSeries = [{ label: "Queries", values: q.values, slot: 1 }];
const bl = timeline.series.find((x) => x.key === "blocked");
if (bl && bl.values.some((v) => v > 0)) qSeries.push({ label: "Blocked", values: bl.values, slot: 2 });
parts.push(chartCard(qSeries.length > 1 ? "Queries and blocked queries" : "Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
lineChart({ series: qSeries, times: timeline.times, step, area: true }), timeTable(qSeries, timeline.times, step),
{ wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null }));
// Failed answers: three fixed series, legend plus end labels.
const fSeries = failures.map((f) => ({ ...f, values: (errors.series.find((x) => x.key === f.key) || { values: errors.times.map(() => 0) }).values }));
const anyFailures = fSeries.some((f) => f.values.some((v) => v > 0));
// The two charts are one above the other: the same room for labels on
// both sides keeps their time axes in line.
const gutter = { left: 52, right: Math.max(endLabelWidth(qSeries), anyFailures ? endLabelWidth(fSeries) : 0) };
parts.push(chartCard(qSeries.length > 1 ? "Queries and blocked queries" : "Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
lineChart({ series: qSeries, times: timeline.times, step, area: true, gutter }), timeTable(qSeries, timeline.times, step),
{ wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null }));
parts.push(chartCard("Failed answers", "NXDOMAIN: the name doesn't exist · SERVFAIL: resolution failed · REFUSED: not allowed",
anyFailures ? lineChart({ series: fSeries, times: errors.times, step: stepSeconds[errors.step] }) : h("div", { class: "empty" }, "No failed answers " + rangeLabel + "."),
anyFailures ? lineChart({ series: fSeries, times: errors.times, step: stepSeconds[errors.step], gutter }) : h("div", { class: "empty" }, "No failed answers " + rangeLabel + "."),
anyFailures ? timeTable(fSeries, errors.times, stepSeconds[errors.step]) : null,
{ wide: true, legend: anyFailures ? legend(fSeries) : null }));
@@ -107,6 +110,8 @@ export async function render(el, params, query) {
b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null,
rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null,
b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: () => "#/blocking" })) : null));
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel));
el.classList.remove("refreshing");
mount(el, parts);
scheduleRefresh(el, params, query, gen);
@@ -118,6 +123,8 @@ async function loadAll(since, heatSince, queryLog) {
timeline: get("/stats/timeline", { since, key: "queries,blocked" }),
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }),
heat: get("/stats/heatmap", { since: heatSince }),
server: get("/stats/server").catch(() => null),
gauges: get("/stats/timeline", { since, key: gaugeKeys.join(",") }),
};
const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"];
groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); });
@@ -162,6 +169,63 @@ function filterRow(since) {
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
}
const gaugeKeys = ["g:named:rss", "g:named:mem", "g:cache:mem", "g:named:cpu"];
// serverSection shows named's process, cache, clients, the host and the
// disks now, and named's memory and CPU over time.
function serverSection(sv, gauges, rangeLabel) {
const srv = (sv && sv.server) || {};
const named = (sv && sv.named) || {};
const host = srv.host || {};
const out = [h("h2", { class: "section" }, "Server")];
const clients = (v, what) => {
// "now/soft/hard" or "now/limit".
const [now, ...limits] = (v || "").split("/");
return v ? [now, "of " + limits[limits.length - 1] + " allowed" + what] : null;
};
const rc = clients(named.recursive_clients, srv.recursion_highwater ? " · most at once: " + srv.recursion_highwater : "");
const tc = clients(named.tcp_clients, srv.tcp_highwater ? " · most at once: " + srv.tcp_highwater : "");
out.push(h("div", { class: "cards" },
tile("named memory", srv.rss ? bytes(srv.rss) : "–", srv.rss ? "resident · named counts " + bytes(srv.mem_in_use) + " · " + srv.threads + " threads" : "named's process isn't visible to jarld"),
tile("named CPU", srv.pid ? srv.cpu_percent.toFixed(1) + "%" : "–", "of one CPU, last minute" + (named.cpus ? " · " + named.cpus + " CPUs" : "")),
tile("Cache", compact(srv.cache_rrsets || 0) + " RRsets", bytes(srv.cache_mem || 0) + " of memory, all views"),
rc ? tile("Recursive clients", rc[0], rc[1]) : null,
tc ? tile("TCP clients", tc[0], tc[1]) : null,
tile("Up since", srv.boot_time ? relTime(srv.boot_time).replace(" ago", "") : "–", srv.config_time ? "named started " + fmtTime(srv.boot_time) + " · reconfigured " + relTime(srv.config_time) : ""),
host.mem_total ? tile("Host memory", pct((host.mem_total - host.mem_available) / host.mem_total), "of " + bytes(host.mem_total) + " in use · load " + (host.load || []).map((x) => x.toFixed(2)).join(" ")) : null,
...((sv && sv.disks) || []).map((d) => tile("Disk space", bytes(d.free) + " free", h("span", { title: d.path }, "of " + bytes(d.total) + ", for " + d.holds.join(", "))))));
if (sv && sv.named_error) out.push(h("div", { class: "msg info" }, "Client counts need rndc: " + sv.named_error));
// Gauges over time: averages, -1 where there was no sample.
const series = (key) => ((gauges.series || []).find((x) => x.key === key) || { values: gauges.times.map(() => -1) }).values.map((v) => (v < 0 ? null : v));
const gStep = stepSeconds[gauges.step];
const memSeries = [
{ label: "Resident", values: series("g:named:rss"), slot: 1 },
{ label: "named's count", values: series("g:named:mem"), slot: 2 },
{ label: "Cache", values: series("g:cache:mem"), slot: 3 },
];
const cpuSeries = [{ label: "CPU", values: series("g:named:cpu").map((v) => (v == null ? null : v / 10)), slot: 1 }];
const any = (ss) => ss.some((x) => x.values.some((v) => v != null));
const gutter = { left: 64, right: endLabelWidth(memSeries) };
out.push(chartCard("named memory", "average per " + stepName(gauges.step) + ", " + rangeLabel + " · resident is what the system sees",
any(memSeries) ? lineChart({ series: memSeries, times: gauges.times, step: gStep, gutter, format: bytes }) : h("div", { class: "empty" }, "No samples yet: they are taken once a minute."),
any(memSeries) ? timeTable(memSeries, gauges.times, gStep, bytes) : null, { wide: true, legend: any(memSeries) ? legend(memSeries) : null }));
out.push(chartCard("named CPU", "percent of one CPU, average per " + stepName(gauges.step),
any(cpuSeries) ? lineChart({ series: cpuSeries, times: gauges.times, step: gStep, gutter, format: (v) => (Math.round(v * 10) / 10) + "%" }) : h("div", { class: "empty" }, "No samples yet."),
any(cpuSeries) ? timeTable(cpuSeries, gauges.times, gStep, (v) => v.toFixed(1) + "%") : null, { wide: true }));
return out;
}
// bytes formats a size in binary units.
function bytes(n) {
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return (i === 0 || n >= 100 ? Math.round(n) : n.toFixed(1).replace(/\.0$/, "")) + " " + units[i];
}
function tile(label, value, sub) {
return h("div", { class: "card stat" }, h("div", { class: "label" }, label), h("div", { class: "value big" }, value), h("div", { class: "sub" }, sub));
}
+15 -2
View File
@@ -130,7 +130,10 @@ main { padding: 16px; max-width: 1400px; margin: 0 auto; }
/* Panels and cards */
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 14px; }
.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-bottom: 14px; }
/* Tiles share a row and grow to fill it, so a wrapped tile never sits
alone beside an empty space. */
.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; }
.cards > * { flex: 1 1 170px; min-width: 0; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow); }
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
.card .value { font-size: 18px; font-weight: 600; margin-top: 2px; }
@@ -317,6 +320,7 @@ td.break { word-break: break-all; }
}
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
h2.section { margin: 24px 0 10px; font-size: 18px; }
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
.segmented a { padding: 4px 12px; color: var(--text); border-right: 1px solid var(--border); text-decoration: none; font-variant-numeric: tabular-nums; }
@@ -329,7 +333,16 @@ td.break { word-break: break-all; }
.chart-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.chart-head .grow { flex: 1; min-width: 200px; }
.chart-head h2 { margin: 0; font-size: 16px; }
.chart-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0 14px; }
/* Two or three columns; the last cards stretch to fill their row, so no
card is left alone beside an empty space. */
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
.chart-grid > :last-child:nth-child(odd) { grid-column: 1 / -1; }
@media (min-width: 1100px) {
.chart-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.chart-grid > :last-child:nth-child(odd) { grid-column: auto; }
.chart-grid > :last-child:nth-child(3n+1) { grid-column: 1 / -1; }
.chart-grid > :last-child:nth-child(3n+2) { grid-column: span 2; }
}
.chart-grid > .panel h2 { margin: 0; font-size: 16px; }
.list-sub { margin: 2px 0 8px; }
+1 -1
View File
@@ -44,7 +44,7 @@ export function fmtTime(iso) {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d)) return iso;
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" });
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
}
export function relTime(iso) {