This commit is contained in:
Mateusz Gruszczyński committed 2026-11-08 23:23:21 +01:00
1 parent 1ac94213e0
commit 0119b11005
28 files changed
+698 -153

No files matched your search

+148 -10
View File
@@ -202,7 +202,7 @@ async function savePoolTablePrefs() {
if (!authenticated) return;
if (!poolPreferenceSaveQueued) poolTablePrefs = normalizePoolTablePrefs(saved);
}
syncPoolColumns(); syncPoolSortDirection();
syncPoolColumns(); syncPoolSortDirection(); applyBackendOverviewView();
} catch (error) { if (error.status !== 401) toast(error.message, true); }
finally { poolPreferenceSaveRunning = false; }
}
@@ -212,11 +212,15 @@ function syncPoolColumns() {
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => { input.checked = visible.has(input.dataset.poolColumnToggle); });
}
function syncPoolSortDirection() {
const control = $("pool-sort-direction"); if (!control) return;
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
for (const id of ["pool-sort-direction", "bo-sort-direction"]) {
const control = $(id); if (!control) continue;
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
}
}
function applyPoolTableView() {
for (const id of ["pool-sort", "bo-sort"]) { const control = $(id); if (control && control.value !== poolTablePrefs.sort) control.value = poolTablePrefs.sort; }
applyBackendOverviewView();
const body = $("pool-rows"); if (!body) return;
const query = ($("pool-search")?.value || "").trim().toLowerCase();
const key = $("pool-sort")?.value || poolTablePrefs.sort;
@@ -234,18 +238,142 @@ function applyPoolTableView() {
if (empty) { empty.textContent = query && rows.length && !visible ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = visible > 0 || (!rows.length && !document.querySelector("#backend-diagnostics-card:not([hidden])")); }
}
function initPoolTableTools() {
const sort = $("pool-sort"), direction = $("pool-sort-direction"), search = $("pool-search");
if (sort) { sort.value = poolTablePrefs.sort; sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); }); }
direction?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
search?.addEventListener("input", applyPoolTableView);
buildBackendOverviewTools();
for (const id of ["pool-sort", "bo-sort"]) {
const sort = $(id); if (!sort) continue;
sort.value = poolTablePrefs.sort;
sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); });
}
for (const id of ["pool-sort-direction", "bo-sort-direction"]) $(id)?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
$("pool-search")?.addEventListener("input", applyPoolTableView);
$("bo-search")?.addEventListener("input", applyBackendOverviewView);
$("bo-open")?.addEventListener("click", () => setPanel("vhosts"));
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => input.addEventListener("change", () => {
const visible = new Set(poolTablePrefs.columns);
if (input.checked) visible.add(input.dataset.poolColumnToggle); else visible.delete(input.dataset.poolColumnToggle);
if (!visible.size) { input.checked = true; toast(t("backendColumnsRequired"), true); return; }
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns();
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns(); applyBackendOverviewView();
}));
syncPoolColumns(); syncPoolSortDirection();
}
// Backends overview (dashboard card "backend_table"): a richer view of the same backend data. It shares the sort,
// 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();
let boItems = [], boFilter = "all", boHasProxy = true;
function buildBackendOverviewTools() {
const sort = $("bo-sort"), options = $("bo-column-options");
if (sort && !sort.options.length) for (const key of poolColumns) { const option = el("option", t(BO_SORT_LABELS[key])); option.value = key; option.dataset.i18n = BO_SORT_LABELS[key]; sort.append(option); }
if (options && !options.children.length) for (const key of poolColumns) {
const label = el("label", undefined, "check"), input = el("input"), text = el("span", t(BO_SORT_LABELS[key]));
input.type = "checkbox"; input.dataset.poolColumnToggle = key; text.dataset.i18n = BO_SORT_LABELS[key]; label.append(input, text); options.append(label);
}
}
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);
}
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");
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(" "));
svg.append(line); return svg;
}
function renderBackendOverview(items, hasProxy) {
boItems = items; boHasProxy = hasProxy !== false;
const seen = new Set();
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);
}
for (const key of boLatency.keys()) if (!seen.has(key)) boLatency.delete(key);
for (const key of boOpen) if (!seen.has(key)) boOpen.delete(key);
$("backend-overview-card")?.classList.toggle("is-no-proxy", !boHasProxy);
applyBackendOverviewView();
}
function applyBackendOverviewView() {
const body = $("bo-rows"); if (!body) return;
const visibleColumns = new Set(poolTablePrefs.columns);
const query = ($("bo-search")?.value || "").trim().toLowerCase();
const focusKey = document.activeElement?.dataset?.boKey, hadFocus = Boolean(focusKey) && body.contains(document.activeElement);
const counts = { all: boItems.length, up: 0, down: 0, other: 0 };
for (const item of boItems) counts[item.group]++;
const summary = $("bo-summary"); if (summary) summary.textContent = boItems.length ? t("backendSummary").replace("{up}", counts.up).replace("{total}", boItems.length) : "";
const filters = $("bo-filters");
if (filters) {
filters.setAttribute("aria-label", t("backendFilterLabel"));
filters.replaceChildren(...[["all", "backendFilterAll"], ["up", "vhUp"], ["down", "vhDown"], ["other", "backendFilterOther"]].map(([key, label]) => {
const chip = el("button", undefined, `bo-chip is-${key}`), count = el("b", String(counts[key]), counts[key] ? "" : "is-zero");
chip.type = "button"; chip.dataset.uiIconName = "none"; chip.setAttribute("aria-pressed", String(boFilter === key));
chip.append(el("span", t(label)), count); chip.addEventListener("click", () => { boFilter = key; applyBackendOverviewView(); }); return chip;
}));
}
const key = poolTablePrefs.sort;
const list = boItems.filter(item => (boFilter === "all" || item.group === boFilter) && (!query || item.search.includes(query)));
list.sort((left, right) => {
const a = left.sort[key] ?? "", b = right.sort[key] ?? "";
const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
return poolTablePrefs.descending ? -result : result;
});
const showIdentity = visibleColumns.has("vhost") || visibleColumns.has("backend"), showEvent = visibleColumns.has("lastFailure") || visibleColumns.has("lastRecovery");
const detailColumns = BO_DETAIL_COLUMNS.filter(column => visibleColumns.has(column)), expandable = detailColumns.length > 0;
const columns = [
expandable && ["toggle", ""], showIdentity && ["identity", `${visibleColumns.has("vhost") ? t("vhVhost") : ""}${visibleColumns.has("vhost") && visibleColumns.has("backend") ? " / " : ""}${visibleColumns.has("backend") ? t("backend") : ""}`],
visibleColumns.has("status") && ["status", t("status")], visibleColumns.has("active") && ["active", t("vhActive"), true], visibleColumns.has("requests") && ["requests", t("vhRequests"), true],
visibleColumns.has("errors") && ["errors", t("vhErrors")], visibleColumns.has("latency") && ["latency", t("vhLatency")], visibleColumns.has("traffic") && ["traffic", t("vhTraffic"), true], showEvent && ["event", t("backendLastEvent")],
].filter(Boolean);
const head = $("bo-head"); if (head) head.replaceChildren(...columns.map(([id, label, numeric]) => { const th = el("th", label, numeric ? "is-num" : ""); th.dataset.boColumn = id; if (id === "toggle") th.setAttribute("aria-label", t("backendDetailsToggle")); return th; }));
const rows = [];
for (const item of list) {
const row = el("tr", undefined, `bo-row is-${item.tone}${item.disabled ? " is-disabled" : ""}`), open = expandable && boOpen.has(item.key);
for (const [id] of columns) {
const cell = el("td"); cell.dataset.boColumn = id;
if (id === "toggle") {
const button = el("button", undefined, "bo-expand"); button.type = "button"; button.dataset.uiIconName = "none"; button.dataset.boKey = item.key;
button.setAttribute("aria-expanded", String(open)); button.setAttribute("aria-label", t("backendDetailsToggle")); button.title = t("backendDetailsToggle");
button.addEventListener("click", () => { if (boOpen.has(item.key)) boOpen.delete(item.key); else boOpen.add(item.key); applyBackendOverviewView(); });
cell.append(button);
} else if (id === "identity") {
cell.className = "bo-identity"; const name = el("div", visibleColumns.has("vhost") ? item.vhost : item.backend, "bo-name"); name.title = item.vhost; cell.append(name);
if (visibleColumns.has("vhost") && visibleColumns.has("backend")) { const sub = el("div", item.backend, "bo-sub"); sub.title = item.backend; cell.append(sub); }
} 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 === "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`; }
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))); }
else if (item.lastRecoveryMs) cell.append(el("span", t("backendEventRecovered"), "is-recovered"), el("small", boAgo(item.lastRecoveryMs)));
else cell.append(el("span", t("backendEventNone"), "is-zero"));
}
row.append(cell);
}
rows.push(row);
if (open) {
const detail = el("tr", undefined, "bo-detail-row"), cell = el("td"), grid = el("div", undefined, "bo-detail-grid"); cell.colSpan = Math.max(1, columns.length);
const values = { generation: item.generation, generationRequests: item.generationRequests, generationAge: item.generationAgeText, rotations: item.rotations, lastFailure: item.lastFailureText, lastRecovery: item.lastRecoveryText };
for (const column of detailColumns) { const entry = el("div", undefined, "bo-detail-item"), value = el("b", String(values[column] ?? "-")); if (column === "lastFailure" && item.lastFailureMs) { value.className = "is-failed"; value.title = item.lastFailureDetail || ""; } else if (column === "lastRecovery" && item.lastRecoveryMs) value.className = "is-recovered"; entry.append(el("span", t(BO_SORT_LABELS[column])), value); grid.append(entry); }
cell.append(grid); detail.append(cell); rows.push(detail);
}
}
body.replaceChildren(...rows);
if (hadFocus) [...body.querySelectorAll(".bo-expand")].find(button => button.dataset.boKey === focusKey)?.focus();
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); });
}
async function toggleVhostEnabled(vhost) {
if (!envelope || busy) return;
const next = structuredClone(envelope.config), target = (next.vhosts || []).find(item => item.id === vhost.id);
@@ -287,6 +415,7 @@ function render() {
);
const diagnosticsCard = $("backend-diagnostics-card");
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
$("backend-overview-card")?.classList.toggle("is-no-proxy", !configuredHasProxySource);
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
const visibleVhosts = (config.vhosts || []).filter(v => {
if (!query) return true;
@@ -459,7 +588,7 @@ function renderStatus(status) {
renderUpstreamProtocolBadges(status);
refreshVhostBackendPopup(status);
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
const rows = [], summaries = new Map(), aggregate = new Map();
const rows = [], summaries = new Map(), aggregate = new Map(), overviewItems = [];
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
@@ -481,6 +610,14 @@ function renderStatus(status) {
const sortValues = { vhost: poolLabel.toLowerCase(), backend: String(member.id || "").toLowerCase(), status: label.toLowerCase(), active: Number(member.active) || 0, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, generation: Number(member.upstream_client_generation) || 0, generationRequests: Number(member.upstream_client_generation_requests) || 0, generationAge: Number.isFinite(generationAge) ? generationAge : -1, rotations: Number(member.upstream_client_rotations_total) || 0, lastFailure: Number(member.last_failure_at_ms) || 0, lastRecovery: Number(member.last_recovered_ms) || 0 };
row.poolSort = sortValues; row.poolSearchValue = [poolLabel, member.id, label, member.status, member.last_failure_class, member.last_failure_detail].filter(Boolean).join(" ").toLowerCase();
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.dataset.poolColumn = poolColumns[index]; cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
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,
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,
});
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
}
@@ -524,6 +661,7 @@ 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);
const backendDiagnosticsCard = $("backend-diagnostics-card");
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
applyPoolTableView();