This commit is contained in:
Mateusz Gruszczyński committed 2026-10-11 22:55:22 +02:00
1 parent 4cc994b3bc
commit 79fd198949
48 files changed
+922 -202

No files matched your search

+66 -14
View File
@@ -1,4 +1,4 @@
let configHistoryEntries = [], configHistorySelectedRevision = null;
let configHistoryEntries = [], configHistorySelectedRevision = null, configHistorySelectedJson = null;
function historyDate(value, options = {}) {
const effective = { ...options };
if (Object.hasOwn(effective, "timeStyle") || Object.hasOwn(effective, "hour")) effective.hour12 = (adminPreferences?.time_format || "24h") === "12h";
@@ -12,8 +12,8 @@ function historyDayKey(value) {
function historyMonthKey(value) { return historyDayKey(value).slice(0, 7); }
function historyValue(value) {
if (value === undefined) return t("configHistoryMissing");
const text = JSON.stringify(value, null, 2);
return text.length > 6000 ? `${text.slice(0, 6000)}\n...` : text;
const result = JSON.stringify(value);
return result.length > 320 ? `${result.slice(0, 320)}…` : result;
}
function syncConfigHistoryRestoreButton() {
const button = $("config-history-restore"), revision = Number(configHistorySelectedRevision);
@@ -21,14 +21,57 @@ function syncConfigHistoryRestoreButton() {
button.hidden = configHistorySelectedRevision === null;
button.disabled = busy || !envelope || !Number.isSafeInteger(revision) || revision >= Number(envelope.revision);
}
// Compare leaf fields, not serialized object/array blobs. Object IDs are used
// for stable array paths so reordering vhosts does not become a huge fake diff.
function configDiff(before, after, path = "$", output = [], depth = 0) {
if (output.length >= 200) return output;
if (Object.is(before, after)) return output;
const beforeObject = before && typeof before === "object" && !Array.isArray(before);
const afterObject = after && typeof after === "object" && !Array.isArray(after);
if (beforeObject && afterObject && depth < 10) {
if (output.length >= 200 || Object.is(before, after)) return output;
if (depth >= 24) {
if (JSON.stringify(before) !== JSON.stringify(after)) output.push({ path, before: "[nested value]", after: "[nested value]" });
return output;
}
const oldObj = before !== null && typeof before === "object";
const newObj = after !== null && typeof after === "object";
if (oldObj && newObj && !Array.isArray(before) && !Array.isArray(after)) {
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
for (const key of keys) configDiff(before[key], after[key], `${path}.${key}`, output, depth + 1);
for (const key of keys) {
if (output.length >= 200) break;
configDiff(before[key], after[key], `${path}[${JSON.stringify(key)}]`, output, depth + 1);
}
return output;
}
if (Array.isArray(before) && Array.isArray(after)) {
const named = items => items.every(item => item && typeof item === "object" && !Array.isArray(item) && (typeof item.id === "string" || typeof item.name === "string"));
const itemKey = item => String(item.id ?? item.name);
const stable = named(before) && named(after)
&& new Set(before.map(itemKey)).size === before.length
&& new Set(after.map(itemKey)).size === after.length;
if (stable) {
const oldItems = new Map(before.map(item => [itemKey(item), item]));
const newItems = new Map(after.map(item => [itemKey(item), item]));
for (const id of new Set([...oldItems.keys(), ...newItems.keys()])) {
if (output.length >= 200) break;
configDiff(oldItems.get(id), newItems.get(id), `${path}[${JSON.stringify(id)}]`, output, depth + 1);
}
} else {
for (let i = 0; i < Math.max(before.length, after.length) && output.length < 200; i++) {
configDiff(before[i], after[i], `${path}[${i}]`, output, depth + 1);
}
}
return output;
}
// New/deleted trees are traversed to show the affected fields only.
const tree = oldObj ? before : newObj ? after : null;
if (tree && !Array.isArray(tree)) {
for (const key of Object.keys(tree).sort()) {
if (output.length >= 200) break;
configDiff(before?.[key], after?.[key], `${path}[${JSON.stringify(key)}]`, output, depth + 1);
}
return output;
}
if (Array.isArray(tree)) {
for (let i = 0; i < tree.length && output.length < 200; i++) {
configDiff(before?.[i], after?.[i], `${path}[${i}]`, output, depth + 1);
}
return output;
}
if (JSON.stringify(before) !== JSON.stringify(after)) output.push({ path, before, after });
@@ -38,21 +81,22 @@ function renderConfigRevisionDetail(detail) {
const entry = detail?.revision;
if (!entry) return;
configHistorySelectedRevision = Number(entry.revision);
configHistorySelectedJson = entry.previous_config ? { before: entry.previous_config, after: entry.config } : null;
const compareButton = $("config-history-compare-open");
compareButton.disabled = !configHistorySelectedJson;
$("config-history-detail-title").textContent = `${t("configHistoryRevision")} ${entry.revision}`;
$("config-history-detail-meta").textContent = `${historyDate(entry.time, { dateStyle: "medium", timeStyle: "short" })} · ${entry.actor}`;
syncConfigHistoryRestoreButton();
document.querySelectorAll(".config-history-revision").forEach(button => button.classList.toggle("active", Number(button.dataset.revision) === Number(entry.revision)));
const diff = configDiff(entry.previous_config, entry.config);
const box = $("config-history-diff"); box.replaceChildren();
if (!entry.previous_config) { box.append(el("p", t("configHistoryBaseline"), "empty")); return; }
const diff = configDiff(entry.previous_config, entry.config);
if (!diff.length) { box.append(el("p", t("configHistoryNoChanges"), "empty")); return; }
for (const change of diff) {
const card = el("article", undefined, "config-history-change");
card.append(el("strong", change.path, "config-history-change-path"));
const values = el("div", undefined, "config-history-change-values");
for (const [label, value] of [[t("configHistoryBefore"), change.before], [t("configHistoryAfter"), change.after]]) {
const side = el("div"); side.append(el("span", label, "small-label"), el("pre", historyValue(value))); values.append(side);
}
values.append(el("code", historyValue(change.before), "diff-before"), el("span", "→", "fine"), el("code", historyValue(change.after), "diff-after"));
card.append(values); box.append(card);
}
if (diff.length >= 200) box.append(el("p", t("configHistoryDiffLimit"), "fine"));
@@ -127,7 +171,7 @@ async function restoreConfigRevision() {
finally { busy = false; if (button?.isConnected) syncConfigHistoryRestoreButton(); }
}
async function loadConfigHistory() {
configHistorySelectedRevision = null; syncConfigHistoryRestoreButton();
configHistorySelectedRevision = null; configHistorySelectedJson = null; $("config-history-compare-open").disabled = true; syncConfigHistoryRestoreButton();
$("config-history-summary").textContent = t("configHistoryLoading");
try {
const result = await api("/api/config/history?limit=512"); configHistoryEntries = result.revisions || []; renderConfigHistory();
@@ -138,3 +182,11 @@ $("config-history-open").addEventListener("click", () => { const dialog = $("con
$("config-history-close").addEventListener("click", () => $("config-history-dialog").close());
$("config-history-reload").addEventListener("click", () => void loadConfigHistory());
$("config-history-restore").addEventListener("click", () => void restoreConfigRevision());
$("config-history-compare-open").addEventListener("click", () => {
if (!configHistorySelectedJson) return;
$("config-history-json-before").textContent = JSON.stringify(configHistorySelectedJson.before, null, 2);
$("config-history-json-after").textContent = JSON.stringify(configHistorySelectedJson.after, null, 2);
const dialog = $("config-history-json-dialog"); if (!dialog.open) dialog.showModal();
});
$("config-history-json-close").addEventListener("click", () => $("config-history-json-dialog").close());
+73 -49
View File
@@ -1766,16 +1766,14 @@
id="settings-tab-connections" role="tab" tabindex="-1" type="button"></button><button
aria-controls="settings-page-docker-agents" aria-selected="false" data-i18n="dockerAgents"
data-settings-tab="docker-agents" id="settings-tab-docker-agents" role="tab" tabindex="-1"
type="button"></button><button aria-controls="settings-page-response" aria-selected="false"
data-i18n="responseSettings" data-settings-tab="response" id="settings-tab-response"
role="tab" tabindex="-1" type="button"></button><button aria-controls="settings-page-cache"
type="button"></button><button aria-controls="settings-page-policies" aria-selected="false" data-i18n="policyRulesTitle"
data-settings-tab="policies" id="settings-tab-policies" role="tab" tabindex="-1"
type="button">Rules & headers</button><button aria-controls="settings-page-cache"
aria-selected="false" data-i18n="cacheSettings" data-settings-tab="cache"
id="settings-tab-cache" role="tab" tabindex="-1" type="button"></button><button
aria-controls="settings-page-transform" aria-selected="false" data-i18n="transformSettings"
data-settings-tab="transform" id="settings-tab-transform" role="tab" tabindex="-1"
type="button"></button><button aria-controls="settings-page-headers" aria-selected="false"
data-i18n="customHeaders" data-settings-tab="headers" id="settings-tab-headers" role="tab"
tabindex="-1" type="button"></button><button aria-controls="settings-page-forwarding"
type="button"></button><button aria-controls="settings-page-forwarding"
aria-selected="false" data-i18n="forwardingSettings" data-settings-tab="forwarding"
id="settings-tab-forwarding" role="tab" tabindex="-1" type="button"></button><button
aria-controls="settings-page-pages" aria-selected="false" data-i18n="globalPages"
@@ -2065,8 +2063,55 @@
type="submit">Save and connect agents</button></div>
</form>
</div>
<div aria-labelledby="settings-tab-response" class="settings-page" data-settings-page="response"
hidden="" id="settings-page-response" role="tabpanel">
<section aria-labelledby="settings-tab-policies" class="settings-page"
data-settings-page="policies" hidden="" id="settings-page-policies" role="tabpanel">
<div class="card edit-form">
<div class="card-heading"><h2 data-i18n="policyRulesTitle">Rules & headers</h2><span class="pill live" data-i18n="hotApply">LIVE APPLY</span></div>
<nav aria-label="Rules and headers" class="settings-tabs sub-tabs" id="policy-settings-tabs" role="tablist">
<button aria-selected="true" data-policy-settings-tab="user-agent" data-i18n="globalUaBlockingTitle" type="button">Global User-Agent blocklist</button>
<button aria-selected="false" data-policy-settings-tab="headers" data-i18n="customHeaders" type="button">Global custom headers</button>
<button aria-selected="false" data-policy-settings-tab="response" data-i18n="responseSettings" type="button">Client response</button>
</nav>
</div>
<div data-policy-settings-page="user-agent">
<article class="card security-policy-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="userAgentBlocking">USER-AGENT BLOCKING</span>
<h2 data-i18n="globalUaBlockingTitle">Global User-Agent blocklist</h2>
<p class="fine" data-i18n="globalUaBlockingHelp">Blocks matching User-Agent values on public proxy vhosts before an upstream is opened. Per-vhost lists are configured in Vhosts → Security.</p>
</div>
</div>
<form class="edit-form security-inline-form" id="global-ua-block-form">
<label class="check"><input id="global-ua-block-enabled" type="checkbox" /><span data-i18n="enabled">Enabled</span></label>
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per line</span><textarea id="global-ua-block-patterns" rows="6" maxlength="32768" spellcheck="false" placeholder="BadBot&#10;curl/*&#10;*scanner*"></textarea></label>
<p class="fine" data-i18n="uaBlockPatternHelp">Matching is case-insensitive. Plain text matches a substring; * is a wildcard. Requests without User-Agent are not blocked by this rule.</p>
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button></div>
</form>
</article>
</div>
<div data-policy-settings-page="headers" hidden="">
<form class="card edit-form" id="headers-form">
<div class="card-heading">
<h2 data-i18n="customHeaders">Global custom headers</h2><span class="pill live"
data-i18n="hotApply">LIVE APPLY</span>
</div>
<p class="fine" data-i18n="globalHeadersHelp">Request rules go to the backend; response
rules go to the client. Route rules run after global rules. Server policy takes
final precedence.</p>
<h3 class="subheading" data-i18n="responseHeaders">Response to client</h3>
<div class="header-rules" id="response-header-rules"></div><button class="quiet"
data-i18n="addHeader" id="add-response-header" type="button">Add header
rule</button>
<h3 class="subheading" data-i18n="requestHeaders">Request to backend</h3>
<div class="header-rules" id="request-header-rules"></div><button class="quiet"
data-i18n="addHeader" id="add-request-header" type="button">Add header rule</button>
<p class="fine" data-i18n="headersHelp">Operations: set, append, remove. Transport, body
encoding and client-identity headers are protected.</p>
<div class="form-actions"><button class="primary" data-i18n="saveHeaders"
type="submit">Apply header rules</button></div>
</form>
</div>
<div data-policy-settings-page="response" hidden="">
<form class="card edit-form" id="response-form">
<div class="card-heading">
<h2 data-i18n="responseSettings">Client response</h2><span class="pill live"
@@ -2106,6 +2151,7 @@
type="submit">Apply response settings</button></div>
</form>
</div>
</section>
<div aria-labelledby="settings-tab-cache" class="settings-page" data-settings-page="cache"
hidden="" id="settings-page-cache" role="tabpanel">
<form class="card edit-form" id="cache-form">
@@ -2435,29 +2481,6 @@
type="submit">Apply transformation policy</button></div>
</form>
</div>
<div aria-labelledby="settings-tab-headers" class="settings-page" data-settings-page="headers"
hidden="" id="settings-page-headers" role="tabpanel">
<form class="card edit-form" id="headers-form">
<div class="card-heading">
<h2 data-i18n="customHeaders">Global custom headers</h2><span class="pill live"
data-i18n="hotApply">LIVE APPLY</span>
</div>
<p class="fine" data-i18n="globalHeadersHelp">Request rules go to the backend; response
rules go to the client. Route rules run after global rules. Server policy takes
final precedence.</p>
<h3 class="subheading" data-i18n="responseHeaders">Response to client</h3>
<div class="header-rules" id="response-header-rules"></div><button class="quiet"
data-i18n="addHeader" id="add-response-header" type="button">Add header
rule</button>
<h3 class="subheading" data-i18n="requestHeaders">Request to backend</h3>
<div class="header-rules" id="request-header-rules"></div><button class="quiet"
data-i18n="addHeader" id="add-request-header" type="button">Add header rule</button>
<p class="fine" data-i18n="headersHelp">Operations: set, append, remove. Transport, body
encoding and client-identity headers are protected.</p>
<div class="form-actions"><button class="primary" data-i18n="saveHeaders"
type="submit">Apply header rules</button></div>
</form>
</div>
<div aria-labelledby="settings-tab-forwarding" class="settings-page"
data-settings-page="forwarding" hidden="" id="settings-page-forwarding" role="tabpanel">
<form class="card edit-form" id="forwarding-form">
@@ -2965,20 +2988,6 @@
</form>
</details>
</article>
<article class="card security-policy-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="userAgentBlocking">USER-AGENT BLOCKING</span>
<h2 data-i18n="globalUaBlockingTitle">Global User-Agent blocklist</h2>
<p class="fine" data-i18n="globalUaBlockingHelp">Blocks matching User-Agent values on public proxy vhosts before an upstream is opened. Per-vhost lists are configured in Vhosts → Security.</p>
</div>
</div>
<form class="edit-form security-inline-form" id="global-ua-block-form">
<label class="check"><input id="global-ua-block-enabled" type="checkbox" /><span data-i18n="enabled">Enabled</span></label>
<label><span data-i18n="uaBlockPatterns">Blocked patterns, one per line</span><textarea id="global-ua-block-patterns" rows="6" maxlength="32768" spellcheck="false" placeholder="BadBot&#10;curl/*&#10;*scanner*"></textarea></label>
<p class="fine" data-i18n="uaBlockPatternHelp">Matching is case-insensitive. Plain text matches a substring; * is a wildcard. Requests without User-Agent are not blocked by this rule.</p>
<div class="form-actions"><button class="primary" data-i18n="apply" type="submit">Validate, save and apply</button></div>
</form>
</article>
<article class="card security-policy-card">
<div class="card-heading">
<div><span class="small-label" data-i18n="securityAccessControl">ACCESS
@@ -3174,10 +3183,13 @@
<div><span class="small-label" data-i18n="configHistoryChanges">CHANGES</span>
<h3 id="config-history-detail-title">-</h3>
</div>
<div class="config-history-detail-actions"><span class="fine"
id="config-history-detail-meta"></span><button class="outline"
data-i18n="configHistoryRestoreAction" id="config-history-restore" type="button" hidden
disabled>Restore revision</button></div>
<div class="config-history-detail-actions">
<span class="fine" id="config-history-detail-meta"></span>
<button class="outline" data-i18n="configHistoryRestoreAction"
id="config-history-restore" type="button" hidden disabled>Restore revision</button>
<button class="quiet" data-i18n="configHistoryFullCompare"
id="config-history-compare-open" type="button" disabled>Full JSON comparison</button>
</div>
</div>
<div class="config-history-revisions" id="config-history-revisions"></div>
<div class="config-history-diff" id="config-history-diff">
@@ -3186,6 +3198,18 @@
</aside>
</div>
</dialog>
<dialog aria-labelledby="config-json-compare-title" class="config-history-json-dialog" id="config-history-json-dialog">
<div class="window-heading">
<h2 data-i18n="configHistoryFullCompare" id="config-json-compare-title">Full JSON comparison</h2>
<div class="window-tools">
<button class="outline" data-i18n="close" id="config-history-json-close" type="button">Close</button>
</div>
</div>
<div class="config-history-json-columns">
<section><h3 data-i18n="configHistoryBefore">Before</h3><pre id="config-history-json-before"></pre></section>
<section><h3 data-i18n="configHistoryAfter">After</h3><pre id="config-history-json-after"></pre></section>
</div>
</dialog>
<dialog aria-labelledby="audit-dialog-title" class="audit-dialog" id="audit-dialog">
<div class="window-heading">
<div><span class="small-label" data-i18n="auditRetention">SQLITE / LAST 100 / RETAINED 10,000</span>
+3 -2
View File
@@ -111,7 +111,8 @@
const enabled = field(grid, "listener-enabled", "vhEnabled", item.enabled !== false, "checkbox");
const tls = field(grid, "listener-tls", "vhTls", !!b.tls, "checkbox"); const h2 = field(grid, "listener-h2", "vhHttp2", b.tls && b.http2 !== false, "checkbox"); const h2c = field(grid, "listener-h2c", "listenerH2c", !b.tls && !!b.h2c, "checkbox");
const preservedHttp2 = effectiveHttp2Settings(b);
const h2Settings = button(row, "h2Configure", () => {
const protocolActions = el("div", undefined, "listener-protocol-settings-actions vh-wide"); row.append(protocolActions);
const h2Settings = button(protocolActions, "h2Configure", () => {
window.rustProxySettings?.select("connections");
window.rustProxySettings?.selectHttpTab?.("http2");
document.querySelector('[data-http-settings-page="http2"]')?.scrollIntoView({ block: "start" });
@@ -119,7 +120,7 @@
h2Settings.classList.add("listener-h2-settings-link");
const preservedHttp3 = Object.hasOwn(b, "http3") ? b.http3 : (b.tls ? { ...h3Defaults } : null);
const h3 = field(grid, "listener-h3", "h3Enable", b.tls && !!preservedHttp3, "checkbox");
const h3Settings = button(row, "h3Configure", () => {
const h3Settings = button(protocolActions, "h3Configure", () => {
window.rustProxySettings?.select("connections");
window.rustProxySettings?.selectHttpTab?.("http3");
document.querySelector('[data-http-settings-page="http3"]')?.scrollIntoView({ block: "start" });
+5 -1
View File
@@ -394,8 +394,12 @@
if (agent.acl_profile_id === profile.id) users.push(`docker-agent:${agent.id}`);
for (const assignment of agent.assignments || []) if (assignment.acl_profile_id === profile.id) users.push(`docker:${agent.id}/${assignment.hostname || assignment.service_key}`);
}
if (users.length) pieces.push(`${t("aclProfileAttached")}: ${users.join(", ")}`);
box.append(el("p", pieces.filter(Boolean).join("\n"), "entity-details"));
if (users.length) {
const attached = el("details", undefined, "acl-profile-attached");
attached.append(el("summary", `${t("aclProfileAttached")} (${users.length})`), el("p", users.join(", "), "entity-details"));
box.append(attached);
}
const actions = el("div", undefined, "entity-actions"); actions.append(action(t("edit"), () => edit(profile)), action(t("remove"), async () => {
if (users.length) { toast(`${t("aclProfileInUse")}: ${users.join(", ")}`, true); return; }
if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.acl_profiles = (next.acl_profiles || []).filter(item => item.id !== profile.id); if (await saveSection("acl-profiles", next.acl_profiles || [])) clear();
+23 -2
View File
@@ -7,7 +7,7 @@
const requestedTab = typeof location !== "undefined" && location.pathname === "/logging"
? "logging"
: (typeof location !== "undefined" && location.pathname === "/settings" ? new URLSearchParams(location.search).get("tab") : null);
const tabAliases = { http2: "connections" };
const tabAliases = { http2: "connections", headers: "policies", response: "policies" };
// Keep the public panel hostname implicit. Legacy configurations may have
// written it into allowed_hosts in 0.31.4; do not show/save it twice.
function pinnedAdminHostname(value) {
@@ -57,6 +57,27 @@
});
}
search?.addEventListener("input", applySettingsSearch);
const policyTabs = [...document.querySelectorAll("[data-policy-settings-tab]")].filter(button => typeof button.addEventListener === "function" && typeof button.setAttribute === "function");
function selectPolicyTab(name, focus = false) {
if (!policyTabs.some(button => button.dataset.policySettingsTab === name)) return;
for (const button of policyTabs) {
const chosen = button.dataset.policySettingsTab === name;
button.setAttribute("aria-selected", String(chosen)); button.tabIndex = chosen ? 0 : -1;
if (chosen && focus) button.focus();
}
document.querySelectorAll("[data-policy-settings-page]").forEach(page => page.hidden = page.dataset.policySettingsPage !== name);
}
for (const button of policyTabs) {
button.addEventListener("click", () => selectPolicyTab(button.dataset.policySettingsTab));
button.addEventListener("keydown", event => {
const index = policyTabs.indexOf(button); let next;
if (event.key === "ArrowRight" || event.key === "ArrowDown") next = (index + 1) % policyTabs.length;
else if (event.key === "ArrowLeft" || event.key === "ArrowUp") next = (index + policyTabs.length - 1) % policyTabs.length;
else if (event.key === "Home") next = 0; else if (event.key === "End") next = policyTabs.length - 1; else return;
event.preventDefault(); selectPolicyTab(policyTabs[next].dataset.policySettingsTab, true);
});
}
selectPolicyTab(["headers", "response"].includes(requestedTab) ? requestedTab : "user-agent");
const cacheTabs = [...document.querySelectorAll("[data-cache-settings-tab]")];
function selectCacheTab(name, focus = false) {
if (!cacheTabs.some(button => button.dataset.cacheSettingsTab === name)) return;
@@ -352,6 +373,6 @@ curl --fail --show-error --request POST \
const text = await response.text(); if (epoch !== authEpoch || !authenticated) return; $("prom-preview-output").textContent = text.slice(0, 262144); $("prom-preview-output").hidden = false;
} catch (error) { if (epoch === authEpoch) toast(error.message, true); } finally { button.disabled = false; }
});
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-certificate-ids").value = ""; $("certificate-api-allow-deploy").checked = false; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
window.rustProxySettings = { select, selectHttpTab, syncLocation, render, refreshSearch: applySettingsSearch, current: () => active, reset() { if (search) search.value = ""; applySettingsSearch(); select("listeners", false, false); selectPolicyTab("user-agent"); selectCacheTab("general"); selectLoggingTab("outputs"); selectHttpTab("http2"); certificateApiState = null; certificateApiLoading = false; $("certificate-api-key-id").value = ""; $("certificate-api-comment").value = ""; $("certificate-api-certificate-ids").value = ""; $("certificate-api-allow-deploy").checked = false; $("certificate-api-token").value = ""; $("certificate-api-secret").textContent = ""; $("certificate-api-credentials").replaceChildren(); $("certificate-api-secret-wrap").hidden = true; $("prom-preview-output").textContent = ""; $("prom-preview-output").hidden = true; } };
select(active); render();
})();
+61 -6
View File
@@ -4239,7 +4239,8 @@ dialog.vhost-dialog,
dialog.logs-dialog,
dialog.audit-dialog,
dialog.certificate-dialog,
dialog.config-history-dialog {
dialog.config-history-dialog,
dialog.config-history-json-dialog {
border-radius: 0;
background: var(--bg);
color: var(--text);
@@ -8252,18 +8253,34 @@ body[data-density="compact"] .page-footer {
overflow: hidden;
}
.config-history-dialog[open] {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
height: min(820px, calc(100dvh - 28px));
}
.config-history-layout {
display: grid;
grid-template-columns: minmax(380px, .95fr) minmax(420px, 1.05fr);
grid-template-rows: minmax(0, 1fr);
gap: 10px;
padding: 10px;
max-height: calc(100dvh - 90px);
min-height: 0;
overflow: hidden;
}
.config-history-browser,
.config-history-detail {
min-width: 0;
overflow: auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.config-history-detail {
display: flex;
flex-direction: column;
overflow: hidden;
}
.config-history-months {
@@ -8353,9 +8370,12 @@ button.config-history-day.is-selected {
.config-history-revisions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
max-height: 110px;
overflow-y: auto;
}
.config-history-revision {
@@ -8369,7 +8389,14 @@ button.config-history-day.is-selected {
.config-history-diff {
display: grid;
flex: 1 1 auto;
min-height: 0;
align-content: start;
grid-auto-rows: max-content;
gap: 8px;
overflow-y: auto;
padding-right: 4px;
overscroll-behavior: contain;
}
.config-history-change {
@@ -8402,14 +8429,22 @@ button.config-history-day.is-selected {
@media (max-width: 860px) {
.config-history-layout {
grid-template-columns: 1fr;
overflow: auto;
display: block;
overflow-y: auto;
}
.config-history-browser {
max-height: min(300px, 38dvh);
overflow-y: auto;
margin-bottom: 10px;
}
.config-history-browser,
.config-history-detail {
display: block;
overflow: visible;
}
.config-history-revisions { max-height: none; }
.config-history-diff { overflow: visible; }
.config-history-change-values {
grid-template-columns: 1fr;
@@ -10463,3 +10498,23 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
overflow-wrap: anywhere;
}
}
/* 0.31.7: separate protocol tuning actions; keep them comfortably tappable. */
.listener-protocol-settings-actions { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; margin-block: 10px; }
.listener-protocol-settings-actions button { margin: 0; }
.listener-protocol-settings-actions button[hidden] { display: none; }
/* 0.31.7 revision preview shows only changed leaf fields. */
.config-history-change-values { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.config-history-change-values .diff-before { color: var(--muted); text-decoration: line-through; }
.config-history-change-values .diff-after { color: var(--text); }
.config-history-change-values code { white-space: pre-wrap; overflow-wrap: anywhere; }
.config-history-json-dialog { width: min(1200px, calc(100vw - 24px)); max-width: calc(100vw - 24px); max-height: calc(100dvh - 28px); overflow: auto; }
.config-history-json-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; }
.config-history-json-columns section { min-width: 0; }
.config-history-json-columns pre { padding: 12px; margin: 0; background: var(--panel); border: 0; overflow: auto; max-height: 68dvh; font: 11px/1.5 var(--mono); }
@media (max-width: 760px) { .config-history-json-columns { grid-template-columns: 1fr; } .config-history-json-columns pre { max-height: 38dvh; } }
.acl-profile-attached { margin-block: 6px; }
.acl-profile-attached summary { cursor: pointer; color: var(--muted); font-size: 12px; }
.acl-profile-attached p { overflow-wrap: anywhere; }
+3 -3
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, backendHealthEditorControl = null;
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], uaRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0, backendHealthEditorControl = null;
const chartHistory = new Map();
const backendTrafficHistory = new Map();
const vhostTrafficHistory = new Map();
@@ -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, backendHealthEditorControl,
basicEditor, locationsReader, aclControl, readBindings, readHosts, countryRatesReader, uaRatesReader, 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; backendHealthEditorControl = state.backendHealthEditorControl || null;
readBindings = state.readBindings; readHosts = state.readHosts; countryRatesReader = state.countryRatesReader; uaRatesReader = state.uaRatesReader || (() => []); pagesEditor = state.pagesEditor; backendHealthEditorControl = state.backendHealthEditorControl || null;
stagedCertificates = state.stagedCertificates || []; uploading = state.uploading === true; initialized = true; dirty = true; $("vhost-editing").textContent = state.label;
if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
}
+2 -2
View File
@@ -248,7 +248,7 @@
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");
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); uaRatesReader = userAgentRateEditor(g, rate.user_agent_limits || []); note(g, "vhRateHelp");
const security = value.security_headers || {}, hsts = security.hsts || {};
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
@@ -446,7 +446,7 @@
return {
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, error_fallback: mainFallbackTarget ? { target: mainFallbackTarget, statuses: mainFallbackStatuses.length ? mainFallbackStatuses : [404] } : null, force_ssl: yes("force-ssl"), www_redirect: yes("www-redirect"), www_redirect_target: str("www-redirect-target") || null, forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), compression: collectCompression(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader(), user_agent_limits: uaRatesReader() },
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
+18
View File
@@ -0,0 +1,18 @@
"use strict";
// Per-vhost rate limit editors, shared in the bundled vhost closure.
function userAgentRateEditor(parent, values = []) {
parent.append(text("h3", "vhUaRateTitle", "subheading vh-wide"));
const rows = el("div", undefined, "vh-wide vh-ua-rates"); parent.append(rows);
const add = (value = {}) => {
if (rows.children.length >= 64) { toast(t("vhUaRateMax"), true); return; }
const row = el("div", undefined, "vh-row vh-grid");
field(row, "ua-rate-pattern", "vhUaRatePattern", value.pattern || "", "text", { required: true, maxLength: 256 });
field(row, "ua-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
field(row, "ua-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
field(row, "ua-rate-burst", "vhRateBurst", value.burst ?? 0, "number", { required: true, min: 0, max: 1000000 });
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
};
for (const value of values) add(value);
parent.append(button("vhAddUaRate", () => { add(); changed(); })); note(parent, "vhUaRateHelp");
return () => [...rows.children].map(row => ({ pattern: str("ua-rate-pattern", row).trim(), requests: num("ua-rate-requests", row), window_seconds: num("ua-rate-window", row), burst: num("ua-rate-burst", row) }));
}