diff options
| author | Stefan Boberg <[email protected]> | 2026-03-12 15:03:03 +0100 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-03-12 15:03:03 +0100 |
| commit | 81bc43aa96f0059cecb28d1bd88338b7d84667f9 (patch) | |
| tree | a3428cb7fddceae0b284d33562af5bf3e64a367e /src/zenserver/frontend/html | |
| parent | update fmt 12.0.0 -> 12.1.0 (#828) (diff) | |
| download | zen-81bc43aa96f0059cecb28d1bd88338b7d84667f9.tar.xz zen-81bc43aa96f0059cecb28d1bd88338b7d84667f9.zip | |
Transparent proxy mode (#823)
Adds a **transparent TCP proxy mode** to zenserver (activated via `zenserver proxy`), allowing it to sit between clients and upstream Zen servers to inspect and monitor HTTP/1.x traffic in real time. Primarily useful during development, to be able to observe multi-server/client interactions in one place.
- **Dedicated proxy port** -- Proxy mode defaults to port 8118 with its own data directory to avoid collisions with a normal zenserver instance.
- **TCP proxy core** (`src/zenserver/proxy/`) -- A new transparent TCP proxy that forwards connections to upstream targets, with support for both TCP/IP and Unix socket listeners. Multi-threaded I/O for connection handling. Supports Unix domain sockets for both upstream/downstream.
- **HTTP traffic inspection** -- Parses HTTP/1.x request/response streams inline to extract method, path, status, content length, and WebSocket upgrades without breaking the proxied data.
- **Proxy dashboard** -- A web UI showing live connection stats, per-target request counts, active connections, bytes transferred, and client IP/session ID rollups.
- **Server mode display** -- Dashboard banner now shows the running server mode (Zen Proxy, Zen Compute, etc.).
Supporting changes included in this branch:
- **Wildcard log level matching** -- Log levels can now be set per-category using wildcard patterns (e.g. `proxy.*=debug`).
- **`zen down --all`** -- New flag to shut down all running zenserver instances; also used by the new `xmake kill` task.
- Minor test stability fixes (flaky hash collisions, per-thread RNG seeds).
- Support ZEN_MALLOC environment variable for default allocator selection and switch default to rpmalloc
- Fixed sentry-native build to allow LTO on Windows
Diffstat (limited to 'src/zenserver/frontend/html')
| -rw-r--r-- | src/zenserver/frontend/html/pages/page.js | 36 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/proxy.js | 452 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/start.js | 24 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/zen.css | 2 |
4 files changed, 504 insertions, 10 deletions
diff --git a/src/zenserver/frontend/html/pages/page.js b/src/zenserver/frontend/html/pages/page.js index dd8032c28..89a86a044 100644 --- a/src/zenserver/frontend/html/pages/page.js +++ b/src/zenserver/frontend/html/pages/page.js @@ -71,7 +71,7 @@ export class ZenPage extends PageBase add_branding(parent) { var banner = parent.tag("zen-banner"); - banner.attr("subtitle", "SERVER"); + banner.attr("subtitle", "Server"); banner.attr("tagline", "Local Storage Service"); banner.attr("logo-src", "favicon.ico"); banner.attr("load", "0"); @@ -80,6 +80,13 @@ export class ZenPage extends PageBase this._poll_status(); } + static _mode_taglines = { + "Server": "Local Storage Service", + "Proxy": "Proxy Service", + "Compute": "Compute Service", + "Hub": "Hub Service", + }; + async _poll_status() { try @@ -89,10 +96,18 @@ export class ZenPage extends PageBase { var obj = cbo.as_object(); - var hostname = obj.find("hostname"); - if (hostname) + var mode = obj.find("serverMode"); + if (mode) { - this._banner.attr("tagline", "Local Storage Service \u2014 " + hostname.as_value()); + var modeStr = mode.as_value(); + this._banner.attr("subtitle", modeStr); + var tagline = ZenPage._mode_taglines[modeStr] || modeStr; + + var hostname = obj.find("hostname"); + if (hostname) + tagline += " \u2014 " + hostname.as_value(); + + this._banner.attr("tagline", tagline); } var cpu = obj.find("cpuUsagePercent"); @@ -115,16 +130,17 @@ export class ZenPage extends PageBase // which links to show based on the services that are currently registered. const service_dashboards = [ - { base_uri: "/compute/", label: "Compute", href: "/dashboard/?page=compute" }, - { base_uri: "/orch/", label: "Orchestrator", href: "/dashboard/?page=orchestrator" }, - { base_uri: "/hub/", label: "Hub", href: "/dashboard/?page=hub" }, + { base_uri: "/sessions/", label: "Sessions", href: "/dashboard/?page=sessions" }, + { base_uri: "/z$/", label: "Cache", href: "/dashboard/?page=cache" }, + { base_uri: "/prj/", label: "Projects", href: "/dashboard/?page=projects" }, + { base_uri: "/compute/", label: "Compute", href: "/dashboard/?page=compute" }, + { base_uri: "/orch/", label: "Orchestrator", href: "/dashboard/?page=orchestrator" }, + { base_uri: "/hub/", label: "Hub", href: "/dashboard/?page=hub" }, + { base_uri: "/proxy/", label: "Proxy", href: "/dashboard/?page=proxy" }, ]; nav.tag("a").text("Home").attr("href", "/dashboard/"); - nav.tag("a").text("Sessions").attr("href", "/dashboard/?page=sessions"); - nav.tag("a").text("Cache").attr("href", "/dashboard/?page=cache"); - nav.tag("a").text("Projects").attr("href", "/dashboard/?page=projects"); this._info_link = nav.tag("a").text("Info").attr("href", "/dashboard/?page=info"); new Fetcher().resource("/api/").json().then((data) => { diff --git a/src/zenserver/frontend/html/pages/proxy.js b/src/zenserver/frontend/html/pages/proxy.js new file mode 100644 index 000000000..50e1f255a --- /dev/null +++ b/src/zenserver/frontend/html/pages/proxy.js @@ -0,0 +1,452 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" +import { Table } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + generate_crumbs() {} + + async main() + { + this.set_title("proxy"); + + // Recording + const record_section = this.add_section("Recording"); + this._record_host = record_section; + this._init_record_controls(record_section); + + // Summary + const summary_section = this.add_section("Summary"); + this._summary_grid = summary_section.tag().classify("grid").classify("stats-tiles"); + + // Mappings + const mappings_section = this.add_section("Proxy Mappings"); + this._mappings_host = mappings_section; + this._mappings_table = null; + + // Active Connections + const connections_section = this.add_section("Active Connections"); + this._connections_host = connections_section; + this._connections_table = null; + + try { this._view_mode = localStorage.getItem("zen-proxy-view-mode") || "per-conn"; } catch (e) { this._view_mode = "per-conn"; } + this._init_view_tabs(connections_section); + + await this._update(); + this._connect_stats_ws(); + } + + async _update() + { + try + { + const data = await new Fetcher().resource("/proxy/stats").json(); + this._render_summary(data); + this._render_mappings(data); + this._render_connections(data); + } + catch (e) { /* service unavailable */ } + } + + _connect_stats_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/stats`); + + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) { this._ws_paused = false; } + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const all_stats = JSON.parse(ev.data); + const data = all_stats["proxy"]; + if (data) + { + this._render_summary(data); + this._render_mappings(data); + this._render_connections(data); + } + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { this._stats_ws = null; }; + ws.onerror = () => { ws.close(); }; + + this._stats_ws = ws; + } + catch (e) { /* WebSocket not available */ } + } + + _init_record_controls(host) + { + const container = host.tag().classify("card"); + container.inner().style.display = "flex"; + container.inner().style.alignItems = "center"; + container.inner().style.gap = "12px"; + container.inner().style.padding = "12px 16px"; + + this._record_btn = document.createElement("button"); + this._record_btn.className = "history-tab"; + this._record_btn.textContent = "Start Recording"; + this._record_btn.addEventListener("click", () => this._toggle_recording()); + container.inner().appendChild(this._record_btn); + + this._record_status = document.createElement("span"); + this._record_status.style.fontSize = "0.85em"; + this._record_status.style.opacity = "0.7"; + this._record_status.textContent = "Off"; + container.inner().appendChild(this._record_status); + + this._recording = false; + } + + _update_record_ui(data) + { + const recording = !!data.recording; + this._recording = recording; + + this._record_btn.textContent = recording ? "Stop Recording" : "Start Recording"; + this._record_btn.classList.toggle("active", recording); + + const dir = data.recordDir || ""; + this._record_status.textContent = recording ? "Recording to: " + dir : "Off"; + } + + async _toggle_recording() + { + try + { + const endpoint = this._recording ? "/proxy/record/stop" : "/proxy/record/start"; + await fetch(endpoint, { method: "POST" }); + } + catch (e) { /* ignore */ } + } + + _render_summary(data) + { + this._update_record_ui(data); + + const grid = this._summary_grid; + grid.inner().innerHTML = ""; + + const mappings = data.mappings || []; + let totalActive = 0; + let totalPeak = 0; + let totalConn = 0; + let totalBytes = 0; + let totalRequestRate1 = 0; + let totalByteRate1 = 0; + let totalByteRate5 = 0; + + for (const m of mappings) + { + totalActive += (m.activeConnections || 0); + totalPeak += (m.peakActiveConnections || 0); + totalConn += (m.totalConnections || 0); + totalBytes += (m.bytesFromClient || 0) + (m.bytesToClient || 0); + totalRequestRate1 += (m.requestRate1 || 0); + totalByteRate1 += (m.byteRate1 || 0); + totalByteRate5 += (m.byteRate5 || 0); + } + + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Connections"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(totalActive), "currently open", true); + this._metric(body, Friendly.sep(totalPeak), "peak"); + this._metric(body, Friendly.sep(totalConn), "total since startup"); + } + + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Throughput"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(totalRequestRate1, 1) + "/s", "req/sec (1m)", true); + this._metric(body, Friendly.bytes(totalByteRate1) + "/s", "bandwidth (1m)", true); + this._metric(body, Friendly.bytes(totalByteRate5) + "/s", "bandwidth (5m)"); + this._metric(body, Friendly.bytes(totalBytes), "total transferred"); + } + } + + _render_mappings(data) + { + const mappings = data.mappings || []; + + if (this._mappings_table) + { + this._mappings_table.clear(); + } + else + { + this._mappings_table = this._mappings_host.add_widget( + Table, + ["listen", "target", "active", "peak", "total", "from client", "to client"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric, -1 + ); + } + + for (const m of mappings) + { + this._mappings_table.add_row( + m.listen || "", + m.target || "", + Friendly.sep(m.activeConnections || 0), + Friendly.sep(m.peakActiveConnections || 0), + Friendly.sep(m.totalConnections || 0), + Friendly.bytes(m.bytesFromClient || 0), + Friendly.bytes(m.bytesToClient || 0), + ); + } + } + + _init_view_tabs(host) + { + const tabs_el = document.createElement("div"); + tabs_el.className = "history-tabs"; + tabs_el.style.marginBottom = "8px"; + tabs_el.style.width = "fit-content"; + host.tag().inner().appendChild(tabs_el); + + this._view_tabs = {}; + const make_tab = (label, mode) => { + const btn = document.createElement("button"); + btn.className = "history-tab"; + btn.textContent = label; + btn.addEventListener("click", () => { + if (this._view_mode === mode) { return; } + this._view_mode = mode; + try { localStorage.setItem("zen-proxy-view-mode", mode); } catch (e) {} + this._update_active_tab(); + if (this._connections_table) + { + this._connections_table.destroy(); + this._connections_table = null; + } + if (this._last_data) + { + this._render_connections(this._last_data); + } + }); + tabs_el.appendChild(btn); + this._view_tabs[mode] = btn; + }; + + make_tab("Per Connection", "per-conn"); + make_tab("Group by IP", "by-ip"); + make_tab("Group by Session", "by-session"); + this._update_active_tab(); + } + + _update_active_tab() + { + for (const [mode, btn] of Object.entries(this._view_tabs)) + { + btn.classList.toggle("active", this._view_mode === mode); + } + } + + _render_connections(data) + { + this._last_data = data; + + const mappings = data.mappings || []; + let connections = []; + for (const m of mappings) + { + for (const c of (m.connections || [])) + { + connections.push(c); + } + } + + if (this._view_mode === "by-ip") + { + this._render_connections_grouped_ip(connections); + } + else if (this._view_mode === "by-session") + { + this._render_connections_grouped_session(connections); + } + else + { + this._render_connections_flat(connections); + } + } + + _render_connections_flat(connections) + { + if (this._connections_table) + { + this._connections_table.clear(); + } + else + { + this._connections_table = this._connections_host.add_widget( + Table, + ["client", "session", "target", "requests", "from client", "to client", "duration"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric, -1 + ); + } + + for (const c of connections) + { + const row = this._connections_table.add_row( + c.client || "", + c.sessionId || "", + c.target || "", + Friendly.sep(c.requests || 0), + Friendly.bytes(c.bytesFromClient || 0), + Friendly.bytes(c.bytesToClient || 0), + Friendly.duration((c.durationMs || 0) / 1000), + ); + row.get_cell(0).style("textAlign", "left"); + row.get_cell(1).style("textAlign", "left"); + row.get_cell(2).style("textAlign", "left"); + if (c.websocket) + { + this._append_badge(row.get_cell(0), "WS"); + } + } + } + + _append_badge(cell, text) + { + const badge = document.createElement("span"); + badge.className = "detail-tag"; + badge.style.marginLeft = "6px"; + badge.style.background = "color-mix(in srgb, var(--theme_p0) 15%, transparent)"; + badge.style.color = "var(--theme_p0)"; + badge.textContent = text; + cell.inner().appendChild(badge); + } + + _render_connections_grouped_ip(connections) + { + if (this._connections_table) + { + this._connections_table.clear(); + } + else + { + this._connections_table = this._connections_host.add_widget( + Table, + ["client ip", "conns", "requests", "from client", "to client", "max duration"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric, -1 + ); + } + + const groups = new Map(); + for (const c of connections) + { + const ip = (c.client || "").replace(/:\d+$/, ""); + let group = groups.get(ip); + if (!group) + { + group = { ip: ip, conns: 0, wsConns: 0, requests: 0, bytesFromClient: 0, bytesToClient: 0, maxDurationMs: 0 }; + groups.set(ip, group); + } + group.conns++; + if (c.websocket) { group.wsConns++; } + group.requests += (c.requests || 0); + group.bytesFromClient += (c.bytesFromClient || 0); + group.bytesToClient += (c.bytesToClient || 0); + group.maxDurationMs = Math.max(group.maxDurationMs, c.durationMs || 0); + } + + for (const g of groups.values()) + { + const row = this._connections_table.add_row( + g.ip, + Friendly.sep(g.conns), + Friendly.sep(g.requests), + Friendly.bytes(g.bytesFromClient), + Friendly.bytes(g.bytesToClient), + Friendly.duration(g.maxDurationMs / 1000), + ); + row.get_cell(0).style("textAlign", "left"); + if (g.wsConns > 0) + { + this._append_badge(row.get_cell(0), g.wsConns === 1 ? "WS" : `${g.wsConns} WS`); + } + } + } + + _render_connections_grouped_session(connections) + { + if (this._connections_table) + { + this._connections_table.clear(); + } + else + { + this._connections_table = this._connections_host.add_widget( + Table, + ["session", "conns", "requests", "from client", "to client", "max duration"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric, -1 + ); + } + + const groups = new Map(); + for (const c of connections) + { + const sid = c.sessionId || "(none)"; + let group = groups.get(sid); + if (!group) + { + group = { sessionId: sid, conns: 0, wsConns: 0, requests: 0, bytesFromClient: 0, bytesToClient: 0, maxDurationMs: 0 }; + groups.set(sid, group); + } + group.conns++; + if (c.websocket) { group.wsConns++; } + group.requests += (c.requests || 0); + group.bytesFromClient += (c.bytesFromClient || 0); + group.bytesToClient += (c.bytesToClient || 0); + group.maxDurationMs = Math.max(group.maxDurationMs, c.durationMs || 0); + } + + for (const g of groups.values()) + { + const row = this._connections_table.add_row( + g.sessionId, + Friendly.sep(g.conns), + Friendly.sep(g.requests), + Friendly.bytes(g.bytesFromClient), + Friendly.bytes(g.bytesToClient), + Friendly.duration(g.maxDurationMs / 1000), + ); + row.get_cell(0).style("textAlign", "left"); + if (g.wsConns > 0) + { + this._append_badge(row.get_cell(0), g.wsConns === 1 ? "WS" : `${g.wsConns} WS`); + } + } + } + + _metric(parent, value, label, hero = false) + { + const m = parent.tag().classify("tile-metric"); + if (hero) + { + m.classify("tile-metric-hero"); + } + m.tag().classify("metric-value").text(value); + m.tag().classify("metric-label").text(label); + } +} diff --git a/src/zenserver/frontend/html/pages/start.js b/src/zenserver/frontend/html/pages/start.js index 3a68a725d..580045060 100644 --- a/src/zenserver/frontend/html/pages/start.js +++ b/src/zenserver/frontend/html/pages/start.js @@ -262,6 +262,30 @@ export class Page extends ZenPage tile.on_click(() => { window.location = "?page=stat&provider=builds"; }); } + // Proxy tile + if (all_stats["proxy"]) + { + const s = all_stats["proxy"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Proxy"); + const body = tile.tag().classify("tile-metrics"); + + const mappings = s.mappings || []; + let totalActive = 0; + let totalBytes = 0; + for (const m of mappings) + { + totalActive += (m.activeConnections || 0); + totalBytes += (m.bytesFromClient || 0) + (m.bytesToClient || 0); + } + + this._add_tile_metric(body, Friendly.sep(totalActive), "active connections", true); + this._add_tile_metric(body, Friendly.sep(mappings.length), "mappings"); + this._add_tile_metric(body, Friendly.bytes(totalBytes), "traffic"); + + tile.on_click(() => { window.location = "?page=proxy"; }); + } + // Workspace tile (ws) if (all_stats["ws"]) { diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css index a968aecab..74336f0e1 100644 --- a/src/zenserver/frontend/html/zen.css +++ b/src/zenserver/frontend/html/zen.css @@ -1062,7 +1062,9 @@ tr:last-child td { .history-tab { background: transparent; + border: none; color: var(--theme_g1); + cursor: pointer; font-size: 12px; font-weight: 600; padding: 4px 12px; |