This commit is contained in:
Mateusz Gruszczyński committed 2026-10-13 00:00:06 +02:00
1 parent d23fa5e154
commit 81ab39e70a
51 files changed
+5352 -1489

No files matched your search

+270 -10
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.31.9",
"version": "0.32.0",
"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."
},
"servers": [
@@ -1521,7 +1521,7 @@
},
"upstream": {
"type": "string",
"description": "Single raw upstream as IP:port."
"description": "Legacy single TCP/UDP upstream (IP:port); used only when backends is empty. An omitted field gets the legacy model default."
},
"connect_timeout_seconds": {
"type": "integer",
@@ -1537,13 +1537,34 @@
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"backends": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/components/schemas/StreamBackend"
},
"description": "Optional backend pool. Empty retains legacy upstream as primary."
},
"balance": {
"type": "string",
"enum": [
"round_robin",
"least_connections",
"least_sessions"
],
"default": "round_robin"
},
"metrics_enabled": {
"type": "boolean",
"nullable": true,
"description": "Optional default for per-backend detailed accounting. Null defaults off; basic Stream counters remain active and Prometheus include_streams remains authoritative."
}
},
"required": [
"id",
"protocol",
"listen",
"upstream"
"listen"
],
"additionalProperties": false,
"description": "Layer-4 stream host. It bypasses the HTTP vhost/ACL/cache/header/compression pipeline."
@@ -1595,6 +1616,13 @@
"type": "integer",
"format": "int64",
"minimum": 0
},
"backends": {
"type": "array",
"items": {
"$ref": "#/components/schemas/StreamBackendMetrics"
},
"description": "Runtime backend states; detail metrics are null when disabled."
}
},
"additionalProperties": false,
@@ -3350,7 +3378,7 @@
"dashboard_widgets": {
"type": "array",
"minItems": 1,
"maxItems": 12,
"maxItems": 16,
"uniqueItems": true,
"items": {
"type": "string",
@@ -3366,7 +3394,11 @@
"http3",
"tls",
"http_protocol",
"websocket_protocol"
"websocket_protocol",
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health"
]
},
"default": [
@@ -3381,13 +3413,17 @@
"http3",
"tls",
"http_protocol",
"websocket_protocol"
"websocket_protocol",
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health"
],
"description": "Account-scoped order for configurable Overview telemetry cards."
},
"dashboard_hidden_widgets": {
"type": "array",
"maxItems": 12,
"maxItems": 16,
"uniqueItems": true,
"items": {
"type": "string",
@@ -3403,10 +3439,19 @@
"http3",
"tls",
"http_protocol",
"websocket_protocol"
"websocket_protocol",
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health"
]
},
"default": [],
"default": [
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health"
],
"description": "Account-scoped Overview telemetry cards hidden from the dashboard."
},
"backend_table_columns": {
@@ -6116,6 +6161,221 @@
"credential",
"token"
]
},
"StreamBackend": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"address"
],
"properties": {
"id": {
"type": "string",
"pattern": "^[A-Za-z0-9_-]{1,64}$"
},
"name": {
"type": "string",
"maxLength": 96
},
"address": {
"type": "string",
"description": "Concrete upstream IP:port"
},
"enabled": {
"type": "boolean",
"default": true
},
"drain": {
"type": "boolean",
"default": false
},
"weight": {
"type": "integer",
"minimum": 1,
"maximum": 16,
"default": 1
},
"max_sessions": {
"type": "integer",
"nullable": true,
"minimum": 1,
"maximum": 65535
},
"health": {
"$ref": "#/components/schemas/StreamHealthCheck"
},
"metrics_enabled": {
"type": "boolean",
"nullable": true,
"description": "Optional detailed backend metrics. Null inherits Stream metrics_enabled (off when unset); an explicit boolean overrides it."
}
}
},
"StreamHealthCheck": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": [
"off",
"tcp_connect",
"udp_probe"
],
"default": "off"
},
"interval_seconds": {
"type": "integer",
"minimum": 1,
"maximum": 3600,
"default": 10
},
"timeout_ms": {
"type": "integer",
"minimum": 50,
"maximum": 30000,
"default": 1000
},
"rise": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 2
},
"fall": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 2
},
"payload_hex": {
"type": "string",
"maxLength": 256,
"description": "At most 128 bytes; sent only when exact reply expected"
},
"expect_hex": {
"type": "string",
"nullable": true,
"maxLength": 256,
"description": "Exact response; without it, UDP stays Unverified and no probe is sent"
}
}
},
"StreamBackendMetrics": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"address": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"drain": {
"type": "boolean"
},
"detail_enabled": {
"type": "boolean"
},
"status": {
"type": "string",
"enum": [
"unverified",
"checking",
"healthy",
"unhealthy",
"disabled"
]
},
"active": {
"type": "integer",
"minimum": 0
},
"sessions_total": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"sessions_completed": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"assignments": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"rejected": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"errors": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"bytes_in": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"bytes_out": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"packets_in": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"packets_out": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"health": {
"type": "object",
"additionalProperties": false,
"properties": {
"last_checked_unix_ms": {
"type": "integer",
"nullable": true
},
"latency_ms": {
"type": "integer",
"nullable": true
},
"last_error": {
"type": "string",
"nullable": true
},
"successful": {
"type": "integer"
},
"failed": {
"type": "integer"
},
"last_result": {
"type": "string",
"nullable": true,
"enum": [
"success",
"failure"
],
"description": "Most recent active probe outcome, null if no probe has completed."
}
}
}
}
}
}
},
+43 -7
View File
@@ -30,8 +30,37 @@ fn default_admin_dashboard_widgets() -> Vec<String> {
.map(|value| (*value).to_owned())
.collect()
}
fn default_file_log_sort() -> String { "time".to_owned() }
fn default_file_log_descending() -> bool { true }
const STREAM_DASHBOARD_WIDGETS: &[&str] = &[
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health",
];
fn default_hidden_dashboard_widgets() -> Vec<String> {
STREAM_DASHBOARD_WIDGETS
.iter()
.map(|id| (*id).to_owned())
.collect()
}
// Old accounts stored a complete 12-card order and often an explicit empty
// hidden list. Upgrade it in memory: append opt-in Stream cards as hidden.
fn migrate_dashboard_widgets(prefs: &mut AdminPreferences) {
for id in STREAM_DASHBOARD_WIDGETS {
if !prefs.dashboard_widgets.iter().any(|item| item == id) {
prefs.dashboard_widgets.push((*id).to_owned());
if !prefs.dashboard_hidden_widgets.iter().any(|item| item == id) {
prefs.dashboard_hidden_widgets.push((*id).to_owned());
}
}
}
}
fn default_file_log_sort() -> String {
"time".to_owned()
}
fn default_file_log_descending() -> bool {
true
}
fn default_file_log_columns() -> Vec<String> {
[
"time",
@@ -97,7 +126,7 @@ struct AdminPreferences {
dashboard_density: String,
#[serde(default = "default_admin_dashboard_widgets")]
dashboard_widgets: Vec<String>,
#[serde(default)]
#[serde(default = "default_hidden_dashboard_widgets")]
dashboard_hidden_widgets: Vec<String>,
#[serde(default = "default_file_log_columns")]
file_log_columns: Vec<String>,
@@ -150,7 +179,7 @@ impl Default for AdminPreferences {
table_density: default_admin_table_density(),
dashboard_density: default_admin_dashboard_density(),
dashboard_widgets: default_admin_dashboard_widgets(),
dashboard_hidden_widgets: Vec::new(),
dashboard_hidden_widgets: default_hidden_dashboard_widgets(),
file_log_columns: default_file_log_columns(),
file_log_sort: default_file_log_sort(),
file_log_descending: default_file_log_descending(),
@@ -388,8 +417,16 @@ async fn preferences(
.map_err(auth::internal)?;
let preferences = match stored {
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
Ok(_) | Err(_) => {
Ok(mut preferences) => {
migrate_dashboard_widgets(&mut preferences);
if validate_admin_preferences(&preferences).is_ok() {
preferences
} else {
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
AdminPreferences::default()
}
}
Err(_) => {
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
AdminPreferences::default()
}
@@ -420,4 +457,3 @@ async fn update_preferences(
}
Ok(Json(preferences))
}
+4 -1
View File
@@ -88,5 +88,8 @@ const DASHBOARD_WIDGETS: &[&str] = &[
"tls",
"http_protocol",
"websocket_protocol",
"stream_overview",
"stream_traffic",
"stream_sessions",
"stream_backend_health",
];
+13 -10
View File
@@ -1,7 +1,7 @@
mod documentation;
mod public;
mod panel_content;
mod panel_assets;
mod panel_content;
mod public;
// Embedded asset URLs change with every build, independent of Cargo package version.
// Only an exact identifier from this binary is accepted; arbitrary suffixes are 404.
@@ -11,9 +11,13 @@ fn asset_build_id() -> &'static str {
fn fingerprinted_path(path: &str, prefix: &str) -> Option<String> {
let name = path.strip_prefix(prefix)?;
let (stem, extension) = name.rsplit_once('.')?;
if extension != "js" && extension != "css" { return None; }
if extension != "js" && extension != "css" {
return None;
}
let (base, token) = stem.rsplit_once('_')?;
if token != asset_build_id() { return None; }
if token != asset_build_id() {
return None;
}
Some(format!("{prefix}{base}.{extension}"))
}
@@ -53,6 +57,7 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/app/session-resume.js"),
include_str!("web/app/refresh.js"),
include_str!("web/app/preferences.js"),
include_str!("web/app/stream-dashboard.js"),
include_str!("web/app/config-history.js"),
include_str!("web/app/runtime.js"),
include_str!("web/app/settings.js"),
@@ -306,14 +311,12 @@ fn panel_document_path(path: &str) -> bool {
fn anonymous_allowed(method: &Method, path: &str) -> bool {
if method == Method::GET || method == Method::HEAD {
panel_document_path(path)
|| fingerprinted_path(path, "/assets/")
.is_some_and(|base| matches!(base.as_str(), "/assets/login.js" | "/assets/style.css"))
|| fingerprinted_path(path, "/assets/").is_some_and(|base| {
matches!(base.as_str(), "/assets/login.js" | "/assets/style.css")
})
|| matches!(
path,
"/assets/login-langs.json"
| "/favicon.svg"
| "/healthz"
| "/api/auth/status"
"/assets/login-langs.json" | "/favicon.svg" | "/healthz" | "/api/auth/status"
)
} else {
method == Method::POST && matches!(path, "/api/auth/setup" | "/api/auth/login")
+1010 -483
View File
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -59,7 +59,7 @@ function translate() {
window.rustProxyDrafts?.refresh();
window.rustProxyPreferences?.refreshAppearanceUi?.();
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); } if (lastLogs) renderLogs(lastLogs);
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); window.rustProxyStreamDashboard?.render(latestStatus); } if (lastLogs) renderLogs(lastLogs);
syncSettingsDirtyIndicators();
}
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
@@ -160,6 +160,10 @@ function setPanel(name, navigate = true) {
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
if (name === "settings") window.rustProxySettings?.syncLocation?.();
window.rustProxyRefresh?.setPanel(name);
// Paint Stream tiles immediately on return from Appearance using cached status.
// Do not create an extra sample/poll on navigation.
if (name === "overview" && previousPanel !== name && typeof latestStatus !== "undefined" && latestStatus)
window.rustProxyStreamDashboard?.render?.(latestStatus, null, false);
// Read the cached Docker status on each visit, independent of the Live-view toggle.
// This is GET /api/docker, not the POST that triggers an agent poll.
if (name === "docker-vhosts" && previousPanel !== name && authenticated && envelope) void window.rustProxyRefresh?.manual("docker");
@@ -262,7 +266,7 @@ function renderStatus(s, systemSample = false) {
$("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5));
$("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5));
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s);
renderFooter(s); renderDiagnostics(s, true, systemSample); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s); window.rustProxyStreamDashboard?.render(s);
}
function renderFooter(s) {
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
@@ -370,7 +374,7 @@ function authReturnDestination() {
function lock(notify = true) {
window.rustProxyRefresh?.stop();
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; $("logs-menu").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); window.rustProxyFileLogs?.reset(); window.rustProxyDrafts?.reset(); $("logs-open").hidden = true; $("file-logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); window.rustProxyStreamDashboard?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
$("workspace").querySelectorAll("form").forEach(form => form.reset());
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
+29 -8
View File
@@ -3,10 +3,10 @@ const ADMIN_TIME_FORMATS = new Set(["24h", "12h"]);
const ADMIN_CHART_STYLES = new Set(["default", "soft", "technical", "high_contrast", "neon", "monochrome", "warm", "cool"]);
const ADMIN_DENSITIES = new Set(["normal", "compact", "ultra"]);
const ADMIN_BUTTON_STYLES = new Set(["normal", "compact"]);
const ADMIN_DASHBOARD_WIDGETS = Object.freeze(["requests", "active", "bandwidth", "errors", "cache", "memory", "websocket", "http2", "http3", "tls", "http_protocol", "websocket_protocol"]);
const ADMIN_DASHBOARD_WIDGETS = Object.freeze(["requests", "active", "bandwidth", "errors", "cache", "memory", "websocket", "http2", "http3", "tls", "http_protocol", "websocket_protocol", "stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"]);
const ADMIN_APPEARANCE_DEFAULTS = Object.freeze({
theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", chart_style: "default",
button_style: "normal", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [],
button_style: "normal", table_density: "normal", dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: ["stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"],
});
const CHART_STYLE_KEYS = Object.freeze({ default: "chartStyleDefault", soft: "chartStyleSoft", technical: "chartStyleTechnical", high_contrast: "chartStyleHighContrast", neon: "chartStyleNeon", monochrome: "chartStyleMonochrome", warm: "chartStyleWarm", cool: "chartStyleCool" });
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve(), adminPreferencesUnavailable = false;
@@ -24,9 +24,14 @@ function normalizeDashboardWidgets(value) {
for (const id of ADMIN_DASHBOARD_WIDGETS) if (!seen.has(id)) result.push(id);
return result;
}
function normalizeHiddenDashboardWidgets(value) {
function normalizeHiddenDashboardWidgets(value, savedOrder = ADMIN_DASHBOARD_WIDGETS) {
const seen = new Set();
return (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
const hidden = (Array.isArray(value) ? value : []).filter(id => ADMIN_DASHBOARD_WIDGETS.includes(id) && !seen.has(id) && seen.add(id));
// Also protect preferences written by 0.31.x with an explicit empty hidden list.
for (const id of ADMIN_DASHBOARD_WIDGETS.filter(id => id.startsWith("stream_"))) {
if (!savedOrder?.includes(id) && !seen.has(id)) { hidden.push(id); seen.add(id); }
}
return hidden;
}
function browserLanguage() {
const packs = [...languagePacks.values()], candidates = (navigator.languages || [navigator.language]).filter(Boolean).map(value => value.replaceAll("_", "-").toLowerCase());
@@ -81,6 +86,11 @@ function applyDashboardLayout(order, hidden) {
const grid = document.querySelector("#panel-overview .stats-grid"); if (!grid) return { order: normalizedOrder, hidden: [...hiddenSet] };
const nodes = new Map([...grid.querySelectorAll("[data-dashboard-widget]")].map(node => [node.dataset.dashboardWidget, node]));
for (const id of normalizedOrder) { const node = nodes.get(id); if (!node) continue; node.hidden = hiddenSet.has(id); grid.append(node); }
// Apply the newly selected visibility, not the previous saved preferences.
// During load, adminPreferences is assigned only after this function returns.
window.rustProxyStreamDashboard?.syncVisibility?.([...hiddenSet]);
if (typeof latestStatus !== "undefined" && latestStatus && currentPanel === "overview")
window.rustProxyStreamDashboard?.render?.(latestStatus, [...hiddenSet], false);
return { order: normalizedOrder, hidden: [...hiddenSet] };
}
function dashboardWidgetLabel(id) {
@@ -94,7 +104,18 @@ function renderDashboardWidgetSettings() {
const row = el("div", undefined, "dashboard-widget-setting"); row.dataset.dashboardWidgetSetting = id;
const visible = !hidden.has(id);
const toggle = el("button", undefined, "dashboard-widget-toggle"); toggle.type = "button"; toggle.classList.toggle("is-hidden", !visible); toggle.setAttribute("aria-pressed", String(visible)); toggle.setAttribute("aria-label", `${t("dashboardWidgetVisible")}: ${dashboardWidgetLabel(id)}`);
toggle.append(el("span", undefined, "dashboard-widget-visibility"), el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
const labelBlock = el("span", undefined, "dashboard-widget-setting-label-block");
labelBlock.append(el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
const descriptionKey = ({ stream_overview: "streamWidgetOverviewDescription", stream_traffic: "streamWidgetTrafficDescription", stream_sessions: "streamWidgetSessionsDescription", stream_backend_health: "streamWidgetHealthDescription" })[id];
if (descriptionKey) {
const description = el("small", t(descriptionKey), "dashboard-widget-description");
description.dataset.i18n = descriptionKey;
description.id = `dashboard-widget-help-${id}`;
description.setAttribute("role", "tooltip");
toggle.setAttribute("aria-describedby", description.id);
labelBlock.append(description);
}
toggle.append(el("span", undefined, "dashboard-widget-visibility"), labelBlock);
const actions = el("span", undefined, "dashboard-widget-setting-actions");
const up = el("button", "↑", "quiet"); up.type = "button"; up.disabled = index === 0; up.setAttribute("aria-label", t("moveUp"));
const down = el("button", "↓", "quiet"); down.type = "button"; down.disabled = index === order.length - 1; down.setAttribute("aria-label", t("moveDown"));
@@ -127,7 +148,7 @@ function formatDateTime(value) { const date = value instanceof Date ? value : ne
function formatTime(value) { const date = value instanceof Date ? value : new Date(value); return date.toLocaleTimeString(locale(), { hour12: (adminPreferences?.time_format || "24h") === "12h" }); }
function applyAdminPreferences(value, cache = true) {
if (!value) return value;
const dashboard = applyDashboardLayout(value.dashboard_widgets, value.dashboard_hidden_widgets);
const dashboard = applyDashboardLayout(value.dashboard_widgets, normalizeHiddenDashboardWidgets(value.dashboard_hidden_widgets, value.dashboard_widgets));
const normalized = {
...value,
refresh: normalizeRefreshPreferences(value.refresh),
@@ -279,9 +300,9 @@ async function resetAppearancePatch(patch, control, messageKey = "appearanceSect
document.querySelectorAll("[data-appearance-reset]").forEach(control => control.addEventListener("click", () => {
const patches = {
interface: { theme: "dark", language: "auto", ui_scale: 100, time_format: "24h", button_style: "normal" }, charts: { chart_style: "default" }, tables: { table_density: "normal" },
dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [] },
dashboard: { dashboard_density: "compact", dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets] },
};
void resetAppearancePatch(patches[control.dataset.appearanceReset] || {}, control);
}));
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [] }, $("appearance-reset"), "appearanceResetDone"));
$("appearance-reset")?.addEventListener("click", () => void resetAppearancePatch({ ...ADMIN_APPEARANCE_DEFAULTS, dashboard_widgets: [...ADMIN_DASHBOARD_WIDGETS], dashboard_hidden_widgets: [...ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets] }, $("appearance-reset"), "appearanceResetDone"));
refreshAppearanceUi();
+114
View File
@@ -0,0 +1,114 @@
// Four opt-in cards in the EXISTING dashboard widget order and status refresh.
// No timers, fetches, or independent persistence are created here.
window.rustProxyStreamDashboard = (() => {
const ids = ["stream_overview", "stream_traffic", "stream_sessions", "stream_backend_health"];
const history = { last: null, rx: [], tx: [] };
let wasVisible = false;
const card = id => document.querySelector(`[data-dashboard-widget="${id}"]`);
const set = (id, value) => { const node = $(id); if (node) node.textContent = value; };
const trafficRates = (rx, tx) => {
set("stream-dashboard-rx-rate", rx);
set("stream-dashboard-tx-rate", tx);
};
function clearTraffic() {
history.last = null; history.rx.length = 0; history.tx.length = 0;
$("stream-dashboard-rx")?.setAttribute("points", "0,55 300,55");
$("stream-dashboard-tx")?.setAttribute("points", "0,55 300,55");
}
function syncVisibility(hiddenOverride = null) {
const prefs = window.rustProxyPreferences?.current?.();
const hidden = new Set(hiddenOverride ?? prefs?.dashboard_hidden_widgets ?? ADMIN_APPEARANCE_DEFAULTS.dashboard_hidden_widgets);
// Visibility belongs exclusively to Appearance. An absent, disabled or
// restart-pending Stream must not silently override an explicit opt-in.
for (const id of ids) if (card(id)) card(id).hidden = hidden.has(id);
if (card("stream_traffic")?.hidden && wasVisible) clearTraffic();
wasVisible = !card("stream_traffic")?.hidden;
}
function line(points) {
const peak = Math.max(1, ...history.rx, ...history.tx);
return points.map((value, i) => `${i * 300 / Math.max(1, points.length - 1)},${55 - value / peak * 50}`).join(" ");
}
function render(status, hiddenOverride = null, recordTrafficSample = true) {
syncVisibility(hiddenOverride);
if (!status || currentPanel !== "overview" || ids.every(id => card(id)?.hidden)) return;
const configured = envelope?.config?.streams || status?.streams?.desired || [];
const active = (status?.streams?.active || []).filter(item => item.enabled !== false);
const metrics = status?.streams?.metrics || {};
if (!configured.some(item => item.enabled !== false) && !active.length) {
// A deliberately shown tile has a neutral empty state, not a
// hidden DOM node. No history is sampled without running streams.
for (const name of ["overview", "sessions", "health", "traffic"]) {
if (!card(({ overview: "stream_overview", sessions: "stream_sessions", health: "stream_backend_health", traffic: "stream_traffic" })[name])?.hidden) {
set(`stream-dashboard-${name}`, "-");
if (name === "traffic") trafficRates("-", "-");
else set(`stream-dashboard-${name}-detail`, t("streamWidgetUnavailable"));
}
}
if (!card("stream_traffic")?.hidden) clearTraffic();
return;
}
if (!card("stream_overview")?.hidden) {
const desired = configured.filter(item => item.enabled !== false);
const attention = desired.filter(item => !active.some(a => a.id === item.id)).length +
active.filter(item => (metrics[item.id]?.backends || []).length &&
!(metrics[item.id].backends || []).some(b => b.enabled && !b.drain && b.status !== "unhealthy")).length;
set("stream-dashboard-overview", `${active.length} / ${desired.length}`);
set("stream-dashboard-overview-detail", `${t("streamWidgetActiveConfigured")} · ${attention} ${t("streamWidgetAttention")}`);
}
if (!card("stream_sessions")?.hidden) {
const sum = protocol => active.filter(item => item.protocol === protocol)
.reduce((count, item) => count + (Number(metrics[item.id]?.active) || 0), 0);
const tcp = sum("tcp"), udp = sum("udp");
set("stream-dashboard-sessions", fmt(tcp + udp));
set("stream-dashboard-sessions-detail", `TCP ${fmt(tcp)} · UDP ${fmt(udp)}`);
}
if (!card("stream_backend_health")?.hidden) {
const backends = active.flatMap(item => metrics[item.id]?.backends || []);
const healthy = backends.filter(b => b.status === "healthy").length;
const unhealthy = backends.filter(b => b.status === "unhealthy").length;
const unverified = backends.filter(b => b.status === "unverified" || b.status === "checking").length;
const emptyPools = active.filter(item => (metrics[item.id]?.backends || []).length > 0 &&
!(metrics[item.id].backends || []).some(b => b.enabled && !b.drain && b.status !== "unhealthy")).length;
set("stream-dashboard-health", `${healthy} / ${unhealthy}`);
set("stream-dashboard-health-detail", `${t("streamWidgetHealthyUnhealthy")} · ${unverified} ${t("streamWidgetUnverified")} · ${emptyPools} ${t("streamWidgetEmptyPools")}`);
}
if (card("stream_traffic")?.hidden) return;
const views = active.map(item => metrics[item.id]).filter(Boolean);
if (!views.length) {
set("stream-dashboard-traffic", t("streamWidgetUnavailable"));
trafficRates("-", "-");
return;
}
// Switching tabs or appearance preferences paints cached numbers without
// generating an extra rate sample from the same status response.
if (!recordTrafficSample) {
if (history.rx.length) {
const rx = history.rx.at(-1), tx = history.tx.at(-1);
set("stream-dashboard-traffic", `${bytes(Math.round(rx))}/s`);
trafficRates(`${bytes(Math.round(rx))}/s`, `${bytes(Math.round(tx))}/s`);
} else {
set("stream-dashboard-traffic", "-");
trafficRates("-", "-");
}
return;
}
const rxTotal = views.reduce((sum, view) => sum + (Number(view.bytes_in) || 0), 0);
const txTotal = views.reduce((sum, view) => sum + (Number(view.bytes_out) || 0), 0);
const now = performance.now();
let rx = 0, tx = 0;
if (history.last) {
const seconds = Math.max(.001, (now - history.last.when) / 1000);
if (rxTotal >= history.last.rx) rx = (rxTotal - history.last.rx) / seconds;
if (txTotal >= history.last.tx) tx = (txTotal - history.last.tx) / seconds;
}
history.last = { when: now, rx: rxTotal, tx: txTotal };
history.rx.push(rx); history.tx.push(tx);
if (history.rx.length > 60) { history.rx.shift(); history.tx.shift(); }
set("stream-dashboard-traffic", `${bytes(Math.round(rx))}/s`);
trafficRates(`${bytes(Math.round(rx))}/s`, `${bytes(Math.round(tx))}/s`);
$("stream-dashboard-rx")?.setAttribute("points", line(history.rx));
$("stream-dashboard-tx")?.setAttribute("points", line(history.tx));
}
function reset() { clearTraffic(); wasVisible = false; }
return { syncVisibility, render, reset };
})();
+568 -259
View File
File diff suppressed because it is too large. Load diff
+100 -8
View File
@@ -53,6 +53,60 @@
return () => { if (input.value.trim()) commit(); if (!current) throw new Error(t("streamListenRequired")); return current; };
}
const backendDefaults = (index = 0, address = "127.0.0.1:10001") => ({
id: index ? `backend-${index + 1}` : "primary", name: index ? `Backend ${index + 1}` : "Primary",
address, enabled: true, drain: false, weight: 1, max_sessions: null, metrics_enabled: null,
health: { mode: "off", interval_seconds: 10, timeout_ms: 1000, rise: 2, fall: 2, payload_hex: "", expect_hex: null }
});
let backendList = null;
function renderBackendEditor(parent, backend, protocol) {
if (backendList?.children.length >= 32) { toast(t("streamBackendLimit"), true); return; }
const row = el("fieldset", undefined, "stream-backend-row vh-wide"); row.append(el("legend", backend.id || t("streamBackend")));
const controls = el("div", undefined, "vh-grid"); row.append(controls);
field(controls, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
field(controls, "backend-name", "streamBackendName", backend.name || "", "text", { maxLength: 96 });
field(controls, "backend-address", "streamBackendAddress", backend.address, "text", { required: true, maxLength: 128 });
field(controls, "backend-weight", "streamBackendWeight", backend.weight ?? 1, "number", { min: 1, max: 16, required: true });
field(controls, "backend-max", "streamBackendMaxSessions", backend.max_sessions ?? "", "number", { min: 1, max: 65535 });
field(controls, "backend-enabled", "vhEnabled", backend.enabled !== false, "checkbox");
field(controls, "backend-drain", "streamBackendDrain", backend.drain === true, "checkbox");
const detail = backend.metrics_enabled == null ? "inherit" : String(backend.metrics_enabled);
field(controls, "backend-metrics", "streamBackendDetailMetrics", detail, "select", { values: [["inherit", "streamMetricsFromStream"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
const hc = backend.health || backendDefaults().health;
field(controls, "backend-health-mode", "streamHealthMode", hc.mode || "off", "select", { values: [["off", "streamHealthOff"], [protocol === "tcp" ? "tcp_connect" : "udp_probe", protocol === "tcp" ? "streamHealthTcp" : "streamHealthUdp"]] });
field(controls, "backend-health-interval", "streamHealthInterval", hc.interval_seconds ?? 10, "number", { min: 1, max: 3600, required: true });
field(controls, "backend-health-timeout", "streamHealthTimeout", hc.timeout_ms ?? 1000, "number", { min: 50, max: 30000, required: true });
field(controls, "backend-health-rise", "streamHealthRise", hc.rise ?? 2, "number", { min: 1, max: 10, required: true });
field(controls, "backend-health-fall", "streamHealthFall", hc.fall ?? 2, "number", { min: 1, max: 10, required: true });
if (protocol === "udp") {
field(controls, "backend-health-payload", "streamHealthPayload", hc.payload_hex || "", "text", { maxLength: 256 });
field(controls, "backend-health-expected", "streamHealthExpected", hc.expect_hex || "", "text", { maxLength: 256 });
}
const remove = button("remove", () => { if (backendList.children.length > 1) { row.remove(); changed(); } });
row.append(remove); backendList.append(row);
}
const optBool = value => value === "inherit" ? null : value === "true";
function collectBackends() {
const rows = [...backendList.querySelectorAll(".stream-backend-row")];
if (!rows.length) throw new Error(t("streamBackendRequired"));
const get = (row, name) => row.querySelector(`[name="${name}"]`);
const selected = rows.map(row => ({
id: get(row, "backend-id").value.trim(), name: get(row, "backend-name").value.trim(),
address: get(row, "backend-address").value.trim(), weight: Number(get(row, "backend-weight").value),
max_sessions: get(row, "backend-max").value === "" ? null : Number(get(row, "backend-max").value),
enabled: get(row, "backend-enabled").checked, drain: get(row, "backend-drain").checked,
metrics_enabled: optBool(get(row, "backend-metrics").value),
health: {
mode: get(row, "backend-health-mode").value, interval_seconds: Number(get(row, "backend-health-interval").value),
timeout_ms: Number(get(row, "backend-health-timeout").value), rise: Number(get(row, "backend-health-rise").value),
fall: Number(get(row, "backend-health-fall").value),
payload_hex: get(row, "backend-health-payload")?.value.trim() || "",
expect_hex: get(row, "backend-health-expected")?.value.trim() || null
}
}));
if (new Set(selected.map(b => b.id)).size !== selected.length) throw new Error(t("streamBackendDuplicate"));
return selected;
}
let readListen = () => "";
function load(value = defaults(), show = true) {
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
@@ -63,18 +117,41 @@
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
window.rustProxyEnableControl(enabledControl);
const enabledLabel = enabledControl.closest("label");
enabledLabel?.querySelector(".vh-enable-hint")?.remove();
enabledLabel?.classList.add("vh-enable-no-hint");
note(g, "streamsHelp");
g = group("streamNetwork", "network");
readListen = socketChipField(g, value.listen || "0.0.0.0:10000");
field(g, "upstream", "streamUpstream", value.upstream || "127.0.0.1:10001", "text", { required: true, maxLength: 128 });
field(g, "balance", "streamBalance", value.balance || "round_robin", "select", { values: [["round_robin", "streamRoundRobin"], [protocol.value === "tcp" ? "least_connections" : "least_sessions", "streamLeastSessions"]] });
field(g, "stream-metrics", "streamDetailMetrics", value.metrics_enabled == null ? "inherit" : String(value.metrics_enabled), "select", { values: [["inherit", "streamMetricsDefaultOff"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
note(g, "streamMetricsHelp");
const pool = el("div", undefined, "vh-wide stream-backend-pool"); pool.append(text("h3", "streamBackendPool"));
backendList = el("div", undefined, "stream-backend-list"); pool.append(backendList);
const initial = value.backends?.length ? value.backends : [backendDefaults(0, value.upstream || "127.0.0.1:10001")];
for (const backend of initial) renderBackendEditor(backendList, backend, protocol.value);
pool.append(button("streamBackendAdd", () => { renderBackendEditor(backendList, backendDefaults(backendList.children.length), protocol.value); changed(); }));
note(pool, "streamHealthUdpHelp"); g.append(pool);
note(g, "streamDedicatedListenerHelp");
g = group("streamLimits", "limits");
const connectTimeout = field(g, "connect-timeout", "streamConnectTimeout", value.connect_timeout_seconds ?? 5, "number", { min: 1, max: 300, required: true });
field(g, "idle-timeout", "streamIdleTimeout", value.idle_timeout_seconds ?? 300, "number", { min: 1, max: 86400, required: true });
field(g, "max-connections", "streamMaxConnections", value.max_connections ?? 1024, "number", { min: 1, max: 65535, required: true });
const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", sync); sync();
const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", () => {
sync();
// Explicitly reset incompatible health modes when switching transport.
for (const row of backendList.children) {
const mode = row.querySelector('[name="backend-health-mode"]');
if (mode?.value !== "off") mode.value = "off";
}
const currentBackends = collectBackends(); backendList.replaceChildren();
currentBackends.forEach(backend => renderBackendEditor(backendList, backend, protocol.value));
const balance = read("balance"); balance.replaceChildren();
for (const [val, key] of [["round_robin", "streamRoundRobin"], [protocol.value === "tcp" ? "least_connections" : "least_sessions", "streamLeastSessions"]]) { const option = text("option", key); option.value = val; balance.append(option); }
changed();
}); sync();
note(g, "streamLimitsHelp");
if (show) { activateTab("details"); if (!dialog.open) dialog.showModal(); }
$("stream-live-summary").textContent = editing ? t("streamNoLiveMetrics") : "";
@@ -83,18 +160,20 @@
}
function collect() {
const backends = collectBackends();
return {
id: str("id"), name: str("name"), enabled: yes("enabled"), protocol: str("protocol"),
listen: readListen(), upstream: str("upstream"), connect_timeout_seconds: Number(str("connect-timeout") || 5),
listen: readListen(), upstream: backends[0].address, backends, balance: str("balance"),
metrics_enabled: optBool(str("stream-metrics")), connect_timeout_seconds: Number(str("connect-timeout") || 5),
idle_timeout_seconds: num("idle-timeout"), max_connections: num("max-connections")
};
}
function suspendDraft() {
const state = { editing, revision, label: $("stream-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("stream-tabs")), readListen };
const state = { editing, revision, label: $("stream-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("stream-tabs")), readListen, backendList };
const title = read("id")?.value.trim() || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state };
}
function restoreDraft(state) {
window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("stream-tabs"), state.tabs); readListen = state.readListen;
window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("stream-tabs"), state.tabs); readListen = state.readListen; backendList = state.backendList;
editing = state.editing; revision = state.revision; initialized = true; dirty = true; $("stream-editing").textContent = state.label; if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
}
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("stream"); if (dialog.open) dialog.close(); return true; }
@@ -106,7 +185,7 @@
const configuredIds = new Set(configured.map(stream => stream.id));
for (const id of chartHistory.keys()) if (!configuredIds.has(id)) chartHistory.delete(id);
const query = ($("stream-search")?.value || "").trim().toLowerCase();
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream].filter(Boolean).join(" ").toLowerCase().includes(query));
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream, ...(stream.backends || []).flatMap(b => [b.id, b.name, b.address])].filter(Boolean).join(" ").toLowerCase().includes(query));
const active = new Map((latestStatus?.streams?.active || []).map(item => [item.id, item]));
const sortValue = stream => {
const live = active.get(stream.id), same = live && JSON.stringify(live) === JSON.stringify(stream);
@@ -126,8 +205,17 @@
head.append(actions); box.append(head);
const details = el("div", undefined, "vhost-detail-strip");
for (const [key, value] of [["streamProtocol", String(stream.protocol || "tcp").toUpperCase()], ["streamListen", stream.listen], ["streamUpstream", stream.upstream], ["streamIdleShort", `${stream.idle_timeout_seconds}s`], ["streamMaxConnections", String(stream.max_connections)]]) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
for (const [key, value] of [["streamProtocol", String(stream.protocol || "tcp").toUpperCase()], ["streamListen", stream.listen], ["streamUpstream", `${stream.backends?.length ? stream.backends.length + " · " : ""}${stream.upstream}`], ["streamIdleShort", `${stream.idle_timeout_seconds}s`], ["streamMaxConnections", String(stream.max_connections)]]) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
box.append(details);
const backendBox = el("div", undefined, "stream-backend-status");
backendBox.append(text("span", "streamBackendPool", "small-label"));
for (const backend of stream.backends?.length ? stream.backends : [backendDefaults(0, stream.upstream)]) {
const row = el("div", undefined, "stream-backend-status-row");
row.append(el("span", `${backend.name || backend.id} · ${backend.address}`));
const health = el("span", t("streamWidgetUnverified"), "pill"); health.dataset.streamBackendStatus = `${stream.id}|${backend.id}`;
row.append(health); backendBox.append(row);
}
box.append(backendBox);
const live = el("div", undefined, "vhost-live-grid stream-live-grid");
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = stream.id; live.append(item); };
@@ -156,11 +244,15 @@
const now = performance.now();
for (const desired of envelope?.config.streams || []) {
const id = desired.id, live = active.get(id), data = metrics[id];
if (currentPanel === "streams") for (const backend of data?.backends || []) {
const node = document.querySelector(`[data-stream-backend-status="${CSS.escape(`${id}|${backend.id}`)}"]`);
if (node) node.textContent = `${t("streamHealthMode")}: ${t(`streamHealthState_${backend.status}`)} · ${backend.active} ${t("streamActiveSessions")}`;
}
const same = live && JSON.stringify(live) === JSON.stringify(desired);
const state = desired.enabled === false ? t("vhDisabled") : !live || !same ? t("streamPendingRestart") : t("streamRunning");
const set = (suffix, value) => document.querySelectorAll(`[data-${suffix.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${CSS.escape(id)}"]`).forEach(node => node.textContent = value);
set("streamState", state); set("streamActive", data ? fmt(data.active) : "-"); set("streamSessions", data ? fmt(data.sessions_total) : "-"); set("streamErrors", data ? fmt(data.errors) : "-"); set("streamRejected", data ? fmt(data.rejected) : "-"); set("streamTransferred", data ? bytes((Number(data.bytes_in) || 0) + (Number(data.bytes_out) || 0)) : "-");
if (!data) continue;
if (!data || currentPanel !== "streams") continue;
const history = chartHistory.get(id) || { lastTime: now, lastIn: Number(data.bytes_in) || 0, lastOut: Number(data.bytes_out) || 0, points: [], bucketSize: 1, pendingRx: 0, pendingTx: 0, pendingCount: 0 };
const elapsed = Math.max(.001, (now - history.lastTime) / 1000), inBytes = Number(data.bytes_in) || 0, outBytes = Number(data.bytes_out) || 0;
const rx = inBytes >= history.lastIn ? (inBytes - history.lastIn) / elapsed : 0, tx = outBytes >= history.lastOut ? (outBytes - history.lastOut) / elapsed : 0;
+416 -54
View File
@@ -8474,8 +8474,14 @@ button.config-history-day.is-selected {
display: block;
overflow: visible;
}
.config-history-revisions { max-height: none; }
.config-history-diff { overflow: visible; }
.config-history-revisions {
max-height: none;
}
.config-history-diff {
overflow: visible;
}
.config-history-change-values {
grid-template-columns: 1fr;
@@ -8723,7 +8729,6 @@ button.config-history-day.is-selected {
box-shadow: 0 1px 0 var(--line);
}
/* Stored Logs sorting is performed by the bounded server scan before pagination. */
.file-logs-table th .file-log-sort-button {
display: inline-flex;
align-items: center;
@@ -8737,10 +8742,20 @@ button.config-history-day.is-selected {
cursor: pointer;
text-align: left;
}
.file-logs-table th .file-log-sort-button:hover { color: var(--accent); }
.file-logs-table th .file-log-sort-button:hover {
color: var(--accent);
}
.file-logs-table th[aria-sort="ascending"] .file-log-sort-button,
.file-logs-table th[aria-sort="descending"] .file-log-sort-button { color: var(--accent); }
.file-logs-table .file-log-sort-indicator { font-size: 11px; opacity: .8; }
.file-logs-table th[aria-sort="descending"] .file-log-sort-button {
color: var(--accent);
}
.file-logs-table .file-log-sort-indicator {
font-size: 11px;
opacity: .8;
}
.file-logs-table tbody tr:not(.file-log-detail-row) {
cursor: pointer;
@@ -9789,7 +9804,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
stroke-linejoin: var(--chart-linejoin);
}
/* Compact toolbar fields use one scaled box model, so native selects and inputs stay aligned. */
.backend-table-tools,
.vhost-list-tools,
.docker-toolbar,
@@ -9814,7 +9828,6 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
padding-block: 0
}
/* ACL policy builder: guided OR-composition for country + IP exceptions. */
.acl-profile-layout {
grid-template-columns: minmax(260px, .72fr) minmax(440px, 1.28fr)
}
@@ -10277,6 +10290,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
.cache-maintenance-info .metric-info-popover code+.fine {
margin-top: 8px;
}
/* Stored Logs: compact header option and distinct analysis workspace. */
.file-logs-heading-actions .file-log-consolidate {
display: inline-flex;
@@ -10290,23 +10304,72 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
white-space: nowrap;
cursor: pointer;
}
.file-log-consolidate input { width: 14px; height: 14px; margin: 0; accent-color: var(--accent); }
.file-log-consolidate:hover { color: var(--text); }
.file-log-tabs { display: flex; gap: .25rem; border-bottom: 1px solid var(--line); margin: .9rem 0 1rem; }
.file-log-consolidate input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--accent);
}
.file-log-consolidate:hover {
color: var(--text);
}
.file-log-tabs {
display: flex;
gap: .25rem;
border-bottom: 1px solid var(--line);
margin: .9rem 0 1rem;
}
.file-log-tab {
appearance: none; background: transparent; border: 0; border-bottom: 2px solid transparent;
border-radius: 0; padding: .65rem 1rem; color: var(--muted); cursor: pointer;
font: inherit; font-weight: 500;
appearance: none;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: .65rem 1rem;
color: var(--muted);
cursor: pointer;
font: inherit;
font-weight: 500;
}
.file-log-tab:hover { color: var(--text); }
.file-log-tab.active { color: var(--text); border-bottom-color: var(--accent); }
.file-log-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
#file-log-view-counts[hidden], #file-log-view-entries[hidden] { display: none !important; }
.file-log-advanced-parser h3 { margin: 0 0 .7rem; font-size: 1rem; }
.file-log-tab:hover {
color: var(--text);
}
.file-log-tab.active {
color: var(--text);
border-bottom-color: var(--accent);
}
.file-log-tab:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
#file-log-view-counts[hidden],
#file-log-view-entries[hidden] {
display: none !important;
}
.file-log-advanced-parser h3 {
margin: 0 0 .7rem;
font-size: 1rem;
}
@media (max-width: 700px) {
.file-logs-heading-actions .file-log-consolidate { margin-inline-end: auto; }
.file-log-tab { padding: .6rem .7rem; }
.file-logs-heading-actions .file-log-consolidate {
margin-inline-end: auto;
}
.file-log-tab {
padding: .6rem .7rem;
}
}
/* 0.31.5 traffic/log analysis actions */
.traffic-copy-value,
.file-log-aggregate-copy {
@@ -10320,18 +10383,21 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
margin: 0;
cursor: copy;
}
.traffic-copy-value:hover,
.traffic-copy-value:focus-visible,
.file-log-aggregate-copy:hover,
.file-log-aggregate-copy:focus-visible {
text-decoration: underline;
}
.file-log-advanced-parser {
margin: 0 0 1rem;
padding: .75rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius, 12px);
}
.file-log-aggregate-form {
display: grid;
grid-template-columns: minmax(220px, 2fr) minmax(140px, 1fr) minmax(120px, 1fr) auto;
@@ -10339,16 +10405,21 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
align-items: end;
margin-top: .75rem;
}
.file-log-aggregate-table {
margin-top: .75rem;
}
.file-log-aggregate-table th:last-child,
.file-log-aggregate-table td:last-child {
width: 8rem;
text-align: right;
}
@media (max-width: 800px) {
.file-log-aggregate-form { grid-template-columns: 1fr; }
.file-log-aggregate-form {
grid-template-columns: 1fr;
}
}
@@ -10356,6 +10427,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
The complete parsed JSON is available by opening any row, while the desktop
table and the persisted column picker keep their original behavior. */
@media (max-width: 700px) {
#panel-filelogs,
#panel-filelogs .file-logs-workspace,
#file-log-view-entries,
@@ -10368,33 +10440,43 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
max-width: 100%;
width: 100%;
}
#panel-filelogs .file-logs-workspace {
gap: 10px;
padding: 12px;
overflow: visible;
}
#panel-filelogs .card-heading {
min-width: 0;
flex-wrap: wrap;
gap: 8px;
}
#panel-filelogs .file-logs-heading-actions {
width: 100%;
justify-content: flex-start;
gap: 4px;
}
#panel-filelogs .file-logs-heading-actions .file-log-consolidate {
margin-inline-end: auto;
white-space: normal;
}
#panel-filelogs .file-log-search-help-popover {
width: min(320px, calc(100vw - 54px));
}
#panel-filelogs .file-logs-filter,
#panel-filelogs .file-log-aggregate-form {
grid-template-columns: minmax(0, 1fr);
}
#panel-filelogs .file-log-limit { max-width: none; }
#panel-filelogs .file-log-limit {
max-width: none;
}
#panel-filelogs .file-logs-filter label,
#panel-filelogs .file-logs-filter input,
#panel-filelogs .file-logs-filter select,
@@ -10404,21 +10486,31 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
min-width: 0;
max-width: 100%;
}
#panel-filelogs .file-log-tabs { min-width: 0; }
#panel-filelogs .file-log-tabs {
min-width: 0;
}
#panel-filelogs .file-log-tab {
flex: 1 1 0;
min-width: 0;
white-space: normal;
}
#panel-filelogs .file-logs-table-toolbar {
align-items: flex-start;
gap: 5px;
}
#panel-filelogs .file-log-column-picker { align-self: flex-end; }
#panel-filelogs .file-log-column-picker {
align-self: flex-end;
}
#panel-filelogs .file-logs-table {
max-height: none;
overflow: visible;
}
#panel-filelogs .file-logs-table table,
#panel-filelogs .file-logs-table tbody {
display: block;
@@ -10426,7 +10518,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
min-width: 0;
white-space: normal;
}
#panel-filelogs .file-logs-table thead { display: none; }
#panel-filelogs .file-logs-table thead {
display: none;
}
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -10438,12 +10534,17 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
border: 1px solid var(--line);
background: var(--panel);
}
#panel-filelogs .file-logs-table tbody tr:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
#panel-filelogs .file-logs-table tbody tr.selected { border-color: var(--accent); }
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td {
#panel-filelogs .file-logs-table tbody tr.selected {
border-color: var(--accent);
}
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td {
display: block;
min-width: 0;
max-width: none;
@@ -10454,6 +10555,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
white-space: normal;
overflow-wrap: anywhere;
}
/* Every user-selected column is available even when the administrator
replaces all default columns with custom parsed fields. */
#panel-filelogs .file-logs-table td[data-mobile-column="record"] {
@@ -10462,6 +10564,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
line-height: 1.45;
padding-inline-end: 16px;
}
#panel-filelogs .file-logs-table td[data-mobile-column="time"],
#panel-filelogs .file-logs-table td[data-mobile-column="event"],
#panel-filelogs .file-logs-table td[data-mobile-column="status"],
@@ -10469,14 +10572,17 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
color: var(--muted);
font-size: 11px;
}
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td::before {
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td::before {
content: attr(data-mobile-label) ": ";
color: var(--text-subtle);
font-weight: 400;
}
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row) > td[data-mobile-column="record"]::before {
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)>td[data-mobile-column="record"]::before {
content: "";
}
#panel-filelogs .file-logs-table tbody tr:not(.file-log-detail-row)::after {
content: "+";
position: absolute;
@@ -10486,7 +10592,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
font-size: 15px;
pointer-events: none;
}
#panel-filelogs .file-logs-table tbody tr.selected::after { content: "-"; }
#panel-filelogs .file-logs-table tbody tr.selected::after {
content: "-";
}
#panel-filelogs .file-logs-table .file-log-detail-row {
display: block;
margin: -8px 0 8px;
@@ -10494,6 +10604,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
border-top: 0;
background: var(--panel2);
}
#panel-filelogs .file-logs-table .file-log-detail-row td {
display: block;
box-sizing: border-box;
@@ -10502,21 +10613,40 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
max-width: 100%;
white-space: normal;
}
#panel-filelogs .file-log-field { grid-template-columns: minmax(0, 1fr); }
#panel-filelogs .file-log-inline-detail { min-width: 0; padding: 8px; }
#panel-filelogs .file-log-inline-detail pre { max-width: 100%; overflow-x: auto; }
#panel-filelogs .file-log-field {
grid-template-columns: minmax(0, 1fr);
}
#panel-filelogs .file-log-inline-detail {
min-width: 0;
padding: 8px;
}
#panel-filelogs .file-log-inline-detail pre {
max-width: 100%;
overflow-x: auto;
}
#panel-filelogs .file-log-aggregate-table {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
#panel-filelogs .file-log-aggregate-table table { table-layout: fixed; }
#panel-filelogs .file-log-aggregate-table table {
table-layout: fixed;
}
#panel-filelogs .file-log-aggregate-table td:first-child {
white-space: normal;
overflow-wrap: anywhere;
}
#panel-filelogs .file-logs-pager { justify-content: space-between; }
#panel-filelogs .file-logs-pager {
justify-content: space-between;
}
}
/* Tablet/narrow-desktop log tables scroll locally instead of expanding <main>. */
@@ -10527,7 +10657,11 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
min-width: 0;
max-width: 100%;
}
#panel-filelogs .file-logs-workspace { grid-template-columns: minmax(0, 1fr); }
#panel-filelogs .file-logs-workspace {
grid-template-columns: minmax(0, 1fr);
}
#panel-filelogs .file-logs-table,
#panel-filelogs .file-log-aggregate-table {
box-sizing: border-box;
@@ -10535,14 +10669,22 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
max-width: 100%;
overflow-x: auto;
}
@media (max-width: 700px) {
#panel-filelogs .file-logs-table { overflow: visible; }
#panel-filelogs .file-logs-table {
overflow: visible;
}
#panel-filelogs .file-logs-table td[data-mobile-column="time"],
#panel-filelogs .file-logs-table td[data-mobile-column="vhost"] { grid-column: 1 / -1; }
#panel-filelogs .file-logs-table td[data-mobile-column="vhost"] {
grid-column: 1 / -1;
}
#panel-filelogs .file-logs-table td[data-mobile-column="record"] {
max-height: 4.4em;
overflow: hidden;
}
#panel-filelogs .file-logs-table tr.selected td[data-mobile-column="record"] {
max-height: none;
overflow-wrap: anywhere;
@@ -10550,21 +10692,241 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
}
/* 0.31.7: separate protocol tuning actions; keep them comfortably tappable. */
.listener-protocol-settings-actions { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; margin-block: 10px; }
.listener-protocol-settings-actions button { margin: 0; }
.listener-protocol-settings-actions button[hidden] { display: none; }
.listener-protocol-settings-actions {
display: flex;
gap: 14px;
flex-wrap: wrap;
align-items: center;
margin-block: 10px;
}
.listener-protocol-settings-actions button {
margin: 0;
}
.listener-protocol-settings-actions button[hidden] {
display: none;
}
/* 0.31.7 revision preview shows only changed leaf fields. */
.config-history-change-values { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.config-history-change-values .diff-before { color: var(--muted); text-decoration: line-through; }
.config-history-change-values .diff-after { color: var(--text); }
.config-history-change-values code { white-space: pre-wrap; overflow-wrap: anywhere; }
.config-history-json-dialog { width: min(1200px, calc(100vw - 24px)); max-width: calc(100vw - 24px); max-height: calc(100dvh - 28px); overflow: auto; }
.config-history-json-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; }
.config-history-json-columns section { min-width: 0; }
.config-history-json-columns pre { padding: 12px; margin: 0; background: var(--panel); border: 0; overflow: auto; max-height: 68dvh; font: 11px/1.5 var(--mono); }
@media (max-width: 760px) { .config-history-json-columns { grid-template-columns: 1fr; } .config-history-json-columns pre { max-height: 38dvh; } }
.config-history-change-values {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.acl-profile-attached { margin-block: 6px; }
.acl-profile-attached summary { cursor: pointer; color: var(--muted); font-size: 12px; }
.acl-profile-attached p { overflow-wrap: anywhere; }
.config-history-change-values .diff-before {
color: var(--muted);
text-decoration: line-through;
}
.config-history-change-values .diff-after {
color: var(--text);
}
.config-history-change-values code {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.config-history-json-dialog {
width: min(1200px, calc(100vw - 24px));
max-width: calc(100vw - 24px);
max-height: calc(100dvh - 28px);
overflow: auto;
}
.config-history-json-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 12px;
}
.config-history-json-columns section {
min-width: 0;
}
.config-history-json-columns pre {
padding: 12px;
margin: 0;
background: var(--panel);
border: 0;
overflow: auto;
max-height: 68dvh;
font: 11px/1.5 var(--mono);
}
@media (max-width: 760px) {
.config-history-json-columns {
grid-template-columns: 1fr;
}
.config-history-json-columns pre {
max-height: 38dvh;
}
}
.acl-profile-attached {
margin-block: 6px;
}
.acl-profile-attached summary {
cursor: pointer;
color: var(--muted);
font-size: 12px;
}
.acl-profile-attached p {
overflow-wrap: anywhere;
}
/* v0.32 Stream extensions reuse the responsive Vhost editor and stat cards. */
.stream-backend-pool {
display: grid;
gap: .8rem;
min-width: 0;
}
.stream-backend-list {
display: grid;
gap: 1rem;
}
.stream-backend-row {
min-width: 0;
border: 1px solid var(--line, #454d59);
border-radius: .65rem;
padding: .85rem;
}
.stream-backend-row>legend {
font-weight: 650;
padding: 0 .35rem;
}
.stream-backend-row>.quiet {
margin-top: .6rem;
}
.stream-backend-status {
display: grid;
gap: .4rem;
padding: .75rem 0;
}
.stream-backend-status-row {
display: flex;
flex-wrap: wrap;
gap: .5rem;
align-items: center;
justify-content: space-between;
font-size: .85em;
}
.stream-backend-status-row>span:first-child {
overflow-wrap: anywhere;
}
[data-dashboard-widget^="stream_"][hidden] {
display: none !important;
}
/* Description text is a tooltip, not a second line inside each Appearance tile. */
.dashboard-widget-setting {
position: relative;
}
.dashboard-widget-setting:is(:hover, :focus-within) {
z-index: 5;
}
.dashboard-widget-setting-label-block {
display: block;
min-width: 0;
}
.dashboard-widget-description {
position: absolute;
z-index: 10;
inset-inline-start: 4px;
top: calc(100% + 5px);
width: max-content;
max-width: min(290px, calc(100vw - 48px));
box-sizing: border-box;
padding: 9px 11px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--panel2);
box-shadow: 0 6px 20px rgba(0, 0, 0, .24);
color: var(--text);
font-size: 11px;
font-weight: 400;
line-height: 1.45;
white-space: normal;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-4px);
transition: opacity .12s ease, transform .12s ease, visibility .12s;
}
.dashboard-widget-setting:is(:hover, :focus-within) .dashboard-widget-description {
opacity: 1;
visibility: visible;
transform: none;
}
.dashboard-widget-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.vh-enable-control.vh-enable-no-hint {
grid-template-rows: auto;
}
.vh-enable-control.vh-enable-no-hint> :is(input, .vh-enable-state) {
grid-row: 1;
}
#panel-overview .stats-grid>.stat {
min-width: 0;
}
#panel-overview .stats-grid .stat>small {
overflow-wrap: anywhere;
}
#panel-overview .stream-traffic-legend .legend-rx i {
background: var(--chart-accent);
}
#panel-overview .stream-traffic-legend .legend-tx i {
background: var(--chart-secondary);
}
#panel-overview .stats-grid .stat-chart>.stat-chart-legend {
flex-wrap: wrap;
white-space: normal;
}
@media (min-width: 1201px) {
#panel-overview .stats-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
#panel-overview .stats-grid>.stat {
min-height: 126px;
box-sizing: border-box;
}
html[data-dashboard-density="normal"] #panel-overview .stats-grid>.stat {
min-height: 136px;
}
html[data-dashboard-density="ultra"] #panel-overview .stats-grid>.stat {
min-height: 105px;
}
}
+471 -468
View File
@@ -1,473 +1,476 @@
function basicAuthEditor(parent, settings = {}) {
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
function add(user = {}) {
if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user");
const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 });
const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword");
const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row);
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
function basicAuthEditor(parent, settings = {}) {
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
function add(user = {}) {
if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user");
const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 });
const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword");
const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row);
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
}
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
}
function hostTagEditor(parent, hosts = [], onUpdate = () => { }) {
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
const values = [];
const normalize = raw => {
let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value;
if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base;
};
const sync = () => { hidden.value = values.join("\n"); onUpdate([...values]); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); };
const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; };
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } });
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } });
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
}
function load(value = defaults(), migrationIds = [], show = true) {
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
let refreshCanonical = () => { };
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
const enabledLabel = enabledControl.closest("label");
enabledLabel?.querySelector(".vh-enable-hint")?.remove();
enabledLabel?.classList.add("vh-enable-no-hint");
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
note(g, "sourceHelp");
const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1";
const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
note(g, "vhWebsocketTransportHelp");
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
note(g, "vhWebsocketLegacySupportHelp");
g = group("vhFrontends");
// Keep the list of names beside the WWW routing control. Every name in
// frontend.hosts remains an independent alias of this vhost.
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
note(g, "listenerDefaultPortsHint");
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
note(g, "vhForceSslHelp");
const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");
const wwwHeader = el("div", undefined, "vh-www-header");
const wwwTitle = text("strong", "vhWwwPanelTitle", "vh-www-title");
const wwwHostCount = el("span", "", "vh-www-host-count");
const wwwRedirect = field(wwwHeader, "www-redirect", "vhWwwEnable", value.www_redirect === true, "checkbox");
wwwRedirect.closest("label").classList.add("vh-www-toggle");
wwwHeader.prepend(wwwTitle, wwwHostCount);
const wwwInactive = text("p", "vhWwwInactive", "fine vh-www-inactive");
const wwwDetails = el("div", undefined, "vh-www-details");
wwwDetails.id = "vh-www-details";
wwwRedirect.setAttribute("aria-controls", wwwDetails.id);
const wwwLabel = text("p", "vhWwwRedirectTarget", "vh-www-option-title");
const wwwChoices = el("div", undefined, "vh-www-choices");
wwwChoices.setAttribute("role", "radiogroup");
wwwChoices.setAttribute("aria-label", t("vhWwwRedirectTarget"));
const wwwTarget = document.createElement("input");
wwwTarget.type = "hidden"; wwwTarget.name = "www-redirect-target";
const wwwNoHosts = text("p", "vhWwwNoHosts", "fine vh-www-no-hosts");
const wwwOther = el("div", undefined, "vh-www-other");
const wwwHelp = text("p", "vhWwwRedirectHelp", "fine vh-www-help");
wwwDetails.append(wwwLabel, wwwChoices, wwwNoHosts, wwwOther, wwwHelp);
wwwPanel.append(wwwHeader, wwwInactive, wwwDetails, wwwTarget);
g.append(wwwPanel);
// Persist only the selected canonical host; choosing the www variant does
// not require adding an extra hostname to the vhost manually.
let selectedCanonical = value.www_redirect_target || (value.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
const syncCanonical = () => {
const allHosts = lines(read("hosts").value);
const exactHosts = allHosts.filter(host => !host.startsWith("*.") && !/^[\d.:]+$/.test(host));
const bases = [...new Set(exactHosts.map(host => host.startsWith("www.") ? host.slice(4) : host))]
.filter(base => !/^[\d.:]+$/.test(base) && `www.${base}`.length <= 253);
const options = bases.flatMap(base => [base, `www.${base}`]);
if (!options.includes(selectedCanonical)) selectedCanonical = exactHosts[0] || "";
wwwTarget.value = selectedCanonical;
wwwHostCount.textContent = t("vhWwwSharedCount").replace("{count}", String(allHosts.length));
wwwChoices.replaceChildren();
for (const base of bases) {
const pair = el("div", undefined, "vh-www-domain-pair");
pair.append(el("strong", base, "vh-www-domain-label"));
const directions = el("div", undefined, "vh-www-pair-directions");
for (const target of [base, `www.${base}`]) {
const companion = target === base ? `www.${base}` : base;
const choice = el("label", undefined, "vh-www-choice");
if (target === selectedCanonical) choice.classList.add("is-selected");
const radio = document.createElement("input"); radio.type = "radio"; radio.name = "www-canonical-choice";
radio.value = target; radio.checked = target === selectedCanonical;
const copy = el("span", undefined, "vh-www-choice-copy");
copy.append(el("strong", target), el("span", `${companion} \u2192 ${target}`, "vh-www-route"));
choice.append(radio, copy);
radio.addEventListener("change", () => {
if (!radio.checked) return;
selectedCanonical = target;
syncCanonical();
changed();
});
directions.append(choice);
}
pair.append(directions); wwwChoices.append(pair);
}
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
}
function hostTagEditor(parent, hosts = [], onUpdate = () => {}) {
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
const values = [];
const normalize = raw => {
let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value;
if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base;
};
const sync = () => { hidden.value = values.join("\n"); onUpdate([...values]); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); };
const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; };
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } });
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } });
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
}
function load(value = defaults(), migrationIds = [], show = true) {
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
let refreshCanonical = () => {};
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
window.rustProxyEnableControl(enabledControl, "vhEnableHint");
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
note(g, "sourceHelp");
const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1";
const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
note(g, "vhWebsocketTransportHelp");
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
note(g, "vhWebsocketLegacySupportHelp");
g = group("vhFrontends");
// Keep the list of names beside the WWW routing control. Every name in
// frontend.hosts remains an independent alias of this vhost.
readHosts = hostTagEditor(g, value.frontend?.hosts || [], hosts => refreshCanonical(hosts));
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
note(g, "listenerDefaultPortsHint");
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
note(g, "vhForceSslHelp");
const wwwPanel = el("section", undefined, "vh-wide vh-www-panel");
const wwwHeader = el("div", undefined, "vh-www-header");
const wwwTitle = text("strong", "vhWwwPanelTitle", "vh-www-title");
const wwwHostCount = el("span", "", "vh-www-host-count");
const wwwRedirect = field(wwwHeader, "www-redirect", "vhWwwEnable", value.www_redirect === true, "checkbox");
wwwRedirect.closest("label").classList.add("vh-www-toggle");
wwwHeader.prepend(wwwTitle, wwwHostCount);
const wwwInactive = text("p", "vhWwwInactive", "fine vh-www-inactive");
const wwwDetails = el("div", undefined, "vh-www-details");
wwwDetails.id = "vh-www-details";
wwwRedirect.setAttribute("aria-controls", wwwDetails.id);
const wwwLabel = text("p", "vhWwwRedirectTarget", "vh-www-option-title");
const wwwChoices = el("div", undefined, "vh-www-choices");
wwwChoices.setAttribute("role", "radiogroup");
wwwChoices.setAttribute("aria-label", t("vhWwwRedirectTarget"));
const wwwTarget = document.createElement("input");
wwwTarget.type = "hidden"; wwwTarget.name = "www-redirect-target";
const wwwNoHosts = text("p", "vhWwwNoHosts", "fine vh-www-no-hosts");
const wwwOther = el("div", undefined, "vh-www-other");
const wwwHelp = text("p", "vhWwwRedirectHelp", "fine vh-www-help");
wwwDetails.append(wwwLabel, wwwChoices, wwwNoHosts, wwwOther, wwwHelp);
wwwPanel.append(wwwHeader, wwwInactive, wwwDetails, wwwTarget);
g.append(wwwPanel);
// Persist only the selected canonical host; choosing the www variant does
// not require adding an extra hostname to the vhost manually.
let selectedCanonical = value.www_redirect_target || (value.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
const syncCanonical = () => {
const allHosts = lines(read("hosts").value);
const exactHosts = allHosts.filter(host => !host.startsWith("*.") && !/^[\d.:]+$/.test(host));
const bases = [...new Set(exactHosts.map(host => host.startsWith("www.") ? host.slice(4) : host))]
.filter(base => !/^[\d.:]+$/.test(base) && `www.${base}`.length <= 253);
const options = bases.flatMap(base => [base, `www.${base}`]);
if (!options.includes(selectedCanonical)) selectedCanonical = exactHosts[0] || "";
wwwTarget.value = selectedCanonical;
wwwHostCount.textContent = t("vhWwwSharedCount").replace("{count}", String(allHosts.length));
wwwChoices.replaceChildren();
for (const base of bases) {
const pair = el("div", undefined, "vh-www-domain-pair");
pair.append(el("strong", base, "vh-www-domain-label"));
const directions = el("div", undefined, "vh-www-pair-directions");
for (const target of [base, `www.${base}`]) {
const companion = target === base ? `www.${base}` : base;
const choice = el("label", undefined, "vh-www-choice");
if (target === selectedCanonical) choice.classList.add("is-selected");
const radio = document.createElement("input"); radio.type = "radio"; radio.name = "www-canonical-choice";
radio.value = target; radio.checked = target === selectedCanonical;
const copy = el("span", undefined, "vh-www-choice-copy");
copy.append(el("strong", target), el("span", `${companion} \u2192 ${target}`, "vh-www-route"));
choice.append(radio, copy);
radio.addEventListener("change", () => {
if (!radio.checked) return;
selectedCanonical = target;
syncCanonical();
changed();
});
directions.append(choice);
}
pair.append(directions); wwwChoices.append(pair);
}
const companion = selectedCanonical.startsWith("www.") ? selectedCanonical.slice(4) : `www.${selectedCanonical}`;
const aliases = allHosts.filter(host => host !== selectedCanonical && host !== companion);
wwwOther.replaceChildren();
if (aliases.length) {
wwwOther.append(text("span", "vhWwwOtherDomains"));
for (const host of aliases) wwwOther.append(el("code", host, "vh-www-alias"));
}
wwwNoHosts.hidden = bases.length > 0;
wwwRedirect.disabled = !bases.length;
if (!bases.length) wwwRedirect.checked = false;
// Never hide the destination choices: the unchecked state is a preview
// that the operator can configure before enabling the rule.
wwwDetails.hidden = false;
wwwInactive.hidden = wwwRedirect.checked;
wwwRedirect.setAttribute("aria-expanded", "true");
wwwPanel.classList.toggle("is-enabled", wwwRedirect.checked);
};
refreshCanonical = syncCanonical;
wwwRedirect.addEventListener("change", () => { syncCanonical(); changed(); });
syncCanonical();
field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox");
note(g, "vhForwardClientIpHelp");
field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox");
const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow);
const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox");
const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox");
note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly");
const syncFrontendTransports = () => {
const capabilities = bindingControl.capabilities();
h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3;
if (!capabilities.http2) h2Enabled.checked = false;
if (!capabilities.http3) h3Enabled.checked = false;
const h2Available = capabilities.http2 && h2Enabled.checked;
const h3Available = capabilities.http3 && h3Enabled.checked;
websocketMode.disabled = !websocketEnabled.checked;
websocketLegacy.disabled = !websocketEnabled.checked;
const optionH2 = websocketMode.querySelector('option[value="h2"]');
const optionH3 = websocketMode.querySelector('option[value="h3"]');
const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]');
optionH2.disabled = !h2Available;
optionH3.disabled = !h3Available;
optionH2H3.disabled = !h2Available || !h3Available;
const selected = websocketMode.value;
if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) {
websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1";
}
};
websocketEnabled.addEventListener("change", syncFrontendTransports);
bindingControl.onChange(syncFrontendTransports);
h2Enabled.addEventListener("change", syncFrontendTransports);
h3Enabled.addEventListener("change", syncFrontendTransports);
syncFrontendTransports();
g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members);
(value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); }));
g = group("vhBackendDiagnostics");
field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox");
field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox");
field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox");
note(g, "vhBackendDiagnosticsHelp");
const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) };
g = group("vhProxyTimeouts");
field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 });
field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 });
field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
note(g, "vhProxyTimeoutsHelp");
const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {};
g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
note(g, "vhRetryHelp");
field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 });
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
field(g, "pool-recycle-requests", "vhPoolRecycleRequests", keepalive.pool_recycle_requests ?? 25000, "number", { required: true, min: 0, max: 10000000 });
field(g, "pool-recycle-seconds", "vhPoolRecycleSeconds", keepalive.pool_recycle_seconds ?? 3600, "number", { required: true, min: 0, max: 604800 });
field(g, "http1-max-buffer", "vhHttp1MaxBuffer", keepalive.http1_max_buffer_bytes ?? 131072, "number", { required: true, min: 8192, max: 1048576 });
note(g, "vhPoolRecycleHelp");
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
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 });
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");
const passive = settings.passive_health || {};
g = group("vhPassiveHealth");
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
note(g, "vhPassiveHelp");
const passiveFields = [
field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"),
field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"),
field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"),
field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"),
field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"),
field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }),
field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 })
];
const passive5xx = passiveFields[3], passiveSlow = passiveFields[4];
const passive5xxWarning = note(g, "vhPassive5xxWarning");
const passiveSlowWarning = note(g, "vhPassiveSlowWarning");
const syncPassive = () => {
passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; });
passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked;
passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked;
};
passiveEnabled.addEventListener("change", syncPassive);
passive5xx.addEventListener("change", syncPassive);
passiveSlow.addEventListener("change", syncPassive);
syncPassive();
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
g = group("vhTrafficPolicy");
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
field(g, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
field(g, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(g, "vhMethodHelp");
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); uaRatesReader = userAgentRateEditor(g, rate.user_agent_limits || []); note(g, "vhRateHelp");
const security = value.security_headers || {}, hsts = security.hsts || {};
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
const uaBlock = value.user_agent_blocking || {};
field(g, "ua-block-enabled", "vhUaBlockEnabled", uaBlock.enabled === true, "checkbox");
field(g, "ua-block-patterns", "vhUaBlockPatterns", (uaBlock.patterns || []).join("\n"), "textarea", { rows: 4, maxLength: 32768 });
note(g, "vhUaBlockHelp");
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
g = group("vhLocations");
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
field(g, "main-error-statuses", "vhErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
note(g, "vhErrorFallbackHelp");
locationsReader = locationsEditor(g, value.routes || []);
g = group("vhSsl");
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox");
const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox");
const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 });
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; };
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp");
g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g;
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
note(g, "authModeHelp");
const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody);
aclBody.append(text("h3", "vhIpAccess"));
aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody);
taBody.append(text("h3", "authTinyauth"));
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
authEnable.closest("label").hidden = true;
note(taBody, "taHelp");
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced);
field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
basicBody.append(text("h3", "authBasic"));
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
const syncAuth = () => {
authEnable.checked = authMode.value === "tinyauth";
taBody.hidden = !authEnable.checked;
basicBody.hidden = authMode.value !== "basic";
authUrl.required = loginUrl.required = authEnable.checked;
};
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy", "text", { maxLength: 256 });
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp");
g = group("vhCompression"); const compression = value.compression || {};
const compressionTri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const compressionToggle = compressionTri(g, "vh-compression-enabled", "compressionEnabled", compression.enabled);
const compressionOptions = el("div", undefined, "vh-wide vh-grid vh-compression-options"); g.append(compressionOptions);
const compressionCodecInputs = [
compressionTri(compressionOptions, "vh-compression-zstd", "compressionZstd", compression.zstd),
compressionTri(compressionOptions, "vh-compression-br", "compressionBr", compression.br),
compressionTri(compressionOptions, "vh-compression-gzip", "compressionGzip", compression.gzip),
compressionTri(compressionOptions, "vh-compression-deflate", "compressionDeflate", compression.deflate)
];
const syncCompressionVisibility = () => {
const disabled = compressionToggle.value === "off";
compressionOptions.hidden = disabled;
for (const input of compressionCodecInputs) input.disabled = disabled;
};
compressionToggle.addEventListener("change", syncCompressionVisibility); syncCompressionVisibility();
note(g, "vhCompressionHelp");
g = group("vhCache"); const cache = value.cache || {};
const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled);
if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled");
const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions);
field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 });
field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 });
field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 });
tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query);
tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies);
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age); tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits);
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
const forceCache = field(cacheOptions, "vh-cache-force-enabled", "vhCacheForceEnabled", cache.unsafe_force?.enabled === true, "checkbox");
const forceTargets = field(cacheOptions, "vh-cache-force-targets", "vhCacheForceTargets", (cache.unsafe_force?.targets || []).join("\n"), "textarea", { rows: 3, maxLength: 65536 });
const syncForceCache = () => { forceTargets.disabled = !forceCache.checked; }; forceCache.addEventListener("change", async () => { if (forceCache.checked && !(await appConfirm(t("vhCacheForceConfirm"), { danger: true }))) forceCache.checked = false; syncForceCache(); }); syncForceCache();
note(cacheOptions, "vhCacheForceHelp");
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
const syncCacheVisibility = () => {
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
cacheOptions.hidden = !enabled;
};
cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility();
g = group("vhTransform"); const transform = value.transform || { enabled: false };
const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox");
const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions);
for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]);
const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncTransform = () => {
transformOptions.hidden = !transformEnabled.checked;
transformExtensions.disabled = !customTransformExtensions.checked;
};
transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform();
note(transformOptions, "vhTransformHelp");
g = group("vhHeaders"); headerEditors(g, value.headers || {});
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
}
function collect() {
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
let paths, acl, headers;
try {
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error();
} catch { throw new Error(t("vhJsonInvalid")); }
const members = [...form.querySelectorAll(".vh-member")].map(row => ({
id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row),
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),
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)
} : null,
allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row),
preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)),
tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row),
max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row)
})).filter(member => member.url);
const bindingSelection = readBindings();
const mainSourceType = str("source-type");
const mainFallbackTarget = str("main-error-target"), mainFallbackStatuses = lines(str("main-error-statuses")).map(value => Number(value));
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
if (yes("www-redirect") && !str("www-redirect-target")) throw new Error(t("vhWwwNoHosts"));
if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired"));
if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired"));
if (yes("force-ssl")) {
const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false;
for (const id of bindingSelection) {
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
if (!endpoint || endpoint.enabled === false) continue;
if (!endpoint.binding?.tls) { httpSelected = true; continue; }
tlsSelected = true;
for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) {
const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1]));
}
}
if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp"));
if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls"));
if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict"));
const companion = selectedCanonical.startsWith("www.") ? selectedCanonical.slice(4) : `www.${selectedCanonical}`;
const aliases = allHosts.filter(host => host !== selectedCanonical && host !== companion);
wwwOther.replaceChildren();
if (aliases.length) {
wwwOther.append(text("span", "vhWwwOtherDomains"));
for (const host of aliases) wwwOther.append(el("code", host, "vh-www-alias"));
}
if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired"));
const selectedUsesHttp3 = bindingSelection.some(id => {
wwwNoHosts.hidden = bases.length > 0;
wwwRedirect.disabled = !bases.length;
if (!bases.length) wwwRedirect.checked = false;
// Never hide the destination choices: the unchecked state is a preview
// that the operator can configure before enabling the rule.
wwwDetails.hidden = false;
wwwInactive.hidden = wwwRedirect.checked;
wwwRedirect.setAttribute("aria-expanded", "true");
wwwPanel.classList.toggle("is-enabled", wwwRedirect.checked);
};
refreshCanonical = syncCanonical;
wwwRedirect.addEventListener("change", () => { syncCanonical(); changed(); });
syncCanonical();
field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox");
note(g, "vhForwardClientIpHelp");
field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox");
const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow);
const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox");
const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox");
note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly");
const syncFrontendTransports = () => {
const capabilities = bindingControl.capabilities();
h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3;
if (!capabilities.http2) h2Enabled.checked = false;
if (!capabilities.http3) h3Enabled.checked = false;
const h2Available = capabilities.http2 && h2Enabled.checked;
const h3Available = capabilities.http3 && h3Enabled.checked;
websocketMode.disabled = !websocketEnabled.checked;
websocketLegacy.disabled = !websocketEnabled.checked;
const optionH2 = websocketMode.querySelector('option[value="h2"]');
const optionH3 = websocketMode.querySelector('option[value="h3"]');
const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]');
optionH2.disabled = !h2Available;
optionH3.disabled = !h3Available;
optionH2H3.disabled = !h2Available || !h3Available;
const selected = websocketMode.value;
if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) {
websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1";
}
};
websocketEnabled.addEventListener("change", syncFrontendTransports);
bindingControl.onChange(syncFrontendTransports);
h2Enabled.addEventListener("change", syncFrontendTransports);
h3Enabled.addEventListener("change", syncFrontendTransports);
syncFrontendTransports();
g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members);
(value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); }));
g = group("vhBackendDiagnostics");
field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox");
field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox");
field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox");
note(g, "vhBackendDiagnosticsHelp");
const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) };
g = group("vhProxyTimeouts");
field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 });
field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 });
field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
note(g, "vhProxyTimeoutsHelp");
const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {};
g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
note(g, "vhRetryHelp");
field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 });
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
field(g, "pool-recycle-requests", "vhPoolRecycleRequests", keepalive.pool_recycle_requests ?? 25000, "number", { required: true, min: 0, max: 10000000 });
field(g, "pool-recycle-seconds", "vhPoolRecycleSeconds", keepalive.pool_recycle_seconds ?? 3600, "number", { required: true, min: 0, max: 604800 });
field(g, "http1-max-buffer", "vhHttp1MaxBuffer", keepalive.http1_max_buffer_bytes ?? 131072, "number", { required: true, min: 8192, max: 1048576 });
note(g, "vhPoolRecycleHelp");
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
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 });
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");
const passive = settings.passive_health || {};
g = group("vhPassiveHealth");
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
note(g, "vhPassiveHelp");
const passiveFields = [
field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"),
field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"),
field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"),
field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"),
field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"),
field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }),
field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 })
];
const passive5xx = passiveFields[3], passiveSlow = passiveFields[4];
const passive5xxWarning = note(g, "vhPassive5xxWarning");
const passiveSlowWarning = note(g, "vhPassiveSlowWarning");
const syncPassive = () => {
passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; });
passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked;
passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked;
};
passiveEnabled.addEventListener("change", syncPassive);
passive5xx.addEventListener("change", syncPassive);
passiveSlow.addEventListener("change", syncPassive);
syncPassive();
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
const rate = value.rate_limit || {}, access = rate.country_access || {}, methodPolicy = value.method_policy || {};
g = group("vhTrafficPolicy");
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
field(g, "method-mode", "vhMethodMode", methodPolicy.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
field(g, "method-list", "vhMethodList", (methodPolicy.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(g, "vhMethodHelp");
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); uaRatesReader = userAgentRateEditor(g, rate.user_agent_limits || []); note(g, "vhRateHelp");
const security = value.security_headers || {}, hsts = security.hsts || {};
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
const uaBlock = value.user_agent_blocking || {};
field(g, "ua-block-enabled", "vhUaBlockEnabled", uaBlock.enabled === true, "checkbox");
field(g, "ua-block-patterns", "vhUaBlockPatterns", (uaBlock.patterns || []).join("\n"), "textarea", { rows: 4, maxLength: 32768 });
note(g, "vhUaBlockHelp");
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
g = group("vhLocations");
field(g, "main-error-target", "vhErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
field(g, "main-error-statuses", "vhErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
note(g, "vhErrorFallbackHelp");
locationsReader = locationsEditor(g, value.routes || []);
g = group("vhSsl");
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox");
const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox");
const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 });
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; };
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp");
g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g;
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
note(g, "authModeHelp");
const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody);
aclBody.append(text("h3", "vhIpAccess"));
aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody);
taBody.append(text("h3", "authTinyauth"));
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
authEnable.closest("label").hidden = true;
note(taBody, "taHelp");
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced);
field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
basicBody.append(text("h3", "authBasic"));
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
const syncAuth = () => {
authEnable.checked = authMode.value === "tinyauth";
taBody.hidden = !authEnable.checked;
basicBody.hidden = authMode.value !== "basic";
authUrl.required = loginUrl.required = authEnable.checked;
};
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy", "text", { maxLength: 256 });
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp");
g = group("vhCompression"); const compression = value.compression || {};
const compressionTri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const compressionToggle = compressionTri(g, "vh-compression-enabled", "compressionEnabled", compression.enabled);
const compressionOptions = el("div", undefined, "vh-wide vh-grid vh-compression-options"); g.append(compressionOptions);
const compressionCodecInputs = [
compressionTri(compressionOptions, "vh-compression-zstd", "compressionZstd", compression.zstd),
compressionTri(compressionOptions, "vh-compression-br", "compressionBr", compression.br),
compressionTri(compressionOptions, "vh-compression-gzip", "compressionGzip", compression.gzip),
compressionTri(compressionOptions, "vh-compression-deflate", "compressionDeflate", compression.deflate)
];
const syncCompressionVisibility = () => {
const disabled = compressionToggle.value === "off";
compressionOptions.hidden = disabled;
for (const input of compressionCodecInputs) input.disabled = disabled;
};
compressionToggle.addEventListener("change", syncCompressionVisibility); syncCompressionVisibility();
note(g, "vhCompressionHelp");
g = group("vhCache"); const cache = value.cache || {};
const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled);
if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled");
const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions);
field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 });
field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 });
field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 });
tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query);
tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies);
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age); tri(cacheOptions, "vh-cache-only-hits", "cacheOnlyHits", cache.expose_only_hits);
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
const forceCache = field(cacheOptions, "vh-cache-force-enabled", "vhCacheForceEnabled", cache.unsafe_force?.enabled === true, "checkbox");
const forceTargets = field(cacheOptions, "vh-cache-force-targets", "vhCacheForceTargets", (cache.unsafe_force?.targets || []).join("\n"), "textarea", { rows: 3, maxLength: 65536 });
const syncForceCache = () => { forceTargets.disabled = !forceCache.checked; }; forceCache.addEventListener("change", async () => { if (forceCache.checked && !(await appConfirm(t("vhCacheForceConfirm"), { danger: true }))) forceCache.checked = false; syncForceCache(); }); syncForceCache();
note(cacheOptions, "vhCacheForceHelp");
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
const syncCacheVisibility = () => {
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
cacheOptions.hidden = !enabled;
};
cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility();
g = group("vhTransform"); const transform = value.transform || { enabled: false };
const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox");
const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions);
for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]);
const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
const syncTransform = () => {
transformOptions.hidden = !transformEnabled.checked;
transformExtensions.disabled = !customTransformExtensions.checked;
};
transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform();
note(transformOptions, "vhTransformHelp");
g = group("vhHeaders"); headerEditors(g, value.headers || {});
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
}
function collect() {
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
let paths, acl, headers;
try {
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error();
} catch { throw new Error(t("vhJsonInvalid")); }
const members = [...form.querySelectorAll(".vh-member")].map(row => ({
id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row),
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),
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)
} : null,
allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row),
preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)),
tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row),
max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row)
})).filter(member => member.url);
const bindingSelection = readBindings();
const mainSourceType = str("source-type");
const mainFallbackTarget = str("main-error-target"), mainFallbackStatuses = lines(str("main-error-statuses")).map(value => Number(value));
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
if (yes("www-redirect") && !str("www-redirect-target")) throw new Error(t("vhWwwNoHosts"));
if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired"));
if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired"));
if (yes("force-ssl")) {
const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false;
for (const id of bindingSelection) {
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
return !!endpoint?.binding?.http3;
});
if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) {
const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() };
const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() };
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
if (!endpoint || endpoint.enabled === false) continue;
if (!endpoint.binding?.tls) { httpSelected = true; continue; }
tlsSelected = true;
for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) {
const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1]));
}
}
return {
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader(), user_agent_limits: uaRatesReader() },
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
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") },
passive_health: {
enabled: yes("passive-enabled"),
count_connect_errors: yes("passive-connect"),
count_timeouts: yes("passive-timeouts"),
count_io_errors: yes("passive-io"),
count_http_5xx: yes("passive-5xx"),
count_slow_responses: yes("passive-slow"),
fall: str("passive-fall") === "" ? null : num("passive-fall"),
rise: str("passive-rise") === "" ? null : num("passive-rise")
},
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
},
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") },
user_agent_blocking: { enabled: yes("ua-block-enabled"), patterns: lines(str("ua-block-patterns")) }
};
if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp"));
if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls"));
if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict"));
}
if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired"));
const selectedUsesHttp3 = bindingSelection.some(id => {
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
return !!endpoint?.binding?.http3;
});
if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) {
const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() };
const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() };
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
}
return {
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader(), user_agent_limits: uaRatesReader() },
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
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") },
passive_health: {
enabled: yes("passive-enabled"),
count_connect_errors: yes("passive-connect"),
count_timeouts: yes("passive-timeouts"),
count_io_errors: yes("passive-io"),
count_http_5xx: yes("passive-5xx"),
count_slow_responses: yes("passive-slow"),
fall: str("passive-fall") === "" ? null : num("passive-fall"),
rise: str("passive-rise") === "" ? null : num("passive-rise")
},
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
},
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") },
user_agent_blocking: { enabled: yes("ua-block-enabled"), patterns: lines(str("ua-block-patterns")) }
};
}