aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend
diff options
context:
space:
mode:
authorDan Engelbrecht <[email protected]>2026-03-27 16:23:59 +0100
committerGitHub Enterprise <[email protected]>2026-03-27 16:23:59 +0100
commitaff16da9a634ff6869b0394bf936bbb45096ad54 (patch)
tree3d88cc842e6a87c3e4d07cc838a31b8dbabfce9a /src/zenserver/frontend
parentremove CPR HTTP client backend (#894) (diff)
downloadzen-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.cpp10
-rw-r--r--src/zenserver/frontend/frontend.h1
-rw-r--r--src/zenserver/frontend/html/pages/builds.js88
-rw-r--r--src/zenserver/frontend/html/pages/hub.js15
-rw-r--r--src/zenserver/frontend/html/pages/objectstore.js48
-rw-r--r--src/zenserver/frontend/html/pages/page.js72
-rw-r--r--src/zenserver/frontend/html/pages/projects.js50
-rw-r--r--src/zenserver/frontend/html/pages/start.js115
-rw-r--r--src/zenserver/frontend/html/pages/workspaces.js236
-rw-r--r--src/zenserver/frontend/html/zen.css84
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;