v0.36.10
This commit is contained in:
1 parent
e260612ef9
commit
643999f17e
25 files changed
+421
-86
No files matched your search
+18
-2
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.36.10",
|
||||
"version": "0.36.11",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
|
||||
},
|
||||
"servers": [
|
||||
@@ -4268,7 +4268,7 @@
|
||||
"backend_table_columns": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 14,
|
||||
"maxItems": 21,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
@@ -4281,6 +4281,13 @@
|
||||
"errors",
|
||||
"latency",
|
||||
"traffic",
|
||||
"rate",
|
||||
"statuses",
|
||||
"p95",
|
||||
"cache",
|
||||
"probe",
|
||||
"causes",
|
||||
"protocol",
|
||||
"generation",
|
||||
"generationRequests",
|
||||
"generationAge",
|
||||
@@ -4298,6 +4305,8 @@
|
||||
"errors",
|
||||
"latency",
|
||||
"traffic",
|
||||
"statuses",
|
||||
"cache",
|
||||
"generation",
|
||||
"generationRequests",
|
||||
"generationAge",
|
||||
@@ -4318,6 +4327,13 @@
|
||||
"errors",
|
||||
"latency",
|
||||
"traffic",
|
||||
"rate",
|
||||
"statuses",
|
||||
"p95",
|
||||
"cache",
|
||||
"probe",
|
||||
"causes",
|
||||
"protocol",
|
||||
"generation",
|
||||
"generationRequests",
|
||||
"generationAge",
|
||||
|
||||
@@ -84,6 +84,7 @@ fn default_file_log_columns() -> Vec<String> {
|
||||
fn default_backend_table_columns() -> Vec<String> {
|
||||
BACKEND_TABLE_COLUMNS
|
||||
.iter()
|
||||
.filter(|value| !BACKEND_TABLE_OPTIONAL_COLUMNS.contains(value))
|
||||
.map(|value| (*value).to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ const BACKEND_TABLE_COLUMNS: &[&str] = &[
|
||||
"errors",
|
||||
"latency",
|
||||
"traffic",
|
||||
"rate",
|
||||
"statuses",
|
||||
"p95",
|
||||
"cache",
|
||||
"probe",
|
||||
"causes",
|
||||
"protocol",
|
||||
"generation",
|
||||
"generationRequests",
|
||||
"generationAge",
|
||||
@@ -30,6 +37,13 @@ const BACKEND_TABLE_COLUMNS: &[&str] = &[
|
||||
"lastFailure",
|
||||
"lastRecovery",
|
||||
];
|
||||
const BACKEND_TABLE_OPTIONAL_COLUMNS: &[&str] = &[
|
||||
"rate",
|
||||
"p95",
|
||||
"probe",
|
||||
"causes",
|
||||
"protocol",
|
||||
];
|
||||
const VHOST_LIST_SORT_COLUMNS: &[&str] = &[
|
||||
"id",
|
||||
"hostname",
|
||||
|
||||
@@ -174,6 +174,7 @@ fn status_payload(state: &App) -> Value {
|
||||
.iter()
|
||||
.map(|(id, metrics)| (id.clone(), metrics.view()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>());
|
||||
payload["vhost_cache_totals"] = json!(state.cache.scope_totals());
|
||||
let mut backend_pool_domains = std::collections::BTreeMap::new();
|
||||
for agent in &snapshot.envelope.config.docker_agents.agents {
|
||||
for assignment in agent
|
||||
|
||||
@@ -61,6 +61,7 @@ fn live_status_payload(state: &App) -> Value {
|
||||
"backends": snapshot.envelope.config.vhosts.iter().map(|v| v.backends.len()).sum::<usize>(),
|
||||
"backend_pools": snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>(),
|
||||
"vhost_metrics": snapshot.vhost_metrics.iter().map(|(id, metrics)| (id.clone(), metrics.view())).collect::<std::collections::BTreeMap<_, _>>(),
|
||||
"vhost_cache_totals": state.cache.scope_totals(),
|
||||
"streams": {"metrics": stream_metrics},
|
||||
"logging": {"sinks": state.logs.stats(), "error_file": crate::logging::error_log_stats()}
|
||||
})
|
||||
|
||||
+13
-3
@@ -2155,8 +2155,18 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
initial["backend_table_columns"].as_array().unwrap().len(),
|
||||
BACKEND_TABLE_COLUMNS.len()
|
||||
BACKEND_TABLE_COLUMNS.len() - BACKEND_TABLE_OPTIONAL_COLUMNS.len()
|
||||
);
|
||||
assert!(initial["backend_table_columns"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|column| column == "cache"));
|
||||
assert!(!initial["backend_table_columns"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|column| column == "probe"));
|
||||
assert_eq!(initial["backend_table_sort"], "vhost");
|
||||
assert_eq!(initial["backend_table_descending"], false);
|
||||
|
||||
@@ -2193,8 +2203,8 @@ mod tests {
|
||||
json!(default_file_log_columns())
|
||||
);
|
||||
assert_eq!(
|
||||
legacy["backend_table_columns"].as_array().unwrap().len(),
|
||||
BACKEND_TABLE_COLUMNS.len()
|
||||
legacy["backend_table_columns"],
|
||||
json!(default_backend_table_columns())
|
||||
);
|
||||
assert_eq!(legacy["backend_table_sort"], "vhost");
|
||||
assert_eq!(legacy["backend_table_descending"], false);
|
||||
|
||||
@@ -2848,3 +2848,35 @@ html[data-table-density=ultra] .mb-bar { height: 16px; }
|
||||
.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; }
|
||||
.bo-statuses { min-width: 150px; }
|
||||
.bo-statuses small { display: inline-flex; gap: 6px; }
|
||||
.bo-statuses small b { font-weight: 500; }
|
||||
.bo-statuses small .is-warn { color: var(--accent); }
|
||||
.bo-statuses small .is-bad { color: var(--red); }
|
||||
.bo-stack { display: flex; gap: 1px; height: 3px; margin-top: 5px; overflow: hidden; background: var(--line); border-radius: var(--rp-progress-radius, 0); }
|
||||
.bo-stack>i { display: block; flex: 1 1 0; height: 100%; }
|
||||
.bo-stack>i.is-ok { background: var(--green); opacity: .8; }
|
||||
.bo-stack>i.is-info { background: var(--blue); }
|
||||
.bo-stack>i.is-warn { background: var(--accent); }
|
||||
.bo-stack>i.is-bad { background: var(--red); }
|
||||
.bo-pair { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.bo-pair small { display: block; margin-top: 1px; color: var(--text-subtle); font-size: .9em; }
|
||||
.bo-cache { min-width: 200px; }
|
||||
.bo-cache .bo-chartcell { justify-content: flex-start; }
|
||||
.bo-cache-hit { display: inline-flex; align-items: center; gap: 6px; min-width: 70px; }
|
||||
.bo-ring { width: 20px; height: 20px; flex: 0 0 auto; }
|
||||
.bo-ring circle { fill: none; stroke-width: 3; }
|
||||
.bo-ring-track { stroke: var(--line); }
|
||||
.bo-ring-value { stroke: var(--green); stroke-linecap: butt; }
|
||||
.bo-traffic-text .bo-hit b { color: var(--green); font-weight: 500; }
|
||||
.bo-traffic-text .bo-origin b { color: var(--text); font-weight: 500; }
|
||||
.bo-causes { display: flex; flex-wrap: nowrap; align-items: center; gap: 4px; }
|
||||
.bo-cause { display: inline-flex; align-items: center; gap: 5px; padding: 1px 6px; border: 1px solid var(--danger-line-strong); border-radius: var(--rp-radius-control, 0); color: var(--red); font: 11px var(--mono); }
|
||||
.bo-cause b { font-weight: 600; }
|
||||
.bo-cause.is-soft { border-color: var(--field-line); color: var(--muted); }
|
||||
.bo-causes small { color: var(--text-subtle); font: 11px var(--mono); }
|
||||
html[data-table-density=ultra] .bo-pair small { display: inline; margin-left: 6px; }
|
||||
html[data-table-density=ultra] .bo-ring { width: 14px; height: 14px; }
|
||||
html[data-table-density=ultra] .bo-stack { margin-top: 2px; }
|
||||
html[data-table-density=compact] .bo-ring { width: 18px; height: 18px; }
|
||||
html[data-table-density=compact] .bo-stack { margin-top: 3px; }
|
||||
@@ -663,6 +663,20 @@
|
||||
<label class="check"><input checked data-pool-column-toggle="traffic"
|
||||
type="checkbox" /><span
|
||||
data-i18n="vhTraffic">Traffic/s</span></label>
|
||||
<label class="check"><input data-pool-column-toggle="rate" type="checkbox" /><span
|
||||
data-i18n="backendColRate">Req/s</span></label>
|
||||
<label class="check"><input data-pool-column-toggle="statuses" type="checkbox" /><span
|
||||
data-i18n="backendColStatuses">HTTP codes</span></label>
|
||||
<label class="check"><input data-pool-column-toggle="p95" type="checkbox" /><span
|
||||
data-i18n="backendColP95">p95 / p99</span></label>
|
||||
<label class="check"><input data-pool-column-toggle="cache" type="checkbox" /><span
|
||||
data-i18n="backendColCache">Cache</span></label>
|
||||
<label class="check"><input data-pool-column-toggle="probe" type="checkbox" /><span
|
||||
data-i18n="backendColProbe">Probe</span></label>
|
||||
<label class="check"><input data-pool-column-toggle="causes" type="checkbox" /><span
|
||||
data-i18n="backendColCauses">Error causes</span></label>
|
||||
<label class="check"><input data-pool-column-toggle="protocol" type="checkbox" /><span
|
||||
data-i18n="backendColProtocol">Protocol</span></label>
|
||||
<label class="check"><input checked data-pool-column-toggle="generation"
|
||||
type="checkbox" /><span data-i18n="vhClientGeneration">Client
|
||||
gen.</span></label>
|
||||
@@ -700,6 +714,13 @@
|
||||
<th data-i18n="vhErrors" data-pool-column="errors">Errors</th>
|
||||
<th data-i18n="vhLatency" data-pool-column="latency">Latency</th>
|
||||
<th data-i18n="vhTraffic" data-pool-column="traffic">Traffic/s</th>
|
||||
<th data-i18n="backendColRate" data-pool-column="rate">Req/s</th>
|
||||
<th data-i18n="backendColStatuses" data-pool-column="statuses">HTTP codes</th>
|
||||
<th data-i18n="backendColP95" data-pool-column="p95">p95 / p99</th>
|
||||
<th data-i18n="backendColCache" data-pool-column="cache">Cache</th>
|
||||
<th data-i18n="backendColProbe" data-pool-column="probe">Probe</th>
|
||||
<th data-i18n="backendColCauses" data-pool-column="causes">Error causes</th>
|
||||
<th data-i18n="backendColProtocol" data-pool-column="protocol">Protocol</th>
|
||||
<th data-i18n="vhClientGeneration" data-pool-column="generation">Client gen.
|
||||
</th>
|
||||
<th data-i18n="vhGenerationRequests" data-pool-column="generationRequests">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], uaRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
|
||||
const chartHistory = new Map();
|
||||
const backendTrafficHistory = new Map();
|
||||
const backendRequestHistory = new Map();
|
||||
const vhostTrafficHistory = new Map();
|
||||
const dialog = $("vhost-dialog");
|
||||
const form = $("vhost-form"), container = $("vhost-fields");
|
||||
|
||||
+127
-11
@@ -151,9 +151,11 @@ document.addEventListener("scroll", event => {
|
||||
if (activeVhostBackendPopup && !activeVhostBackendPopup.node.contains(event.target)) positionVhostBackendPopup();
|
||||
}, true);
|
||||
|
||||
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
|
||||
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "rate", "statuses", "p95", "cache", "probe", "causes", "protocol", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
|
||||
const poolOptionalColumns = ["rate", "p95", "probe", "causes", "protocol"];
|
||||
const vhostSortColumns = ["id", "hostname", "status", "listeners", "certificate", "backends"];
|
||||
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] });
|
||||
const defaultPoolColumns = () => poolColumns.filter(key => !poolOptionalColumns.includes(key));
|
||||
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: defaultPoolColumns() });
|
||||
const defaultVhostListPrefs = () => ({ sort: "id", descending: false });
|
||||
let poolTablePrefs = defaultPoolTablePrefs(), vhostListPrefs = defaultVhostListPrefs(), poolPreferenceSaveRunning = false, poolPreferenceSaveQueued = false;
|
||||
function normalizePoolTablePrefs(preferences) {
|
||||
@@ -161,7 +163,7 @@ function normalizePoolTablePrefs(preferences) {
|
||||
return {
|
||||
sort: poolColumns.includes(preferences?.backend_table_sort) ? preferences.backend_table_sort : "vhost",
|
||||
descending: preferences?.backend_table_descending === true,
|
||||
columns: selected.length ? selected : [...poolColumns],
|
||||
columns: selected.length ? selected : defaultPoolColumns(),
|
||||
};
|
||||
}
|
||||
function normalizeVhostListPrefs(preferences) {
|
||||
@@ -256,7 +258,7 @@ function initPoolTableTools() {
|
||||
}));
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
}
|
||||
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_SORT_LABELS = { vhost: "vhVhost", backend: "backend", status: "status", active: "vhActive", requests: "vhRequests", errors: "vhErrors", latency: "vhLatency", traffic: "vhTraffic", rate: "backendColRate", statuses: "backendColStatuses", p95: "backendColP95", cache: "backendColCache", probe: "backendColProbe", causes: "backendColCauses", protocol: "backendColProtocol", generation: "vhClientGeneration", generationRequests: "vhGenerationRequests", generationAge: "vhGenerationAge", rotations: "vhClientRotations", lastFailure: "vhLastFailure", lastRecovery: "vhLastRecovery" };
|
||||
const BO_DETAIL_COLUMNS = ["generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
|
||||
const BO_SAMPLES = 30;
|
||||
const boOpen = new Set(), boSeries = new Map();
|
||||
@@ -270,6 +272,54 @@ function buildBackendOverviewTools() {
|
||||
input.type = "checkbox"; input.dataset.poolColumnToggle = key; text.dataset.i18n = BO_SORT_LABELS[key]; label.append(input, text); options.append(label);
|
||||
}
|
||||
}
|
||||
const BO_CAUSES = [["http_5xx_total", "5xx"], ["timeouts_total", "timeout"], ["connect_errors_total", "connect"], ["tls_errors_total", "TLS"], ["dns_errors_total", "DNS"], ["io_errors_total", "I/O"], ["queue_timeouts_total", "queue"], ["capacity_rejections_total", "limit"], ["retry_attempts_total", "retry"]];
|
||||
function boMs(value) {
|
||||
if (!(value > 0)) return "-";
|
||||
if (value >= 10000) return ">5 s";
|
||||
return value >= 1000 ? `${Number((value / 1000).toFixed(1))} s` : `${Math.round(value)} ms`;
|
||||
}
|
||||
function boPercent(part, total) {
|
||||
if (!(total > 0) || !(part > 0)) return "0%";
|
||||
const pct = part / total * 100;
|
||||
return pct < 0.1 ? "<0.1%" : `${pct.toFixed(pct < 10 ? 1 : 0)}%`;
|
||||
}
|
||||
function backendRequestRate(key, requests, snapshot, now, seen) {
|
||||
seen.add(key);
|
||||
const previous = backendRequestHistory.get(key);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const continuous = rustProxyRateSampling.continuous(previous, now, rustProxyRateSampling.cadence("overview_seconds"));
|
||||
const rate = continuous && requests >= previous.requests && now > previous.at ? (requests - previous.requests) / ((now - previous.at) / 1000) : previous?.rate || 0;
|
||||
backendRequestHistory.set(key, { ...rustProxyRateSampling.stamp(now), requests, snapshot, rate: continuous ? rate : 0 });
|
||||
return continuous ? rate : 0;
|
||||
}
|
||||
function backendExtraMetrics(pool, member, status, snapshot, now, seen) {
|
||||
const vhost = status.vhost_metrics?.[pool.id] || {}, cacheTotals = status.vhost_cache_totals?.[pool.id] || null;
|
||||
const classes = Array.isArray(vhost.status_classes) ? vhost.status_classes.map(value => Math.max(0, Number(value) || 0)) : [0, 0, 0, 0, 0];
|
||||
const classTotal = classes.reduce((sum, value) => sum + value, 0);
|
||||
const hits = Number(cacheTotals?.hits) || 0, misses = Number(cacheTotals?.misses) || 0, bypasses = Number(cacheTotals?.bypasses) || 0, lookups = hits + misses + bypasses;
|
||||
const cacheBytes = Math.max(0, Number(cacheTotals?.saved_body_bytes) || 0), outBytes = Math.max(cacheBytes, Number(vhost.bytes_out) || 0), originBytes = Math.max(0, outBytes - cacheBytes);
|
||||
const p95 = Number(vhost.p95_latency_ms) || 0, p99 = Number(vhost.p99_latency_ms) || 0;
|
||||
const probeOff = member.health_mode === "off", probeLatency = Number(member.latency_ms) || 0, probeAt = Number(member.last_probe_ms) || 0;
|
||||
const causes = BO_CAUSES.map(([field, label]) => ({ label, value: Math.max(0, Number(member[field]) || 0) })).filter(cause => cause.value > 0).sort((a, b) => b.value - a.value);
|
||||
const causeTotal = causes.filter(cause => cause.label !== "retry").reduce((sum, cause) => sum + cause.value, 0);
|
||||
const h1 = Math.max(0, Number(member.upstream_protocol_http1_total) || 0), h2 = Math.max(0, Number(member.upstream_protocol_http2_total) || 0);
|
||||
const protocol = member.upstream_protocol_observed === "h1" || member.upstream_protocol_observed === "h2" ? member.upstream_protocol_observed.toUpperCase() : "";
|
||||
const rate = backendRequestRate(`${pool.id}\u0000${member.id}`, Math.max(0, Number(member.requests) || 0), snapshot, now, seen);
|
||||
const hitRatio = lookups ? hits / lookups : -1, errorShare = classTotal ? (classes[3] + classes[4]) / classTotal : -1;
|
||||
return {
|
||||
rate, classes, classTotal, hits, misses, bypasses, lookups, cacheBytes, originBytes, outBytes, p95, p99, probeOff, probeLatency, probeAt, lastProbeResult: member.last_result || "", causes, causeTotal, h1, h2, protocol,
|
||||
sort: { rate, statuses: errorShare, p95, cache: hitRatio, probe: probeOff ? -1 : probeLatency, causes: causeTotal, protocol: h1 + h2 ? h2 / (h1 + h2) : -1 },
|
||||
text: {
|
||||
rate: `${rate.toFixed(rate < 10 ? 1 : 0)}/s`,
|
||||
statuses: classTotal ? ["2xx", "3xx", "4xx", "5xx"].map((label, index) => `${label} ${boPercent(classes[index + 1], classTotal)}`).join(" · ") : "-",
|
||||
p95: p95 ? `${boMs(p95)} / ${boMs(p99)}` : "-",
|
||||
cache: lookups || cacheBytes ? `HIT ${boPercent(hits, lookups)} · ${bytes(cacheBytes)} / ${bytes(originBytes)}` : "-",
|
||||
probe: probeOff || !(probeLatency > 0) ? "-" : `${probeLatency.toFixed(1)} ms`,
|
||||
causes: causes.length ? causes.map(cause => `${cause.label} ${cause.value}`).join(" · ") : "-",
|
||||
protocol: protocol ? `${protocol}${h1 + h2 ? ` · H2 ${boPercent(h2, h1 + h2)}` : ""}` : "-",
|
||||
},
|
||||
};
|
||||
}
|
||||
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));
|
||||
@@ -341,6 +391,67 @@ function renderBackendOverview(items, hasProxy, snapshot = null) {
|
||||
$("backend-overview-card")?.classList.toggle("is-no-proxy", !boHasProxy);
|
||||
applyBackendOverviewView();
|
||||
}
|
||||
function boStack(parts) {
|
||||
const track = el("div", undefined, "bo-stack"), total = parts.reduce((sum, [value]) => sum + value, 0);
|
||||
if (total > 0) for (const [value, kind] of parts) if (value > 0) { const part = el("i", undefined, `is-${kind}`); part.style.flexGrow = String(value); part.style.minWidth = "2px"; track.append(part); }
|
||||
return track;
|
||||
}
|
||||
function boRing(ratio) {
|
||||
const svg = document.createElementNS(BO_SVG, "svg"), radius = 8, length = 2 * Math.PI * radius;
|
||||
svg.setAttribute("viewBox", "0 0 20 20"); svg.setAttribute("class", "bo-ring"); svg.setAttribute("aria-hidden", "true");
|
||||
for (const kind of ["track", "value"]) {
|
||||
const circle = document.createElementNS(BO_SVG, "circle");
|
||||
circle.setAttribute("cx", "10"); circle.setAttribute("cy", "10"); circle.setAttribute("r", String(radius)); circle.setAttribute("class", `bo-ring-${kind}`);
|
||||
if (kind === "value") { circle.setAttribute("stroke-dasharray", `${(Math.max(0, Math.min(1, ratio)) * length).toFixed(2)} ${length.toFixed(2)}`); circle.setAttribute("transform", "rotate(-90 10 10)"); }
|
||||
svg.append(circle);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
const BO_EXTRA_CELLS = {
|
||||
rate(cell, extra) { cell.className = "is-num"; cell.textContent = extra.text.rate; if (!(extra.rate > 0)) cell.classList.add("is-zero"); },
|
||||
statuses(cell, extra) {
|
||||
cell.className = "bo-statuses"; cell.title = `${t("backendVhostScope")}\n${["1xx", "2xx", "3xx", "4xx", "5xx"].map((label, index) => `${label}: ${extra.classes[index]}`).join("\n")}`;
|
||||
if (!extra.classTotal) { cell.append(el("span", "-", "is-zero")); return; }
|
||||
const top = el("div", undefined, "bo-meter-top"), issues = el("small");
|
||||
top.append(el("span", `2xx ${boPercent(extra.classes[1], extra.classTotal)}`));
|
||||
for (const [index, kind] of [[3, "warn"], [4, "bad"]]) if (extra.classes[index] > 0) issues.append(el("b", `${index + 1}xx ${boPercent(extra.classes[index], extra.classTotal)}`, `is-${kind}`));
|
||||
if (!issues.children.length) issues.textContent = "4xx/5xx 0%";
|
||||
top.append(issues); cell.append(top, boStack([[extra.classes[0] + extra.classes[1], "ok"], [extra.classes[2], "info"], [extra.classes[3], "warn"], [extra.classes[4], "bad"]]));
|
||||
},
|
||||
p95(cell, extra) {
|
||||
cell.className = "bo-pair"; cell.title = `${t("backendVhostScope")} · ${t("backendBucketBound")}`;
|
||||
if (!extra.p95) { cell.append(el("span", "-", "is-zero")); return; }
|
||||
cell.append(el("span", boMs(extra.p95)), el("small", `p99 ${boMs(extra.p99)}`));
|
||||
},
|
||||
probe(cell, extra) {
|
||||
cell.className = "bo-pair";
|
||||
if (extra.probeOff || !(extra.probeLatency > 0)) { cell.append(el("span", "-", "is-zero")); return; }
|
||||
cell.title = extra.lastProbeResult; cell.append(el("span", `${extra.probeLatency.toFixed(1)} ms`));
|
||||
if (extra.probeAt > 0) cell.append(el("small", boAgo(extra.probeAt)));
|
||||
},
|
||||
cache(cell, extra) {
|
||||
cell.className = "bo-cache";
|
||||
cell.title = `${t("backendVhostScope")}\nHIT ${extra.hits} · MISS ${extra.misses} · BYPASS ${extra.bypasses}\nCACHE ${bytes(extra.cacheBytes)} (${boPercent(extra.cacheBytes, extra.outBytes)}) · ORIGIN ${bytes(extra.originBytes)}`;
|
||||
if (!extra.lookups && !extra.cacheBytes) { cell.append(el("span", "-", "is-zero")); return; }
|
||||
const wrap = el("div", undefined, "bo-chartcell"), hit = el("div", undefined, "bo-cache-hit"), text = el("div", undefined, "bo-traffic-text");
|
||||
hit.append(boRing(extra.lookups ? extra.hits / extra.lookups : 0), el("span", extra.lookups ? boPercent(extra.hits, extra.lookups) : "-"));
|
||||
const line = (kind, label, value) => { const row = el("div", undefined, `bo-${kind}`); row.append(el("span", label), el("b", bytes(value), value > 0 ? "" : "is-zero")); return row; };
|
||||
text.append(line("hit", "CACHE", extra.cacheBytes), line("origin", "ORIGIN", extra.originBytes));
|
||||
wrap.append(hit, text); cell.append(wrap);
|
||||
},
|
||||
causes(cell, extra) {
|
||||
cell.className = "bo-causes"; cell.title = extra.text.causes;
|
||||
if (!extra.causes.length) { cell.append(el("span", "-", "is-zero")); return; }
|
||||
for (const cause of extra.causes.slice(0, 3)) { const chip = el("span", undefined, `bo-cause${cause.label === "retry" ? " is-soft" : ""}`); chip.append(cause.label, el("b", String(cause.value))); cell.append(chip); }
|
||||
if (extra.causes.length > 3) cell.append(el("small", `+${extra.causes.length - 3}`));
|
||||
},
|
||||
protocol(cell, extra) {
|
||||
cell.className = "bo-pair"; cell.title = `H1 ${extra.h1} · H2 ${extra.h2}`;
|
||||
if (!extra.protocol) { cell.append(el("span", "-", "is-zero")); return; }
|
||||
cell.append(el("span", extra.protocol));
|
||||
if (extra.h1 + extra.h2) cell.append(el("small", `H2 ${boPercent(extra.h2, extra.h1 + extra.h2)}`));
|
||||
},
|
||||
};
|
||||
function applyBackendOverviewView() {
|
||||
const body = $("bo-rows"); if (!body) return;
|
||||
const visibleColumns = new Set(poolTablePrefs.columns);
|
||||
@@ -370,7 +481,9 @@ function applyBackendOverviewView() {
|
||||
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")],
|
||||
visibleColumns.has("rate") && ["rate", t("backendColRate"), true], visibleColumns.has("errors") && ["errors", t("vhErrors")], visibleColumns.has("statuses") && ["statuses", t("backendColStatuses")], visibleColumns.has("latency") && ["latency", t("vhLatency")],
|
||||
visibleColumns.has("p95") && ["p95", t("backendColP95"), true], visibleColumns.has("probe") && ["probe", t("backendColProbe"), true], visibleColumns.has("cache") && ["cache", t("backendColCache")], visibleColumns.has("traffic") && ["traffic", t("vhTraffic"), true],
|
||||
visibleColumns.has("causes") && ["causes", t("backendColCauses")], visibleColumns.has("protocol") && ["protocol", t("backendColProtocol")], 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 = [];
|
||||
@@ -405,6 +518,7 @@ function applyBackendOverviewView() {
|
||||
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 in BO_EXTRA_CELLS) BO_EXTRA_CELLS[id](cell, item.extra);
|
||||
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))); }
|
||||
@@ -651,7 +765,7 @@ function renderStatus(status) {
|
||||
refreshVhostBackendPopup(status);
|
||||
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
||||
const rows = [], summaries = new Map(), aggregate = new Map(), overviewItems = [];
|
||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set(), seenRequests = 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]));
|
||||
for (const pool of status.backend_pools || []) {
|
||||
@@ -659,6 +773,7 @@ function renderStatus(status) {
|
||||
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
|
||||
for (const member of members) {
|
||||
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
|
||||
const extra = backendExtraMetrics(pool, member, status, trafficSnapshot, trafficNow, seenRequests);
|
||||
const label = vhostDisabled ? t("vhDisabled") : (member.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
|
||||
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
|
||||
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${formatTime(member.last_failure_at_ms)}` : "-";
|
||||
@@ -668,17 +783,17 @@ function renderStatus(status) {
|
||||
const generationAge = Math.max(0, Number(generationAgeRaw));
|
||||
const generationAgeText = generationAgeRaw == null || !Number.isFinite(generationAge) ? "-" : generationAge >= 3600 ? `${Math.floor(generationAge / 3600)}h ${Math.floor((generationAge % 3600) / 60)}m` : generationAge >= 60 ? `${Math.floor(generationAge / 60)}m ${Math.floor(generationAge % 60)}s` : `${Math.floor(generationAge)}s`;
|
||||
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
|
||||
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, member.upstream_client_generation ?? "-", member.upstream_client_generation_requests ?? "-", generationAgeText, member.upstream_client_rotations_total ?? "-", lastFailure, lastRecovery];
|
||||
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 };
|
||||
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, extra.text.rate, extra.text.statuses, extra.text.p95, extra.text.cache, extra.text.probe, extra.text.causes, extra.text.protocol, member.upstream_client_generation ?? "-", member.upstream_client_generation_requests ?? "-", generationAgeText, member.upstream_client_rotations_total ?? "-", lastFailure, lastRecovery];
|
||||
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, ...extra.sort, 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);
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); const column = poolColumns[index]; cell.dataset.poolColumn = column; cell.title = column === "status" ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : column === "lastFailure" ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (column === "status" && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (column === "lastFailure" && 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, ...(({ 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,
|
||||
extra, 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; }
|
||||
@@ -698,6 +813,7 @@ function renderStatus(status) {
|
||||
});
|
||||
}
|
||||
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
|
||||
for (const key of backendRequestHistory.keys()) if (!seenRequests.has(key)) backendRequestHistory.delete(key);
|
||||
for (const [id, vhost] of configuredVhosts) {
|
||||
const existing = aggregate.get(id) || {};
|
||||
const metrics = status.vhost_metrics?.[id];
|
||||
@@ -822,5 +938,5 @@ $("pool-open").addEventListener("click", () => setPanel("vhosts"));
|
||||
initPoolTableTools();
|
||||
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
|
||||
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
|
||||
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { closeVhostBackendPopup(); dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
|
||||
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { closeVhostBackendPopup(); dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); backendRequestHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
Reference in new issue
Block a user