"use strict"; (() => { const sortColumns = ["service", "hostname", "status", "backend", "scheme", "published"]; let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false, listPrefs = { sort: "service", descending: false }; function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.docker_vhost_sort) ? preferences.docker_vhost_sort : "service", descending: preferences?.docker_vhost_descending === true }; } function syncSortDirection() { const control = $("docker-vhost-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); } function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); applyViewFilters(); } async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ docker_vhost_sort: listPrefs.sort, docker_vhost_descending: listPrefs.descending })); if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); applyViewFilters(); } catch (error) { if (error.status !== 401) toast(error.message, true); } } const LIVE_KEY = "rustproxy.docker.live-view"; const HIDDEN_KEY = "rustproxy.docker.hidden-vhosts"; const cfg = () => envelope?.config?.docker_agents || { agents: [] }; const obs = id => observations.find(item => item.agent_id === id); const hasUnobservedAgent = () => (cfg().agents || []).some(agent => agent.enabled !== false && !obs(agent.id)); const firstListener = () => envelope?.config?.listeners?.endpoints?.find(item => item.enabled !== false)?.id || "http"; const defaultHealth = () => ({ mode: "tcp", path: "/", host: null, interval_ms: 5000, timeout_ms: 2000, slow_response_ms: 1500, fall: 3, rise: 2, expected_status_min: 200, expected_status_max: 399 }); const defaultPassiveHealth = () => ({ enabled: false, count_connect_errors: true, count_timeouts: true, count_io_errors: true, count_http_5xx: true, count_slow_responses: false, fall: null, rise: null }); const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 }); const liveEnabled = () => localStorage.getItem(LIVE_KEY) === "1"; function loadHidden() { try { const value = JSON.parse(localStorage.getItem(HIDDEN_KEY) || "[]"); return new Set(Array.isArray(value) ? value.filter(item => typeof item === "string") : []); } catch { return new Set(); } } let hiddenVhosts = loadHidden(); const viewKey = (agentId, serviceKey) => `${agentId}|${serviceKey}`; function saveHidden() { try { localStorage.setItem(HIDDEN_KEY, JSON.stringify([...hiddenVhosts].sort())); } catch { } } function expandedHost(value, baseDomain) { const host = String(value || "").trim().replace(/\.$/, "").toLowerCase(); return host && host.includes(".") ? host : host ? `${host}.${baseDomain}` : ""; } function publicListener(agent) { const endpoints = envelope?.config?.listeners?.endpoints || [], wanted = new Set(agent?.listener_ids || []), candidates = endpoints.filter(item => item.enabled !== false && wanted.has(item.id)); return candidates.find(item => item.tls) || candidates[0] || null; } function proxiedUrl(agent, hostname) { const host = expandedHost(hostname, agent.base_domain); if (!host) return ""; const listener = publicListener(agent), secure = !!listener?.tls, scheme = secure ? "https" : "http", address = String(listener?.address || ""); const match = address.match(/:(\d+)$/), port = match ? Number(match[1]) : (secure ? 443 : 80), suffix = port === (secure ? 443 : 80) ? "" : `:${port}`; return `${scheme}://${host}${suffix}/`; } function selectedListenerBindings(agent) { const wanted = new Set(agent?.listener_ids || []); return (envelope?.config?.listeners?.endpoints || []).filter(item => item.enabled !== false && wanted.has(item.id)).flatMap(item => { const addresses = [item.binding?.address, ...(item.additional_addresses || [])].filter(Boolean); return addresses.map(address => ({ address, tls: !!item.binding?.tls })); }); } function canForceSsl(agent) { const bindings = selectedListenerBindings(agent), tlsPorts = new Set(); let hasHttp = false; for (const binding of bindings) { if (!binding.tls) { hasHttp = true; continue; } const match = String(binding.address || "").match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1])); } return hasHttp && tlsPorts.size > 0 && (tlsPorts.has(443) || tlsPorts.size === 1); } 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 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")); const values = []; const normalize = raw => { const value = String(raw || "").trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; if (value.length > 253 || !/^[a-z0-9.-]+$/.test(value) || value.startsWith(".") || value.endsWith(".") || value.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid")); return value; }; const sync = () => { box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); onChange(); }); chip.append(remove); box.insertBefore(chip, inputNode); } }; const add = (raw, changed = true) => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); if (changed) onChange(); }; const consume = raw => { for (const part of String(raw || "").split(/[\s,]+/).filter(Boolean)) add(part); inputNode.value = ""; }; inputNode.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(inputNode.value); } catch (error) { toast(error.message, true); } } }); inputNode.addEventListener("blur", () => { if (!inputNode.value.trim()) return; try { consume(inputNode.value); } catch (error) { toast(error.message, true); } }); inputNode.addEventListener("paste", event => { const value = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(value.trim())) return; event.preventDefault(); try { consume(value); } catch (error) { toast(error.message, true); } }); box.append(inputNode); field.append(box); field.title = t("dockerAliasesHelp"); inputNode.title = t("dockerAliasesHelp"); for (const value of initial) { try { add(value, false); } catch { } } return { node: field, read() { if (inputNode.value.trim()) consume(inputNode.value); return [...values]; }, count: () => values.length, input: inputNode }; } function legacyHostname(assignment) { return assignment?.hostname || assignment?.subdomain || ""; } function serviceBindings(service) { if (!service) return []; if (Array.isArray(service.bindings) && service.bindings.length) return service.bindings; if (service.public_port) return [{ bind_ip: service.bind_ip || "", public_port: service.public_port }]; return []; } function ipFamily(ip) { return String(ip || "").includes(":") ? 6 : 4; } function bindingSupports(binding, ip) { const bind = String(binding?.bind_ip || "").trim(); if (!bind) return true; if (bind === "0.0.0.0") return ipFamily(ip) === 4; if (bind === "::") return ipFamily(ip) === 6; return bind === ip; } function targetOptions(inventory, service, assignment, agent) { const byIp = new Map(); const add = (ip, label) => { if (ip && !byIp.has(ip)) byIp.set(ip, label || ip); }; for (const address of inventory?.addresses || []) { const details = [address.interface, address.scope, address.source].filter(Boolean).join(" · "); if (!service || serviceBindings(service).some(binding => bindingSupports(binding, address.ip))) add(address.ip, details ? `${address.ip} (${details})` : address.ip); } for (const binding of serviceBindings(service)) { const ip = String(binding.bind_ip || ""); if (ip && ip !== "0.0.0.0" && ip !== "::" && !ip.startsWith("127.") && ip !== "::1") add(ip, `${ip} (${t("dockerBindingIp")})`); } if (agent?.default_target_ip) add(agent.default_target_ip, `${agent.default_target_ip} (${t("dockerAgentDefaultIp")})`); if (assignment?.target_ip) add(assignment.target_ip, assignment.target_ip); return [...byIp.entries()]; } function prettyFingerprint(value) { const compact = String(value || "").replace(/[^a-fA-F0-9]/g, "").toUpperCase(); return compact.length === 64 ? compact.match(/.{1,2}/g).join(":") : (value || "-"); } function checkedAgo(value) { if (!value) return t("dockerNeverChecked"); const seconds = Math.max(0, Math.round((Date.now() - Number(value)) / 1000)); if (seconds < 2) return t("dockerJustNow"); if (seconds < 60) return t("dockerSecondsAgo").replace("{seconds}", seconds); return t("dockerMinutesAgo").replace("{minutes}", Math.floor(seconds / 60)); } function securityInfo(agent, state) { const https = String(agent?.endpoint || "").toLowerCase().startsWith("https://"); const pinned = !!agent?.tls_fingerprint_sha256; if (!https) return { key: "dockerTlsPlain", cls: "warn", detail: t("dockerTlsPlainHelp") }; if (pinned) return { key: "dockerTlsPinned", cls: state?.ok ? "live" : "", detail: `${t("dockerTlsFingerprint")}: ${prettyFingerprint(agent.tls_fingerprint_sha256)}` }; if (state?.ok) return { key: "dockerTlsPublicCa", cls: "live", detail: t("dockerTlsPublicCaHelp") }; return { key: "dockerTlsPublicCa", cls: "", detail: t("dockerTlsPublicCaHelp") }; } function updateSecurityBadge(row) { const endpoint = row.querySelector(".docker-agent-endpoint")?.value || "", fingerprint = row.querySelector(".docker-agent-fingerprint")?.value || ""; const info = securityInfo({ endpoint, tls_fingerprint_sha256: fingerprint }, null), badge = row.querySelector(".docker-agent-security-badge"); if (badge) { badge.textContent = t(info.key); badge.className = `pill docker-agent-security-badge ${info.cls}`.trim(); } const hint = row.querySelector(".docker-agent-security-hint"); if (hint) hint.textContent = info.detail; } function agentSettingsRow(agent = null) { const value = agent || { id: "", name: "", enabled: true, endpoint: "https://127.0.0.1:9876", token: "", tls_fingerprint_sha256: null, base_domain: "docker1.lab.local", poll_interval_seconds: 30, certificate_id: null, acl_profile_id: null, default_target_ip: null, listener_ids: [firstListener()], assignments: [] }; const row = el("div", undefined, "docker-agent-settings entity"); row.dataset.originalId = agent?.id || ""; row._assignments = structuredClone(value.assignments || []); const head = el("div", undefined, "card-heading"), title = el("div"); title.append(el("span", t("dockerAgent"), "small-label"), el("h3", value.name || value.id || t("dockerAgentNew"))); const enabled = input("", "checkbox", "docker-agent-enabled"); enabled.checked = value.enabled !== false; const enabledLabel = el("label", undefined, "check"); enabledLabel.append(enabled, el("span", t("enabled"))); const state = agent ? obs(agent.id) : null, stateBadge = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill docker-agent-status-badge ${state?.ok ? "live" : ""}`.trim()); const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("docker-agents-form"); }), actions = el("div", undefined, "entity-actions docker-agent-heading-actions"); actions.append(enabledLabel, stateBadge, remove); head.append(title, actions); const id = input(value.id, "text", "docker-agent-id"); id.maxLength = 64; id.required = true; id.pattern = "[A-Za-z0-9_-]{1,64}"; if (agent) id.readOnly = true; const name = input(value.name, "text", "docker-agent-name"); name.maxLength = 96; name.required = true; const endpoint = input(value.endpoint, "url", "docker-agent-endpoint"); endpoint.required = true; endpoint.placeholder = "https://10.0.0.20:9876"; const token = input(value.token, "password", "docker-agent-token"); token.minLength = 24; token.maxLength = 512; token.required = true; token.autocomplete = "new-password"; const fingerprint = input(value.tls_fingerprint_sha256 || "", "text", "docker-agent-fingerprint mono"); fingerprint.maxLength = 128; fingerprint.placeholder = "SHA-256"; fingerprint.spellcheck = false; const domain = input(value.base_domain, "text", "docker-agent-domain"); domain.required = true; domain.placeholder = "docker1.lab.local"; const poll = input(value.poll_interval_seconds ?? 30, "number", "docker-agent-poll"); poll.min = 5; poll.max = 3600; poll.required = true; 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 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"))); 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); endpoint.addEventListener("input", () => updateSecurityBadge(row)); fingerprint.addEventListener("input", () => updateSecurityBadge(row)); row.append(head, security, grid); updateSecurityBadge(row); return row; } function renderSettings() { const target = $("docker-agent-settings-list"); if (!target || !envelope || settingsDirty.has("docker-agents-form")) return; target.replaceChildren(...(cfg().agents || []).map(agentSettingsRow)); if (!target.children.length) target.append(el("p", t("dockerNoAgents"), "empty")); } function collectSettings() { const rows = [...$("docker-agent-settings-list").querySelectorAll(".docker-agent-settings")]; return { agents: rows.map(row => { const listenerIds = row._readListeners?.() || []; if (!listenerIds.length) throw fieldError(row.querySelector(".docker-agent-listener-choices") || row, t("dockerListenersRequired")); return { 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, listener_ids: listenerIds, assignments: structuredClone(row._assignments || []) }; }) }; } function runtimeFor(agentId, serviceKey) { const route = poolRoutes.find(item => item.agent_id === agentId && item.service_key === serviceKey); return route ? poolIndex.get(route.pool_id) : null; } function healthStatus(runtime, assignment, stale) { if (stale) return { key: "dockerBackendMissing", cls: "warn" }; if (!assignment || assignment.enabled === false) return { key: "dockerBackendNotActive", cls: "" }; if (!runtime) return { key: "dockerBackendChecking", cls: "" }; const member = runtime.members?.[0]; if (!member) return { key: "dockerBackendChecking", cls: "" }; if (runtime.health_mode === "off") return { key: "dockerBackendHealthOff", cls: "" }; const map = { up: "dockerBackendUp", down: "dockerBackendDown", checking: "dockerBackendChecking", draining: "dockerBackendDraining", disabled: "dockerBackendNotActive" }; return { key: map[member.status] || "dockerBackendChecking", cls: member.status === "up" ? "live" : member.status === "down" ? "danger" : "" }; } function runtimeSummary(runtime, assignment, stale) { const wrap = el("div", undefined, "docker-runtime"), status = healthStatus(runtime, assignment, stale); wrap.append(el("span", t(status.key), `pill ${status.cls}`.trim())); const member = runtime?.members?.[0]; if (!member) { wrap.append(el("span", t("dockerBackendNoMetrics"), "fine")); return wrap; } const latency = Number(member.latency_ms || member.ewma_latency_ms || 0), metrics = [`${t("dockerHealthLatency")}: ${latency > 0 ? `${latency.toFixed(1)} ms` : "-"}`, `${t("dockerHealthResult")}: ${member.last_result || "-"}`, `${t("dockerHealthRequests")}: ${Number(member.requests || 0).toLocaleString()}`, `${t("dockerHealthErrors")}: ${Number(member.errors || 0).toLocaleString()}`, `${t("dockerHealthActive")}: ${Number(member.active || 0).toLocaleString()}`]; wrap.append(el("span", metrics.join(" · "), "fine")); return wrap; } function selectHealthMode(value) { const control = el("select", undefined, "docker-health-mode"); for (const [name, key] of [["http", "dockerHealthHttp"], ["tcp", "dockerHealthTcp"], ["off", "dockerHealthOff"]]) { const option = el("option", t(key)); option.value = name; control.append(option); } control.value = value; return control; } function numberInput(value, cls, min, max, optional = false) { const node = input(value ?? "", "number", cls); node.min = min; node.max = max; if (!optional) node.required = true; return node; } function syncHealthFields(details) { const http = details.querySelector(".docker-health-mode")?.value === "http"; details.querySelectorAll("[data-http-only]").forEach(node => node.hidden = !http); } function healthEditor(assignment) { const health = { ...defaultHealth(), ...(assignment?.health_check || {}) }, details = el("details", undefined, "docker-health-editor"), summary = el("summary"); summary.append(el("span", t("dockerHealthCheck")), el("small", t("dockerHealthSummary"), "fine")); details.append(summary); const grid = el("div", undefined, "field-grid docker-health-grid"), mode = selectHealthMode(health.mode || "tcp"), path = input(health.path || "/", "text", "docker-health-path"), host = input(health.host || "", "text", "docker-health-host"); path.maxLength = 1024; host.maxLength = 253; host.placeholder = t("dockerHealthHostAuto"); const interval = numberInput(health.interval_ms, "docker-health-interval", 250, 300000), timeout = numberInput(health.timeout_ms, "docker-health-timeout", 50, 60000), slow = numberInput(health.slow_response_ms, "docker-health-slow", 0, 60000), fall = numberInput(health.fall, "docker-health-fall", 1, 20), rise = numberInput(health.rise, "docker-health-rise", 1, 20), statusMin = numberInput(health.expected_status_min, "docker-health-status-min", 100, 599), statusMax = numberInput(health.expected_status_max, "docker-health-status-max", 100, 599); const httpFields = [labeled("dockerHealthPath", path), labeled("dockerHealthHost", host), labeled("dockerHealthStatusMin", statusMin), labeled("dockerHealthStatusMax", statusMax)]; httpFields.forEach(node => node.dataset.httpOnly = "1"); grid.append(labeled("dockerHealthMode", mode), ...httpFields, labeled("dockerHealthInterval", interval), labeled("dockerHealthTimeout", timeout), labeled("dockerHealthSlow", slow), labeled("dockerHealthFall", fall), labeled("dockerHealthRise", rise)); details.append(grid); mode.addEventListener("change", () => syncHealthFields(details)); syncHealthFields(details); return details; } function passiveHealthEditor(assignment) { const passive = { ...defaultPassiveHealth(), ...(assignment?.passive_health || {}) }, details = el("details", undefined, "docker-health-editor docker-passive-health-editor"), summary = el("summary"); summary.append(el("span", t("dockerPassiveHealth")), el("small", t("dockerPassiveHealthSummary"), "fine")); details.append(summary); const grid = el("div", undefined, "field-grid docker-health-grid"); const enabled = input("", "checkbox", "docker-passive-enabled"); enabled.checked = passive.enabled === true; const connect = input("", "checkbox", "docker-passive-connect"); connect.checked = passive.count_connect_errors !== false; const timeouts = input("", "checkbox", "docker-passive-timeouts"); timeouts.checked = passive.count_timeouts !== false; const io = input("", "checkbox", "docker-passive-io"); io.checked = passive.count_io_errors !== false; const http5xx = input("", "checkbox", "docker-passive-5xx"); http5xx.checked = passive.count_http_5xx !== false; const slow = input("", "checkbox", "docker-passive-slow"); slow.checked = passive.count_slow_responses === true; const fall = numberInput(passive.fall, "docker-passive-fall", 1, 20, true), rise = numberInput(passive.rise, "docker-passive-rise", 1, 20, true); const optional = [connect, timeouts, io, http5xx, slow, fall, rise]; const optionalFields = [checkboxField("vhPassiveConnect", connect), checkboxField("vhPassiveTimeouts", timeouts), checkboxField("vhPassiveIo", io), checkboxField("vhPassive5xx", http5xx), checkboxField("vhPassiveSlow", slow), labeled("vhPassiveFall", fall), labeled("vhPassiveRise", rise)]; const warning5xx = el("p", t("vhPassive5xxWarning"), "fine docker-passive-5xx-warning"); const warningSlow = el("p", t("vhPassiveSlowWarning"), "fine docker-passive-slow-warning"); grid.append(checkboxField("vhPassiveEnabled", enabled), ...optionalFields, warning5xx, warningSlow); details.append(grid); const sync = () => { optional.forEach(control => { control.disabled = !enabled.checked; }); optionalFields.forEach(field => { field.hidden = !enabled.checked; }); warning5xx.hidden = !enabled.checked || !http5xx.checked; warningSlow.hidden = !enabled.checked || !slow.checked; }; enabled.addEventListener("change", sync); http5xx.addEventListener("change", sync); slow.addEventListener("change", sync); sync(); return details; } function collectPassiveHealth(row) { const optional = cls => { const value = row.querySelector(cls)?.value?.trim() || ""; return value === "" ? null : Number(value); }; return { enabled: row.querySelector(".docker-passive-enabled")?.checked === true, count_connect_errors: row.querySelector(".docker-passive-connect")?.checked !== false, count_timeouts: row.querySelector(".docker-passive-timeouts")?.checked !== false, count_io_errors: row.querySelector(".docker-passive-io")?.checked !== false, count_http_5xx: row.querySelector(".docker-passive-5xx")?.checked !== false, count_slow_responses: row.querySelector(".docker-passive-slow")?.checked === true, fall: optional(".docker-passive-fall"), rise: optional(".docker-passive-rise") }; } 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 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; } 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); const grid = el("div", undefined, "field-grid docker-timeout-grid"); grid.append(labeled("vhProxyConnectTimeout", numberInput(value.connect_timeout_seconds ?? 10, "docker-proxy-connect", 1, 300)), labeled("vhProxyHeaderTimeout", numberInput(value.response_header_timeout_seconds ?? 120, "docker-proxy-header", 1, 86400)), labeled("vhProxyReadTimeout", numberInput(value.read_timeout_seconds ?? 60, "docker-proxy-read", 1, 86400)), labeled("vhProxySendTimeout", numberInput(value.send_timeout_seconds ?? 60, "docker-proxy-send", 1, 86400))); details.append(grid); return details; } function collectHealth(row) { const n = cls => Number(row.querySelector(cls).value); return { mode: row.querySelector(".docker-health-mode").value, path: row.querySelector(".docker-health-path").value.trim() || "/", host: row.querySelector(".docker-health-host").value.trim() || null, interval_ms: n(".docker-health-interval"), timeout_ms: n(".docker-health-timeout"), slow_response_ms: n(".docker-health-slow"), fall: n(".docker-health-fall"), rise: n(".docker-health-rise"), expected_status_min: n(".docker-health-status-min"), expected_status_max: n(".docker-health-status-max") }; } function collectAssignment(row) { 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, // 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, 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) }; } 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); } 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; } function assignmentRow(agent, inventory, service, assignment, stale = false) { const key = assignment?.service_key || service.key, row = el("article", undefined, "docker-service-row"); row.dataset.key = key; row.dataset.viewKey = viewKey(agent.id, key); const runtime = runtimeFor(agent.id, key), member = runtime?.members?.[0], technicalName = service?.technical_name || assignment?.service_key || ""; row.dockerSort = { service: technicalName.toLowerCase(), hostname: String(legacyHostname(assignment) || technicalName).toLowerCase(), status: stale ? 4 : assignment?.enabled === false || !assignment ? 3 : member?.status === "up" ? 0 : member?.status === "down" ? 1 : 2, backend: String(assignment?.target_ip || agent.default_target_ip || inventory?.public_ip || "").toLowerCase(), scheme: String(assignment?.scheme || "http"), published: assignment && assignment.enabled !== false ? 0 : 1, }; const top = el("div", undefined, "docker-service-head"), publish = input("", "checkbox", "docker-publish"); publish.checked = !!assignment && assignment.enabled !== false; const publishLabel = el("label", undefined, "check docker-publish-field"); publishLabel.append(publish, el("span", t("dockerPublish"))); const meta = el("div", undefined, "docker-service-meta"), technical = service?.technical_name || assignment?.service_key || "-", bindings = serviceBindings(service).map(binding => `${binding.bind_ip || "*"}:${binding.public_port}`).join(", "); meta.append(el("b", technical), el("span", stale ? t("dockerServiceMissing") : `${service.container_name} · ${service.image}`), el("span", stale ? assignment.service_key : `${service.private_port}/tcp → ${bindings || "-"}`)); const visibility = el("button", "", "quiet docker-view-toggle"); visibility.type = "button"; const syncVisibility = () => { visibility.textContent = t(hiddenVhosts.has(row.dataset.viewKey) ? "dockerUnhideVhost" : "dockerHideVhost"); }; syncVisibility(); visibility.addEventListener("click", () => { const key = row.dataset.viewKey; if (hiddenVhosts.has(key)) hiddenVhosts.delete(key); else hiddenVhosts.add(key); saveHidden(); syncVisibility(); applyViewFilters(); }); const openApp = el("a", t("dockerOpenApp"), "outline docker-open-app"); openApp.target = "_blank"; openApp.rel = "noopener noreferrer"; const syncOpenApp = hostname => { const url = proxiedUrl(agent, hostname); openApp.href = url || "#"; openApp.hidden = !publish.checked || !url; openApp.title = url || ""; }; syncOpenApp(legacyHostname(assignment)); const headActions = el("div", undefined, "docker-service-head-actions"); headActions.append(runtimeSummary(runtime, assignment, stale), openApp, visibility); top.append(publishLabel, meta, headActions); row.append(top); const editor = el("details", undefined, "docker-service-editor"), editorSummary = el("summary"), editorTitle = el("span", t("dockerConfigureService")), editorHint = el("small", "", "fine docker-config-hint"); editorSummary.append(editorTitle, editorHint); editor.append(editorSummary); const route = el("div", undefined, "docker-route-grid"), host = input(legacyHostname(assignment) || service?.technical_name || "", "text", "docker-hostname"); host.maxLength = 253; host.placeholder = service?.technical_name || "app1"; let aliases; const aliasChanged = () => { updateHint?.(); row.dispatchEvent(new Event("input", { bubbles: true })); }; aliases = aliasEditor(assignment?.aliases || [], aliasChanged); row._readAliases = aliases.read; const target = el("select", undefined, "docker-target-ip"), auto = el("option", agent.default_target_ip ? `${t("dockerBackendIpAuto")} (${agent.default_target_ip})` : t("dockerBackendIpAuto")); auto.value = ""; target.append(auto); for (const [ip, label] of targetOptions(inventory, service, assignment, agent)) { const option = el("option", label); option.value = ip; target.append(option); } target.value = assignment?.target_ip || ""; const scheme = el("select", undefined, "docker-scheme"); for (const name of ["http", "https"]) { const option = el("option", name.toUpperCase()); option.value = name; scheme.append(option); } scheme.value = assignment?.scheme || "http"; const preserve = input("", "checkbox", "docker-preserve-host"); preserve.checked = assignment?.preserve_host !== false; const preserveLabel = checkboxField("dockerPreserveHost", preserve); preserveLabel.classList.add("docker-preserve-field"); const forceSsl = input("", "checkbox", "docker-force-ssl"), forceCapable = canForceSsl(agent); forceSsl.checked = assignment ? assignment.force_ssl === true : forceCapable; forceSsl.disabled = !forceCapable; const forceSslField = checkboxField("dockerForceSsl", forceSsl); if (!forceCapable) forceSslField.append(el("small", t("dockerForceSslUnavailable"), "fine")); const websockets = input("", "checkbox", "docker-websockets"); websockets.checked = assignment?.websockets !== false; const websocketField = checkboxField("dockerWebsockets", websockets); const cacheEnabled = input("", "checkbox", "docker-cache-enabled"); cacheEnabled.checked = assignment?.cache_enabled === true; const cacheField = checkboxField("dockerCache", cacheEnabled); 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); 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(); for (const control of [host, aliases.input, target, scheme]) { control.addEventListener("input", updateHint); 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 () => { const wanted = publish.checked; publish.disabled = true; apply.disabled = true; try { const saved = await saveAssignment(agent.id, row, wanted); if (!saved) publish.checked = !wanted; } catch (error) { publish.checked = !wanted; toast(error.message, true); } finally { if (row.isConnected) { publish.disabled = false; apply.disabled = !publish.checked; row.classList.toggle("is-unpublished", !publish.checked); syncOpenApp(host.value); } } }); row.classList.toggle("is-unpublished", !publish.checked); return row; } function addressSummary(inventory) { const addresses = inventory.addresses || []; if (!addresses.length && inventory.public_ip) return inventory.public_ip; return addresses.map(address => { const details = [address.interface, address.scope, address.source].filter(Boolean).join("/"); return details ? `${address.ip} (${details})` : address.ip; }).join(" · "); } function renderDiscovery() { const target = $("docker-discovery-list"); if (!target || !envelope) return; target.replaceChildren(); const agents = cfg().agents || []; if (!agents.length) { target.append(el("p", t("dockerNoAgentsConfigured"), "card empty")); applyViewFilters(); return; } for (const agent of agents) { 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; } 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 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(); } function sortServiceRows(card) { const holder = card.querySelector(".docker-service-list"); if (!holder) return; const rows = [...holder.querySelectorAll(":scope > .docker-service-row")]; rows.sort((left, right) => { const a = left.dockerSort?.[listPrefs.sort] ?? "", b = right.dockerSort?.[listPrefs.sort] ?? ""; const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; }); for (const row of rows) holder.append(row); } function applyViewFilters() { const query = ($("docker-search")?.value || "").trim().toLowerCase(); for (const card of document.querySelectorAll("#docker-discovery-list .docker-agent-card")) { sortServiceRows(card); const agentMatch = !!query && (card.dataset.searchText || "").includes(query); const rows = [...card.querySelectorAll(".docker-service-row")]; let visible = 0; for (const row of rows) { const viewHidden = hiddenVhosts.has(row.dataset.viewKey); const values = [...row.querySelectorAll("input, select")].map(control => control.value || "").join(" "); const matches = !query || agentMatch || `${row.textContent} ${values}`.toLowerCase().includes(query); row.classList.toggle("is-view-hidden", viewHidden); row.hidden = (!showHidden && viewHidden) || !matches; if (!row.hidden) visible += 1; } card.hidden = rows.length ? visible === 0 : (!!query && !(card.dataset.searchText || "").includes(query)); } const toggle = $("docker-show-hidden"); if (toggle) { const key = showHidden ? "dockerHideHidden" : "dockerShowHidden"; toggle.dataset.i18n = key; toggle.textContent = t(key); } } function syncLiveSwitch() { const control = $("docker-live-refresh"); if (control) control.checked = liveEnabled(); } function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor[open], .docker-agent-details[open]") && !document.activeElement?.closest?.(".docker-service-row"); } let dockerLoading = null, dockerForcePending = false; async function refresh(force = false, context = null) { if (!authenticated) return; if (dockerLoading) { if (force) dockerForcePending = true; return dockerLoading; } dockerLoading = loadDocker(force, context); try { return await dockerLoading; } finally { dockerLoading = null; if (dockerForcePending && authenticated) { dockerForcePending = false; void refresh(true); } else dockerForcePending = false; } } async function loadDocker(force = false, context = null) { const button = $("docker-refresh"), epoch = authEpoch; if (button && force) button.disabled = true; const options = force ? { method: "POST", body: "{}" } : {}; if (context?.signal) options.signal = context.signal; try { const data = await api(force ? "/api/docker/refresh" : "/api/docker", options); if (!authenticated || epoch !== authEpoch || (context?.isCurrent && !context.isCurrent())) return; observations = data.observations || []; pools = data.pools || []; poolRoutes = data.pool_routes || []; poolIndex = new Map(pools.map(pool => [pool.id, pool])); renderSettings(); if (force || canLiveRender()) renderDiscovery(); } catch (error) { if (force && error?.name !== "AbortError" && authenticated && epoch === authEpoch) toast(error.message, true); } finally { if (button) button.disabled = false; } } $("docker-refresh")?.addEventListener("click", () => void refresh(true)); $("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); rustProxyRefresh.sync(); }); $("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); }); $("docker-search")?.addEventListener("input", applyViewFilters); $("docker-vhost-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); applyViewFilters(); }); $("docker-vhost-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); applyViewFilters(); }); $("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); } }); 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) }); window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } }; })();