490 lines
54 KiB
JavaScript
490 lines
54 KiB
JavaScript
"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 selectedProfileIds(value) { return [...new Set([value?.acl_profile_id, ...(value?.acl_profile_ids || [])].filter(Boolean))]; }
|
||
function profileChoices(initial, className) {
|
||
return window.rustProxyAclPicker.create(initial, className);
|
||
}
|
||
function readProfileChoices(scope, selector) {
|
||
return window.rustProxyAclPicker.read(scope.querySelector(selector));
|
||
}
|
||
function profileWarning(choices, inheritedCount = 0) {
|
||
const warning = el("p", t("multiAclWarning"), "fine acl-stack-warning");
|
||
const sync = () => {
|
||
const selected = window.rustProxyAclPicker.count(choices);
|
||
warning.hidden = (selected || inheritedCount) < 2;
|
||
};
|
||
choices.addEventListener("change", sync); sync(); return warning;
|
||
}
|
||
function profileField(key, picker, help) {
|
||
const wrapper = el("div", undefined, "docker-field docker-profile-field");
|
||
wrapper.append(el("span", t(key)), picker);
|
||
if (help) wrapper.append(el("small", help, "fine"));
|
||
return wrapper;
|
||
}
|
||
function aliasEditor(initial = [], onChange = () => { }) {
|
||
const field = el("div", undefined, "docker-field docker-alias-field host-chip-field"), box = el("div", undefined, "host-chip-editor docker-alias-editor"), inputNode = input("", "text", "docker-alias-input");
|
||
field.append(el("span", t("dockerAliases"))); inputNode.maxLength = 253; inputNode.placeholder = "alias1 / app.example.com"; inputNode.setAttribute("aria-label", t("dockerAliasAdd"));
|
||
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 = profileChoices(selectedProfileIds(value), "docker-agent-acl-profile");
|
||
const defaultAclField = profileField("dockerDefaultAcl", defaultAcl, t("dockerDefaultAclHelp"));
|
||
defaultAclField.append(profileWarning(defaultAcl));
|
||
const grid = el("div", undefined, "field-grid docker-agent-grid");
|
||
grid.append(labeled("dockerAgentId", id), labeled("dockerAgentName", name), labeled("dockerAgentEndpoint", endpoint, t("dockerAgentEndpointHelp")), labeled("dockerAgentToken", token), labeled("dockerTlsFingerprint", fingerprint, t("dockerTlsFingerprintHelp")), labeled("dockerBaseDomain", domain), labeled("dockerPollInterval", poll, t("dockerPollIntervalHelp")), labeled("dockerDefaultTarget", target), labeled("dockerCertificate", certificate, t("dockerCertificateHelp")), defaultAclField);
|
||
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: null, acl_profile_ids: readProfileChoices(row, ".docker-agent-acl-profile"), default_target_ip: row.querySelector(".docker-agent-target").value.trim() || null,
|
||
listener_ids: listenerIds, assignments: structuredClone(row._assignments || [])
|
||
};
|
||
})
|
||
};
|
||
}
|
||
|
||
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 = profileChoices(selectedProfileIds(assignment), "docker-acl-profile");
|
||
const inheritedProfiles = selectedProfileIds(agent);
|
||
const profileControl = profileField("dockerAclProfile", profile, inheritedProfiles.length ? `${t("dockerAclProfileInherit")}: ${inheritedProfiles.map(id => (envelope?.config?.acl_profiles || []).find(item => item.id === id)?.name || id).join(", ")}` : t("dockerAclProfileNone"));
|
||
profileControl.append(profileWarning(profile, inheritedProfiles.length));
|
||
const inline = input("", "checkbox", "docker-acl-inline"); inline.checked = !!assignment?.acl; const inlineLabel = checkboxField("dockerAclInline", inline);
|
||
const defaultAction = el("select", undefined, "docker-acl-default"); for (const value of ["deny", "allow"]) { const option = el("option", t(value)); option.value = value; defaultAction.append(option); } defaultAction.value = assignment?.acl?.default || "deny";
|
||
const rules = document.createElement("textarea"); rules.className = "docker-acl-rules"; rules.rows = 4; rules.maxLength = 32768; rules.spellcheck = false; rules.value = rulesText(assignment?.acl); rules.placeholder = "allow 10.0.0.0/8\ndeny 0.0.0.0/0";
|
||
const sync = () => { defaultAction.disabled = !inline.checked; rules.disabled = !inline.checked; }; inline.addEventListener("change", sync); sync();
|
||
grid.append(profileControl, inlineLabel, labeled("dockerAclDefault", defaultAction), labeled("dockerAclRules", rules, t("dockerAclHelp"))); details.append(grid); return details;
|
||
}
|
||
function timeoutEditor(assignment) {
|
||
const value = { ...defaultProxyTimeouts(), ...(assignment?.proxy_timeouts || {}) }, details = el("details", undefined, "docker-timeout-editor"), summary = el("summary"); summary.append(el("span", t("dockerProxyTimeouts")), el("small", t("dockerProxyTimeoutsHelp"), "fine")); details.append(summary);
|
||
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, http2_enabled: row.querySelector(".docker-http2-enabled").checked, http3_enabled: row.querySelector(".docker-http3-enabled").checked, cache_enabled: row.querySelector(".docker-cache-enabled").checked, transform_enabled: row.querySelector(".docker-transform-enabled").checked, compression_enabled: row.querySelector(".docker-compression-enabled").checked,
|
||
// Docker publications intentionally expose one compression switch only. Codec selection stays global.
|
||
compression_zstd: null, compression_br: null, compression_gzip: null, compression_deflate: null,
|
||
acl_profile_id: null, acl_profile_ids: readProfileChoices(row, ".docker-acl-profile"),
|
||
allow_certificate_mismatch: row.querySelector(".docker-cert-mismatch")?.checked === true,
|
||
acl: inline ? { default: row.querySelector(".docker-acl-default").value, rules: parseRules(row.querySelector(".docker-acl-rules").value) } : null,
|
||
proxy_timeouts: { connect_timeout_seconds: Number(row.querySelector(".docker-proxy-connect").value), response_header_timeout_seconds: Number(row.querySelector(".docker-proxy-header").value), read_timeout_seconds: Number(row.querySelector(".docker-proxy-read").value), send_timeout_seconds: Number(row.querySelector(".docker-proxy-send").value) },
|
||
health_check: collectHealth(row), passive_health: collectPassiveHealth(row)
|
||
};
|
||
}
|
||
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"));
|
||
const previous = index >= 0 ? agent.assignments[index] : null;
|
||
const consentNames = item => JSON.stringify([item?.hostname || "", ...(item?.aliases || [])].map(v => v.toLowerCase().replace(/\.$/, "")).sort());
|
||
if (assignment.allow_certificate_mismatch && (!previous?.allow_certificate_mismatch || consentNames(assignment) !== consentNames(previous)) && !(await appConfirm(t("dockerCertMismatchConfirm"), { danger: true }))) return false;
|
||
if (index >= 0) agent.assignments[index] = assignment; else agent.assignments.push(assignment);
|
||
}
|
||
else if (index >= 0) agent.assignments.splice(index, 1);
|
||
const saved = await saveSection("docker-agents", current); if (saved) row.classList.remove("is-dirty"); return saved;
|
||
}
|
||
|
||
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);
|
||
const certMismatch = input("", "checkbox", "docker-cert-mismatch"); certMismatch.checked = assignment?.allow_certificate_mismatch === true;
|
||
const mismatchField = checkboxField("dockerCertMismatchForce", certMismatch);
|
||
const mismatchHelp = el("p", t("dockerCertMismatchWarning"), "fine acl-stack-warning");
|
||
const mismatchUnavailable = el("p", t("dockerCertMismatchUnavailable"), "fine");
|
||
const riskPill = el("span", t("dockerCertMismatchRisk"), "acl-picker-policy is-deny docker-cert-risk-pill");
|
||
const syncMismatch = () => {
|
||
certMismatch.disabled = !agent.certificate_id;
|
||
if (!agent.certificate_id) certMismatch.checked = false;
|
||
mismatchHelp.hidden = !certMismatch.checked;
|
||
mismatchUnavailable.hidden = !!agent.certificate_id;
|
||
riskPill.hidden = !certMismatch.checked;
|
||
};
|
||
certMismatch.addEventListener("change", syncMismatch);
|
||
mismatchField.append(mismatchHelp, mismatchUnavailable);
|
||
const certificateBar = el("div", undefined, "docker-service-certificate-bar");
|
||
const tlsControl = el("div", undefined, "docker-service-certificate-control");
|
||
const tlsHeading = el("div", undefined, "docker-service-certificate-title");
|
||
tlsHeading.append(el("strong", t("dockerCertSection")), riskPill);
|
||
tlsControl.append(tlsHeading, mismatchField); certificateBar.append(tlsControl);
|
||
syncMismatch();
|
||
|
||
const h2Enabled = input("", "checkbox", "docker-http2-enabled"); h2Enabled.checked = assignment?.http2_enabled !== false;
|
||
const h3Enabled = input("", "checkbox", "docker-http3-enabled"); h3Enabled.checked = assignment?.http3_enabled !== false;
|
||
const protocolBox = el("section", undefined, "docker-protocol-panel");
|
||
const protocolHeading = el("div", undefined, "docker-protocol-heading");
|
||
protocolHeading.append(el("strong", t("dockerFrontendProtocols")), el("small", t("dockerFrontendProtocolsHelp"), "fine"));
|
||
const protocolOptions = el("div", undefined, "docker-protocol-options");
|
||
const h1 = el("span", undefined, "docker-protocol-fixed");
|
||
h1.append(el("strong", "HTTP/1.1"), el("small", t("dockerHttp1Always"), "fine"));
|
||
protocolOptions.append(h1, checkboxField("dockerHttp2Enabled", h2Enabled), checkboxField("dockerHttp3Enabled", h3Enabled));
|
||
protocolBox.append(protocolHeading, protocolOptions);
|
||
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, labeled("dockerBackendIp", target), labeled("dockerBackendScheme", scheme), switches);
|
||
editor.append(certificateBar, route, protocolBox, aclEditor(assignment, agent), timeoutEditor(assignment), healthEditor(assignment), passiveHealthEditor(assignment));
|
||
const actions = el("div", undefined, "docker-service-actions"), apply = el("button", t("dockerApplyService"), "outline docker-apply"); apply.type = "button"; apply.disabled = !publish.checked; apply.addEventListener("click", async () => { apply.disabled = true; try { await saveAssignment(agent.id, row, true); } catch (error) { toast(error.message, true); } finally { if (row.isConnected) apply.disabled = !publish.checked; } }); actions.append(apply); editor.append(actions); row.append(editor);
|
||
function updateHint() { const targetLabel = target.selectedOptions?.[0]?.textContent || t("dockerBackendIpAuto"), aliasCount = aliases.count(); editorHint.textContent = `${host.value.trim() || technical} → ${scheme.value.toUpperCase()} · ${targetLabel} · H1${h2Enabled.checked ? "/H2" : ""}${h3Enabled.checked ? "/H3" : ""}${aliasCount ? ` · +${aliasCount} ${t("dockerAliasesShort")}` : ""}`; } updateHint();
|
||
for (const control of [host, aliases.input, target, scheme]) { control.addEventListener("input", updateHint); control.addEventListener("change", updateHint); }
|
||
for (const control of [h2Enabled, h3Enabled]) control.addEventListener("change", updateHint);
|
||
host.addEventListener("input", () => syncOpenApp(host.value));
|
||
row.addEventListener("input", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); }); row.addEventListener("change", event => { if (!event.target.classList.contains("docker-publish")) row.classList.add("is-dirty"); });
|
||
publish.addEventListener("change", async () => {
|
||
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) 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 = selectedProfileIds(agent).map(id => ((envelope?.config?.acl_profiles || []).find(profile => profile.id === id)?.name || id)).join(", ") || t("dockerDefaultAclNone");
|
||
summary.append(el("span", `${t("dockerAgentVersion")}: ${inv.agent_version}`), el("span", `${t("dockerAgentPollEvery")}: ${agent.poll_interval_seconds ?? 30}s`), el("span", `${t("dockerCertificate")}: ${agent.certificate_id || t("vhCertificateDefault")}`), el("span", `${t("dockerDefaultAcl")}: ${defaultAclName}`), el("span", `${t("dockerDetectedIps")}: ${addressSummary(inv) || t("unavailable")}`), el("span", `${t("dockerDefaultTarget")}: ${defaultIp}`), el("span", `${t("dockerBaseDomain")}: *.${agent.base_domain}`)); if (inv.certificate_sha256) summary.append(el("span", `${t("dockerTlsFingerprint")}: ${prettyFingerprint(inv.certificate_sha256)}`, "mono docker-fingerprint")); agentDetails.append(detailsSummary, summary); box.append(agentDetails);
|
||
const rows = el("div", undefined, "docker-service-list"), assignments = new Map((agent.assignments || []).map(item => [item.service_key, item])); for (const service of inv.services || []) rows.append(assignmentRow(agent, inv, service, assignments.get(service.key), false)); for (const item of agent.assignments || []) if (!(inv.services || []).some(service => service.key === item.service_key)) rows.append(assignmentRow(agent, inv, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box);
|
||
}
|
||
applyViewFilters();
|
||
}
|
||
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 () => {
|
||
const next = collectSettings();
|
||
const changingAcceptedCertificate = next.agents.some(agent => {
|
||
const previous = (cfg().agents || []).find(old => old.id === agent.id);
|
||
return previous && previous.certificate_id !== agent.certificate_id && agent.assignments.some(item => item.allow_certificate_mismatch);
|
||
});
|
||
if (changingAcceptedCertificate && !(await appConfirm(t("dockerCertMismatchConfirm"), { danger: true }))) return;
|
||
if (await saveSection("docker-agents", next)) { setSettingsDirty("docker-agents-form", false); await refresh(true); }
|
||
});
|
||
} catch (error) { toast(error.message, true); }
|
||
});
|
||
rustProxyRefresh.register("docker", {
|
||
interval: () => 5,
|
||
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(); } };
|
||
})();
|