201 lines
21 KiB
JavaScript
201 lines
21 KiB
JavaScript
"use strict";
|
||
// Layer-4 stream management mirrors the vhost workflow while keeping TCP/UDP
|
||
// listeners separate from the HTTP policy stack.
|
||
(() => {
|
||
const sortColumns = ["name", "id", "protocol", "listen", "upstream", "status", "maxConnections", "idleTimeout"];
|
||
let editing = null, revision = null, dirty = false, initialized = false, listPrefs = { sort: "name", descending: false };
|
||
const chartHistory = new Map();
|
||
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.stream_list_sort) ? preferences.stream_list_sort : "name", descending: preferences?.stream_list_descending === true }; }
|
||
function syncSortDirection() { const control = $("stream-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 ($("stream-sort")) $("stream-sort").value = listPrefs.sort; syncSortDirection(); render(); }
|
||
async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ stream_list_sort: listPrefs.sort, stream_list_descending: listPrefs.descending })); if ($("stream-sort")) $("stream-sort").value = listPrefs.sort; syncSortDirection(); } catch (error) { if (error.status !== 401) toast(error.message, true); } }
|
||
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 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);
|
||
return ({ name: String(stream.name || stream.id || "").toLowerCase(), id: String(stream.id || "").toLowerCase(), protocol: String(stream.protocol || "tcp"), listen: String(stream.listen || "").toLowerCase(), upstream: String(stream.upstream || "").toLowerCase(), status: stream.enabled === false ? 2 : same ? 0 : 1, maxConnections: Number(stream.max_connections || 0), idleTimeout: Number(stream.idle_timeout_seconds || 0) })[listPrefs.sort];
|
||
};
|
||
streams.sort((left, right) => { const a = sortValue(left), b = sortValue(right); 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; });
|
||
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-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); render(); });
|
||
$("stream-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); 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, applyPreferences, reset() { dialog.close(); editing = null; revision = null; dirty = false; initialized = false; listPrefs = { sort: "name", descending: false }; chartHistory.clear(); container.replaceChildren(); $("stream-search").value = ""; $("stream-list").replaceChildren(); if ($("stream-sort")) $("stream-sort").value = listPrefs.sort; syncSortDirection(); } };
|
||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||
})();
|