This commit is contained in:
Mateusz Gruszczyński committed 2026-11-08 23:42:50 +01:00
1 parent 0119b11005
commit 0cbe0566f5
6 files changed
+707 -607

No files matched your search

+34 -7
View File
@@ -2351,7 +2351,8 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
}
/* Backends overview card (0.36.7): follows table density (global table rules), corner style tokens and interface colours */
#panel-overview .backend-overview-card.is-no-proxy { display: none !important; }
#panel-overview .backend-overview-card.is-no-proxy,
#panel-overview #backend-diagnostics-card.is-no-proxy { display: none !important; }
.bo-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; min-width: 0; }
.bo-summary { color: var(--muted); font: 11px var(--mono); }
.bo-filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; }
@@ -2362,7 +2363,7 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
.bo-chip[aria-pressed=true] b { color: var(--accent); }
.bo-chip.is-down b:not(.is-zero) { color: var(--red); }
.backend-overview-card .table-scroll { max-height: 520px; overflow: auto; border-top: 1px solid var(--line); }
.bo-table { width: 100%; min-width: 960px; border-collapse: collapse; font-size: 12px; }
.bo-table { width: 100%; min-width: 1300px; border-collapse: collapse; font-size: 12px; }
.bo-table th { position: sticky; top: 0; z-index: 1; padding: 8px 10px; background: var(--panel); color: var(--muted); font: 600 10px/1.2 var(--sans); letter-spacing: .05em; text-align: left; text-transform: uppercase; white-space: nowrap; }
.bo-table td { padding: 9px 10px; border-top: 1px solid var(--row-line); vertical-align: middle; white-space: nowrap; }
.bo-table .is-num { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
@@ -2389,15 +2390,18 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
.bo-badge.is-down>i { background: var(--red); }
.bo-badge.is-warn { border-color: var(--warn-line); color: var(--accent); }
.bo-badge.is-warn>i { background: var(--accent); }
.bo-errors { min-width: 130px; }
.bo-errors { min-width: 150px; }
.bo-meter-top { display: flex; justify-content: space-between; gap: 8px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.bo-meter-top small { color: var(--text-subtle); font-size: .9em; }
.bo-meter { height: 2px; margin-top: 5px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
.bo-meter>i { display: block; height: 100%; background: var(--accent); border-radius: var(--rp-progress-radius, 0); }
.bo-meter>i.is-high { background: var(--red); }
.bo-latency { min-width: 130px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.bo-latency { min-width: 200px; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.bo-latency>span { display: inline-block; min-width: 62px; }
.bo-spark { width: 56px; height: 18px; margin-left: 6px; vertical-align: middle; }
.bo-spark { height: 22px; margin-left: 6px; vertical-align: middle; flex: 0 0 auto; }
.bo-spark-latency { width: 120px; }
.bo-spark-traffic { width: 96px; margin-left: 0; }
.bo-spark-bars { width: 84px; margin-left: 0; }
.bo-spark polyline { fill: none; stroke: var(--green); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
.bo-row.is-down .bo-spark polyline { stroke: var(--red); }
.bo-row.is-warn .bo-spark polyline { stroke: var(--accent); }
@@ -2412,12 +2416,18 @@ html[data-table-density=normal] .bo-table td { padding-top: 10px; padding-bottom
html[data-table-density=ultra] .bo-table th { padding: 4px 8px; }
html[data-table-density=ultra] .bo-table td { padding-left: 8px; padding-right: 8px; }
html[data-table-density=ultra] .bo-sub { display: none; }
html[data-table-density=ultra] .bo-spark { width: 40px; height: 12px; }
html[data-table-density=ultra] .bo-spark { height: 14px; }
html[data-table-density=ultra] .bo-spark-latency { width: 80px; }
html[data-table-density=ultra] .bo-spark-traffic { width: 64px; }
html[data-table-density=ultra] .bo-spark-bars { width: 56px; }
html[data-table-density=ultra] .bo-meter { margin-top: 2px; }
html[data-table-density=ultra] .bo-event small { display: inline; margin-left: 6px; }
html[data-table-density=ultra] .bo-badge { padding: 0 6px; }
html[data-table-density=ultra] .bo-chip { padding: 4px 8px; font-size: 11px; }
html[data-table-density=compact] .bo-spark { width: 48px; height: 15px; }
html[data-table-density=compact] .bo-spark { height: 18px; }
html[data-table-density=compact] .bo-spark-latency { width: 100px; }
html[data-table-density=compact] .bo-spark-traffic { width: 80px; }
html[data-table-density=compact] .bo-spark-bars { width: 70px; }
html[data-table-density=compact] .bo-chip { padding: 5px 9px; }
html[data-table-density=compact] .bo-meter { margin-top: 3px; }
@@ -2454,3 +2464,20 @@ html[data-table-density=ultra] .pr-chip { padding: 1px 7px; }
/* initial width of the cache outcome segments (set from JS via CSSOM; inline style attributes are blocked by the CSP) */
.cache-mix-hit, .cache-mix-miss, .cache-mix-bypass, .cache-mix-other { width: 0; }
/* Backends overview: trend charts in the requests, latency and traffic cells */
.bo-chartcell { display: flex; align-items: center; justify-content: flex-end; gap: 10px; font: 12px var(--mono); font-variant-numeric: tabular-nums; }
.bo-requests .bo-chartcell>span { min-width: 44px; text-align: right; }
.bo-spark-bars rect { fill: var(--accent); opacity: .75; }
.bo-spark .bo-base { stroke: var(--line); stroke-width: 1.5; stroke-linecap: round; }
.bo-spark-latency .bo-base { stroke-dasharray: 2 4; }
.bo-row .bo-spark-traffic polyline.bo-line-in { stroke: var(--blue); }
.bo-row .bo-spark-traffic polyline.bo-line-out { stroke: var(--green); }
.bo-traffic { min-width: 230px; }
.bo-traffic-text { min-width: 118px; font: 11px/1.55 var(--mono); font-variant-numeric: tabular-nums; }
.bo-traffic-text>div { display: flex; justify-content: space-between; gap: 8px; }
.bo-traffic-text span { color: var(--text-subtle); }
.bo-traffic-text .bo-in b { color: var(--blue); font-weight: 500; }
.bo-traffic-text .bo-out b { color: var(--green); font-weight: 500; }
.bo-traffic-text b.is-zero { color: var(--text-subtle); }
html[data-table-density=ultra] .bo-traffic-text { line-height: 1.25; }
+89 -21
View File
@@ -260,8 +260,9 @@ function initPoolTableTools() {
// direction and column preferences with the compact "Tiny backends" table and follows table density and corner style.
const BO_SORT_LABELS = { vhost: "vhVhost", backend: "backend", status: "status", active: "vhActive", requests: "vhRequests", errors: "vhErrors", latency: "vhLatency", traffic: "vhTraffic", generation: "vhClientGeneration", generationRequests: "vhGenerationRequests", generationAge: "vhGenerationAge", rotations: "vhClientRotations", lastFailure: "vhLastFailure", lastRecovery: "vhLastRecovery" };
const BO_DETAIL_COLUMNS = ["generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
const BO_LATENCY_SAMPLES = 24;
const boOpen = new Set(), boLatency = new Map();
const BO_SAMPLES = 30;
const boOpen = new Set(), boSeries = new Map();
let boSnapshot = null;
let boItems = [], boFilter = "all", boHasProxy = true;
function buildBackendOverviewTools() {
const sort = $("bo-sort"), options = $("bo-column-options");
@@ -271,27 +272,73 @@ function buildBackendOverviewTools() {
input.type = "checkbox"; input.dataset.poolColumnToggle = key; text.dataset.i18n = BO_SORT_LABELS[key]; label.append(input, text); options.append(label);
}
}
function boSeriesOf(item) { return item.series || { latency: [], inRate: [], outRate: [], requests: [] }; }
function boAgo(ms) {
const seconds = Math.max(0, Math.floor((Date.now() - Number(ms)) / 1000));
const value = seconds < 60 ? `${seconds} s` : seconds < 3600 ? `${Math.floor(seconds / 60)} min` : seconds < 86400 ? `${Math.floor(seconds / 3600)} h` : `${Math.floor(seconds / 86400)} d`;
return t("backendAgo").replace("{value}", value);
}
const BO_SVG = "http://www.w3.org/2000/svg";
function boSvg(width, kind) {
const svg = document.createElementNS(BO_SVG, "svg");
svg.setAttribute("viewBox", `0 0 ${width} 20`); svg.setAttribute("class", `bo-spark bo-spark-${kind}`); svg.setAttribute("aria-hidden", "true");
boBaseline(svg, width, kind === "latency" ? 10 : 19); // idle or not-yet-sampled rows still show where the chart is
return svg;
}
function boBaseline(svg, width, y) {
const line = document.createElementNS(BO_SVG, "line");
line.setAttribute("class", "bo-base"); line.setAttribute("x1", "0"); line.setAttribute("x2", String(width)); line.setAttribute("y1", String(y)); line.setAttribute("y2", String(y));
svg.append(line);
}
function boPoints(values, width, min, max) {
const flat = max - min < max * 0.05, step = width / Math.max(1, values.length - 1);
return values.map((value, index) => `${(index * step).toFixed(1)},${(flat ? 10 : 17 - ((value - min) / (max - min)) * 14).toFixed(1)}`).join(" ");
}
function boSparkline(history, current) {
const ns = "http://www.w3.org/2000/svg", svg = document.createElementNS(ns, "svg"), samples = history.filter(value => value > 0);
svg.setAttribute("viewBox", "0 0 64 20"); svg.setAttribute("class", "bo-spark"); svg.setAttribute("aria-hidden", "true");
const svg = boSvg(120, "latency"), samples = history.filter(value => value > 0);
if (!(current > 0) || samples.length < 3) return svg;
const min = Math.min(...samples), max = Math.max(...samples), flat = max - min < max * 0.05, step = 64 / (samples.length - 1), line = document.createElementNS(ns, "polyline");
line.setAttribute("points", samples.map((value, index) => `${(index * step).toFixed(1)},${(flat ? 10 : 17 - ((value - min) / (max - min)) * 14).toFixed(1)}`).join(" "));
const line = document.createElementNS(BO_SVG, "polyline");
line.setAttribute("points", boPoints(samples, 120, Math.min(...samples), Math.max(...samples)));
svg.append(line); return svg;
}
function renderBackendOverview(items, hasProxy) {
function boBars(values) {
const svg = boSvg(84, "bars"), max = Math.max(0, ...values);
if (values.length < 3 || max <= 0) return svg;
const width = 84 / values.length;
values.forEach((value, index) => {
const bar = document.createElementNS(BO_SVG, "rect"), height = value > 0 ? Math.max(1.5, value / max * 18) : 0;
bar.setAttribute("x", (index * width + width * 0.15).toFixed(1)); bar.setAttribute("width", (width * 0.7).toFixed(1));
bar.setAttribute("y", (20 - height).toFixed(1)); bar.setAttribute("height", height.toFixed(1)); svg.append(bar);
});
return svg;
}
function boTrafficChart(inSamples, outSamples) {
const svg = boSvg(96, "traffic"), max = Math.max(0, ...inSamples, ...outSamples);
if (inSamples.length < 3 || max <= 0) return svg;
for (const [values, kind] of [[inSamples, "in"], [outSamples, "out"]]) {
const line = document.createElementNS(BO_SVG, "polyline"), step = 96 / (values.length - 1);
line.setAttribute("class", `bo-line-${kind}`);
line.setAttribute("points", values.map((value, index) => `${(index * step).toFixed(1)},${(18 - (value / max) * 16).toFixed(1)}`).join(" "));
svg.append(line);
}
return svg;
}
function renderBackendOverview(items, hasProxy, snapshot = null) {
boItems = items; boHasProxy = hasProxy !== false;
const seen = new Set();
const seen = new Set(), sample = snapshot === null || snapshot !== boSnapshot;
boSnapshot = snapshot;
const push = (list, value) => { list.push(value); if (list.length > BO_SAMPLES) list.shift(); };
for (const item of items) {
seen.add(item.key);
const history = boLatency.get(item.key) || []; history.push(item.latency); if (history.length > BO_LATENCY_SAMPLES) history.shift(); boLatency.set(item.key, history);
const series = boSeries.get(item.key) || { latency: [], inRate: [], outRate: [], requests: [], lastRequests: null };
if (sample) {
push(series.latency, item.latency); push(series.inRate, item.trafficIn || 0); push(series.outRate, item.trafficOut || 0);
push(series.requests, series.lastRequests === null ? 0 : Math.max(0, item.requests - series.lastRequests)); series.lastRequests = item.requests;
}
boSeries.set(item.key, series);
item.series = series;
}
for (const key of boLatency.keys()) if (!seen.has(key)) boLatency.delete(key);
for (const key of boSeries.keys()) if (!seen.has(key)) boSeries.delete(key);
for (const key of boOpen) if (!seen.has(key)) boOpen.delete(key);
$("backend-overview-card")?.classList.toggle("is-no-proxy", !boHasProxy);
applyBackendOverviewView();
@@ -344,14 +391,22 @@ function applyBackendOverviewView() {
} else if (id === "status") {
const badge = el("span", item.label, `bo-badge is-${item.tone}`); badge.title = item.title; badge.prepend(el("i")); cell.append(badge);
} else if (id === "active") { cell.className = "is-num"; cell.textContent = item.activeText; }
else if (id === "requests") { cell.className = "is-num"; cell.textContent = String(item.requests); }
else if (id === "requests") {
cell.className = "bo-requests"; const wrap = el("div", undefined, "bo-chartcell");
wrap.append(boBars(boSeriesOf(item).requests), el("span", String(item.requests))); wrap.title = t("backendRequestsTrend"); cell.append(wrap);
}
else if (id === "errors") {
const pct = item.requests ? item.errors / item.requests * 100 : 0, top = el("div", undefined, "bo-meter-top"), track = el("div", undefined, "bo-meter"), fill = el("i");
top.append(el("span", String(item.errors), item.errors ? "" : "is-zero"), el("small", item.errors ? (pct < 0.1 ? "<0.1%" : `${pct.toFixed(pct < 10 ? 1 : 0)}%`) : "0%"));
fill.style.width = `${item.errors ? Math.min(100, Math.max(4, pct)) : 0}%`; if (pct >= 5) fill.className = "is-high"; track.append(fill); cell.className = "bo-errors"; cell.append(top, track);
} else if (id === "latency") {
cell.className = "bo-latency"; cell.append(el("span", item.latency > 0 ? `${item.latency.toFixed(1)} ms` : "-", item.latency > 0 ? "" : "is-zero"), boSparkline(boLatency.get(item.key) || [], item.latency));
} else if (id === "traffic") { cell.className = "is-num"; cell.textContent = `${rateBytes(item.traffic)}/s`; }
cell.className = "bo-latency"; cell.append(el("span", item.latency > 0 ? `${item.latency.toFixed(1)} ms` : "-", item.latency > 0 ? "" : "is-zero"), boSparkline(boSeriesOf(item).latency, item.latency));
} else if (id === "traffic") {
cell.className = "bo-traffic"; const wrap = el("div", undefined, "bo-chartcell"), text = el("div", undefined, "bo-traffic-text"); wrap.title = `${rateBytes(item.traffic)}/s`;
const line = (kind, label, value) => { const row = el("div", undefined, `bo-${kind}`); row.append(el("span", label), el("b", `${rateBytes(value)}/s`, value > 0 ? "" : "is-zero")); return row; };
text.append(line("in", "IN", item.trafficIn || 0), line("out", "OUT", item.trafficOut || 0));
wrap.append(boTrafficChart(boSeriesOf(item).inRate, boSeriesOf(item).outRate), text); cell.append(wrap);
}
else if (id === "event") {
cell.className = "bo-event";
if (item.lastFailureMs && item.lastFailureMs >= item.lastRecoveryMs) { const line = el("span", item.lastFailureClass || t("backendEventFailed"), "is-failed"); line.title = item.lastFailureDetail || ""; cell.append(line, el("small", boAgo(item.lastFailureMs))); }
@@ -368,8 +423,11 @@ function applyBackendOverviewView() {
cell.append(grid); detail.append(cell); rows.push(detail);
}
}
const scroller = body.closest(".table-scroll"), scrollTop = scroller?.scrollTop ?? 0, scrollLeft = scroller?.scrollLeft ?? 0;
body.replaceChildren(...rows);
if (hadFocus) [...body.querySelectorAll(".bo-expand")].find(button => button.dataset.boKey === focusKey)?.focus();
if (scroller) { scroller.scrollTop = scrollTop; scroller.scrollLeft = scrollLeft; }
// restore keyboard focus without scrolling: the table is rebuilt on every refresh and a plain focus() jumped back to the focused row
if (hadFocus) [...body.querySelectorAll(".bo-expand")].find(button => button.dataset.boKey === focusKey)?.focus({ preventScroll: true });
const empty = $("bo-empty");
if (empty) { empty.textContent = boItems.length ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = list.length > 0 || !boHasProxy; }
const toggleAll = document.querySelectorAll("#bo-column-options [data-pool-column-toggle]"); toggleAll.forEach(input => { input.checked = visibleColumns.has(input.dataset.poolColumnToggle); });
@@ -414,7 +472,7 @@ function render() {
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
const diagnosticsCard = $("backend-diagnostics-card");
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
diagnosticsCard?.classList.toggle("is-no-proxy", !configuredHasProxySource);
$("backend-overview-card")?.classList.toggle("is-no-proxy", !configuredHasProxySource);
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
const visibleVhosts = (config.vhosts || []).filter(v => {
@@ -564,16 +622,26 @@ function render() {
}
function backendTrafficRate(poolId, member, snapshot, now, seen) {
const key = `${poolId}\u0000${member.id}`; seen.add(key);
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
const bytesIn = Math.max(0, Number(member.bytes_in) || 0), bytesOut = Math.max(0, Number(member.bytes_out) || 0), total = bytesIn + bytesOut;
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
const previous = backendTrafficHistory.get(key);
if (previous?.snapshot === snapshot) return previous.rate;
const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds"));
const observed = continuous && total >= previous.total ? (total - previous.total) / ((now - previous.at) / 1000) : 0;
const sampled = continuous && total >= previous.total, seconds = sampled ? (now - previous.at) / 1000 : 0;
const observed = sampled ? (total - previous.total) / seconds : 0;
let inRate = sampled && bytesIn >= previous.bytesIn ? (bytesIn - previous.bytesIn) / seconds : 0;
let outRate = sampled && bytesOut >= previous.bytesOut ? (bytesOut - previous.bytesOut) / seconds : 0;
const rate = reported > 0 ? reported : observed;
backendTrafficHistory.set(key, { ...rustProxyRateSampling.stamp(now), total, snapshot, rate });
// keep the in/out split consistent with the total shown elsewhere (the server-reported rate wins when present)
if (rate > 0 && inRate + outRate === 0) { inRate = rate * (total > 0 ? bytesIn / total : 0.5); outRate = rate - inRate; }
else if (reported > 0 && inRate + outRate > 0) { const scale = reported / (inRate + outRate); inRate *= scale; outRate *= scale; }
backendTrafficHistory.set(key, { ...rustProxyRateSampling.stamp(now), total, bytesIn, bytesOut, snapshot, rate, inRate, outRate });
return rate;
}
function backendTrafficSplit(poolId, member) {
const history = backendTrafficHistory.get(`${poolId}\u0000${member.id}`);
return { inRate: history?.inRate || 0, outRate: history?.outRate || 0 };
}
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
seen.add(id);
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
@@ -613,7 +681,7 @@ function renderStatus(status) {
const tone = vhostDisabled ? "muted" : member.health_mode === "off" && member.status === "up" ? "muted" : member.status === "up" ? "up" : member.status === "down" ? "down" : member.status === "checking" || member.status === "draining" ? "warn" : "muted";
overviewItems.push({
key: `${pool.id}\u0000${member.id}`, vhost: poolLabel, backend: `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, disabled: vhostDisabled, tone, group: tone === "up" ? "up" : tone === "down" ? "down" : "other",
label, title: vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`, activeText: activeLimit, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic,
label, title: vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`, activeText: activeLimit, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, ...(({ inRate, outRate }) => ({ trafficIn: inRate, trafficOut: outRate }))(backendTrafficSplit(pool.id, member)),
generation: member.upstream_client_generation ?? "-", generationRequests: member.upstream_client_generation_requests ?? "-", generationAgeText, rotations: member.upstream_client_rotations_total ?? "-",
lastFailureMs: Number(member.last_failure_at_ms) || 0, lastFailureClass: member.last_failure_class || "", lastFailureDetail: member.last_failure_detail || "", lastFailureText: lastFailure, lastRecoveryMs: Number(member.last_recovered_ms) || 0, lastRecoveryText: lastRecovery,
sort: sortValues, search: row.poolSearchValue,
@@ -661,9 +729,9 @@ function renderStatus(status) {
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
);
$("pool-rows").replaceChildren(...rows);
renderBackendOverview(overviewItems, hasConfiguredProxySource);
renderBackendOverview(overviewItems, hasConfiguredProxySource, status);
const backendDiagnosticsCard = $("backend-diagnostics-card");
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
backendDiagnosticsCard?.classList.toggle("is-no-proxy", !hasConfiguredProxySource);
applyPoolTableView();
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });