Files
RustProxy/src/admin/web/docker.js
T
2026-10-07 08:51:04 +02:00

369 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
(() => {
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false;
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 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: "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: (() => { const value = row.querySelector(".docker-compression-enabled").value; return value === "" ? null : value === "on"; })(),
compression_zstd: (() => { const value = row.querySelector(".docker-compression-zstd").value; return value === "" ? null : value === "on"; })(), compression_br: (() => { const value = row.querySelector(".docker-compression-br").value; return value === "" ? null : value === "on"; })(), compression_gzip: (() => { const value = row.querySelector(".docker-compression-gzip").value; return value === "" ? null : value === "on"; })(), compression_deflate: (() => { const value = row.querySelector(".docker-compression-deflate").value; return value === "" ? null : value === "on"; })(),
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 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 runtime = runtimeFor(agent.id, key), 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 compressionSelect = (className, current) => { const select = el("select", undefined, className); for (const [value, keyName] of [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]]) { const option = el("option", t(keyName)); option.value = value; select.append(option); } select.value = current === true ? "on" : current === false ? "off" : ""; return select; };
const compressionEnabled = compressionSelect("docker-compression-enabled", assignment?.compression_enabled);
const compressionZstd = compressionSelect("docker-compression-zstd", assignment?.compression_zstd), compressionBr = compressionSelect("docker-compression-br", assignment?.compression_br), compressionGzip = compressionSelect("docker-compression-gzip", assignment?.compression_gzip), compressionDeflate = compressionSelect("docker-compression-deflate", assignment?.compression_deflate);
const compressionFields = [[labeled("compressionZstd", compressionZstd), compressionZstd], [labeled("compressionBr", compressionBr), compressionBr], [labeled("compressionGzip", compressionGzip), compressionGzip], [labeled("compressionDeflate", compressionDeflate), compressionDeflate]];
const syncCompressionVisibility = () => { const disabled = compressionEnabled.value === "off"; for (const [field, control] of compressionFields) { field.hidden = disabled; control.disabled = disabled; } };
compressionEnabled.addEventListener("change", syncCompressionVisibility); syncCompressionVisibility();
const switches = el("div", undefined, "docker-switch-row"); switches.append(preserveLabel, forceSslField, websocketField, cacheField, transformField);
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), labeled("dockerCompression", compressionEnabled, t("dockerCompressionHelp")), ...compressionFields.map(([field]) => field), 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; }
if (!state?.ok) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state?.error || t("dockerNoObservation"), "fine"), el("small", sec.detail, "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 applyViewFilters() {
const query = ($("docker-search")?.value || "").trim().toLowerCase();
for (const card of document.querySelectorAll("#docker-discovery-list .docker-agent-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"); }
async function refresh(force = false) {
if (!authenticated) return; const button = $("docker-refresh"); if (button && force) button.disabled = true;
try { const data = await api(force ? "/api/docker/refresh" : "/api/docker", force ? { method: "POST", body: "{}" } : {}); 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) 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"); if (event.target.checked) void refresh(false); });
$("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); });
$("docker-search")?.addEventListener("input", 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); } });
setInterval(() => { if (authenticated && liveEnabled() && typeof currentPanel !== "undefined" && currentPanel === "docker-vhosts" && canLiveRender()) void refresh(false); }, 5000);
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
})();