v0.35.1: performance, AGPL licensing and documentation overhaul

- Web Server reads static files inline; proxy middleware chain flattened
- About window with license summary and project links; AGPL-3.0-or-later
- Rewritten documentation: feature list, screenshots, performance guide
- Fix false spikes in per-Vhost request-rate charts
This commit is contained in:
gru committed 2026-11-05 18:29:56 +01:00
1 parent d3b492874b
commit eba9f89283
135 files changed
+1425 -1064

No files matched your search

+5 -6
View File
@@ -11,10 +11,11 @@ macro_rules! reader_doc {
pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
reader_doc!("README.md", "project"),
reader_doc!("CHANGELOG.md", "project"),
reader_doc!("ROADMAP.md", "project"),
reader_doc!("LICENSE", "project"),
reader_doc!("docs/README.md", "guides"),
reader_doc!("docs/FEATURES.md", "guides"),
reader_doc!("docs/SCREENSHOTS.md", "guides"),
reader_doc!("docs/INSTALL.md", "guides"),
reader_doc!("docs/CONTAINER.md", "guides"),
reader_doc!("docs/CONFIGURATION.md", "guides"),
reader_doc!("docs/VHOSTS.md", "guides"),
reader_doc!("docs/CUSTOM_LOCATIONS.md", "guides"),
@@ -24,11 +25,9 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
reader_doc!("docs/CACHE.md", "guides"),
reader_doc!("docs/RESPONSE_DELIVERY.md", "guides"),
reader_doc!("docs/TRANSPORT.md", "guides"),
reader_doc!("docs/TLS_POST_QUANTUM.md", "guides"),
reader_doc!("docs/STREAMS.md", "guides"),
reader_doc!("docs/ACME_PROVIDERS.md", "guides"),
reader_doc!("docs/CERTIFICATES.md", "guides"),
reader_doc!("docs/DOCKER_DISCOVERY.md", "guides"),
reader_doc!("docs/INTEGRATIONS.md", "guides"),
reader_doc!("docs/DASHBOARD.md", "guides"),
reader_doc!("docs/MONITORING.md", "guides"),
reader_doc!("docs/LOGS.md", "guides"),
@@ -36,6 +35,6 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
reader_doc!("docs/MIGRATION.md", "guides"),
reader_doc!("docs/SECURITY.md", "guides"),
reader_doc!("docs/API.md", "guides"),
reader_doc!("docs/API_ACCESS.md", "guides"),
reader_doc!("docs/PERFORMANCE.md", "guides"),
reader_doc!("docs/TROUBLESHOOTING.md", "guides"),
];
+1 -1
View File
@@ -12,7 +12,7 @@ mod tests {
assert!(documents.iter().any(|doc| doc["path"] == "docs/PHP_FPM.md"));
assert!(documents
.iter()
.any(|doc| doc["path"] == "docs/TLS_POST_QUANTUM.md"));
.any(|doc| doc["path"] == "docs/FEATURES.md"));
assert!(documents.iter().all(|doc| {
doc["path"].as_str().is_some_and(|value| !value.is_empty())
&& doc["title"].as_str().is_some_and(|value| !value.is_empty())
+2 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.35.0",
"version": "0.35.1",
"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": [
@@ -9521,7 +9521,7 @@
}
}
},
"description": "Maximum body 16 KiB. Staging by default. Production requires explicit consent. Provider fields depend on the selected provider; see docs/ACME_PROVIDERS.md. Poll GET /api/acme.",
"description": "Maximum body 16 KiB. Staging by default. Production requires explicit consent. Provider fields depend on the selected provider; see docs/CERTIFICATES.md. Poll GET /api/acme.",
"parameters": [
{
"name": "Origin",
+1 -2
View File
@@ -1043,8 +1043,7 @@ mod tests {
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// GET returns the same standalone section shape accepted by PUT, at the
// matching revision and without unrequested configuration fields.
// GET returns the same section shape that PUT accepts.
let read = router
.clone()
.oneshot(
+1 -2
View File
@@ -21,8 +21,7 @@ function syncConfigHistoryRestoreButton() {
button.hidden = configHistorySelectedRevision === null;
button.disabled = busy || !envelope || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision);
}
// Compare leaf fields, not serialized object/array blobs. Object IDs are used
// for stable array paths so reordering vhosts does not become a huge fake diff.
// Leaf-level diff keyed by object IDs, so reordering is not a change.
function configDiff(before, after, path = "$", output = [], depth = 0) {
if (output.length >= 200 || Object.is(before, after)) return output;
if (depth >= 24) {
+1 -3
View File
@@ -144,9 +144,7 @@ window.rustProxyDashboardLayout = (() => {
widen.addEventListener("click", () => resize(id, 1));
const widthValue = el("span", `${width}/12`, "overview-layout-width-value");
widthValue.title = t("overviewLayoutWidth");
// The visual editor intentionally keeps uniformly sized previews.
// Group sizing separately from ordering/hiding so both sets wrap
// inside a narrow dialog rather than spilling into adjacent tiles.
// Size and order controls are grouped separately so they wrap in narrow dialogs.
const sizeActions = el("span", undefined, "overview-layout-size-actions");
sizeActions.append(narrow, widthValue, widen);
const orderActions = el("span", undefined, "overview-layout-order-actions");
+5 -3
View File
@@ -1,6 +1,4 @@
// One continuity policy for all browser-side counter-derived rates.
// Background tabs cannot supply a valid instantaneous rate for an interval
// spanning the pause. Keep the plotted history and rebase on the next sample.
// Shared rate sampling: rebase after background-tab pauses instead of plotting a false spike.
const rustProxyRateSampling = (() => {
let generation = 0;
const pause = () => { generation += 1; };
@@ -19,6 +17,10 @@ const rustProxyRateSampling = (() => {
at - previous.at <= Math.max(10000, seconds * 3000));
}
function difference(previous, values, at, intervalSeconds = 3) {
if (previous && previous.generation === generation && Number.isFinite(at) &&
Number.isFinite(previous.at) && at >= previous.at && at - previous.at < 250) {
return { next: previous, deltas: null, seconds: null, rapid: true };
}
const next = { ...stamp(at), values: [...values] };
if (!continuous(previous, at, intervalSeconds) ||
values.some((value, i) => !Number.isFinite(value) || value < 0 ||
+1 -3
View File
@@ -1,6 +1,4 @@
/* Theme palettes and appearance tokens. Runtime preference variables remain writable by JavaScript.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Theme palettes and appearance tokens */
:root {
color-scheme: dark;
+39 -8
View File
@@ -1,6 +1,4 @@
/* Shared reset, typography, controls, forms, tables, cards and overlay primitives.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Shared reset, typography, controls, forms, tables, cards and overlay primitives */
* {
box-sizing: border-box;
@@ -1304,8 +1302,9 @@ body[data-density=compact] .page-footer {
}
.about-dialog {
width: calc(min(560px, 100vw - 24px));
overflow: hidden;
width: calc(min(640px, 100vw - 24px));
max-height: calc(100vh - 24px);
overflow: auto;
}
.about-dialog .window-heading button {
@@ -1370,6 +1369,38 @@ body[data-density=compact] .page-footer {
line-height: 1.65;
}
.about-license {
display: grid;
gap: 8px;
padding: 12px 14px;
border: 1px solid var(--line);
background: var(--panel);
}
.about-license h3 {
margin: 0;
}
.about-license p {
margin: 0;
line-height: 1.6;
}
.about-license ul {
display: grid;
gap: 4px;
margin: 0;
padding-inline-start: 18px;
color: var(--muted);
line-height: 1.5;
}
.about-links {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
}
.about-repository {
justify-content: space-between;
gap: 12px;
@@ -1381,7 +1412,9 @@ body[data-density=compact] .page-footer {
.about-repository span:last-child {
color: var(--muted);
font: 10px var(--mono);
overflow-wrap: anywhere;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
html[data-table-density=compact] :is(table th, table td) {
@@ -1479,7 +1512,6 @@ html[data-table-density=ultra] :is(table th, table td) {
align-items: center;
}
/* Keep generated ACL instructions in the original metrics-example code style. */
.path-permissions {
min-width: 0;
}
@@ -1506,7 +1538,6 @@ html[data-table-density=ultra] :is(table th, table td) {
max-width: 260px;
}
/* Permission guidance belongs to its generated command block, not the path inputs. */
#certificate-external-acl-example.path-permissions {
margin-top: 20px;
border-top: 1px solid var(--line);
+1 -3
View File
@@ -1,6 +1,4 @@
/* Application startup, authentication, navigation, page chrome and draft dock.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Application startup, authentication, navigation, page chrome and draft dock */
html.app-loading .shell {
visibility: hidden;
+2 -5
View File
@@ -1,6 +1,4 @@
/* Overview metrics, charts, dashboard density, inline layout controls and presets.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Overview metrics, charts, dashboard density, inline layout controls and presets */
html[data-theme=ultra_dark] :is(.card, .stat, dialog) {
box-shadow: 0 6px 24px rgba(0, 0, 0, .18);
@@ -76,8 +74,7 @@ html[data-theme=cream] :is(.card, .stat, dialog) {
stroke-dasharray: var(--chart-grid-dash, 2 5);
}
/* Optional X/Y scale readouts do not change SVG coordinates or chart ranges.
* Labels are HTML, not distorted SVG text under preserveAspectRatio="none". */
/* Axis labels are HTML overlays, not stretched SVG text. */
.rp-chart-axis-host {
position: relative;
display: flow-root;
+1 -3
View File
@@ -1,6 +1,4 @@
/* Shared service editors: Vhosts, backends, Web Server, Streams, routing and ACL pickers.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Shared service editors: Vhosts, backends, Web Server, Streams, routing and ACL pickers */
.host-chip-editor.field-invalid,
.listener-choices.field-invalid {
+2 -4
View File
@@ -1,6 +1,4 @@
/* Settings, certificates, integrations and Docker agent/service management.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Settings, certificates, integrations and Docker agent/service management */
.settings-tabs button.has-unsaved:after {
content: "";
@@ -1205,7 +1203,7 @@ html[data-dashboard-density=normal] #panel-overview .stat>span,
width: 100%;
min-width: 0;
}
/* 0.34.13: global security policy forms, grouped with clear control boundaries. */
/* Global security policy forms */
.global-policy-form .global-policy-section {
display: grid;
gap: 10px;
+1 -3
View File
@@ -1,6 +1,4 @@
/* Traffic, cache, transformations, live/stored logs and configuration history.
* Order is defined in manifest.json; responsive rules are centralized in 70-responsive.css.
*/
/* Traffic, cache, transformations, live/stored logs and configuration history */
.certificate-card .pill,
.cache-miss-key,
+3 -5
View File
@@ -1,6 +1,4 @@
/* Responsive rules. Each viewport, container and reduced-motion condition is declared once.
* Historical overrides were resolved before consolidation; preserve the documented order.
*/
/* Responsive rules */
@media (min-width:1201px) {
#panel-overview .stats-grid {
@@ -1052,7 +1050,8 @@
}
.named-listener-row .vh-grid,
.about-meta {
.about-meta,
.about-links {
grid-template-columns: 1fr;
}
@@ -1388,7 +1387,6 @@
letter-spacing: -2px;
}
/* The standalone auth heading keeps its later, unconditional font size. */
.login-section h1:where(:not(.auth-main h1)) {
font-size: 40px;
}
-6
View File
@@ -91,9 +91,3 @@ When replacing a previously built checkout with a source ZIP, include the root
the [generated-CSS recovery steps](../../../../scripts/README.md#missing-generated-css-during-rust-compilation).
Source preflight does not generate Cargo outputs. The build tracks the generated
bundle as well as its inputs, and rejects a missing or empty output.
The optional [dashboard browser regression runner](../../../../tools/dev/README-dashboard-css.md)
assembles the actual HTML and JavaScript against synthetic API fixtures and
compares a supplied baseline stylesheet with the built candidate. It includes
breakpoint/container boundaries and dynamic editor states. Its dependencies are
development-only; they are not part of the application build or deployment.
+34 -3
View File
@@ -1,4 +1,4 @@
/* A deliberately self-contained documentation layout. */
/* Documentation Reader layout. */
* {
box-sizing: border-box
}
@@ -325,6 +325,38 @@ main {
color: #52606d
}
.reader-table {
overflow-x: auto;
margin: 14px 0
}
.reader-table table {
border-collapse: collapse;
width: 100%;
font-size: 14px
}
.reader-table :is(th, td) {
text-align: left;
vertical-align: top;
padding: 7px 10px;
border: 1px solid #e0e5ea;
line-height: 1.5
}
.reader-table th {
background: #f4f6f8
}
.reader-image {
display: block;
margin: 12px 0;
padding: 10px 12px;
border: 1px dashed #c9d1d9;
color: #52606d;
font-size: 13px
}
.reader-empty {
color: #687581;
padding: 10px 8px
@@ -344,8 +376,7 @@ main {
padding: 18px
}
}
/* Authenticated documentation uses the same stored corner preference as the
main panel, while retaining its original flat shape for "Original". */
/* Corner style follows the panel preference. */
html[data-corner-style=square] {
--rp-docs-radius-surface: 0px;
--rp-docs-radius-control: 0px;
+23 -2
View File
@@ -10,6 +10,7 @@
let documents = [], docsByPath = new Map(), currentDocument = null, activeTab = "reader";
let channel = null; try { channel = new BroadcastChannel("rustproxy-admin-session"); } catch { }
const t = key => translations[key] ?? fallback[key] ?? key;
const REPOSITORY_RAW = "https://git.linuxiarz.pl/gru/RustProxy/raw/branch/master";
function docsConfirm(message) {
return new Promise(resolve => {
@@ -78,12 +79,22 @@
}
function appendInline(parent, text, basePath) {
const pattern = /(`[^`]+`|\[[^\]]+\]\([^)]+\))/g; let offset = 0;
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|!?\[[^\]]*\]\([^)]+\))/g; let offset = 0;
for (const match of text.matchAll(pattern)) {
if (match.index > offset) parent.append(document.createTextNode(text.slice(offset, match.index)));
const token = match[0];
if (token.startsWith("`")) {
const code = document.createElement("code"); code.textContent = token.slice(1, -1); parent.append(code);
} else if (token.startsWith("**")) {
const strong = document.createElement("strong"); appendInline(strong, token.slice(2, -2), basePath); parent.append(strong);
} else if (token.startsWith("!")) {
const parsed = /^!\[([^\]]*)\]\(([^)]+)\)$/.exec(token);
const caption = document.createElement("a"); caption.className = "reader-image";
caption.textContent = `${t("docsImage")}: ${parsed[1] || parsed[2]}`;
const target = parsed[2].trim();
caption.href = /^https?:\/\//i.test(target) ? target : `${REPOSITORY_RAW}/${normalizeDocPath(basePath, target)}`;
caption.target = "_blank"; caption.rel = "noreferrer";
parent.append(caption);
} else {
const parsed = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); const anchor = document.createElement("a");
anchor.textContent = parsed[1]; const target = parsed[2].trim();
@@ -106,7 +117,7 @@
function renderMarkdown(documentInfo) {
const target = element("docs-reader-content"); target.replaceChildren();
const lines = documentInfo.content.replaceAll("\r\n", "\n").split("\n");
let code = null, list = null, paragraph = [];
let code = null, list = null, table = null, paragraph = [];
const flushParagraph = () => { if (!paragraph.length) return; const p = document.createElement("p"); appendInline(p, paragraph.join(" ").trim(), documentInfo.path); target.append(p); paragraph = []; };
const closeList = () => { list = null; };
for (const line of lines) {
@@ -117,6 +128,16 @@
continue;
}
if (code) { code.lines.push(line); code.node.textContent = code.lines.join("\n"); continue; }
if (/^\s*\|.*\|\s*$/.test(line)) {
flushParagraph(); closeList();
const cells = line.trim().slice(1, -1).split("|").map(cell => cell.trim());
if (cells.every(cell => /^:?-{2,}:?$/.test(cell))) continue;
if (!table) { table = document.createElement("table"); const wrap = document.createElement("div"); wrap.className = "reader-table"; wrap.append(table); target.append(wrap); }
const row = document.createElement("tr");
for (const cell of cells) { const node = document.createElement(table.rows.length ? "td" : "th"); appendInline(node, cell, documentInfo.path); row.append(node); }
table.append(row); continue;
}
table = null;
const heading = /^(#{1,4})\s+(.+)$/.exec(line);
if (heading) { flushParagraph(); closeList(); const h = document.createElement(`h${heading[1].length}`); appendInline(h, heading[2], documentInfo.path); target.append(h); continue; }
const bullet = /^\s*[-*]\s+(.+)$/.exec(line), ordered = /^\s*\d+\.\s+(.+)$/.exec(line);
+39 -11
View File
@@ -3143,7 +3143,7 @@
<p class="fine" data-i18n="tlsPqcHelp">TLS 1.3 only: prioritize hybrid
X25519MLKEM768 with classic key exchanges as fallback. This is a key exchange
group, not a cipher suite. Disabled by default.</p>
<a href="/docs#doc=docs%2FTLS_POST_QUANTUM.md" target="_blank"
<a href="/docs#doc=docs%2FTRANSPORT.md" target="_blank"
rel="noopener noreferrer" data-i18n="tlsPqcDocs">Learn about PQC, compatibility
and testing (documentation)</a>
</div>
@@ -4300,24 +4300,52 @@ log_errors = On</code></pre>
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>__RUSTPROXY_VERSION__</strong></div>
</div>
<p data-i18n="aboutTagline">Reverse proxy, web server, certificate manager and traffic monitor in a single
service with a built-in administration panel.</p>
<dl class="about-meta">
<div>
<dt data-i18n="aboutAuthor">Author</dt>
<dd>Mateusz Gruszczynski @linuxiarz.pl</dd>
<dd>Mateusz Gruszczyński · linuxiarz.pl</dd>
</div>
<div>
<dt data-i18n="aboutLicense">License</dt>
<dd data-i18n="aboutLicenseValue">GNU AGPL v3</dd>
<dd data-i18n="aboutLicenseValue">GNU AGPL v3.0 or later</dd>
</div>
<div>
<dt data-i18n="aboutCopyright">Copyright</dt>
<dd>© 2026 Mateusz Gruszczyński</dd>
</div>
<div>
<dt data-i18n="aboutSpdx">SPDX identifier</dt>
<dd><code>AGPL-3.0-or-later</code></dd>
</div>
</dl>
<p data-i18n="aboutOpenSourceText">RustProxy is an open-source project. Its source code is publicly
available for inspection, development and community contribution.</p>
<a class="outline about-repository" href="https://git.linuxiarz.pl/gru/RustProxy" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutRepository">Official source
repository</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
<a class="outline about-repository" href="https://rustproxy.linuxiarz.pl/" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutPackagesRepository">Official package
repository</span><span>rustproxy.linuxiarz.pl</span></a>
<section class="about-license" aria-labelledby="about-license-title">
<h3 class="small-label" data-i18n="aboutLicenseTitle" id="about-license-title">License summary</h3>
<p data-i18n="aboutOpenSourceText">RustProxy is free and open-source software. You may use, study,
modify and redistribute it under the terms of the GNU Affero General Public License, version 3
or any later version.</p>
<ul>
<li data-i18n="aboutLicensePointUse">Use it for any purpose, including commercial deployments.</li>
<li data-i18n="aboutLicensePointShare">Distributed copies and modified versions must stay under the
same license and include their source code.</li>
<li data-i18n="aboutLicensePointNetwork">If you run a modified version as a network service, its
users must be able to obtain the modified source code.</li>
<li data-i18n="aboutLicensePointWarranty">The software is provided without any warranty.</li>
</ul>
</section>
<div class="about-links">
<a class="outline about-repository" href="https://rustproxy.linuxiarz.pl/" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutPackagesRepository">Website and packages</span><span>rustproxy.linuxiarz.pl</span></a>
<a class="outline about-repository" href="https://git.linuxiarz.pl/gru/RustProxy"
rel="noopener noreferrer" target="_blank"><span data-i18n="aboutRepository">Source
code</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
<a class="outline about-repository" href="/docs#doc=docs%2FREADME.md" rel="noopener"
target="_blank"><span data-i18n="aboutDocumentation">Documentation and API
reference</span><span>/docs</span></a>
<a class="outline about-repository" href="/docs#doc=LICENSE" rel="noopener" target="_blank"><span
data-i18n="aboutLicenseText">Full license text</span><span>LICENSE</span></a>
</div>
</article>
</dialog>
<div aria-live="polite" class="toast" hidden="" id="toast" popover="manual" role="status"></div>
+4 -11
View File
@@ -24,7 +24,6 @@ const group = (key, advanced = false) => {
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
};
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
// Presentation-only subgroup: all existing inputs remain inside their original form.
const subsection = (parent, key) => {
const box = el("section", undefined, "vh-wide vh-setting-section");
box.append(text("h3", key));
@@ -215,8 +214,7 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
// Preserve Host is a request-authority choice, not an HTTP/1.1-only option.
// The Rust forwarder keeps HTTP/2 :authority consistent with this value.
// Preserve Host also applies to HTTP/2 :authority.
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
@@ -230,8 +228,7 @@ function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`,
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
note(extra, "vhBackendCapacityHelp");
// The per-member settings live in the Health tab. Keep one persistent form
// per backend, so switching the selection never discards unsaved edits.
// One persistent form per backend keeps unsaved edits when switching.
const healthPanel = el("section", undefined, "vh-grid vh-backend-health-panel");
const customHealth = field(healthPanel, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
const health = value.health_check || {};
@@ -305,9 +302,7 @@ function debugOriginIssues(members) {
let parsed; try { parsed = new URL(member.url); } catch { return false; }
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
if (parsed.protocol !== "https:" || !ipHost) return false;
// The troubleshooting preset is OPTIONAL, not a protocol compatibility rule.
// Warn only when IP-host TLS verification may need an explicit identity;
// HTTP/1.1, Auto and H2 plus preserve_host are all valid configurations.
// Warn only when TLS verification of an IP host needs an explicit server name.
return member.tls_verify === true && !String(member.tls_server_name || "").trim();
});
}
@@ -498,9 +493,7 @@ function locationsEditor(parent, values) {
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
rows.append(row); syncUi();
}
// The main vhost backend is an internal fallback, not a Custom location.
// An empty location list must stay empty so the editor cannot accidentally
// delete or recreate the main route as user configuration.
// The main backend is an internal fallback, never an editable location.
for (const value of values) add(value);
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
+3
View File
@@ -516,6 +516,7 @@ function renderStatus(status) {
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
measured: Boolean(metrics) || Boolean(existing.measured),
});
}
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
@@ -533,8 +534,10 @@ function renderStatus(status) {
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
const now = performance.now();
for (const [id, data] of aggregate) {
if (!data.measured) continue;
const history = chartHistory.get(id) || { last: null, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
const sample = rustProxyRateSampling.difference(history.last, [data.requests], now, rustProxyRateSampling.cadence("overview_seconds"));
if (sample.rapid) continue;
const pausedRate = history.last !== null && sample.deltas === null;
history.last = sample.next;
const rate = sample.deltas ? sample.deltas[0] / sample.seconds : 0;