Files
RustProxy/src/admin/web/streams.js
T
2026-10-06 11:56:10 +02:00

188 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// Layer-4 stream management mirrors the vhost workflow while keeping TCP/UDP
// listeners separate from the HTTP policy stack.
(() => {
let editing = null, revision = null, dirty = false, initialized = false;
const chartHistory = new Map();
const dialog = $("stream-dialog"), form = $("stream-form"), container = $("stream-fields");
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
const button = (key, callback, cls = "quiet") => { const node = text("button", key, cls); node.type = "button"; node.addEventListener("click", callback); return node; };
const field = (parent, name, key, value, type = "text", options = {}) => {
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
const input = document.createElement(type === "select" ? "select" : "input"); input.name = name;
if (type === "select") for (const [val, optionKey] of options.values || []) { const option = text("option", optionKey); option.value = val; input.append(option); }
else input.type = type;
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
for (const attr of ["min", "max", "step", "maxLength", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
parent.append(label); return input;
};
const group = (key, tab) => { const box = el("fieldset", undefined, "vh-group"); box.dataset.tab = tab; box.append(text("legend", key)); const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body; };
const note = (parent, key) => parent.append(text("p", key, "fine vh-wide"));
const read = name => form.querySelector(`[name="${name}"]`);
const str = name => read(name).value.trim();
const num = name => { const value = Number(str(name)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
const yes = name => read(name).checked;
const changed = () => { dirty = true; };
const defaults = () => ({ id: "", name: "", enabled: true, protocol: "tcp", listen: "0.0.0.0:10000", upstream: "127.0.0.1:10001", connect_timeout_seconds: 5, idle_timeout_seconds: 300, max_connections: 1024 });
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
function activateTab(name) {
document.querySelectorAll("#stream-tabs button").forEach(node => { node.classList.toggle("active", node.dataset.tab === name); node.setAttribute("aria-pressed", String(node.dataset.tab === name)); });
container.querySelectorAll(".vh-group").forEach(groupNode => groupNode.hidden = groupNode.dataset.tab !== name);
}
function initTabs() {
const tabs = $("stream-tabs"); tabs.replaceChildren();
for (const [name, key] of [["details", "streamDetailsTab"], ["network", "streamNetworkTab"], ["limits", "streamLimitsTab"]]) { const node = button(key, () => activateTab(name)); node.dataset.tab = name; tabs.append(node); }
activateTab("details");
}
function socketChipField(parent, value) {
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "streamListen"));
const box = el("div", undefined, "host-chip-editor stream-socket-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 128; input.placeholder = "0.0.0.0:10000 / [::]:10000"; input.setAttribute("aria-label", t("streamListenAdd"));
let current = value || "";
const sync = () => { box.querySelectorAll(".host-chip").forEach(node => node.remove()); if (current) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", current)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${current}`); remove.addEventListener("click", () => { current = ""; sync(); changed(); input.focus(); }); chip.append(remove); box.insertBefore(chip, input); } input.hidden = Boolean(current); };
const commit = () => { const candidate = input.value.trim(); if (!candidate) return; if (candidate.length > 128 || /[\s,]/.test(candidate)) throw new Error(t("streamListenInvalid")); current = candidate; input.value = ""; sync(); changed(); };
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { commit(); } catch (error) { toast(error.message, true); } } });
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { commit(); } catch (error) { toast(error.message, true); } });
box.append(input); wrapper.append(box, text("p", "streamListenHelp", "fine")); parent.append(wrapper); sync();
return () => { if (input.value.trim()) commit(); if (!current) throw new Error(t("streamListenRequired")); return current; };
}
let readListen = () => "";
function load(value = defaults(), show = true) {
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
$("stream-editing").textContent = t(editing ? "streamEditLabel" : "streamNewLabel");
let g = group("streamEditor", "details");
field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}", readOnly: Boolean(editing) });
field(g, "name", "streamName", value.name, "text", { maxLength: 96 });
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);
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 });
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();
note(g, "streamLimitsHelp");
if (show) { activateTab("details"); if (!dialog.open) dialog.showModal(); }
$("stream-live-summary").textContent = editing ? t("streamNoLiveMetrics") : "";
if (latestStatus) renderStatus(latestStatus);
dirty = false;
}
function collect() {
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),
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 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;
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; }
function render() {
if (!envelope) return;
if (!initialized) load(defaults(), false);
const configured = envelope.config.streams || [];
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 cards = streams.map(stream => {
const box = el("article", undefined, "vhost-card stream-card"); box.dataset.streamCard = stream.id;
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
title.append(el("strong", stream.name || stream.id, "entity-title"), el("span", String(stream.protocol || "tcp").toUpperCase(), "pill live"));
if (stream.enabled === false) title.append(el("span", t("vhDisabled"), "pill"));
identity.append(title, el("div", stream.id, "vhost-hosts")); head.append(identity);
const actions = el("div", undefined, "entity-actions");
actions.append(button("edit", () => { window.rustProxyDrafts.prepare(); load(structuredClone(stream)); }), button("remove", async () => {
if (busy || !(await appConfirm(t("streamDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.streams = (next.streams || []).filter(item => item.id !== stream.id); if (await saveSection("streams", next.streams || [])) { if (editing === stream.id) load(); render(); }
}));
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); }
box.append(details);
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); };
metric("status", "streamState"); metric("streamActiveSessions", "streamActive"); metric("streamSessionsTotal", "streamSessions"); metric("streamErrors", "streamErrors"); metric("streamRejected", "streamRejected"); metric("streamTransferred", "streamTransferred"); box.append(live);
const chart = el("div", undefined, "vhost-chart stream-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "streamTrafficChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.streamChartValue = stream.id; chartHead.append(chartValue); chart.append(chartHead);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.streamChart = stream.id; svg.setAttribute("aria-label", t("streamTrafficChart"));
for (const cls of ["stream-chart-rx", "stream-chart-tx"]) { const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", cls); svg.append(polyline); }
chart.append(svg); const legend = el("div", undefined, "stream-chart-legend"); legend.append(el("span", `RX · ${t("streamClientToUpstream")}`), el("span", `TX · ${t("streamUpstreamToClient")}`)); chart.append(legend); box.append(chart);
return box;
});
$("stream-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "streamSearchEmpty" : "streamEmpty", "fine")]));
if (latestStatus) renderStatus(latestStatus);
}
function addPoint(history, rx, tx) {
history.pendingRx += rx; history.pendingTx += tx; history.pendingCount += 1;
if (history.pendingCount >= history.bucketSize) { history.points.push([history.pendingRx / history.pendingCount, history.pendingTx / history.pendingCount]); history.pendingRx = 0; history.pendingTx = 0; history.pendingCount = 0; }
if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) { const a = history.points[i], b = history.points[i + 1] || a; compacted.push([(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]); } history.points = compacted; history.bucketSize *= 2; }
}
function renderStatus(status) {
const streamStatus = status.streams || {}, metrics = streamStatus.metrics || {}, active = new Map((streamStatus.active || []).map(item => [item.id, item]));
$("stream-restart-banner").hidden = !streamStatus.restart_required; $("stream-restart-banner").textContent = t("streamRestartPending");
const activeList = (streamStatus.active || []).filter(item => item.enabled !== false).map(item => `${String(item.protocol || "tcp").toUpperCase()} ${item.listen} → ${item.upstream}`);
$("stream-active-summary").textContent = `${t("streamActive")}: ${activeList.join(" | ") || t("streamNone")}`;
const now = performance.now();
for (const desired of envelope?.config.streams || []) {
const id = desired.id, live = active.get(id), data = metrics[id];
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;
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;
history.lastTime = now; history.lastIn = inBytes; history.lastOut = outBytes; addPoint(history, rx, tx); chartHistory.set(id, history);
const points = history.pendingCount ? [...history.points, [history.pendingRx / history.pendingCount, history.pendingTx / history.pendingCount]] : history.points;
const peak = Math.max(1, ...points.flat()); const toPoints = index => points.map((pair, pointIndex) => `${pointIndex * 320 / Math.max(1, points.length - 1)},${50 - pair[index] / peak * 44}`).join(" ");
const svg = document.querySelector(`[data-stream-chart="${CSS.escape(id)}"]`); svg?.querySelector(".stream-chart-rx")?.setAttribute("points", toPoints(0)); svg?.querySelector(".stream-chart-tx")?.setAttribute("points", toPoints(1));
const label = document.querySelector(`[data-stream-chart-value="${CSS.escape(id)}"]`); if (label) label.textContent = `RX ${bytes(Math.round(rx))}/s · TX ${bytes(Math.round(tx))}/s`;
}
if (editing) { const data = metrics[editing]; $("stream-live-summary").textContent = data ? `${t("streamActiveSessions")}: ${fmt(data.active)} · RX ${bytes(data.bytes_in)} · TX ${bytes(data.bytes_out)} · ${t("streamErrors")}: ${fmt(data.errors)}` : t("streamNoLiveMetrics"); }
}
form.addEventListener("input", changed); form.addEventListener("change", changed);
form.addEventListener("submit", async event => {
event.preventDefault(); if (!envelope || busy) return;
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); invalid.reportValidity(); return; }
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
try {
const value = collect(), next = structuredClone(envelope.config); next.streams ||= [];
if (!editing && next.streams.some(stream => stream.id === value.id)) throw new Error(t("streamDuplicate"));
if (!editing && next.streams.length >= 128) throw new Error(t("streamLimit"));
const index = next.streams.findIndex(stream => stream.id === editing); if (index < 0) next.streams.push(value); else next.streams[index] = value;
if (await saveSection("streams", next.streams || [])) { dirty = false; load(value, false); dialog.close(); render(); }
} catch (error) { toast(error.message, true); }
});
$("stream-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
$("stream-listener-add").addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("listeners"); });
$("stream-search").addEventListener("input", render);
$("stream-close").addEventListener("click", closeEditor);
$("stream-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.streams || []).find(item => item.id === editing) || defaults()) : defaults()); });
dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
window.rustProxyDrafts.register("stream", { labelKey: "streams", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyStreams = { render, renderStatus, reset() { dialog.close(); editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("stream-search").value = ""; $("stream-list").replaceChildren(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
})();