This commit is contained in:
Mateusz Gruszczyński committed 2026-10-21 14:39:51 +02:00
1 parent e037190c14
commit 5cb3b37627
37 files changed
+1031 -138

No files matched your search

+121 -12
View File
@@ -2,6 +2,7 @@
(() => {
const sortColumns = ["service", "hostname", "status", "backend", "scheme", "published"];
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false, listPrefs = { sort: "service", descending: false };
const expandedMetrics = new Set(), metricHistory = new Map();
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(); }
@@ -23,11 +24,11 @@
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;
return candidates.find(item => item.binding?.tls === true) || 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 listener = publicListener(agent), secure = !!listener?.binding?.tls, scheme = secure ? "https" : "http", address = String(listener?.binding?.address || "");
const match = address.match(/:(\d+)$/), port = match ? Number(match[1]) : (secure ? 443 : 80), suffix = port === (secure ? 443 : 80) ? "" : `:${port}`;
return `${scheme}://${host}${suffix}/`;
}
@@ -209,6 +210,83 @@
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 syncMetricsToggle() {
const control = $("docker-metrics-toggle-all"); if (!control) return;
const sections = [...document.querySelectorAll("#docker-discovery-list .docker-service-metrics")];
const allOpen = sections.length > 0 && sections.every(section => section.open);
const key = allOpen ? "dockerCollapseAllMetrics" : "dockerExpandAllMetrics";
control.disabled = sections.length === 0; control.dataset.i18n = key; control.textContent = t(key);
control.setAttribute("aria-expanded", String(allOpen));
}
function statisticsSection(agent, serviceKey) {
const section = el("details", undefined, "docker-service-editor docker-service-metrics");
section.dataset.viewKey = viewKey(agent.id, serviceKey);
section.dataset.agentId = agent.id; section.dataset.serviceKey = serviceKey;
section.open = expandedMetrics.has(section.dataset.viewKey);
const summary = el("summary"); summary.append(el("span", t("dockerTrafficStatistics"))); section.append(summary);
const grid = el("div", undefined, "vhost-live-grid"), fields = {};
for (const key of ["status", "vhRequests", "vhActive", "vhErrors", "vhLatency", "vhTraffic"]) {
const item = el("div", undefined, "vhost-live-metric"), value = el("strong", "-");
item.append(el("span", t(key)), value); fields[key] = value; grid.append(item);
}
section.append(grid);
const chart = el("div", undefined, "vhost-chart"), head = el("div", undefined, "vhost-chart-head");
const chartValue = el("span", "-", "fine"); head.append(el("span", t("vhLifetimeChart"), "small-label"), chartValue); chart.append(head);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.setAttribute("aria-label", t("vhLifetimeChart"));
const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); line.setAttribute("class", "vhost-chart-line");
svg.append(line); chart.append(svg); section.append(chart);
section.dockerMetricView = { fields, chartValue, line };
section.addEventListener("toggle", () => {
if (section.open) expandedMetrics.add(section.dataset.viewKey);
else expandedMetrics.delete(section.dataset.viewKey);
syncMetricsToggle();
});
return section;
}
function renderDockerMetrics(status = latestStatus) {
const observed = status?.vhost_metrics || {};
const livePools = new Map((status?.backend_pools || []).map(pool => [pool.id, pool]));
const now = performance.now();
for (const route of poolRoutes) {
const value = observed[route.pool_id]; if (!value) continue;
const history = metricHistory.get(route.pool_id) || { last: null, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
if (history.snapshot !== value) {
const requests = Number(value.requests), transferred = Number(value.bytes_in) + Number(value.bytes_out);
const sample = rustProxyRateSampling.difference(history.last, [requests, transferred], now, rustProxyRateSampling.cadence("overview_seconds"));
history.paused = history.last !== null && sample.deltas === null;
history.last = sample.next; history.rate = sample.deltas ? sample.deltas[0] / sample.seconds : 0;
history.traffic = sample.deltas ? sample.deltas[1] / sample.seconds : 0;
if (!history.paused) {
history.pendingSum += history.rate; history.pendingCount += 1;
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; }
}
if (history.points.length >= 120) {
const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2);
history.points = compacted; history.bucketSize *= 2;
}
history.snapshot = value; history.metrics = value; metricHistory.set(route.pool_id, history);
}
}
for (const section of document.querySelectorAll("#docker-discovery-list .docker-service-metrics")) {
const [agentId, serviceKey] = [section.dataset.agentId, section.dataset.serviceKey];
const route = poolRoutes.find(item => item.agent_id === agentId && item.service_key === serviceKey);
const history = route && metricHistory.get(route.pool_id), metrics = history?.metrics;
const view = section.dockerMetricView, runtime = route && (livePools.get(route.pool_id) || poolIndex.get(route.pool_id)), members = runtime?.members || [];
view.fields.status.textContent = !runtime || !members.length ? "-" : runtime.health_mode === "off" ? t("vhHealthDisabled") : `${members.filter(member => member.status === "up").length}/${members.length} ${t("vhUp")}`;
view.fields.vhRequests.textContent = metrics ? fmt(metrics.requests) : "-";
view.fields.vhActive.textContent = metrics ? fmt(metrics.active) : "-";
view.fields.vhErrors.textContent = metrics ? fmt(metrics.errors) : "-";
view.fields.vhLatency.textContent = Number(metrics?.ewma_latency_ms) > 0 ? `${fmt(metrics.ewma_latency_ms, 1)} ms` : "-";
view.fields.vhTraffic.textContent = !history || history.paused ? "-" : `${rateBytes(history.traffic)}/s`;
if (!history) { view.chartValue.textContent = "-"; view.line.setAttribute("points", ""); continue; }
const data = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points;
const peak = Math.max(0, ...data), scale = Math.max(1, peak);
const points = data.map((value, index) => `${index * 320 / Math.max(1, data.length - 1)},${50 - value / scale * 44}`).join(" ");
view.line.setAttribute("points", points);
view.chartValue.textContent = `${history.paused ? "-" : history.rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${peak.toFixed(1)}`;
}
}
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); }
@@ -280,7 +358,6 @@
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,
@@ -312,18 +389,35 @@
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")));
publish.setAttribute("role", "switch");
publish.setAttribute("aria-checked", String(publish.checked));
const publishLabel = el("label", undefined, "docker-publish-field");
publishLabel.append(el("span", t("dockerPublish"), "docker-publish-text"), publish);
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 liveLogs = el("button", t("liveLogs"), "outline docker-open-logs"); liveLogs.type = "button";
liveLogs.title = t("dockerLiveAccessLogsHelp");
liveLogs.addEventListener("click", () => {
const route = poolRoutes.find(item => item.agent_id === agent.id && item.service_key === key);
if (publish.checked && route?.pool_id) window.rustProxyLogs?.open(route.pool_id);
});
const syncOpenActions = hostname => {
const url = proxiedUrl(agent, hostname);
openApp.href = url || "#"; openApp.hidden = !publish.checked || !url; openApp.title = url || "";
liveLogs.hidden = !publish.checked;
liveLogs.disabled = !publish.checked || !poolRoutes.some(item => item.agent_id === agent.id && item.service_key === key && item.pool_id);
publish.setAttribute("aria-checked", String(publish.checked));
};
syncOpenActions(legacyHostname(assignment));
const quickActions = el("div", undefined, "docker-service-quick-actions"); quickActions.append(openApp, liveLogs);
const headActions = el("div", undefined, "docker-service-head-actions"); headActions.append(runtimeSummary(runtime, assignment, stale), quickActions, 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";
row.dockerSyncActions = () => syncOpenActions(host.value);
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";
@@ -346,7 +440,6 @@
backendIpSettings.append(labeled("dockerBackendIp", target), certificateBar);
const h2Enabled = input("", "checkbox", "docker-http2-enabled"); h2Enabled.checked = assignment?.http2_enabled !== false;
// New publications default to H3 off; legacy assignments keep their saved behavior.
const h3Enabled = input("", "checkbox", "docker-http3-enabled"); h3Enabled.checked = assignment ? assignment.http3_enabled !== false : false;
const protocolBox = el("details", undefined, "docker-protocol-editor"), protocolSummary = el("summary");
protocolSummary.append(el("span", t("dockerFrontendProtocols")), el("small", t("dockerFrontendProtocolsHelp"), "fine"));
@@ -358,15 +451,17 @@
route.append(labeled("dockerHostname", host, t("dockerHostnameHelp").replace("{domain}", agent.base_domain)), aliases.node, backendIpSettings, labeled("dockerBackendScheme", scheme), switches);
editor.append(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);
if (agent.enabled !== false && assignment && assignment.enabled !== false) row.append(statisticsSection(agent, key));
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));
host.addEventListener("input", () => syncOpenActions(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;
syncOpenActions(host.value);
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); } }
finally { publish.setAttribute("aria-checked", String(publish.checked)); if (row.isConnected) { publish.disabled = false; apply.disabled = !publish.checked; row.classList.toggle("is-unpublished", !publish.checked); syncOpenActions(host.value); } }
});
row.classList.toggle("is-unpublished", !publish.checked); return row;
}
@@ -390,6 +485,7 @@
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();
renderDockerMetrics();
}
function sortServiceRows(card) {
const holder = card.querySelector(".docker-service-list"); if (!holder) return;
@@ -414,9 +510,10 @@
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); }
syncMetricsToggle();
}
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"); }
function canLiveRender() { const target = $("docker-discovery-list"); return !target?.querySelector(".docker-service-row.is-dirty, .docker-service-editor:not(.docker-service-metrics)[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;
@@ -442,13 +539,25 @@
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]));
document.querySelectorAll("#docker-discovery-list .docker-service-row").forEach(row => row.dockerSyncActions?.());
renderSettings(); if (force || canLiveRender()) renderDiscovery();
else renderDockerMetrics();
} 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-metrics-toggle-all")?.addEventListener("click", () => {
const sections = [...document.querySelectorAll("#docker-discovery-list .docker-service-metrics")];
const expand = !sections.every(section => section.open);
for (const section of sections) {
section.open = expand;
if (expand) expandedMetrics.add(section.dataset.viewKey);
else expandedMetrics.delete(section.dataset.viewKey);
}
syncMetricsToggle();
});
$("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);
@@ -475,5 +584,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(); } };
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, renderStatus: renderDockerMetrics, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); expandedMetrics.clear(); metricHistory.clear(); 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(); } };
})();