Files
RustProxy/src/admin/web/streams.js
T
2026-10-21 09:38:21 +02:00

310 lines
30 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";
(() => {
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; };
}
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);
const backendSection = key => {
const section = el("section", undefined, "vh-wide vh-setting-section stream-backend-section");
section.append(text("h3", key));
const grid = el("div", undefined, "vh-grid"); section.append(grid); controls.append(section);
return grid;
};
const connectionFields = backendSection("streamBackendConnectionSection");
field(connectionFields, "backend-id", "streamBackendId", backend.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" });
field(connectionFields, "backend-name", "streamBackendName", backend.name || "", "text", { maxLength: 96 });
field(connectionFields, "backend-address", "streamBackendAddress", backend.address, "text", { required: true, maxLength: 128 });
field(connectionFields, "backend-weight", "streamBackendWeight", backend.weight ?? 1, "number", { min: 1, max: 16, required: true });
field(connectionFields, "backend-max", "streamBackendMaxSessions", backend.max_sessions ?? "", "number", { min: 1, max: 65535 });
field(connectionFields, "backend-enabled", "vhEnabled", backend.enabled !== false, "checkbox");
field(connectionFields, "backend-drain", "streamBackendDrain", backend.drain === true, "checkbox");
const detail = backend.metrics_enabled == null ? "inherit" : String(backend.metrics_enabled);
field(connectionFields, "backend-metrics", "streamBackendDetailMetrics", detail, "select", { values: [["inherit", "streamMetricsFromStream"], ["true", "streamMetricsOn"], ["false", "streamMetricsOff"]] });
const hc = backend.health || backendDefaults().health;
const healthFields = backendSection("streamBackendHealthSection");
const healthMode = field(healthFields, "backend-health-mode", "streamHealthMode", hc.mode || "off", "select", { values: [["off", "streamHealthOff"], [protocol === "tcp" ? "tcp_connect" : "udp_probe", protocol === "tcp" ? "streamHealthTcp" : "streamHealthUdp"]] });
const probeFields = el("div", undefined, "vh-wide vh-grid stream-health-probe-fields");
healthFields.append(probeFields);
const syncProbeFields = () => {
const inactive = healthMode.value === "off";
probeFields.hidden = inactive;
probeFields.querySelectorAll("input, select, textarea").forEach(input => { input.disabled = inactive; });
};
healthMode.addEventListener("change", syncProbeFields);
field(probeFields, "backend-health-interval", "streamHealthInterval", hc.interval_seconds ?? 10, "number", { min: 1, max: 3600, required: true });
field(probeFields, "backend-health-timeout", "streamHealthTimeout", hc.timeout_ms ?? 1000, "number", { min: 50, max: 30000, required: true });
field(probeFields, "backend-health-rise", "streamHealthRise", hc.rise ?? 2, "number", { min: 1, max: 10, required: true });
field(probeFields, "backend-health-fall", "streamHealthFall", hc.fall ?? 2, "number", { min: 1, max: 10, required: true });
if (protocol === "udp") {
field(probeFields, "backend-health-payload", "streamHealthPayload", hc.payload_hex || "", "text", { maxLength: 256 });
field(probeFields, "backend-health-expected", "streamHealthExpected", hc.expect_hex || "", "text", { maxLength: 256 });
}
syncProbeFields();
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();
$("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);
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, "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();
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") : "";
if (latestStatus) renderStatus(latestStatus);
dirty = false;
}
function collect() {
const backends = collectBackends();
return {
id: str("id"), name: str("name"), enabled: yes("enabled"), protocol: str("protocol"),
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, 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; 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; }
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, ...(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);
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.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); };
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];
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 || currentPanel !== "streams") continue;
const history = chartHistory.get(id) || { last: null, points: [], bucketSize: 1, pendingRx: 0, pendingTx: 0, pendingCount: 0 };
const sample = rustProxyRateSampling.difference(history.last, [Number(data.bytes_in) || 0, Number(data.bytes_out) || 0], now, rustProxyRateSampling.cadence("overview_seconds"));
const pausedRate = history.last !== null && sample.deltas === null;
history.last = sample.next;
const rx = sample.deltas ? sample.deltas[0] / sample.seconds : 0, tx = sample.deltas ? sample.deltas[1] / sample.seconds : 0;
if (!pausedRate) 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 = pausedRate ? "RX - · TX -" : `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);
})();