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 | |
| 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')
| -rw-r--r-- | src/zenserver/frontend/html/pages/cache.js | 690 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/compute.js | 693 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/entry.js | 4 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/hub.js | 122 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/info.js | 261 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/map.js | 4 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/metrics.js | 232 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/oplog.js | 2 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/orchestrator.js | 405 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/page.js | 69 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/project.js | 2 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/projects.js | 447 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/sessions.js | 61 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/start.js | 327 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/stat.js | 2 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/tree.js | 2 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/zcache.js | 8 |
17 files changed, 3217 insertions, 114 deletions
diff --git a/src/zenserver/frontend/html/pages/cache.js b/src/zenserver/frontend/html/pages/cache.js new file mode 100644 index 000000000..3b838958a --- /dev/null +++ b/src/zenserver/frontend/html/pages/cache.js @@ -0,0 +1,690 @@ +// 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("cache"); + + // Cache Service Stats + const stats_section = this._collapsible_section("Cache Service Stats"); + stats_section.tag().classify("dropall").text("raw yaml \u2192").on_click(() => { + window.open("/stats/z$.yaml?cidstorestats=true&cachestorestats=true", "_blank"); + }); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + this._details_host = stats_section; + this._details_container = null; + this._selected_category = null; + + const stats = await new Fetcher().resource("stats", "z$").json(); + if (stats) + { + this._render_stats(stats); + } + + this._connect_stats_ws(); + + // Cache Namespaces + var section = this._collapsible_section("Cache Namespaces"); + + section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all()); + + var columns = [ + "namespace", + "dir", + "buckets", + "entries", + "size disk", + "size mem", + "actions", + ]; + + var zcache_info = await new Fetcher().resource("/z$/").json(); + this._cache_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric); + + for (const namespace of zcache_info["Namespaces"] || []) + { + new Fetcher().resource(`/z$/${namespace}/`).json().then((data) => { + const row = this._cache_table.add_row( + "", + data["Configuration"]["RootDir"], + data["Buckets"].length, + data["EntryCount"], + Friendly.bytes(data["StorageSize"].DiskSize), + Friendly.bytes(data["StorageSize"].MemorySize) + ); + var cell = row.get_cell(0); + cell.tag().text(namespace).on_click(() => this.view_namespace(namespace)); + + cell = row.get_cell(-1); + const action_tb = new Toolbar(cell, true); + action_tb.left().add("view").on_click(() => this.view_namespace(namespace)); + action_tb.left().add("drop").on_click(() => this.drop_namespace(namespace)); + + row.attr("zs_name", namespace); + }); + } + + // Namespace detail area (inside namespaces section so it collapses together) + this._namespace_host = section; + this._namespace_container = null; + this._selected_namespace = null; + + // Restore namespace from URL if present + const ns_param = this.get_param("namespace"); + if (ns_param) + { + this.view_namespace(ns_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; + } + + _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["z$"]; + 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; + + this._last_stats = stats; + grid.inner().innerHTML = ""; + + // Store I/O tile + { + const store = safe(stats, "cache.store"); + if (store) + { + const tile = grid.tag().classify("card").classify("stats-tile").classify("stats-tile-detailed"); + if (this._selected_category === "store") tile.classify("stats-tile-selected"); + tile.on_click(() => this._select_category("store")); + tile.tag().classify("card-title").text("Store I/O"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const storeHits = store.hits || 0; + const storeMisses = store.misses || 0; + const storeTotal = storeHits + storeMisses; + const storeRatio = storeTotal > 0 ? ((storeHits / storeTotal) * 100).toFixed(1) + "%" : "-"; + this._metric(left, storeRatio, "store hit ratio", true); + this._metric(left, Friendly.sep(storeHits), "hits"); + this._metric(left, Friendly.sep(storeMisses), "misses"); + this._metric(left, Friendly.sep(store.writes || 0), "writes"); + this._metric(left, Friendly.sep(store.rejected_reads || 0), "rejected reads"); + this._metric(left, Friendly.sep(store.rejected_writes || 0), "rejected writes"); + + const right = columns.tag().classify("tile-metrics"); + const readRateMean = safe(store, "read.bytes.rate_mean") || 0; + const readRate1 = safe(store, "read.bytes.rate_1") || 0; + const readRate5 = safe(store, "read.bytes.rate_5") || 0; + const writeRateMean = safe(store, "write.bytes.rate_mean") || 0; + const writeRate1 = safe(store, "write.bytes.rate_1") || 0; + const writeRate5 = safe(store, "write.bytes.rate_5") || 0; + this._metric(right, Friendly.bytes(readRateMean) + "/s", "read rate (mean)", true); + this._metric(right, Friendly.bytes(readRate1) + "/s", "read rate (1m)"); + this._metric(right, Friendly.bytes(readRate5) + "/s", "read rate (5m)"); + this._metric(right, Friendly.bytes(writeRateMean) + "/s", "write rate (mean)"); + this._metric(right, Friendly.bytes(writeRate1) + "/s", "write rate (1m)"); + this._metric(right, Friendly.bytes(writeRate5) + "/s", "write rate (5m)"); + } + } + + // Hit/Miss tile + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Hit Ratio"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const hits = safe(stats, "cache.hits") || 0; + const misses = safe(stats, "cache.misses") || 0; + const writes = safe(stats, "cache.writes") || 0; + const total = hits + misses; + const ratio = total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "-"; + + this._metric(left, ratio, "hit ratio", true); + this._metric(left, Friendly.sep(hits), "hits"); + this._metric(left, Friendly.sep(misses), "misses"); + this._metric(left, Friendly.sep(writes), "writes"); + + const right = columns.tag().classify("tile-metrics"); + const cidHits = safe(stats, "cache.cidhits") || 0; + const cidMisses = safe(stats, "cache.cidmisses") || 0; + const cidWrites = safe(stats, "cache.cidwrites") || 0; + const cidTotal = cidHits + cidMisses; + const cidRatio = cidTotal > 0 ? ((cidHits / cidTotal) * 100).toFixed(1) + "%" : "-"; + + this._metric(right, cidRatio, "cid hit ratio", true); + this._metric(right, Friendly.sep(cidHits), "cid hits"); + this._metric(right, Friendly.sep(cidMisses), "cid misses"); + this._metric(right, Friendly.sep(cidWrites), "cid writes"); + } + + // 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(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)"); + } + const badRequests = safe(stats, "cache.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"); + } + 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"); + } + } + } + + // RPC tile + { + const rpc = safe(stats, "cache.rpc"); + if (rpc) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("RPC"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + this._metric(left, Friendly.sep(rpc.count || 0), "rpc calls", true); + this._metric(left, Friendly.sep(rpc.ops || 0), "batch ops"); + + const right = columns.tag().classify("tile-metrics"); + if (rpc.records) + { + this._metric(right, Friendly.sep(rpc.records.count || 0), "record calls"); + this._metric(right, Friendly.sep(rpc.records.ops || 0), "record ops"); + } + if (rpc.values) + { + this._metric(right, Friendly.sep(rpc.values.count || 0), "value calls"); + this._metric(right, Friendly.sep(rpc.values.ops || 0), "value ops"); + } + if (rpc.chunks) + { + this._metric(right, Friendly.sep(rpc.chunks.count || 0), "chunk calls"); + this._metric(right, Friendly.sep(rpc.chunks.ops || 0), "chunk ops"); + } + } + } + + // Storage tile + { + const tile = grid.tag().classify("card").classify("stats-tile").classify("stats-tile-detailed"); + if (this._selected_category === "storage") tile.classify("stats-tile-selected"); + tile.on_click(() => this._select_category("storage")); + 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, "cache.size.disk") != null ? Friendly.bytes(safe(stats, "cache.size.disk")) : "-", "cache disk", true); + this._metric(left, safe(stats, "cache.size.memory") != null ? Friendly.bytes(safe(stats, "cache.size.memory")) : "-", "cache 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"); + } + + // Upstream tile (only if upstream is active) + { + const upstream = safe(stats, "upstream"); + if (upstream) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Upstream"); + const body = tile.tag().classify("tile-metrics"); + + const upstreamHits = safe(stats, "cache.upstream_hits") || 0; + this._metric(body, Friendly.sep(upstreamHits), "upstream hits", true); + + if (upstream.url) + { + this._metric(body, upstream.url, "endpoint"); + } + } + } + } + + _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 _select_category(category) + { + // Toggle off if already selected + if (this._selected_category === category) + { + this._selected_category = null; + this._clear_details(); + this._render_stats(this._last_stats); + return; + } + + this._selected_category = category; + this._render_stats(this._last_stats); + + // Fetch detailed stats + const detailed = await new Fetcher() + .resource("stats", "z$") + .param("cachestorestats", "true") + .param("cidstorestats", "true") + .json(); + + if (!detailed || this._selected_category !== category) + { + return; + } + + this._clear_details(); + + const safe = (obj, path) => path.split(".").reduce((a, b) => a && a[b], obj); + + if (category === "store") + { + this._render_store_details(detailed, safe); + } + else if (category === "storage") + { + this._render_storage_details(detailed, safe); + } + } + + _clear_details() + { + if (this._details_container) + { + this._details_container.inner().remove(); + this._details_container = null; + } + } + + _render_store_details(stats, safe) + { + const namespaces = safe(stats, "cache.store.namespaces") || []; + if (namespaces.length === 0) + { + return; + } + + const container = this._details_host.tag(); + this._details_container = container; + + const columns = [ + "namespace", + "bucket", + "hits", + "misses", + "writes", + "hit ratio", + "read count", + "read bandwidth", + "write count", + "write bandwidth", + ]; + const table = new Table(container, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric); + + for (const ns of namespaces) + { + const nsHits = ns.hits || 0; + const nsMisses = ns.misses || 0; + const nsTotal = nsHits + nsMisses; + const nsRatio = nsTotal > 0 ? ((nsHits / nsTotal) * 100).toFixed(1) + "%" : "-"; + + const readCount = safe(ns, "read.request.count") || 0; + const readBytes = safe(ns, "read.bytes.count") || 0; + const writeCount = safe(ns, "write.request.count") || 0; + const writeBytes = safe(ns, "write.bytes.count") || 0; + + table.add_row( + ns.namespace, + "", + Friendly.sep(nsHits), + Friendly.sep(nsMisses), + Friendly.sep(ns.writes || 0), + nsRatio, + Friendly.sep(readCount), + Friendly.bytes(readBytes), + Friendly.sep(writeCount), + Friendly.bytes(writeBytes), + ); + + if (ns.buckets && ns.buckets.length > 0) + { + for (const bucket of ns.buckets) + { + const bHits = bucket.hits || 0; + const bMisses = bucket.misses || 0; + const bTotal = bHits + bMisses; + const bRatio = bTotal > 0 ? ((bHits / bTotal) * 100).toFixed(1) + "%" : "-"; + + const bReadCount = safe(bucket, "read.request.count") || 0; + const bReadBytes = safe(bucket, "read.bytes.count") || 0; + const bWriteCount = safe(bucket, "write.request.count") || 0; + const bWriteBytes = safe(bucket, "write.bytes.count") || 0; + + table.add_row( + ns.namespace, + bucket.bucket, + Friendly.sep(bHits), + Friendly.sep(bMisses), + Friendly.sep(bucket.writes || 0), + bRatio, + Friendly.sep(bReadCount), + Friendly.bytes(bReadBytes), + Friendly.sep(bWriteCount), + Friendly.bytes(bWriteBytes), + ); + } + } + } + } + + _render_storage_details(stats, safe) + { + const namespaces = safe(stats, "cache.store.namespaces") || []; + if (namespaces.length === 0) + { + return; + } + + const container = this._details_host.tag(); + this._details_container = container; + + const columns = [ + "namespace", + "bucket", + "disk", + "memory", + ]; + const table = new Table(container, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric); + + for (const ns of namespaces) + { + const diskSize = safe(ns, "size.disk") || 0; + const memSize = safe(ns, "size.memory") || 0; + + table.add_row( + ns.namespace, + "", + Friendly.bytes(diskSize), + Friendly.bytes(memSize), + ); + + if (ns.buckets && ns.buckets.length > 0) + { + for (const bucket of ns.buckets) + { + const bDisk = safe(bucket, "size.disk") || 0; + const bMem = safe(bucket, "size.memory") || 0; + + table.add_row( + ns.namespace, + bucket.bucket, + Friendly.bytes(bDisk), + Friendly.bytes(bMem), + ); + } + } + } + } + + async view_namespace(namespace) + { + // Toggle off if already selected + if (this._selected_namespace === namespace) + { + this._selected_namespace = null; + this._clear_namespace(); + this._clear_param("namespace"); + return; + } + + this._selected_namespace = namespace; + this._clear_namespace(); + this.set_param("namespace", namespace); + + const info = await new Fetcher().resource(`/z$/${namespace}/`).json(); + if (this._selected_namespace !== namespace) + { + return; + } + + const section = this._namespace_host.add_section(namespace); + this._namespace_container = section; + + // Buckets table + const bucket_section = section.add_section("Buckets"); + const bucket_columns = ["name", "disk", "memory", "entries", "actions"]; + const bucket_table = bucket_section.add_widget( + Table, + bucket_columns, + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric + ); + + // Right-align header for numeric columns (skip # and name) + const header = bucket_table._element.firstElementChild; + for (let i = 2; i < header.children.length - 1; i++) + { + header.children[i].style.textAlign = "right"; + } + + let totalDisk = 0, totalMem = 0, totalEntries = 0; + const total_row = bucket_table.add_row("TOTAL"); + total_row.get_cell(0).style("fontWeight", "bold"); + total_row.get_cell(1).style("textAlign", "right").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"); + + for (const bucket of info["Buckets"]) + { + const row = bucket_table.add_row(bucket); + new Fetcher().resource(`/z$/${namespace}/${bucket}`).json().then((data) => { + row.get_cell(1).text(Friendly.bytes(data["StorageSize"]["DiskSize"])).style("textAlign", "right"); + row.get_cell(2).text(Friendly.bytes(data["StorageSize"]["MemorySize"])).style("textAlign", "right"); + row.get_cell(3).text(Friendly.sep(data["DiskEntryCount"])).style("textAlign", "right"); + + const cell = row.get_cell(-1); + const action_tb = new Toolbar(cell, true); + action_tb.left().add("drop").on_click(() => this.drop_bucket(namespace, bucket)); + + totalDisk += data["StorageSize"]["DiskSize"]; + totalMem += data["StorageSize"]["MemorySize"]; + totalEntries += data["DiskEntryCount"]; + total_row.get_cell(1).text(Friendly.bytes(totalDisk)).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(2).text(Friendly.bytes(totalMem)).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(3).text(Friendly.sep(totalEntries)).style("textAlign", "right").style("fontWeight", "bold"); + }); + } + + } + + _clear_param(name) + { + this._params.delete(name); + const url = new URL(window.location); + url.searchParams.delete(name); + history.replaceState(null, "", url); + } + + _clear_namespace() + { + if (this._namespace_container) + { + this._namespace_container._parent.inner().remove(); + this._namespace_container = null; + } + } + + drop_bucket(namespace, bucket) + { + const drop = async () => { + await new Fetcher().resource("z$", namespace, bucket).delete(); + // Refresh the namespace view + this._selected_namespace = null; + this._clear_namespace(); + this.view_namespace(namespace); + }; + + new Modal() + .title("Confirmation") + .message(`Drop bucket '${bucket}'?`) + .option("Yes", () => drop()) + .option("No"); + } + + drop_namespace(namespace) + { + const drop = async () => { + await new Fetcher().resource("z$", namespace).delete(); + this.reload(); + }; + + new Modal() + .title("Confirmation") + .message(`Drop cache namespace '${namespace}'?`) + .option("Yes", () => drop()) + .option("No"); + } + + async drop_all() + { + const drop = async () => { + for (const row of this._cache_table) + { + const namespace = row.attr("zs_name"); + await new Fetcher().resource("z$", namespace).delete(); + } + this.reload(); + }; + + new Modal() + .title("Confirmation") + .message("Drop every cache namespace?") + .option("Yes", () => drop()) + .option("No"); + } +} diff --git a/src/zenserver/frontend/html/pages/compute.js b/src/zenserver/frontend/html/pages/compute.js new file mode 100644 index 000000000..ab3d49c27 --- /dev/null +++ b/src/zenserver/frontend/html/pages/compute.js @@ -0,0 +1,693 @@ +// 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" + +const MAX_HISTORY_POINTS = 60; + +// Windows FILETIME: 100ns ticks since 1601-01-01 +const FILETIME_EPOCH_OFFSET_MS = 11644473600000n; +function filetimeToDate(ticks) +{ + if (!ticks) return null; + const ms = BigInt(ticks) / 10000n - FILETIME_EPOCH_OFFSET_MS; + return new Date(Number(ms)); +} + +function formatTime(date) +{ + if (!date) return "-"; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function formatDuration(startDate, endDate) +{ + if (!startDate || !endDate) return "-"; + const ms = endDate - startDate; + if (ms < 0) return "-"; + if (ms < 1000) return ms + " ms"; + if (ms < 60000) return (ms / 1000).toFixed(2) + " s"; + const m = Math.floor(ms / 60000); + const s = ((ms % 60000) / 1000).toFixed(0).padStart(2, "0"); + return `${m}m ${s}s`; +} + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("compute"); + + this._history = { timestamps: [], pending: [], running: [], completed: [], cpu: [] }; + this._selected_worker = null; + this._chart_js = null; + this._queue_chart = null; + this._cpu_chart = null; + + this._ws_paused = false; + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) {} + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + // Action Queue section + const queue_section = this._collapsible_section("Action Queue"); + this._queue_grid = queue_section.tag().classify("grid").classify("stats-tiles"); + this._chart_host = queue_section; + + // Performance Metrics section + const perf_section = this._collapsible_section("Performance Metrics"); + this._perf_host = perf_section; + this._perf_grid = null; + + // Workers section + const workers_section = this._collapsible_section("Workers"); + this._workers_host = workers_section; + this._workers_table = null; + this._worker_detail_container = null; + + // Queues section + const queues_section = this._collapsible_section("Queues"); + this._queues_host = queues_section; + this._queues_table = null; + + // Action History section + const history_section = this._collapsible_section("Recent Actions"); + this._history_host = history_section; + this._history_table = null; + + // System Resources section + const sys_section = this._collapsible_section("System Resources"); + this._sys_grid = sys_section.tag().classify("grid").classify("stats-tiles"); + + // Load Chart.js dynamically + this._load_chartjs(); + + // Initial fetch + await this._fetch_all(); + + // Poll + this._poll_timer = setInterval(() => { + if (!this._ws_paused) + { + this._fetch_all(); + } + }, 2000); + } + + _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; + } + + async _load_chartjs() + { + if (window.Chart) + { + this._chart_js = window.Chart; + this._init_charts(); + return; + } + + try + { + const script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"; + script.onload = () => { + this._chart_js = window.Chart; + this._init_charts(); + }; + document.head.appendChild(script); + } + catch (e) { /* Chart.js not available */ } + } + + _init_charts() + { + if (!this._chart_js) + { + return; + } + + // Queue history chart + { + const card = this._chart_host.tag().classify("card"); + card.tag().classify("card-title").text("Action Queue History"); + const container = card.tag(); + container.style("position", "relative").style("height", "300px").style("marginTop", "20px"); + const canvas = document.createElement("canvas"); + container.inner().appendChild(canvas); + + this._queue_chart = new this._chart_js(canvas.getContext("2d"), { + type: "line", + data: { + labels: [], + datasets: [ + { label: "Pending", data: [], borderColor: "#f0883e", backgroundColor: "rgba(240, 136, 62, 0.1)", tension: 0.4, fill: true }, + { label: "Running", data: [], borderColor: "#58a6ff", backgroundColor: "rgba(88, 166, 255, 0.1)", tension: 0.4, fill: true }, + { label: "Completed", data: [], borderColor: "#3fb950", backgroundColor: "rgba(63, 185, 80, 0.1)", tension: 0.4, fill: true }, + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: true, labels: { color: "#8b949e" } } }, + scales: { x: { display: false }, y: { beginAtZero: true, ticks: { color: "#8b949e" }, grid: { color: "#21262d" } } } + } + }); + } + + // CPU sparkline (will be appended to CPU card later) + this._cpu_canvas = document.createElement("canvas"); + this._cpu_chart = new this._chart_js(this._cpu_canvas.getContext("2d"), { + type: "line", + data: { + labels: [], + datasets: [{ + data: [], + borderColor: "#58a6ff", + backgroundColor: "rgba(88, 166, 255, 0.15)", + borderWidth: 1.5, + tension: 0.4, + fill: true, + pointRadius: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false, min: 0, max: 100 } } + } + }); + } + + async _fetch_all() + { + try + { + const [stats, sysinfo, workers_data, queues_data, history_data] = await Promise.all([ + new Fetcher().resource("/stats/compute").json().catch(() => null), + new Fetcher().resource("/compute/sysinfo").json().catch(() => null), + new Fetcher().resource("/compute/workers").json().catch(() => null), + new Fetcher().resource("/compute/queues").json().catch(() => null), + new Fetcher().resource("/compute/jobs/history").param("limit", "50").json().catch(() => null), + ]); + + if (stats) + { + this._render_queue_stats(stats); + this._update_queue_chart(stats); + this._render_perf(stats); + } + if (sysinfo) + { + this._render_sysinfo(sysinfo); + } + if (workers_data) + { + this._render_workers(workers_data); + } + if (queues_data) + { + this._render_queues(queues_data); + } + if (history_data) + { + this._render_action_history(history_data); + } + } + catch (e) { /* service unavailable */ } + } + + _render_queue_stats(data) + { + const grid = this._queue_grid; + grid.inner().innerHTML = ""; + + const tiles = [ + { title: "Pending Actions", value: data.actions_pending || 0, label: "waiting to be scheduled" }, + { title: "Running Actions", value: data.actions_submitted || 0, label: "currently executing" }, + { title: "Completed Actions", value: data.actions_complete || 0, label: "results available" }, + ]; + + for (const t of tiles) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text(t.title); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(t.value), t.label, true); + } + } + + _update_queue_chart(data) + { + const h = this._history; + h.timestamps.push(new Date().toLocaleTimeString()); + h.pending.push(data.actions_pending || 0); + h.running.push(data.actions_submitted || 0); + h.completed.push(data.actions_complete || 0); + + while (h.timestamps.length > MAX_HISTORY_POINTS) + { + h.timestamps.shift(); + h.pending.shift(); + h.running.shift(); + h.completed.shift(); + } + + if (this._queue_chart) + { + this._queue_chart.data.labels = h.timestamps; + this._queue_chart.data.datasets[0].data = h.pending; + this._queue_chart.data.datasets[1].data = h.running; + this._queue_chart.data.datasets[2].data = h.completed; + this._queue_chart.update("none"); + } + } + + _render_perf(data) + { + if (!this._perf_grid) + { + this._perf_grid = this._perf_host.tag().classify("grid").classify("stats-tiles"); + } + const grid = this._perf_grid; + grid.inner().innerHTML = ""; + + const retired = data.actions_retired || {}; + + // Completion rate card + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Completion Rate"); + const body = tile.tag().classify("tile-columns"); + + const left = body.tag().classify("tile-metrics"); + this._metric(left, this._fmt_rate(retired.rate_1), "1 min rate", true); + this._metric(left, this._fmt_rate(retired.rate_5), "5 min rate"); + this._metric(left, this._fmt_rate(retired.rate_15), "15 min rate"); + + const right = body.tag().classify("tile-metrics"); + this._metric(right, Friendly.sep(retired.count || 0), "total retired", true); + this._metric(right, this._fmt_rate(retired.rate_mean), "mean rate"); + } + } + + _fmt_rate(rate) + { + if (rate == null) return "-"; + return rate.toFixed(2) + "/s"; + } + + _render_workers(data) + { + const workerIds = data.workers || []; + + if (this._workers_table) + { + this._workers_table.clear(); + } + else + { + this._workers_table = this._workers_host.add_widget( + Table, + ["name", "platform", "cores", "timeout", "functions", "worker ID"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + if (workerIds.length === 0) + { + return; + } + + // Fetch each worker's descriptor + Promise.all( + workerIds.map(id => + new Fetcher().resource("/compute/workers", id).json() + .then(desc => ({ id, desc })) + .catch(() => ({ id, desc: null })) + ) + ).then(results => { + this._workers_table.clear(); + for (const { id, desc } of results) + { + const name = desc ? (desc.name || "-") : "-"; + const host = desc ? (desc.host || "-") : "-"; + const cores = desc ? (desc.cores != null ? desc.cores : "-") : "-"; + const timeout = desc ? (desc.timeout != null ? desc.timeout + "s" : "-") : "-"; + const functions = desc ? (desc.functions ? desc.functions.length : 0) : "-"; + + const row = this._workers_table.add_row( + "", + host, + String(cores), + String(timeout), + String(functions), + id, + ); + + // Make name clickable to expand detail + const cell = row.get_cell(0); + cell.tag().text(name).on_click(() => this._toggle_worker_detail(id, desc)); + + // Highlight selected + if (id === this._selected_worker) + { + row.style("background", "var(--theme_p3)"); + } + } + + this._worker_descriptors = Object.fromEntries(results.map(r => [r.id, r.desc])); + + // Re-render detail if still selected + if (this._selected_worker && this._worker_descriptors[this._selected_worker]) + { + this._show_worker_detail(this._selected_worker, this._worker_descriptors[this._selected_worker]); + } + else if (this._selected_worker) + { + this._selected_worker = null; + this._clear_worker_detail(); + } + }); + } + + _toggle_worker_detail(id, desc) + { + if (this._selected_worker === id) + { + this._selected_worker = null; + this._clear_worker_detail(); + return; + } + this._selected_worker = id; + this._show_worker_detail(id, desc); + } + + _clear_worker_detail() + { + if (this._worker_detail_container) + { + this._worker_detail_container._parent.inner().remove(); + this._worker_detail_container = null; + } + } + + _show_worker_detail(id, desc) + { + this._clear_worker_detail(); + if (!desc) + { + return; + } + + const section = this._workers_host.add_section(desc.name || id); + this._worker_detail_container = section; + + // Basic info table + const info_table = section.add_widget( + Table, ["property", "value"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + const fields = [ + ["Worker ID", id], + ["Path", desc.path || "-"], + ["Platform", desc.host || "-"], + ["Build System", desc.buildsystem_version || "-"], + ["Cores", desc.cores != null ? String(desc.cores) : "-"], + ["Timeout", desc.timeout != null ? desc.timeout + "s" : "-"], + ]; + for (const [label, value] of fields) + { + info_table.add_row(label, value); + } + + // Functions + const functions = desc.functions || []; + if (functions.length > 0) + { + const fn_section = section.add_section("Functions"); + const fn_table = fn_section.add_widget( + Table, ["name", "version"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + for (const f of functions) + { + fn_table.add_row(f.name || "-", f.version || "-"); + } + } + + // Executables + const executables = desc.executables || []; + if (executables.length > 0) + { + const exec_section = section.add_section("Executables"); + const exec_table = exec_section.add_widget( + Table, ["path", "hash", "size"], Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric + ); + let totalSize = 0; + for (const e of executables) + { + exec_table.add_row(e.name || "-", e.hash || "-", e.size != null ? Friendly.bytes(e.size) : "-"); + totalSize += e.size || 0; + } + const total_row = exec_table.add_row("TOTAL", "", Friendly.bytes(totalSize)); + total_row.get_cell(0).style("fontWeight", "bold"); + total_row.get_cell(2).style("fontWeight", "bold"); + } + + // Files + const files = desc.files || []; + if (files.length > 0) + { + const files_section = section.add_section("Files"); + const files_table = files_section.add_widget( + Table, ["name", "hash"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + for (const f of files) + { + files_table.add_row(typeof f === "string" ? f : (f.name || "-"), typeof f === "string" ? "" : (f.hash || "")); + } + } + + // Directories + const dirs = desc.dirs || []; + if (dirs.length > 0) + { + const dirs_section = section.add_section("Directories"); + for (const d of dirs) + { + dirs_section.tag().classify("detail-tag").text(d); + } + } + + // Environment + const env = desc.environment || []; + if (env.length > 0) + { + const env_section = section.add_section("Environment"); + for (const e of env) + { + env_section.tag().classify("detail-tag").text(e); + } + } + } + + _render_queues(data) + { + const queues = data.queues || []; + + if (this._queues_table) + { + this._queues_table.clear(); + } + else + { + this._queues_table = this._queues_host.add_widget( + Table, + ["ID", "status", "active", "completed", "failed", "abandoned", "cancelled", "token"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + for (const q of queues) + { + const id = q.queue_id != null ? String(q.queue_id) : "-"; + const status = q.state === "cancelled" ? "cancelled" + : q.state === "draining" ? "draining" + : q.is_complete ? "complete" : "active"; + + this._queues_table.add_row( + id, + status, + String(q.active_count ?? 0), + String(q.completed_count ?? 0), + String(q.failed_count ?? 0), + String(q.abandoned_count ?? 0), + String(q.cancelled_count ?? 0), + q.queue_token || "-", + ); + } + } + + _render_action_history(data) + { + const entries = data.history || []; + + if (this._history_table) + { + this._history_table.clear(); + } + else + { + this._history_table = this._history_host.add_widget( + Table, + ["LSN", "queue", "status", "function", "started", "finished", "duration", "worker ID", "action ID"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + // Entries arrive oldest-first; reverse to show newest at top + for (const entry of [...entries].reverse()) + { + const lsn = entry.lsn != null ? String(entry.lsn) : "-"; + const queueId = entry.queueId ? String(entry.queueId) : "-"; + const status = entry.succeeded == null ? "unknown" + : entry.succeeded ? "ok" : "failed"; + const desc = entry.actionDescriptor || {}; + const fn = desc.Function || "-"; + const startDate = filetimeToDate(entry.time_Running); + const endDate = filetimeToDate(entry.time_Completed ?? entry.time_Failed); + + this._history_table.add_row( + lsn, + queueId, + status, + fn, + formatTime(startDate), + formatTime(endDate), + formatDuration(startDate, endDate), + entry.workerId || "-", + entry.actionId || "-", + ); + } + } + + _render_sysinfo(data) + { + const grid = this._sys_grid; + grid.inner().innerHTML = ""; + + // CPU card + { + const cpuUsage = data.cpu_usage || 0; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("CPU Usage"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, cpuUsage.toFixed(1) + "%", "percent", true); + + // Progress bar + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", cpuUsage + "%"); + + // CPU sparkline + this._history.cpu.push(cpuUsage); + while (this._history.cpu.length > MAX_HISTORY_POINTS) this._history.cpu.shift(); + if (this._cpu_chart) + { + const sparkContainer = body.tag(); + sparkContainer.style("position", "relative").style("height", "60px").style("marginTop", "12px"); + sparkContainer.inner().appendChild(this._cpu_canvas); + + this._cpu_chart.data.labels = this._history.cpu.map(() => ""); + this._cpu_chart.data.datasets[0].data = this._history.cpu; + this._cpu_chart.update("none"); + } + + // CPU details + this._stat_row(body, "Packages", data.cpu_count != null ? String(data.cpu_count) : "-"); + this._stat_row(body, "Physical Cores", data.core_count != null ? String(data.core_count) : "-"); + this._stat_row(body, "Logical Processors", data.lp_count != null ? String(data.lp_count) : "-"); + } + + // Memory card + { + const memUsed = data.memory_used || 0; + const memTotal = data.memory_total || 1; + const memPercent = (memUsed / memTotal) * 100; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Memory"); + const body = tile.tag().classify("tile-metrics"); + this._stat_row(body, "Used", Friendly.bytes(memUsed)); + this._stat_row(body, "Total", Friendly.bytes(memTotal)); + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", memPercent + "%"); + } + + // Disk card + { + const diskUsed = data.disk_used || 0; + const diskTotal = data.disk_total || 1; + const diskPercent = (diskUsed / diskTotal) * 100; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Disk"); + const body = tile.tag().classify("tile-metrics"); + this._stat_row(body, "Used", Friendly.bytes(diskUsed)); + this._stat_row(body, "Total", Friendly.bytes(diskTotal)); + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", diskPercent + "%"); + } + } + + _stat_row(parent, label, value) + { + const row = parent.tag().classify("stats-row"); + row.tag().classify("stats-label").text(label); + row.tag().classify("stats-value").text(value); + } + + _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/entry.js b/src/zenserver/frontend/html/pages/entry.js index f418b17ba..1e4c82e3f 100644 --- a/src/zenserver/frontend/html/pages/entry.js +++ b/src/zenserver/frontend/html/pages/entry.js @@ -262,8 +262,8 @@ export class Page extends ZenPage io_hash = ret; } - size = (size !== undefined) ? Friendly.kib(size) : ""; - raw_size = (raw_size !== undefined) ? Friendly.kib(raw_size) : ""; + size = (size !== undefined) ? Friendly.bytes(size) : ""; + raw_size = (raw_size !== undefined) ? Friendly.bytes(raw_size) : ""; const row = table.add_row(file_name, size, raw_size); diff --git a/src/zenserver/frontend/html/pages/hub.js b/src/zenserver/frontend/html/pages/hub.js new file mode 100644 index 000000000..f9e4fff33 --- /dev/null +++ b/src/zenserver/frontend/html/pages/hub.js @@ -0,0 +1,122 @@ +// 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 +{ + async main() + { + this.set_title("hub"); + + // Capacity + const stats_section = this.add_section("Capacity"); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + + // Modules + const mod_section = this.add_section("Modules"); + this._mod_host = mod_section; + this._mod_table = null; + + await this._update(); + this._poll_timer = setInterval(() => this._update(), 2000); + } + + async _update() + { + try + { + const [stats, status] = await Promise.all([ + new Fetcher().resource("/hub/stats").json(), + new Fetcher().resource("/hub/status").json(), + ]); + + this._render_capacity(stats); + this._render_modules(status); + } + catch (e) { /* service unavailable */ } + } + + _render_capacity(data) + { + const grid = this._stats_grid; + grid.inner().innerHTML = ""; + + const current = data.currentInstanceCount || 0; + const max = data.maxInstanceCount || 0; + const limit = data.instanceLimit || 0; + + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Active Modules"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(current), "currently provisioned", true); + } + + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Peak Modules"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(max), "high watermark", true); + } + + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Instance Limit"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(limit), "maximum allowed", true); + if (limit > 0) + { + const pct = ((current / limit) * 100).toFixed(0) + "%"; + this._metric(body, pct, "utilization"); + } + } + } + + _render_modules(data) + { + const modules = data.modules || []; + + if (this._mod_table) + { + this._mod_table.clear(); + } + else + { + this._mod_table = this._mod_host.add_widget( + Table, + ["module ID", "status"], + Table.Flag_FitLeft|Table.Flag_PackRight + ); + } + + if (modules.length === 0) + { + return; + } + + for (const m of modules) + { + this._mod_table.add_row( + m.moduleId || "", + m.provisioned ? "provisioned" : "inactive", + ); + } + } + + _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/info.js b/src/zenserver/frontend/html/pages/info.js new file mode 100644 index 000000000..f92765c78 --- /dev/null +++ b/src/zenserver/frontend/html/pages/info.js @@ -0,0 +1,261 @@ +// 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" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("info"); + + const [info, gc, services, version] = await Promise.all([ + new Fetcher().resource("/health/info").json(), + new Fetcher().resource("/admin/gc").json().catch(() => null), + new Fetcher().resource("/api/").json().catch(() => ({})), + new Fetcher().resource("/health/version").param("detailed", "true").text(), + ]); + + const section = this.add_section("Server Info"); + const grid = section.tag().classify("grid").classify("info-tiles"); + + // Application + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Application"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "version", version || info.BuildVersion || "-"); + this._prop(list, "http server", info.HttpServerClass || "-"); + this._prop(list, "port", info.Port || "-"); + this._prop(list, "pid", info.Pid || "-"); + this._prop(list, "dedicated", info.IsDedicated ? "yes" : "no"); + + if (info.StartTimeMs) + { + const start = new Date(info.StartTimeMs); + const elapsed = Date.now() - info.StartTimeMs; + this._prop(list, "started", start.toLocaleString()); + this._prop(list, "uptime", this._format_duration(elapsed)); + } + + this._prop(list, "data root", info.DataRoot || "-"); + this._prop(list, "log path", info.AbsLogPath || "-"); + } + + // System + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("System"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "hostname", info.Hostname || "-"); + this._prop(list, "platform", info.Platform || "-"); + this._prop(list, "os", info.OS || "-"); + this._prop(list, "arch", info.Arch || "-"); + + const sys = info.System; + if (sys) + { + this._prop(list, "cpus", sys.cpu_count || "-"); + this._prop(list, "cores", sys.core_count || "-"); + this._prop(list, "logical processors", sys.lp_count || "-"); + this._prop(list, "total memory", sys.total_memory_mb ? Friendly.bytes(sys.total_memory_mb * 1048576) : "-"); + this._prop(list, "available memory", sys.avail_memory_mb ? Friendly.bytes(sys.avail_memory_mb * 1048576) : "-"); + if (sys.uptime_seconds) + { + this._prop(list, "system uptime", this._format_duration(sys.uptime_seconds * 1000)); + } + } + } + + // Runtime Configuration + if (info.RuntimeConfig) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Runtime Configuration"); + const list = tile.tag().classify("info-props"); + + for (const key in info.RuntimeConfig) + { + this._prop(list, key, info.RuntimeConfig[key] || "-"); + } + } + + // Build Configuration + if (info.BuildConfig) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Build Configuration"); + const list = tile.tag().classify("info-props"); + + for (const key in info.BuildConfig) + { + this._prop(list, key, info.BuildConfig[key] ? "yes" : "no"); + } + } + + // Services + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Services"); + const list = tile.tag().classify("info-props"); + + const svc_list = (services.services || []).map(s => s.base_uri).sort(); + for (const uri of svc_list) + { + this._prop(list, uri, "registered"); + } + } + + // Garbage Collection + if (gc) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Garbage Collection"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "status", gc.Status || "-"); + + if (gc.AreDiskWritesBlocked !== undefined) + { + this._prop(list, "disk writes blocked", gc.AreDiskWritesBlocked ? "yes" : "no"); + } + + if (gc.DiskSize) + { + this._prop(list, "disk size", gc.DiskSize); + this._prop(list, "disk used", gc.DiskUsed); + this._prop(list, "disk free", gc.DiskFree); + } + + const cfg = gc.Config; + if (cfg) + { + this._prop(list, "gc enabled", cfg.Enabled ? "yes" : "no"); + if (cfg.Interval) + { + this._prop(list, "interval", this._friendly_duration(cfg.Interval)); + } + if (cfg.LightweightInterval) + { + this._prop(list, "lightweight interval", this._friendly_duration(cfg.LightweightInterval)); + } + if (cfg.MaxCacheDuration) + { + this._prop(list, "max cache duration", this._friendly_duration(cfg.MaxCacheDuration)); + } + if (cfg.MaxProjectStoreDuration) + { + this._prop(list, "max project duration", this._friendly_duration(cfg.MaxProjectStoreDuration)); + } + if (cfg.MaxBuildStoreDuration) + { + this._prop(list, "max build duration", this._friendly_duration(cfg.MaxBuildStoreDuration)); + } + } + + if (gc.FullGC) + { + if (gc.FullGC.LastTime) + { + this._prop(list, "last full gc", this._friendly_timestamp(gc.FullGC.LastTime)); + } + if (gc.FullGC.TimeToNext) + { + this._prop(list, "next full gc", this._friendly_duration(gc.FullGC.TimeToNext)); + } + } + + if (gc.LightweightGC) + { + if (gc.LightweightGC.LastTime) + { + this._prop(list, "last lightweight gc", this._friendly_timestamp(gc.LightweightGC.LastTime)); + } + if (gc.LightweightGC.TimeToNext) + { + this._prop(list, "next lightweight gc", this._friendly_duration(gc.LightweightGC.TimeToNext)); + } + } + } + } + + _prop(parent, label, value) + { + const row = parent.tag().classify("info-prop"); + row.tag().classify("info-prop-label").text(label); + const val = row.tag().classify("info-prop-value"); + const str = String(value); + if (str.match(/^[A-Za-z]:[\\/]/) || str.startsWith("/")) + { + val.tag("a").text(str).attr("href", "vscode://" + str.replace(/\\/g, "/")); + } + else + { + val.text(str); + } + } + + _friendly_timestamp(value) + { + const d = new Date(value); + if (isNaN(d.getTime())) + { + return String(value); + } + return d.toLocaleString(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); + } + + _friendly_duration(value) + { + if (typeof value === "number") + { + return this._format_duration(value); + } + + const str = String(value); + const match = str.match(/^[+-]?(?:(\d+)\.)?(\d+):(\d+):(\d+)(?:\.(\d+))?$/); + if (!match) + { + return str; + } + + const days = parseInt(match[1] || "0", 10); + const hours = parseInt(match[2], 10); + const minutes = parseInt(match[3], 10); + const seconds = parseInt(match[4], 10); + const total_seconds = days * 86400 + hours * 3600 + minutes * 60 + seconds; + + return this._format_duration(total_seconds * 1000); + } + + _format_duration(ms) + { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) + { + return `${days}d ${hours % 24}h ${minutes % 60}m`; + } + if (hours > 0) + { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) + { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; + } +} diff --git a/src/zenserver/frontend/html/pages/map.js b/src/zenserver/frontend/html/pages/map.js index 58046b255..ac8f298aa 100644 --- a/src/zenserver/frontend/html/pages/map.js +++ b/src/zenserver/frontend/html/pages/map.js @@ -116,9 +116,9 @@ export class Page extends ZenPage for (const name of sorted_keys) nodes.push(new_nodes[name] / branch_size); - var stats = Friendly.kib(branch_size); + var stats = Friendly.bytes(branch_size); stats += " / "; - stats += Friendly.kib(total_size); + stats += Friendly.bytes(total_size); stats += " ("; stats += 0|((branch_size * 100) / total_size); stats += "%)"; diff --git a/src/zenserver/frontend/html/pages/metrics.js b/src/zenserver/frontend/html/pages/metrics.js new file mode 100644 index 000000000..e7a2eca67 --- /dev/null +++ b/src/zenserver/frontend/html/pages/metrics.js @@ -0,0 +1,232 @@ +// 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 { PropTable, Toolbar } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +class TemporalStat +{ + constructor(data, as_bytes) + { + this._data = data; + this._as_bytes = as_bytes; + } + + toString() + { + const columns = [ + /* count */ {}, + /* rate */ {}, + /* t */ {}, {}, + ]; + const data = this._data; + for (var key in data) + { + var out = columns[0]; + if (key.startsWith("rate_")) out = columns[1]; + else if (key.startsWith("t_p")) out = columns[3]; + else if (key.startsWith("t_")) out = columns[2]; + out[key] = data[key]; + } + + var friendly = this._as_bytes ? Friendly.bytes : Friendly.sep; + + var content = ""; + for (var i = 0; i < columns.length; ++i) + { + const column = columns[i]; + for (var key in column) + { + var value = column[key]; + if (i) + { + value = Friendly.sep(value, 2); + key = key.padStart(9); + content += key + ": " + value; + } + else + content += friendly(value); + content += "\r\n"; + } + } + + return content; + } + + tag() + { + return "pre"; + } +} + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("metrics"); + + const metrics_section = this.add_section("metrics"); + const top_toolbar = metrics_section.add_widget(Toolbar); + const tb_right = top_toolbar.right(); + this._refresh_label = tb_right.add("", "label"); + this._pause_btn = tb_right.add("pause").on_click(() => this._toggle_pause()); + + this._paused = false; + this._last_refresh = Date.now(); + this._provider_views = []; + + const providers_data = await new Fetcher().resource("stats").json(); + const providers = providers_data["providers"] || []; + + const stats_list = await Promise.all(providers.map((provider) => + new Fetcher() + .resource("stats", provider) + .param("cidstorestats", "true") + .param("cachestorestats", "true") + .json() + .then((stats) => ({ provider, stats })) + )); + + for (const { provider, stats } of stats_list) + { + this._condense(stats); + this._provider_views.push(this._render_provider(provider, stats)); + } + + this._last_refresh = Date.now(); + this._update_refresh_label(); + + this._timer_id = setInterval(() => this._refresh(), 5000); + this._tick_id = setInterval(() => this._update_refresh_label(), 1000); + + document.addEventListener("visibilitychange", () => { + if (document.hidden) + this._pause_timer(false); + else if (!this._paused) + this._resume_timer(); + }); + } + + _render_provider(provider, stats) + { + const section = this.add_section(provider); + const toolbar = section.add_widget(Toolbar); + + toolbar.right().add("detailed →").on_click(() => { + window.location = "?page=stat&provider=" + provider; + }); + + const table = section.add_widget(PropTable); + let current_stats = stats; + let current_category = undefined; + + const show_category = (cat) => { + current_category = cat; + table.clear(); + table.add_object(current_stats[cat], true, 3); + }; + + var first = undefined; + for (var name in stats) + { + first = first || name; + toolbar.left().add(name).on_click(show_category, name); + } + + if (first) + show_category(first); + + return { + provider, + set_stats: (new_stats) => { + current_stats = new_stats; + if (current_category && current_stats[current_category]) + show_category(current_category); + }, + }; + } + + async _refresh() + { + const updates = await Promise.all(this._provider_views.map((view) => + new Fetcher() + .resource("stats", view.provider) + .param("cidstorestats", "true") + .param("cachestorestats", "true") + .json() + .then((stats) => ({ view, stats })) + )); + + for (const { view, stats } of updates) + { + this._condense(stats); + view.set_stats(stats); + } + + this._last_refresh = Date.now(); + this._update_refresh_label(); + } + + _update_refresh_label() + { + const elapsed = Math.floor((Date.now() - this._last_refresh) / 1000); + this._refresh_label.inner().textContent = "refreshed " + elapsed + "s ago"; + } + + _toggle_pause() + { + if (this._paused) + this._resume_timer(); + else + this._pause_timer(true); + } + + _pause_timer(user_paused=true) + { + clearInterval(this._timer_id); + this._timer_id = undefined; + if (user_paused) + { + this._paused = true; + this._pause_btn.inner().textContent = "resume"; + } + } + + _resume_timer() + { + this._paused = false; + this._pause_btn.inner().textContent = "pause"; + this._timer_id = setInterval(() => this._refresh(), 5000); + this._refresh(); + } + + _condense(stats) + { + const impl = function(node) + { + for (var name in node) + { + const candidate = node[name]; + if (!(candidate instanceof Object)) + continue; + + if (candidate["rate_mean"] != undefined) + { + const as_bytes = (name.indexOf("bytes") >= 0); + node[name] = new TemporalStat(candidate, as_bytes); + continue; + } + + impl(candidate); + } + } + + for (var name in stats) + impl(stats[name]); + } +} diff --git a/src/zenserver/frontend/html/pages/oplog.js b/src/zenserver/frontend/html/pages/oplog.js index a286f8651..fb857affb 100644 --- a/src/zenserver/frontend/html/pages/oplog.js +++ b/src/zenserver/frontend/html/pages/oplog.js @@ -81,7 +81,7 @@ export class Page extends ZenPage const right = nav.right(); right.add(Friendly.sep(oplog_info["opcount"])); - right.add("(" + Friendly.kib(oplog_info["totalsize"]) + ")"); + right.add("(" + Friendly.bytes(oplog_info["totalsize"]) + ")"); right.sep(); var search_input = right.add("search:", "label").tag("input") diff --git a/src/zenserver/frontend/html/pages/orchestrator.js b/src/zenserver/frontend/html/pages/orchestrator.js new file mode 100644 index 000000000..24805c722 --- /dev/null +++ b/src/zenserver/frontend/html/pages/orchestrator.js @@ -0,0 +1,405 @@ +// 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 +{ + async main() + { + this.set_title("orchestrator"); + + // Agents section + const agents_section = this._collapsible_section("Compute Agents"); + this._agents_host = agents_section; + this._agents_table = null; + + // Clients section + const clients_section = this._collapsible_section("Connected Clients"); + this._clients_host = clients_section; + this._clients_table = null; + + // Event history + const history_section = this._collapsible_section("Worker Events"); + this._history_host = history_section; + this._history_table = null; + + const client_history_section = this._collapsible_section("Client Events"); + this._client_history_host = client_history_section; + this._client_history_table = null; + + this._ws_paused = false; + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) {} + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + // Initial fetch + await this._fetch_all(); + + // Connect WebSocket for live updates, fall back to polling + this._connect_ws(); + } + + _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; + } + + async _fetch_all() + { + try + { + const [agents, history, clients, client_history] = await Promise.all([ + new Fetcher().resource("/orch/agents").json(), + new Fetcher().resource("/orch/history").param("limit", "50").json().catch(() => null), + new Fetcher().resource("/orch/clients").json().catch(() => null), + new Fetcher().resource("/orch/clients/history").param("limit", "50").json().catch(() => null), + ]); + + this._render_agents(agents); + if (history) + { + this._render_history(history.events || []); + } + if (clients) + { + this._render_clients(clients.clients || []); + } + if (client_history) + { + this._render_client_history(client_history.client_events || []); + } + } + catch (e) { /* service unavailable */ } + } + + _connect_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/orch/ws`); + + ws.onopen = () => { + if (this._poll_timer) + { + clearInterval(this._poll_timer); + this._poll_timer = null; + } + }; + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const data = JSON.parse(ev.data); + this._render_agents(data); + if (data.events) + { + this._render_history(data.events); + } + if (data.clients) + { + this._render_clients(data.clients); + } + if (data.client_events) + { + this._render_client_history(data.client_events); + } + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { + this._start_polling(); + setTimeout(() => this._connect_ws(), 3000); + }; + + ws.onerror = () => { /* onclose will fire */ }; + } + catch (e) + { + this._start_polling(); + } + } + + _start_polling() + { + if (!this._poll_timer) + { + this._poll_timer = setInterval(() => this._fetch_all(), 2000); + } + } + + _render_agents(data) + { + const workers = data.workers || []; + + if (this._agents_table) + { + this._agents_table.clear(); + } + else + { + this._agents_table = this._agents_host.add_widget( + Table, + ["hostname", "CPUs", "CPU usage", "memory", "queues", "pending", "running", "completed", "traffic", "last seen"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + if (workers.length === 0) + { + return; + } + + let totalCpus = 0, totalWeightedCpu = 0; + let totalMemUsed = 0, totalMemTotal = 0; + let totalQueues = 0, totalPending = 0, totalRunning = 0, totalCompleted = 0; + let totalRecv = 0, totalSent = 0; + + for (const w of workers) + { + const cpus = w.cpus || 0; + const cpuUsage = w.cpu_usage; + const memUsed = w.memory_used || 0; + const memTotal = w.memory_total || 0; + const queues = w.active_queues || 0; + const pending = w.actions_pending || 0; + const running = w.actions_running || 0; + const completed = w.actions_completed || 0; + const recv = w.bytes_received || 0; + const sent = w.bytes_sent || 0; + + totalCpus += cpus; + if (cpus > 0 && typeof cpuUsage === "number") + { + totalWeightedCpu += cpuUsage * cpus; + } + totalMemUsed += memUsed; + totalMemTotal += memTotal; + totalQueues += queues; + totalPending += pending; + totalRunning += running; + totalCompleted += completed; + totalRecv += recv; + totalSent += sent; + + const hostname = w.hostname || ""; + const row = this._agents_table.add_row( + hostname, + cpus > 0 ? Friendly.sep(cpus) : "-", + typeof cpuUsage === "number" ? cpuUsage.toFixed(1) + "%" : "-", + memTotal > 0 ? Friendly.bytes(memUsed) + " / " + Friendly.bytes(memTotal) : "-", + queues > 0 ? Friendly.sep(queues) : "-", + Friendly.sep(pending), + Friendly.sep(running), + Friendly.sep(completed), + this._format_traffic(recv, sent), + this._format_last_seen(w.dt), + ); + + // Link hostname to worker dashboard + if (w.uri) + { + const cell = row.get_cell(0); + cell.inner().textContent = ""; + cell.tag("a").text(hostname).attr("href", w.uri + "/dashboard/compute/").attr("target", "_blank"); + } + } + + // Total row + const total = this._agents_table.add_row( + "TOTAL", + Friendly.sep(totalCpus), + "", + totalMemTotal > 0 ? Friendly.bytes(totalMemUsed) + " / " + Friendly.bytes(totalMemTotal) : "-", + Friendly.sep(totalQueues), + Friendly.sep(totalPending), + Friendly.sep(totalRunning), + Friendly.sep(totalCompleted), + this._format_traffic(totalRecv, totalSent), + "", + ); + total.get_cell(0).style("fontWeight", "bold"); + } + + _render_clients(clients) + { + if (this._clients_table) + { + this._clients_table.clear(); + } + else + { + this._clients_table = this._clients_host.add_widget( + Table, + ["client ID", "hostname", "address", "last seen"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const c of clients) + { + this._clients_table.add_row( + c.id || "", + c.hostname || "", + c.address || "", + this._format_last_seen(c.dt), + ); + } + } + + _render_history(events) + { + if (this._history_table) + { + this._history_table.clear(); + } + else + { + this._history_table = this._history_host.add_widget( + Table, + ["time", "event", "worker", "hostname"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const evt of events) + { + this._history_table.add_row( + this._format_timestamp(evt.ts), + evt.type || "", + evt.worker_id || "", + evt.hostname || "", + ); + } + } + + _render_client_history(events) + { + if (this._client_history_table) + { + this._client_history_table.clear(); + } + else + { + this._client_history_table = this._client_history_host.add_widget( + Table, + ["time", "event", "client", "hostname"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const evt of events) + { + this._client_history_table.add_row( + this._format_timestamp(evt.ts), + evt.type || "", + evt.client_id || "", + evt.hostname || "", + ); + } + } + + _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); + } + + _format_last_seen(dtMs) + { + if (dtMs == null) + { + return "-"; + } + const seconds = Math.floor(dtMs / 1000); + if (seconds < 60) + { + return seconds + "s ago"; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) + { + return minutes + "m " + (seconds % 60) + "s ago"; + } + const hours = Math.floor(minutes / 60); + return hours + "h " + (minutes % 60) + "m ago"; + } + + _format_traffic(recv, sent) + { + if (!recv && !sent) + { + return "-"; + } + return Friendly.bytes(recv) + " / " + Friendly.bytes(sent); + } + + _format_timestamp(ts) + { + if (!ts) + { + return "-"; + } + let date; + if (typeof ts === "number") + { + // .NET-style ticks: convert to Unix ms + const unixMs = (ts - 621355968000000000) / 10000; + date = new Date(unixMs); + } + else + { + date = new Date(ts); + } + if (isNaN(date.getTime())) + { + return "-"; + } + return date.toLocaleTimeString(); + } +} diff --git a/src/zenserver/frontend/html/pages/page.js b/src/zenserver/frontend/html/pages/page.js index 592b699dc..dd8032c28 100644 --- a/src/zenserver/frontend/html/pages/page.js +++ b/src/zenserver/frontend/html/pages/page.js @@ -70,14 +70,41 @@ export class ZenPage extends PageBase add_branding(parent) { - var root = parent.tag().id("branding"); + 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(); + } - const logo_container = root.tag("div").id("logo"); - logo_container.tag("img").attr("src", "favicon.ico").id("zen_icon"); - logo_container.tag("span").id("zen_text").text("zenserver"); - logo_container.tag().id("go_home").on_click(() => window.location.search = ""); + async _poll_status() + { + try + { + var cbo = await new Fetcher().resource("/status/status").cbo(); + if (cbo) + { + var obj = cbo.as_object(); - root.tag("img").attr("src", "epicgames.ico").id("epic_logo"); + var hostname = obj.find("hostname"); + if (hostname) + { + this._banner.attr("tagline", "Local Storage Service \u2014 " + hostname.as_value()); + } + + 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) @@ -88,30 +115,34 @@ 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/compute/compute.html" }, - { base_uri: "/orch/", label: "Orchestrator", href: "/dashboard/compute/orchestrator.html" }, - { base_uri: "/hub/", label: "Hub", href: "/dashboard/compute/hub.html" }, + { 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" }, ]; + 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) => { 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)); - if (links.length === 0) - { - nav.inner().style.display = "none"; - return; - } - + // Insert service links before the Info link + const info_elem = this._info_link.inner(); for (const link of links) { - nav.tag("a").text(link.label).attr("href", link.href); + const a = document.createElement("a"); + a.textContent = link.label; + a.href = link.href; + info_elem.parentNode.insertBefore(a, info_elem); } - }).catch(() => { - nav.inner().style.display = "none"; - }); + }).catch(() => {}); } set_title(...args) diff --git a/src/zenserver/frontend/html/pages/project.js b/src/zenserver/frontend/html/pages/project.js index 42ae30c8c..3a7a45527 100644 --- a/src/zenserver/frontend/html/pages/project.js +++ b/src/zenserver/frontend/html/pages/project.js @@ -59,7 +59,7 @@ export class Page extends ZenPage info = await info; row.get_cell(1).text(info["markerpath"]); - row.get_cell(2).text(Friendly.kib(info["totalsize"])); + row.get_cell(2).text(Friendly.bytes(info["totalsize"])); row.get_cell(3).text(Friendly.sep(info["opcount"])); row.get_cell(4).text(info["expired"]); } 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"); + } +} diff --git a/src/zenserver/frontend/html/pages/sessions.js b/src/zenserver/frontend/html/pages/sessions.js new file mode 100644 index 000000000..95533aa96 --- /dev/null +++ b/src/zenserver/frontend/html/pages/sessions.js @@ -0,0 +1,61 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Table } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("sessions"); + + const data = await new Fetcher().resource("/sessions/").json(); + const sessions = data.sessions || []; + + const section = this.add_section("Sessions"); + + if (sessions.length === 0) + { + section.tag().classify("empty-state").text("No active sessions."); + return; + } + + const columns = [ + "id", + "created", + "updated", + "metadata", + ]; + const table = section.add_widget(Table, columns, Table.Flag_FitLeft); + + for (const session of sessions) + { + const created = session.created_at ? new Date(session.created_at).toLocaleString() : "-"; + const updated = session.updated_at ? new Date(session.updated_at).toLocaleString() : "-"; + const meta = this._format_metadata(session.metadata); + + const row = table.add_row( + session.id || "-", + created, + updated, + meta, + ); + } + } + + _format_metadata(metadata) + { + if (!metadata || Object.keys(metadata).length === 0) + { + return "-"; + } + + return Object.entries(metadata) + .map(([k, v]) => `${k}: ${v}`) + .join(", "); + } +} diff --git a/src/zenserver/frontend/html/pages/start.js b/src/zenserver/frontend/html/pages/start.js index 2cf12bf12..3a68a725d 100644 --- a/src/zenserver/frontend/html/pages/start.js +++ b/src/zenserver/frontend/html/pages/start.js @@ -13,109 +13,117 @@ export class Page extends ZenPage { async main() { + // Discover which services are available + const api_data = await new Fetcher().resource("/api/").json(); + const available = new Set((api_data.services || []).map(s => s.base_uri)); + // project list - var section = this.add_section("projects"); + var project_table = null; + if (available.has("/prj/")) + { + var section = this.add_section("Cooked Projects"); - section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("projects")); + section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("projects")); - var columns = [ - "name", - "project_dir", - "engine_dir", - "actions", - ]; - var project_table = section.add_widget(Table, columns); + var columns = [ + "name", + "project_dir", + "engine_dir", + "actions", + ]; + project_table = section.add_widget(Table, columns); - for (const project of await new Fetcher().resource("/prj/list").json()) - { - var row = project_table.add_row( - "", - project.ProjectRootDir, - project.EngineRootDir, - ); + var projects = await new Fetcher().resource("/prj/list").json(); + projects.sort((a, b) => (b.LastAccessTime || 0) - (a.LastAccessTime || 0)); + projects = projects.slice(0, 25); + projects.sort((a, b) => a.Id.localeCompare(b.Id)); - var cell = row.get_cell(0); - cell.tag().text(project.Id).on_click((x) => this.view_project(x), project.Id); + for (const project of projects) + { + var row = project_table.add_row( + "", + project.ProjectRootDir, + project.EngineRootDir, + ); + + var cell = row.get_cell(0); + cell.tag().text(project.Id).on_click((x) => this.view_project(x), project.Id); - var cell = row.get_cell(-1); - var action_tb = new Toolbar(cell, true); - action_tb.left().add("view").on_click((x) => this.view_project(x), project.Id); - action_tb.left().add("drop").on_click((x) => this.drop_project(x), project.Id); + var cell = row.get_cell(-1); + var action_tb = new Toolbar(cell, true); + action_tb.left().add("view").on_click((x) => this.view_project(x), project.Id); + action_tb.left().add("drop").on_click((x) => this.drop_project(x), project.Id); - row.attr("zs_name", project.Id); + row.attr("zs_name", project.Id); + } } // cache - var section = this.add_section("cache"); - - section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("z$")); - - columns = [ - "namespace", - "dir", - "buckets", - "entries", - "size disk", - "size mem", - "actions", - ] - var zcache_info = new Fetcher().resource("/z$/").json(); - const cache_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight); - for (const namespace of (await zcache_info)["Namespaces"]) + var cache_table = null; + if (available.has("/z$/")) { - new Fetcher().resource(`/z$/${namespace}/`).json().then((data) => { - const row = cache_table.add_row( - "", - data["Configuration"]["RootDir"], - data["Buckets"].length, - data["EntryCount"], - Friendly.kib(data["StorageSize"].DiskSize), - Friendly.kib(data["StorageSize"].MemorySize) - ); - var cell = row.get_cell(0); - cell.tag().text(namespace).on_click(() => this.view_zcache(namespace)); - row.get_cell(1).tag().text(namespace); + var section = this.add_section("Cache"); - cell = row.get_cell(-1); - const action_tb = new Toolbar(cell, true); - action_tb.left().add("view").on_click(() => this.view_zcache(namespace)); - action_tb.left().add("drop").on_click(() => this.drop_zcache(namespace)); + section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("z$")); - row.attr("zs_name", namespace); - }); + var columns = [ + "namespace", + "dir", + "buckets", + "entries", + "size disk", + "size mem", + "actions", + ]; + var zcache_info = await new Fetcher().resource("/z$/").json(); + cache_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight); + for (const namespace of zcache_info["Namespaces"] || []) + { + new Fetcher().resource(`/z$/${namespace}/`).json().then((data) => { + const row = cache_table.add_row( + "", + data["Configuration"]["RootDir"], + data["Buckets"].length, + data["EntryCount"], + Friendly.bytes(data["StorageSize"].DiskSize), + Friendly.bytes(data["StorageSize"].MemorySize) + ); + var cell = row.get_cell(0); + cell.tag().text(namespace).on_click(() => this.view_zcache(namespace)); + row.get_cell(1).tag().text(namespace); + + cell = row.get_cell(-1); + const action_tb = new Toolbar(cell, true); + action_tb.left().add("view").on_click(() => this.view_zcache(namespace)); + action_tb.left().add("drop").on_click(() => this.drop_zcache(namespace)); + + row.attr("zs_name", namespace); + }); + } } - // stats + // stats tiles const safe_lookup = (obj, path, pretty=undefined) => { const ret = path.split(".").reduce((a,b) => a && a[b], obj); - if (ret === undefined) return "-"; + if (ret === undefined) return undefined; return pretty ? pretty(ret) : ret; }; - section = this.add_section("stats"); - columns = [ - "name", - "req count", - "size disk", - "size mem", - "cid total", - ]; - const stats_table = section.add_widget(Table, columns, Table.Flag_PackRight); - var providers = new Fetcher().resource("stats").json(); - for (var provider of (await providers)["providers"]) - { - var stats = await new Fetcher().resource("stats", provider).json(); - var size_stat = (stats.store || stats.cache); - var values = [ - "", - safe_lookup(stats, "requests.count"), - safe_lookup(size_stat, "size.disk", Friendly.kib), - safe_lookup(size_stat, "size.memory", Friendly.kib), - safe_lookup(stats, "cid.size.total"), - ]; - row = stats_table.add_row(...values); - row.get_cell(0).tag().text(provider).on_click((x) => this.view_stat(x), provider); - } + var section = this.add_section("Stats"); + section.tag().classify("dropall").text("metrics dashboard →").on_click(() => { + window.location = "?page=metrics"; + }); + + var providers_data = await new Fetcher().resource("stats").json(); + var provider_list = providers_data["providers"] || []; + var all_stats = {}; + await Promise.all(provider_list.map(async (provider) => { + all_stats[provider] = await new Fetcher().resource("stats", provider).json(); + })); + + this._stats_grid = section.tag().classify("grid").classify("stats-tiles"); + this._safe_lookup = safe_lookup; + this._render_stats(all_stats); // version var ver_tag = this.tag().id("version"); @@ -125,6 +133,159 @@ export class Page extends ZenPage this._project_table = project_table; this._cache_table = cache_table; + + // WebSocket for live stats updates + this._connect_stats_ws(); + } + + _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); + this._render_stats(all_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(all_stats) + { + const grid = this._stats_grid; + const safe_lookup = this._safe_lookup; + + // Clear existing tiles + grid.inner().innerHTML = ""; + + // HTTP tile — aggregate request stats across all providers + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("HTTP"); + const columns = tile.tag().classify("tile-columns"); + + // Left column: request stats + const left = columns.tag().classify("tile-metrics"); + + let total_requests = 0; + let total_rate = 0; + for (const p in all_stats) + { + total_requests += (safe_lookup(all_stats[p], "requests.count") || 0); + total_rate += (safe_lookup(all_stats[p], "requests.rate_1") || 0); + } + + this._add_tile_metric(left, Friendly.sep(total_requests), "total requests", true); + if (total_rate > 0) + this._add_tile_metric(left, Friendly.sep(total_rate, 1) + "/s", "req/sec (1m)"); + + // Right column: websocket stats + const ws = all_stats["http"] ? (all_stats["http"]["websockets"] || {}) : {}; + const right = columns.tag().classify("tile-metrics"); + + this._add_tile_metric(right, Friendly.sep(ws.active_connections || 0), "ws connections", true); + const ws_frames = (ws.frames_received || 0) + (ws.frames_sent || 0); + if (ws_frames > 0) + this._add_tile_metric(right, Friendly.sep(ws_frames), "ws frames"); + const ws_bytes = (ws.bytes_received || 0) + (ws.bytes_sent || 0); + if (ws_bytes > 0) + this._add_tile_metric(right, Friendly.bytes(ws_bytes), "ws traffic"); + + tile.on_click(() => { window.location = "?page=metrics"; }); + } + + // Cache tile (z$) + if (all_stats["z$"]) + { + const s = all_stats["z$"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Cache"); + const body = tile.tag().classify("tile-metrics"); + + const hits = safe_lookup(s, "cache.hits") || 0; + const misses = safe_lookup(s, "cache.misses") || 0; + const ratio = (hits + misses) > 0 ? ((hits / (hits + misses)) * 100).toFixed(1) + "%" : "-"; + + this._add_tile_metric(body, ratio, "hit ratio", true); + this._add_tile_metric(body, safe_lookup(s, "cache.size.disk", Friendly.bytes) || "-", "disk"); + this._add_tile_metric(body, safe_lookup(s, "cache.size.memory", Friendly.bytes) || "-", "memory"); + + tile.on_click(() => { window.location = "?page=stat&provider=z$"; }); + } + + // Project Store tile (prj) + if (all_stats["prj"]) + { + const s = all_stats["prj"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Project Store"); + const body = tile.tag().classify("tile-metrics"); + + this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests", true); + this._add_tile_metric(body, safe_lookup(s, "store.size.disk", Friendly.bytes) || "-", "disk"); + + tile.on_click(() => { window.location = "?page=stat&provider=prj"; }); + } + + // Build Store tile (builds) + if (all_stats["builds"]) + { + const s = all_stats["builds"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Build Store"); + const body = tile.tag().classify("tile-metrics"); + + this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests", true); + this._add_tile_metric(body, safe_lookup(s, "store.size.disk", Friendly.bytes) || "-", "disk"); + + tile.on_click(() => { window.location = "?page=stat&provider=builds"; }); + } + + // Workspace tile (ws) + if (all_stats["ws"]) + { + const s = all_stats["ws"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Workspace"); + const body = tile.tag().classify("tile-metrics"); + + this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests", true); + this._add_tile_metric(body, safe_lookup(s, "workspaces.filescount", Friendly.sep) || "-", "files"); + + tile.on_click(() => { window.location = "?page=stat&provider=ws"; }); + } + } + + _add_tile_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); } view_stat(provider) diff --git a/src/zenserver/frontend/html/pages/stat.js b/src/zenserver/frontend/html/pages/stat.js index d6c7fa8e8..4f020ac5e 100644 --- a/src/zenserver/frontend/html/pages/stat.js +++ b/src/zenserver/frontend/html/pages/stat.js @@ -33,7 +33,7 @@ class TemporalStat out[key] = data[key]; } - var friendly = this._as_bytes ? Friendly.kib : Friendly.sep; + var friendly = this._as_bytes ? Friendly.bytes : Friendly.sep; var content = ""; for (var i = 0; i < columns.length; ++i) diff --git a/src/zenserver/frontend/html/pages/tree.js b/src/zenserver/frontend/html/pages/tree.js index 08a578492..b5fece5a3 100644 --- a/src/zenserver/frontend/html/pages/tree.js +++ b/src/zenserver/frontend/html/pages/tree.js @@ -106,7 +106,7 @@ export class Page extends ZenPage for (var i = 0; i < 2; ++i) { - const size = Friendly.kib(new_nodes[name][i]); + const size = Friendly.bytes(new_nodes[name][i]); info.tag().text(size); } diff --git a/src/zenserver/frontend/html/pages/zcache.js b/src/zenserver/frontend/html/pages/zcache.js index 974893b21..d8bdc892a 100644 --- a/src/zenserver/frontend/html/pages/zcache.js +++ b/src/zenserver/frontend/html/pages/zcache.js @@ -27,8 +27,8 @@ export class Page extends ZenPage cfg_table.add_object(info["Configuration"], true); - storage_table.add_property("disk", Friendly.kib(info["StorageSize"]["DiskSize"])); - storage_table.add_property("mem", Friendly.kib(info["StorageSize"]["MemorySize"])); + storage_table.add_property("disk", Friendly.bytes(info["StorageSize"]["DiskSize"])); + storage_table.add_property("mem", Friendly.bytes(info["StorageSize"]["MemorySize"])); storage_table.add_property("entries", Friendly.sep(info["EntryCount"])); var column_names = ["name", "disk", "mem", "entries", "actions"]; @@ -41,8 +41,8 @@ export class Page extends ZenPage { const row = bucket_table.add_row(bucket); new Fetcher().resource(`/z$/${namespace}/${bucket}`).json().then((data) => { - row.get_cell(1).text(Friendly.kib(data["StorageSize"]["DiskSize"])); - row.get_cell(2).text(Friendly.kib(data["StorageSize"]["MemorySize"])); + row.get_cell(1).text(Friendly.bytes(data["StorageSize"]["DiskSize"])); + row.get_cell(2).text(Friendly.bytes(data["StorageSize"]["MemorySize"])); row.get_cell(3).text(Friendly.sep(data["DiskEntryCount"])); const cell = row.get_cell(-1); |