aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend/html
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-03-12 15:03:03 +0100
committerGitHub Enterprise <[email protected]>2026-03-12 15:03:03 +0100
commit81bc43aa96f0059cecb28d1bd88338b7d84667f9 (patch)
treea3428cb7fddceae0b284d33562af5bf3e64a367e /src/zenserver/frontend/html
parentupdate fmt 12.0.0 -> 12.1.0 (#828) (diff)
downloadzen-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.js36
-rw-r--r--src/zenserver/frontend/html/pages/proxy.js452
-rw-r--r--src/zenserver/frontend/html/pages/start.js24
-rw-r--r--src/zenserver/frontend/html/zen.css2
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;