aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend/html/pages/docs.js
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-03-23 14:19:57 +0100
committerGitHub Enterprise <[email protected]>2026-03-23 14:19:57 +0100
commit2a445406e09328cb4cf320300f2678997d6775b7 (patch)
treea92f02d94c92144cb6ae32160397298533e4c822 /src/zenserver/frontend/html/pages/docs.js
parentadd hub instance crash recovery (#885) (diff)
downloadzen-2a445406e09328cb4cf320300f2678997d6775b7.tar.xz
zen-2a445406e09328cb4cf320300f2678997d6775b7.zip
Dashboard refresh (logs, storage, network, object store, docs) (#835)
## Summary This PR adds a session management service, several new dashboard pages, and a number of infrastructure improvements. ### Sessions Service - `SessionsServiceClient` in `zenutil` announces sessions to a remote zenserver with a 15s heartbeat (POST/PUT/DELETE lifecycle) - Storage server registers itself with its own local sessions service on startup - Session mode attribute coupled to server mode (Compute, Proxy, Hub, etc.) - Ended sessions tracked with `ended_at` timestamp; status filtering (Active/Ended/All) - `--sessions-url` config option for remote session announcement - In-process log sink (`InProcSessionLogSink`) forwards server log output to the server's own session, visible in the dashboard ### Session Log Viewer - POST/GET endpoints for session logs (`/sessions/{id}/log`) supporting raw text and structured JSON/CbObject with batch `entries` array - In-memory log storage per session (capped at 10k entries) with cursor-based pagination for efficient incremental fetching - Log panel in the sessions dashboard with incremental DOM updates, auto-scroll (Follow toggle), newest-first toggle, text filter, and log-level coloring - Auto-selects the server's own session on page load ### TCP Log Streaming - `LogStreamListener` and `TcpLogStreamSink` for log delivery over TCP - Sequence numbers on each message with drop detection and synthetic "dropped" notice on gaps - Gathered buffer writes to reduce syscall overhead when flushing batches - Tests covering basic delivery, multi-line splitting, drop detection, and sequencing ### New Dashboard Pages - **Sessions**: master-detail layout with selectable rows, metadata panel, live WebSocket updates, paging, abbreviated date formatting, and "this" pill for the local session - **Object Store**: summary stats tiles and bucket table with click-to-expand inline object listing (`GET /obj/`) - **Storage**: per-volume disk usage breakdown (`GET /admin/storage`), Garbage Collection status section (next-run countdown, last-run stats), and GC History table with paginated rows and expandable detail panels - **Network**: overview tiles, per-service request table, proxy connections, and live WebSocket updates; distinct client IPs and session counts via HyperLogLog ### Documentation Page - In-dashboard Docs page with sidebar navigation, markdown rendering (via `marked`), Mermaid diagram support (theme-aware), collapsible sections, text filtering with highlighting, and cross-document linking - New user-facing docs: `overview.md` (with architecture and per-mode diagrams), `sessions.md`, `cache.md`, `projects.md`; updated `compute.md` - Dev docs moved to `docs/dev/` ### Infrastructure & Bug Fixes - **Deflate compression** for the embedded frontend zip (~3.4MB → ~950KB); zlib inflate support added to `ZipFs` with cached decompressed buffers - **Local IP addresses**: `GetLocalIpAddresses()` (Windows via `GetAdaptersAddresses`, Linux/Mac via `getifaddrs`); surfaced in `/status/status`, `/health/info`, and the dashboard banner - **Dashboard nav**: unified into `zen-nav` web component with `MutationObserver` for dynamically added links, CSS `::part()` to merge banner/nav border radii, and prefix-based active link detection - Stats broadcast refactored from manual JSON string concatenation to `CbObjectWriter`; `CbObject`-to-JS conversion improved for `TimeSpan`, `DateTime`, and large integers - Stats WebSocket boilerplate consolidated into `ZenPage.connect_stats_ws()`
Diffstat (limited to 'src/zenserver/frontend/html/pages/docs.js')
-rw-r--r--src/zenserver/frontend/html/pages/docs.js415
1 files changed, 415 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html/pages/docs.js b/src/zenserver/frontend/html/pages/docs.js
new file mode 100644
index 000000000..8caf36d0c
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/docs.js
@@ -0,0 +1,415 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+"use strict";
+
+import { ZenPage } from "./page.js"
+import { marked, Renderer } from "../thirdparty/marked.esm.js"
+import { Fetcher } from "../util/fetcher.js"
+
+function slugify(text)
+{
+ return text.toLowerCase().replace(/[^\w]+/g, "-").replace(/^-|-$/g, "");
+}
+
+const renderer = new Renderer();
+renderer.heading = function({ text, depth })
+{
+ const id = slugify(text);
+ if (depth === 1)
+ {
+ return `<h1 id="${id}">${text}<a class="docs-source-link" title="View source"></a><a class="docs-github-link" title="View on GitHub" target="_blank"></a></h1>`;
+ }
+ if (depth === 2)
+ {
+ // Close previous <details> section (if any) and open a new one
+ return `</details><details class="docs-section" open><summary class="docs-section-title" id="${id}">${text}</summary>`;
+ }
+ return `<h${depth} id="${id}">${text}</h${depth}>`;
+};
+
+////////////////////////////////////////////////////////////////////////////////
+export class Page extends ZenPage
+{
+ generate_crumbs() {}
+
+ async main()
+ {
+ this.set_title("docs");
+ this._parent.inner().classList.add("docs-page");
+
+ const index = await new Fetcher().resource("/dashboard/data/_index.json").json();
+ if (!index || index.length === 0)
+ {
+ this._parent.tag().text("No documentation available.");
+ return;
+ }
+
+ // Filter input
+ const filter_box = document.createElement("input");
+ filter_box.type = "text";
+ filter_box.className = "docs-filter";
+ filter_box.placeholder = "Filter\u2026";
+ filter_box.addEventListener("input", () => this._apply_filter(filter_box.value));
+ this._parent.inner().appendChild(filter_box);
+
+ // Layout: sidebar + content
+ const layout = document.createElement("div");
+ layout.className = "docs-layout";
+ this._parent.inner().appendChild(layout);
+
+ // Sidebar
+ const sidebar = document.createElement("nav");
+ sidebar.className = "docs-sidebar";
+ layout.appendChild(sidebar);
+
+ this._docs_index = index;
+ this._sidebar = sidebar;
+ this._selected_link = null;
+ this._filter = "";
+ this._docs_cache = {};
+
+ for (const entry of index)
+ {
+ const link = document.createElement("a");
+ link.className = "docs-sidebar-link";
+ link.textContent = entry.title;
+ link.href = "#";
+ link.addEventListener("click", (e) => {
+ e.preventDefault();
+ this._select_doc(entry, link);
+ });
+ sidebar.appendChild(link);
+ }
+
+ // Prefetch all docs in the background for filtering
+ Promise.all(index.map(entry =>
+ new Fetcher().resource("/dashboard/data/" + entry.path).text()
+ .then(md => { this._docs_cache[entry.path] = md.toLowerCase(); })
+ .catch(() => {})
+ ));
+
+ // Content area
+ const content = document.createElement("article");
+ content.className = "docs-content";
+ layout.appendChild(content);
+ this._content = content;
+
+ // Intercept clicks on links within rendered doc content
+ content.addEventListener("click", (e) => {
+ const anchor = e.target.closest("a");
+ if (!anchor || anchor.classList.contains("docs-source-link") || anchor.classList.contains("docs-github-link"))
+ {
+ return;
+ }
+
+ const href = anchor.getAttribute("href") || "";
+
+ // Fragment-only link (e.g. #workers) — scroll within current doc
+ if (href.startsWith("#"))
+ {
+ e.preventDefault();
+ this._scroll_to_fragment(href.slice(1));
+ return;
+ }
+
+ // Relative link to another doc (e.g. API.md or specs/CompactBinary.md)
+ if (!href.startsWith("http") && href.endsWith(".md"))
+ {
+ const parts = href.split("#");
+ const target_path = parts[0];
+ const target_fragment = parts[1] || null;
+ const target_entry = this._docs_index.find(d => d.path.toLowerCase() === target_path.toLowerCase());
+ if (target_entry)
+ {
+ e.preventDefault();
+ const target_link = this._sidebar.children[this._docs_index.indexOf(target_entry)];
+ this._select_doc(target_entry, target_link, target_fragment);
+ }
+ }
+ });
+
+ // Select initial doc from URL param or first entry
+ const requested = this.get_param("doc", "");
+ const initial = index.find(e => e.path === requested) || index[0];
+ const initial_link = sidebar.children[index.indexOf(initial)];
+ this._select_doc(initial, initial_link);
+ }
+
+ async _select_doc(entry, link, fragment)
+ {
+ if (this._selected_link)
+ {
+ this._selected_link.classList.remove("active");
+ }
+ this._selected_link = link;
+ link.classList.add("active");
+
+ this.set_param("doc", entry.path);
+
+ this._content.innerHTML = "<p class=\"docs-loading\">Loading\u2026</p>";
+
+ try
+ {
+ const md = await new Fetcher().resource("/dashboard/data/" + entry.path).text();
+ this._content.innerHTML = marked.parse(md, { renderer });
+
+ const source_link = this._content.querySelector(".docs-source-link");
+ if (source_link)
+ {
+ source_link.href = "/dashboard/data/" + entry.path;
+ }
+
+ const github_link = this._content.querySelector(".docs-github-link");
+ if (github_link)
+ {
+ github_link.href = "https://github.com/EpicGames/zen/blob/main/docs/" + entry.path;
+ }
+
+ // Render mermaid diagrams
+ await this._render_mermaid();
+
+ // Re-apply filter to the newly rendered content
+ if (this._filter)
+ {
+ this._apply_filter_to_content();
+ }
+
+ // Scroll to fragment if specified
+ const target_fragment = fragment || window.location.hash.slice(1);
+ if (target_fragment)
+ {
+ this._scroll_to_fragment(target_fragment);
+ }
+ else
+ {
+ window.scrollTo(0, 0);
+ }
+ }
+ catch (e)
+ {
+ this._content.innerHTML = "<p class=\"docs-error\">Failed to load document.</p>";
+ }
+ }
+
+ _scroll_to_fragment(id)
+ {
+ if (!id)
+ {
+ return;
+ }
+ const target = this._content.querySelector("#" + CSS.escape(id));
+ if (target)
+ {
+ target.scrollIntoView({ behavior: "smooth" });
+ }
+ }
+
+ _apply_filter(query)
+ {
+ this._filter = query.toLowerCase().trim();
+
+ // Filter sidebar entries based on cached doc content
+ const sidebar_links = this._sidebar.children;
+ for (let i = 0; i < this._docs_index.length; i++)
+ {
+ const entry = this._docs_index[i];
+ const link = sidebar_links[i];
+ if (!this._filter)
+ {
+ link.style.display = "";
+ continue;
+ }
+
+ const cached = this._docs_cache[entry.path];
+ const matches = !cached || cached.includes(this._filter) || entry.title.toLowerCase().includes(this._filter);
+ link.style.display = matches ? "" : "none";
+ }
+
+ this._apply_filter_to_content();
+ }
+
+ _apply_filter_to_content()
+ {
+ // Remove existing highlights
+ for (const mark of this._content.querySelectorAll("mark.docs-highlight"))
+ {
+ const parent = mark.parentNode;
+ parent.replaceChild(document.createTextNode(mark.textContent), mark);
+ parent.normalize();
+ }
+
+ const sections = this._content.querySelectorAll("details.docs-section");
+
+ for (const section of sections)
+ {
+ if (!this._filter)
+ {
+ section.style.display = "";
+ section.open = true;
+ continue;
+ }
+
+ const matches = section.textContent.toLowerCase().includes(this._filter);
+ section.style.display = matches ? "" : "none";
+
+ if (matches)
+ {
+ section.open = true;
+ this._highlight_element(section);
+ }
+ }
+ }
+
+ _get_mermaid_theme()
+ {
+ const attr = document.documentElement.getAttribute("data-theme");
+ const is_dark = attr
+ ? attr === "dark"
+ : window.matchMedia("(prefers-color-scheme: dark)").matches;
+ return is_dark ? "dark" : "default";
+ }
+
+ async _load_mermaid()
+ {
+ if (window.mermaid)
+ {
+ return window.mermaid;
+ }
+
+ return new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.src = "/dashboard/thirdparty/mermaid.min.js";
+ script.onload = () => {
+ window.mermaid.initialize({
+ startOnLoad: false,
+ theme: this._get_mermaid_theme(),
+ themeVariables: {
+ background: "transparent",
+ },
+ });
+ resolve(window.mermaid);
+ };
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ }
+
+ async _render_mermaid()
+ {
+ // marked renders ```mermaid blocks as <code class="language-mermaid"> inside <pre>
+ const code_blocks = this._content.querySelectorAll("pre > code.language-mermaid");
+ if (code_blocks.length === 0)
+ {
+ return;
+ }
+
+ try
+ {
+ const mermaid = await this._load_mermaid();
+
+ for (let i = 0; i < code_blocks.length; i++)
+ {
+ const code = code_blocks[i];
+ const pre = code.parentElement;
+ const definition = code.textContent;
+
+ const { svg } = await mermaid.render(`mermaid-${Date.now()}-${i}`, definition);
+
+ const container = document.createElement("div");
+ container.className = "docs-mermaid";
+ container.dataset.definition = definition;
+ container.innerHTML = svg;
+ pre.replaceWith(container);
+ }
+
+ this._watch_theme();
+ }
+ catch (e)
+ {
+ // Mermaid failed to load or render — leave code blocks as-is
+ }
+ }
+
+ _watch_theme()
+ {
+ if (this._theme_observer)
+ {
+ return;
+ }
+
+ this._theme_observer = new MutationObserver(() => this._rerender_mermaid());
+ this._theme_observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["data-theme"],
+ });
+ }
+
+ async _rerender_mermaid()
+ {
+ const containers = this._content.querySelectorAll(".docs-mermaid[data-definition]");
+ if (containers.length === 0)
+ {
+ return;
+ }
+
+ try
+ {
+ const mermaid = await this._load_mermaid();
+ mermaid.initialize({
+ startOnLoad: false,
+ theme: this._get_mermaid_theme(),
+ themeVariables: {
+ background: "transparent",
+ },
+ });
+
+ for (let i = 0; i < containers.length; i++)
+ {
+ const container = containers[i];
+ const definition = container.dataset.definition;
+ const { svg } = await mermaid.render(`mermaid-${Date.now()}-${i}`, definition);
+ container.innerHTML = svg;
+ }
+ }
+ catch (e)
+ {
+ // Mermaid failed to re-render — leave existing SVGs as-is
+ }
+ }
+
+ _highlight_element(root)
+ {
+ const filter = this._filter;
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ const matches = [];
+
+ let node;
+ while ((node = walker.nextNode()))
+ {
+ if (node.parentElement && node.parentElement.closest("pre"))
+ {
+ continue;
+ }
+
+ const text = node.textContent.toLowerCase();
+ let pos = 0;
+ while ((pos = text.indexOf(filter, pos)) !== -1)
+ {
+ matches.push({ node, pos, len: filter.length });
+ pos += filter.length;
+ }
+ }
+
+ for (let i = matches.length - 1; i >= 0; i--)
+ {
+ const { node: text_node, pos, len } = matches[i];
+ const after = text_node.splitText(pos);
+ after.splitText(len);
+
+ const mark = document.createElement("mark");
+ mark.className = "docs-highlight";
+ mark.textContent = after.textContent;
+ after.parentNode.replaceChild(mark, after);
+ }
+ }
+}