diff options
| author | Stefan Boberg <[email protected]> | 2026-03-23 14:19:57 +0100 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-03-23 14:19:57 +0100 |
| commit | 2a445406e09328cb4cf320300f2678997d6775b7 (patch) | |
| tree | a92f02d94c92144cb6ae32160397298533e4c822 /src/zenserver/frontend/html/pages/docs.js | |
| parent | add hub instance crash recovery (#885) (diff) | |
| download | zen-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.js | 415 |
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); + } + } +} |