diff options
| author | Dan Engelbrecht <[email protected]> | 2026-03-27 16:23:59 +0100 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-03-27 16:23:59 +0100 |
| commit | aff16da9a634ff6869b0394bf936bbb45096ad54 (patch) | |
| tree | 3d88cc842e6a87c3e4d07cc838a31b8dbabfce9a /src/zenserver/frontend | |
| parent | remove CPR HTTP client backend (#894) (diff) | |
| download | zen-aff16da9a634ff6869b0394bf936bbb45096ad54.tar.xz zen-aff16da9a634ff6869b0394bf936bbb45096ad54.zip | |
dashboard improvements (#896)
- Feature: Added Workspaces dashboard page with HTTP request stats and per-workspace metrics
- Feature: Added Build Storage dashboard page with service-specific HTTP request stats
- Improvement: Front page now shows Hub and Object Store activity tiles; HTTP panel is fixed above the tiles grid
- Improvement: HTTP stats tiles now include 5m/15m rates and p999/max latency across all service pages
Diffstat (limited to 'src/zenserver/frontend')
| -rw-r--r-- | src/zenserver/frontend/frontend.cpp | 10 | ||||
| -rw-r--r-- | src/zenserver/frontend/frontend.h | 1 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/builds.js | 88 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/hub.js | 15 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/objectstore.js | 48 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/page.js | 72 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/projects.js | 50 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/start.js | 115 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/workspaces.js | 236 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/zen.css | 84 |
10 files changed, 578 insertions, 141 deletions
diff --git a/src/zenserver/frontend/frontend.cpp b/src/zenserver/frontend/frontend.cpp index fa7b580e8..52ec5b8b3 100644 --- a/src/zenserver/frontend/frontend.cpp +++ b/src/zenserver/frontend/frontend.cpp @@ -239,11 +239,17 @@ HttpFrontendService::HandleRequest(zen::HttpServerRequest& Request) void HttpFrontendService::HandleStatsRequest(HttpServerRequest& Request) { + Request.WriteResponse(HttpResponseCode::OK, CollectStats()); +} + +CbObject +HttpFrontendService::CollectStats() +{ + ZEN_TRACE_CPU("HttpFrontendService::Stats"); CbObjectWriter Cbo; EmitSnapshot("requests", m_HttpRequests, Cbo); - - Request.WriteResponse(HttpResponseCode::OK, Cbo.Save()); + return Cbo.Save(); } uint64_t diff --git a/src/zenserver/frontend/frontend.h b/src/zenserver/frontend/frontend.h index 541e6213b..e0b86f1de 100644 --- a/src/zenserver/frontend/frontend.h +++ b/src/zenserver/frontend/frontend.h @@ -22,6 +22,7 @@ public: virtual void HandleRequest(HttpServerRequest& Request) override; virtual void HandleStatusRequest(HttpServerRequest& Request) override; virtual void HandleStatsRequest(HttpServerRequest& Request) override; + virtual CbObject CollectStats() override; virtual uint64_t GetActivityCounter() override; private: diff --git a/src/zenserver/frontend/html/pages/builds.js b/src/zenserver/frontend/html/pages/builds.js new file mode 100644 index 000000000..095f0bf29 --- /dev/null +++ b/src/zenserver/frontend/html/pages/builds.js @@ -0,0 +1,88 @@ +// 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 +{ + generate_crumbs() {} + + async main() + { + this.set_title("build store"); + + // Build Store Stats + const stats_section = this.add_section("Build Store Stats"); + stats_section.tag().classify("dropall").text("raw yaml \u2192").on_click(() => { + window.open("/stats/builds.yaml", "_blank"); + }); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + + const stats = await new Fetcher().resource("stats", "builds").json(); + if (stats) + { + this._render_stats(stats); + } + + this.connect_stats_ws((all_stats) => { + const s = all_stats["builds"]; + if (s) + { + this._render_stats(s); + } + }); + } + + _render_stats(stats) + { + const grid = this._stats_grid; + const safe = (obj, path) => path.split(".").reduce((a, b) => a && a[b], obj); + + grid.inner().innerHTML = ""; + + // HTTP Requests tile + this._render_http_requests_tile(grid, safe(stats, "requests"), safe(stats, "store.badrequestcount") || 0); + + // Build Store tile + { + const blobs = safe(stats, "store.blobs"); + const metadata = safe(stats, "store.metadata"); + if (blobs || metadata) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Build Store"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + this._metric(left, Friendly.bytes(safe(stats, "store.size.disk") || 0), "disk", true); + if (blobs) + { + this._metric(left, Friendly.sep(blobs.count || 0), "blobs"); + this._metric(left, Friendly.sep(blobs.readcount || 0), "blob reads"); + this._metric(left, Friendly.sep(blobs.writecount || 0), "blob writes"); + const blobHitRatio = (blobs.readcount || 0) > 0 + ? (((blobs.hitcount || 0) / blobs.readcount) * 100).toFixed(1) + "%" + : "-"; + this._metric(left, blobHitRatio, "blob hit ratio"); + } + + const right = columns.tag().classify("tile-metrics"); + if (metadata) + { + this._metric(right, Friendly.sep(metadata.count || 0), "metadata entries", true); + this._metric(right, Friendly.sep(metadata.readcount || 0), "meta reads"); + this._metric(right, Friendly.sep(metadata.writecount || 0), "meta writes"); + const metaHitRatio = (metadata.readcount || 0) > 0 + ? (((metadata.hitcount || 0) / metadata.readcount) * 100).toFixed(1) + "%" + : "-"; + this._metric(right, metaHitRatio, "meta hit ratio"); + } + } + } + } + +} diff --git a/src/zenserver/frontend/html/pages/hub.js b/src/zenserver/frontend/html/pages/hub.js index 149a5c79c..78e3a090c 100644 --- a/src/zenserver/frontend/html/pages/hub.js +++ b/src/zenserver/frontend/html/pages/hub.js @@ -178,7 +178,7 @@ export class Page extends ZenPage try { const [stats, status] = await Promise.all([ - new Fetcher().resource("/hub/stats").json(), + new Fetcher().resource("stats", "hub").json(), new Fetcher().resource("/hub/status").json(), ]); @@ -198,6 +198,9 @@ export class Page extends ZenPage const max = data.maxInstanceCount || 0; const limit = data.instanceLimit || 0; + // HTTP Requests tile + this._render_http_requests_tile(grid, data.requests); + { const tile = grid.tag().classify("card").classify("stats-tile"); tile.tag().classify("card-title").text("Active Modules"); @@ -611,14 +614,4 @@ export class Page extends ZenPage await fetch(`/hub/modules/${moduleId}/${action}`, { method: "POST" }); } - _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/objectstore.js b/src/zenserver/frontend/html/pages/objectstore.js index 69e0a91b3..6b4890614 100644 --- a/src/zenserver/frontend/html/pages/objectstore.js +++ b/src/zenserver/frontend/html/pages/objectstore.js @@ -30,13 +30,16 @@ export class Page extends ZenPage { try { - const data = await new Fetcher().resource("/obj/").json(); - this._render(data); + const [data, stats] = await Promise.all([ + new Fetcher().resource("/obj/").json(), + new Fetcher().resource("stats", "obj").json().catch(() => null), + ]); + this._render(data, stats); } catch (e) { /* service unavailable */ } } - _render(data) + _render(data, stats) { const buckets = data.buckets || []; @@ -53,32 +56,17 @@ export class Page extends ZenPage const total_objects = buckets.reduce((sum, b) => sum + (b.object_count || 0), 0); const total_size = buckets.reduce((sum, b) => sum + (b.size || 0), 0); - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Buckets"); - const body = tile.tag().classify("tile-metrics"); - this._metric(body, Friendly.sep(buckets.length), "total", true); - } - - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Objects"); - const body = tile.tag().classify("tile-metrics"); - this._metric(body, Friendly.sep(total_objects), "total", true); - } + // HTTP Requests tile + this._render_http_requests_tile(grid, stats && stats.requests); { const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Storage"); + tile.tag().classify("card-title").text("Object Store"); const body = tile.tag().classify("tile-metrics"); - this._metric(body, Friendly.bytes(total_size), "total size", true); - } - - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Served"); - const body = tile.tag().classify("tile-metrics"); - this._metric(body, Friendly.bytes(data.total_bytes_served || 0), "total bytes served", true); + this._metric(body, Friendly.sep(buckets.length), "buckets", true); + this._metric(body, Friendly.sep(total_objects), "objects"); + this._metric(body, Friendly.bytes(total_size), "storage"); + this._metric(body, Friendly.bytes(data.total_bytes_served || 0), "bytes served"); } } @@ -219,14 +207,4 @@ export class Page extends ZenPage } } - _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/page.js b/src/zenserver/frontend/html/pages/page.js index d969d651d..cf8d3e3dd 100644 --- a/src/zenserver/frontend/html/pages/page.js +++ b/src/zenserver/frontend/html/pages/page.js @@ -4,6 +4,7 @@ import { WidgetHost } from "../util/widgets.js" import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" //////////////////////////////////////////////////////////////////////////////// export class PageBase extends WidgetHost @@ -148,8 +149,10 @@ export class ZenPage extends PageBase const service_dashboards = [ { base_uri: "/sessions/", label: "Sessions", href: "/dashboard/?page=sessions" }, { base_uri: "/z$/", label: "Cache", href: "/dashboard/?page=cache" }, + { base_uri: "/builds/", label: "Build Store", href: "/dashboard/?page=builds" }, { base_uri: "/prj/", label: "Projects", href: "/dashboard/?page=projects" }, { base_uri: "/obj/", label: "Object Store", href: "/dashboard/?page=objectstore" }, + { base_uri: "/ws/", label: "Workspaces", href: "/dashboard/?page=workspaces" }, { base_uri: "/compute/", label: "Compute", href: "/dashboard/?page=compute" }, { base_uri: "/orch/", label: "Orchestrator", href: "/dashboard/?page=orchestrator" }, { base_uri: "/hub/", label: "Hub", href: "/dashboard/?page=hub" }, @@ -265,4 +268,73 @@ export class ZenPage extends PageBase new_crumb(auto_name); } + + _metric(parent, value, label, hero = false) + { + const m = parent.tag().classify("tile-metric"); + if (hero) + { + m.classify("tile-metric-hero"); + } + m.tag().classify("metric-value").text(value); + m.tag().classify("metric-label").text(label); + } + + _render_http_requests_tile(grid, req, bad_requests = undefined) + { + if (!req) + { + return; + } + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("HTTP Requests"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const reqData = req.requests || req; + this._metric(left, Friendly.sep(reqData.count || 0), "total requests", true); + if (reqData.rate_mean > 0) + { + this._metric(left, Friendly.sep(reqData.rate_mean, 1) + "/s", "req/sec (mean)"); + } + if (reqData.rate_1 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_1, 1) + "/s", "req/sec (1m)"); + } + if (reqData.rate_5 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_5, 1) + "/s", "req/sec (5m)"); + } + if (reqData.rate_15 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_15, 1) + "/s", "req/sec (15m)"); + } + if (bad_requests !== undefined) + { + this._metric(left, Friendly.sep(bad_requests), "bad requests"); + } + + const right = columns.tag().classify("tile-metrics"); + this._metric(right, Friendly.duration(reqData.t_avg || 0), "avg latency", true); + if (reqData.t_p75) + { + this._metric(right, Friendly.duration(reqData.t_p75), "p75"); + } + if (reqData.t_p95) + { + this._metric(right, Friendly.duration(reqData.t_p95), "p95"); + } + if (reqData.t_p99) + { + this._metric(right, Friendly.duration(reqData.t_p99), "p99"); + } + if (reqData.t_p999) + { + this._metric(right, Friendly.duration(reqData.t_p999), "p999"); + } + if (reqData.t_max) + { + this._metric(right, Friendly.duration(reqData.t_max), "max"); + } + } } diff --git a/src/zenserver/frontend/html/pages/projects.js b/src/zenserver/frontend/html/pages/projects.js index a3c0d1555..2469bf70b 100644 --- a/src/zenserver/frontend/html/pages/projects.js +++ b/src/zenserver/frontend/html/pages/projects.js @@ -159,44 +159,7 @@ export class Page extends ZenPage 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"); - } - } - } + this._render_http_requests_tile(grid, safe(stats, "requests"), safe(stats, "store.badrequestcount") || 0); // Store Operations tile { @@ -268,17 +231,6 @@ export class Page extends ZenPage } } - _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 diff --git a/src/zenserver/frontend/html/pages/start.js b/src/zenserver/frontend/html/pages/start.js index df70ea2f4..e5b4d14f1 100644 --- a/src/zenserver/frontend/html/pages/start.js +++ b/src/zenserver/frontend/html/pages/start.js @@ -36,6 +36,15 @@ export class Page extends ZenPage all_stats[provider] = await new Fetcher().resource("stats", provider).json(); })); + this._http_panel = section.tag().classify("card").classify("stats-tile").classify("stats-http-panel"); + this._http_panel.inner().addEventListener("click", () => { window.location = "?page=metrics"; }); + this._http_panel.tag().classify("http-title").text("HTTP"); + const req_section = this._http_panel.tag().classify("http-section"); + req_section.tag().classify("http-section-label").text("Requests"); + this._http_req_metrics = req_section.tag().classify("tile-metrics"); + const ws_section = this._http_panel.tag().classify("http-section"); + ws_section.tag().classify("http-section-label").text("Websockets"); + this._http_ws_metrics = ws_section.tag().classify("tile-metrics"); this._stats_grid = section.tag().classify("grid").classify("stats-tiles"); this._safe_lookup = safe_lookup; this._render_stats(all_stats); @@ -113,7 +122,6 @@ export class Page extends ZenPage ); 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); @@ -143,44 +151,43 @@ export class Page extends ZenPage const grid = this._stats_grid; const safe_lookup = this._safe_lookup; - // Clear existing tiles + // Clear and repopulate service tiles grid 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); - } + // HTTP panel — update metrics containers built once in main() + const left = this._http_req_metrics; + left.inner().innerHTML = ""; - 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)"); + 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); + } - // Right column: websocket stats - const ws = all_stats["http"] ? (all_stats["http"]["websockets"] || {}) : {}; - const right = columns.tag().classify("tile-metrics"); + 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)"); + } - 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"); + const right = this._http_ws_metrics; + right.inner().innerHTML = ""; - tile.on_click(() => { window.location = "?page=metrics"; }); + const ws = all_stats["http"] ? (all_stats["http"]["websockets"] || {}) : {}; + 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"); + } + // Cache tile (z$) if (all_stats["z$"]) @@ -198,7 +205,7 @@ export class Page extends ZenPage 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$"; }); + tile.inner().addEventListener("click", () => { window.location = "?page=stat&provider=z$"; }); } // Project Store tile (prj) @@ -210,9 +217,9 @@ export class Page extends ZenPage 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"); + this._add_tile_metric(body, safe_lookup(s, "project_count", Friendly.sep) || "-", "projects"); - tile.on_click(() => { window.location = "?page=stat&provider=prj"; }); + tile.inner().addEventListener("click", () => { window.location = "?page=stat&provider=prj"; }); } // Build Store tile (builds) @@ -226,7 +233,7 @@ export class Page extends ZenPage 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"; }); + tile.inner().addEventListener("click", () => { window.location = "?page=builds"; }); } // Proxy tile @@ -250,7 +257,37 @@ export class Page extends ZenPage this._add_tile_metric(body, Friendly.sep(mappings.length), "mappings"); this._add_tile_metric(body, Friendly.bytes(totalBytes), "traffic"); - tile.on_click(() => { window.location = "?page=proxy"; }); + tile.inner().addEventListener("click", () => { window.location = "?page=proxy"; }); + } + + // Hub tile + if (all_stats["hub"]) + { + const s = all_stats["hub"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Hub"); + const body = tile.tag().classify("tile-metrics"); + + const current = safe_lookup(s, "currentInstanceCount") || 0; + const limit = safe_lookup(s, "instanceLimit") || safe_lookup(s, "maxInstanceCount") || 0; + this._add_tile_metric(body, `${current} / ${limit}`, "instances", true); + this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests"); + + tile.inner().addEventListener("click", () => { window.location = "?page=stat&provider=hub"; }); + } + + // Object Store tile (obj) + if (all_stats["obj"]) + { + const s = all_stats["obj"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Object 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, "total_bytes_served", Friendly.bytes) || "-", "bytes served"); + + tile.inner().addEventListener("click", () => { window.location = "?page=stat&provider=obj"; }); } // Workspace tile (ws) @@ -262,9 +299,9 @@ export class Page extends ZenPage 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"); + this._add_tile_metric(body, safe_lookup(s, "workspaces", Friendly.sep) || "-", "workspaces"); - tile.on_click(() => { window.location = "?page=stat&provider=ws"; }); + tile.inner().addEventListener("click", () => { window.location = "?page=stat&provider=ws"; }); } } diff --git a/src/zenserver/frontend/html/pages/workspaces.js b/src/zenserver/frontend/html/pages/workspaces.js new file mode 100644 index 000000000..d31fd7373 --- /dev/null +++ b/src/zenserver/frontend/html/pages/workspaces.js @@ -0,0 +1,236 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("workspaces"); + + // Workspace Service Stats + const stats_section = this.add_section("Workspace Service Stats"); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + + const stats = await new Fetcher().resource("stats", "ws").json().catch(() => null); + if (stats) { this._render_stats(stats); } + + this.connect_stats_ws((all_stats) => { + const s = all_stats["ws"]; + if (s) { this._render_stats(s); } + }); + + const section = this.add_section("Workspaces"); + const host = section.tag(); + + // Toolbar: refresh button + const toolbar = host.tag().classify("module-bulk-bar"); + this._btn_refresh = toolbar.tag("button").classify("module-bulk-btn").inner(); + this._btn_refresh.textContent = "\u21BB Refresh"; + this._btn_refresh.addEventListener("click", () => this._do_refresh()); + + // Workspace table (raw DOM — in-place row updates require stable element refs) + const table = document.createElement("table"); + table.className = "module-table"; + const thead = document.createElement("thead"); + const hrow = document.createElement("tr"); + for (const label of ["WORKSPACE ID", "ROOT PATH"]) + { + const th = document.createElement("th"); + th.textContent = label; + hrow.appendChild(th); + } + thead.appendChild(hrow); + table.appendChild(thead); + this._tbody = document.createElement("tbody"); + table.appendChild(this._tbody); + host.inner().appendChild(table); + + // State + this._expanded = new Set(); // workspace ids with shares panel open + this._row_cache = new Map(); // workspace id -> row refs, for in-place DOM updates + this._loading = false; + + await this._load(); + } + + async _load() + { + if (this._loading) { return; } + this._loading = true; + this._btn_refresh.disabled = true; + try + { + const data = await new Fetcher().resource("/ws/").json(); + const workspaces = data.workspaces || []; + this._render(workspaces); + } + catch (e) { /* service unavailable */ } + finally + { + this._loading = false; + this._btn_refresh.disabled = false; + } + } + + async _do_refresh() + { + if (this._loading) { return; } + this._btn_refresh.disabled = true; + try + { + await new Fetcher().resource("/ws/refresh").text(); + } + catch (e) { /* ignore */ } + await this._load(); + } + + _render(workspaces) + { + const ws_map = new Map(workspaces.map(w => [w.id, w])); + + // Remove rows for workspaces no longer present + for (const [id, row] of this._row_cache) + { + if (!ws_map.has(id)) + { + row.tr.remove(); + row.detail_tr.remove(); + this._row_cache.delete(id); + this._expanded.delete(id); + } + } + + // Create or update rows, then reorder tbody to match response order. + // appendChild on an existing node moves it, so iterating in response order + // achieves correct ordering without touching rows already in the right position. + for (const ws of workspaces) + { + const id = ws.id || ""; + const shares = ws.shares || []; + + let row = this._row_cache.get(id); + if (row) + { + // Update in-place — preserves DOM node identity so expanded state is kept + row.root_path_node.nodeValue = ws.root_path || ""; + row.detail_tr.style.display = this._expanded.has(id) ? "" : "none"; + row.btn_expand.textContent = this._expanded.has(id) ? "\u25BE" : "\u25B8"; + const shares_json = JSON.stringify(shares); + if (shares_json !== row.shares_json) + { + row.shares_json = shares_json; + this._render_shares(row.sh_tbody, shares); + } + } + else + { + // Create new workspace row + const tr = document.createElement("tr"); + const detail_tr = document.createElement("tr"); + detail_tr.className = "module-metrics-row"; + detail_tr.style.display = this._expanded.has(id) ? "" : "none"; + + const btn_expand = document.createElement("button"); + btn_expand.className = "module-expand-btn"; + btn_expand.textContent = this._expanded.has(id) ? "\u25BE" : "\u25B8"; + btn_expand.addEventListener("click", () => { + if (this._expanded.has(id)) + { + this._expanded.delete(id); + detail_tr.style.display = "none"; + btn_expand.textContent = "\u25B8"; + } + else + { + this._expanded.add(id); + detail_tr.style.display = ""; + btn_expand.textContent = "\u25BE"; + } + }); + + const id_wrap = document.createElement("span"); + id_wrap.className = "ws-id-wrap"; + id_wrap.appendChild(btn_expand); + id_wrap.appendChild(document.createTextNode("\u00A0" + id)); + const td_id = document.createElement("td"); + td_id.appendChild(id_wrap); + tr.appendChild(td_id); + + const root_path_node = document.createTextNode(ws.root_path || ""); + const td_root = document.createElement("td"); + td_root.appendChild(root_path_node); + tr.appendChild(td_root); + + // Detail row: nested shares table + const sh_table = document.createElement("table"); + sh_table.className = "module-table ws-share-table"; + const sh_thead = document.createElement("thead"); + const sh_hrow = document.createElement("tr"); + for (const label of ["SHARE ID", "SHARE PATH", "ALIAS"]) + { + const th = document.createElement("th"); + th.textContent = label; + sh_hrow.appendChild(th); + } + sh_thead.appendChild(sh_hrow); + sh_table.appendChild(sh_thead); + const sh_tbody = document.createElement("tbody"); + sh_table.appendChild(sh_tbody); + const detail_td = document.createElement("td"); + detail_td.colSpan = 2; + detail_td.className = "ws-detail-cell"; + detail_td.appendChild(sh_table); + detail_tr.appendChild(detail_td); + + this._render_shares(sh_tbody, shares); + + row = { tr, detail_tr, root_path_node, sh_tbody, btn_expand, shares_json: JSON.stringify(shares) }; + this._row_cache.set(id, row); + } + + this._tbody.appendChild(row.tr); + this._tbody.appendChild(row.detail_tr); + } + } + + _render_stats(stats) + { + const grid = this._stats_grid; + grid.inner().innerHTML = ""; + + // HTTP Requests tile + this._render_http_requests_tile(grid, stats.requests); + } + + _render_shares(sh_tbody, shares) + { + sh_tbody.innerHTML = ""; + if (shares.length === 0) + { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = 3; + td.className = "ws-no-shares-cell"; + td.textContent = "No shares"; + tr.appendChild(td); + sh_tbody.appendChild(tr); + return; + } + for (const share of shares) + { + const tr = document.createElement("tr"); + for (const text of [share.id || "", share.share_path || "", share.alias || ""]) + { + const td = document.createElement("td"); + td.textContent = text; + tr.appendChild(td); + } + sh_tbody.appendChild(tr); + } + } +} diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css index b4f7270fc..d9f7491ea 100644 --- a/src/zenserver/frontend/html/zen.css +++ b/src/zenserver/frontend/html/zen.css @@ -803,18 +803,17 @@ zen-banner + zen-nav::part(nav-bar) { /* stats tiles -------------------------------------------------------------- */ -.stats-tiles { - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +.grid.stats-tiles { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); } .stats-tile { cursor: pointer; - transition: border-color 0.15s, background 0.15s; + transition: border-color 0.15s; } .stats-tile:hover { border-color: var(--theme_p0); - background: var(--theme_p4); } .stats-tile-detailed { @@ -873,6 +872,81 @@ zen-banner + zen-nav::part(nav-bar) { font-size: 28px; } +/* HTTP summary panel ------------------------------------------------------- */ + +.stats-http-panel { + display: grid; + grid-template-columns: 20% 1fr 1fr; + align-items: center; + margin-bottom: 16px; +} + +.http-title { + font-size: 22px; + font-weight: 700; + color: var(--theme_bright); + text-transform: uppercase; + letter-spacing: 1px; + line-height: 1; +} + +.http-section { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 24px; + border-left: 1px solid var(--theme_g2); +} + +.http-section-label { + font-size: 11px; + font-weight: 600; + color: var(--theme_g1); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.stats-http-panel .tile-metrics { + flex-direction: row; + align-items: center; + gap: 20px; +} + +/* workspaces page ---------------------------------------------------------- */ + +.ws-id-wrap { + display: inline-flex; + align-items: center; + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; + font-size: 14px; +} + +.ws-share-table { + width: 100%; + margin: 4px 0; +} + +.ws-share-table th { + padding: 4px; +} + +.ws-share-table td { + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; + font-size: 13px; + padding: 4px; +} + +.ws-share-table td.ws-no-shares-cell { + color: var(--theme_g1); + font-style: italic; + font-family: inherit; + padding: 4px 8px; +} + +.module-metrics-row td.ws-detail-cell { + padding-left: 24px; +} + /* start -------------------------------------------------------------------- */ #start { @@ -1030,7 +1104,7 @@ html:has(#map) { .card-title { font-size: 14px; font-weight: 600; - color: var(--theme_g1); + color: var(--theme_g0); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; |