This commit is contained in:
Mateusz Gruszczyński committed 2026-10-20 22:47:13 +02:00
1 parent ee174bc283
commit 52e7ce7b46
24 files changed
+672 -418

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
"use strict";
window.rustProxyAclPicker = (() => {
const LIMIT = 8;
const profiles = () => envelope?.config?.acl_profiles || [];
const chosenIds = root => [...(root?.querySelectorAll('.acl-picker-option input[type="checkbox"]:checked') || [])].map(input => input.value);
const preview = profile => {
const rules = profile.acl?.rules || [], country = profile.country_access || {};
const lines = rules.slice(0, 3).map(rule => `${rule.action === "deny" ? t("deny") : t("allow")} ${rule.network}`);
if (rules.length > 3) lines.push(`+${rules.length - 3} ${t("aclPickerMoreRules")}`);
if (country.mode === "allow" || country.mode === "block") {
const codes = (country.countries || []).slice(0, 5).join(", ");
const extra = (country.countries || []).length - 5;
lines.push(`${t(country.mode === "block" ? "aclPickerBlockedCountries" : "aclPickerAllowedCountries")}: ${codes}${extra > 0 ? ` +${extra}` : ""}`);
}
if (!lines.length) lines.push(t(profile.acl ? "aclPickerNoRules" : "aclPickerNoIpRules"));
return lines.join(" · ");
};
function create(selected = [], className = "") {
const root = el("div", undefined, `acl-picker ${className}`.trim());
const details = el("details", undefined, "acl-picker-details"), summary = el("summary", undefined, "acl-picker-summary");
const heading = el("span", t("aclPickerChoose"), "acl-picker-heading");
const counter = el("span", "", "acl-picker-counter");
summary.append(heading, counter); details.append(summary);
const dropdown = el("div", undefined, "acl-picker-dropdown");
const search = el("input", "", "acl-picker-search"); search.type = "search"; search.placeholder = t("aclPickerSearch"); search.setAttribute("aria-label", t("aclPickerSearch"));
const options = el("div", undefined, "acl-picker-options");
const empty = el("p", t("aclPickerNoMatches"), "fine acl-picker-empty");
const maxHint = el("small", t("aclPickerLimit"), "fine acl-picker-limit");
dropdown.append(search, options, empty, maxHint); details.append(dropdown);
const selectedTags = el("div", undefined, "acl-picker-selected");
root.append(details, selectedTags);
const items = profiles();
const checks = new Map(), selectedSet = new Set(selected);
if (!items.length) {
const noProfiles = el("p", t("aclPickerNoProfiles"), "fine");
options.append(noProfiles); search.disabled = true;
}
for (const profile of items) {
const label = el("label", undefined, "acl-picker-option");
const check = el("input"); check.type = "checkbox"; check.value = profile.id; check.checked = selectedSet.has(profile.id);
const content = el("span", undefined, "acl-picker-option-content");
const headingRow = el("span", undefined, "acl-picker-option-heading");
const name = el("strong", profile.name || profile.id), identity = el("code", profile.id, "acl-picker-id");
const badge = el("span", t(!profile.acl ? "aclPickerNoIpRules" : profile.acl.default === "allow" ? "aclPickerDefaultAllow" : "aclPickerDefaultDeny"), `acl-picker-policy ${profile.acl?.default === "deny" ? "is-deny" : profile.acl?.default === "allow" ? "is-allow" : "is-none"}`);
headingRow.append(name, identity, badge);
content.append(headingRow, el("small", preview(profile), "acl-picker-rule-preview"));
label.append(check, content); options.append(label); checks.set(profile.id, check);
const allRules = (profile.acl?.rules || []).map(rule => `${rule.action} ${rule.network}`).join(" ");
const countries = (profile.country_access?.countries || []).join(" ");
const searchText = `${profile.name || ""} ${profile.id} ${allRules} ${countries}`.toLocaleLowerCase();
label.dataset.search = searchText;
}
const syncSearch = () => {
const query = search.value.trim().toLocaleLowerCase(); let visible = 0;
for (const label of options.querySelectorAll(".acl-picker-option")) {
label.hidden = !!query && !label.dataset.search.includes(query);
if (!label.hidden) visible++;
}
empty.hidden = visible > 0 || items.length === 0;
};
const sync = () => {
const ids = chosenIds(root), full = ids.length >= LIMIT;
counter.textContent = `${ids.length}/${LIMIT}`;
heading.textContent = ids.length ? t("aclPickerSelected").replace("{count}", ids.length) : t("aclPickerChoose");
selectedTags.replaceChildren();
for (const id of ids) {
const profile = items.find(item => item.id === id);
const tag = el("span", undefined, "acl-picker-tag");
const label = el("span", profile?.name || id); label.title = preview(profile || {});
const remove = el("button", "×", "acl-picker-remove"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${profile?.name || id}`);
remove.addEventListener("click", () => { const box = checks.get(id); box.checked = false; box.dispatchEvent(new Event("change", { bubbles: true })); });
tag.append(label, remove); selectedTags.append(tag);
}
if (!ids.length) selectedTags.append(el("span", t("aclPickerNone"), "fine acl-picker-no-selection"));
for (const check of checks.values()) check.disabled = full && !check.checked;
maxHint.hidden = !full;
};
options.addEventListener("change", event => { if (event.target.matches('input[type="checkbox"]')) sync(); });
search.addEventListener("input", syncSearch);
search.addEventListener("keydown", event => { if (event.key === "Escape") { search.value = ""; syncSearch(); details.open = false; summary.focus(); } });
syncSearch(); sync(); return root;
}
return { create, read: chosenIds, count: root => chosenIds(root).length };
})();
+49 -24
View File
@@ -52,23 +52,25 @@
function checkboxField(key, control) { const field = el("div", undefined, "docker-field docker-checkbox-field"), label = el("label", undefined, "check vh-check"); label.append(control, el("span", t("enabled"))); field.append(el("span", t(key)), label); return field; }
function selectedProfileIds(value) { return [...new Set([value?.acl_profile_id, ...(value?.acl_profile_ids || [])].filter(Boolean))]; }
function profileChoices(initial, className) {
const box = el("div", undefined, `docker-acl-choices ${className}`), chosen = new Set(initial);
for (const profile of envelope?.config?.acl_profiles || []) {
const label = el("label", undefined, "check docker-acl-choice"), control = input("", "checkbox");
control.value = profile.id; control.checked = chosen.has(profile.id);
label.append(control, el("span", profile.name || profile.id)); box.append(label);
}
if (!box.children.length) box.append(el("span", t("dockerDefaultAclNone"), "fine"));
return box;
return window.rustProxyAclPicker.create(initial, className);
}
function readProfileChoices(scope, selector) {
return [...scope.querySelectorAll(`${selector} input[type=checkbox]:checked`)].map(input => input.value);
return window.rustProxyAclPicker.read(scope.querySelector(selector));
}
function profileWarning(choices, inheritedCount = 0) {
const warning = el("p", t("multiAclWarning"), "fine acl-stack-warning");
const sync = () => { warning.hidden = (readProfileChoices(choices, ":scope").length || inheritedCount) < 2; };
const sync = () => {
const selected = window.rustProxyAclPicker.count(choices);
warning.hidden = (selected || inheritedCount) < 2;
};
choices.addEventListener("change", sync); sync(); return warning;
}
function profileField(key, picker, help) {
const wrapper = el("div", undefined, "docker-field docker-profile-field");
wrapper.append(el("span", t(key)), picker);
if (help) wrapper.append(el("small", help, "fine"));
return wrapper;
}
function aliasEditor(initial = [], onChange = () => { }) {
const field = el("div", undefined, "docker-field docker-alias-field host-chip-field"), box = el("div", undefined, "host-chip-editor docker-alias-editor"), inputNode = input("", "text", "docker-alias-input");
field.append(el("span", t("dockerAliases"))); inputNode.maxLength = 253; inputNode.placeholder = "alias1 / app.example.com"; inputNode.setAttribute("aria-label", t("dockerAliasAdd"));
@@ -165,7 +167,7 @@
const certificate = el("select", undefined, "docker-agent-certificate"), defaultCertificate = el("option", t("vhCertificateDefault")); defaultCertificate.value = ""; certificate.append(defaultCertificate);
for (const cert of envelope?.config?.tls?.certificates || []) { const item = el("option", cert.id); item.value = cert.id; certificate.append(item); } certificate.value = value.certificate_id || "";
const defaultAcl = profileChoices(selectedProfileIds(value), "docker-agent-acl-profile");
const defaultAclField = labeled("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp"));
const defaultAclField = profileField("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp"));
defaultAclField.append(profileWarning(defaultAcl));
const grid = el("div", undefined, "field-grid docker-agent-grid");
grid.append(labeled("dockerAgentId", id), labeled("dockerAgentName", name), labeled("dockerAgentEndpoint", endpoint, t("dockerAgentEndpointHelp")), labeled("dockerAgentToken", token), labeled("dockerTlsFingerprint", fingerprint, t("dockerTlsFingerprintHelp")), labeled("dockerBaseDomain", domain), labeled("dockerPollInterval", poll, t("dockerPollIntervalHelp")), labeled("dockerDefaultTarget", target), labeled("dockerCertificate", certificate, t("dockerCertificateHelp")), defaultAclField);
@@ -258,13 +260,13 @@
const details = el("details", undefined, "docker-acl-editor"), summary = el("summary"); summary.append(el("span", t("dockerAccessControl")), el("small", t("dockerAclSummary"), "fine")); details.append(summary);
const grid = el("div", undefined, "field-grid docker-acl-grid"), profile = profileChoices(selectedProfileIds(assignment), "docker-acl-profile");
const inheritedProfiles = selectedProfileIds(agent);
const profileField = labeled("dockerAclProfile", profile, inheritedProfiles.length ? `${t("dockerAclProfileInherit")}: ${inheritedProfiles.map(id => (envelope?.config?.acl_profiles || []).find(item => item.id === id)?.name || id).join(", ")}` : t("dockerAclProfileNone"));
profileField.append(profileWarning(profile, inheritedProfiles.length));
const profileControl = profileField("dockerAclProfile", profile, inheritedProfiles.length ? `${t("dockerAclProfileInherit")}: ${inheritedProfiles.map(id => (envelope?.config?.acl_profiles || []).find(item => item.id === id)?.name || id).join(", ")}` : t("dockerAclProfileNone"));
profileControl.append(profileWarning(profile, inheritedProfiles.length));
const inline = input("", "checkbox", "docker-acl-inline"); inline.checked = !!assignment?.acl; const inlineLabel = checkboxField("dockerAclInline", inline);
const defaultAction = el("select", undefined, "docker-acl-default"); for (const value of ["deny", "allow"]) { const option = el("option", t(value)); option.value = value; defaultAction.append(option); } defaultAction.value = assignment?.acl?.default || "deny";
const rules = document.createElement("textarea"); rules.className = "docker-acl-rules"; rules.rows = 4; rules.maxLength = 32768; rules.spellcheck = false; rules.value = rulesText(assignment?.acl); rules.placeholder = "allow 10.0.0.0/8\ndeny 0.0.0.0/0";
const sync = () => { defaultAction.disabled = !inline.checked; rules.disabled = !inline.checked; }; inline.addEventListener("change", sync); sync();
grid.append(profileField, inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details;
grid.append(profileControl, inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details;
}
function timeoutEditor(assignment) {
const value = { ...defaultProxyTimeouts(), ...(assignment?.proxy_timeouts || {}) }, details = el("details", undefined, "docker-timeout-editor"), summary = el("summary"); summary.append(el("span", t("dockerProxyTimeouts")), el("small", t("dockerProxyTimeoutsHelp"), "fine")); details.append(summary);
@@ -277,7 +279,7 @@
const inline = row.querySelector(".docker-acl-inline")?.checked;
return {
service_key: row.dataset.key, hostname: row.querySelector(".docker-hostname").value.trim().toLowerCase(), aliases: row._readAliases?.() || [], target_ip: row.querySelector(".docker-target-ip").value || null,
enabled: true, scheme: row.querySelector(".docker-scheme").value, preserve_host: row.querySelector(".docker-preserve-host").checked, force_ssl: row.querySelector(".docker-force-ssl").checked, websockets: row.querySelector(".docker-websockets").checked, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked, compression_enabled: row.querySelector(".docker-compression-enabled").checked,
enabled: true, scheme: row.querySelector(".docker-scheme").value, preserve_host: row.querySelector(".docker-preserve-host").checked, force_ssl: row.querySelector(".docker-force-ssl").checked, websockets: row.querySelector(".docker-websockets").checked, http2_enabled: row.querySelector(".docker-http2-enabled").checked, http3_enabled: row.querySelector(".docker-http3-enabled").checked, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked, compression_enabled: row.querySelector(".docker-compression-enabled").checked,
// Docker publications intentionally expose one compression switch only. Codec selection stays global.
compression_zstd: null, compression_br: null, compression_gzip: null, compression_deflate: null,
acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-acl-profile"),
@@ -335,13 +337,40 @@
const certMismatch = input("", "checkbox", "docker-cert-mismatch"); certMismatch.checked = assignment?.allow_certificate_mismatch === true;
const mismatchField = checkboxField("dockerCertMismatchForce", certMismatch);
const mismatchHelp = el("p", t("dockerCertMismatchWarning"), "fine acl-stack-warning");
const syncMismatch = () => { certMismatch.disabled = !agent.certificate_id; if (!agent.certificate_id) certMismatch.checked = false; mismatchHelp.hidden = !certMismatch.checked; };
certMismatch.addEventListener("change", syncMismatch); syncMismatch(); mismatchField.append(mismatchHelp);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches, mismatchField);
editor.append(route, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
const mismatchUnavailable = el("p", t("dockerCertMismatchUnavailable"), "fine");
const riskPill = el("span", t("dockerCertMismatchRisk"), "acl-picker-policy is-deny docker-cert-risk-pill");
const syncMismatch = () => {
certMismatch.disabled = !agent.certificate_id;
if (!agent.certificate_id) certMismatch.checked = false;
mismatchHelp.hidden = !certMismatch.checked;
mismatchUnavailable.hidden = !!agent.certificate_id;
riskPill.hidden = !certMismatch.checked;
};
certMismatch.addEventListener("change", syncMismatch);
mismatchField.append(mismatchHelp, mismatchUnavailable);
const certificateBar = el("div", undefined, "docker-service-certificate-bar");
const tlsControl = el("div", undefined, "docker-service-certificate-control");
const tlsHeading = el("div", undefined, "docker-service-certificate-title");
tlsHeading.append(el("strong", t("dockerCertSection")), riskPill);
tlsControl.append(tlsHeading, mismatchField); certificateBar.append(tlsControl);
syncMismatch();
const h2Enabled = input("", "checkbox", "docker-http2-enabled"); h2Enabled.checked = assignment?.http2_enabled !== false;
const h3Enabled = input("", "checkbox", "docker-http3-enabled"); h3Enabled.checked = assignment?.http3_enabled !== false;
const protocolBox = el("section", undefined, "docker-protocol-panel");
const protocolHeading = el("div", undefined, "docker-protocol-heading");
protocolHeading.append(el("strong", t("dockerFrontendProtocols")), el("small", t("dockerFrontendProtocolsHelp"), "fine"));
const protocolOptions = el("div", undefined, "docker-protocol-options");
const h1 = el("span", undefined, "docker-protocol-fixed");
h1.append(el("strong", "HTTP/1.1"), el("small", t("dockerHttp1Always"), "fine"));
protocolOptions.append(h1, checkboxField("dockerHttp2Enabled", h2Enabled), checkboxField("dockerHttp3Enabled", h3Enabled));
protocolBox.append(protocolHeading, protocolOptions);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches);
editor.append(certificateBar, route, protocolBox, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
const actions = el("div", undefined, "docker-service-actions"), apply = el("button", t("dockerApplyService"), "outline docker-apply"); apply.type = "button"; apply.disabled = !publish.checked; apply.addEventListener("click", async () => { apply.disabled = true; try { await saveAssignment(agent.id, row, true); } catch (error) { toast(error.message, true); } finally { if (row.isConnected) apply.disabled = !publish.checked; } }); actions.append(apply); editor.append(actions); row.append(editor);
function updateHint() { const targetLabel = target.selectedOptions?.[0]?.textContent || t("dockerBackendIpAuto"), aliasCount = aliases.count(); editorHint.textContent = `${host.value.trim() || technical} → ${scheme.value.toUpperCase()} · ${targetLabel}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint();
function updateHint() { const targetLabel = target.selectedOptions?.[0]?.textContent || t("dockerBackendIpAuto"), aliasCount = aliases.count(); editorHint.textContent = `${host.value.trim() || technical} → ${scheme.value.toUpperCase()} · ${targetLabel} · H1${h2Enabled.checked ? "/H2" : ""}${h3Enabled.checked ? "/H3" : ""}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint();
for (const control of [host, aliases.input, target, scheme]) { control.addEventListener("input", updateHint); control.addEventListener("change", updateHint); }
for (const control of [h2Enabled, h3Enabled]) control.addEventListener("change", updateHint);
host.addEventListener("input", () => syncOpenApp(host.value));
row.addEventListener("input", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); }); row.addEventListener("change", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); });
publish.addEventListener("change", async () => {
@@ -360,8 +389,6 @@
const state = obs(agent.id), box = el("article", undefined, "card docker-agent-card"); box.dataset.agentId = agent.id; box.dataset.searchText = `${agent.id} ${agent.name || ""} ${agent.base_domain || ""}`.toLowerCase(); const head = el("div", undefined, "card-heading"), left = el("div"); left.append(el("span", agent.id, "small-label"), el("h2", agent.name));
const badges = el("div", undefined, "entity-actions"), connection = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill ${state?.ok ? "live" : ""}`), sec = securityInfo(agent, state); badges.append(el("span", t(sec.key), `pill ${sec.cls}`.trim()), connection); head.append(left, badges); box.append(head);
if (!agent.enabled) { box.append(el("p", t("dockerAgentDisabled"), "fine")); target.append(box); continue; }
// No observation is the normal state while the first background poll is in flight.
// A configured TLS fingerprint alone is not proof that an agent was contacted.
if (!state) box.append(el("p", t("dockerNoObservation"), "fine"));
else if (!state.ok) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state.error || t("dockerUnknownPollError"), "fine")); box.append(error); }
if (!state?.inventory) { const rows = el("div", undefined, "docker-service-list"); for (const item of agent.assignments || []) rows.append(assignmentRow(agent, null, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box); continue; }
@@ -455,8 +482,6 @@
});
rustProxyRefresh.register("docker", {
interval: () => 5,
// Even with local Live view off, observe the first startup result.
// Once every enabled agent has a first result, stop unless Live is enabled.
active: () => currentPanel === "docker-vhosts" && canLiveRender() && (liveEnabled() || hasUnobservedAgent()),
run: async context => refresh(false, context)
});
+171 -65
View File
@@ -12315,7 +12315,8 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
font: 400 10px var(--mono);
}
.docker-agent-summary>span,
/* Docker TLS SHA-256 values must wrap even in narrow mobile details cards. */
.docker-agent-summary > span,
.docker-agent-security-hint,
.docker-fingerprint {
min-width: 0;
@@ -12324,76 +12325,181 @@ html[data-dashboard-density=ultra] #panel-overview .stat-cache-outcomes .cache-m
word-break: break-word;
}
.vh-acl-profile-picker,
.docker-acl-choices {
/* Reusable searchable ACL multi-select: identical in the ordinary vhost editor,
Docker agent defaults and Docker per-service overrides. All internal corners
follow the user-selected control radius; fallbacks preserve Original style. */
.vh-acl-profile-picker, .acl-picker { min-width: 0; width: 100%; }
.vh-acl-profile-picker > strong { display: block; margin-bottom: 8px; }
.acl-picker-details {
border: 1px solid var(--field-line);
background: var(--field-bg);
border-radius: var(--rp-radius-control, 8px);
min-width: 0;
}
.vh-acl-profile-picker>strong {
display: block;
margin-bottom: 8px;
}
.vh-acl-profile-choices,
.docker-acl-choices {
.acl-picker-details[open] { border-color: var(--border-hover); }
.acl-picker-summary {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 5px 14px;
}
.vh-acl-profile-choice,
.docker-acl-choice {
display: inline-flex;
align-items: center;
min-height: 32px;
gap: 7px;
max-width: 100%;
overflow-wrap: anywhere;
gap: 12px;
padding: 10px 12px;
font-size: 12px;
cursor: pointer;
user-select: none;
min-height: 21px;
}
.vh-acl-profile-choice input,
.docker-acl-choice input {
flex: 0 0 auto;
width: auto !important;
min-height: 0 !important;
height: auto !important;
.acl-picker-heading { flex: 1 1 auto; font-weight: 600; min-width: 0; }
.acl-picker-counter {
color: var(--accent);
border: 1px solid var(--pill-line);
border-radius: var(--rp-radius-control, 12px);
padding: 2px 7px;
font: 11px var(--mono);
margin-left: auto;
}
.acl-stack-warning {
margin: 8px 0 0;
font-size: 11px;
line-height: 1.5;
color: var(--muted);
}
.docker-acl-grid .docker-acl-choices,
.docker-agent-grid .docker-acl-choices {
padding: 5px 0;
}
.docker-acl-grid .docker-field:has(.docker-acl-choices) {
grid-column: span 2;
}
.docker-cert-mismatch {
flex: 0 0 auto;
}
@media (max-width: 760px) {
.docker-acl-grid .docker-field:has(.docker-acl-choices) {
grid-column: auto;
}
.docker-agent-summary>span,
.docker-fingerprint {
width: 100%;
}
}
.docker-agent-grid .docker-agent-fingerprint {
min-width: 0;
.acl-picker-dropdown { padding: 10px; border-top: 1px solid var(--line); }
.acl-picker-search, .docker-agent-grid .acl-picker-search {
width: 100%;
box-sizing: border-box;
}
min-height: 34px;
height: 34px;
margin: 0 0 9px;
}
.acl-picker-options { display: grid; max-height: 270px; overflow-y: auto; gap: 5px; }
.acl-picker-option {
display: flex;
align-items: flex-start;
gap: 10px;
border: 1px solid var(--line);
border-radius: var(--rp-radius-control, 6px);
background: var(--panel);
padding: 9px 10px;
cursor: pointer;
min-width: 0;
}
.acl-picker-option:hover, .acl-picker-option:has(input:focus-visible) { border-color: var(--border-hover); }
.acl-picker-option:has(input:checked) { border-color: var(--pill-live-line); background: var(--nav-selected-bg); }
.acl-picker-option[hidden], .acl-picker-empty[hidden], .acl-picker-limit[hidden] { display: none !important; }
.acl-picker-option input[type=checkbox] {
flex: 0 0 auto;
width: 16px !important;
height: 16px !important;
min-height: 16px !important;
margin: 2px 0 0;
}
.acl-picker-option input:disabled { opacity: .35; }
.acl-picker-option:has(input:disabled) { cursor: not-allowed; opacity: .6; }
.acl-picker-option-content { display: grid; flex: 1; min-width: 0; gap: 5px; }
.acl-picker-option-heading { display: flex; flex-wrap: wrap; gap: 5px 9px; align-items: center; }
.acl-picker-option-heading strong { font-size: 12px; color: var(--text); overflow-wrap: anywhere; }
.acl-picker-id { font: 10px var(--mono); color: var(--muted); overflow-wrap: anywhere; }
.acl-picker-policy {
font: 10px var(--mono);
line-height: 1.4;
border-radius: var(--rp-radius-control, 12px);
padding: 2px 7px;
border: 1px solid var(--line);
color: var(--muted);
white-space: nowrap;
}
.acl-picker-policy.is-deny { border-color: var(--danger-line); color: var(--red); }
.acl-picker-policy.is-allow { border-color: var(--pill-live-line); color: var(--green); }
.acl-picker-rule-preview {
display: block;
line-height: 1.45;
overflow-wrap: anywhere;
color: var(--muted);
font-size: 11px;
}
.acl-picker-selected { display: flex; gap: 6px; flex-wrap: wrap; margin: 8px 0 0; min-width: 0; }
.acl-picker-tag {
display: inline-flex;
align-items: center;
max-width: 100%;
gap: 7px;
padding: 2px 4px 2px 9px;
border-radius: var(--rp-radius-control, 15px);
border: 1px solid var(--pill-live-line);
background: var(--nav-selected-bg);
font-size: 11px;
}
.acl-picker-tag > span { overflow-wrap: anywhere; min-width: 0; }
.acl-picker-tag .acl-picker-remove {
border: none;
border-radius: var(--rp-radius-control, 50%);
background: transparent;
color: var(--muted);
min-width: 20px;
height: 20px;
font-size: 16px;
padding: 0;
cursor: pointer;
}
.acl-picker-tag .acl-picker-remove:hover { background: var(--panel2); color: var(--text); }
.acl-picker-no-selection { font-size: 11px; }
.acl-picker-limit { display: block; padding-top: 7px; }
.acl-stack-warning { margin: 8px 0 0; font-size: 11px; line-height: 1.5; color: var(--notice-text); }
.docker-profile-field { min-width: 0; }
.docker-acl-grid .docker-profile-field { grid-column: span 2; }
.docker-agent-grid .docker-profile-field { grid-column: span 2; }
/* Certificate acceptance is an independent, upper-right security control. */
.docker-service-certificate-bar { display: flex; justify-content: flex-end; margin: 0 0 12px; }
.docker-service-certificate-control {
display: grid;
gap: 5px;
width: min(100%, 510px);
padding: 10px 12px;
border: 1px solid var(--notice-line);
border-radius: 8px;
background: var(--notice-bg);
min-width: 0;
}
.docker-service-certificate-title { display: flex; align-items: center; gap: 9px; }
.docker-service-certificate-title strong { font-size: 11px; }
.docker-cert-risk-pill[hidden] { display: none; }
.docker-service-certificate-control .docker-field > span:first-child { color: var(--text); }
.docker-service-certificate-control .docker-checkbox-field { gap: 3px; }
.docker-service-certificate-control .docker-checkbox-field .check { font-size: 11px; }
.docker-service-certificate-control .acl-stack-warning { color: var(--notice-text); }
.docker-service-certificate-control .fine[hidden] { display: none; }
.docker-cert-mismatch { flex: 0 0 auto; }
.docker-protocol-panel {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
padding: 11px 12px;
margin: 10px 0 12px;
min-width: 0;
}
.docker-protocol-heading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 5px 12px; margin-bottom: 9px; }
.docker-protocol-heading strong { font-size: 12px; }
.docker-protocol-heading small { font-size: 11px; }
.docker-protocol-options { display: flex; flex-wrap: wrap; gap: 8px; }
.docker-protocol-options > * {
display: grid;
align-content: center;
min-width: 130px;
flex: 1 1 145px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--panel2);
padding: 8px 12px;
min-height: 49px;
box-sizing: border-box;
}
.docker-protocol-fixed { gap: 4px; }
.docker-protocol-fixed strong { font-size: 11px; }
.docker-protocol-fixed small { font-size: 10px; }
.docker-protocol-options .docker-checkbox-field > span:first-child { color: var(--text); font-size: 11px; }
.docker-protocol-options .docker-checkbox-field .vh-check { font-size: 11px; }
@media (max-width: 760px) {
.docker-acl-grid .docker-profile-field, .docker-agent-grid .docker-profile-field { grid-column: auto; }
.docker-service-certificate-control { width: 100%; }
.docker-agent-summary > span, .docker-fingerprint { width: 100%; }
.acl-picker-option-heading { align-items: flex-start; }
}
/* Keep the editable SHA-256 input from expanding narrow settings grids. */
.docker-agent-grid .docker-agent-fingerprint { min-width: 0; width: 100%; box-sizing: border-box; }
+3 -14
View File
@@ -83,8 +83,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
wwwDetails.append(wwwLabel, wwwChoices, wwwNoHosts, wwwOther, wwwHelp);
wwwPanel.append(wwwHeader, wwwInactive, wwwDetails, wwwTarget);
g.append(wwwPanel);
// Persist only the selected canonical host; choosing the www variant does
// not require adding an extra hostname to the vhost manually.
let selectedCanonical = value.www_redirect_target || (value.frontend?.hosts || []).find(host => !host.startsWith("*.")) || "";
const syncCanonical = () => {
const allHosts = lines(read("hosts").value);
@@ -129,8 +127,6 @@ function load(value = defaults(), migrationIds = [], show = true) {
wwwNoHosts.hidden = bases.length > 0;
wwwRedirect.disabled = !bases.length;
if (!bases.length) wwwRedirect.checked = false;
// Never hide the destination choices: the unchecked state is a preview
// that the operator can configure before enabling the rule.
wwwDetails.hidden = false;
wwwInactive.hidden = wwwRedirect.checked;
wwwRedirect.setAttribute("aria-expanded", "true");
@@ -241,14 +237,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
const selectedProfiles = new Set([value.acl_profile_id, ...(value.acl_profile_ids || [])].filter(Boolean));
const profileBox = el("div", undefined, "vh-wide vh-acl-profile-picker");
profileBox.append(text("strong", "vhAclProfile"));
const choices = el("div", undefined, "vh-acl-profile-choices");
for (const profile of envelope?.config.acl_profiles || []) {
const label = el("label", undefined, "check vh-acl-profile-choice");
const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.name = "acl-profile-choices";
checkbox.value = profile.id; checkbox.checked = selectedProfiles.has(profile.id);
label.append(checkbox, el("span", profile.name || profile.id)); choices.append(label);
}
if (!choices.children.length) choices.append(text("span", "vhAclProfileNone", "fine"));
const choices = window.rustProxyAclPicker.create([...selectedProfiles], "vh-acl-profile-choices");
profileBox.append(choices);
const multiWarning = text("p", "multiAclWarning", "fine acl-stack-warning");
const syncMultiWarning = () => { multiWarning.hidden = choices.querySelectorAll('input:checked').length < 2; };
@@ -406,7 +395,7 @@ function load(value = defaults(), migrationIds = [], show = true) {
}
function collect() {
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
locationsReader();
let paths, acl, headers;
try {
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
@@ -464,7 +453,7 @@ function collect() {
}
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"), allow_certificate_mismatch: yes("allow-certificate-mismatch"), 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: null,
acl_profile_ids: [...form.querySelectorAll('input[name="acl-profile-choices"]:checked')].map(input => input.value),
acl_profile_ids: window.rustProxyAclPicker.read(form.querySelector(".vh-acl-profile-choices")),
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader(), user_agent_limits: uaRatesReader() },
method_policy: { mode: str("method-mode"), methods: lines(str("method-list")).map(value => value.toUpperCase()) },