// Copyright Epic Games, Inc. All Rights Reserved. "use strict"; import { WidgetHost } from "../util/widgets.js" import { Fetcher } from "../util/fetcher.js" import { Friendly } from "../util/friendly.js" //////////////////////////////////////////////////////////////////////////////// export class PageBase extends WidgetHost { constructor(parent, params) { super(parent) this._params = params; } set_title(name) { var value = document.title; if (name.length && value.length) name = value + " - " + name; document.title = name; } get_param(name, fallback=undefined) { var ret = this._params.get(name); if (ret != undefined) return ret; if (fallback != undefined) this.set_param(name, fallback); return fallback; } set_param(name, value, update=true) { this._params.set(name, value); if (!update) return value; const url = new URL(window.location); for (var [key, xfer] of this._params) url.searchParams.set(key, xfer); history.replaceState(null, "", url); return value; } reload() { window.location.reload(); } } //////////////////////////////////////////////////////////////////////////////// export class ZenPage extends PageBase { constructor(parent, ...args) { super(parent, ...args); super.set_title("zen"); this.add_branding(parent); this.add_service_nav(parent); this.generate_crumbs(); } add_branding(parent) { var banner = parent.tag("zen-banner"); banner.attr("subtitle", "Server"); banner.attr("tagline", "Local Storage Service"); banner.attr("logo-src", "favicon.ico"); banner.attr("load", "0"); this._banner = banner; this._poll_status(); } static _mode_taglines = { "Server": "Local Storage Service", "Proxy": "Proxy Service", "Compute": "Compute Service", "Hub": "Hub Service", }; async _poll_status() { try { var cbo = await new Fetcher().resource("/status/status").cbo(); if (cbo) { var obj = cbo.as_object(); var mode = obj.find("serverMode"); if (mode) { 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(); var ipField = obj.find("ipAddresses"); if (ipField) { var ips = []; for (const item of ipField.as_array()) { ips.push(item.as_value()); } if (ips.length > 0) { tagline += " (" + ips.join(", ") + ")"; } } } this._banner.attr("tagline", tagline); } var cpu = obj.find("cpuUsagePercent"); if (cpu) { this._banner.attr("load", cpu.as_value().toFixed(1)); } } } catch (e) { console.warn("status poll:", e); } setTimeout(() => this._poll_status(), 2000); } add_service_nav(parent) { const nav = parent.tag("zen-nav"); // Map service base URIs to dashboard links, this table is also used to detemine // which links to show based on the services that are currently registered. const service_dashboards = [ { base_uri: "/sessions/", label: "Sessions", href: "/dashboard/?page=sessions" }, { base_uri: "/z$/", label: "Cache", href: "/dashboard/?page=cache" }, { base_uri: "/builds/", label: "Build Store", href: "/dashboard/?page=builds" }, { base_uri: "/prj/", label: "Projects", href: "/dashboard/?page=projects" }, { base_uri: "/obj/", label: "Object Store", href: "/dashboard/?page=objectstore" }, { base_uri: "/ws/", label: "Workspaces", href: "/dashboard/?page=workspaces" }, { 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/"); this._storage_link = nav.tag("a").text("Storage").attr("href", "/dashboard/?page=storage"); nav.tag("a").text("Network").attr("href", "/dashboard/?page=network"); this._info_link = nav.tag("a").text("Info").attr("href", "/dashboard/?page=info"); nav.tag("a").text("Docs").attr("href", "/dashboard/?page=docs").attr("data-align", "right"); new Fetcher().resource("/api/").json().then((data) => { const services = data.services || []; const uris = new Set(services.map(s => s.base_uri)); const links = service_dashboards.filter(d => uris.has(d.base_uri)); // Insert service links before the Storage link const storage_elem = this._storage_link.inner(); for (const link of links) { const a = document.createElement("a"); a.textContent = link.label; a.href = link.href; storage_elem.parentNode.insertBefore(a, storage_elem); } }).catch(() => {}); } /** Connect to the /stats WebSocket and invoke callback with parsed stats on each update. * Handles protocol selection, pause toggle, and error/close cleanup. */ connect_stats_ws(callback) { 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 { callback(JSON.parse(ev.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 */ } } set_title(...args) { super.set_title(...args); } generate_crumbs() { var auto_name = this.get_param("page") || "start"; if (auto_name == "start") return; const crumbs = this.tag().id("crumbs"); const new_crumb = function(name, search=undefined) { crumbs.tag(); var crumb = crumbs.tag().text(name); if (search != undefined) crumb.on_click((x) => window.location.search = x, search); }; new_crumb("home", ""); var project = this.get_param("project"); if (project != undefined) { auto_name = project; var oplog = this.get_param("oplog"); if (oplog != undefined) { new_crumb(auto_name, `?page=project&project=${project}`); auto_name = oplog; var opkey = this.get_param("opkey") if (opkey != undefined) { new_crumb(auto_name, `?page=oplog&project=${project}&oplog=${oplog}`); auto_name = opkey.split("/").pop().split("\\").pop(); // Check if we're viewing cook artifacts var page = this.get_param("page"); var hash = this.get_param("hash"); if (hash != undefined && page == "cookartifacts") { new_crumb(auto_name, `?page=entry&project=${project}&oplog=${oplog}&opkey=${opkey}`); auto_name = "cook artifacts"; } } } } new_crumb(auto_name); } _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); } _render_http_requests_tile(grid, req, bad_requests = undefined) { if (!req) { return; } 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(reqData.count || 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)"); } if (reqData.rate_5 > 0) { this._metric(left, Friendly.sep(reqData.rate_5, 1) + "/s", "req/sec (5m)"); } if (reqData.rate_15 > 0) { this._metric(left, Friendly.sep(reqData.rate_15, 1) + "/s", "req/sec (15m)"); } if (bad_requests !== undefined) { this._metric(left, Friendly.sep(bad_requests), "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"); } if (reqData.t_p999) { this._metric(right, Friendly.duration(reqData.t_p999), "p999"); } if (reqData.t_max) { this._metric(right, Friendly.duration(reqData.t_max), "max"); } } _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; } }