aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend/html/pages/projects.js
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-03-09 17:43:08 +0100
committerGitHub Enterprise <[email protected]>2026-03-09 17:43:08 +0100
commitb37b34ea6ad906f54e8104526e77ba66aed997da (patch)
treee80ce17d666aff6d2f0d73d4977128ffb4055476 /src/zenserver/frontend/html/pages/projects.js
parentadd fallback for zencache multirange (#816) (diff)
downloadzen-b37b34ea6ad906f54e8104526e77ba66aed997da.tar.xz
zen-b37b34ea6ad906f54e8104526e77ba66aed997da.zip
Dashboard overhaul, compute integration (#814)
- **Frontend dashboard overhaul**: Unified compute/main dashboards into a single shared UI. Added new pages for cache, projects, metrics, sessions, info (build/runtime config, system stats). Added live-update via WebSockets with pause control, sortable detail tables, themed styling. Refactored compute/hub/orchestrator pages into modular JS. - **HTTP server fixes and stats**: Fixed http.sys local-only fallback when default port is in use, implemented root endpoint redirect for http.sys, fixed Linux/Mac port reuse. Added /stats endpoint exposing HTTP server metrics (bytes transferred, request rates). Added WebSocket stats tracking. - **OTEL/diagnostics hardening**: Improved OTLP HTTP exporter with better error handling and resilience. Extended diagnostics services configuration. - **Session management**: Added new sessions service with HTTP endpoints for registering, updating, querying, and removing sessions. Includes session log file support. This is still WIP. - **CLI subcommand support**: Added support for commands with subcommands in the zen CLI tool, with improved command dispatch. - **Misc**: Exposed CPU usage/hostname to frontend, fixed JS compact binary float32/float64 decoding, limited projects displayed on front page to 25 sorted by last access, added vscode:// link support. Also contains some fixes from TSAN analysis.
Diffstat (limited to 'src/zenserver/frontend/html/pages/projects.js')
-rw-r--r--src/zenserver/frontend/html/pages/projects.js447
1 files changed, 447 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html/pages/projects.js b/src/zenserver/frontend/html/pages/projects.js
new file mode 100644
index 000000000..9c1e519d4
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/projects.js
@@ -0,0 +1,447 @@
+// 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 { Modal } from "../util/modal.js"
+import { Table, Toolbar } from "../util/widgets.js"
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ async main()
+ {
+ this.set_title("projects");
+
+ // Project Service Stats
+ const stats_section = this._collapsible_section("Project Service Stats");
+ stats_section.tag().classify("dropall").text("raw yaml \u2192").on_click(() => {
+ window.open("/stats/prj.yaml", "_blank");
+ });
+ this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles");
+
+ const stats = await new Fetcher().resource("stats", "prj").json();
+ if (stats)
+ {
+ this._render_stats(stats);
+ }
+
+ this._connect_stats_ws();
+
+ // Projects list
+ var section = this._collapsible_section("Projects");
+
+ section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all());
+
+ var columns = [
+ "name",
+ "project dir",
+ "engine dir",
+ "oplogs",
+ "actions",
+ ];
+
+ this._project_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric);
+
+ var projects = await new Fetcher().resource("/prj/list").json();
+ projects.sort((a, b) => (b.LastAccessTime || 0) - (a.LastAccessTime || 0));
+
+ for (const project of projects)
+ {
+ var row = this._project_table.add_row(
+ "",
+ "",
+ "",
+ "",
+ );
+
+ var cell = row.get_cell(0);
+ cell.tag().text(project.Id).on_click(() => this.view_project(project.Id));
+
+ if (project.ProjectRootDir)
+ {
+ row.get_cell(1).tag("a").text(project.ProjectRootDir)
+ .attr("href", "vscode://" + project.ProjectRootDir.replace(/\\/g, "/"));
+ }
+ if (project.EngineRootDir)
+ {
+ row.get_cell(2).tag("a").text(project.EngineRootDir)
+ .attr("href", "vscode://" + project.EngineRootDir.replace(/\\/g, "/"));
+ }
+
+ cell = row.get_cell(-1);
+ const action_tb = new Toolbar(cell, true).left();
+ action_tb.add("view").on_click(() => this.view_project(project.Id));
+ action_tb.add("drop").on_click(() => this.drop_project(project.Id));
+
+ row.attr("zs_name", project.Id);
+
+ // Fetch project details to get oplog count
+ new Fetcher().resource("prj", project.Id).json().then((info) => {
+ const oplogs = info["oplogs"] || [];
+ row.get_cell(3).text(Friendly.sep(oplogs.length)).style("textAlign", "right");
+ // Right-align the corresponding header cell
+ const header = this._project_table._element.firstElementChild;
+ if (header && header.children[4])
+ {
+ header.children[4].style.textAlign = "right";
+ }
+ }).catch(() => {});
+ }
+
+ // Project detail area (inside projects section so it collapses together)
+ this._project_host = section;
+ this._project_container = null;
+ this._selected_project = null;
+
+ // Restore project from URL if present
+ const prj_param = this.get_param("project");
+ if (prj_param)
+ {
+ this.view_project(prj_param);
+ }
+ }
+
+ _collapsible_section(name)
+ {
+ const section = this.add_section(name);
+ const container = section._parent.inner();
+ const heading = container.firstElementChild;
+
+ heading.style.cursor = "pointer";
+ heading.style.userSelect = "none";
+
+ const indicator = document.createElement("span");
+ indicator.textContent = " \u25BC";
+ indicator.style.fontSize = "0.7em";
+ heading.appendChild(indicator);
+
+ let collapsed = false;
+ heading.addEventListener("click", (e) => {
+ if (e.target !== heading && e.target !== indicator)
+ {
+ return;
+ }
+ collapsed = !collapsed;
+ indicator.textContent = collapsed ? " \u25B6" : " \u25BC";
+ let sibling = heading.nextElementSibling;
+ while (sibling)
+ {
+ sibling.style.display = collapsed ? "none" : "";
+ sibling = sibling.nextElementSibling;
+ }
+ });
+
+ return section;
+ }
+
+ _clear_param(name)
+ {
+ this._params.delete(name);
+ const url = new URL(window.location);
+ url.searchParams.delete(name);
+ history.replaceState(null, "", url);
+ }
+
+ _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 stats = all_stats["prj"];
+ if (stats)
+ {
+ this._render_stats(stats);
+ }
+ }
+ catch (e) { /* ignore parse errors */ }
+ };
+
+ ws.onclose = () => { this._stats_ws = null; };
+ ws.onerror = () => { ws.close(); };
+
+ this._stats_ws = ws;
+ }
+ catch (e) { /* WebSocket not available */ }
+ }
+
+ _render_stats(stats)
+ {
+ const safe = (obj, path) => path.split(".").reduce((a, b) => a && a[b], obj);
+ const grid = this._stats_grid;
+
+ grid.inner().innerHTML = "";
+
+ // HTTP Requests tile
+ {
+ const req = safe(stats, "requests");
+ if (req)
+ {
+ const tile = grid.tag().classify("card").classify("stats-tile");
+ tile.tag().classify("card-title").text("HTTP Requests");
+ const columns = tile.tag().classify("tile-columns");
+
+ const left = columns.tag().classify("tile-metrics");
+ const reqData = req.requests || req;
+ this._metric(left, Friendly.sep(safe(stats, "store.requestcount") || 0), "total requests", true);
+ if (reqData.rate_mean > 0)
+ {
+ this._metric(left, Friendly.sep(reqData.rate_mean, 1) + "/s", "req/sec (mean)");
+ }
+ if (reqData.rate_1 > 0)
+ {
+ this._metric(left, Friendly.sep(reqData.rate_1, 1) + "/s", "req/sec (1m)");
+ }
+ const badRequests = safe(stats, "store.badrequestcount") || 0;
+ this._metric(left, Friendly.sep(badRequests), "bad requests");
+
+ const right = columns.tag().classify("tile-metrics");
+ this._metric(right, Friendly.duration(reqData.t_avg || 0), "avg latency", true);
+ if (reqData.t_p75)
+ {
+ this._metric(right, Friendly.duration(reqData.t_p75), "p75");
+ }
+ if (reqData.t_p95)
+ {
+ this._metric(right, Friendly.duration(reqData.t_p95), "p95");
+ }
+ if (reqData.t_p99)
+ {
+ this._metric(right, Friendly.duration(reqData.t_p99), "p99");
+ }
+ }
+ }
+
+ // Store Operations tile
+ {
+ const store = safe(stats, "store");
+ if (store)
+ {
+ const tile = grid.tag().classify("card").classify("stats-tile");
+ tile.tag().classify("card-title").text("Store Operations");
+ const columns = tile.tag().classify("tile-columns");
+
+ const left = columns.tag().classify("tile-metrics");
+ const proj = store.project || {};
+ this._metric(left, Friendly.sep(proj.readcount || 0), "project reads", true);
+ this._metric(left, Friendly.sep(proj.writecount || 0), "project writes");
+ this._metric(left, Friendly.sep(proj.deletecount || 0), "project deletes");
+
+ const right = columns.tag().classify("tile-metrics");
+ const oplog = store.oplog || {};
+ this._metric(right, Friendly.sep(oplog.readcount || 0), "oplog reads", true);
+ this._metric(right, Friendly.sep(oplog.writecount || 0), "oplog writes");
+ this._metric(right, Friendly.sep(oplog.deletecount || 0), "oplog deletes");
+ }
+ }
+
+ // Op & Chunk tile
+ {
+ const store = safe(stats, "store");
+ if (store)
+ {
+ const tile = grid.tag().classify("card").classify("stats-tile");
+ tile.tag().classify("card-title").text("Ops & Chunks");
+ const columns = tile.tag().classify("tile-columns");
+
+ const left = columns.tag().classify("tile-metrics");
+ const op = store.op || {};
+ const opTotal = (op.hitcount || 0) + (op.misscount || 0);
+ const opRatio = opTotal > 0 ? (((op.hitcount || 0) / opTotal) * 100).toFixed(1) + "%" : "-";
+ this._metric(left, opRatio, "op hit ratio", true);
+ this._metric(left, Friendly.sep(op.hitcount || 0), "op hits");
+ this._metric(left, Friendly.sep(op.misscount || 0), "op misses");
+ this._metric(left, Friendly.sep(op.writecount || 0), "op writes");
+
+ const right = columns.tag().classify("tile-metrics");
+ const chunk = store.chunk || {};
+ const chunkTotal = (chunk.hitcount || 0) + (chunk.misscount || 0);
+ const chunkRatio = chunkTotal > 0 ? (((chunk.hitcount || 0) / chunkTotal) * 100).toFixed(1) + "%" : "-";
+ this._metric(right, chunkRatio, "chunk hit ratio", true);
+ this._metric(right, Friendly.sep(chunk.hitcount || 0), "chunk hits");
+ this._metric(right, Friendly.sep(chunk.misscount || 0), "chunk misses");
+ this._metric(right, Friendly.sep(chunk.writecount || 0), "chunk writes");
+ }
+ }
+
+ // Storage tile
+ {
+ const tile = grid.tag().classify("card").classify("stats-tile");
+ tile.tag().classify("card-title").text("Storage");
+ const columns = tile.tag().classify("tile-columns");
+
+ const left = columns.tag().classify("tile-metrics");
+ this._metric(left, safe(stats, "store.size.disk") != null ? Friendly.bytes(safe(stats, "store.size.disk")) : "-", "store disk", true);
+ this._metric(left, safe(stats, "store.size.memory") != null ? Friendly.bytes(safe(stats, "store.size.memory")) : "-", "store memory");
+
+ const right = columns.tag().classify("tile-metrics");
+ this._metric(right, safe(stats, "cid.size.total") != null ? Friendly.bytes(safe(stats, "cid.size.total")) : "-", "cid total", true);
+ this._metric(right, safe(stats, "cid.size.tiny") != null ? Friendly.bytes(safe(stats, "cid.size.tiny")) : "-", "cid tiny");
+ this._metric(right, safe(stats, "cid.size.small") != null ? Friendly.bytes(safe(stats, "cid.size.small")) : "-", "cid small");
+ this._metric(right, safe(stats, "cid.size.large") != null ? Friendly.bytes(safe(stats, "cid.size.large")) : "-", "cid large");
+ }
+ }
+
+ _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);
+ }
+
+ async view_project(project_id)
+ {
+ // Toggle off if already selected
+ if (this._selected_project === project_id)
+ {
+ this._selected_project = null;
+ this._clear_project_detail();
+ this._clear_param("project");
+ return;
+ }
+
+ this._selected_project = project_id;
+ this._clear_project_detail();
+ this.set_param("project", project_id);
+
+ const info = await new Fetcher().resource("prj", project_id).json();
+ if (this._selected_project !== project_id)
+ {
+ return;
+ }
+
+ const section = this._project_host.add_section(project_id);
+ this._project_container = section;
+
+ // Oplogs table
+ const oplog_section = section.add_section("Oplogs");
+ const oplog_table = oplog_section.add_widget(
+ Table,
+ ["name", "marker", "size", "ops", "expired", "actions"],
+ Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric
+ );
+
+ let totalSize = 0, totalOps = 0;
+ const total_row = oplog_table.add_row("TOTAL");
+ total_row.get_cell(0).style("fontWeight", "bold");
+ total_row.get_cell(2).style("textAlign", "right").style("fontWeight", "bold");
+ total_row.get_cell(3).style("textAlign", "right").style("fontWeight", "bold");
+
+ // Right-align header for numeric columns (size, ops)
+ const header = oplog_table._element.firstElementChild;
+ for (let i = 3; i < header.children.length - 1; i++)
+ {
+ header.children[i].style.textAlign = "right";
+ }
+
+ for (const oplog of info["oplogs"] || [])
+ {
+ const name = oplog["id"];
+ const row = oplog_table.add_row("");
+
+ var cell = row.get_cell(0);
+ cell.tag().text(name).link("", {
+ "page": "oplog",
+ "project": project_id,
+ "oplog": name,
+ });
+
+ cell = row.get_cell(-1);
+ const action_tb = new Toolbar(cell, true).left();
+ action_tb.add("list").link("", { "page": "oplog", "project": project_id, "oplog": name });
+ action_tb.add("tree").link("", { "page": "tree", "project": project_id, "oplog": name });
+ action_tb.add("drop").on_click(() => this.drop_oplog(project_id, name));
+
+ new Fetcher().resource("prj", project_id, "oplog", name).json().then((data) => {
+ row.get_cell(1).text(data["markerpath"]);
+ row.get_cell(2).text(Friendly.bytes(data["totalsize"])).style("textAlign", "right");
+ row.get_cell(3).text(Friendly.sep(data["opcount"])).style("textAlign", "right");
+ row.get_cell(4).text(data["expired"]);
+
+ totalSize += data["totalsize"] || 0;
+ totalOps += data["opcount"] || 0;
+ total_row.get_cell(2).text(Friendly.bytes(totalSize)).style("textAlign", "right").style("fontWeight", "bold");
+ total_row.get_cell(3).text(Friendly.sep(totalOps)).style("textAlign", "right").style("fontWeight", "bold");
+ }).catch(() => {});
+ }
+ }
+
+ _clear_project_detail()
+ {
+ if (this._project_container)
+ {
+ this._project_container._parent.inner().remove();
+ this._project_container = null;
+ }
+ }
+
+ drop_oplog(project_id, oplog_id)
+ {
+ const drop = async () => {
+ await new Fetcher().resource("prj", project_id, "oplog", oplog_id).delete();
+ // Refresh the project view
+ this._selected_project = null;
+ this._clear_project_detail();
+ this.view_project(project_id);
+ };
+
+ new Modal()
+ .title("Confirmation")
+ .message(`Drop oplog '${oplog_id}'?`)
+ .option("Yes", () => drop())
+ .option("No");
+ }
+
+ drop_project(project_id)
+ {
+ const drop = async () => {
+ await new Fetcher().resource("prj", project_id).delete();
+ this.reload();
+ };
+
+ new Modal()
+ .title("Confirmation")
+ .message(`Drop project '${project_id}'?`)
+ .option("Yes", () => drop())
+ .option("No");
+ }
+
+ async drop_all()
+ {
+ const drop = async () => {
+ for (const row of this._project_table)
+ {
+ const project_id = row.attr("zs_name");
+ await new Fetcher().resource("prj", project_id).delete();
+ }
+ this.reload();
+ };
+
+ new Modal()
+ .title("Confirmation")
+ .message("Drop every project?")
+ .option("Yes", () => drop())
+ .option("No");
+ }
+}