310 lines
30 KiB
JavaScript
310 lines
30 KiB
JavaScript
"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);
|
||
})();
|