0.32.1
This commit is contained in:
1 parent
81ab39e70a
commit
101df02a5f
45 files changed
+1148
-2277
No files matched your search
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.32.0",
|
||||
"version": "0.32.1",
|
||||
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
|
||||
},
|
||||
"servers": [
|
||||
|
||||
@@ -86,8 +86,6 @@ 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);
|
||||
@@ -104,18 +102,13 @@ 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)}`);
|
||||
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);
|
||||
const description = t(descriptionKey);
|
||||
row.title = description;
|
||||
toggle.setAttribute("aria-description", description);
|
||||
}
|
||||
toggle.append(el("span", undefined, "dashboard-widget-visibility"), labelBlock);
|
||||
toggle.append(el("span", undefined, "dashboard-widget-visibility"), el("span", dashboardWidgetLabel(id), "dashboard-widget-setting-label"));
|
||||
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"));
|
||||
@@ -183,7 +176,6 @@ async function loadAdminPreferences(force = false) {
|
||||
return applyAdminPreferences(value);
|
||||
} catch (error) {
|
||||
if (error.status === 401 || !authenticated || epoch !== authEpoch) throw error;
|
||||
// Preferences are optional for startup. Keep UI and live telemetry usable.
|
||||
console.warn("Preferences unavailable; using defaults", error);
|
||||
adminPreferencesUnavailable = true;
|
||||
return applyAdminPreferences({ ...ADMIN_APPEARANCE_DEFAULTS, refresh: REFRESH_DEFAULTS });
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Single scheduler for all authenticated dashboard polling. SSE is controlled here
|
||||
// but remains one native EventSource with its own browser reconnect behavior.
|
||||
const REFRESH_DEFAULTS = Object.freeze({
|
||||
live_enabled: true, overview_seconds: 2, traffic_seconds: 5,
|
||||
cache_seconds: 5, system_info_seconds: 60
|
||||
@@ -49,13 +47,11 @@ const rustProxyRefresh = (() => {
|
||||
const controller = new AbortController(), generation = task.generation, epoch = authEpoch;
|
||||
const panelAtStart = panel, tabAtStart = settingsTab;
|
||||
task.controller = controller;
|
||||
// A manual request remains valid with Live switched off, but not in a stale view.
|
||||
const isCurrent = () => authenticated && epoch === authEpoch && !controller.signal.aborted &&
|
||||
generation === task.generation && (manual ? panel === panelAtStart && settingsTab === tabAtStart : allowed(task));
|
||||
const promise = Promise.resolve().then(() => task.run({ signal: controller.signal, isCurrent }));
|
||||
task.inFlight = promise.catch(error => {
|
||||
if (error?.name !== "AbortError" && isCurrent()) {
|
||||
// A failure is isolated to this source; other timers continue.
|
||||
if (task.onError) task.onError(error);
|
||||
else console.warn(`Refresh ${task.id} failed:`, error);
|
||||
}
|
||||
@@ -161,19 +157,26 @@ const rustProxyRefresh = (() => {
|
||||
if (!task) return Promise.reject(new Error(`Unknown refresh task: ${id}`));
|
||||
return execute(task, true);
|
||||
}
|
||||
register("status-full", { interval: () => 0, run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status", { signal });
|
||||
if (isCurrent()) renderStatus(value, true);
|
||||
}});
|
||||
register("status-fallback", { interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status?view=live", { signal });
|
||||
if (isCurrent()) renderLiveStatus(value);
|
||||
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); }});
|
||||
register("logs-fallback", { interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/logs", { signal });
|
||||
if (isCurrent()) renderLogs(value);
|
||||
}});
|
||||
register("system-info", { interval: () => options.system_info_seconds,
|
||||
register("status-full", {
|
||||
interval: () => 0, run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status", { signal });
|
||||
if (isCurrent()) renderStatus(value, true);
|
||||
}
|
||||
});
|
||||
register("status-fallback", {
|
||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/status?view=live", { signal });
|
||||
if (isCurrent()) renderLiveStatus(value);
|
||||
}, onError: error => { if (error?.status !== 401) $("connection-label").textContent = t("disconnected"); }
|
||||
});
|
||||
register("logs-fallback", {
|
||||
interval: () => options.overview_seconds, active: () => !liveConnected && (liveFallback || typeof EventSource === "undefined"), run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/logs", { signal });
|
||||
if (isCurrent()) renderLogs(value);
|
||||
}
|
||||
});
|
||||
register("system-info", {
|
||||
interval: () => options.system_info_seconds,
|
||||
active: () => panel === "overview" || (panel === "settings" && ["system-info", "cache"].includes(settingsTab)),
|
||||
run: async ({ signal, isCurrent }) => {
|
||||
const value = await api("/api/system-info", { signal });
|
||||
@@ -183,11 +186,15 @@ const rustProxyRefresh = (() => {
|
||||
window.rustProxySettings?.render();
|
||||
}
|
||||
});
|
||||
register("tunnels", { interval: () => 2, active: () => !!$("tunnels-dialog")?.open,
|
||||
run: async context => { await loadTunnels(context); } });
|
||||
return { register, manual, start, stop, sync, update, setPanel, setSettingsTab,
|
||||
register("tunnels", {
|
||||
interval: () => 2, active: () => !!$("tunnels-dialog")?.open,
|
||||
run: async context => { await loadTunnels(context); }
|
||||
});
|
||||
return {
|
||||
register, manual, start, stop, sync, update, setPanel, setSettingsTab,
|
||||
suspendForSessionValidation, completeSessionValidation,
|
||||
current: () => ({ ...options }), diagnostics: () => [...tasks.values()].map(t => ({ id: t.id, enabled: t.enabled, inFlight: !!t.inFlight, scheduled: t.timer !== null })) };
|
||||
current: () => ({ ...options }), diagnostics: () => [...tasks.values()].map(t => ({ id: t.id, enabled: t.enabled, inFlight: !!t.inFlight, scheduled: t.timer !== null }))
|
||||
};
|
||||
})();
|
||||
window.rustProxyRefresh = rustProxyRefresh;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// All modules must be available before any asynchronous session/config work can
|
||||
// render the panel. DOMContentLoaded also waits for the complete deferred bundle.
|
||||
|
||||
function verifyAdminModules() {
|
||||
const required = [
|
||||
["rustProxyIntegrations", "bindings"],
|
||||
@@ -17,9 +16,6 @@ function verifyAdminModules() {
|
||||
async function startAdminPanel() {
|
||||
try {
|
||||
verifyAdminModules();
|
||||
// Revalidate the session first. The anonymous document has its own login.js;
|
||||
// app.js belongs to the authenticated shell and must not fall back to its
|
||||
// legacy embedded sign-in form when another startup step fails.
|
||||
const info = await api("/api/auth/session");
|
||||
await loadLanguages();
|
||||
setupRequired = false;
|
||||
@@ -35,9 +31,6 @@ async function startAdminPanel() {
|
||||
showStartupFailure(e);
|
||||
}
|
||||
}
|
||||
// The previous bootstrap ran while sibling deferred scripts were still loading.
|
||||
// Starting only after DOMContentLoaded prevents fast API responses from racing
|
||||
// against a slower or failed JS resource on mobile/reverse-proxied connections.
|
||||
if (document.readyState === "complete") startAdminPanel();
|
||||
else document.addEventListener("DOMContentLoaded", startAdminPanel, { once: true });
|
||||
|
||||
@@ -159,7 +152,6 @@ $("reload-certificates").addEventListener("click", async () => {
|
||||
$("reset-certificate").addEventListener("click", () => { $("certificate-form").reset(); $("certificate-file-source").value = "managed"; syncCertificateFileSource(); });
|
||||
syncCertificateFileSource();
|
||||
|
||||
// Dashboard metrics are actual server samples. Null means unavailable, not zero.
|
||||
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null,
|
||||
processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
|
||||
const hiddenNetworkInterfaces = (() => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// One in-flight validation per authenticated document. Neither BFCache restore nor
|
||||
// visibility resume may trigger a reload: a 403 is a recoverable, visible error.
|
||||
|
||||
function revalidateSession() {
|
||||
if (!authenticated) return Promise.resolve(false);
|
||||
if (sessionCheckPromise) return sessionCheckPromise;
|
||||
@@ -16,7 +15,6 @@ function revalidateSession() {
|
||||
window.rustProxyRefresh?.completeSessionValidation(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// api() locks and navigates to the login document on a 401.
|
||||
if (!authenticated || epoch !== authEpoch || error?.status === 401) return false;
|
||||
window.rustProxyRefresh?.completeSessionValidation(false);
|
||||
showSessionValidationFailure(error);
|
||||
@@ -46,7 +44,6 @@ function displayStartupError(error, sessionResume = false) {
|
||||
$("app-session-retry").hidden = !sessionResume;
|
||||
}
|
||||
function showSessionValidationFailure(error) {
|
||||
// Keep the workspace inaccessible while its session cannot be verified.
|
||||
displayStartupError(error, true);
|
||||
}
|
||||
function hideSessionValidationFailure() {
|
||||
@@ -68,7 +65,6 @@ $("app-session-retry").addEventListener("click", () => {
|
||||
);
|
||||
});
|
||||
function showStartupFailure(error) {
|
||||
// All startup failure paths stop background tasks and show visible recovery.
|
||||
window.rustProxyRefresh?.stop();
|
||||
displayStartupError(error);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// 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: [] };
|
||||
@@ -18,8 +17,6 @@ window.rustProxyStreamDashboard = (() => {
|
||||
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;
|
||||
@@ -35,8 +32,6 @@ window.rustProxyStreamDashboard = (() => {
|
||||
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}`, "-");
|
||||
@@ -79,8 +74,6 @@ window.rustProxyStreamDashboard = (() => {
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
// Dedicated cache inspector. Entry bodies and request headers are never loaded.
|
||||
(() => {
|
||||
let loading = false, offset = 0, generation = 0;
|
||||
let liveChartScope = "", liveChartPrevious = null, liveChartPreviousAt = null;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
try {
|
||||
const saved = localStorage.getItem(CONSOLIDATION_STORAGE_KEY);
|
||||
if (consolidationToggle && (saved === "1" || saved === "0")) consolidationToggle.checked = saved === "1";
|
||||
} catch { /* Browser storage can be unavailable; use the HTML default. */ }
|
||||
} catch { /* html default */ }
|
||||
|
||||
const sourceSelect = () => $("file-log-source");
|
||||
function setView(view) {
|
||||
@@ -258,8 +258,6 @@
|
||||
row.setAttribute("aria-label", entrySummary(entry));
|
||||
for (const id of ids) {
|
||||
const value = columnValue(entry, id), cell = el("td", value); cell.title = value;
|
||||
// Mobile uses compact, expandable cards instead of a width-growing
|
||||
// table. All parsed fields remain accessible in the detail row.
|
||||
cell.dataset.mobileColumn = id;
|
||||
const label = columnLabel(id);
|
||||
cell.dataset.mobileLabel = label;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
// No operational API or dashboard module is loaded on this document.
|
||||
(() => {
|
||||
const node = id => document.getElementById(id);
|
||||
const themes = new Set(["dark", "light"]);
|
||||
@@ -53,9 +52,6 @@
|
||||
if (setupRequired) { if (password !== node("password-confirm").value) throw new Error(t("passwordMismatch")); await request("/api/auth/setup", { token: node("setup-token").value.trim(), username, password }); setupRequired = false; translate(); }
|
||||
await request("/api/auth/login", { username, password });
|
||||
node("password").value = ""; node("password-confirm").value = ""; node("setup-token").value = "";
|
||||
// Never accept arbitrary return URLs (including scheme-relative redirects).
|
||||
// A direct panel route is an exact local allowlist entry, so an expired
|
||||
// session can sign back in without losing the requested subpage.
|
||||
location.replace(panelDestination());
|
||||
} catch (error) { node("password").value = ""; node("password-confirm").value = ""; if (error.code === "auth.already_initialized") { setupRequired = false; translate(); } message(error.message); }
|
||||
finally { button.disabled = false; }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
// Shared, safe controls: HTML is edited as text, never injected into the admin DOM.
|
||||
(() => {
|
||||
const label = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const option = (value, key) => { const node = label("option", key); node.value = value; return node; };
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
// Persistent tab DOM: switching tabs never discards unsaved form values.
|
||||
(() => {
|
||||
const buttons = [...document.querySelectorAll("[data-settings-tab]")];
|
||||
const search = $("settings-search"), searchStatus = $("settings-search-status");
|
||||
@@ -8,8 +7,6 @@
|
||||
? "logging"
|
||||
: (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null);
|
||||
const tabAliases = { http2: "connections", headers: "policies", response: "policies" };
|
||||
// Keep the public panel hostname implicit. Legacy configurations may have
|
||||
// written it into allowed_hosts in 0.31.4; do not show/save it twice.
|
||||
function pinnedAdminHostname(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use strict";
|
||||
// Layer-4 stream management mirrors the vhost workflow while keeping TCP/UDP
|
||||
// listeners separate from the HTTP policy stack.
|
||||
(() => {
|
||||
const sortColumns = ["name", "id", "protocol", "listen", "upstream", "status", "maxConnections", "idleTimeout"];
|
||||
let editing = null, revision = null, dirty = false, initialized = false, listPrefs = { sort: "name", descending: false };
|
||||
@@ -141,7 +139,6 @@
|
||||
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();
|
||||
// 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";
|
||||
|
||||
+20
-774
File diff suppressed because it is too large.
Load diff
+469
-471
@@ -1,495 +1,493 @@
|
||||
"use strict";
|
||||
// Vhost UI: language content comes from langs/*.json, never a locale switch.
|
||||
// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS.
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], uaRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
|
||||
const chartHistory = new Map();
|
||||
const backendTrafficHistory = new Map();
|
||||
const vhostTrafficHistory = new Map();
|
||||
const dialog = $("vhost-dialog");
|
||||
const form = $("vhost-form"), container = $("vhost-fields");
|
||||
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const field = (parent, name, key, value, type = "text", options = {}) => {
|
||||
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
|
||||
const input = document.createElement(type === "textarea" ? "textarea" : type === "select" ? "select" : "input");
|
||||
input.name = name;
|
||||
if (type === "select") for (const [val, key] of options.values || []) { const option = text("option", key); option.value = val; input.append(option); }
|
||||
else if (type !== "textarea") input.type = type;
|
||||
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
|
||||
for (const attr of ["min", "max", "step", "maxLength", "rows", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
|
||||
if (type === "textarea") input.spellcheck = false;
|
||||
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
|
||||
parent.append(label); return input;
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], uaRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
|
||||
const chartHistory = new Map();
|
||||
const backendTrafficHistory = new Map();
|
||||
const vhostTrafficHistory = new Map();
|
||||
const dialog = $("vhost-dialog");
|
||||
const form = $("vhost-form"), container = $("vhost-fields");
|
||||
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const field = (parent, name, key, value, type = "text", options = {}) => {
|
||||
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
|
||||
const input = document.createElement(type === "textarea" ? "textarea" : type === "select" ? "select" : "input");
|
||||
input.name = name;
|
||||
if (type === "select") for (const [val, key] of options.values || []) { const option = text("option", key); option.value = val; input.append(option); }
|
||||
else if (type !== "textarea") input.type = type;
|
||||
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
|
||||
for (const attr of ["min", "max", "step", "maxLength", "rows", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
|
||||
if (type === "textarea") input.spellcheck = false;
|
||||
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
|
||||
parent.append(label); return input;
|
||||
};
|
||||
const group = (key, advanced = false) => {
|
||||
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
|
||||
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhCompression: "compression", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
|
||||
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
|
||||
};
|
||||
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
|
||||
const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; };
|
||||
const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`);
|
||||
const str = (name, scope) => read(name, scope).value.trim();
|
||||
const num = (name, scope) => { const value = Number(str(name, scope)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
|
||||
const yes = (name, scope) => read(name, scope).checked;
|
||||
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
|
||||
const nullable = (name, scope) => str(name, scope) || null;
|
||||
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
|
||||
const changed = () => { dirty = true; };
|
||||
const fillWebServices = (select, chosen = "") => {
|
||||
const first = el("option", t("webServiceSelect")); first.value = "";
|
||||
select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; }));
|
||||
if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); }
|
||||
select.value = chosen;
|
||||
};
|
||||
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
|
||||
const defaults = () => ({
|
||||
id: "", enabled: true, force_ssl: false, www_redirect: false, www_redirect_target: null, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
|
||||
frontend: { hosts: [], listener_ids: [] },
|
||||
source: { type: "reverse_proxy" },
|
||||
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
|
||||
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
});
|
||||
|
||||
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
|
||||
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["compression", "vhCompression"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
|
||||
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), expose_only_hits: tri("vh-cache-only-hits"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null, unsafe_force: { enabled: yes("vh-cache-force-enabled"), targets: lines(str("vh-cache-force-targets")) } }; }
|
||||
function collectCompression() { const tri = name => str(name) === "" ? null : str(name) === "on"; return { enabled: tri("vh-compression-enabled"), zstd: tri("vh-compression-zstd"), br: tri("vh-compression-br"), gzip: tri("vh-compression-gzip"), deflate: tri("vh-compression-deflate") }; }
|
||||
function collectTransform() {
|
||||
const tri = name => str(name) === "" ? null : str(name) === "on";
|
||||
return {
|
||||
enabled: yes("vh-transform-enabled"),
|
||||
javascript: tri("vh-transform-javascript"),
|
||||
css: tri("vh-transform-css"),
|
||||
html: tri("vh-transform-html"),
|
||||
svg: tri("vh-transform-svg"),
|
||||
json: tri("vh-transform-json"),
|
||||
extensions: yes("vh-transform-extensions-custom") ? lines(str("vh-transform-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null
|
||||
};
|
||||
const group = (key, advanced = false) => {
|
||||
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
|
||||
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhCompression: "compression", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
|
||||
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
|
||||
}
|
||||
async function purge(id) { if (!(await appConfirm(t("vhPurgeConfirm"), { danger: true }))) return; try { const result = await api(`/api/cache/purge?vhost=${encodeURIComponent(id)}`, { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } }
|
||||
function validateHeaders(value) {
|
||||
const fail = () => { throw new Error(t("headersInvalid")); };
|
||||
if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !["request", "response"].includes(k))) fail();
|
||||
for (const direction of ["request", "response"]) {
|
||||
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
|
||||
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
|
||||
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
|
||||
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
|
||||
}
|
||||
} return value;
|
||||
}
|
||||
function headerEditors(parent, initial, target = read("headers"), onChange = changed) {
|
||||
const holders = {}, adders = {}, emptyStates = {};
|
||||
const renumber = direction => {
|
||||
const rows = holders[direction];
|
||||
[...rows.children].forEach((row, index) => {
|
||||
const title = row.querySelector(".vh-header-rule-title");
|
||||
if (title) title.textContent = `${t("headerRule")} ${index + 1}`;
|
||||
});
|
||||
if (emptyStates[direction]) emptyStates[direction].hidden = rows.children.length > 0;
|
||||
};
|
||||
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
|
||||
const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; };
|
||||
const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`);
|
||||
const str = (name, scope) => read(name, scope).value.trim();
|
||||
const num = (name, scope) => { const value = Number(str(name, scope)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
|
||||
const yes = (name, scope) => read(name, scope).checked;
|
||||
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
|
||||
const nullable = (name, scope) => str(name, scope) || null;
|
||||
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
|
||||
const changed = () => { dirty = true; };
|
||||
const fillWebServices = (select, chosen = "") => {
|
||||
const first = el("option", t("webServiceSelect")); first.value = "";
|
||||
select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; }));
|
||||
if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); }
|
||||
select.value = chosen;
|
||||
const sync = () => {
|
||||
const result = {};
|
||||
for (const direction of ["request", "response"]) result[direction] = [...holders[direction].children].map(row => {
|
||||
const op = str("rule-op", row), name = str("rule-name", row), value = read("rule-value", row).value;
|
||||
return op === "remove" ? { op, name } : { op, name, value };
|
||||
});
|
||||
target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange();
|
||||
};
|
||||
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
|
||||
const defaults = () => ({
|
||||
id: "", enabled: true, force_ssl: false, www_redirect: false, www_redirect_target: null, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
|
||||
frontend: { hosts: [], listener_ids: [] },
|
||||
source: { type: "reverse_proxy" },
|
||||
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
|
||||
error_fallback: null, cache: { enabled: false }, compression: { enabled: null, gzip: null, br: null, zstd: null, deflate: null }, transform: { enabled: false }, rate_limit: {}, method_policy: { mode: "off", methods: [] }, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
for (const direction of ["request", "response"]) {
|
||||
const block = el("section", undefined, "vh-wide location-header-direction location-header-editor"); block.dataset.headerDirection = direction;
|
||||
const heading = el("div", undefined, "location-header-direction-head");
|
||||
heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine"));
|
||||
block.append(heading);
|
||||
const empty = text("p", "headerRulesEmpty", "fine vh-header-empty"); emptyStates[direction] = empty; block.append(empty);
|
||||
const rows = el("div", undefined, "vh-header-rows"); holders[direction] = rows; block.append(rows); parent.append(block);
|
||||
const add = (value = { op: "set", name: "", value: "" }) => {
|
||||
if (rows.children.length >= 32) { toast(t("headerLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-header-row");
|
||||
const rowHead = el("div", undefined, "vh-header-rule-head"), rowTitle = el("strong", "", "vh-header-rule-title");
|
||||
rowHead.append(rowTitle, button("remove", () => { row.remove(); renumber(direction); sync(); })); row.append(rowHead);
|
||||
const fields = el("div", undefined, "vh-header-rule-fields");
|
||||
field(fields, "rule-op", "headerOperation", value.op, "select", { values: [["set", "header_set"], ["append", "header_append"], ["remove", "header_remove"]] });
|
||||
const name = field(fields, "rule-name", "headerName", value.name, "text", { maxLength: 128 }); name.placeholder = "X-App-Header";
|
||||
const ruleValue = field(fields, "rule-value", "headerValue", value.value ?? "", "text", { maxLength: 8192 }); ruleValue.placeholder = "value";
|
||||
row.append(fields);
|
||||
const enabled = () => { ruleValue.disabled = str("rule-op", row) === "remove"; row.classList.toggle("is-remove", ruleValue.disabled); };
|
||||
row.addEventListener("change", () => { enabled(); sync(); }); row.addEventListener("input", sync); rows.append(row); enabled(); renumber(direction);
|
||||
};
|
||||
adders[direction] = add; for (const rule of initial[direction] || []) add(rule); renumber(direction);
|
||||
block.append(button("addHeader", () => { add(); sync(); }, "quiet location-add-header"));
|
||||
}
|
||||
target.addEventListener("input", () => {
|
||||
try {
|
||||
const value = validateHeaders(JSON.parse(target.value));
|
||||
for (const direction of ["request", "response"]) {
|
||||
holders[direction].replaceChildren();
|
||||
for (const rule of value[direction] || []) adders[direction](rule);
|
||||
renumber(direction);
|
||||
}
|
||||
target.setCustomValidity(""); onChange();
|
||||
} catch (error) { target.setCustomValidity(error.message || t("headersInvalid")); }
|
||||
});
|
||||
|
||||
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
|
||||
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["compression", "vhCompression"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
|
||||
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), expose_only_hits: tri("vh-cache-only-hits"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null, unsafe_force: { enabled: yes("vh-cache-force-enabled"), targets: lines(str("vh-cache-force-targets")) } }; }
|
||||
function collectCompression() { const tri = name => str(name) === "" ? null : str(name) === "on"; return { enabled: tri("vh-compression-enabled"), zstd: tri("vh-compression-zstd"), br: tri("vh-compression-br"), gzip: tri("vh-compression-gzip"), deflate: tri("vh-compression-deflate") }; }
|
||||
function collectTransform() {
|
||||
const tri = name => str(name) === "" ? null : str(name) === "on";
|
||||
return {
|
||||
enabled: yes("vh-transform-enabled"),
|
||||
javascript: tri("vh-transform-javascript"),
|
||||
css: tri("vh-transform-css"),
|
||||
html: tri("vh-transform-html"),
|
||||
svg: tri("vh-transform-svg"),
|
||||
json: tri("vh-transform-json"),
|
||||
extensions: yes("vh-transform-extensions-custom") ? lines(str("vh-transform-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null
|
||||
};
|
||||
}
|
||||
async function purge(id) { if (!(await appConfirm(t("vhPurgeConfirm"), { danger: true }))) return; try { const result = await api(`/api/cache/purge?vhost=${encodeURIComponent(id)}`, { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } }
|
||||
function validateHeaders(value) {
|
||||
const fail = () => { throw new Error(t("headersInvalid")); };
|
||||
if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !["request", "response"].includes(k))) fail();
|
||||
for (const direction of ["request", "response"]) {
|
||||
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
|
||||
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
|
||||
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
|
||||
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
|
||||
}
|
||||
} return value;
|
||||
}
|
||||
function headerEditors(parent, initial, target = read("headers"), onChange = changed) {
|
||||
const holders = {}, adders = {}, emptyStates = {};
|
||||
const renumber = direction => {
|
||||
const rows = holders[direction];
|
||||
[...rows.children].forEach((row, index) => {
|
||||
const title = row.querySelector(".vh-header-rule-title");
|
||||
if (title) title.textContent = `${t("headerRule")} ${index + 1}`;
|
||||
});
|
||||
if (emptyStates[direction]) emptyStates[direction].hidden = rows.children.length > 0;
|
||||
};
|
||||
const sync = () => {
|
||||
const result = {};
|
||||
for (const direction of ["request", "response"]) result[direction] = [...holders[direction].children].map(row => {
|
||||
const op = str("rule-op", row), name = str("rule-name", row), value = read("rule-value", row).value;
|
||||
return op === "remove" ? { op, name } : { op, name, value };
|
||||
});
|
||||
target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange();
|
||||
};
|
||||
for (const direction of ["request", "response"]) {
|
||||
const block = el("section", undefined, "vh-wide location-header-direction location-header-editor"); block.dataset.headerDirection = direction;
|
||||
const heading = el("div", undefined, "location-header-direction-head");
|
||||
heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine"));
|
||||
block.append(heading);
|
||||
const empty = text("p", "headerRulesEmpty", "fine vh-header-empty"); emptyStates[direction] = empty; block.append(empty);
|
||||
const rows = el("div", undefined, "vh-header-rows"); holders[direction] = rows; block.append(rows); parent.append(block);
|
||||
const add = (value = { op: "set", name: "", value: "" }) => {
|
||||
if (rows.children.length >= 32) { toast(t("headerLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-header-row");
|
||||
const rowHead = el("div", undefined, "vh-header-rule-head"), rowTitle = el("strong", "", "vh-header-rule-title");
|
||||
rowHead.append(rowTitle, button("remove", () => { row.remove(); renumber(direction); sync(); })); row.append(rowHead);
|
||||
const fields = el("div", undefined, "vh-header-rule-fields");
|
||||
field(fields, "rule-op", "headerOperation", value.op, "select", { values: [["set", "header_set"], ["append", "header_append"], ["remove", "header_remove"]] });
|
||||
const name = field(fields, "rule-name", "headerName", value.name, "text", { maxLength: 128 }); name.placeholder = "X-App-Header";
|
||||
const ruleValue = field(fields, "rule-value", "headerValue", value.value ?? "", "text", { maxLength: 8192 }); ruleValue.placeholder = "value";
|
||||
row.append(fields);
|
||||
const enabled = () => { ruleValue.disabled = str("rule-op", row) === "remove"; row.classList.toggle("is-remove", ruleValue.disabled); };
|
||||
row.addEventListener("change", () => { enabled(); sync(); }); row.addEventListener("input", sync); rows.append(row); enabled(); renumber(direction);
|
||||
};
|
||||
adders[direction] = add; for (const rule of initial[direction] || []) add(rule); renumber(direction);
|
||||
block.append(button("addHeader", () => { add(); sync(); }, "quiet location-add-header"));
|
||||
note(parent, "locationHeadersHelp");
|
||||
return { read: () => validateHeaders(JSON.parse(target.value)) };
|
||||
}
|
||||
function suspendDraft() {
|
||||
const state = {
|
||||
editing, revision, label: $("vhost-editing").textContent,
|
||||
fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")),
|
||||
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, uaRatesReader, pagesEditor, backendHealthEditorControl,
|
||||
stagedCertificates, uploading
|
||||
};
|
||||
const title = read("id")?.value.trim() || editing || t("draftUntitled");
|
||||
draftEpoch++; editing = null; revision = null; dirty = false; stagedCertificates = []; uploading = false; dialog.close();
|
||||
return { title, state };
|
||||
}
|
||||
function restoreDraft(state) {
|
||||
draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs);
|
||||
editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl;
|
||||
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; uaRatesReader = state.uaRatesReader || (() => []); pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null;
|
||||
stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
|
||||
if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
function closeEditor() {
|
||||
if (dirty) return window.rustProxyDrafts.dock("vhost");
|
||||
draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); if (dialog.open) dialog.close(); return true;
|
||||
}
|
||||
$("vhost-close").addEventListener("click", closeEditor); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
|
||||
function administrationListenCandidates() {
|
||||
return [
|
||||
envelope?.config.administration?.listen,
|
||||
latestStatus?.administration?.locked_listen,
|
||||
latestStatus?.administration?.desired?.listen,
|
||||
latestStatus?.administration?.active?.listen,
|
||||
latestStatus?.admin_listen,
|
||||
].map(value => String(value || "").trim()).filter((value, index, values) => value && values.indexOf(value) === index);
|
||||
}
|
||||
function adminListenerBackendCandidate(raw) {
|
||||
const splitSocket = value => {
|
||||
const textValue = String(value || "").trim();
|
||||
if (!textValue) return null;
|
||||
if (textValue.startsWith("[")) {
|
||||
const match = textValue.match(/^\[([^\]]+)\]:(\d+)$/); return match ? { host: match[1].toLowerCase(), port: Number(match[2]) } : null;
|
||||
}
|
||||
target.addEventListener("input", () => {
|
||||
try {
|
||||
const value = validateHeaders(JSON.parse(target.value));
|
||||
for (const direction of ["request", "response"]) {
|
||||
holders[direction].replaceChildren();
|
||||
for (const rule of value[direction] || []) adders[direction](rule);
|
||||
renumber(direction);
|
||||
}
|
||||
target.setCustomValidity(""); onChange();
|
||||
} catch (error) { target.setCustomValidity(error.message || t("headersInvalid")); }
|
||||
});
|
||||
note(parent, "locationHeadersHelp");
|
||||
return { read: () => validateHeaders(JSON.parse(target.value)) };
|
||||
}
|
||||
function suspendDraft() {
|
||||
const state = {
|
||||
editing, revision, label: $("vhost-editing").textContent,
|
||||
fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")),
|
||||
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, uaRatesReader, pagesEditor, backendHealthEditorControl,
|
||||
stagedCertificates, uploading
|
||||
};
|
||||
const title = read("id")?.value.trim() || editing || t("draftUntitled");
|
||||
draftEpoch++; editing = null; revision = null; dirty = false; stagedCertificates = []; uploading = false; dialog.close();
|
||||
return { title, state };
|
||||
}
|
||||
function restoreDraft(state) {
|
||||
draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs);
|
||||
editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl;
|
||||
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; uaRatesReader = state.uaRatesReader || (() => []); pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null;
|
||||
stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
|
||||
if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
function closeEditor() {
|
||||
if (dirty) return window.rustProxyDrafts.dock("vhost");
|
||||
draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); if (dialog.open) dialog.close(); return true;
|
||||
}
|
||||
$("vhost-close").addEventListener("click", closeEditor); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
|
||||
function administrationListenCandidates() {
|
||||
return [
|
||||
envelope?.config.administration?.listen,
|
||||
latestStatus?.administration?.locked_listen,
|
||||
latestStatus?.administration?.desired?.listen,
|
||||
latestStatus?.administration?.active?.listen,
|
||||
latestStatus?.admin_listen,
|
||||
].map(value => String(value || "").trim()).filter((value, index, values) => value && values.indexOf(value) === index);
|
||||
}
|
||||
function adminListenerBackendCandidate(raw) {
|
||||
const splitSocket = value => {
|
||||
const textValue = String(value || "").trim();
|
||||
if (!textValue) return null;
|
||||
if (textValue.startsWith("[")) {
|
||||
const match = textValue.match(/^\[([^\]]+)\]:(\d+)$/); return match ? { host: match[1].toLowerCase(), port: Number(match[2]) } : null;
|
||||
}
|
||||
const at = textValue.lastIndexOf(":"); if (at <= 0) return null;
|
||||
const port = Number(textValue.slice(at + 1)); return Number.isInteger(port) ? { host: textValue.slice(0, at).toLowerCase(), port } : null;
|
||||
};
|
||||
const adminListeners = administrationListenCandidates().map(splitSocket).filter(Boolean);
|
||||
if (!adminListeners.length) return null;
|
||||
let parsed; try { parsed = new URL(String(raw || "").trim()); } catch { return false; }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const loopback = value => value === 'localhost' || value === '::1' || /^127(?:\.\d{1,3}){3}$/.test(value);
|
||||
return adminListeners.some(admin => {
|
||||
if (port !== admin.port) return false;
|
||||
const wildcard = admin.host === '0.0.0.0' || admin.host === '::' || admin.host === '[::]';
|
||||
return host === admin.host || (wildcard && loopback(host)) || (loopback(admin.host) && loopback(host));
|
||||
});
|
||||
}
|
||||
const at = textValue.lastIndexOf(":"); if (at <= 0) return null;
|
||||
const port = Number(textValue.slice(at + 1)); return Number.isInteger(port) ? { host: textValue.slice(0, at).toLowerCase(), port } : null;
|
||||
};
|
||||
const adminListeners = administrationListenCandidates().map(splitSocket).filter(Boolean);
|
||||
if (!adminListeners.length) return null;
|
||||
let parsed; try { parsed = new URL(String(raw || "").trim()); } catch { return false; }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const loopback = value => value === 'localhost' || value === '::1' || /^127(?:\.\d{1,3}){3}$/.test(value);
|
||||
return adminListeners.some(admin => {
|
||||
if (port !== admin.port) return false;
|
||||
const wildcard = admin.host === '0.0.0.0' || admin.host === '::' || admin.host === '[::]';
|
||||
return host === admin.host || (wildcard && loopback(host)) || (loopback(admin.host) && loopback(host));
|
||||
});
|
||||
}
|
||||
|
||||
function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`, url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }) {
|
||||
if (parent.children.length >= 32) { toast(t("vhRowLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
|
||||
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
|
||||
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
|
||||
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); backendHealthEditorControl?.refresh(); changed(); }); head.append(remove); row.append(head);
|
||||
function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`, url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }) {
|
||||
if (parent.children.length >= 32) { toast(t("vhRowLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
|
||||
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
|
||||
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
|
||||
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); backendHealthEditorControl?.refresh(); changed(); }); head.append(remove); row.append(head);
|
||||
|
||||
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
|
||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
|
||||
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
|
||||
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
|
||||
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
|
||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
|
||||
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
|
||||
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
|
||||
|
||||
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
|
||||
const controls = {};
|
||||
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain], ["member-health-enabled", "backendHealthEnabled", value.health_check_enabled !== false]]) controls[name] = field(routing, name, key, checked, "checkbox");
|
||||
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
|
||||
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
|
||||
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
|
||||
const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol();
|
||||
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
|
||||
const controls = {};
|
||||
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain], ["member-health-enabled", "backendHealthEnabled", value.health_check_enabled !== false]]) controls[name] = field(routing, name, key, checked, "checkbox");
|
||||
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
|
||||
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
|
||||
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
|
||||
const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol();
|
||||
|
||||
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
|
||||
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
const riskGate = el("div", undefined, "vh-wide vh-local-risk-gate"); extra.append(riskGate);
|
||||
const localRisk = field(riskGate, "member-local-risk", "vhLocalListenerRisk", value.accept_local_listener_risk === true, "checkbox");
|
||||
localRisk.addEventListener("change", async () => { if (localRisk.checked && !(await appConfirm(t("vhLocalListenerRiskConfirm"), { danger: true }))) localRisk.checked = false; });
|
||||
note(riskGate, "vhLocalListenerRiskHelp");
|
||||
field(extra, "member-tls-name", "vhTlsServerName", value.tls_server_name || "", "text", { maxLength: 253, placeholder: "backend.example.internal" });
|
||||
const tlsVerify = field(extra, "member-tls-verify", "vhTlsVerify", value.tls_verify === true, "checkbox"); note(extra, "vhTlsVerifyHelp");
|
||||
field(extra, "member-ca", "vhCa", value.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
|
||||
note(extra, "vhBackendCapacityHelp");
|
||||
// The per-member settings live in the Health tab. Keep one persistent form
|
||||
// per backend, so switching the selection never discards unsaved edits.
|
||||
const healthPanel = el("section", undefined, "vh-grid vh-backend-health-panel");
|
||||
const customHealth = field(healthPanel, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
|
||||
const health = value.health_check || {};
|
||||
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); healthPanel.append(healthFields);
|
||||
field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 });
|
||||
field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 });
|
||||
field(healthFields, "member-health-interval", "vhInterval", health.interval_ms ?? 5000, "number", { min: 250, max: 300000, required: true });
|
||||
field(healthFields, "member-health-timeout", "vhTimeout", health.timeout_ms ?? 2000, "number", { min: 50, max: 60000, required: true });
|
||||
field(healthFields, "member-health-slow", "vhSlow", health.slow_response_ms ?? 1500, "number", { min: 0, max: 60000, required: true });
|
||||
field(healthFields, "member-health-fall", "vhFall", health.fall ?? 3, "number", { min: 1, max: 20, required: true });
|
||||
field(healthFields, "member-health-rise", "vhRise", health.rise ?? 2, "number", { min: 1, max: 20, required: true });
|
||||
field(healthFields, "member-health-min", "vhStatusMin", health.expected_status_min ?? 200, "number", { min: 100, max: 599, required: true });
|
||||
field(healthFields, "member-health-max", "vhStatusMax", health.expected_status_max ?? 399, "number", { min: 100, max: 599, required: true });
|
||||
const syncHealth = () => {
|
||||
const editable = controls["member-health-enabled"].checked && customHealth.checked;
|
||||
healthFields.hidden = !customHealth.checked;
|
||||
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !editable; });
|
||||
};
|
||||
let customInitialized = value.health_check != null;
|
||||
customHealth.addEventListener("change", () => {
|
||||
if (customHealth.checked && !customInitialized) { inheritBackendHealthInputs(healthPanel); customInitialized = true; }
|
||||
syncHealth();
|
||||
});
|
||||
controls["member-health-enabled"].addEventListener("change", () => { syncHealth(); backendHealthEditorControl?.refresh(); });
|
||||
row.memberHealthPanel = healthPanel;
|
||||
row.memberHealthEnabled = controls["member-health-enabled"];
|
||||
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
|
||||
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
const riskGate = el("div", undefined, "vh-wide vh-local-risk-gate"); extra.append(riskGate);
|
||||
const localRisk = field(riskGate, "member-local-risk", "vhLocalListenerRisk", value.accept_local_listener_risk === true, "checkbox");
|
||||
localRisk.addEventListener("change", async () => { if (localRisk.checked && !(await appConfirm(t("vhLocalListenerRiskConfirm"), { danger: true }))) localRisk.checked = false; });
|
||||
note(riskGate, "vhLocalListenerRiskHelp");
|
||||
field(extra, "member-tls-name", "vhTlsServerName", value.tls_server_name || "", "text", { maxLength: 253, placeholder: "backend.example.internal" });
|
||||
const tlsVerify = field(extra, "member-tls-verify", "vhTlsVerify", value.tls_verify === true, "checkbox"); note(extra, "vhTlsVerifyHelp");
|
||||
field(extra, "member-ca", "vhCa", value.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
|
||||
note(extra, "vhBackendCapacityHelp");
|
||||
// The per-member settings live in the Health tab. Keep one persistent form
|
||||
// per backend, so switching the selection never discards unsaved edits.
|
||||
const healthPanel = el("section", undefined, "vh-grid vh-backend-health-panel");
|
||||
const customHealth = field(healthPanel, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
|
||||
const health = value.health_check || {};
|
||||
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); healthPanel.append(healthFields);
|
||||
field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 });
|
||||
field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 });
|
||||
field(healthFields, "member-health-interval", "vhInterval", health.interval_ms ?? 5000, "number", { min: 250, max: 300000, required: true });
|
||||
field(healthFields, "member-health-timeout", "vhTimeout", health.timeout_ms ?? 2000, "number", { min: 50, max: 60000, required: true });
|
||||
field(healthFields, "member-health-slow", "vhSlow", health.slow_response_ms ?? 1500, "number", { min: 0, max: 60000, required: true });
|
||||
field(healthFields, "member-health-fall", "vhFall", health.fall ?? 3, "number", { min: 1, max: 20, required: true });
|
||||
field(healthFields, "member-health-rise", "vhRise", health.rise ?? 2, "number", { min: 1, max: 20, required: true });
|
||||
field(healthFields, "member-health-min", "vhStatusMin", health.expected_status_min ?? 200, "number", { min: 100, max: 599, required: true });
|
||||
field(healthFields, "member-health-max", "vhStatusMax", health.expected_status_max ?? 399, "number", { min: 100, max: 599, required: true });
|
||||
const syncHealth = () => {
|
||||
const editable = controls["member-health-enabled"].checked && customHealth.checked;
|
||||
healthFields.hidden = !customHealth.checked;
|
||||
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !editable; });
|
||||
};
|
||||
let customInitialized = value.health_check != null;
|
||||
customHealth.addEventListener("change", () => {
|
||||
if (customHealth.checked && !customInitialized) { inheritBackendHealthInputs(healthPanel); customInitialized = true; }
|
||||
syncHealth();
|
||||
row.append(advanced);
|
||||
});
|
||||
controls["member-health-enabled"].addEventListener("change", () => { syncHealth(); backendHealthEditorControl?.refresh(); });
|
||||
row.memberHealthPanel = healthPanel;
|
||||
row.memberHealthEnabled = controls["member-health-enabled"];
|
||||
syncHealth();
|
||||
row.append(advanced);
|
||||
|
||||
const actions = el("div", undefined, "vh-backend-actions");
|
||||
actions.append(button("vhDebugOriginPreset", async () => {
|
||||
if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return;
|
||||
if (!url.value.trim()) url.value = "https://127.0.0.1:9000";
|
||||
controls["member-pool"].checked = true; controls["member-private"].checked = true;
|
||||
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed();
|
||||
}));
|
||||
actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions);
|
||||
const actions = el("div", undefined, "vh-backend-actions");
|
||||
actions.append(button("vhDebugOriginPreset", async () => {
|
||||
if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return;
|
||||
if (!url.value.trim()) url.value = "https://127.0.0.1:9000";
|
||||
controls["member-pool"].checked = true; controls["member-private"].checked = true;
|
||||
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed();
|
||||
}));
|
||||
actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions);
|
||||
|
||||
const syncIdentity = () => { title.textContent = id.value.trim() || t("backend"); endpoint.textContent = url.value.trim() || "-"; };
|
||||
const syncRisk = (clearWhenHidden = false) => {
|
||||
const show = adminListenerBackendCandidate(url.value);
|
||||
riskGate.hidden = show !== true;
|
||||
if (clearWhenHidden && show === false) localRisk.checked = false;
|
||||
};
|
||||
id.addEventListener("input", () => { syncIdentity(); backendHealthEditorControl?.refresh(); }); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); backendHealthEditorControl?.refresh(); }); url.addEventListener("change", () => syncRisk(true));
|
||||
row.syncLocalRisk = () => syncRisk(false);
|
||||
syncIdentity(); syncRisk(false); parent.append(row); backendHealthEditorControl?.refresh();
|
||||
const syncIdentity = () => { title.textContent = id.value.trim() || t("backend"); endpoint.textContent = url.value.trim() || "-"; };
|
||||
const syncRisk = (clearWhenHidden = false) => {
|
||||
const show = adminListenerBackendCandidate(url.value);
|
||||
riskGate.hidden = show !== true;
|
||||
if (clearWhenHidden && show === false) localRisk.checked = false;
|
||||
};
|
||||
id.addEventListener("input", () => { syncIdentity(); backendHealthEditorControl?.refresh(); }); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); backendHealthEditorControl?.refresh(); }); url.addEventListener("change", () => syncRisk(true));
|
||||
row.syncLocalRisk = () => syncRisk(false);
|
||||
syncIdentity(); syncRisk(false); parent.append(row); backendHealthEditorControl?.refresh();
|
||||
}
|
||||
|
||||
function countryRateEditor(parent, values = []) {
|
||||
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhCountryRateLimit"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "country-rate-country", "vhCountry", value.country || "", "text", { required: true, maxLength: 2, pattern: "[A-Za-z]{2}" });
|
||||
field(row, "country-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "country-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "country-rate-burst", "vhRateBurst", value.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddCountryRate", () => { add(); changed(); }));
|
||||
return () => [...rows.children].map(row => ({
|
||||
country: str("country-rate-country", row).toUpperCase(), requests: num("country-rate-requests", row),
|
||||
window_seconds: num("country-rate-window", row), burst: num("country-rate-burst", row)
|
||||
}));
|
||||
}
|
||||
function debugOriginIssues(members) {
|
||||
return members.filter(member => {
|
||||
let parsed; try { parsed = new URL(member.url); } catch { return false; }
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
|
||||
if (parsed.protocol !== "https:" || !ipHost) return false;
|
||||
return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify;
|
||||
});
|
||||
}
|
||||
function aclEditor(parent, initial, onChange = () => { }) {
|
||||
const enabled = field(parent, "acl-use", "aclUse", !!initial, "checkbox"), body = el("div", undefined, "vh-wide vh-grid"); parent.append(body);
|
||||
const policy = field(body, "acl-default", "aclDefault", initial?.default || "deny", "select", { values: [["deny", "vhDeny"], ["allow", "aclAllow"]] }), rows = el("div", undefined, "vh-wide"); body.append(rows);
|
||||
function add(value = { network: "", action: "allow" }) { const row = el("div", undefined, "acl-rule"); field(row, "network", "aclNetwork", value.network, "text", { maxLength: 64 }); field(row, "action", "aclAction", value.action, "select", { values: [["allow", "aclAllow"], ["deny", "vhDeny"]] }); row.append(button("remove", () => { row.remove(); onChange(); })); rows.append(row); }
|
||||
body.append(button("aclAdd", () => { add(); onChange(); })); for (const rule of initial?.rules || []) add(rule);
|
||||
const sync = () => { body.hidden = !enabled.checked; onChange(); }; enabled.addEventListener("change", sync); body.addEventListener("input", onChange); body.addEventListener("change", onChange); body.hidden = !enabled.checked;
|
||||
note(parent, "aclHelp"); return { read: () => enabled.checked ? { default: policy.value, rules: [...rows.children].map(r => ({ network: str("network", r), action: str("action", r) })) } : null };
|
||||
}
|
||||
function openLocationHelp(topic = "locations") {
|
||||
const dialog = $("location-help-dialog"), title = $("location-help-title"), body = $("location-help-body");
|
||||
if (!dialog || !title || !body) return;
|
||||
title.textContent = t(topic === "headers" ? "headersDocsTitle" : "locationDocsTitle");
|
||||
const section = (headingKey, keys, cls = "") => {
|
||||
const box = el("section", undefined, `location-doc-section ${cls}`.trim());
|
||||
if (headingKey) box.append(text("h3", headingKey));
|
||||
for (const key of keys) box.append(el("p", t(key)));
|
||||
return box;
|
||||
};
|
||||
if (topic === "headers") {
|
||||
body.replaceChildren(
|
||||
el("p", t("headersDocsIntro"), "location-doc-intro"),
|
||||
section("locationHeaders", ["headersDocsSet", "headersDocsAppend", "headersDocsRemove"]),
|
||||
section("advanced", ["headersDocsProtected", "headersDocsOrder"])
|
||||
);
|
||||
} else {
|
||||
body.replaceChildren(
|
||||
el("p", t("locationDocsIntro"), "location-doc-intro"),
|
||||
section("locationDocsMatchHeading", ["locationDocsPrefix", "locationDocsExact", "locationDocsRegex"]),
|
||||
section("locationDocsActionHeading", ["locationDocsReturn", "locationDocsRewrite"]),
|
||||
section("locationDocsPolicyHeading", ["locationDocsAccess", "locationDocsLimits"]),
|
||||
section("locationDocsExamplesHeading", ["locationDocsExampleExact", "locationDocsExampleStatic", "locationDocsExampleRegex"], "location-doc-examples")
|
||||
);
|
||||
}
|
||||
|
||||
function countryRateEditor(parent, values = []) {
|
||||
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhCountryRateLimit"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "country-rate-country", "vhCountry", value.country || "", "text", { required: true, maxLength: 2, pattern: "[A-Za-z]{2}" });
|
||||
field(row, "country-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "country-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "country-rate-burst", "vhRateBurst", value.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddCountryRate", () => { add(); changed(); }));
|
||||
return () => [...rows.children].map(row => ({
|
||||
country: str("country-rate-country", row).toUpperCase(), requests: num("country-rate-requests", row),
|
||||
window_seconds: num("country-rate-window", row), burst: num("country-rate-burst", row)
|
||||
}));
|
||||
}
|
||||
function debugOriginIssues(members) {
|
||||
return members.filter(member => {
|
||||
let parsed; try { parsed = new URL(member.url); } catch { return false; }
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
|
||||
if (parsed.protocol !== "https:" || !ipHost) return false;
|
||||
return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify;
|
||||
dialog.showModal();
|
||||
}
|
||||
function locationsEditor(parent, values) {
|
||||
const toolbar = el("div", undefined, "vh-wide location-toolbar"), rows = el("div", undefined, "vh-wide vh-locations");
|
||||
const addTop = button("locationAdd", () => { add(); sync(); }, "primary");
|
||||
toolbar.append(addTop, button("documentation", () => openLocationHelp("locations")));
|
||||
parent.append(toolbar); note(parent, "locationHelp"); parent.append(rows); let reloading = false;
|
||||
function readRows() {
|
||||
return [...rows.children].map(r => {
|
||||
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
|
||||
const handled = action === "proxy" || action === "web_server";
|
||||
const internal = yes("location-internal", r), fallbackTarget = str("location-error-target", r);
|
||||
const fallbackStatuses = lines(str("location-error-statuses", r)).map(value => Number(value));
|
||||
return {
|
||||
id: str("location-id", r), internal, path_prefix: internal ? "/" : str("location-path", r), match_type: internal ? "prefix" : str("location-match", r),
|
||||
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
|
||||
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
|
||||
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
|
||||
acl_profile_id: str("location-acl-profile", r) || null,
|
||||
acl: r.readAcl(),
|
||||
country_access: { mode: str("location-country-mode", r), countries: lines(str("location-country-list", r)).map(value => value.toUpperCase()) },
|
||||
method_policy: { mode: str("location-method-mode", r), methods: lines(str("location-method-list", r)).map(value => value.toUpperCase()) },
|
||||
headers: r.readHeaders(),
|
||||
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
|
||||
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null,
|
||||
redirect: action === "redirect" ? { status: Number(str("location-redirect-status", r)), target: str("location-redirect-target", r), rewrite_pattern: nullable("location-redirect-pattern", r), preserve_query: yes("location-redirect-query", r) } : null,
|
||||
error_fallback: fallbackTarget ? { target: fallbackTarget, statuses: fallbackStatuses.length ? fallbackStatuses : [404] } : null
|
||||
};
|
||||
});
|
||||
}
|
||||
function aclEditor(parent, initial, onChange = () => { }) {
|
||||
const enabled = field(parent, "acl-use", "aclUse", !!initial, "checkbox"), body = el("div", undefined, "vh-wide vh-grid"); parent.append(body);
|
||||
const policy = field(body, "acl-default", "aclDefault", initial?.default || "deny", "select", { values: [["deny", "vhDeny"], ["allow", "aclAllow"]] }), rows = el("div", undefined, "vh-wide"); body.append(rows);
|
||||
function add(value = { network: "", action: "allow" }) { const row = el("div", undefined, "acl-rule"); field(row, "network", "aclNetwork", value.network, "text", { maxLength: 64 }); field(row, "action", "aclAction", value.action, "select", { values: [["allow", "aclAllow"], ["deny", "vhDeny"]] }); row.append(button("remove", () => { row.remove(); onChange(); })); rows.append(row); }
|
||||
body.append(button("aclAdd", () => { add(); onChange(); })); for (const rule of initial?.rules || []) add(rule);
|
||||
const sync = () => { body.hidden = !enabled.checked; onChange(); }; enabled.addEventListener("change", sync); body.addEventListener("input", onChange); body.addEventListener("change", onChange); body.hidden = !enabled.checked;
|
||||
note(parent, "aclHelp"); return { read: () => enabled.checked ? { default: policy.value, rules: [...rows.children].map(r => ({ network: str("network", r), action: str("action", r) })) } : null };
|
||||
}
|
||||
function openLocationHelp(topic = "locations") {
|
||||
const dialog = $("location-help-dialog"), title = $("location-help-title"), body = $("location-help-body");
|
||||
if (!dialog || !title || !body) return;
|
||||
title.textContent = t(topic === "headers" ? "headersDocsTitle" : "locationDocsTitle");
|
||||
const section = (headingKey, keys, cls = "") => {
|
||||
const box = el("section", undefined, `location-doc-section ${cls}`.trim());
|
||||
if (headingKey) box.append(text("h3", headingKey));
|
||||
for (const key of keys) box.append(el("p", t(key)));
|
||||
return box;
|
||||
};
|
||||
if (topic === "headers") {
|
||||
body.replaceChildren(
|
||||
el("p", t("headersDocsIntro"), "location-doc-intro"),
|
||||
section("locationHeaders", ["headersDocsSet", "headersDocsAppend", "headersDocsRemove"]),
|
||||
section("advanced", ["headersDocsProtected", "headersDocsOrder"])
|
||||
);
|
||||
} else {
|
||||
body.replaceChildren(
|
||||
el("p", t("locationDocsIntro"), "location-doc-intro"),
|
||||
section("locationDocsMatchHeading", ["locationDocsPrefix", "locationDocsExact", "locationDocsRegex"]),
|
||||
section("locationDocsActionHeading", ["locationDocsReturn", "locationDocsRewrite"]),
|
||||
section("locationDocsPolicyHeading", ["locationDocsAccess", "locationDocsLimits"]),
|
||||
section("locationDocsExamplesHeading", ["locationDocsExampleExact", "locationDocsExampleStatic", "locationDocsExampleRegex"], "location-doc-examples")
|
||||
);
|
||||
function sync() { if (reloading) return; try { read("paths").value = JSON.stringify(readRows(), null, 2); changed(); } catch { } }
|
||||
function memberOptions(select, chosen = select.value) { const base = text("option", "locationPool"); base.value = ""; select.replaceChildren(base, ...[...form.querySelectorAll(".vh-member")].map(m => { const id = str("member-id", m), o = el("option", `${id} / ${str("member-url", m)}`); o.value = id; return o; })); if (chosen && !Array.from(select.options).some(o => o.value === chosen)) { const o = el("option", `${chosen} / ${t("locationMissing")}`); o.value = chosen; select.append(o); } select.value = chosen; }
|
||||
function add(value = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("locationLimit"), true); return; }
|
||||
const row = el("details", undefined, "vh-location"); row.open = true;
|
||||
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
|
||||
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
|
||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
||||
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
||||
const internal = field(body, "location-internal", "locationInternal", value.internal === true, "checkbox");
|
||||
const initialAction = value.response ? "return" : value.redirect ? "redirect" : value.source?.type === "web_server" ? "web_server" : "proxy";
|
||||
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["redirect", "locationRedirectAction"], ["return", "locationReturnAction"]] });
|
||||
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
|
||||
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
|
||||
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
|
||||
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
|
||||
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
||||
field(handlingBody, "location-error-target", "locationErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(handlingBody, "location-error-statuses", "locationErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(handlingBody, "locationErrorFallbackHelp");
|
||||
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
||||
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
|
||||
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
|
||||
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
|
||||
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
|
||||
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
|
||||
const redirectBox = el("div", undefined, "vh-wide vh-grid location-redirect-box"); handlingBody.append(redirectBox);
|
||||
field(redirectBox, "location-redirect-status", "locationRedirectStatus", value.redirect?.status ?? 302, "select", { values: [["301", "locationRedirect301"], ["302", "locationRedirect302"], ["303", "locationRedirect303"], ["307", "locationRedirect307"], ["308", "locationRedirect308"]] });
|
||||
field(redirectBox, "location-redirect-target", "locationRedirectTarget", value.redirect?.target || "", "url", { maxLength: 2048 });
|
||||
field(redirectBox, "location-redirect-pattern", "locationRedirectPattern", value.redirect?.rewrite_pattern || "", "text", { maxLength: 1024 });
|
||||
field(redirectBox, "location-redirect-query", "locationRedirectQuery", value.redirect?.preserve_query !== false, "checkbox");
|
||||
note(redirectBox, "locationRedirectHelp");
|
||||
note(handlingBody, "locationRewriteHelp");
|
||||
|
||||
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra);
|
||||
const locationProfile = field(accessBody, "location-acl-profile", "locationAclProfile", value.acl_profile_id || "", "select");
|
||||
const locationNoProfile = text("option", "vhAclProfileNone"); locationNoProfile.value = "";
|
||||
locationProfile.append(locationNoProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; }));
|
||||
locationProfile.value = value.acl_profile_id || "";
|
||||
field(accessBody, "location-method-mode", "locationMethodMode", value.method_policy?.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
|
||||
field(accessBody, "location-method-list", "locationMethodList", (value.method_policy?.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(accessBody, "locationMethodHelp");
|
||||
field(accessBody, "location-country-mode", "locationCountryMode", value.country_access?.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(accessBody, "location-country-list", "locationCountryList", (value.country_access?.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(accessBody, "locationCountryHelp");
|
||||
const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
|
||||
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
|
||||
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
|
||||
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
|
||||
headerToolbar.append(visualButton, jsonButton, docsButton); headerBody.append(headerToolbar, visualPane, jsonPane);
|
||||
const raw = field(jsonPane, "location-headers", "headersAdvancedJson", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 8, maxLength: 65536 });
|
||||
const editor = headerEditors(visualPane, value.headers || {}, raw, sync); row.readHeaders = () => editor.read();
|
||||
|
||||
const preview = el("section", undefined, "vh-wide location-preview");
|
||||
preview.append(text("h4", "locationPreviewTitle"));
|
||||
const previewStatus = text("p", "locationPreviewDisabled", "fine location-preview-disabled"); preview.append(previewStatus);
|
||||
const previewGrid = el("dl", undefined, "location-preview-grid"), previewValues = {};
|
||||
for (const [name, key] of [["match", "locationPreviewMatch"], ["handling", "locationPreviewHandling"], ["rewrite", "locationPreviewRewrite"], ["cache", "locationPreviewCache"], ["access", "locationPreviewAccess"], ["headers", "locationPreviewHeaders"]]) {
|
||||
const item = el("div"); item.append(text("dt", key)); const output = el("dd", "-"); previewValues[name] = output; item.append(output); previewGrid.append(item);
|
||||
}
|
||||
dialog.showModal();
|
||||
}
|
||||
function locationsEditor(parent, values) {
|
||||
const toolbar = el("div", undefined, "vh-wide location-toolbar"), rows = el("div", undefined, "vh-wide vh-locations");
|
||||
const addTop = button("locationAdd", () => { add(); sync(); }, "primary");
|
||||
toolbar.append(addTop, button("documentation", () => openLocationHelp("locations")));
|
||||
parent.append(toolbar); note(parent, "locationHelp"); parent.append(rows); let reloading = false;
|
||||
function readRows() {
|
||||
return [...rows.children].map(r => {
|
||||
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
|
||||
const handled = action === "proxy" || action === "web_server";
|
||||
const internal = yes("location-internal", r), fallbackTarget = str("location-error-target", r);
|
||||
const fallbackStatuses = lines(str("location-error-statuses", r)).map(value => Number(value));
|
||||
return {
|
||||
id: str("location-id", r), internal, path_prefix: internal ? "/" : str("location-path", r), match_type: internal ? "prefix" : str("location-match", r),
|
||||
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
|
||||
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
|
||||
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
|
||||
acl_profile_id: str("location-acl-profile", r) || null,
|
||||
acl: r.readAcl(),
|
||||
country_access: { mode: str("location-country-mode", r), countries: lines(str("location-country-list", r)).map(value => value.toUpperCase()) },
|
||||
method_policy: { mode: str("location-method-mode", r), methods: lines(str("location-method-list", r)).map(value => value.toUpperCase()) },
|
||||
headers: r.readHeaders(),
|
||||
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
|
||||
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null,
|
||||
redirect: action === "redirect" ? { status: Number(str("location-redirect-status", r)), target: str("location-redirect-target", r), rewrite_pattern: nullable("location-redirect-pattern", r), preserve_query: yes("location-redirect-query", r) } : null,
|
||||
error_fallback: fallbackTarget ? { target: fallbackTarget, statuses: fallbackStatuses.length ? fallbackStatuses : [404] } : null
|
||||
};
|
||||
});
|
||||
}
|
||||
function sync() { if (reloading) return; try { read("paths").value = JSON.stringify(readRows(), null, 2); changed(); } catch { } }
|
||||
function memberOptions(select, chosen = select.value) { const base = text("option", "locationPool"); base.value = ""; select.replaceChildren(base, ...[...form.querySelectorAll(".vh-member")].map(m => { const id = str("member-id", m), o = el("option", `${id} / ${str("member-url", m)}`); o.value = id; return o; })); if (chosen && !Array.from(select.options).some(o => o.value === chosen)) { const o = el("option", `${chosen} / ${t("locationMissing")}`); o.value = chosen; select.append(o); } select.value = chosen; }
|
||||
function add(value = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("locationLimit"), true); return; }
|
||||
const row = el("details", undefined, "vh-location"); row.open = true;
|
||||
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
|
||||
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
|
||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
||||
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
||||
const internal = field(body, "location-internal", "locationInternal", value.internal === true, "checkbox");
|
||||
const initialAction = value.response ? "return" : value.redirect ? "redirect" : value.source?.type === "web_server" ? "web_server" : "proxy";
|
||||
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["redirect", "locationRedirectAction"], ["return", "locationReturnAction"]] });
|
||||
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
|
||||
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
|
||||
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
|
||||
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
preview.append(previewGrid); body.append(preview);
|
||||
|
||||
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
||||
field(handlingBody, "location-error-target", "locationErrorFallbackTarget", value.error_fallback?.target || "", "text", { maxLength: 64, pattern: "[A-Za-z0-9_-]{0,64}" });
|
||||
field(handlingBody, "location-error-statuses", "locationErrorFallbackStatuses", (value.error_fallback?.statuses || [404]).join(", "), "text", { maxLength: 64 });
|
||||
note(handlingBody, "locationErrorFallbackHelp");
|
||||
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
||||
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
|
||||
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
|
||||
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
|
||||
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
|
||||
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
|
||||
const redirectBox = el("div", undefined, "vh-wide vh-grid location-redirect-box"); handlingBody.append(redirectBox);
|
||||
field(redirectBox, "location-redirect-status", "locationRedirectStatus", value.redirect?.status ?? 302, "select", { values: [["301", "locationRedirect301"], ["302", "locationRedirect302"], ["303", "locationRedirect303"], ["307", "locationRedirect307"], ["308", "locationRedirect308"]] });
|
||||
field(redirectBox, "location-redirect-target", "locationRedirectTarget", value.redirect?.target || "", "url", { maxLength: 2048 });
|
||||
field(redirectBox, "location-redirect-pattern", "locationRedirectPattern", value.redirect?.rewrite_pattern || "", "text", { maxLength: 1024 });
|
||||
field(redirectBox, "location-redirect-query", "locationRedirectQuery", value.redirect?.preserve_query !== false, "checkbox");
|
||||
note(redirectBox, "locationRedirectHelp");
|
||||
note(handlingBody, "locationRewriteHelp");
|
||||
const actions = el("div", undefined, "vh-wide location-actions");
|
||||
const dedicated = button("locationAddBackend", () => { const members = form.querySelector(".vh-members"); let i = 1; const ids = new Set([...members.children].map(r => str("member-id", r))); while (ids.has(`location-origin-${i}`)) i++; const id = `location-origin-${i}`; memberRow(members, { id, url: "http://127.0.0.1:9001", weight: 1, enabled: true, pool_eligible: false, allow_private: false }); memberOptions(backend, id); sync(); activateTab("backends"); });
|
||||
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
|
||||
|
||||
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra);
|
||||
const locationProfile = field(accessBody, "location-acl-profile", "locationAclProfile", value.acl_profile_id || "", "select");
|
||||
const locationNoProfile = text("option", "vhAclProfileNone"); locationNoProfile.value = "";
|
||||
locationProfile.append(locationNoProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; }));
|
||||
locationProfile.value = value.acl_profile_id || "";
|
||||
field(accessBody, "location-method-mode", "locationMethodMode", value.method_policy?.mode || "off", "select", { values: [["off", "vhOff"], ["allow", "locationMethodAllow"], ["block", "locationMethodBlock"]] });
|
||||
field(accessBody, "location-method-list", "locationMethodList", (value.method_policy?.methods || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(accessBody, "locationMethodHelp");
|
||||
field(accessBody, "location-country-mode", "locationCountryMode", value.country_access?.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(accessBody, "location-country-list", "locationCountryList", (value.country_access?.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(accessBody, "locationCountryHelp");
|
||||
const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
|
||||
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
|
||||
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
|
||||
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
|
||||
headerToolbar.append(visualButton, jsonButton, docsButton); headerBody.append(headerToolbar, visualPane, jsonPane);
|
||||
const raw = field(jsonPane, "location-headers", "headersAdvancedJson", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 8, maxLength: 65536 });
|
||||
const editor = headerEditors(visualPane, value.headers || {}, raw, sync); row.readHeaders = () => editor.read();
|
||||
const syncUi = () => {
|
||||
const isReturn = action.value === "return", isRedirect = action.value === "redirect", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
|
||||
match.disabled = path.disabled = internal.checked;
|
||||
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
|
||||
webService.disabled = !isWeb; webService.required = isWeb;
|
||||
returnBox.hidden = !isReturn; redirectBox.hidden = !isRedirect; rewriteMode.disabled = isReturn || isRedirect; rewriteValue.disabled = isReturn || isRedirect || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
|
||||
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
|
||||
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
|
||||
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
|
||||
const id = str("location-id", row) || t("locationId"), pattern = internal.checked ? `@${id}` : (str("location-path", row) || "/");
|
||||
summary.textContent = `${id} · ${internal.checked ? t("locationInternal") : t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isRedirect ? "locationRedirectShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
|
||||
|
||||
const preview = el("section", undefined, "vh-wide location-preview");
|
||||
preview.append(text("h4", "locationPreviewTitle"));
|
||||
const previewStatus = text("p", "locationPreviewDisabled", "fine location-preview-disabled"); preview.append(previewStatus);
|
||||
const previewGrid = el("dl", undefined, "location-preview-grid"), previewValues = {};
|
||||
for (const [name, key] of [["match", "locationPreviewMatch"], ["handling", "locationPreviewHandling"], ["rewrite", "locationPreviewRewrite"], ["cache", "locationPreviewCache"], ["access", "locationPreviewAccess"], ["headers", "locationPreviewHeaders"]]) {
|
||||
const item = el("div"); item.append(text("dt", key)); const output = el("dd", "-"); previewValues[name] = output; item.append(output); previewGrid.append(item);
|
||||
}
|
||||
preview.append(previewGrid); body.append(preview);
|
||||
|
||||
const actions = el("div", undefined, "vh-wide location-actions");
|
||||
const dedicated = button("locationAddBackend", () => { const members = form.querySelector(".vh-members"); let i = 1; const ids = new Set([...members.children].map(r => str("member-id", r))); while (ids.has(`location-origin-${i}`)) i++; const id = `location-origin-${i}`; memberRow(members, { id, url: "http://127.0.0.1:9001", weight: 1, enabled: true, pool_eligible: false, allow_private: false }); memberOptions(backend, id); sync(); activateTab("backends"); });
|
||||
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
|
||||
|
||||
const syncUi = () => {
|
||||
const isReturn = action.value === "return", isRedirect = action.value === "redirect", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
|
||||
match.disabled = path.disabled = internal.checked;
|
||||
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
|
||||
webService.disabled = !isWeb; webService.required = isWeb;
|
||||
returnBox.hidden = !isReturn; redirectBox.hidden = !isRedirect; rewriteMode.disabled = isReturn || isRedirect; rewriteValue.disabled = isReturn || isRedirect || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
|
||||
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
|
||||
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
|
||||
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
|
||||
const id = str("location-id", row) || t("locationId"), pattern = internal.checked ? `@${id}` : (str("location-path", row) || "/");
|
||||
summary.textContent = `${id} · ${internal.checked ? t("locationInternal") : t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isRedirect ? "locationRedirectShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
|
||||
|
||||
previewStatus.hidden = yes("location-enabled", row);
|
||||
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
|
||||
if (isReturn) {
|
||||
const status = str("location-response-status", row) || "200", type = str("location-response-type", row) || "text/plain";
|
||||
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
|
||||
previewValues.rewrite.textContent = "-";
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else if (isRedirect) {
|
||||
const status = str("location-redirect-status", row) || "302", target = str("location-redirect-target", row) || "-", capture = str("location-redirect-pattern", row);
|
||||
previewValues.handling.textContent = `${t("locationRedirectAction")}: HTTP ${status} · ${target} · ${t("locationPreviewBackendSkipped")}`;
|
||||
previewValues.rewrite.textContent = capture || "-";
|
||||
previewStatus.hidden = yes("location-enabled", row);
|
||||
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
|
||||
if (isReturn) {
|
||||
const status = str("location-response-status", row) || "200", type = str("location-response-type", row) || "text/plain";
|
||||
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
|
||||
previewValues.rewrite.textContent = "-";
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else if (isRedirect) {
|
||||
const status = str("location-redirect-status", row) || "302", target = str("location-redirect-target", row) || "-", capture = str("location-redirect-pattern", row);
|
||||
previewValues.handling.textContent = `${t("locationRedirectAction")}: HTTP ${status} · ${target} · ${t("locationPreviewBackendSkipped")}`;
|
||||
previewValues.rewrite.textContent = capture || "-";
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else {
|
||||
if (isWeb) {
|
||||
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
|
||||
previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else {
|
||||
if (isWeb) {
|
||||
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
|
||||
previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else {
|
||||
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
|
||||
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
|
||||
}
|
||||
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
|
||||
const replacement = read("location-rewrite-value", row).value.trim();
|
||||
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
|
||||
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
|
||||
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
|
||||
}
|
||||
const accessParts = [];
|
||||
const profile = str("location-acl-profile", row); if (profile) accessParts.push(`${t("locationAclProfile")}: ${profile}`);
|
||||
const methodMode = str("location-method-mode", row); if (methodMode !== "off") accessParts.push(`${t("locationMethodMode")}: ${t(methodMode === "allow" ? "locationMethodAllow" : "locationMethodBlock")} ${lines(str("location-method-list", row)).join(", ")}`);
|
||||
const countryMode = str("location-country-mode", row); if (countryMode !== "off") accessParts.push(`${t("locationCountryMode")}: ${t(countryMode === "allow" ? "vhCountryAllow" : "vhCountryBlock")} ${lines(str("location-country-list", row)).join(", ")}`);
|
||||
if (yes("acl-use", row)) accessParts.push(t("locationPreviewCustomAcl"));
|
||||
previewValues.access.textContent = accessParts.length ? accessParts.join(" · ") : t("locationPreviewVhostAcl");
|
||||
try {
|
||||
const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0;
|
||||
previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`;
|
||||
} catch { previewValues.headers.textContent = t("headersInvalid"); }
|
||||
};
|
||||
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
|
||||
rows.append(row); syncUi();
|
||||
}
|
||||
// The main vhost backend is an internal fallback, not a Custom location.
|
||||
// An empty location list must stay empty so the editor cannot accidentally
|
||||
// delete or recreate the main route as user configuration.
|
||||
for (const value of values) add(value);
|
||||
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
|
||||
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
|
||||
// Advanced JSON remains an explicit escape hatch; edits rebuild visual rows.
|
||||
read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } });
|
||||
sync(); return readRows;
|
||||
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
|
||||
const replacement = read("location-rewrite-value", row).value.trim();
|
||||
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
|
||||
}
|
||||
const accessParts = [];
|
||||
const profile = str("location-acl-profile", row); if (profile) accessParts.push(`${t("locationAclProfile")}: ${profile}`);
|
||||
const methodMode = str("location-method-mode", row); if (methodMode !== "off") accessParts.push(`${t("locationMethodMode")}: ${t(methodMode === "allow" ? "locationMethodAllow" : "locationMethodBlock")} ${lines(str("location-method-list", row)).join(", ")}`);
|
||||
const countryMode = str("location-country-mode", row); if (countryMode !== "off") accessParts.push(`${t("locationCountryMode")}: ${t(countryMode === "allow" ? "vhCountryAllow" : "vhCountryBlock")} ${lines(str("location-country-list", row)).join(", ")}`);
|
||||
if (yes("acl-use", row)) accessParts.push(t("locationPreviewCustomAcl"));
|
||||
previewValues.access.textContent = accessParts.length ? accessParts.join(" · ") : t("locationPreviewVhostAcl");
|
||||
try {
|
||||
const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0;
|
||||
previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`;
|
||||
} catch { previewValues.headers.textContent = t("headersInvalid"); }
|
||||
};
|
||||
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
|
||||
rows.append(row); syncUi();
|
||||
}
|
||||
// The main vhost backend is an internal fallback, not a Custom location.
|
||||
// An empty location list must stay empty so the editor cannot accidentally
|
||||
// delete or recreate the main route as user configuration.
|
||||
for (const value of values) add(value);
|
||||
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
|
||||
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
|
||||
// Advanced JSON remains an explicit escape hatch; edits rebuild visual rows.
|
||||
read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } });
|
||||
sync(); return readRows;
|
||||
}
|
||||
@@ -55,8 +55,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
|
||||
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");
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
"use strict";
|
||||
// Per-member health editor shares the vhost form state from core.js.
|
||||
// Starting a custom override should copy the CURRENT shared policy once,
|
||||
// rather than surprising the operator with unrelated hardcoded defaults.
|
||||
function inheritBackendHealthInputs(panel) {
|
||||
for (const [local, shared] of [
|
||||
["member-health-mode", "health-mode"], ["member-health-path", "health-path"],
|
||||
["member-health-host", "health-host"], ["member-health-interval", "interval"],
|
||||
["member-health-timeout", "timeout"], ["member-health-slow", "slow"],
|
||||
["member-health-fall", "fall"], ["member-health-rise", "rise"],
|
||||
["member-health-min", "status-min"], ["member-health-max", "status-max"]
|
||||
]) {
|
||||
const source = read(shared), target = read(local, panel);
|
||||
if (source && target) target.value = source.value;
|
||||
}
|
||||
}
|
||||
function backendHealthEditor(parent, members) {
|
||||
const section = el("section", undefined, "vh-wide vh-backend-health-editor");
|
||||
section.append(text("h3", "backendHealthPerBackend"));
|
||||
note(section, "backendHealthSelectionHelp");
|
||||
const selection = field(section, "health-backend-select", "backendHealthSelect", "", "select");
|
||||
const empty = note(section, "backendHealthNoEnabled");
|
||||
const panels = el("div", undefined, "vh-health-member-panels"); section.append(panels); parent.append(section);
|
||||
let active = null, eligible = [];
|
||||
const refresh = () => {
|
||||
eligible = [...members.children].filter(row => row.memberHealthEnabled.checked);
|
||||
if (!eligible.includes(active)) active = eligible[0] || null;
|
||||
selection.replaceChildren(...eligible.map((row, index) => {
|
||||
const id = str("member-id", row) || t("backend");
|
||||
const address = str("member-url", row);
|
||||
const option = el("option", address ? `${id} — ${address}` : id);
|
||||
option.value = String(index); return option;
|
||||
}));
|
||||
selection.value = active ? String(eligible.indexOf(active)) : "";
|
||||
selection.disabled = eligible.length === 0;
|
||||
empty.hidden = eligible.length !== 0;
|
||||
panels.replaceChildren(...[...members.children].map(row => row.memberHealthPanel));
|
||||
for (const row of members.children) row.memberHealthPanel.hidden = row !== active;
|
||||
};
|
||||
selection.addEventListener("change", () => { active = eligible[Number(selection.value)] || null; refresh(); });
|
||||
refresh();
|
||||
return { refresh, reveal(row) { if (eligible.includes(row)) { active = row; refresh(); } } };
|
||||
function inheritBackendHealthInputs(panel) {
|
||||
for (const [local, shared] of [
|
||||
["member-health-mode", "health-mode"], ["member-health-path", "health-path"],
|
||||
["member-health-host", "health-host"], ["member-health-interval", "interval"],
|
||||
["member-health-timeout", "timeout"], ["member-health-slow", "slow"],
|
||||
["member-health-fall", "fall"], ["member-health-rise", "rise"],
|
||||
["member-health-min", "status-min"], ["member-health-max", "status-max"]
|
||||
]) {
|
||||
const source = read(shared), target = read(local, panel);
|
||||
if (source && target) target.value = source.value;
|
||||
}
|
||||
}
|
||||
function backendHealthEditor(parent, members) {
|
||||
const section = el("section", undefined, "vh-wide vh-backend-health-editor");
|
||||
section.append(text("h3", "backendHealthPerBackend"));
|
||||
note(section, "backendHealthSelectionHelp");
|
||||
const selection = field(section, "health-backend-select", "backendHealthSelect", "", "select");
|
||||
const empty = note(section, "backendHealthNoEnabled");
|
||||
const panels = el("div", undefined, "vh-health-member-panels"); section.append(panels); parent.append(section);
|
||||
let active = null, eligible = [];
|
||||
const refresh = () => {
|
||||
eligible = [...members.children].filter(row => row.memberHealthEnabled.checked);
|
||||
if (!eligible.includes(active)) active = eligible[0] || null;
|
||||
selection.replaceChildren(...eligible.map((row, index) => {
|
||||
const id = str("member-id", row) || t("backend");
|
||||
const address = str("member-url", row);
|
||||
const option = el("option", address ? `${id} — ${address}` : id);
|
||||
option.value = String(index); return option;
|
||||
}));
|
||||
selection.value = active ? String(eligible.indexOf(active)) : "";
|
||||
selection.disabled = eligible.length === 0;
|
||||
empty.hidden = eligible.length !== 0;
|
||||
panels.replaceChildren(...[...members.children].map(row => row.memberHealthPanel));
|
||||
for (const row of members.children) row.memberHealthPanel.hidden = row !== active;
|
||||
};
|
||||
selection.addEventListener("change", () => { active = eligible[Number(selection.value)] || null; refresh(); });
|
||||
refresh();
|
||||
return { refresh, reveal(row) { if (eligible.includes(row)) { active = row; refresh(); } } };
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
"use strict";
|
||||
// Per-vhost rate limit editors, shared in the bundled vhost closure.
|
||||
function userAgentRateEditor(parent, values = []) {
|
||||
parent.append(text("h3", "vhUaRateTitle", "subheading vh-wide"));
|
||||
const rows = el("div", undefined, "vh-wide vh-ua-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhUaRateMax"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "ua-rate-pattern", "vhUaRatePattern", value.pattern || "", "text", { required: true, maxLength: 256 });
|
||||
field(row, "ua-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "ua-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "ua-rate-burst", "vhRateBurst", value.burst ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddUaRate", () => { add(); changed(); })); note(parent, "vhUaRateHelp");
|
||||
return () => [...rows.children].map(row => ({ pattern: str("ua-rate-pattern", row).trim(), requests: num("ua-rate-requests", row), window_seconds: num("ua-rate-window", row), burst: num("ua-rate-burst", row) }));
|
||||
}
|
||||
function userAgentRateEditor(parent, values = []) {
|
||||
parent.append(text("h3", "vhUaRateTitle", "subheading vh-wide"));
|
||||
const rows = el("div", undefined, "vh-wide vh-ua-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhUaRateMax"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "ua-rate-pattern", "vhUaRatePattern", value.pattern || "", "text", { required: true, maxLength: 256 });
|
||||
field(row, "ua-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "ua-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "ua-rate-burst", "vhRateBurst", value.burst ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddUaRate", () => { add(); changed(); })); note(parent, "vhUaRateHelp");
|
||||
return () => [...rows.children].map(row => ({ pattern: str("ua-rate-pattern", row).trim(), requests: num("ua-rate-requests", row), window_seconds: num("ua-rate-window", row), burst: num("ua-rate-burst", row) }));
|
||||
}
|
||||
+374
-376
@@ -1,380 +1,378 @@
|
||||
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
|
||||
const vhostSortColumns = ["id", "hostname", "status", "listeners", "certificate", "backends"];
|
||||
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] });
|
||||
const defaultVhostListPrefs = () => ({ sort: "id", descending: false });
|
||||
let poolTablePrefs = defaultPoolTablePrefs(), vhostListPrefs = defaultVhostListPrefs(), poolPreferenceSaveRunning = false, poolPreferenceSaveQueued = false;
|
||||
function normalizePoolTablePrefs(preferences) {
|
||||
const selected = Array.isArray(preferences?.backend_table_columns) ? preferences.backend_table_columns.filter(key => poolColumns.includes(key)) : [];
|
||||
return {
|
||||
sort: poolColumns.includes(preferences?.backend_table_sort) ? preferences.backend_table_sort : "vhost",
|
||||
descending: preferences?.backend_table_descending === true,
|
||||
columns: selected.length ? selected : [...poolColumns],
|
||||
};
|
||||
}
|
||||
function normalizeVhostListPrefs(preferences) {
|
||||
return { sort: vhostSortColumns.includes(preferences?.vhost_list_sort) ? preferences.vhost_list_sort : "id", descending: preferences?.vhost_list_descending === true };
|
||||
}
|
||||
function syncVhostSortDirection() {
|
||||
const control = $("vhost-sort-direction"); if (!control) return;
|
||||
control.textContent = vhostListPrefs.descending ? "↓" : "↑";
|
||||
control.setAttribute("aria-label", t(vhostListPrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||
}
|
||||
function applyPreferences(preferences) {
|
||||
poolTablePrefs = normalizePoolTablePrefs(preferences);
|
||||
vhostListPrefs = normalizeVhostListPrefs(preferences);
|
||||
const poolSort = $("pool-sort"); if (poolSort) poolSort.value = poolTablePrefs.sort;
|
||||
const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort;
|
||||
syncPoolColumns(); syncPoolSortDirection(); syncVhostSortDirection(); applyPoolTableView(); render();
|
||||
}
|
||||
async function saveVhostListPrefs() {
|
||||
if (!authenticated) return;
|
||||
try {
|
||||
const saved = await window.rustProxyPreferences.update({ vhost_list_sort: vhostListPrefs.sort, vhost_list_descending: vhostListPrefs.descending });
|
||||
vhostListPrefs = normalizeVhostListPrefs(saved); const sort = $("vhost-sort"); if (sort) sort.value = vhostListPrefs.sort; syncVhostSortDirection();
|
||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||
}
|
||||
async function savePoolTablePrefs() {
|
||||
if (!authenticated) return;
|
||||
poolPreferenceSaveQueued = true;
|
||||
if (poolPreferenceSaveRunning) return;
|
||||
poolPreferenceSaveRunning = true;
|
||||
try {
|
||||
while (poolPreferenceSaveQueued && authenticated) {
|
||||
poolPreferenceSaveQueued = false;
|
||||
const saved = await window.rustProxyPreferences.update({
|
||||
backend_table_columns: poolColumns.filter(key => poolTablePrefs.columns.includes(key)),
|
||||
backend_table_sort: poolTablePrefs.sort,
|
||||
backend_table_descending: poolTablePrefs.descending,
|
||||
});
|
||||
if (!authenticated) return;
|
||||
if (!poolPreferenceSaveQueued) poolTablePrefs = normalizePoolTablePrefs(saved);
|
||||
}
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||
finally { poolPreferenceSaveRunning = false; }
|
||||
}
|
||||
function syncPoolColumns() {
|
||||
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
|
||||
const vhostSortColumns = ["id", "hostname", "status", "listeners", "certificate", "backends"];
|
||||
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] });
|
||||
const defaultVhostListPrefs = () => ({ sort: "id", descending: false });
|
||||
let poolTablePrefs = defaultPoolTablePrefs(), vhostListPrefs = defaultVhostListPrefs(), poolPreferenceSaveRunning = false, poolPreferenceSaveQueued = false;
|
||||
function normalizePoolTablePrefs(preferences) {
|
||||
const selected = Array.isArray(preferences?.backend_table_columns) ? preferences.backend_table_columns.filter(key => poolColumns.includes(key)) : [];
|
||||
return {
|
||||
sort: poolColumns.includes(preferences?.backend_table_sort) ? preferences.backend_table_sort : "vhost",
|
||||
descending: preferences?.backend_table_descending === true,
|
||||
columns: selected.length ? selected : [...poolColumns],
|
||||
};
|
||||
}
|
||||
function normalizeVhostListPrefs(preferences) {
|
||||
return { sort: vhostSortColumns.includes(preferences?.vhost_list_sort) ? preferences.vhost_list_sort : "id", descending: preferences?.vhost_list_descending === true };
|
||||
}
|
||||
function syncVhostSortDirection() {
|
||||
const control = $("vhost-sort-direction"); if (!control) return;
|
||||
control.textContent = vhostListPrefs.descending ? "↓" : "↑";
|
||||
control.setAttribute("aria-label", t(vhostListPrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||
}
|
||||
function applyPreferences(preferences) {
|
||||
poolTablePrefs = normalizePoolTablePrefs(preferences);
|
||||
vhostListPrefs = normalizeVhostListPrefs(preferences);
|
||||
const poolSort = $("pool-sort"); if (poolSort) poolSort.value = poolTablePrefs.sort;
|
||||
const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort;
|
||||
syncPoolColumns(); syncPoolSortDirection(); syncVhostSortDirection(); applyPoolTableView(); render();
|
||||
}
|
||||
async function saveVhostListPrefs() {
|
||||
if (!authenticated) return;
|
||||
try {
|
||||
const saved = await window.rustProxyPreferences.update({ vhost_list_sort: vhostListPrefs.sort, vhost_list_descending: vhostListPrefs.descending });
|
||||
vhostListPrefs = normalizeVhostListPrefs(saved); const sort = $("vhost-sort"); if (sort) sort.value = vhostListPrefs.sort; syncVhostSortDirection();
|
||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||
}
|
||||
async function savePoolTablePrefs() {
|
||||
if (!authenticated) return;
|
||||
poolPreferenceSaveQueued = true;
|
||||
if (poolPreferenceSaveRunning) return;
|
||||
poolPreferenceSaveRunning = true;
|
||||
try {
|
||||
while (poolPreferenceSaveQueued && authenticated) {
|
||||
poolPreferenceSaveQueued = false;
|
||||
const saved = await window.rustProxyPreferences.update({
|
||||
backend_table_columns: poolColumns.filter(key => poolTablePrefs.columns.includes(key)),
|
||||
backend_table_sort: poolTablePrefs.sort,
|
||||
backend_table_descending: poolTablePrefs.descending,
|
||||
});
|
||||
if (!authenticated) return;
|
||||
if (!poolPreferenceSaveQueued) poolTablePrefs = normalizePoolTablePrefs(saved);
|
||||
}
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||
finally { poolPreferenceSaveRunning = false; }
|
||||
}
|
||||
function syncPoolColumns() {
|
||||
const visible = new Set(poolTablePrefs.columns);
|
||||
document.querySelectorAll("[data-pool-column]").forEach(node => { node.hidden = !visible.has(node.dataset.poolColumn); });
|
||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => { input.checked = visible.has(input.dataset.poolColumnToggle); });
|
||||
}
|
||||
function syncPoolSortDirection() {
|
||||
const control = $("pool-sort-direction"); if (!control) return;
|
||||
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
|
||||
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||
}
|
||||
function applyPoolTableView() {
|
||||
const body = $("pool-rows"); if (!body) return;
|
||||
const query = ($("pool-search")?.value || "").trim().toLowerCase();
|
||||
const key = $("pool-sort")?.value || poolTablePrefs.sort;
|
||||
const rows = [...body.querySelectorAll("tr")];
|
||||
rows.sort((left, right) => {
|
||||
const a = left.poolSort?.[key] ?? "", b = right.poolSort?.[key] ?? "";
|
||||
const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
|
||||
return poolTablePrefs.descending ? -result : result;
|
||||
});
|
||||
body.replaceChildren(...rows);
|
||||
let visible = 0;
|
||||
for (const row of rows) { const match = !query || (row.poolSearchValue || "").includes(query); row.hidden = !match; if (match) visible++; }
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
const empty = $("pool-empty");
|
||||
if (empty) { empty.textContent = query && rows.length && !visible ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = visible > 0 || (!rows.length && !document.querySelector("#backend-diagnostics-card:not([hidden])")); }
|
||||
}
|
||||
function initPoolTableTools() {
|
||||
const sort = $("pool-sort"), direction = $("pool-sort-direction"), search = $("pool-search");
|
||||
if (sort) { sort.value = poolTablePrefs.sort; sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); }); }
|
||||
direction?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
|
||||
search?.addEventListener("input", applyPoolTableView);
|
||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => input.addEventListener("change", () => {
|
||||
const visible = new Set(poolTablePrefs.columns);
|
||||
document.querySelectorAll("[data-pool-column]").forEach(node => { node.hidden = !visible.has(node.dataset.poolColumn); });
|
||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => { input.checked = visible.has(input.dataset.poolColumnToggle); });
|
||||
}
|
||||
function syncPoolSortDirection() {
|
||||
const control = $("pool-sort-direction"); if (!control) return;
|
||||
control.textContent = poolTablePrefs.descending ? "↓" : "↑";
|
||||
control.setAttribute("aria-label", t(poolTablePrefs.descending ? "backendSortDescending" : "backendSortAscending"));
|
||||
}
|
||||
function applyPoolTableView() {
|
||||
const body = $("pool-rows"); if (!body) return;
|
||||
const query = ($("pool-search")?.value || "").trim().toLowerCase();
|
||||
const key = $("pool-sort")?.value || poolTablePrefs.sort;
|
||||
const rows = [...body.querySelectorAll("tr")];
|
||||
rows.sort((left, right) => {
|
||||
const a = left.poolSort?.[key] ?? "", b = right.poolSort?.[key] ?? "";
|
||||
const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
|
||||
return poolTablePrefs.descending ? -result : result;
|
||||
});
|
||||
body.replaceChildren(...rows);
|
||||
let visible = 0;
|
||||
for (const row of rows) { const match = !query || (row.poolSearchValue || "").includes(query); row.hidden = !match; if (match) visible++; }
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
const empty = $("pool-empty");
|
||||
if (empty) { empty.textContent = query && rows.length && !visible ? t("backendSearchEmpty") : t("vhHealthEmpty"); empty.hidden = visible > 0 || (!rows.length && !document.querySelector("#backend-diagnostics-card:not([hidden])")); }
|
||||
}
|
||||
function initPoolTableTools() {
|
||||
const sort = $("pool-sort"), direction = $("pool-sort-direction"), search = $("pool-search");
|
||||
if (sort) { sort.value = poolTablePrefs.sort; sort.addEventListener("change", () => { poolTablePrefs.sort = sort.value; void savePoolTablePrefs(); applyPoolTableView(); }); }
|
||||
direction?.addEventListener("click", () => { poolTablePrefs.descending = !poolTablePrefs.descending; void savePoolTablePrefs(); applyPoolTableView(); });
|
||||
search?.addEventListener("input", applyPoolTableView);
|
||||
document.querySelectorAll("[data-pool-column-toggle]").forEach(input => input.addEventListener("change", () => {
|
||||
const visible = new Set(poolTablePrefs.columns);
|
||||
if (input.checked) visible.add(input.dataset.poolColumnToggle); else visible.delete(input.dataset.poolColumnToggle);
|
||||
if (!visible.size) { input.checked = true; toast(t("backendColumnsRequired"), true); return; }
|
||||
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns();
|
||||
}));
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
}
|
||||
async function toggleVhostEnabled(vhost) {
|
||||
if (!envelope || busy) return;
|
||||
const next = structuredClone(envelope.config), target = (next.vhosts || []).find(item => item.id === vhost.id);
|
||||
if (!target) return;
|
||||
target.enabled = vhost.enabled === false;
|
||||
if (await saveSection("vhosts", next.vhosts || [])) render();
|
||||
}
|
||||
if (input.checked) visible.add(input.dataset.poolColumnToggle); else visible.delete(input.dataset.poolColumnToggle);
|
||||
if (!visible.size) { input.checked = true; toast(t("backendColumnsRequired"), true); return; }
|
||||
poolTablePrefs.columns = poolColumns.filter(key => visible.has(key)); void savePoolTablePrefs(); syncPoolColumns();
|
||||
}));
|
||||
syncPoolColumns(); syncPoolSortDirection();
|
||||
}
|
||||
async function toggleVhostEnabled(vhost) {
|
||||
if (!envelope || busy) return;
|
||||
const next = structuredClone(envelope.config), target = (next.vhosts || []).find(item => item.id === vhost.id);
|
||||
if (!target) return;
|
||||
target.enabled = vhost.enabled === false;
|
||||
if (await saveSection("vhosts", next.vhosts || [])) render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
if (!initialized) load(defaults(), [], false);
|
||||
const config = envelope.config;
|
||||
const configuredHasProxySource = (config.vhosts || []).some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
const diagnosticsCard = $("backend-diagnostics-card");
|
||||
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
|
||||
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
|
||||
const visibleVhosts = (config.vhosts || []).filter(v => {
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
v.id,
|
||||
...(v.frontend?.hosts || []),
|
||||
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
|
||||
v.source?.web_service_id,
|
||||
...(v.routes || []).map(route => route.source?.web_service_id),
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
const sortValue = v => ({
|
||||
id: String(v.id || "").toLowerCase(),
|
||||
hostname: String(v.frontend?.hosts?.[0] || "").toLowerCase(),
|
||||
status: v.enabled === false ? 1 : 0,
|
||||
listeners: (v.frontend?.listener_ids || []).join(" ").toLowerCase(),
|
||||
certificate: String(v.certificate_id || "").toLowerCase(),
|
||||
backends: Number(v.backends?.length || 0),
|
||||
}[vhostListPrefs.sort]);
|
||||
visibleVhosts.sort((left, right) => {
|
||||
const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
|
||||
return vhostListPrefs.descending ? -result : result;
|
||||
});
|
||||
const cards = visibleVhosts.map(v => {
|
||||
const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id;
|
||||
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
|
||||
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
|
||||
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
|
||||
if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill"));
|
||||
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions");
|
||||
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
|
||||
const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null;
|
||||
const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean);
|
||||
const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true);
|
||||
const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); });
|
||||
openButton.disabled = !browseHost || v.enabled === false;
|
||||
const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`);
|
||||
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
|
||||
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
||||
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
|
||||
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
|
||||
}), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head);
|
||||
|
||||
const details = el("div", undefined, "vhost-detail-strip");
|
||||
const selectedEndpoints = (v.frontend?.listener_ids || []).map(id =>
|
||||
config.listeners?.endpoints?.find(e => e.id === id) || { id }
|
||||
);
|
||||
const listeners = selectedEndpoints.map(endpoint => {
|
||||
const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", ");
|
||||
return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`;
|
||||
});
|
||||
const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2);
|
||||
const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3);
|
||||
const protocols = [
|
||||
"HTTP/1.1",
|
||||
v.http2_enabled !== false && supportsH2 ? "H2" : null,
|
||||
v.http3_enabled !== false && supportsH3 ? "H3" : null,
|
||||
].filter(Boolean).join(" + ");
|
||||
const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit");
|
||||
const detailItems = [
|
||||
["vhListeners", listeners.join(" | ") || "-"],
|
||||
["vhProtocols", protocols],
|
||||
["tlsPolicy", tlsPolicy],
|
||||
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
|
||||
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
|
||||
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
|
||||
["vhBackendPool", v.source?.type === "web_server"
|
||||
? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length
|
||||
? `${t("webServer")} + ${(v.backends || []).length}`
|
||||
: t("webServer"))
|
||||
: `${(v.backends || []).length}`],
|
||||
];
|
||||
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
||||
box.append(details);
|
||||
|
||||
const live = el("div", undefined, "vhost-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] = v.id; live.append(item); };
|
||||
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
|
||||
box.append(live);
|
||||
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
|
||||
const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (hasProxySource) {
|
||||
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
|
||||
}
|
||||
const policy = el("div", undefined, "vhost-policy-pills");
|
||||
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
|
||||
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
|
||||
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
|
||||
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
|
||||
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
|
||||
if (v.www_redirect) {
|
||||
const destination = v.www_redirect_target || (v.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
|
||||
policy.append(el("small", `WWW 308 \u2192 ${destination}`, "pill"));
|
||||
}
|
||||
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
|
||||
if (v.websockets) {
|
||||
const transports = ["H1"];
|
||||
if (v.websocket_rfc8441) transports.push("H2/RFC8441");
|
||||
if (v.websocket_rfc9220) transports.push("H3/RFC9220");
|
||||
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
|
||||
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
|
||||
}
|
||||
if (policy.children.length) box.append(policy);
|
||||
return box;
|
||||
});
|
||||
$("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")]));
|
||||
$("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
// SSE snapshots arrive slower than the backend sampler, so a short burst can be zeroed before the UI sees it.
|
||||
// Fall back to the byte-counter delta for this dashboard interval when the reported instantaneous rate is zero.
|
||||
function backendTrafficRate(poolId, member, snapshot, now, seen) {
|
||||
const key = `${poolId}\u0000${member.id}`; seen.add(key);
|
||||
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
|
||||
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
|
||||
const previous = backendTrafficHistory.get(key);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
const rate = reported > 0 ? reported : observed;
|
||||
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
|
||||
seen.add(id);
|
||||
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
|
||||
const previous = vhostTrafficHistory.get(id);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function renderStatus(status) {
|
||||
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
||||
const rows = [], summaries = new Map(), aggregate = new Map();
|
||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
||||
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
|
||||
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
|
||||
for (const pool of status.backend_pools || []) {
|
||||
const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false;
|
||||
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
|
||||
for (const member of members) {
|
||||
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
|
||||
const label = vhostDisabled ? t("vhDisabled") : (member.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
|
||||
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
|
||||
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${formatTime(member.last_failure_at_ms)}` : "-";
|
||||
const lastRecovery = Number(member.last_recovered_ms) > 0 ? formatTime(member.last_recovered_ms) : "-";
|
||||
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
|
||||
const generationAgeRaw = member.upstream_client_generation_age_seconds;
|
||||
const generationAge = Math.max(0, Number(generationAgeRaw));
|
||||
const generationAgeText = generationAgeRaw == null || !Number.isFinite(generationAge) ? "-" : generationAge >= 3600 ? `${Math.floor(generationAge / 3600)}h ${Math.floor((generationAge % 3600) / 60)}m` : generationAge >= 60 ? `${Math.floor(generationAge / 60)}m ${Math.floor(generationAge % 60)}s` : `${Math.floor(generationAge)}s`;
|
||||
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
|
||||
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, member.upstream_client_generation ?? "-", member.upstream_client_generation_requests ?? "-", generationAgeText, member.upstream_client_rotations_total ?? "-", lastFailure, lastRecovery];
|
||||
const sortValues = { vhost: poolLabel.toLowerCase(), backend: String(member.id || "").toLowerCase(), status: label.toLowerCase(), active: Number(member.active) || 0, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, generation: Number(member.upstream_client_generation) || 0, generationRequests: Number(member.upstream_client_generation_requests) || 0, generationAge: Number.isFinite(generationAge) ? generationAge : -1, rotations: Number(member.upstream_client_rotations_total) || 0, lastFailure: Number(member.last_failure_at_ms) || 0, lastRecovery: Number(member.last_recovered_ms) || 0 };
|
||||
row.poolSort = sortValues; row.poolSearchValue = [poolLabel, member.id, label, member.status, member.last_failure_class, member.last_failure_detail].filter(Boolean).join(" ").toLowerCase();
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.dataset.poolColumn = poolColumns[index]; cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
||||
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
|
||||
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
|
||||
}
|
||||
const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | ");
|
||||
summaries.set(pool.id, summary || t("vhHealthEmpty"));
|
||||
aggregate.set(pool.id, {
|
||||
disabled: vhostDisabled,
|
||||
healthMode: pool.health_mode,
|
||||
up,
|
||||
total: members.length,
|
||||
active,
|
||||
requests,
|
||||
errors,
|
||||
traffic,
|
||||
latency: latencyWeight ? weightedLatency / latencyWeight : 0,
|
||||
});
|
||||
}
|
||||
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
|
||||
for (const [id, vhost] of configuredVhosts) {
|
||||
const existing = aggregate.get(id) || {};
|
||||
const metrics = status.vhost_metrics?.[id];
|
||||
const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
const disabled = vhost.enabled === false;
|
||||
aggregate.set(id, {
|
||||
...existing,
|
||||
disabled,
|
||||
staticOnly: !hasProxySource,
|
||||
healthMode: existing.healthMode,
|
||||
up: existing.up ?? 0,
|
||||
total: existing.total ?? 0,
|
||||
active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0,
|
||||
requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0,
|
||||
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
|
||||
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
|
||||
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
|
||||
});
|
||||
}
|
||||
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
|
||||
const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
$("pool-rows").replaceChildren(...rows);
|
||||
const backendDiagnosticsCard = $("backend-diagnostics-card");
|
||||
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
|
||||
applyPoolTableView();
|
||||
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
|
||||
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
|
||||
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
|
||||
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
|
||||
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
|
||||
const now = performance.now();
|
||||
for (const [id, data] of aggregate) {
|
||||
const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
|
||||
const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests;
|
||||
history.pendingSum += rate; history.pendingCount += 1;
|
||||
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; }
|
||||
if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2); history.points = compacted; history.bucketSize *= 2; }
|
||||
chartHistory.set(id, history);
|
||||
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
|
||||
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
|
||||
}
|
||||
const editingVhost = editing ? configuredVhosts.get(editing) : null;
|
||||
const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
|
||||
$("vhost-health-live").hidden = !!editingVhost && !editingHasProxy;
|
||||
$("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint");
|
||||
}
|
||||
form.addEventListener("input", changed); form.addEventListener("change", changed);
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault(); if (!envelope || busy || uploading) return;
|
||||
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) {
|
||||
const healthPanel = invalid.closest(".vh-backend-health-panel");
|
||||
if (healthPanel) backendHealthEditorControl?.reveal([...form.querySelector(".vh-members").children].find(row => row.memberHealthPanel === healthPanel));
|
||||
activateTab(invalid.closest(".vh-group")?.dataset.tab || "details");
|
||||
const details = invalid.closest("details"); if (details) details.open = true;
|
||||
invalid.reportValidity(); return;
|
||||
}
|
||||
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
|
||||
try {
|
||||
const epoch = draftEpoch; uploading = true;
|
||||
try { await basicEditor.prepare(); } finally { uploading = false; }
|
||||
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
|
||||
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
|
||||
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
|
||||
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
|
||||
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
|
||||
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
|
||||
if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); }
|
||||
const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value;
|
||||
const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []);
|
||||
if (saved) { load(value, [], false); dialog.close(); render(); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
if (!initialized) load(defaults(), [], false);
|
||||
const config = envelope.config;
|
||||
const configuredHasProxySource = (config.vhosts || []).some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
const diagnosticsCard = $("backend-diagnostics-card");
|
||||
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
|
||||
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
|
||||
const visibleVhosts = (config.vhosts || []).filter(v => {
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
v.id,
|
||||
...(v.frontend?.hosts || []),
|
||||
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
|
||||
v.source?.web_service_id,
|
||||
...(v.routes || []).map(route => route.source?.web_service_id),
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
$("vhost-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
|
||||
$("vhost-search").addEventListener("input", render);
|
||||
$("vhost-sort")?.addEventListener("change", event => { vhostListPrefs.sort = event.target.value; void saveVhostListPrefs(); render(); });
|
||||
$("vhost-sort-direction")?.addEventListener("click", () => { vhostListPrefs.descending = !vhostListPrefs.descending; syncVhostSortDirection(); void saveVhostListPrefs(); render(); });
|
||||
$("vhost-reset").addEventListener("click", async () => {
|
||||
if (!(await allowDiscard())) return;
|
||||
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
|
||||
load(saved ? structuredClone(saved) : defaults());
|
||||
const sortValue = v => ({
|
||||
id: String(v.id || "").toLowerCase(),
|
||||
hostname: String(v.frontend?.hosts?.[0] || "").toLowerCase(),
|
||||
status: v.enabled === false ? 1 : 0,
|
||||
listeners: (v.frontend?.listener_ids || []).join(" ").toLowerCase(),
|
||||
certificate: String(v.certificate_id || "").toLowerCase(),
|
||||
backends: Number(v.backends?.length || 0),
|
||||
}[vhostListPrefs.sort]);
|
||||
visibleVhosts.sort((left, right) => {
|
||||
const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
|
||||
return vhostListPrefs.descending ? -result : result;
|
||||
});
|
||||
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
|
||||
initPoolTableTools();
|
||||
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
|
||||
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
|
||||
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
const cards = visibleVhosts.map(v => {
|
||||
const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id;
|
||||
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
|
||||
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
|
||||
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
|
||||
if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill"));
|
||||
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions");
|
||||
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
|
||||
const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null;
|
||||
const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean);
|
||||
const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true);
|
||||
const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); });
|
||||
openButton.disabled = !browseHost || v.enabled === false;
|
||||
const quickToggle = button(v.enabled === false ? "vhQuickEnable" : "vhQuickDisable", () => toggleVhostEnabled(v), `quiet vhost-quick-toggle ${v.enabled === false ? "is-enable" : "is-disable"}`);
|
||||
quickToggle.setAttribute("aria-pressed", String(v.enabled !== false));
|
||||
actions.append(quickToggle, openButton, button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(v)); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
||||
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
|
||||
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
|
||||
}), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head);
|
||||
|
||||
const details = el("div", undefined, "vhost-detail-strip");
|
||||
const selectedEndpoints = (v.frontend?.listener_ids || []).map(id =>
|
||||
config.listeners?.endpoints?.find(e => e.id === id) || { id }
|
||||
);
|
||||
const listeners = selectedEndpoints.map(endpoint => {
|
||||
const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", ");
|
||||
return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`;
|
||||
});
|
||||
const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2);
|
||||
const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3);
|
||||
const protocols = [
|
||||
"HTTP/1.1",
|
||||
v.http2_enabled !== false && supportsH2 ? "H2" : null,
|
||||
v.http3_enabled !== false && supportsH3 ? "H3" : null,
|
||||
].filter(Boolean).join(" + ");
|
||||
const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit");
|
||||
const detailItems = [
|
||||
["vhListeners", listeners.join(" | ") || "-"],
|
||||
["vhProtocols", protocols],
|
||||
["tlsPolicy", tlsPolicy],
|
||||
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
|
||||
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
|
||||
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
|
||||
["vhBackendPool", v.source?.type === "web_server"
|
||||
? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length
|
||||
? `${t("webServer")} + ${(v.backends || []).length}`
|
||||
: t("webServer"))
|
||||
: `${(v.backends || []).length}`],
|
||||
];
|
||||
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
||||
box.append(details);
|
||||
|
||||
const live = el("div", undefined, "vhost-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] = v.id; live.append(item); };
|
||||
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
|
||||
box.append(live);
|
||||
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
|
||||
const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (hasProxySource) {
|
||||
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
|
||||
}
|
||||
const policy = el("div", undefined, "vhost-policy-pills");
|
||||
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
|
||||
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
|
||||
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
|
||||
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
|
||||
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
|
||||
if (v.www_redirect) {
|
||||
const destination = v.www_redirect_target || (v.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
|
||||
policy.append(el("small", `WWW 308 \u2192 ${destination}`, "pill"));
|
||||
}
|
||||
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
|
||||
if (v.websockets) {
|
||||
const transports = ["H1"];
|
||||
if (v.websocket_rfc8441) transports.push("H2/RFC8441");
|
||||
if (v.websocket_rfc9220) transports.push("H3/RFC9220");
|
||||
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
|
||||
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
|
||||
}
|
||||
if (policy.children.length) box.append(policy);
|
||||
return box;
|
||||
});
|
||||
$("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")]));
|
||||
$("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
function backendTrafficRate(poolId, member, snapshot, now, seen) {
|
||||
const key = `${poolId}\u0000${member.id}`; seen.add(key);
|
||||
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
|
||||
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
|
||||
const previous = backendTrafficHistory.get(key);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
const rate = reported > 0 ? reported : observed;
|
||||
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
|
||||
seen.add(id);
|
||||
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
|
||||
const previous = vhostTrafficHistory.get(id);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function renderStatus(status) {
|
||||
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
||||
const rows = [], summaries = new Map(), aggregate = new Map();
|
||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
||||
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
|
||||
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
|
||||
for (const pool of status.backend_pools || []) {
|
||||
const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false;
|
||||
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
|
||||
for (const member of members) {
|
||||
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
|
||||
const label = vhostDisabled ? t("vhDisabled") : (member.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
|
||||
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
|
||||
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${formatTime(member.last_failure_at_ms)}` : "-";
|
||||
const lastRecovery = Number(member.last_recovered_ms) > 0 ? formatTime(member.last_recovered_ms) : "-";
|
||||
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
|
||||
const generationAgeRaw = member.upstream_client_generation_age_seconds;
|
||||
const generationAge = Math.max(0, Number(generationAgeRaw));
|
||||
const generationAgeText = generationAgeRaw == null || !Number.isFinite(generationAge) ? "-" : generationAge >= 3600 ? `${Math.floor(generationAge / 3600)}h ${Math.floor((generationAge % 3600) / 60)}m` : generationAge >= 60 ? `${Math.floor(generationAge / 60)}m ${Math.floor(generationAge % 60)}s` : `${Math.floor(generationAge)}s`;
|
||||
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
|
||||
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, member.upstream_client_generation ?? "-", member.upstream_client_generation_requests ?? "-", generationAgeText, member.upstream_client_rotations_total ?? "-", lastFailure, lastRecovery];
|
||||
const sortValues = { vhost: poolLabel.toLowerCase(), backend: String(member.id || "").toLowerCase(), status: label.toLowerCase(), active: Number(member.active) || 0, requests: Number(member.requests) || 0, errors: Number(member.errors) || 0, latency: Number(member.ewma_latency_ms) || 0, traffic: memberTraffic, generation: Number(member.upstream_client_generation) || 0, generationRequests: Number(member.upstream_client_generation_requests) || 0, generationAge: Number.isFinite(generationAge) ? generationAge : -1, rotations: Number(member.upstream_client_rotations_total) || 0, lastFailure: Number(member.last_failure_at_ms) || 0, lastRecovery: Number(member.last_recovered_ms) || 0 };
|
||||
row.poolSort = sortValues; row.poolSearchValue = [poolLabel, member.id, label, member.status, member.last_failure_class, member.last_failure_detail].filter(Boolean).join(" ").toLowerCase();
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.dataset.poolColumn = poolColumns[index]; cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 12 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 12 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
||||
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
|
||||
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
|
||||
}
|
||||
const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | ");
|
||||
summaries.set(pool.id, summary || t("vhHealthEmpty"));
|
||||
aggregate.set(pool.id, {
|
||||
disabled: vhostDisabled,
|
||||
healthMode: pool.health_mode,
|
||||
up,
|
||||
total: members.length,
|
||||
active,
|
||||
requests,
|
||||
errors,
|
||||
traffic,
|
||||
latency: latencyWeight ? weightedLatency / latencyWeight : 0,
|
||||
});
|
||||
}
|
||||
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
|
||||
for (const [id, vhost] of configuredVhosts) {
|
||||
const existing = aggregate.get(id) || {};
|
||||
const metrics = status.vhost_metrics?.[id];
|
||||
const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
const disabled = vhost.enabled === false;
|
||||
aggregate.set(id, {
|
||||
...existing,
|
||||
disabled,
|
||||
staticOnly: !hasProxySource,
|
||||
healthMode: existing.healthMode,
|
||||
up: existing.up ?? 0,
|
||||
total: existing.total ?? 0,
|
||||
active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0,
|
||||
requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0,
|
||||
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
|
||||
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
|
||||
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
|
||||
});
|
||||
}
|
||||
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
|
||||
const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
$("pool-rows").replaceChildren(...rows);
|
||||
const backendDiagnosticsCard = $("backend-diagnostics-card");
|
||||
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
|
||||
applyPoolTableView();
|
||||
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
|
||||
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
|
||||
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
|
||||
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
|
||||
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
|
||||
const now = performance.now();
|
||||
for (const [id, data] of aggregate) {
|
||||
const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
|
||||
const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests;
|
||||
history.pendingSum += rate; history.pendingCount += 1;
|
||||
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; }
|
||||
if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2); history.points = compacted; history.bucketSize *= 2; }
|
||||
chartHistory.set(id, history);
|
||||
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
|
||||
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
|
||||
}
|
||||
const editingVhost = editing ? configuredVhosts.get(editing) : null;
|
||||
const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
|
||||
$("vhost-health-live").hidden = !!editingVhost && !editingHasProxy;
|
||||
$("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint");
|
||||
}
|
||||
form.addEventListener("input", changed); form.addEventListener("change", changed);
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault(); if (!envelope || busy || uploading) return;
|
||||
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) {
|
||||
const healthPanel = invalid.closest(".vh-backend-health-panel");
|
||||
if (healthPanel) backendHealthEditorControl?.reveal([...form.querySelector(".vh-members").children].find(row => row.memberHealthPanel === healthPanel));
|
||||
activateTab(invalid.closest(".vh-group")?.dataset.tab || "details");
|
||||
const details = invalid.closest("details"); if (details) details.open = true;
|
||||
invalid.reportValidity(); return;
|
||||
}
|
||||
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
|
||||
try {
|
||||
const epoch = draftEpoch; uploading = true;
|
||||
try { await basicEditor.prepare(); } finally { uploading = false; }
|
||||
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
|
||||
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
|
||||
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
|
||||
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
|
||||
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
|
||||
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
|
||||
if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); }
|
||||
const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value;
|
||||
const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []);
|
||||
if (saved) { load(value, [], false); dialog.close(); render(); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
$("vhost-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
|
||||
$("vhost-search").addEventListener("input", render);
|
||||
$("vhost-sort")?.addEventListener("change", event => { vhostListPrefs.sort = event.target.value; void saveVhostListPrefs(); render(); });
|
||||
$("vhost-sort-direction")?.addEventListener("click", () => { vhostListPrefs.descending = !vhostListPrefs.descending; syncVhostSortDirection(); void saveVhostListPrefs(); render(); });
|
||||
$("vhost-reset").addEventListener("click", async () => {
|
||||
if (!(await allowDiscard())) return;
|
||||
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
|
||||
load(saved ? structuredClone(saved) : defaults());
|
||||
});
|
||||
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
|
||||
initPoolTableTools();
|
||||
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
|
||||
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
|
||||
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
@@ -1,6 +1,4 @@
|
||||
"use strict";
|
||||
// Shared profile editor is independent of the Web Service editor. Values are
|
||||
// never rendered as HTML and saves use the revisioned configuration API.
|
||||
window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams, parsePhpValues, validatePhpCustomData }) => {
|
||||
function refreshProfileSelect(chosen = $("web-service-php-profile").value) {
|
||||
const select = $("web-service-php-profile");
|
||||
@@ -12,8 +10,6 @@ window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams,
|
||||
select.value = chosen;
|
||||
}
|
||||
|
||||
// Use exactly the same validators and FastCGI field semantics for local and
|
||||
// shared configuration. None of the profile values are interpolated as HTML.
|
||||
const profileFields = [
|
||||
["enabled", "webServicePhpEnabled", "checkbox"],
|
||||
["endpoint", "webServicePhpEndpoint", "text"],
|
||||
@@ -109,9 +105,9 @@ window.rustProxyPhpProfiles = ({ defaults, mapLines, lines, parseFastCgiParams,
|
||||
for (const input of Object.values(controls)) if (!input.checkValidity()) throw new Error(t("phpProfileInvalid"));
|
||||
const php = Object.fromEntries(profileFields.map(([key, , kind]) => [key,
|
||||
kind === "checkbox" ? controls[key].checked : kind === "number" ? Number(controls[key].value) :
|
||||
key === "extensions" ? lines(controls[key].value).map(item => item.replace(/^\./, "").toLowerCase()) :
|
||||
key === "custom_params" ? parseFastCgiParams(controls[key].value) :
|
||||
key === "php_values" || key === "php_admin_values" ? parsePhpValues(controls[key].value) : controls[key].value.trim()
|
||||
key === "extensions" ? lines(controls[key].value).map(item => item.replace(/^\./, "").toLowerCase()) :
|
||||
key === "custom_params" ? parseFastCgiParams(controls[key].value) :
|
||||
key === "php_values" || key === "php_admin_values" ? parsePhpValues(controls[key].value) : controls[key].value.trim()
|
||||
]));
|
||||
validatePhpCustomData(php.custom_params, php.php_values, php.php_admin_values);
|
||||
const next = structuredClone(envelope.config.php_fpm_profiles || []);
|
||||
|
||||
@@ -81,7 +81,6 @@
|
||||
}
|
||||
|
||||
function validatePhpCustomData(customParams, phpValues, phpAdminValues = {}) {
|
||||
// Never allow an admin override to conflict with ordinary PHP_VALUE.
|
||||
if (Object.keys(phpValues).some(name => Object.hasOwn(phpAdminValues, name))) throw new Error(t("webServicePhpValuesInvalid"));
|
||||
const size = [...Object.entries(customParams), ...Object.entries(phpValues), ...Object.entries(phpAdminValues)]
|
||||
.reduce((total, [name, value]) => total + name.length + value.length + 2, 0);
|
||||
@@ -174,7 +173,7 @@
|
||||
function syncCompression() {
|
||||
const disabled = $("web-service-compression").value === "off";
|
||||
$("web-service-compression-options").hidden = disabled;
|
||||
for (const name of ["zstd", "br", "gzip", "deflate"]) $( `web-service-compression-${name}` ).disabled = disabled;
|
||||
for (const name of ["zstd", "br", "gzip", "deflate"]) $(`web-service-compression-${name}`).disabled = disabled;
|
||||
}
|
||||
|
||||
function syncPrecompressed() {
|
||||
@@ -245,7 +244,7 @@
|
||||
$("web-service-cache-rules").value = cacheLines(item.cache_rules);
|
||||
$("web-service-response-headers").value = mapLines(item.response_headers);
|
||||
$("web-service-compression").value = item.compression?.enabled === true ? "on" : item.compression?.enabled === false ? "off" : "";
|
||||
for (const name of ["zstd", "br", "gzip", "deflate"]) $( `web-service-compression-${name}` ).value = item.compression?.[name] === true ? "on" : item.compression?.[name] === false ? "off" : "";
|
||||
for (const name of ["zstd", "br", "gzip", "deflate"]) $(`web-service-compression-${name}`).value = item.compression?.[name] === true ? "on" : item.compression?.[name] === false ? "off" : "";
|
||||
$("web-service-precompressed").checked = item.precompressed?.enabled === true;
|
||||
$("web-service-precompressed-br").checked = item.precompressed?.brotli !== false;
|
||||
$("web-service-precompressed-gzip").checked = item.precompressed?.gzip !== false;
|
||||
|
||||
Reference in new issue
Block a user