diff options
| author | Stefan Boberg <[email protected]> | 2026-05-05 15:47:48 +0200 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-05-05 15:47:48 +0200 |
| commit | 01286c6233347d561064fc9e6cf9deaf2087ceb7 (patch) | |
| tree | bdbfdf01725baa2d2dd3d73727e6506b41421dff /src/zen/frontend/html/timeline.js | |
| parent | hub async s3 client (#1024) (diff) | |
| download | archived-zen-main.tar.xz archived-zen-main.zip | |
Branch started as a sessions-service overhaul (persistence, client liveness, UE_LOGFMT intake) and grew to pick up adjacent infrastructure work: an early-startup log backlog, a hardened `MemoryArena`, the `zen trace serve` viewer gaining a counter view + compact timeline + tabbed callsite panel, defensive fixes in the third-party `tourist` trace parser, a series of allocation reductions across the HTTP and compact-binary hot paths, and a new `zen sessions` CLI command tree.
## Sessions service
**Persistence.** Each session lives on disk under `<DataRoot>/sessions/<id>/` as `info.cb` (metadata) plus `log.bin` (length-prefixed CbObject log records). On startup the service scans that directory and loads prior sessions as ended sessions, preloading the tail of each log so historical views work after a restart. `SessionLog` is noexcept-constructed and falls back to a disabled state on disk errors, so a bad disk can't take down `RegisterSession`. `GetSession` falls back to the ended-sessions list (fixes historical log fetches over HTTP). `LoadTail` counts only successfully-parsed records.
**Pruning.** Periodic cleanup task drops ended sessions once any of three caps is exceeded: age (default 1 year), count (default 1000), or total on-disk footprint (default 50 MiB). Runs 30 s after startup, hourly thereafter. Active sessions never pruned; disk removal and directory stat happen outside the exclusive lock so a slow filesystem can't stall lookups.
**Client liveness.** Sessions carry a `ProcessHandle` for the client-reported pid, captured at registration time so Windows pid recycling can't produce false positives. A 30 s asio timer probes liveness and ends dead sessions through the normal remove path, producing a synthetic `Session ended: process exited (...)` line persisted to `log.bin`. Windows decodes common NTSTATUS exit codes to human names (Ctrl-C, access violation, stack overflow, ...); POSIX stays at plain `process exited`. Clients auto-fill `ClientPid` only for local targets (unix socket / loopback); the server defensively accepts pids only from `IsLocalMachineRequest()` peers. zenserver also reports its own pid when registering its self-session, so it shows up with a real pid in the dashboard and `zen sessions ls`.
**Synthetic end-of-session line.** `RemoveSession` takes an optional reason; before the session moves to the ended list it appends an Info-level `Session ended[: reason]` entry through the normal log path (released outside `m_Lock`). Current reasons: `client request` (HTTP DELETE), `server shutdown` (self-session), `process exited (...)` (liveness).
**UE_LOGFMT structured entries.** `POST /sessions/{id}/log` now accepts `{level, logger, format, fields}` alongside the existing `{level, logger, message}` shape. New `logtemplate.{h,cpp}` implements UE's `StructuredLog.cpp` template grammar (field paths with `.name` / `[N]`, `{{`/`}}` escapes, `$text` / `$format` / `$locformat` object conventions, bounded recursion). Renders to a displayable message at intake while persisting raw format + fields so a future UI can drill into fields without another schema bump. Hot path is zero-alloc — renders into `ExtendableStringBuilder<256>` using stack-buffered `Oid::ToString` / `IoHash::ToHexString` overloads. UI shows a `{…}` marker with the raw template + JSON-pretty fields on hover.
**Parent sessions.** `SessionInfo` gains `parent_session_id`; hub-managed storage server child processes inherit the hub's session id via `--parent-session=<id>`. `ZEN_SESSIONS_URL` env var becomes a fallback for `--sessions-url` / config when neither is provided. The in-process session log sink is disabled when a remote sessions target is configured (logs flow through `SessionsServiceClient` instead). The sessions UI groups child sessions under their parent (collapsible/expandable, sorts as a unit, supports nesting).
**Platform reporting.** `SessionInfo` gains `Platform`, flowed end-to-end: client auto-fills via `GetRuntimePlatformName()`, server persists in `info.cb` (`plat`) and emits on GET. UI renders as a SimpleIcons-style inline SVG (windows / macOS / iOS / linux / wine / android / playstation / xbox / nintendo) with case-insensitive alias resolution (Win32/Win64, PS4/PS5, XSX/XSS, NintendoSwitch, iPhone/iPad, Darwin/OSX). Unknown values fall back to text; sorting runs on the underlying string.
**WebSocket log streaming.** Sessions UI moves from 2 s polling to a WebSocket push model. New `WsSubscriber` has a stable id + helper methods. UI caps the log-line DOM at 5 000 entries with a shared cursor-regression helper, factored out of two call sites. Per-broadcast allocations trimmed on the push path; fixed a stack overrun in the WS log broadcast hex-id buffer.
**Log memory.** `LogEntry::Level` is now `logging::LogLevel` (1 byte) instead of `std::string` (~32 B) — saves ~310 KB per full 10 k-entry deque and eliminates a per-message allocation in the in-proc sink. On-disk format writes an int32 and accepts either int or legacy string on read. `LogEntry` strings now live in a `MemoryArena`; logger names are interned across the deque. `SessionLog::Append` and `WriteSessionInfoFile` drop their `UniqueBuffer` round-trip and write `CbObject::GetView()` straight through `BasicFile` / `SafeWriteFile`. Multi-entry `POST /log` batched under one lock + one push.
**In-proc log timestamps.** `InProcSessionLogSink::TimePointToDateTime` previously preserved only whole seconds, so every in-proc entry rendered at `.000` ms in the dashboard and `zen sessions tail`. It now adds the sub-second part (nanoseconds → 100 ns ticks) to keep ms precision end-to-end.
**UI.** Side "Session Details" panel is gone — its info is inline in the table (appname, mode, platform, id, timestamps, this/log pills, active dot). Bottom panel is a tabbed `Log | Metadata` view with a right-side "Session Information" panel beside metadata; log-only controls (filter, newest-first, follow, log-level filter, expand/collapse) hide when Metadata is active, polling keeps running across tab switches. Wide-mode toggle fills the viewport edge-to-edge. Log lines show the logger category; timestamps render in 24 h with zero-padded fields regardless of locale. Sessions list defaults to All / 10 per page / created-desc, gains click-to-sort headers on the full dataset, a header filter box, and a pager aligned to the table's right edge. Duplicate auto-injected `<h1>Sessions</h1>` removed.
## `zen sessions` CLI
New command tree on the `zen` client for inspecting the sessions service from the terminal:
- **`zen sessions ls`** — lists sessions (active first, ended next; newest-first within each group) with id, status, app/mode, pid, created, duration, and log count. Supports `--status active|ended|all` (default `all`).
- **`zen sessions status`** — prints the sessions service summary: self id, active / ended counts, and the read/write/delete/list/request/bad-request counters from `/stats/sessions`.
- **`zen sessions tail [session]`** — tails a session's log. With no argument it tails zenserver's own session (resolved via `/sessions/list`'s `self_id`); an explicit 24-hex id targets any session, including ended ones (historical replay). `--lines N` (default 50, 0 = all buffered) trims the initial dump client-side. `--follow` prefers a WebSocket push subscription on `/sessions/ws` for sub-second latency; on upgrade failure (older server, blocked port, unix-socket transport) it falls back to HTTP cursor polling at `--interval-ms` (default 500), with sleeps chunked to 50 ms so Ctrl-C reacts quickly. Output matches `zen::logging::FullFormatter` (`[YY-MM-DD HH:MM:SS.mmm] [lvl] [logger] message`); on a TTY the level is colored and the logger is bold, with continuation lines indented under the message column using the *visible* prefix width. 404 surfaces as `(session ended)` and connection errors as `(server gone)` — both clean exits, so stopping the server mid-tail no longer prints a stack trace.
- **`zen sessions ui`** — opens `<host>/dashboard/?page=sessions` in the user's default browser. Rejects unix-socket hosts.
A small `ZenServiceClient::IsUnixSocket()` helper now wraps the unix-socket check used by `ui`, `sessions tail` (WS path), and `sessions ui`.
## Logging
`BacklogSink` captures early-startup log entries in a fixed-capacity ring so late-attached sinks (session sink, file sink) can replay them. Detaches from the broadcast list when disabled; backed by destructor-only cleanup (no `unique_ptr` indirection per entry). Tuned defaults so the backlog covers typical bring-up without unbounded growth.
## `zen trace serve` viewer
- Compact timeline mode for high-density views.
- New `TRACE_INT_VALUE` / `TRACE_FLOAT_VALUE` counter trace points + a counters page in the viewer.
- Callsite tables collapsed into a single tabbed panel.
- Lossless `Oid <-> Guid` bridge for trace session ids; trace `SessionId` plumbed through.
- `tourist` parser hardening: bounds-check `BufferStream::read`, validate `Type::info_size` before `patch()`, convert `parse_important_aux` to a loop (avoids deep recursion), widen `ParserPool` index to `uint32`, bounds-check field offsets in the dispatcher, pin `Types::parse` buffer up-front.
## `MemoryArena`
Configurable chunk size, inline chunk list, oversize requests routed to truly-dedicated chunks (no slack waste, no fragmentation when one allocation is much larger than the chunk).
## Allocation cleanups across hot paths
- `zenhttp::HttpRequestRouter::HandleRequest` and `FormatPackageMessageInternal`: drop heap allocations.
- Compact-binary validation: `eastl::fixed_vector` + `eastl::sort`; eliminate `std::vector` churn.
- `zenserverprocess`: trim transient allocations in spawn paths.
- Sessions HTTP intake / broadcast: drop transient `std::string` allocs.
Diffstat (limited to 'src/zen/frontend/html/timeline.js')
| -rw-r--r-- | src/zen/frontend/html/timeline.js | 170 |
1 files changed, 111 insertions, 59 deletions
diff --git a/src/zen/frontend/html/timeline.js b/src/zen/frontend/html/timeline.js index f463a8418..e0fd64181 100644 --- a/src/zen/frontend/html/timeline.js +++ b/src/zen/frontend/html/timeline.js @@ -2,16 +2,35 @@ // Canvas-drawn flame graph with per-thread swimlanes, pan+zoom, hover // tooltip, click-to-select and scope-name highlighting. -const HEADER_H = 18; // thread name row height -const DEPTH_H = 16; // scope lane row height const MAX_DRAWN_DEPTH = 32; -const MIN_RECT_W = 1.5; // don't draw narrower than this (px) -const RULER_H = 20; -const THREAD_GAP = 6; -const PADDING_X = 0; -const REGION_LANE_H = 18; // region band row height -const REGION_HEADER_H = 16; // category header row height -const REGIONS_GAP = 6; // gap between the region rack and the first thread +const MIN_RECT_W = 1.5; // don't draw narrower than this (px) +const RULER_H = 20; +const THREAD_GAP = 6; +const PADDING_X = 0; +const REGIONS_GAP = 6; // gap between the region rack and the first thread + +// Per-mode row metrics. Compact mode (toggled with 'c') shrinks every +// vertical dimension so the flame graph fits many more threads/depths in +// the viewport at the cost of in-bar text labels. +const METRICS_NORMAL = { + headerH: 18, // thread name row height + depthH: 16, // scope lane row height + regionLaneH: 18, // region band row height + regionHeaderH: 16, // region category header row height + scopeFontPx: 11, // in-bar scope label font size + headerFontPx: 11, // thread header / region row font size + showLabels: true, +}; + +const METRICS_COMPACT = { + headerH: 12, + depthH: 4, + regionLaneH: 6, + regionHeaderH: 12, + scopeFontPx: 0, // labels too tall to fit; suppressed + headerFontPx: 9, + showLabels: false, +}; // Scope colors: golden-angle hue rotation keyed on NameId so the same scope // always renders in the same color across zoom levels. @@ -69,6 +88,8 @@ export class Timeline { this.bookmarks = (this.model.bookmarks || []).slice().sort((a, b) => a.time_us - b.time_us); this.bookmarksVisible = true; + this.compact = false; + this.metrics = METRICS_NORMAL; this.regionCategories = (this.model.regionCategories || []).filter(c => c.lane_count > 0); // All categories enabled by default; renderRegionCategories() calls // setEnabledRegionCategories() shortly after construction. @@ -144,6 +165,17 @@ export class Timeline { this.requestDraw(); } + setCompact(compact) { + const next = !!compact; + if (next === this.compact) return; + this.compact = next; + this.metrics = next ? METRICS_COMPACT : METRICS_NORMAL; + this.recomputeRegionsBlockH(); + this.requestDraw(); + } + + toggleCompact() { this.setCompact(!this.compact); } + setEnabledRegionCategories(indices) { this.enabledRegionCategories = indices instanceof Set ? indices : new Set(indices); this.recomputeRegionsBlockH(); @@ -152,10 +184,11 @@ export class Timeline { recomputeRegionsBlockH() { this.regionsBlockH = 0; + const M = this.metrics || METRICS_NORMAL; for (let i = 0; i < this.regionCategories.length; i++) { if (!this.enabledRegionCategories || !this.enabledRegionCategories.has(i)) continue; const cat = this.regionCategories[i]; - this.regionsBlockH += REGION_HEADER_H + cat.lane_count * REGION_LANE_H; + this.regionsBlockH += M.regionHeaderH + cat.lane_count * M.regionLaneH; } if (this.regionsBlockH > 0) { this.regionsBlockH += REGIONS_GAP; @@ -406,6 +439,7 @@ export class Timeline { if (ma.thread_id !== mb.thread_id) return ma.thread_id - mb.thread_id; return (ma.name || "").localeCompare(mb.name || "", undefined, { numeric: true }); }); + const M = this.metrics; for (const threadId of sorted) { const timeline = this.timelines.get(threadId); const scopes = timeline ? timeline.scopes : []; @@ -414,8 +448,8 @@ export class Timeline { if (s[3] > maxDepth) maxDepth = s[3]; } if (maxDepth > MAX_DRAWN_DEPTH) maxDepth = MAX_DRAWN_DEPTH; - const rowH = HEADER_H + (maxDepth + 1) * DEPTH_H; - rows.push({ threadId, y, headerH: HEADER_H, maxDepth, height: rowH }); + const rowH = M.headerH + (maxDepth + 1) * M.depthH; + rows.push({ threadId, y, headerH: M.headerH, maxDepth, height: rowH }); y += rowH + THREAD_GAP; } // y now points at the bottom of the last row in scrolled coords. @@ -467,6 +501,7 @@ export class Timeline { const fg1 = getComputedStyle(document.body).getPropertyValue("--fg1") || "#c9d1d9"; const fg2 = getComputedStyle(document.body).getPropertyValue("--fg2") || "#8b949e"; + const M = this.metrics; for (const row of rows) { if (row.y > H) break; if (row.y + row.height < RULER_H) continue; @@ -479,14 +514,14 @@ export class Timeline { const prefix = isLane ? "⬦ " : ""; const label = `${prefix}${(meta && meta.name) || `tid ${row.threadId}`} · ${row.threadId}`; ctx.fillStyle = isLane ? "rgba(180, 140, 255, 0.8)" : fg2; - ctx.font = "11px -apple-system, Segoe UI, sans-serif"; + ctx.font = `${M.headerFontPx}px -apple-system, Segoe UI, sans-serif`; ctx.textBaseline = "middle"; ctx.fillText(label, 6, row.y + row.headerH / 2); // Swimlane backgrounds for (let d = 0; d <= row.maxDepth; d++) { ctx.fillStyle = d % 2 === 0 ? "rgba(255,255,255,0.015)" : "rgba(255,255,255,0.00)"; - ctx.fillRect(0, row.y + row.headerH + d * DEPTH_H, W, DEPTH_H); + ctx.fillRect(0, row.y + row.headerH + d * M.depthH, W, M.depthH); } const timeline = this.timelines.get(row.threadId); @@ -554,6 +589,7 @@ export class Timeline { drawRegions(ctx, W) { if (this.regionCategories.length === 0) return; + const M = this.metrics; const startUs = this.startUs; const endUs = this.endUs; const pxPerUs = this.pxPerUs(); @@ -566,13 +602,15 @@ export class Timeline { const cat = this.regionCategories[ci]; // Category header ctx.fillStyle = bg1; - ctx.fillRect(0, catY, W, REGION_HEADER_H); - ctx.fillStyle = fg2; - ctx.font = "10px -apple-system, Segoe UI, sans-serif"; - ctx.textBaseline = "middle"; - ctx.textAlign = "left"; - ctx.fillText(`Timing Regions \u2013 ${cat.name}`, 6, catY + REGION_HEADER_H / 2); - catY += REGION_HEADER_H; + ctx.fillRect(0, catY, W, M.regionHeaderH); + if (M.showLabels) { + ctx.fillStyle = fg2; + ctx.font = "10px -apple-system, Segoe UI, sans-serif"; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(`Timing Regions \u2013 ${cat.name}`, 6, catY + M.regionHeaderH / 2); + } + catY += M.regionHeaderH; // Region bands for this category for (const r of cat.regions) { @@ -585,35 +623,35 @@ export class Timeline { const w = Math.max(MIN_RECT_W, (endRegUs - beginUs) * pxPerUs); if (w < MIN_RECT_W) continue; - const y = catY + r.depth * REGION_LANE_H; + const y = catY + r.depth * M.regionLaneH; ctx.fillStyle = regionFillColor(r.name); - ctx.fillRect(x, y + 1, w, REGION_LANE_H - 2); + ctx.fillRect(x, y + 1, w, M.regionLaneH - 2); ctx.strokeStyle = "rgba(255,255,255,0.2)"; ctx.lineWidth = 1; - ctx.strokeRect(x + 0.5, y + 1.5, w - 1, REGION_LANE_H - 3); + ctx.strokeRect(x + 0.5, y + 1.5, w - 1, M.regionLaneH - 3); const visX = Math.max(x, 0); const visRight = Math.min(x + w, this.width); const visW = visRight - visX; - if (visW > 24 && r.name) { + if (M.showLabels && visW > 24 && r.name) { ctx.fillStyle = "rgba(255,255,255,0.95)"; ctx.font = "11px -apple-system, Segoe UI, sans-serif"; ctx.textBaseline = "middle"; ctx.textAlign = "left"; ctx.save(); ctx.beginPath(); - ctx.rect(visX + 3, y, visW - 6, REGION_LANE_H); + ctx.rect(visX + 3, y, visW - 6, M.regionLaneH); ctx.clip(); - ctx.fillText(r.name, visX + 5, y + REGION_LANE_H / 2); + ctx.fillText(r.name, visX + 5, y + M.regionLaneH / 2); ctx.restore(); } - this.hits.push({ x, y, w, h: REGION_LANE_H - 2, region: r, regionCategory: cat.name }); + this.hits.push({ x, y, w, h: M.regionLaneH - 2, region: r, regionCategory: cat.name }); } - catY += cat.lane_count * REGION_LANE_H; + catY += cat.lane_count * M.regionLaneH; } } @@ -660,6 +698,8 @@ export class Timeline { drawScopes(ctx, timeline, row, pxPerUs, textColor) { const { scopes, perDepth } = timeline; + const M = this.metrics; + const depthH = M.depthH; const startUs = this.startUs; const endUs = this.endUs; @@ -669,7 +709,9 @@ export class Timeline { ctx.textBaseline = "middle"; ctx.textAlign = "left"; - ctx.font = "11px -apple-system, Segoe UI, sans-serif"; + if (M.showLabels) { + ctx.font = `${M.scopeFontPx}px -apple-system, Segoe UI, sans-serif`; + } const rowTop = row.y + row.headerH; const maxDepth = Math.min(row.maxDepth, perDepth.length - 1); @@ -695,7 +737,11 @@ export class Timeline { } } - const y = rowTop + depth * DEPTH_H; + const y = rowTop + depth * depthH; + // Compact mode shrinks the bar to the row height; normal mode + // leaves a 1px gap top and bottom for readability. + const barTop = M.showLabels ? y + 1 : y; + const barH = M.showLabels ? depthH - 2 : depthH; let rendered = 0; for (let j = lo; j < indices.length; j++) { const s = scopes[indices[j]]; @@ -718,39 +764,44 @@ export class Timeline { ctx.fillStyle = isHighlighted ? `hsl(${hue.toFixed(0)}, 50%, 50%)` : `hsl(${hue.toFixed(0)}, 30%, 35%)`; - ctx.fillRect(x, y + 1, w, DEPTH_H - 2); - ctx.strokeStyle = "rgba(255,255,255,0.25)"; - ctx.lineWidth = 1; - ctx.setLineDash([2, 2]); - ctx.beginPath(); - ctx.moveTo(x, y + 1.5); - ctx.lineTo(x + w, y + 1.5); - ctx.stroke(); - ctx.setLineDash([]); + ctx.fillRect(x, barTop, w, barH); + if (M.showLabels) { + ctx.strokeStyle = "rgba(255,255,255,0.25)"; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.moveTo(x, barTop + 0.5); + ctx.lineTo(x + w, barTop + 0.5); + ctx.stroke(); + ctx.setLineDash([]); + } } else { ctx.fillStyle = isHighlighted ? scopeHighlightColor(nameId) : scopeFillColor(nameId); - ctx.fillRect(x, y + 1, w, DEPTH_H - 2); + ctx.fillRect(x, barTop, w, barH); } // Draw the label pinned to the visible portion of the rect - // so zooming into a long scope still shows its name. - const visX = Math.max(x, 0); - const visRight = Math.min(x + w, this.width); - const visW = visRight - visX; - if (visW > 30) { - const name = this.model.scopeNames[nameId] || "?"; - const maxChars = Math.floor((visW - 6) / 6); - const shown = name.length > maxChars ? name.slice(0, Math.max(0, maxChars - 1)) + "…" : name; - ctx.fillStyle = "rgba(255,255,255,0.95)"; - ctx.save(); - ctx.beginPath(); - ctx.rect(visX + 3, y, visW - 6, DEPTH_H); - ctx.clip(); - ctx.fillText(shown, visX + 4, y + DEPTH_H / 2); - ctx.restore(); + // so zooming into a long scope still shows its name. Skipped + // in compact mode where the bar is too short for readable text. + if (M.showLabels) { + const visX = Math.max(x, 0); + const visRight = Math.min(x + w, this.width); + const visW = visRight - visX; + if (visW > 30) { + const name = this.model.scopeNames[nameId] || "?"; + const maxChars = Math.floor((visW - 6) / 6); + const shown = name.length > maxChars ? name.slice(0, Math.max(0, maxChars - 1)) + "…" : name; + ctx.fillStyle = "rgba(255,255,255,0.95)"; + ctx.save(); + ctx.beginPath(); + ctx.rect(visX + 3, y, visW - 6, depthH); + ctx.clip(); + ctx.fillText(shown, visX + 4, y + depthH / 2); + ctx.restore(); + } } - this.hits.push({ x, y, w, h: DEPTH_H - 2, threadId: row.threadId, tuple: s }); + this.hits.push({ x, y, w, h: depthH, threadId: row.threadId, tuple: s }); } } } @@ -764,12 +815,13 @@ export class Timeline { const { rows } = this.layoutThreads(); const row = rows.find((r) => r.threadId === this.selected.threadId); if (!row) return; + const M = this.metrics; const x = this.xAtUs(beginUs); const w = Math.max(MIN_RECT_W, durationUs * this.pxPerUs()); - const y = row.y + row.headerH + depth * DEPTH_H; + const y = row.y + row.headerH + depth * M.depthH; ctx.strokeStyle = "#ffffff"; ctx.lineWidth = 1.5; - ctx.strokeRect(x - 0.5, y + 0.5, w + 1, DEPTH_H - 1); + ctx.strokeRect(x - 0.5, y + 0.5, w + 1, M.depthH - 1); } drawViewportInfo() { |