"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); })();