v0.36.8
This commit is contained in:
1 parent
1ac94213e0
commit
0119b11005
28 files changed
+698
-153
No files matched your search
+148
-10
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user