v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
@@ -0,0 +1,177 @@
|
||||
"use strict";
|
||||
// Layer-4 stream management mirrors the vhost workflow while keeping TCP/UDP
|
||||
// listeners separate from the HTTP policy stack.
|
||||
(() => {
|
||||
let editing = null, revision = null, dirty = false, initialized = false;
|
||||
const chartHistory = new Map();
|
||||
const dialog = $("stream-dialog"), form = $("stream-form"), container = $("stream-fields");
|
||||
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const button = (key, callback, cls = "quiet") => { const node = text("button", key, cls); node.type = "button"; node.addEventListener("click", callback); return node; };
|
||||
const field = (parent, name, key, value, type = "text", options = {}) => {
|
||||
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
|
||||
const input = document.createElement(type === "select" ? "select" : "input"); input.name = name;
|
||||
if (type === "select") for (const [val, optionKey] of options.values || []) { const option = text("option", optionKey); option.value = val; input.append(option); }
|
||||
else input.type = type;
|
||||
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
|
||||
for (const attr of ["min", "max", "step", "maxLength", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
|
||||
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
|
||||
parent.append(label); return input;
|
||||
};
|
||||
const group = (key, tab) => { const box = el("fieldset", undefined, "vh-group"); box.dataset.tab = tab; box.append(text("legend", key)); const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body; };
|
||||
const note = (parent, key) => parent.append(text("p", key, "fine vh-wide"));
|
||||
const read = name => form.querySelector(`[name="${name}"]`);
|
||||
const str = name => read(name).value.trim();
|
||||
const num = name => { const value = Number(str(name)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
|
||||
const yes = name => read(name).checked;
|
||||
const changed = () => { dirty = true; };
|
||||
const defaults = () => ({ id: "", name: "", enabled: true, protocol: "tcp", listen: "0.0.0.0:10000", upstream: "127.0.0.1:10001", connect_timeout_seconds: 5, idle_timeout_seconds: 300, max_connections: 1024 });
|
||||
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
|
||||
|
||||
function activateTab(name) {
|
||||
document.querySelectorAll("#stream-tabs button").forEach(node => { node.classList.toggle("active", node.dataset.tab === name); node.setAttribute("aria-pressed", String(node.dataset.tab === name)); });
|
||||
container.querySelectorAll(".vh-group").forEach(groupNode => groupNode.hidden = groupNode.dataset.tab !== name);
|
||||
}
|
||||
function initTabs() {
|
||||
const tabs = $("stream-tabs"); tabs.replaceChildren();
|
||||
for (const [name, key] of [["details", "streamDetailsTab"], ["network", "streamNetworkTab"], ["limits", "streamLimitsTab"]]) { const node = button(key, () => activateTab(name)); node.dataset.tab = name; tabs.append(node); }
|
||||
activateTab("details");
|
||||
}
|
||||
function socketChipField(parent, value) {
|
||||
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "streamListen"));
|
||||
const box = el("div", undefined, "host-chip-editor stream-socket-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 128; input.placeholder = "0.0.0.0:10000 / [::]:10000"; input.setAttribute("aria-label", t("streamListenAdd"));
|
||||
let current = value || "";
|
||||
const sync = () => { box.querySelectorAll(".host-chip").forEach(node => node.remove()); if (current) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", current)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${current}`); remove.addEventListener("click", () => { current = ""; sync(); changed(); input.focus(); }); chip.append(remove); box.insertBefore(chip, input); } input.hidden = Boolean(current); };
|
||||
const commit = () => { const candidate = input.value.trim(); if (!candidate) return; if (candidate.length > 128 || /[\s,]/.test(candidate)) throw new Error(t("streamListenInvalid")); current = candidate; input.value = ""; sync(); changed(); };
|
||||
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { commit(); } catch (error) { toast(error.message, true); } } });
|
||||
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { commit(); } catch (error) { toast(error.message, true); } });
|
||||
box.append(input); wrapper.append(box, text("p", "streamListenHelp", "fine")); parent.append(wrapper); sync();
|
||||
return () => { if (input.value.trim()) commit(); if (!current) throw new Error(t("streamListenRequired")); return current; };
|
||||
}
|
||||
|
||||
let readListen = () => "";
|
||||
function load(value = defaults(), show = true) {
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true; container.replaceChildren(); initTabs();
|
||||
$("stream-editing").textContent = t(editing ? "streamEditLabel" : "streamNewLabel");
|
||||
let g = group("streamEditor", "details");
|
||||
field(g, "id", "streamId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}", readOnly: Boolean(editing) });
|
||||
field(g, "name", "streamName", value.name, "text", { maxLength: 96 });
|
||||
const protocol = field(g, "protocol", "streamProtocol", value.protocol || "tcp", "select", { values: [["tcp", "streamTcp"], ["udp", "streamUdp"]] });
|
||||
field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
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")
|
||||
};
|
||||
}
|
||||
async function closeEditor() { if (!(await allowDiscard())) return false; dialog.close(); return true; }
|
||||
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
if (!initialized) load(defaults(), false);
|
||||
const configured = envelope.config.streams || [];
|
||||
const configuredIds = new Set(configured.map(stream => stream.id));
|
||||
for (const id of chartHistory.keys()) if (!configuredIds.has(id)) chartHistory.delete(id);
|
||||
const query = ($("stream-search")?.value || "").trim().toLowerCase();
|
||||
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream].filter(Boolean).join(" ").toLowerCase().includes(query));
|
||||
const cards = streams.map(stream => {
|
||||
const box = el("article", undefined, "vhost-card stream-card"); box.dataset.streamCard = stream.id;
|
||||
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
|
||||
title.append(el("strong", stream.name || stream.id, "entity-title"), el("span", String(stream.protocol || "tcp").toUpperCase(), "pill live"));
|
||||
if (stream.enabled === false) title.append(el("span", t("vhDisabled"), "pill"));
|
||||
identity.append(title, el("div", stream.id, "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions");
|
||||
actions.append(button("edit", async () => { if (await allowDiscard()) 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", async () => { if (await allowDiscard()) load(); });
|
||||
$("stream-listener-add").addEventListener("click", async () => { if (await allowDiscard()) load(); });
|
||||
$("stream-search").addEventListener("input", render);
|
||||
$("stream-close").addEventListener("click", closeEditor);
|
||||
$("stream-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.streams || []).find(item => item.id === editing) || defaults()) : defaults()); });
|
||||
dialog.addEventListener("cancel", event => { if (!dirty) return; event.preventDefault(); void allowDiscard().then(ok => { if (ok) dialog.close(); }); });
|
||||
window.rustProxyStreams = { render, renderStatus, reset() { dialog.close(); editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("stream-search").value = ""; $("stream-list").replaceChildren(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
})();
|
||||
Reference in new issue
Block a user