This commit is contained in:
Mateusz Gruszczyński committed 2026-11-06 23:16:36 +01:00
1 parent 09414dfb09
commit f4606cce18
36 files changed
+641 -74

No files matched your search

+30 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.35.1",
"version": "0.35.2",
"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": [
@@ -3434,6 +3434,19 @@
"nullable": true,
"description": "Optional HTTP Host override. When null for an HTTP vhost probe, RustProxy uses the first exact vhost DNS hostname; if none exists it falls back to the backend host."
},
"port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"nullable": true,
"description": "Optional port for active probes. When null or equal to the backend port, probes use the backend address; otherwise TCP and HTTP probes connect to the same host on this port with the same TLS settings and address restrictions."
},
"expect_body": {
"type": "string",
"nullable": true,
"maxLength": 256,
"description": "Optional text that must appear in the first 64 KiB of an HTTP probe response body. A response with an accepted status but without this text counts as a failed probe (result http_body)."
},
"interval_ms": {
"type": "integer",
"minimum": 250,
@@ -3766,6 +3779,10 @@
"extra_h3_share",
"extra_cache_miss_rate",
"extra_backend_active",
"extra_backends_healthy",
"extra_error_share",
"extra_rejected_rate",
"extra_cert_expiry",
"wide_protocol_rates",
"wide_cpu",
"wide_memory",
@@ -3847,6 +3864,10 @@
"extra_h3_share",
"extra_cache_miss_rate",
"extra_backend_active",
"extra_backends_healthy",
"extra_error_share",
"extra_rejected_rate",
"extra_cert_expiry",
"wide_protocol_rates",
"wide_cpu",
"wide_memory",
@@ -3935,6 +3956,10 @@
"extra_h3_share",
"extra_cache_miss_rate",
"extra_backend_active",
"extra_backends_healthy",
"extra_error_share",
"extra_rejected_rate",
"extra_cert_expiry",
"wide_protocol_rates",
"wide_cpu",
"wide_memory",
@@ -4004,6 +4029,10 @@
"extra_h3_share",
"extra_cache_miss_rate",
"extra_backend_active",
"extra_backends_healthy",
"extra_error_share",
"extra_rejected_rate",
"extra_cert_expiry",
"wide_protocol_rates",
"wide_cpu",
"wide_memory",
+18 -2
View File
@@ -165,7 +165,7 @@ function setPanel(name, navigate = true) {
if (name === "filelogs") void window.rustProxyFileLogs?.refresh();
if (name === "tls") void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); });
}
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
function bytes(value) { if (value < 1024) return `${Math.round(value)} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
function uptime(value) {
let seconds = Math.max(0, Math.floor(Number(value) || 0));
if (seconds < 60) return `${seconds}s`;
@@ -232,7 +232,23 @@ window.rustProxyOverviewSamples.onChange(() => {
}
redrawOverviewHistoryCharts();
});
function renderStatus(s, systemSample = false) {
// Safari has no CSS scroll anchoring: keep the element under the viewport centre in place
// when a live refresh changes the height of content above it.
const nativeScrollAnchoring = typeof CSS !== "undefined" && CSS.supports?.("overflow-anchor", "auto");
function preserveScrollAnchor(render) {
if (nativeScrollAnchoring || window.scrollY <= 0) return render();
const probe = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
const anchor = probe?.closest?.("article, tr, .mini-live-chart, .network-interface-row") || probe;
const before = anchor?.isConnected ? anchor.getBoundingClientRect().top : null;
const result = render();
if (before !== null && anchor.isConnected) {
const shift = anchor.getBoundingClientRect().top - before;
if (Math.abs(shift) >= 1) window.scrollBy(0, shift);
}
return result;
}
function renderStatus(s, systemSample = false) { return preserveScrollAnchor(() => renderStatusNow(s, systemSample)); }
function renderStatusNow(s, systemSample = false) {
const firstStatus = latestStatus === null;
latestStatus = {
...(latestStatus || {}), ...s,
+39 -2
View File
@@ -8,10 +8,12 @@ window.rustProxyExtraOverview = (() => {
"extra_response_latency", "extra_backend_unhealthy",
"extra_total_rps", "extra_active_exchanges", "extra_h1_share", "extra_h3_share",
"extra_cache_miss_rate", "extra_backend_active",
"extra_backends_healthy", "extra_error_share", "extra_rejected_rate", "extra_cert_expiry",
];
const bounded = new Set([
"extra_process_fds", "extra_cache_memory", "extra_cold_queue_memory", "extra_cold_storage",
"extra_h1_share", "extra_h2_share", "extra_h3_share",
"extra_backends_healthy", "extra_cert_expiry",
]);
const histories = new Map(), lastCounter = new Map(), lastValue = new Map();
const lastUnits = new Map(), lastSegments = new Map();
@@ -98,11 +100,36 @@ window.rustProxyExtraOverview = (() => {
unit: "count", detail: ""
};
}
if (id === "extra_backends_healthy") {
const members = Array.isArray(status.backend_pools)
? status.backend_pools.flatMap(pool => (pool.members || []).map(member => ({ pool: pool.id, ...member }))) : null;
if (!members) return { value: null, unit: "count", detail: "" };
const monitored = members.filter(member => member.health_check_enabled !== false && member.health_mode !== "off");
const down = monitored.filter(member => member.status === "down" || member.healthy === false);
const up = monitored.length - down.length;
return {
value: monitored.length ? `${fmt(up)} / ${fmt(monitored.length)}` : null, unit: "text",
meterPercent: monitored.length ? up / monitored.length * 100 : null,
detail: !monitored.length ? t("overviewExtraBackendsNone")
: down.length ? `${t("overviewExtraBackendsDown")}: ${down.slice(0, 3).map(member => `${member.pool}/${member.id}`).join(", ")}${down.length > 3 ? " …" : ""}`
: t("overviewExtraBackendsAllUp"),
};
}
if (id === "extra_cert_expiry") {
const inventory = typeof certificateInventory !== "undefined" && certificateInventory instanceof Map ? [...certificateInventory.values()] : [];
const dated = inventory.filter(cert => Number.isFinite(Number(cert?.days_remaining)))
.sort((a, b) => Number(a.days_remaining) - Number(b.days_remaining));
if (!dated.length) return { value: null, unit: "days", detail: t("overviewExtraCertNone") };
const nearest = dated[0], days = Number(nearest.days_remaining);
const name = (Array.isArray(nearest.covered_hosts) && nearest.covered_hosts[0]) || (Array.isArray(nearest.hosts) && nearest.hosts[0]) || nearest.id;
return { value: days, unit: "days", meterPercent: Math.max(0, Math.min(100, days / 90 * 100)), detail: `${name} · ${fmt(dated.length)} ${t("overviewExtraCertCount")}` };
}
if (id === "extra_cache_memory") return ramBudget(c.resident_bytes, c.hot_limit_bytes);
if (id === "extra_cold_queue_memory") return ramBudget(c.cold_queue_bytes, c.cold_queue_limit_bytes);
if (id === "extra_cold_storage") return ramBudget(c.cold_bytes, c.cold_limit_bytes);
if (!["extra_tls_error_rate", "extra_request_error_rate", "extra_cache_hit_rate", "extra_h2_share",
"extra_total_rps", "extra_h1_share", "extra_h3_share", "extra_cache_miss_rate"].includes(id))
"extra_total_rps", "extra_h1_share", "extra_h3_share", "extra_cache_miss_rate",
"extra_error_share", "extra_rejected_rate"].includes(id))
return { value: null, unit: "count", detail: "" };
const fields = ({
extra_tls_error_rate: [m.tls_handshake_errors],
@@ -113,8 +140,10 @@ window.rustProxyExtraOverview = (() => {
extra_h3_share: [m.http3_requests, m.requests],
extra_total_rps: [m.requests],
extra_cache_miss_rate: [c.misses],
extra_error_share: [m.errors, m.requests],
extra_rejected_rate: [m.rejected_requests, m.rejected_connections],
})[id].map(positive);
const unit = ["extra_cache_hit_rate", "extra_h2_share", "extra_h1_share", "extra_h3_share"].includes(id)
const unit = ["extra_cache_hit_rate", "extra_h2_share", "extra_h1_share", "extra_h3_share", "extra_error_share"].includes(id)
? "percent" : "rate";
if (fields.some(v => v === null)) {
lastCounter.delete(id);
@@ -124,6 +153,12 @@ window.rustProxyExtraOverview = (() => {
const result = elapsedDelta(id, fields, now);
if (!result) return { value: null, unit, detail: "", slices: null };
const { deltas, seconds } = result;
if (id === "extra_rejected_rate") return {
value: (deltas[0] + deltas[1]) / seconds, unit, detail: `${t("overviewExtraRejectedDetail")}: ${fmt(fields[0])} / ${fmt(fields[1])}`
};
if (id === "extra_error_share") return {
value: deltas[1] > 0 ? Math.min(100, deltas[0] / deltas[1] * 100) : 0, unit, detail: `${t("overviewExtraErrorShareDetail")}: ${fmt(deltas[0])} / ${fmt(deltas[1])}`
};
if (unit === "rate") return { value: deltas[0] / seconds, unit, detail: "" };
const sum = id === "extra_cache_hit_rate" ? deltas[0] + deltas[1] : deltas[1];
return {
@@ -137,6 +172,8 @@ window.rustProxyExtraOverview = (() => {
if (unit === "bytes") return fmtBytes(value);
if (unit === "ms") return `${fmt(value, 1)} ms`;
if (unit === "rate") return `${fmt(value, 1)}/s`;
if (unit === "days") return `${fmt(value)} ${t("overviewExtraDaysUnit")}`;
if (unit === "text") return String(value);
return fmt(value);
}
function points(id, history, unit) {
+1 -1
View File
@@ -182,7 +182,7 @@ const rustProxyRefresh = (() => {
const value = await api("/api/system-info", { signal });
if (!isCurrent()) return;
latestStatus = { ...(latestStatus || {}), ...value };
if (latestStatus.metrics) renderDiagnostics(latestStatus, false, true);
if (latestStatus.metrics) preserveScrollAnchor(() => renderDiagnostics(latestStatus, false, true));
window.rustProxySettings?.render();
}
});
+14
View File
@@ -2119,3 +2119,17 @@ html[data-dashboard-density] #panel-overview .stat :is(.stat-chart-legend, .over
color: var(--muted);
font: 400 10px var(--mono);
}
/* Live values stay on one line so refreshes do not change card heights. */
.mini-live-chart-head> :last-child,
.network-interface-row-head .network-interface-head-actions {
flex: 0 0 auto;
white-space: nowrap;
}
.mini-live-chart-head>span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+4
View File
@@ -5,6 +5,10 @@
"bandwidth",
"errors",
"memory",
"extra_backends_healthy",
"extra_error_share",
"extra_rejected_rate",
"extra_cert_expiry",
"websocket",
"backend_traffic",
"extra_response_latency",
+44
View File
@@ -867,5 +867,49 @@
"category": "web_server",
"default_hidden": true,
"display_type": "chart"
},
{
"id": "extra_backends_healthy",
"label_key": "overviewExtraBackendsHealthy",
"description_key": "overviewExtraBackendsHealthyDescription",
"element_selector": "[data-dashboard-widget=\"extra_backends_healthy\"]",
"data_source": "backend_status",
"no_data": "neutral_dash",
"category": "backends",
"default_hidden": false,
"display_type": "value_chart"
},
{
"id": "extra_error_share",
"label_key": "overviewExtraErrorShare",
"description_key": "overviewExtraErrorShareDescription",
"element_selector": "[data-dashboard-widget=\"extra_error_share\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": false,
"display_type": "value_chart"
},
{
"id": "extra_rejected_rate",
"label_key": "overviewExtraRejectedRate",
"description_key": "overviewExtraRejectedRateDescription",
"element_selector": "[data-dashboard-widget=\"extra_rejected_rate\"]",
"data_source": "status_metrics",
"no_data": "neutral_dash",
"category": "performance",
"default_hidden": false,
"display_type": "value_chart"
},
{
"id": "extra_cert_expiry",
"label_key": "overviewExtraCertExpiry",
"description_key": "overviewExtraCertExpiryDescription",
"element_selector": "[data-dashboard-widget=\"extra_cert_expiry\"]",
"data_source": "system_status",
"no_data": "neutral_dash",
"category": "overview",
"default_hidden": false,
"display_type": "value_chart"
}
]
@@ -365,6 +365,36 @@
</polyline>
</svg><small id="overview-extra-extra_backend_unhealthy-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_backends_healthy" hidden>
<span data-i18n="overviewExtraBackendsHealthy">overviewExtraBackendsHealthy</span><strong id="overview-extra-extra_backends_healthy">-</strong>
<div class="overview-meter" id="overview-extra-extra_backends_healthy-meter" role="progressbar"
data-i18n-aria="overviewExtraBackendsHealthy" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_backends_healthy-meter-fill"></span>
</div><small id="overview-extra-extra_backends_healthy-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_error_share" hidden>
<span data-i18n="overviewExtraErrorShare">overviewExtraErrorShare</span><strong id="overview-extra-extra_error_share">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraErrorShare" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_error_share-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_error_share-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_rejected_rate" hidden>
<span data-i18n="overviewExtraRejectedRate">overviewExtraRejectedRate</span><strong id="overview-extra-extra_rejected_rate">-</strong>
<svg preserveAspectRatio="none" data-i18n-aria="overviewExtraRejectedRate" role="img" viewBox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line" id="overview-extra-extra_rejected_rate-line" points="0,55 300,55">
</polyline>
</svg><small id="overview-extra-extra_rejected_rate-detail">-</small>
</article>
<article class="stat stat-meter" data-dashboard-widget="extra_cert_expiry" hidden>
<span data-i18n="overviewExtraCertExpiry">overviewExtraCertExpiry</span><strong id="overview-extra-extra_cert_expiry">-</strong>
<div class="overview-meter" id="overview-extra-extra_cert_expiry-meter" role="progressbar"
data-i18n-aria="overviewExtraCertExpiry" aria-valuemin="0" aria-valuemax="100" aria-valuetext="-">
<span class="overview-meter-fill" id="overview-extra-extra_cert_expiry-meter-fill"></span>
</div><small id="overview-extra-extra_cert_expiry-detail">-</small>
</article>
<article class="stat stat-chart" data-dashboard-widget="extra_total_rps" hidden>
<span data-i18n="overviewExtraTotalRps">HTTP requests / s</span><strong
id="overview-extra-extra_total_rps">-</strong>
+3
View File
@@ -38,6 +38,7 @@ const num = (name, scope) => { const value = Number(str(name, scope)); if (!Numb
const yes = (name, scope) => read(name, scope).checked;
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
const nullable = (name, scope) => str(name, scope) || null;
const optionalPort = (name, scope) => { const value = str(name, scope); return value ? Number(value) : null; };
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
const changed = () => { dirty = true; };
const fillWebServices = (select, chosen = "") => {
@@ -236,6 +237,8 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 });
field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 });
field(healthFields, "member-health-port", "vhHealthPort", health.port ?? "", "number", { min: 1, max: 65535, step: 1 }).placeholder = t("vhHealthPortPlaceholder");
field(healthFields, "member-health-expect-body", "vhHealthExpectBody", health.expect_body ?? "", "text", { maxLength: 256 });
field(healthFields, "member-health-interval", "vhInterval", health.interval_ms ?? 5000, "number", { min: 250, max: 300000, required: true });
field(healthFields, "member-health-timeout", "vhTimeout", health.timeout_ms ?? 2000, "number", { min: 50, max: 60000, required: true });
field(healthFields, "member-health-slow", "vhSlow", health.slow_response_ms ?? 1500, "number", { min: 0, max: 60000, required: true });
+4 -2
View File
@@ -238,6 +238,8 @@ function load(value = defaults(), migrationIds = [], show = true) {
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
field(g, "health-port", "vhHealthPort", health.port ?? "", "number", { min: 1, max: 65535, step: 1 }).placeholder = t("vhHealthPortPlaceholder");
field(g, "health-expect-body", "vhHealthExpectBody", health.expect_body || "", "text", { maxLength: 256 });
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
backendHealthEditorControl = backendHealthEditor(g, members);
note(g, "backendHealthOverridesHelp");
@@ -560,7 +562,7 @@ function collect() {
pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row),
health_check_enabled: yes("member-health-enabled", row),
health_check: yes("member-health-custom", row.memberHealthPanel) ? {
mode: str("member-health-mode", row.memberHealthPanel), path: str("member-health-path", row.memberHealthPanel), host: nullable("member-health-host", row.memberHealthPanel),
mode: str("member-health-mode", row.memberHealthPanel), path: str("member-health-path", row.memberHealthPanel), host: nullable("member-health-host", row.memberHealthPanel), port: optionalPort("member-health-port", row.memberHealthPanel), expect_body: nullable("member-health-expect-body", row.memberHealthPanel),
interval_ms: num("member-health-interval", row.memberHealthPanel), timeout_ms: num("member-health-timeout", row.memberHealthPanel), slow_response_ms: num("member-health-slow", row.memberHealthPanel),
fall: num("member-health-fall", row.memberHealthPanel), rise: num("member-health-rise", row.memberHealthPanel),
expected_status_min: num("member-health-min", row.memberHealthPanel), expected_status_max: num("member-health-max", row.memberHealthPanel)
@@ -630,7 +632,7 @@ function collect() {
load_balancing: {
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive"), pool_recycle_requests: num("pool-recycle-requests"), pool_recycle_seconds: num("pool-recycle-seconds"), http1_max_buffer_bytes: num("http1-max-buffer") },
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), port: optionalPort("health-port"), expect_body: nullable("health-expect-body"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
passive_health: {
enabled: yes("passive-enabled"),
count_connect_errors: yes("passive-connect"),
+1 -1
View File
@@ -2,7 +2,7 @@
function inheritBackendHealthInputs(panel) {
for (const [local, shared] of [
["member-health-mode", "health-mode"], ["member-health-path", "health-path"],
["member-health-host", "health-host"], ["member-health-interval", "interval"],
["member-health-host", "health-host"], ["member-health-port", "health-port"], ["member-health-expect-body", "health-expect-body"], ["member-health-interval", "interval"],
["member-health-timeout", "timeout"], ["member-health-slow", "slow"],
["member-health-fall", "fall"], ["member-health-rise", "rise"],
["member-health-min", "status-min"], ["member-health-max", "status-max"]