This commit is contained in:
Mateusz Gruszczyński committed 2026-10-10 08:53:55 +02:00
1 parent 1431dd47f0
commit f3dd34dae4
42 files changed
+950 -133

No files matched your search

+23 -11
View File
@@ -1,7 +1,7 @@
"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;
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
const chartHistory = new Map();
const backendTrafficHistory = new Map();
const vhostTrafficHistory = new Map();
@@ -137,7 +137,7 @@
const state = {
editing, revision, label: $("vhost-editing").textContent,
fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("vhost-tabs")),
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, pagesEditor,
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, pagesEditor, backendHealthEditorControl,
stagedCertificates, uploading
};
const title = read("id")?.value.trim() || editing || t("draftUntitled");
@@ -147,7 +147,7 @@
function restoreDraft(state) {
draftEpoch++; window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("vhost-tabs"), state.tabs);
editing = state.editing; revision = state.revision; basicEditor = state.basicEditor; locationsReader = state.locationsReader; aclControl = state.aclControl;
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; pagesEditor = state.pagesEditor;
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null;
stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
}
@@ -194,7 +194,7 @@
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);
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); backendHealthEditorControl?.refresh(); changed(); }); head.append(remove); row.append(head);
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
@@ -222,9 +222,12 @@
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");
const customHealth = field(extra, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
// The per-member settings live in the Health tab. Keep one persistent form
// per backend, so switching the selection never discards unsaved edits.
const healthPanel = el("section", undefined, "vh-grid vh-backend-health-panel");
const customHealth = field(healthPanel, "member-health-custom", "backendHealthOverride", value.health_check != null, "checkbox");
const health = value.health_check || {};
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); extra.append(healthFields);
const healthFields = el("div", undefined, "vh-wide vh-grid vh-backend-health-fields"); healthPanel.append(healthFields);
field(healthFields, "member-health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
field(healthFields, "member-health-path", "vhHealthPath", health.path ?? "/", "text", { maxLength: 1024 });
field(healthFields, "member-health-host", "vhHealthHost", health.host ?? "", "text", { maxLength: 253 });
@@ -236,11 +239,19 @@
field(healthFields, "member-health-min", "vhStatusMin", health.expected_status_min ?? 200, "number", { min: 100, max: 599, required: true });
field(healthFields, "member-health-max", "vhStatusMax", health.expected_status_max ?? 399, "number", { min: 100, max: 599, required: true });
const syncHealth = () => {
const editable = controls["member-health-enabled"].checked && customHealth.checked;
healthFields.hidden = !customHealth.checked;
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !customHealth.checked; });
healthFields.querySelectorAll("input,select").forEach(input => { input.disabled = !editable; });
};
customHealth.addEventListener("change", syncHealth); syncHealth();
note(extra, "backendHealthOverridesHelp");
let customInitialized = value.health_check != null;
customHealth.addEventListener("change", () => {
if (customHealth.checked && !customInitialized) { inheritBackendHealthInputs(healthPanel); customInitialized = true; }
syncHealth();
});
controls["member-health-enabled"].addEventListener("change", () => { syncHealth(); backendHealthEditorControl?.refresh(); });
row.memberHealthPanel = healthPanel;
row.memberHealthEnabled = controls["member-health-enabled"];
syncHealth();
row.append(advanced);
const actions = el("div", undefined, "vh-backend-actions");
@@ -258,10 +269,11 @@
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));
id.addEventListener("input", () => { syncIdentity(); backendHealthEditorControl?.refresh(); }); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); backendHealthEditorControl?.refresh(); }); url.addEventListener("change", () => syncRisk(true));
row.syncLocalRisk = () => syncRisk(false);
syncIdentity(); syncRisk(false); parent.append(row);
syncIdentity(); syncRisk(false); parent.append(row); backendHealthEditorControl?.refresh();
}
function countryRateEditor(parent, values = []) {
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
const add = (value = {}) => {
+8 -6
View File
@@ -34,7 +34,7 @@
}
function load(value = defaults(), migrationIds = [], show = true) {
draftEpoch++; stagedCertificates = []; uploading = false;
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
@@ -205,6 +205,8 @@
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
backendHealthEditorControl = backendHealthEditor(g, members);
note(g, "backendHealthOverridesHelp");
const passive = settings.passive_health || {};
g = group("vhPassiveHealth");
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
@@ -391,11 +393,11 @@
id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row),
pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row),
health_check_enabled: yes("member-health-enabled", row),
health_check: yes("member-health-custom", row) ? {
mode: str("member-health-mode", row), path: str("member-health-path", row), host: nullable("member-health-host", row),
interval_ms: num("member-health-interval", row), timeout_ms: num("member-health-timeout", row), slow_response_ms: num("member-health-slow", row),
fall: num("member-health-fall", row), rise: num("member-health-rise", row),
expected_status_min: num("member-health-min", row), expected_status_max: num("member-health-max", row)
health_check: yes("member-health-custom", row.memberHealthPanel) ? {
mode: str("member-health-mode", row.memberHealthPanel), path: str("member-health-path", row.memberHealthPanel), host: nullable("member-health-host", row.memberHealthPanel),
interval_ms: num("member-health-interval", row.memberHealthPanel), timeout_ms: num("member-health-timeout", row.memberHealthPanel), slow_response_ms: num("member-health-slow", row.memberHealthPanel),
fall: num("member-health-fall", row.memberHealthPanel), rise: num("member-health-rise", row.memberHealthPanel),
expected_status_min: num("member-health-min", row.memberHealthPanel), expected_status_max: num("member-health-max", row.memberHealthPanel)
} : null,
allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row),
preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)),
+43
View File
@@ -0,0 +1,43 @@
"use strict";
// Per-member health editor shares the vhost form state from core.js.
// Starting a custom override should copy the CURRENT shared policy once,
// rather than surprising the operator with unrelated hardcoded defaults.
function inheritBackendHealthInputs(panel) {
for (const [local, shared] of [
["member-health-mode", "health-mode"], ["member-health-path", "health-path"],
["member-health-host", "health-host"], ["member-health-interval", "interval"],
["member-health-timeout", "timeout"], ["member-health-slow", "slow"],
["member-health-fall", "fall"], ["member-health-rise", "rise"],
["member-health-min", "status-min"], ["member-health-max", "status-max"]
]) {
const source = read(shared), target = read(local, panel);
if (source && target) target.value = source.value;
}
}
function backendHealthEditor(parent, members) {
const section = el("section", undefined, "vh-wide vh-backend-health-editor");
section.append(text("h3", "backendHealthPerBackend"));
note(section, "backendHealthSelectionHelp");
const selection = field(section, "health-backend-select", "backendHealthSelect", "", "select");
const empty = note(section, "backendHealthNoEnabled");
const panels = el("div", undefined, "vh-health-member-panels"); section.append(panels); parent.append(section);
let active = null, eligible = [];
const refresh = () => {
eligible = [...members.children].filter(row => row.memberHealthEnabled.checked);
if (!eligible.includes(active)) active = eligible[0] || null;
selection.replaceChildren(...eligible.map((row, index) => {
const id = str("member-id", row) || t("backend");
const address = str("member-url", row);
const option = el("option", address ? `${id} — ${address}` : id);
option.value = String(index); return option;
}));
selection.value = active ? String(eligible.indexOf(active)) : "";
selection.disabled = eligible.length === 0;
empty.hidden = eligible.length !== 0;
panels.replaceChildren(...[...members.children].map(row => row.memberHealthPanel));
for (const row of members.children) row.memberHealthPanel.hidden = row !== active;
};
selection.addEventListener("change", () => { active = eligible[Number(selection.value)] || null; refresh(); });
refresh();
return { refresh, reveal(row) { if (eligible.includes(row)) { active = row; refresh(); } } };
}
+8 -2
View File
@@ -340,7 +340,13 @@
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; }
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) {
const healthPanel = invalid.closest(".vh-backend-health-panel");
if (healthPanel) backendHealthEditorControl?.reveal([...form.querySelector(".vh-members").children].find(row => row.memberHealthPanel === healthPanel));
activateTab(invalid.closest(".vh-group")?.dataset.tab || "details");
const details = invalid.closest("details"); if (details) details.open = true;
invalid.reportValidity(); return;
}
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
try {
const epoch = draftEpoch; uploading = true;
@@ -370,5 +376,5 @@
initPoolTableTools();
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; backendHealthEditorControl = null; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);