This commit is contained in:
Mateusz Gruszczyński committed 2026-10-20 22:09:50 +02:00
1 parent 1fd81eaf5f
commit ee174bc283
36 files changed
+908 -183

No files matched your search

+60 -15
View File
@@ -50,6 +50,25 @@
function input(value = "", type = "text", cls = "") { const node = el("input"); node.type = type; node.value = value ?? ""; if (cls) node.className = cls; return node; }
function labeled(key, control, help = "") { const label = el("label", undefined, "docker-field"); label.append(el("span", t(key)), control); if (help) label.append(el("small", help, "fine")); return label; }
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;
}
function readProfileChoices(scope, selector) {
return [...scope.querySelectorAll(`${selector} input[type=checkbox]:checked`)].map(input => input.value);
}
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; };
choices.addEventListener("change", sync); sync(); return warning;
}
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"));
@@ -145,10 +164,11 @@
const target = input(value.default_target_ip || value.target_ip_override || "", "text", "docker-agent-target"); target.placeholder = t("dockerTargetAuto");
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 = el("select", undefined, "docker-agent-acl-profile"), noAcl = el("option", t("dockerDefaultAclNone")); noAcl.value = ""; defaultAcl.append(noAcl);
for (const profile of envelope?.config?.acl_profiles || []) { const item = el("option", profile.name || profile.id); item.value = profile.id; defaultAcl.append(item); } defaultAcl.value = value.acl_profile_id || "";
const defaultAcl = profileChoices(selectedProfileIds(value), "docker-agent-acl-profile");
const defaultAclField = labeled("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")), labeled("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp")));
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);
const listenerField = el("div", undefined, "docker-agent-listener-field"); listenerField.append(el("span", t("dockerListeners"), "docker-agent-field-label"));
const listenerControl = window.rustProxyIntegrations.bindings(listenerField, value.listener_ids || [], !agent); listenerField.querySelector(".listener-choices")?.classList.add("docker-agent-listener-choices"); row._readListeners = listenerControl.read; grid.append(listenerField);
const security = el("div", undefined, "docker-agent-security"), secBadge = el("span", "", "pill docker-agent-security-badge"), secHint = el("span", "", "fine docker-agent-security-hint"); security.append(secBadge, secHint);
@@ -167,7 +187,7 @@
id: row.querySelector(".docker-agent-id").value.trim(), name: row.querySelector(".docker-agent-name").value.trim(), enabled: row.querySelector(".docker-agent-enabled").checked,
endpoint: row.querySelector(".docker-agent-endpoint").value.trim().replace(/\/$/, ""), token: row.querySelector(".docker-agent-token").value,
tls_fingerprint_sha256: row.querySelector(".docker-agent-fingerprint").value.trim() || null, base_domain: row.querySelector(".docker-agent-domain").value.trim().toLowerCase(),
poll_interval_seconds: Number(row.querySelector(".docker-agent-poll").value), certificate_id: row.querySelector(".docker-agent-certificate").value || null, acl_profile_id: row.querySelector(".docker-agent-acl-profile").value || null, default_target_ip: row.querySelector(".docker-agent-target").value.trim() || null,
poll_interval_seconds: Number(row.querySelector(".docker-agent-poll").value), certificate_id: row.querySelector(".docker-agent-certificate").value || null, acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-agent-acl-profile"), default_target_ip: row.querySelector(".docker-agent-target").value.trim() || null,
listener_ids: listenerIds, assignments: structuredClone(row._assignments || [])
};
})
@@ -236,15 +256,15 @@
}
function aclEditor(assignment, agent) {
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 = el("select", undefined, "docker-acl-profile");
const inheritedProfile = agent?.acl_profile_id ? ((envelope?.config?.acl_profiles || []).find(item => item.id === agent.acl_profile_id)?.name || agent.acl_profile_id) : "";
const none = el("option", inheritedProfile ? `${t("dockerAclProfileInherit")}: ${inheritedProfile}` : t("dockerAclProfileNone")); none.value = ""; profile.append(none);
for (const item of envelope?.config?.acl_profiles || []) { const option = el("option", item.name || item.id); option.value = item.id; profile.append(option); } profile.value = assignment?.acl_profile_id || "";
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 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(labeled("dockerAclProfile", profile), inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details;
grid.append(profileField, 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);
@@ -260,7 +280,9 @@
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,
// 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: row.querySelector(".docker-acl-profile")?.value || null, acl: inline ? { default: row.querySelector(".docker-acl-default").value, rules: parseRules(row.querySelector(".docker-acl-rules").value) } : null,
acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-acl-profile"),
allow_certificate_mismatch: row.querySelector(".docker-cert-mismatch")?.checked === true,
acl: inline ? { default: row.querySelector(".docker-acl-default").value, rules: parseRules(row.querySelector(".docker-acl-rules").value) } : null,
proxy_timeouts: { connect_timeout_seconds: Number(row.querySelector(".docker-proxy-connect").value), response_header_timeout_seconds: Number(row.querySelector(".docker-proxy-header").value), read_timeout_seconds: Number(row.querySelector(".docker-proxy-read").value), send_timeout_seconds: Number(row.querySelector(".docker-proxy-send").value) },
health_check: collectHealth(row), passive_health: collectPassiveHealth(row)
};
@@ -268,7 +290,13 @@
async function saveAssignment(agentId, row, publish) {
const current = structuredClone(cfg()), agent = current.agents.find(item => item.id === agentId); if (!agent) return false;
const index = (agent.assignments || []).findIndex(item => item.service_key === row.dataset.key);
if (publish) { const assignment = collectAssignment(row); if (!assignment.hostname) throw new Error(t("dockerHostnameRequired")); if (index >= 0) agent.assignments[index] = assignment; else agent.assignments.push(assignment); }
if (publish) {
const assignment = collectAssignment(row); if (!assignment.hostname) throw new Error(t("dockerHostnameRequired"));
const previous = index >= 0 ? agent.assignments[index] : null;
const consentNames = item => JSON.stringify([item?.hostname || "", ...(item?.aliases || [])].map(v => v.toLowerCase().replace(/\.$/, "")).sort());
if (assignment.allow_certificate_mismatch && (!previous?.allow_certificate_mismatch || consentNames(assignment) !== consentNames(previous)) && !(await appConfirm(t("dockerCertMismatchConfirm"), { danger: true }))) return false;
if (index >= 0) agent.assignments[index] = assignment; else agent.assignments.push(assignment);
}
else if (index >= 0) agent.assignments.splice(index, 1);
const saved = await saveSection("docker-agents", current); if (saved) row.classList.remove("is-dirty"); return saved;
}
@@ -304,7 +332,12 @@
const transformEnabled = input("", "checkbox", "docker-transform-enabled"); transformEnabled.checked = assignment?.transform_enabled === true; const transformField = checkboxField("dockerTransform", transformEnabled);
const compressionEnabled = input("", "checkbox", "docker-compression-enabled"); compressionEnabled.checked = assignment?.compression_enabled !== false; const compressionField = checkboxField("dockerCompression", compressionEnabled); compressionField.title = t("dockerCompressionHelp");
const switches = el("div", undefined, "docker-switch-row"); switches.append(preserveLabel, forceSslField, websocketField, cacheField, compressionField, transformField);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches);
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 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();
@@ -335,8 +368,8 @@
const inv = state.inventory, defaultIp = agent.default_target_ip || agent.target_ip_override || t("dockerTargetAuto"), serviceCount = (inv.services || []).length, overview = el("div", undefined, "docker-agent-overview"); box.dataset.searchText += ` ${inv.hostname || ""}`.toLowerCase();
overview.append(el("span", `${t("dockerHost")}: ${inv.hostname}`), el("span", `${t("dockerServices")}: ${serviceCount}`), el("span", `${t("dockerLastChecked")}: ${checkedAgo(state.checked_at_unix_ms)}`), el("span", `*.${agent.base_domain}`)); box.append(overview);
const agentDetails = el("details", undefined, "docker-agent-details"), detailsSummary = el("summary", t("dockerAgentDetails")), summary = el("div", undefined, "docker-agent-summary");
const defaultAclName = agent.acl_profile_id ? ((envelope?.config?.acl_profiles || []).find(profile => profile.id === agent.acl_profile_id)?.name || agent.acl_profile_id) : t("dockerDefaultAclNone");
summary.append(el("span", `${t("dockerAgentVersion")}: ${inv.agent_version}`), el("span", `${t("dockerAgentPollEvery")}: ${agent.poll_interval_seconds ?? 30}s`), el("span", `${t("dockerCertificate")}: ${agent.certificate_id || t("vhCertificateDefault")}`), el("span", `${t("dockerDefaultAcl")}: ${defaultAclName}`), el("span", `${t("dockerDetectedIps")}: ${addressSummary(inv) || t("unavailable")}`), el("span", `${t("dockerDefaultTarget")}: ${defaultIp}`), el("span", `${t("dockerBaseDomain")}: *.${agent.base_domain}`)); if (inv.certificate_sha256) summary.append(el("span", `${t("dockerTlsFingerprint")}: ${prettyFingerprint(inv.certificate_sha256)}`, "mono")); agentDetails.append(detailsSummary, summary); box.append(agentDetails);
const defaultAclName = selectedProfileIds(agent).map(id => ((envelope?.config?.acl_profiles || []).find(profile => profile.id === id)?.name || id)).join(", ") || t("dockerDefaultAclNone");
summary.append(el("span", `${t("dockerAgentVersion")}: ${inv.agent_version}`), el("span", `${t("dockerAgentPollEvery")}: ${agent.poll_interval_seconds ?? 30}s`), el("span", `${t("dockerCertificate")}: ${agent.certificate_id || t("vhCertificateDefault")}`), el("span", `${t("dockerDefaultAcl")}: ${defaultAclName}`), el("span", `${t("dockerDetectedIps")}: ${addressSummary(inv) || t("unavailable")}`), el("span", `${t("dockerDefaultTarget")}: ${defaultIp}`), el("span", `${t("dockerBaseDomain")}: *.${agent.base_domain}`)); if (inv.certificate_sha256) summary.append(el("span", `${t("dockerTlsFingerprint")}: ${prettyFingerprint(inv.certificate_sha256)}`, "mono docker-fingerprint")); agentDetails.append(detailsSummary, summary); box.append(agentDetails);
const rows = el("div", undefined, "docker-service-list"), assignments = new Map((agent.assignments || []).map(item => [item.service_key, item])); for (const service of inv.services || []) rows.append(assignmentRow(agent, inv, service, assignments.get(service.key), false)); for (const item of agent.assignments || []) if (!(inv.services || []).some(service => service.key === item.service_key)) rows.append(assignmentRow(agent, inv, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box);
}
applyViewFilters();
@@ -407,7 +440,19 @@
$("docker-show-hidden")?.addEventListener("click", () => { showHidden = !showHidden; applyViewFilters(); });
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
$("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } });
$("docker-agents-form")?.addEventListener("submit", async event => {
event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try {
await withFormSubmitState(form, event.submitter, async () => {
const next = collectSettings();
const changingAcceptedCertificate = next.agents.some(agent => {
const previous = (cfg().agents || []).find(old => old.id === agent.id);
return previous && previous.certificate_id !== agent.certificate_id && agent.assignments.some(item => item.allow_certificate_mismatch);
});
if (changingAcceptedCertificate && !(await appConfirm(t("dockerCertMismatchConfirm"), { danger: true }))) return;
if (await saveSection("docker-agents", next)) { setSettingsDirty("docker-agents-form", false); await refresh(true); }
});
} catch (error) { toast(error.message, true); }
});
rustProxyRefresh.register("docker", {
interval: () => 5,
// Even with local Live view off, observe the first startup result.