This commit is contained in:
Mateusz Gruszczyński committed 2026-10-13 00:00:06 +02:00
1 parent d23fa5e154
commit 81ab39e70a
51 files changed
+5352 -1489

No files matched your search

+100 -8
View File
@@ -53,6 +53,60 @@
return () => { if (input.value.trim()) commit(); if (!current) throw new Error(t("streamListenRequired")); return current; };
}
const backendDefaults = (index = 0, address = "127.0.0.1:10001") => ({
id: index ? `backend-${index + 1}` : "primary", name: index ? `Backend ${index + 1}` : "Primary",
address, enabled: true, drain: false, weight: 1, max_sessions: null, metrics_enabled: null,
health: { mode: "off", interval_seconds: 10, timeout_ms: 1000, rise: 2, fall: 2, payload_hex: "", expect_hex: null }
});
let backendList = null;
function renderBackendEditor(parent, backend, protocol) {
if (backendList?.children.length >= 32) { toast(t("streamBackendLimit"), true); return; }
const row = el("fieldset", undefined, "stream-backend-row vh-wide"); row.append(el("legend", backend.id || t("streamBackend")));
const controls = el("div", undefined, "vh-grid"); row.append(controls);
field(controls, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
field(controls, "backend-name", "streamBackendName", backend.name || "", "text", { maxLength: 96 });
field(controls, "backend-address", "streamBackendAddress", backend.address, "text", { required: true, maxLength: 128 });
field(controls, "backend-weight", "streamBackendWeight", backend.weight ?? 1, "number", { min: 1, max: 16, required: true });
field(controls, "backend-max", "streamBackendMaxSessions", backend.max_sessions ?? "", "number", { min: 1, max: 65535 });
field(controls, "backend-enabled", "vhEnabled", backend.enabled !== false, "checkbox");
field(controls, "backend-drain", "streamBackendDrain", backend.drain === true, "checkbox");
const detail = backend.metrics_enabled == null ? "inherit" : String(backend.metrics_enabled);
field(controls, "backend-metrics", "streamBackendDetailMetrics", detail, "select", { values: [["inherit", "streamMetricsFromStream"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
const hc = backend.health || backendDefaults().health;
field(controls, "backend-health-mode", "streamHealthMode", hc.mode || "off", "select", { values: [["off", "streamHealthOff"], [protocol === "tcp" ? "tcp_connect" : "udp_probe", protocol === "tcp" ? "streamHealthTcp" : "streamHealthUdp"]] });
field(controls, "backend-health-interval", "streamHealthInterval", hc.interval_seconds ?? 10, "number", { min: 1, max: 3600, required: true });
field(controls, "backend-health-timeout", "streamHealthTimeout", hc.timeout_ms ?? 1000, "number", { min: 50, max: 30000, required: true });
field(controls, "backend-health-rise", "streamHealthRise", hc.rise ?? 2, "number", { min: 1, max: 10, required: true });
field(controls, "backend-health-fall", "streamHealthFall", hc.fall ?? 2, "number", { min: 1, max: 10, required: true });
if (protocol === "udp") {
field(controls, "backend-health-payload", "streamHealthPayload", hc.payload_hex || "", "text", { maxLength: 256 });
field(controls, "backend-health-expected", "streamHealthExpected", hc.expect_hex || "", "text", { maxLength: 256 });
}
const remove = button("remove", () => { if (backendList.children.length > 1) { row.remove(); changed(); } });
row.append(remove); backendList.append(row);
}
const optBool = value => value === "inherit" ? null : value === "true";
function collectBackends() {
const rows = [...backendList.querySelectorAll(".stream-backend-row")];
if (!rows.length) throw new Error(t("streamBackendRequired"));
const get = (row, name) => row.querySelector(`[name="${name}"]`);
const selected = rows.map(row => ({
id: get(row, "backend-id").value.trim(), name: get(row, "backend-name").value.trim(),
address: get(row, "backend-address").value.trim(), weight: Number(get(row, "backend-weight").value),
max_sessions: get(row, "backend-max").value === "" ? null : Number(get(row, "backend-max").value),
enabled: get(row, "backend-enabled").checked, drain: get(row, "backend-drain").checked,
metrics_enabled: optBool(get(row, "backend-metrics").value),
health: {
mode: get(row, "backend-health-mode").value, interval_seconds: Number(get(row, "backend-health-interval").value),
timeout_ms: Number(get(row, "backend-health-timeout").value), rise: Number(get(row, "backend-health-rise").value),
fall: Number(get(row, "backend-health-fall").value),
payload_hex: get(row, "backend-health-payload")?.value.trim() || "",
expect_hex: get(row, "backend-health-expected")?.value.trim() || null
}
}));
if (new Set(selected.map(b => b.id)).size !== selected.length) throw new Error(t("streamBackendDuplicate"));
return selected;
}
let readListen = () => "";
function load(value = defaults(), show = true) {
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
@@ -63,18 +117,41 @@
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
const enabledControl = field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
window.rustProxyEnableControl(enabledControl);
const enabledLabel = enabledControl.closest("label");
enabledLabel?.querySelector(".vh-enable-hint")?.remove();
enabledLabel?.classList.add("vh-enable-no-hint");
note(g, "streamsHelp");
g = group("streamNetwork", "network");
readListen = socketChipField(g, value.listen || "0.0.0.0:10000");
field(g, "upstream", "streamUpstream", value.upstream || "127.0.0.1:10001", "text", { required: true, maxLength: 128 });
field(g, "balance", "streamBalance", value.balance || "round_robin", "select", { values: [["round_robin", "streamRoundRobin"], [protocol.value === "tcp" ? "least_connections" : "least_sessions", "streamLeastSessions"]] });
field(g, "stream-metrics", "streamDetailMetrics", value.metrics_enabled == null ? "inherit" : String(value.metrics_enabled), "select", { values: [["inherit", "streamMetricsDefaultOff"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
note(g, "streamMetricsHelp");
const pool = el("div", undefined, "vh-wide stream-backend-pool"); pool.append(text("h3", "streamBackendPool"));
backendList = el("div", undefined, "stream-backend-list"); pool.append(backendList);
const initial = value.backends?.length ? value.backends : [backendDefaults(0, value.upstream || "127.0.0.1:10001")];
for (const backend of initial) renderBackendEditor(backendList, backend, protocol.value);
pool.append(button("streamBackendAdd", () => { renderBackendEditor(backendList, backendDefaults(backendList.children.length), protocol.value); changed(); }));
note(pool, "streamHealthUdpHelp"); g.append(pool);
note(g, "streamDedicatedListenerHelp");
g = group("streamLimits", "limits");
const connectTimeout = field(g, "connect-timeout", "streamConnectTimeout", value.connect_timeout_seconds ?? 5, "number", { min: 1, max: 300, required: true });
field(g, "idle-timeout", "streamIdleTimeout", value.idle_timeout_seconds ?? 300, "number", { min: 1, max: 86400, required: true });
field(g, "max-connections", "streamMaxConnections", value.max_connections ?? 1024, "number", { min: 1, max: 65535, required: true });
const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", sync); sync();
const sync = () => { connectTimeout.disabled = protocol.value !== "tcp"; }; protocol.addEventListener("change", () => {
sync();
// Explicitly reset incompatible health modes when switching transport.
for (const row of backendList.children) {
const mode = row.querySelector('[name="backend-health-mode"]');
if (mode?.value !== "off") mode.value = "off";
}
const currentBackends = collectBackends(); backendList.replaceChildren();
currentBackends.forEach(backend => renderBackendEditor(backendList, backend, protocol.value));
const balance = read("balance"); balance.replaceChildren();
for (const [val, key] of [["round_robin", "streamRoundRobin"], [protocol.value === "tcp" ? "least_connections" : "least_sessions", "streamLeastSessions"]]) { const option = text("option", key); option.value = val; balance.append(option); }
changed();
}); sync();
note(g, "streamLimitsHelp");
if (show) { activateTab("details"); if (!dialog.open) dialog.showModal(); }
$("stream-live-summary").textContent = editing ? t("streamNoLiveMetrics") : "";
@@ -83,18 +160,20 @@
}
function collect() {
const backends = collectBackends();
return {
id: str("id"), name: str("name"), enabled: yes("enabled"), protocol: str("protocol"),
listen: readListen(), upstream: str("upstream"), connect_timeout_seconds: Number(str("connect-timeout") || 5),
listen: readListen(), upstream: backends[0].address, backends, balance: str("balance"),
metrics_enabled: optBool(str("stream-metrics")), connect_timeout_seconds: Number(str("connect-timeout") || 5),
idle_timeout_seconds: num("idle-timeout"), max_connections: num("max-connections")
};
}
function suspendDraft() {
const state = { editing, revision, label: $("stream-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("stream-tabs")), readListen };
const state = { editing, revision, label: $("stream-editing").textContent, fields: window.rustProxyDrafts.takeChildren(container), tabs: window.rustProxyDrafts.takeChildren($("stream-tabs")), readListen, backendList };
const title = read("id")?.value.trim() || editing || t("draftUntitled"); editing = null; revision = null; dirty = false; dialog.close(); return { title, state };
}
function restoreDraft(state) {
window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("stream-tabs"), state.tabs); readListen = state.readListen;
window.rustProxyDrafts.restoreChildren(container, state.fields); window.rustProxyDrafts.restoreChildren($("stream-tabs"), state.tabs); readListen = state.readListen; backendList = state.backendList;
editing = state.editing; revision = state.revision; initialized = true; dirty = true; $("stream-editing").textContent = state.label; if (!dialog.open) dialog.showModal(); if (latestStatus) renderStatus(latestStatus);
}
function closeEditor() { if (dirty) return window.rustProxyDrafts.dock("stream"); if (dialog.open) dialog.close(); return true; }
@@ -106,7 +185,7 @@
const configuredIds = new Set(configured.map(stream => stream.id));
for (const id of chartHistory.keys()) if (!configuredIds.has(id)) chartHistory.delete(id);
const query = ($("stream-search")?.value || "").trim().toLowerCase();
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream].filter(Boolean).join(" ").toLowerCase().includes(query));
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream, ...(stream.backends || []).flatMap(b => [b.id, b.name, b.address])].filter(Boolean).join(" ").toLowerCase().includes(query));
const active = new Map((latestStatus?.streams?.active || []).map(item => [item.id, item]));
const sortValue = stream => {
const live = active.get(stream.id), same = live && JSON.stringify(live) === JSON.stringify(stream);
@@ -126,8 +205,17 @@
head.append(actions); box.append(head);
const details = el("div", undefined, "vhost-detail-strip");
for (const [key, value] of [["streamProtocol", String(stream.protocol || "tcp").toUpperCase()], ["streamListen", stream.listen], ["streamUpstream", stream.upstream], ["streamIdleShort", `${stream.idle_timeout_seconds}s`], ["streamMaxConnections", String(stream.max_connections)]]) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
for (const [key, value] of [["streamProtocol", String(stream.protocol || "tcp").toUpperCase()], ["streamListen", stream.listen], ["streamUpstream", `${stream.backends?.length ? stream.backends.length + " · " : ""}${stream.upstream}`], ["streamIdleShort", `${stream.idle_timeout_seconds}s`], ["streamMaxConnections", String(stream.max_connections)]]) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
box.append(details);
const backendBox = el("div", undefined, "stream-backend-status");
backendBox.append(text("span", "streamBackendPool", "small-label"));
for (const backend of stream.backends?.length ? stream.backends : [backendDefaults(0, stream.upstream)]) {
const row = el("div", undefined, "stream-backend-status-row");
row.append(el("span", `${backend.name || backend.id} · ${backend.address}`));
const health = el("span", t("streamWidgetUnverified"), "pill"); health.dataset.streamBackendStatus = `${stream.id}|${backend.id}`;
row.append(health); backendBox.append(row);
}
box.append(backendBox);
const live = el("div", undefined, "vhost-live-grid stream-live-grid");
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = stream.id; live.append(item); };
@@ -156,11 +244,15 @@
const now = performance.now();
for (const desired of envelope?.config.streams || []) {
const id = desired.id, live = active.get(id), data = metrics[id];
if (currentPanel === "streams") for (const backend of data?.backends || []) {
const node = document.querySelector(`[data-stream-backend-status="${CSS.escape(`${id}|${backend.id}`)}"]`);
if (node) node.textContent = `${t("streamHealthMode")}: ${t(`streamHealthState_${backend.status}`)} · ${backend.active} ${t("streamActiveSessions")}`;
}
const same = live && JSON.stringify(live) === JSON.stringify(desired);
const state = desired.enabled === false ? t("vhDisabled") : !live || !same ? t("streamPendingRestart") : t("streamRunning");
const set = (suffix, value) => document.querySelectorAll(`[data-${suffix.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${CSS.escape(id)}"]`).forEach(node => node.textContent = value);
set("streamState", state); set("streamActive", data ? fmt(data.active) : "-"); set("streamSessions", data ? fmt(data.sessions_total) : "-"); set("streamErrors", data ? fmt(data.errors) : "-"); set("streamRejected", data ? fmt(data.rejected) : "-"); set("streamTransferred", data ? bytes((Number(data.bytes_in) || 0) + (Number(data.bytes_out) || 0)) : "-");
if (!data) continue;
if (!data || currentPanel !== "streams") continue;
const history = chartHistory.get(id) || { lastTime: now, lastIn: Number(data.bytes_in) || 0, lastOut: Number(data.bytes_out) || 0, points: [], bucketSize: 1, pendingRx: 0, pendingTx: 0, pendingCount: 0 };
const elapsed = Math.max(.001, (now - history.lastTime) / 1000), inBytes = Number(data.bytes_in) || 0, outBytes = Number(data.bytes_out) || 0;
const rx = inBytes >= history.lastIn ? (inBytes - history.lastIn) / elapsed : 0, tx = outBytes >= history.lastOut ? (outBytes - history.lastOut) / elapsed : 0;