diff options
| author | Stefan Boberg <[email protected]> | 2026-03-09 17:43:08 +0100 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-03-09 17:43:08 +0100 |
| commit | b37b34ea6ad906f54e8104526e77ba66aed997da (patch) | |
| tree | e80ce17d666aff6d2f0d73d4977128ffb4055476 /src/zenserver/frontend/html/pages/projects.js | |
| parent | add fallback for zencache multirange (#816) (diff) | |
| download | zen-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.js | 447 |
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"); + } +} |