This commit is contained in:
Mateusz Gruszczyński committed 2026-10-14 10:50:21 +02:00
1 parent e3de084149
commit e1ac899ec0
14 files changed
+480 -77

No files matched your search

+1 -1
View File
@@ -277,7 +277,7 @@ fn secure(mut response: Response) -> Response {
let h = response.headers_mut();
h.insert(
"cache-control",
HeaderValue::from_static("private, no-store, no-cache"),
HeaderValue::from_static("private, no-store, no-cache, no-transform"),
);
h.insert(
"x-content-type-options",
+96 -37
View File
@@ -206,8 +206,22 @@ function svgNode(tag, attrs = {}) {
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
return node;
}
// Diagnostic charts keep their SVG and label nodes across samples. Updating the
// existing Text node avoids remove/insert mutations while the user is scrolling.
function setChartText(node, value) {
const text = String(value);
if (node.firstChild?.nodeType === 3 && node.firstChild === node.lastChild) {
if (node.firstChild.data !== text) node.firstChild.data = text;
} else if (node.textContent !== text) {
node.textContent = text;
}
}
// Preserve stable Overview DOM while live samples arrive: replacing network SVGs
// during an iOS WebKit momentum scroll can invalidate the current scroll anchor.
let latestNetworkInterfaceSnapshot = [];
function renderNetworkInterfaceCharts(interfaces, appendSample) {
const current = Array.isArray(interfaces) ? interfaces.slice().sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))) : [];
latestNetworkInterfaceSnapshot = current;
const names = new Set(current.map(item => item.name));
for (const name of [...networkInterfaceSamples.keys()]) if (!names.has(name)) networkInterfaceSamples.delete(name);
if (appendSample) {
@@ -222,37 +236,79 @@ function renderNetworkInterfaceCharts(interfaces, appendSample) {
}
const target = $("network-interface-charts");
if (!target) return;
target.replaceChildren();
const visible = current.filter(item => item?.name && !hiddenNetworkInterfaces.has(item.name));
$("network-interface-chart-value").textContent = t("networkInterfacesVisibleCount").replace("{visible}", visible.length).replace("{total}", current.length);
if (!current.length) { target.append(el("p", t("networkNoInterfaces"), "empty network-interface-empty")); return; }
for (const item of visible) {
const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] };
const peak = Math.max(1, ...history.rx, ...history.tx);
const row = el("div", undefined, "network-interface-row");
const head = el("div", undefined, "mini-live-chart-head network-interface-row-head");
const rate = el("span", `${t("networkInterfaceRate")}: ${fmtBytes(item.rx_bytes_per_second)} / ${fmtBytes(item.tx_bytes_per_second)}`, "fine");
const hide = action(t("hideMetric"), () => { hiddenNetworkInterfaces.add(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); });
hide.classList.add("network-interface-hide");
const actions = el("span", undefined, "network-interface-head-actions"); actions.append(rate, hide);
head.append(el("b", item.name || "-", "mono"), actions);
const svg = svgNode("svg", { viewBox: "0 0 300 60", role: "img", "aria-label": `${item.name} ${t("networkInterfacesHistory")}` });
svg.append(svgNode("path", { class: "chart-grid", d: "M0 15H300 M0 30H300 M0 45H300" }));
svg.append(svgNode("polyline", { class: "diagnostic-line network-rx-line", points: sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55" }));
svg.append(svgNode("polyline", { class: "diagnostic-line network-tx-line", points: sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55" }));
row.append(head, svg); target.append(row);
}
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
if (hidden.length) {
const hiddenRow = el("div", undefined, "network-interface-hidden");
hiddenRow.append(el("span", t("hiddenInterfaces"), "fine"));
for (const item of hidden) {
const show = action(`${t("showMetric")} ${item.name}`, () => { hiddenNetworkInterfaces.delete(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); });
hiddenRow.append(show);
setChartText($("network-interface-chart-value"), t("networkInterfacesVisibleCount").replace("{visible}", visible.length).replace("{total}", current.length));
const existing = new Map([...target.children].filter(node => node.classList.contains("network-interface-row"))
.map(node => [node.dataset.interfaceName, node]));
const children = [];
if (!current.length) {
const empty = target.querySelector(":scope > .network-interface-empty") || el("p", undefined, "empty network-interface-empty");
empty.textContent = t("networkNoInterfaces");
children.push(empty);
} else {
for (const item of visible) {
const name = item.name;
let row = existing.get(name);
if (!row) {
row = el("div", undefined, "network-interface-row");
row.dataset.interfaceName = name;
const head = el("div", undefined, "mini-live-chart-head network-interface-row-head");
const rate = el("span", undefined, "fine network-interface-rate");
const hide = action(t("hideMetric"), () => {
hiddenNetworkInterfaces.add(name);
saveHiddenNetworkInterfaces();
renderNetworkInterfaceCharts(latestNetworkInterfaceSnapshot, false);
});
hide.classList.add("network-interface-hide");
const actions = el("span", undefined, "network-interface-head-actions");
actions.append(rate, hide);
head.append(el("b", name, "mono"), actions);
const svg = svgNode("svg", { viewBox: "0 0 300 60", role: "img", "aria-label": `${name} ${t("networkInterfacesHistory")}` });
svg.append(svgNode("path", { class: "chart-grid", d: "M0 15H300 M0 30H300 M0 45H300" }));
svg.append(svgNode("polyline", { class: "diagnostic-line network-rx-line", points: "0,55 300,55" }));
svg.append(svgNode("polyline", { class: "diagnostic-line network-tx-line", points: "0,55 300,55" }));
row.append(head, svg);
}
const history = networkInterfaceSamples.get(name) || { rx: [], tx: [] };
const peak = Math.max(1, ...history.rx, ...history.tx);
setChartText(row.querySelector(".network-interface-rate"), `${t("networkInterfaceRate")}: ${fmtBytes(item.rx_bytes_per_second)} / ${fmtBytes(item.tx_bytes_per_second)}`);
setChartText(row.querySelector(".network-interface-hide"), t("hideMetric"));
row.querySelector(".network-rx-line").setAttribute("points", sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55");
row.querySelector(".network-tx-line").setAttribute("points", sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55");
row.querySelector("svg").setAttribute("aria-label", `${name} ${t("networkInterfacesHistory")}`);
children.push(row);
}
target.append(hiddenRow);
} else if (!visible.length) {
target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty"));
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
if (hidden.length) {
const shelf = target.querySelector(":scope > .network-interface-hidden") || el("div", undefined, "network-interface-hidden");
const previous = [...shelf.querySelectorAll("button[data-interface-name]")].map(node => node.dataset.interfaceName);
const changed = previous.length !== hidden.length || previous.some((name, i) => name !== hidden[i].name);
if (changed) {
shelf.replaceChildren(el("span", t("hiddenInterfaces"), "fine"));
for (const item of hidden) {
const show = action(`${t("showMetric")} ${item.name}`, () => {
hiddenNetworkInterfaces.delete(item.name);
saveHiddenNetworkInterfaces();
renderNetworkInterfaceCharts(latestNetworkInterfaceSnapshot, false);
});
show.dataset.interfaceName = item.name;
shelf.append(show);
}
} else {
setChartText(shelf.querySelector(".fine"), t("hiddenInterfaces"));
for (const button of shelf.querySelectorAll("button[data-interface-name]")) setChartText(button, `${t("showMetric")} ${button.dataset.interfaceName}`);
}
children.push(shelf);
} else if (!visible.length) {
const empty = target.querySelector(":scope > .network-interface-empty") || el("p", undefined, "empty network-interface-empty");
empty.textContent = t("networkNoVisibleInterfaces");
children.push(empty);
}
}
// The common live-sample path updates only text and SVG path attributes.
// Reparent rows solely when interfaces or visibility have actually changed.
if (children.length !== target.children.length || children.some((node, index) => node !== target.children[index])) {
target.replaceChildren(...children);
}
}
function renderDiagnostics(status, appendSample = true, systemSample = appendSample) {
@@ -316,10 +372,10 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
$("cache-miss-sparkline").setAttribute("points", sparklinePoints(cacheMissSamples, 300, 60, 5, cacheOutcomePeak));
$("cache-bypass-sparkline").setAttribute("points", sparklinePoints(cacheBypassSamples, 300, 60, 5, cacheOutcomePeak));
const latestCacheDelta = [cacheHitSamples.at(-1), cacheMissSamples.at(-1), cacheBypassSamples.at(-1)];
$("cache-outcome-chart-value").textContent = latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-";
setChartText($("cache-outcome-chart-value"), latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-");
const hotLimit = Number(c.hot_limit_bytes), hotPeak = Number.isFinite(hotLimit) && hotLimit > 0 ? Math.max(hotLimit, ...cacheMemorySamples) : null;
$("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples, 300, 60, 5, hotPeak));
$("cache-chart-value").textContent = occupancyText(c.resident_bytes, c.hot_limit_bytes);
setChartText($("cache-chart-value"), occupancyText(c.resident_bytes, c.hot_limit_bytes));
const transientLimit = Number(c.transient_limit_bytes);
const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0
? Math.max(transientLimit, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples)
@@ -328,11 +384,11 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
$("cache-fill-sparkline").setAttribute("points", sparklinePoints(cacheFillSamples, 300, 60, 5, transientPeak));
$("cache-queue-sparkline").setAttribute("points", sparklinePoints(cacheColdQueueSamples, 300, 60, 5, transientPeak));
$("cache-io-sparkline").setAttribute("points", sparklinePoints(cacheColdIoSamples, 300, 60, 5, transientPeak));
$("cache-transient-chart-value").textContent = occupancyText(c.transient_bytes, c.transient_limit_bytes);
setChartText($("cache-transient-chart-value"), occupancyText(c.transient_bytes, c.transient_limit_bytes));
const coldEnabled = envelope?.config.cache?.cold_enabled === true;
const coldLimit = Number(c.cold_limit_bytes), coldPeak = Number.isFinite(coldLimit) && coldLimit > 0 ? Math.max(coldLimit, ...cacheColdStorageSamples) : null;
$("cache-cold-sparkline").setAttribute("points", sparklinePoints(cacheColdStorageSamples, 300, 60, 5, coldPeak));
$("cache-cold-chart-value").textContent = coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled");
setChartText($("cache-cold-chart-value"), coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled"));
const processPlatform = [d.platform, d.environment].filter(Boolean).join(" / ");
setOptionalMetric("process-platform", !!d.platform && d.platform !== "Unsupported", processPlatform);
setOptionalMetric("process-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`);
@@ -362,9 +418,9 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
setOptionalMetric("cgroup-memory", cgroupMemory != null, cgroupMemory);
$("process-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples));
$("process-cpu-chart-value").textContent = d.process_cpu_percent == null ? "" : `${fmt(d.process_cpu_percent, 1)}%`;
setChartText($("process-cpu-chart-value"), d.process_cpu_percent == null ? "" : `${fmt(d.process_cpu_percent, 1)}%`);
$("process-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
$("process-rss-chart-value").textContent = d.process_rss_bytes == null ? "" : fmtBytes(d.process_rss_bytes);
setChartText($("process-rss-chart-value"), d.process_rss_bytes == null ? "" : fmtBytes(d.process_rss_bytes));
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = d.process_cpu_percent == null;
$("process-rss-sparkline").closest(".mini-live-chart").hidden = d.process_rss_bytes == null;
updateCacheMemorySizing();
@@ -381,11 +437,14 @@ function renderDiagnostics(status, appendSample = true, systemSample = appendSam
const networkPeak = Math.max(1, ...networkRxSamples, ...networkTxSamples);
$("network-rx-sparkline").setAttribute("points", sparklinePoints(networkRxSamples, 300, 60, 5, networkPeak));
$("network-tx-sparkline").setAttribute("points", sparklinePoints(networkTxSamples, 300, 60, 5, networkPeak));
$("network-chart-value").textContent = networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : "";
setChartText($("network-chart-value"), networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : "");
$("network-rx-sparkline").closest(".mini-live-chart").hidden = !networkRateSupported;
// Advance interface charts at the live Overview cadence, just like the
// aggregate network chart. Between slower system-info reads, repeat the
// latest observed rates without initiating another system data fetch.
renderNetworkInterfaceCharts(d.network_interfaces || [], appendSample);
$("latency-sparkline").setAttribute("points", sparklinePoints(responseLatencySamples));
$("latency-chart-value").textContent = m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`;
setChartText($("latency-chart-value"), m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`);
$("latency-sparkline").closest(".mini-live-chart").hidden = m.mean_duration_ms == null;
$("compression-counters").textContent = [m.compression_gzip, m.compression_br, m.compression_zstd, m.compression_deflate].map(n => fmt(n)).join(" / ");
const age = d.sampled_at_ms ? Math.max(0, Math.floor((Date.now() - d.sampled_at_ms) / 1000)) : null;
+2 -9
View File
@@ -10732,15 +10732,8 @@ html:is([data-corner-style="square"], [data-corner-style="subtle"], [data-corner
@media (max-width: 640px) { .acl-profile-rule { grid-template-columns: 1fr; } }
/* Latest HTTP version observed on a real request to each configured origin.
A badge without an observation must not claim that Auto selected H1 or H2. */
.vhost-backend-protocols {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 5px;
min-width: 0;
margin: 8px 0;
}
Protocol indicators share the Vhost policy-pills group but retain their
original colors and observed/unobserved states. */
.vhost-backend-protocol-badge {
display: inline-flex;
gap: 4px;
+12 -14
View File
@@ -211,20 +211,6 @@ function render() {
];
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
box.append(details);
if ((v.backends || []).length && (v.source?.type !== "web_server" || (v.routes || []).some(route =>
!route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"))) {
const badges = el("div", undefined, "vhost-backend-protocols");
for (const backend of v.backends) {
const badge = el("span", undefined, "vhost-backend-protocol-badge");
badge.dataset.upstreamProtocolVhost = v.id;
badge.dataset.upstreamProtocolBackend = backend.id;
badge.textContent = upstreamProtocolBadgeText(backend, null).label;
badge.title = t("vhBackendProtocolUnobserved");
badges.append(badge);
}
box.append(badges);
}
const live = el("div", undefined, "vhost-live-grid");
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
@@ -253,6 +239,18 @@ function render() {
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
}
// Keep the upstream protocol indicators with the other Vhost policy pills.
// Retain their own badge class so observed H1/H2 keeps its green styling.
if (hasProxySource) {
for (const backend of v.backends || []) {
const badge = el("span", undefined, "vhost-backend-protocol-badge");
badge.dataset.upstreamProtocolVhost = v.id;
badge.dataset.upstreamProtocolBackend = backend.id;
badge.textContent = upstreamProtocolBadgeText(backend, null).label;
badge.title = t("vhBackendProtocolUnobserved");
policy.append(badge);
}
}
if (policy.children.length) box.append(policy);
return box;
});
+5 -2
View File
@@ -52,7 +52,9 @@ pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
last_char = Some(' ');
}
pending_space = false;
if matches!(ch, '{' | '}' | ':' | ';' | ',' | '>') && last_char == Some(' ') {
// A space before ':' may be a descendant combinator before a pseudo-class.
// E.g. `.card :is(.child)` must not become `.card:is(.child)`.
if matches!(ch, '{' | '}' | ';' | ',' | '>') && last_char == Some(' ') {
out.pop();
}
out.push(ch);
@@ -71,5 +73,6 @@ fn needs_space(previous: Option<char>, next: Option<char>) -> bool {
return false;
};
!matches!(previous, '{' | '}' | ':' | ';' | ',' | '>')
&& !matches!(next, '{' | '}' | ':' | ';' | ',' | '>')
// A ':' can introduce a pseudo-class after a descendant combinator.
&& !matches!(next, '{' | '}' | ';' | ',' | '>')
}
+25
View File
@@ -405,4 +405,29 @@ mod tests {
let received = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&received[..], encoded.as_slice());
}
#[test]
fn css_minifier_preserves_descendant_pseudoclasses() {
let source = r#"html:is([data-corner-style="square"], [data-corner-style="rounded"]) :is(.stat, .card) { border-radius: var(--rp-radius-surface) !important; }
.menu :hover { color: red; }
.menu :not(.hidden) { display: block; }
.menu:hover { color: blue; }
body { color: red; }"#;
let result = css::minify(source.as_bytes()).expect("minify CSS");
let output = std::str::from_utf8(&result).expect("UTF-8 CSS");
assert!(output.contains("html:is([data-corner-style=\"square\"],[data-corner-style=\"rounded\"]) :is(.stat,.card)"), "{output}");
assert!(output.contains(".menu :hover{"), "{output}");
assert!(output.contains(".menu :not(.hidden){"), "{output}");
assert!(output.contains(".menu:hover{"), "{output}");
assert!(output.contains("body{color:red;}"), "{output}");
}
#[test]
fn css_minifier_preserves_dashboard_corner_selector() {
let input = include_str!("../admin/web/style.css");
let result = css::minify(input.as_bytes()).expect("minify panel stylesheet");
let output = std::str::from_utf8(&result).expect("UTF-8 CSS");
assert!(output.contains("html:is([data-corner-style=\"square\"],[data-corner-style=\"subtle\"],[data-corner-style=\"rounded\"],[data-corner-style=\"extra\"]) :is("), "lost descendant pseudo-class combinator");
assert!(output.contains(".card,.stat,dialog,.login-card,"), "lost card selector list");
assert!(output.contains("border-radius:var(--rp-radius-surface)!important"), "lost radius declaration");
}
}