v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,402 @@
|
||||
"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 = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0;
|
||||
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", 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, 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 }],
|
||||
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, 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"], ["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"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null }; }
|
||||
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"); 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")); }
|
||||
});
|
||||
note(parent, "locationHeadersHelp");
|
||||
return { read: () => validateHeaders(JSON.parse(target.value)) };
|
||||
}
|
||||
async function closeEditor() { if (!(await allowDiscard())) return false; draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); dialog.close(); return true; }
|
||||
$("vhost-close").addEventListener("click", () => void closeEditor()); dialog.addEventListener("cancel", event => { event.preventDefault(); void 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));
|
||||
});
|
||||
}
|
||||
|
||||
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(); 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 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]]) 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");
|
||||
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 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); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); }); url.addEventListener("change", () => syncRisk(true));
|
||||
row.syncLocalRisk = () => syncRisk(false);
|
||||
syncIdentity(); syncRisk(false); parent.append(row);
|
||||
}
|
||||
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")
|
||||
);
|
||||
}
|
||||
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 !== "return";
|
||||
return {
|
||||
id: str("location-id", r), path_prefix: str("location-path", r), match_type: 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: r.readAcl(), 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
|
||||
};
|
||||
});
|
||||
}
|
||||
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 initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy";
|
||||
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["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);
|
||||
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 });
|
||||
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 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);
|
||||
}
|
||||
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", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
|
||||
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
|
||||
webService.disabled = !isWeb; webService.required = isWeb;
|
||||
returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || 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 = str("location-path", row) || "/";
|
||||
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : 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 (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")}`;
|
||||
}
|
||||
previewValues.access.textContent = yes("acl-use", row) ? t("locationPreviewCustomAcl") : 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;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
function basicAuthEditor(parent, settings = {}) {
|
||||
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
|
||||
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
|
||||
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
|
||||
function add(user = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user");
|
||||
const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 });
|
||||
const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword");
|
||||
const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row);
|
||||
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
|
||||
}
|
||||
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
|
||||
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
|
||||
}
|
||||
|
||||
function hostTagEditor(parent, hosts = []) {
|
||||
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
|
||||
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
|
||||
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
|
||||
const values = [];
|
||||
const normalize = raw => {
|
||||
let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value;
|
||||
if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
|
||||
if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base;
|
||||
};
|
||||
const sync = () => { hidden.value = values.join("\n"); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
|
||||
const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); };
|
||||
const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; };
|
||||
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } });
|
||||
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } });
|
||||
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
|
||||
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
|
||||
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
|
||||
}
|
||||
|
||||
function load(value = defaults(), migrationIds = [], show = true) {
|
||||
draftEpoch++; stagedCertificates = []; uploading = false;
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || []); field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
|
||||
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
|
||||
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
|
||||
note(g, "sourceHelp");
|
||||
const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
|
||||
const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1";
|
||||
const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
|
||||
note(g, "vhWebsocketTransportHelp");
|
||||
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
||||
note(g, "vhWebsocketLegacySupportHelp");
|
||||
g = group("vhFrontends");
|
||||
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
||||
note(g, "listenerDefaultPortsHint");
|
||||
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
|
||||
note(g, "vhForceSslHelp");
|
||||
field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox");
|
||||
note(g, "vhForwardClientIpHelp");
|
||||
field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox");
|
||||
const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow);
|
||||
const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox");
|
||||
const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox");
|
||||
note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly");
|
||||
const syncFrontendTransports = () => {
|
||||
const capabilities = bindingControl.capabilities();
|
||||
h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3;
|
||||
if (!capabilities.http2) h2Enabled.checked = false;
|
||||
if (!capabilities.http3) h3Enabled.checked = false;
|
||||
const h2Available = capabilities.http2 && h2Enabled.checked;
|
||||
const h3Available = capabilities.http3 && h3Enabled.checked;
|
||||
websocketMode.disabled = !websocketEnabled.checked;
|
||||
websocketLegacy.disabled = !websocketEnabled.checked;
|
||||
const optionH2 = websocketMode.querySelector('option[value="h2"]');
|
||||
const optionH3 = websocketMode.querySelector('option[value="h3"]');
|
||||
const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]');
|
||||
optionH2.disabled = !h2Available;
|
||||
optionH3.disabled = !h3Available;
|
||||
optionH2H3.disabled = !h2Available || !h3Available;
|
||||
const selected = websocketMode.value;
|
||||
if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) {
|
||||
websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1";
|
||||
}
|
||||
};
|
||||
websocketEnabled.addEventListener("change", syncFrontendTransports);
|
||||
bindingControl.onChange(syncFrontendTransports);
|
||||
h2Enabled.addEventListener("change", syncFrontendTransports);
|
||||
h3Enabled.addEventListener("change", syncFrontendTransports);
|
||||
syncFrontendTransports();
|
||||
g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members);
|
||||
(value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); }));
|
||||
g = group("vhBackendDiagnostics");
|
||||
field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox");
|
||||
field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox");
|
||||
field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox");
|
||||
note(g, "vhBackendDiagnosticsHelp");
|
||||
const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) };
|
||||
g = group("vhProxyTimeouts");
|
||||
field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 });
|
||||
field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 });
|
||||
field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
|
||||
field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
|
||||
note(g, "vhProxyTimeoutsHelp");
|
||||
const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {};
|
||||
g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
|
||||
field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
|
||||
field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
|
||||
field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
|
||||
field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
|
||||
note(g, "vhRetryHelp");
|
||||
field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
|
||||
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 });
|
||||
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
|
||||
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
|
||||
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
|
||||
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
|
||||
const passive = settings.passive_health || {};
|
||||
g = group("vhPassiveHealth");
|
||||
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
|
||||
note(g, "vhPassiveHelp");
|
||||
const passiveFields = [
|
||||
field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"),
|
||||
field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"),
|
||||
field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"),
|
||||
field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"),
|
||||
field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"),
|
||||
field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }),
|
||||
field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 })
|
||||
];
|
||||
const passive5xx = passiveFields[3], passiveSlow = passiveFields[4];
|
||||
const passive5xxWarning = note(g, "vhPassive5xxWarning");
|
||||
const passiveSlowWarning = note(g, "vhPassiveSlowWarning");
|
||||
const syncPassive = () => {
|
||||
passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; });
|
||||
passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked;
|
||||
passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked;
|
||||
};
|
||||
passiveEnabled.addEventListener("change", syncPassive);
|
||||
passive5xx.addEventListener("change", syncPassive);
|
||||
passiveSlow.addEventListener("change", syncPassive);
|
||||
syncPassive();
|
||||
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
|
||||
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
|
||||
const rate = value.rate_limit || {}, access = rate.country_access || {};
|
||||
g = group("vhTrafficPolicy");
|
||||
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
|
||||
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
|
||||
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
|
||||
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); note(g, "vhRateHelp");
|
||||
const security = value.security_headers || {}, hsts = security.hsts || {};
|
||||
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
|
||||
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
|
||||
field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
|
||||
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
|
||||
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
|
||||
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
|
||||
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
||||
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
||||
g = group("vhLocations"); locationsReader = locationsEditor(g, value.routes || []);
|
||||
g = group("vhSsl");
|
||||
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
|
||||
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
|
||||
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
|
||||
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
|
||||
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
|
||||
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
|
||||
const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox");
|
||||
const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox");
|
||||
const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 });
|
||||
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; };
|
||||
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp");
|
||||
g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g;
|
||||
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
|
||||
note(g, "authModeHelp");
|
||||
|
||||
const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody);
|
||||
aclBody.append(text("h3", "vhIpAccess"));
|
||||
aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
|
||||
|
||||
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody);
|
||||
taBody.append(text("h3", "authTinyauth"));
|
||||
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
|
||||
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
|
||||
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
|
||||
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
|
||||
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
|
||||
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
|
||||
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
|
||||
authEnable.closest("label").hidden = true;
|
||||
note(taBody, "taHelp");
|
||||
|
||||
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
|
||||
const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced);
|
||||
field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
|
||||
field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
|
||||
field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
|
||||
basicBody.append(text("h3", "authBasic"));
|
||||
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
|
||||
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
|
||||
const syncAuth = () => {
|
||||
authEnable.checked = authMode.value === "tinyauth";
|
||||
taBody.hidden = !authEnable.checked;
|
||||
basicBody.hidden = authMode.value !== "basic";
|
||||
authUrl.required = loginUrl.required = authEnable.checked;
|
||||
};
|
||||
authMode.addEventListener("change", syncAuth); syncAuth();
|
||||
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
|
||||
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
|
||||
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.25.0", "text", { maxLength: 256 });
|
||||
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
|
||||
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
|
||||
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
|
||||
const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp");
|
||||
g = group("vhCache"); const cache = value.cache || {};
|
||||
const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled);
|
||||
if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled");
|
||||
const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions);
|
||||
field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 });
|
||||
field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 });
|
||||
field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 });
|
||||
tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query);
|
||||
tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies);
|
||||
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
|
||||
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
|
||||
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
|
||||
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age);
|
||||
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
|
||||
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
|
||||
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
|
||||
const syncCacheVisibility = () => {
|
||||
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
|
||||
cacheOptions.hidden = !enabled;
|
||||
};
|
||||
cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility();
|
||||
g = group("vhTransform"); const transform = value.transform || { enabled: false };
|
||||
const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox");
|
||||
const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions);
|
||||
for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]);
|
||||
const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
|
||||
const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
|
||||
const syncTransform = () => {
|
||||
transformOptions.hidden = !transformEnabled.checked;
|
||||
transformExtensions.disabled = !customTransformExtensions.checked;
|
||||
};
|
||||
transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform();
|
||||
note(transformOptions, "vhTransformHelp");
|
||||
g = group("vhHeaders"); headerEditors(g, value.headers || {});
|
||||
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
|
||||
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
|
||||
|
||||
}
|
||||
function collect() {
|
||||
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
|
||||
let paths, acl, headers;
|
||||
try {
|
||||
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
|
||||
if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error();
|
||||
} catch { throw new Error(t("vhJsonInvalid")); }
|
||||
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })).filter(member => member.url);
|
||||
const bindingSelection = readBindings();
|
||||
const mainSourceType = str("source-type");
|
||||
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
|
||||
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
|
||||
if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired"));
|
||||
if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired"));
|
||||
if (yes("force-ssl")) {
|
||||
const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false;
|
||||
for (const id of bindingSelection) {
|
||||
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
|
||||
if (!endpoint || endpoint.enabled === false) continue;
|
||||
if (!endpoint.binding?.tls) { httpSelected = true; continue; }
|
||||
tlsSelected = true;
|
||||
for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) {
|
||||
const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1]));
|
||||
}
|
||||
}
|
||||
if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp"));
|
||||
if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls"));
|
||||
if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict"));
|
||||
}
|
||||
if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired"));
|
||||
const selectedUsesHttp3 = bindingSelection.some(id => {
|
||||
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
|
||||
return !!endpoint?.binding?.http3;
|
||||
});
|
||||
if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) {
|
||||
const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() };
|
||||
const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
|
||||
const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() };
|
||||
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
|
||||
}
|
||||
return {
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
|
||||
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
|
||||
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
|
||||
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
|
||||
load_balancing: {
|
||||
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive") },
|
||||
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
|
||||
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
|
||||
passive_health: {
|
||||
enabled: yes("passive-enabled"),
|
||||
count_connect_errors: yes("passive-connect"),
|
||||
count_timeouts: yes("passive-timeouts"),
|
||||
count_io_errors: yes("passive-io"),
|
||||
count_http_5xx: yes("passive-5xx"),
|
||||
count_slow_responses: yes("passive-slow"),
|
||||
fall: str("passive-fall") === "" ? null : num("passive-fall"),
|
||||
rise: str("passive-rise") === "" ? null : num("passive-rise")
|
||||
},
|
||||
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
|
||||
},
|
||||
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
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 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;
|
||||
actions.append(openButton, button("edit", async () => { if (await allowDiscard()) 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.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") : (pool.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 || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-";
|
||||
const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-";
|
||||
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
|
||||
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`, lastFailure, lastRecovery];
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 8 ? (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 === 8 && 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);
|
||||
$("pool-empty").hidden = rows.length > 0 || !hasConfiguredProxySource;
|
||||
const backendDiagnosticsCard = $("backend-diagnostics-card");
|
||||
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
|
||||
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) { 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", async () => { if (await allowDiscard()) load(); });
|
||||
$("vhost-search").addEventListener("input", 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"));
|
||||
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
|
||||
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
Reference in new issue
Block a user