aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend/html/pages
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-05-05 15:47:48 +0200
committerGitHub Enterprise <[email protected]>2026-05-05 15:47:48 +0200
commit01286c6233347d561064fc9e6cf9deaf2087ceb7 (patch)
treebdbfdf01725baa2d2dd3d73727e6506b41421dff /src/zenserver/frontend/html/pages
parenthub async s3 client (#1024) (diff)
downloadarchived-zen-main.tar.xz
archived-zen-main.zip
sessions: persist to disk, prune, track client liveness, accept UE_LOGFMT (#1014)HEADmain
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/zenserver/frontend/html/pages')
-rw-r--r--src/zenserver/frontend/html/pages/platform_icons.js61
-rw-r--r--src/zenserver/frontend/html/pages/sessions.js1015
2 files changed, 895 insertions, 181 deletions
diff --git a/src/zenserver/frontend/html/pages/platform_icons.js b/src/zenserver/frontend/html/pages/platform_icons.js
new file mode 100644
index 000000000..65a04c840
--- /dev/null
+++ b/src/zenserver/frontend/html/pages/platform_icons.js
@@ -0,0 +1,61 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+
+// SimpleIcons-style platform glyphs (viewBox 0 0 24 24, fill: currentColor)
+// used by the sessions table to render a recognizable icon instead of a raw
+// platform label. Lives in its own module because the data table is bulky and
+// other pages may want to reuse the resolver.
+
+"use strict";
+
+// Path data is intentionally one-line per entry to keep the icon table dense.
+// Unknown platforms fall through to a text cell at the call site.
+export const PLATFORM_ICONS = {
+ windows: { label: "Windows", path: "M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-13.051-1.351" },
+ macos: { label: "macOS", path: "M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" },
+ ios: { label: "iOS", path: "M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" },
+ linux: { label: "Linux", path: "M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.2.271-.53.4-.953.58-.42.17-.94.33-1.31.84-.5.84-.33 1.83.03 2.72.39.89.95 1.64.89 2.13-.06.72.21 1.24.59 1.48.38.24.83.24 1.17.24.34 0 .57-.01.67-.06.19-.1.38-.25.55-.26.22-.02.43.11.72.26.67.35 1.3.43 1.88.3.57-.13 1.08-.44 1.52-.78.9-.74 1.7-1.5 2.6-1.63.24-.03.5-.05.78-.05.13 0 .25 0 .39.02.11.01.26.1.5.26.25.17.6.4 1 .59.4.17.87.32 1.4.3.59-.02 1.18-.22 1.73-.63 1.57-1.19 4.21-.97 5.13-2.08.11-.14.18-.3.2-.48.02-.18 0-.36-.07-.54-.06-.19-.17-.38-.32-.57-.15-.19-.35-.38-.56-.55-.38-.31-.58-.67-.57-1.11.01-.28.14-.58.28-.85.11-.28.24-.54.24-.81-.02-.28-.13-.55-.36-.77-.23-.23-.55-.42-1.01-.51a.424.424 0 00-.14-.02c-.3-.04-.57-.04-.87-.04-.13-.31-.28-.62-.43-.91-.9-1.72-2.23-3.13-3.27-4.31-.92-1.02-1.55-2-1.62-3.07-.12-.9-.07-1.94-.1-2.93-.02-.92-.15-1.81-.67-2.5-.49-.7-1.32-1.22-2.73-1.22z" },
+ wine: { label: "Wine", path: "M8 2h8l-1 7a4 4 0 01-3 4v5h3v2H9v-2h3v-5a4 4 0 01-3-4L8 2z" },
+ android: { label: "Android", path: "M17.523 15.3414c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993.0001.5511-.4482.9997-.9993.9997m-11.046 0c-.5511 0-.9993-.4486-.9993-.9997s.4482-.9993.9993-.9993c.5511 0 .9993.4482.9993.9993 0 .5511-.4482.9997-.9993.9997m11.4045-6.02l1.9973-3.4592a.416.416 0 00-.1521-.5676.416.416 0 00-.5677.1521l-2.0223 3.503C15.5902 8.2439 13.8533 7.8508 12 7.8508s-3.5902.3931-5.1367 1.0989L4.841 5.4467a.4161.4161 0 00-.5677-.1521.4157.4157 0 00-.1521.5676l1.9973 3.4592C2.6889 11.1867.3432 14.6589 0 18.761h24c-.3432-4.1021-2.6889-7.5743-6.1185-9.4396" },
+ playstation: { label: "PlayStation", path: "M8.985 2.596v17.548l3.915 1.261V6.688c0-.69.304-1.151.794-.991.636.181.76.814.76 1.505v5.875c2.441 1.193 4.362 0 4.362-3.118 0-3.198-1.13-4.63-4.442-5.76-1.313-.444-3.697-1.203-5.389-1.603zM0 17.81c.069.24.213.489.487.728C4.024 21.22 9.45 22.395 15.03 22.395c.58 0 1.142-.034 1.725-.08-5.423-1.39-9.33-3.77-15.203-4.87a5.78 5.78 0 01-1.55-.364v.728zm18.7-8.97c.057-.035.114-.057.194-.08.695-.148 1.15.217 1.15.908 0 .706-.478 1.283-1.162 1.486-.08.023-.137.034-.194.057v4.74c.079-.023.148-.034.228-.057 3.426-1.193 4.374-2.796 4.374-5.502 0-2.637-1.37-4.063-3.357-4.748-.387-.137-.764-.228-1.162-.32l-.068-.023v3.54l-.003-.001z" },
+ xbox: { label: "Xbox", path: "M4.102 21.033C6.211 22.881 8.977 24 12 24c3.026 0 5.789-1.119 7.902-2.965 1.16-1.016-4.553-6.929-7.902-9.518-3.349 2.589-9.063 8.499-7.898 9.516zm11.08-18.52c2.699-1.159 5.062-1.169 6.52-.546l.025.033c-1.377-2.152-3.624-4.001-7.004-3.978-2.049 0-4.062.826-5.725 2.208 1.964.468 4.114 1.404 6.184 2.283zM2.27 1.976l.025-.033c1.458-.623 3.82-.613 6.519.546 2.07-.879 4.22-1.815 6.184-2.283C13.335.824 11.32-.002 9.272 0 5.891-.02 3.646 1.828 2.27 1.976zM1.62 19.46l-.012.003C.597 17.8 0 15.838 0 13.749c0-1.749.425-3.399 1.157-4.85.9-1.784 4.126-5.59 5.73-7.37.118-.131-4.425 1.976-5.267 9.931-.025.221-.025.442-.025.663 0 2.586.741 5 2.025 7.017zm20.763 0l-.011-.003c1.283-2.017 2.025-4.431 2.025-7.017 0-.22 0-.442-.025-.663-.842-7.955-5.386-10.062-5.267-9.93 1.604 1.779 4.83 5.585 5.73 7.37.732 1.45 1.157 3.1 1.157 4.849 0 2.09-.596 4.051-1.609 5.714l.013.003-.014-.324z" },
+ nintendo: { label: "Nintendo", path: "M14.176 24h3.674c3.376 0 6.15-2.774 6.15-6.15V6.15C24 2.775 21.226 0 17.85 0H14.16c-.205 0-.38.174-.38.38v23.24c0 .206.19.38.396.38zM8.252 24c.212 0 .39-.174.39-.384V.374c0-.21-.178-.374-.39-.374h-2.1A6.167 6.167 0 0 0 0 6.15v11.7C0 21.224 2.775 24 6.152 24h2.1zm-4.59-15.763a2.578 2.578 0 0 1 2.58-2.58 2.577 2.577 0 0 1 2.578 2.58 2.579 2.579 0 1 1-5.157 0zm12.556 11.928a3.063 3.063 0 0 1 3.067-3.065 3.063 3.063 0 0 1 3.065 3.065 3.067 3.067 0 0 1-3.065 3.07 3.065 3.065 0 0 1-3.067-3.07z" },
+};
+
+// Resolve a platform string (as reported by the client) to an icon entry.
+// Intentionally liberal so UE-style variants like "Win64", "PS5", "XSX",
+// "NintendoSwitch" all land on the right icon.
+export function resolve_platform_icon(platform)
+{
+ const p = platform.toLowerCase();
+ if (p.includes("windows") || p === "win32" || p === "win64" || p === "win") return PLATFORM_ICONS.windows;
+ if (p === "wine") return PLATFORM_ICONS.wine;
+ if (p.includes("android")) return PLATFORM_ICONS.android;
+ if (p === "ios" || p === "iphone" || p === "ipad" || p === "ipados" || p === "tvos") return PLATFORM_ICONS.ios;
+ if (p === "mac" || p === "macos" || p === "osx" || p === "darwin") return PLATFORM_ICONS.macos;
+ if (p === "linux") return PLATFORM_ICONS.linux;
+ if (p.includes("playstation") || /^ps\d/.test(p) || p === "psvita") return PLATFORM_ICONS.playstation;
+ if (p.includes("xbox") || p === "xsx" || p === "xss") return PLATFORM_ICONS.xbox;
+ if (p.includes("nintendo") || p === "switch") return PLATFORM_ICONS.nintendo;
+ return null;
+}
+
+// Build a <span> element representing the platform — either an inline SVG
+// glyph (when the platform resolves) or a plain text fallback.
+export function make_platform_cell(platform)
+{
+ const el = document.createElement("span");
+ if (!platform) { return el; }
+ const icon = resolve_platform_icon(platform);
+ if (!icon)
+ {
+ // Unknown platform — fall back to the raw label.
+ el.textContent = platform;
+ return el;
+ }
+ el.className = "platform-icon";
+ el.title = icon.label;
+ el.setAttribute("aria-label", icon.label);
+ // Paths are hard-coded (no user-controlled input), so innerHTML is safe.
+ el.innerHTML = `<svg viewBox="0 0 24 24" role="img" focusable="false"><path d="${icon.path}"/></svg>`;
+ return el;
+}
diff --git a/src/zenserver/frontend/html/pages/sessions.js b/src/zenserver/frontend/html/pages/sessions.js
index c74ede14e..70b850698 100644
--- a/src/zenserver/frontend/html/pages/sessions.js
+++ b/src/zenserver/frontend/html/pages/sessions.js
@@ -4,7 +4,69 @@
import { ZenPage } from "./page.js"
import { Fetcher } from "../util/fetcher.js"
+import { CbObject } from "../util/compactbinary.js"
import { Table, PropTable } from "../util/widgets.js"
+import { make_platform_cell } from "./platform_icons.js"
+
+// Run @p fn and swallow any thrown error, but log it at debug level under
+// @p label so the failure isn't completely invisible. Use this in places
+// where the failure mode is genuinely "drop the frame and move on" — JSON /
+// CB parse failures, optional WebSocket setup, transient send errors. The
+// debug-level log keeps normal consoles clean; surface them by enabling
+// "Verbose" in DevTools.
+function quietly(label, fn)
+{
+ try { return fn(); }
+ catch (e)
+ {
+ console.debug(`[sessions] ${label}:`, e);
+ return undefined;
+ }
+}
+
+// Dev tools read better with 24h + zero-padded fields; don't defer to the
+// browser's default locale which is 12h AM/PM for en-US.
+const TIME_OPTS = { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" };
+const DATE_OPTS = { year: "numeric", month: "2-digit", day: "2-digit", ...TIME_OPTS };
+
+// UE log levels in ascending order of severity. Each incoming entry's
+// level string (lowercased) maps to a numeric rank we can compare against
+// the user-selected threshold when filtering. Both the short ("warn") and
+// long ("warning") spellings are covered because zencore emits the long
+// form but older clients / tests may use the short one.
+const LEVEL_RANK = {
+ trace: 0,
+ debug: 1,
+ info: 2,
+ warning: 3, warn: 3,
+ error: 4, err: 4,
+ critical: 5,
+};
+
+// Cap on the in-DOM log line count. The server's deque is bounded
+// (MaxLogEntries on the C++ side) but the WS push delivers every new
+// entry forever during a long Cook, so the browser DOM would grow
+// without bound. At 5000 lines the panel still feels live for tail-
+// following while keeping the page responsive. Older entries fall off
+// the far end on every append.
+const MAX_LOG_LINES_IN_DOM = 5000;
+
+// Level-filter dropdown options. `rank` is the minimum rank that survives
+// — entries with a lower rank are hidden. -1 disables level filtering.
+const LEVEL_FILTER_OPTIONS = [
+ { value: "all", label: "All levels", rank: -1 },
+ { value: "debug", label: "Debug+", rank: 1 },
+ { value: "info", label: "Info+", rank: 2 },
+ { value: "warn", label: "Warn+", rank: 3 },
+ { value: "error", label: "Error+", rank: 4 },
+];
+
+// Double-chevron icons for the expand / collapse panel toggle. Up when
+// collapsed (click to grow the log panel upward into the table's space);
+// down when expanded (click to shrink back down). currentColor so the
+// surrounding button styles control tinting.
+const ICON_CHEVRON_UP = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 17 12 11 18 17"/><polyline points="6 11 12 5 18 11"/></svg>';
+const ICON_CHEVRON_DOWN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 7 12 13 18 7"/><polyline points="6 13 12 19 18 13"/></svg>';
function fmt_date(iso)
{
@@ -13,15 +75,15 @@ function fmt_date(iso)
const now = new Date();
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate())
{
- return d.toLocaleTimeString();
+ return d.toLocaleTimeString([], TIME_OPTS);
}
- return d.toLocaleString();
+ return d.toLocaleString([], DATE_OPTS);
}
function fmt_time(iso)
{
if (!iso) { return ""; }
- return new Date(iso).toLocaleTimeString();
+ return new Date(iso).toLocaleTimeString([], TIME_OPTS);
}
////////////////////////////////////////////////////////////////////////////////
@@ -33,32 +95,43 @@ export class Page extends ZenPage
{
this.set_title("sessions");
- this._status = this.get_param("status", "active");
+ this._status = this.get_param("status", "all");
const section = this.add_section("Sessions");
- section._parent.inner().classList.add("sessions-section");
-
- this._init_status_tabs(section, this._status);
+ const section_dom = section._parent.inner();
+ section_dom.classList.add("sessions-section");
+ // The "Sessions" nav item in the banner already identifies the page;
+ // drop the auto-generated section heading so it doesn't duplicate.
+ const heading = section_dom.querySelector(":scope > h1, :scope > h2");
+ if (heading) { heading.remove(); }
const query = (this._status === "ended" || this._status === "all") ? "?status=" + this._status : "";
const data = await new Fetcher().resource("/sessions/" + query).json();
const sessions = data.sessions || [];
this._self_id = data.self_id || null;
- // Layout: table on the left, detail panel on the right
- this._container = section.tag().classify("sessions-layout");
- this._table_host = this._container.tag().classify("sessions-table");
- this._detail_panel = this._container.tag().classify("sessions-detail");
- this._detail_panel.tag().classify("sessions-detail-placeholder").text("Select a session to view details.");
+ // Flat vertical layout: header row, then table, then the bottom panel
+ // (tabs for log and metadata). All session-level info that used to
+ // live in a side panel is now shown inline in the table columns.
+ this._init_status_tabs(section, this._status);
+ this._table_host = section.tag().classify("sessions-table");
this._selected_id = this._self_id;
this._selected_row = null;
- this._page_size = 25;
+ this._page_size = 10;
this._page = 0;
-
- // Log panel below the table/detail layout
- this._log_panel = section.tag().classify("sessions-log-panel");
- this._log_panel.inner().style.display = "none";
- this._log_poll_timer = null;
+ this._text_filter = "";
+ this._sort_key = "created_at";
+ this._sort_asc = false;
+
+ this._panel = section.tag().classify("sessions-log-panel");
+ this._panel.inner().style.display = "none";
+ this._active_tab = "log";
+ this._log_expanded = false;
+ // Persist the level threshold across session selections so users
+ // don't have to re-pick after clicking a different session.
+ this._log_min_level = -1;
+ this._log_min_level_name = "all";
+ this._collapsed_session_groups = new Set();
this._render_sessions(sessions);
this._connect_ws();
@@ -68,8 +141,16 @@ export class Page extends ZenPage
{
const status = this._status;
- // Clear existing table content
+ // Clear existing table content (and any prior pager contents; the
+ // pager lives in the header row but its state depends on what we're
+ // about to render).
this._table_host.inner().replaceChildren();
+ if (this._pager_host)
+ {
+ this._pager_host.replaceChildren();
+ }
+
+ this._last_sessions = sessions;
if (sessions.length === 0)
{
@@ -79,74 +160,247 @@ export class Page extends ZenPage
return;
}
- let columns;
+ // Apply the text filter (case-insensitive substring match across the
+ // session fields a user is likely to scan for: id, appname, mode).
+ const filter = this._text_filter;
+ let filtered = filter
+ ? sessions.filter(s => {
+ const haystack = [s.id, s.appname, s.mode].filter(v => v).join(" ").toLowerCase();
+ return haystack.includes(filter);
+ })
+ : sessions;
+
+ if (filtered.length === 0)
+ {
+ this._table_host.tag().classify("empty-state").text("No sessions match the filter.");
+ this._selected_row = null;
+ return;
+ }
+
+ // When the log panel is expanded, collapse the row set to just the
+ // selected session so the log gets the maximum vertical real estate.
+ // The expand toggle lives in the panel header — see _show_session_panel.
+ if (this._log_expanded && this._selected_id)
+ {
+ const selected = sessions.find(s => s.id === this._selected_id);
+ if (selected) { filtered = [selected]; }
+ }
+
+ // Column specs carry both the header label and how to extract the
+ // real sort value so date columns compare chronologically rather
+ // than by locale-formatted text.
+ const str_val = (field) => (s) => (s[field] || "").toLowerCase();
+ const date_val = (field) => (s) => s[field] ? new Date(s[field]).getTime() : 0;
+
+ const common = [
+ { name: "appname", key: "appname", kind: "str", get: str_val("appname") },
+ { name: "mode", key: "mode", kind: "str", get: str_val("mode") },
+ { name: "platform", key: "platform", kind: "str", get: str_val("platform") },
+ { name: "id", key: "id", kind: "str", get: str_val("id") },
+ { name: "created", key: "created_at", kind: "date", get: date_val("created_at") },
+ ];
+ let last_col;
if (status === "all")
{
- columns = ["id", "appname", "mode", "created", "last activity"];
+ last_col = { name: "last activity", key: "last_activity", kind: "date",
+ get: s => new Date(s.ended_at || s.updated_at || 0).getTime() };
}
else if (status === "ended")
{
- columns = ["id", "appname", "mode", "created", "ended"];
+ last_col = { name: "ended", key: "ended_at", kind: "date", get: date_val("ended_at") };
}
else
{
- columns = ["id", "appname", "mode", "created", "updated"];
+ last_col = { name: "updated", key: "updated_at", kind: "date", get: date_val("updated_at") };
}
- this._last_sessions = sessions;
- const total = sessions.length;
+ const col_specs = [...common, last_col];
+
+ // Pick the active sort column (fall back to created_at if the current
+ // sort key isn't in this tab's column set — e.g. switching from "all"
+ // back to "ended" after sorting by last_activity).
+ const sort_col = col_specs.find(c => c.key === this._sort_key) || col_specs.find(c => c.key === "created_at");
+ const dir = this._sort_asc ? 1 : -1;
+ const compare_sessions = (a, b) => {
+ const av = sort_col.get(a), bv = sort_col.get(b);
+ if (av < bv) return -1 * dir;
+ if (av > bv) return 1 * dir;
+ return 0;
+ };
+ const grouped = this._build_session_groups(filtered);
+ grouped.parents.sort(compare_sessions);
+ for (const parent of grouped.parents)
+ {
+ grouped.children.get(parent.id)?.sort(compare_sessions);
+ }
+
+ const total = grouped.parents.length;
const page_count = this._page_size > 0 ? Math.ceil(total / this._page_size) : 1;
if (this._page >= page_count) { this._page = Math.max(0, page_count - 1); }
const start = this._page_size > 0 ? this._page * this._page_size : 0;
- const visible = this._page_size > 0 ? sessions.slice(start, start + this._page_size) : sessions;
+ const visible = this._page_size > 0 ? grouped.parents.slice(start, start + this._page_size) : grouped.parents;
- const table = new Table(this._table_host, columns, Table.Flag_FitLeft);
+ const column_names = col_specs.map(c => c.name);
+ const table = new Table(this._table_host, column_names, Table.Flag_FitLeft, -1);
+
+ // Attach header click handlers + active-column indicator.
+ const zen_table = this._table_host.inner().querySelector(".zen_table");
+ const header_elem = zen_table ? zen_table.firstElementChild : null;
+ if (header_elem)
+ {
+ const header_cells = header_elem.children;
+ for (let i = 0; i < col_specs.length; i++)
+ {
+ const col = col_specs[i];
+ const cell = header_cells[i];
+ if (!cell) { continue; }
+ cell.style.cursor = "pointer";
+ cell.style.userSelect = "none";
+ if (col.key === sort_col.key)
+ {
+ cell.textContent = col.name + (this._sort_asc ? " \u25B2" : " \u25BC");
+ cell.classList.add("sessions-sort-active");
+ }
+ cell.addEventListener("click", () => {
+ if (this._sort_key === col.key)
+ {
+ this._sort_asc = !this._sort_asc;
+ }
+ else
+ {
+ this._sort_key = col.key;
+ // New column defaults: dates start descending (newest
+ // first — the natural reading for timestamps); string
+ // columns start ascending (A→Z).
+ this._sort_asc = col.kind !== "date";
+ }
+ this._page = 0;
+ this._render_sessions(this._last_sessions);
+ });
+ }
+ }
let new_selected_row = null;
let new_selected_session = null;
- for (const session of visible)
- {
+ const render_session_row = (session, is_child = false, child_count = 0) => {
const created = fmt_date(session.created_at);
const updated = fmt_date(session.updated_at);
const ended = fmt_date(session.ended_at);
const mode = session.mode || "-";
+ const appname = session.appname || "-";
+ const platform = session.platform || "";
+ const full_id = session.id || "";
+ // Elide the middle of the 24-char OID so the column stays narrow;
+ // the full id is still available as a tooltip on the cell below.
+ const id_display = full_id.length > 12
+ ? full_id.slice(0, 8) + "\u2026" + full_id.slice(-4)
+ : (full_id || "-");
+
let row_values;
if (status === "all")
{
const last_activity = session.ended_at ? ended : updated;
- row_values = [session.id || "-", session.appname || "-", mode, created, last_activity];
+ row_values = [appname, mode, platform, id_display, created, last_activity];
}
else if (status === "ended")
{
- row_values = [session.id || "-", session.appname || "-", mode, created, ended];
+ row_values = [appname, mode, platform, id_display, created, ended];
}
else
{
- row_values = [session.id || "-", session.appname || "-", mode, created, updated];
+ row_values = [appname, mode, platform, id_display, created, updated];
}
const row = table.add_row(...row_values);
+ // Swap the platform cell's text for a recognizable icon. Sort
+ // already runs on session.platform so the cell content doesn't
+ // affect ordering.
+ const platform_cell = row.get_cell(2).inner();
+ platform_cell.replaceChildren(make_platform_cell(platform));
+
+ if (full_id)
+ {
+ row.get_cell(3).inner().title = full_id;
+ }
+
+ // Indicator layout in the appname cell: [group toggle] [child elbow]
+ // [dot] appname [this] [log]. The pills sit after the name so their
+ // widths don't push names around and misalign the column across rows.
+ const appname_cell = row.get_cell(0);
+ if (child_count > 0)
+ {
+ const collapsed = this._collapsed_session_groups.has(session.id);
+ const toggle = document.createElement("button");
+ toggle.type = "button";
+ toggle.className = "sessions-group-toggle";
+ toggle.textContent = collapsed ? "\u25B8" : "\u25BE";
+ toggle.title = collapsed ? "Expand child sessions" : "Collapse child sessions";
+ toggle.addEventListener("click", (ev) => {
+ ev.stopPropagation();
+ if (collapsed)
+ {
+ this._collapsed_session_groups.delete(session.id);
+ }
+ else
+ {
+ this._collapsed_session_groups.add(session.id);
+ }
+ this._render_sessions(this._last_sessions);
+ });
+ appname_cell.inner().prepend(toggle);
+ }
+ else if (is_child)
+ {
+ const spacer = document.createElement("span");
+ spacer.className = "sessions-group-child-spacer";
+ spacer.textContent = "\u2514";
+ appname_cell.inner().prepend(spacer);
+ }
+ else
+ {
+ const spacer = document.createElement("span");
+ spacer.className = "sessions-group-toggle-spacer";
+ appname_cell.inner().prepend(spacer);
+ }
+
if (status === "all" && !session.ended_at)
{
- const id_cell = row.get_cell(0);
const dot = document.createElement("span");
dot.className = "health-dot health-green";
dot.style.marginRight = "6px";
dot.style.width = "8px";
dot.style.height = "8px";
dot.title = "active";
- id_cell.inner().prepend(dot);
+ appname_cell.inner().insertBefore(dot, appname_cell.inner().firstChild.nextSibling);
}
if (this._self_id && session.id === this._self_id)
{
const pill = document.createElement("span");
- pill.className = "sessions-self-pill";
+ pill.className = "sessions-pill sessions-self-pill";
pill.textContent = "this";
- row.get_cell(1).inner().prepend(pill);
+ appname_cell.inner().appendChild(pill);
+ }
+
+ if (session.log_count)
+ {
+ const log_pill = document.createElement("span");
+ log_pill.className = "sessions-pill sessions-log-indicator-pill";
+ log_pill.textContent = "log";
+ log_pill.title = session.log_count + " log entr" + (session.log_count === 1 ? "y" : "ies");
+ appname_cell.inner().appendChild(log_pill);
+ }
+
+ const row_elem = row.inner();
+ if (is_child)
+ {
+ for (const cell of row_elem.children)
+ {
+ cell.classList.add("sessions-child-row");
+ }
}
// Restore selection
@@ -157,12 +411,28 @@ export class Page extends ZenPage
}
// Table rows use display:contents so we attach click to each cell
- const row_elem = row.inner();
for (const cell of row_elem.children)
{
cell.style.cursor = "pointer";
cell.addEventListener("click", () => this._select_session(row, session));
}
+ };
+
+ const render_session_tree = (session, is_child = false) => {
+ const children = grouped.children.get(session.id) || [];
+ render_session_row(session, is_child, children.length);
+ if (!this._collapsed_session_groups.has(session.id))
+ {
+ for (const child of children)
+ {
+ render_session_tree(child, true);
+ }
+ }
+ };
+
+ for (const session of visible)
+ {
+ render_session_tree(session);
}
this._selected_row = null;
@@ -171,40 +441,106 @@ export class Page extends ZenPage
this._select_session(new_selected_row, new_selected_session);
}
- if (this._page_size > 0 && total > this._page_size)
+ this._render_pager(total, page_count);
+ }
+
+ _build_session_groups(sessions)
+ {
+ const by_id = new Map();
+ for (const session of sessions)
{
- const footer = document.createElement("div");
- footer.className = "sessions-pager";
+ if (session.id)
+ {
+ by_id.set(session.id, session);
+ }
+ }
- const make_btn = (label, enabled, on_click) => {
- const btn = document.createElement("button");
- btn.className = "history-tab";
- btn.textContent = label;
- btn.disabled = !enabled;
- if (enabled)
+ const parents = [];
+ const children = new Map();
+ for (const session of sessions)
+ {
+ const parent_id = session.parent_session_id;
+ if (parent_id && by_id.has(parent_id) && parent_id !== session.id)
+ {
+ let group = children.get(parent_id);
+ if (!group)
{
- btn.addEventListener("click", on_click);
+ group = [];
+ children.set(parent_id, group);
}
- return btn;
- };
+ group.push(session);
+ }
+ else
+ {
+ parents.push(session);
+ }
+ }
- footer.appendChild(make_btn("\u25C0", this._page > 0, () => {
- this._page--;
- this._render_sessions(sessions);
- }));
+ // Keep the currently selected child visible when live updates rebuild the
+ // table by automatically expanding the group that contains it.
+ if (this._selected_id)
+ {
+ for (const [parent_id, group] of children)
+ {
+ if (group.some(s => s.id === this._selected_id))
+ {
+ this._collapsed_session_groups.delete(parent_id);
+ break;
+ }
+ }
+ }
- const label = document.createElement("span");
- label.className = "sessions-pager-label";
- label.textContent = `${this._page + 1} / ${page_count}`;
- footer.appendChild(label);
+ return { parents, children };
+ }
- footer.appendChild(make_btn("\u25B6", this._page < page_count - 1, () => {
- this._page++;
- this._render_sessions(sessions);
- }));
+ // Shared button.history-tab builder used for both the pager arrows and
+ // the status-mode tab strip. opts: { active?, disabled?, on_click? }.
+ _make_history_tab(label, opts)
+ {
+ const btn = document.createElement("button");
+ btn.className = "history-tab";
+ btn.textContent = label;
+ if (opts.active) { btn.classList.add("active"); }
+ if (opts.disabled) { btn.disabled = true; }
+ if (opts.on_click && !opts.disabled)
+ {
+ btn.addEventListener("click", opts.on_click);
+ }
+ return btn;
+ }
- this._table_host.inner().appendChild(footer);
+ _render_pager(total, page_count)
+ {
+ if (!this._pager_host)
+ {
+ return;
+ }
+ this._pager_host.replaceChildren();
+ if (!(this._page_size > 0 && total > this._page_size))
+ {
+ return;
}
+
+ this._pager_host.appendChild(this._make_history_tab("\u25C0", {
+ disabled: this._page === 0,
+ on_click: () => {
+ this._page--;
+ this._render_sessions(this._last_sessions);
+ },
+ }));
+
+ const label = document.createElement("span");
+ label.className = "sessions-pager-label";
+ label.textContent = `${this._page + 1} / ${page_count}`;
+ this._pager_host.appendChild(label);
+
+ this._pager_host.appendChild(this._make_history_tab("\u25B6", {
+ disabled: this._page >= page_count - 1,
+ on_click: () => {
+ this._page++;
+ this._render_sessions(this._last_sessions);
+ },
+ }));
}
_filter_sessions(all_sessions)
@@ -222,11 +558,15 @@ export class Page extends ZenPage
_connect_ws()
{
- try
- {
+ quietly("ws connect", () => {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${location.host}/sessions/ws`);
- try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) { this._ws_paused = false; }
+ // Log-push frames arrive as compact-binary over binary frames;
+ // asking for ArrayBuffer (default in modern browsers, explicit
+ // here) lets us feed the bytes straight into our CB parser.
+ ws.binaryType = "arraybuffer";
+ this._ws_paused = quietly("ws-paused storage read", () =>
+ localStorage.getItem("zen-ws-paused") === "true") === true;
document.addEventListener("zen-ws-toggle", (e) => {
this._ws_paused = e.detail.paused;
});
@@ -236,52 +576,189 @@ export class Page extends ZenPage
{
return;
}
- try
+ // Two transports share this socket:
+ // - text/JSON: the session-list snapshots broadcast on a
+ // timer (untyped for backward compat).
+ // - binary/CB: event-driven log deltas, stamped with
+ // type="log" so future frame types can be added.
+ if (typeof ev.data === "string")
{
- const data = JSON.parse(ev.data);
- if (data.self_id) { this._self_id = data.self_id; }
- const all_sessions = data.sessions || [];
- const filtered = this._filter_sessions(all_sessions);
- this._render_sessions(filtered);
+ quietly("ws json frame", () => {
+ const data = JSON.parse(ev.data);
+ if (data.self_id) { this._self_id = data.self_id; }
+ const all_sessions = data.sessions || [];
+ const filtered = this._filter_sessions(all_sessions);
+ this._render_sessions(filtered);
+ });
+ }
+ else if (ev.data instanceof ArrayBuffer)
+ {
+ quietly("ws cb frame", () => {
+ const bytes = new Uint8Array(ev.data);
+ // CbObject extends CbFieldView, not CbObjectView —
+ // to_js_object() lives on CbObjectView.prototype.
+ // Bridge via as_object() which wraps the field as
+ // a view of the same underlying bytes.
+ const frame = new CbObject(bytes).as_object().to_js_object();
+ this._handle_ws_frame(frame);
+ });
}
- catch (e) { /* ignore parse errors */ }
};
+ ws.onopen = () => {
+ // Resubscribe after a (re)connect if a session is already
+ // selected, so live tailing resumes without a reselect.
+ this._resubscribe_log();
+ };
ws.onclose = () => { this._ws = null; };
ws.onerror = () => { ws.close(); };
this._ws = ws;
+ });
+ }
+
+ _handle_ws_frame(frame)
+ {
+ if (frame && frame.type === "log")
+ {
+ // Guard against stale deltas that arrive after the user has
+ // switched sessions — the server does its best but there's
+ // always a window.
+ if (frame.session !== this._log_session_id)
+ {
+ return;
+ }
+ if (typeof frame.cursor === "number" && frame.cursor < this._log_cursor)
+ {
+ // Cursor regressed (session reset while we were subscribed).
+ this._resync_log_from_zero();
+ return;
+ }
+ this._log_cursor = frame.cursor || this._log_cursor;
+ if (Array.isArray(frame.entries) && frame.entries.length > 0)
+ {
+ this._append_log_entries(frame.entries);
+ }
}
- catch (e) { /* WebSocket not available */ }
+ }
+
+ // Wipe the panel and re-replay the log from cursor 0, then re-
+ // subscribe so the WS feeds deltas from the fresh tail. Used both
+ // when the WS frame reports a cursor regression (session reset
+ // while we were subscribed) and when an HTTP refetch sees the same.
+ // Returns the underlying fetch promise so callers can await if they
+ // need ordering guarantees.
+ _resync_log_from_zero()
+ {
+ this._log_cursor = 0;
+ if (this._log_body) { this._log_body.replaceChildren(); }
+ return this._fetch_log().then(() => this._subscribe_log());
+ }
+
+ _ws_send(obj)
+ {
+ const ws = this._ws;
+ if (!ws || ws.readyState !== WebSocket.OPEN) { return false; }
+ return quietly("ws send", () => { ws.send(JSON.stringify(obj)); return true; }) === true;
+ }
+
+ _subscribe_log()
+ {
+ if (!this._log_session_id) { return; }
+ // Don't subscribe until the initial replay has resolved — the
+ // cursor is stale 0 until then and the server would flush the
+ // entire history we're about to fetch via HTTP, duplicating
+ // every line in the DOM.
+ if (!this._log_fetch_done) { return; }
+ this._ws_send({ type: "sub_log", session: this._log_session_id, cursor: this._log_cursor | 0 });
+ }
+
+ _unsubscribe_log()
+ {
+ this._ws_send({ type: "unsub_log" });
+ }
+
+ // Called on ws.onopen to restore the subscription after a reconnect.
+ _resubscribe_log()
+ {
+ this._subscribe_log();
}
_init_status_tabs(host, active_status)
{
+ const row = document.createElement("div");
+ row.className = "sessions-header-row";
+ host.tag().inner().appendChild(row);
+
const tabs_el = document.createElement("div");
tabs_el.className = "history-tabs";
- tabs_el.style.marginBottom = "8px";
- tabs_el.style.width = "fit-content";
- host.tag().inner().appendChild(tabs_el);
+ row.appendChild(tabs_el);
const make_tab = (label, mode) => {
- const btn = document.createElement("button");
- btn.className = "history-tab";
- btn.textContent = label;
- if (mode === active_status)
- {
- btn.classList.add("active");
- }
- btn.addEventListener("click", () => {
- if (mode === active_status) { return; }
- this.set_param("status", mode);
- this.reload();
- });
- tabs_el.appendChild(btn);
+ tabs_el.appendChild(this._make_history_tab(label, {
+ active: mode === active_status,
+ on_click: mode === active_status ? null : () => {
+ this.set_param("status", mode);
+ this.reload();
+ },
+ }));
};
make_tab("Active", "active");
make_tab("Ended", "ended");
make_tab("All", "all");
+
+ const filter_input = document.createElement("input");
+ filter_input.type = "search";
+ filter_input.className = "sessions-list-filter";
+ filter_input.placeholder = "Filter\u2026";
+ filter_input.autocomplete = "off";
+ filter_input.spellcheck = false;
+ filter_input.addEventListener("input", () => {
+ this._text_filter = filter_input.value.toLowerCase().trim();
+ this._page = 0;
+ if (this._last_sessions)
+ {
+ this._render_sessions(this._last_sessions);
+ }
+ });
+ row.appendChild(filter_input);
+
+ // Right-aligned pager host; populated per-render in _render_sessions
+ // so the arrows don't live inside the table (where they'd shift
+ // vertically as the table grows/shrinks between pages).
+ const spacer = document.createElement("span");
+ spacer.style.flex = "1";
+ row.appendChild(spacer);
+
+ this._pager_host = document.createElement("div");
+ this._pager_host.className = "sessions-header-pager";
+ row.appendChild(this._pager_host);
+ }
+
+ _session_detail_metadata(session)
+ {
+ const details = {
+ status: session.ended_at ? "ended" : "active",
+ session_id: session.id || "-",
+ parent_session_id: session.parent_session_id || "-",
+ appname: session.appname || "-",
+ mode: session.mode || "-",
+ platform: session.platform || "-",
+ pid: session.pid || "-",
+ jobid: session.jobid || "-",
+ created_at: session.created_at ? fmt_date(session.created_at) : "-",
+ updated_at: session.updated_at ? fmt_date(session.updated_at) : "-",
+ };
+ if (session.ended_at)
+ {
+ details.ended_at = fmt_date(session.ended_at);
+ }
+ if (session.log_count)
+ {
+ details.log_count = session.log_count;
+ }
+ return details;
}
_select_session(row, session)
@@ -304,72 +781,89 @@ export class Page extends ZenPage
cell.classList.add("sessions-selected");
}
- // Only rebuild the detail panel and log when the session changes
- if (!changed)
- {
- return;
- }
-
- // Rebuild detail panel
- const panel = this._detail_panel;
- panel.inner().replaceChildren();
-
- panel.tag("h3").text("Session Details");
-
- const props = new PropTable(panel);
- props.add_property("id", session.id || "-");
- props.add_property("appname", session.appname || "-");
- if (session.mode)
+ // Rebuild the bottom panel only when the selection actually changes.
+ if (changed)
{
- props.add_property("mode", session.mode);
+ this._show_session_panel(session);
}
- if (session.jobid)
- {
- props.add_property("jobid", session.jobid);
- }
- props.add_property("created", fmt_date(session.created_at));
- props.add_property("updated", fmt_date(session.updated_at));
- if (session.ended_at)
- {
- props.add_property("ended", fmt_date(session.ended_at));
- }
-
- if (session.metadata && Object.keys(session.metadata).length > 0)
- {
- panel.tag("h3").text("Metadata");
- const meta_props = new PropTable(panel);
- meta_props.add_object(session.metadata);
- }
-
- // Show log panel for this session
- this._show_log(session.id);
}
- _show_log(session_id)
+ _show_session_panel(session)
{
- // Stop any existing poll
- if (this._log_poll_timer)
+ // Unsubscribe from the previous session's log stream (if any)
+ // before we switch. The server treats a subsequent sub_log as a
+ // replacement, but an explicit unsub makes the intent clear and
+ // stops any in-flight pushes that could arrive as we're wiping
+ // the panel.
+ if (this._log_session_id && this._log_session_id !== session.id)
{
- clearInterval(this._log_poll_timer);
- this._log_poll_timer = null;
+ this._unsubscribe_log();
}
- this._log_session_id = session_id;
+ this._log_session_id = session.id;
this._log_cursor = 0; // monotonic cursor for incremental fetching
+ this._log_fetch_done = false; // gates _subscribe_log until replay resolves
this._log_follow = true;
this._log_newest_first = true;
+ this._log_filter = "";
- this._log_panel.inner().style.display = "";
- this._log_panel.inner().replaceChildren();
+ this._panel.inner().style.display = "";
+ this._panel.inner().replaceChildren();
- // Header
+ // Header with tab strip, filter, and log-view controls
const header = document.createElement("div");
header.className = "sessions-log-header";
- const title = document.createElement("span");
- title.className = "sessions-log-title";
- title.textContent = "Log";
- header.appendChild(title);
+ const tabs = document.createElement("div");
+ tabs.className = "sessions-panel-tabs";
+ header.appendChild(tabs);
+
+ const log_tab = document.createElement("button");
+ log_tab.type = "button";
+ log_tab.className = "sessions-panel-tab";
+ log_tab.textContent = "Log";
+ tabs.appendChild(log_tab);
+
+ const meta_tab = document.createElement("button");
+ meta_tab.type = "button";
+ meta_tab.className = "sessions-panel-tab";
+ meta_tab.textContent = "Metadata";
+ tabs.appendChild(meta_tab);
+
+ // Spacer sits between the tab strip and the right-hand controls so
+ // the Expand button stays flush right on both tabs (log_controls is
+ // hidden on Metadata — if the spacer lived there too, the button
+ // would jump left).
+ const spacer = document.createElement("span");
+ spacer.className = "sessions-log-spacer";
+ header.appendChild(spacer);
+
+ // Log-only controls: filter, newest-first, follow. Hidden when the
+ // Metadata tab is active since they don't apply there.
+ const log_controls = document.createElement("span");
+ log_controls.className = "sessions-log-controls";
+ header.appendChild(log_controls);
+
+ // Level filter: hides entries below the selected severity. Sits
+ // before the text filter since level is a coarser cut than text.
+ const level_select = document.createElement("select");
+ level_select.className = "sessions-log-level-filter";
+ level_select.title = "Hide log entries below this severity level";
+ for (const opt of LEVEL_FILTER_OPTIONS)
+ {
+ const o = document.createElement("option");
+ o.value = opt.value;
+ o.textContent = opt.label;
+ level_select.appendChild(o);
+ }
+ level_select.value = this._log_min_level_name;
+ level_select.addEventListener("change", () => {
+ this._log_min_level_name = level_select.value;
+ const opt = LEVEL_FILTER_OPTIONS.find(o => o.value === level_select.value);
+ this._log_min_level = opt ? opt.rank : -1;
+ this._apply_log_filter();
+ });
+ log_controls.appendChild(level_select);
const filter_input = document.createElement("input");
filter_input.type = "text";
@@ -379,12 +873,7 @@ export class Page extends ZenPage
this._log_filter = filter_input.value.toLowerCase();
this._apply_log_filter();
});
- header.appendChild(filter_input);
- this._log_filter = "";
-
- const spacer = document.createElement("span");
- spacer.style.flex = "1";
- header.appendChild(spacer);
+ log_controls.appendChild(filter_input);
const order_btn = document.createElement("button");
order_btn.className = "history-tab active";
@@ -394,7 +883,7 @@ export class Page extends ZenPage
order_btn.classList.toggle("active", this._log_newest_first);
this._reorder_log();
});
- header.appendChild(order_btn);
+ log_controls.appendChild(order_btn);
const follow_btn = document.createElement("button");
follow_btn.className = "history-tab active";
@@ -407,30 +896,113 @@ export class Page extends ZenPage
this._scroll_to_follow();
}
});
- header.appendChild(follow_btn);
+ log_controls.appendChild(follow_btn);
this._log_follow_btn = follow_btn;
- this._log_panel.inner().appendChild(header);
+ // Expand / collapse toggle: applies to the whole page layout (table
+ // vs log panel balance) so it lives outside log_controls and stays
+ // visible on both tabs. Double-chevron direction mirrors the way
+ // the panel grows — up when there's room to expand, down when
+ // expanded and ready to collapse back.
+ const expand_btn = document.createElement("button");
+ expand_btn.type = "button";
+ expand_btn.className = "history-tab sessions-panel-toggle";
+ const refresh_toggle = () => {
+ expand_btn.innerHTML = this._log_expanded ? ICON_CHEVRON_DOWN : ICON_CHEVRON_UP;
+ expand_btn.title = this._log_expanded
+ ? "Restore the sessions table"
+ : "Collapse the sessions table to focus on this session's log";
+ expand_btn.setAttribute("aria-label", this._log_expanded ? "Collapse log panel" : "Expand log panel");
+ expand_btn.classList.toggle("active", this._log_expanded);
+ };
+ refresh_toggle();
+ expand_btn.addEventListener("click", () => {
+ this._log_expanded = !this._log_expanded;
+ refresh_toggle();
+ if (this._last_sessions) { this._render_sessions(this._last_sessions); }
+ });
+ header.appendChild(expand_btn);
+
+ this._panel.inner().appendChild(header);
// Log body
- const body = document.createElement("div");
- body.className = "sessions-log-body";
- body.addEventListener("scroll", () => {
+ const log_body = document.createElement("div");
+ log_body.className = "sessions-log-body";
+ log_body.addEventListener("scroll", () => {
const at_follow_edge = this._log_newest_first
- ? (body.scrollTop <= 4)
- : (body.scrollTop + body.clientHeight >= body.scrollHeight - 4);
+ ? (log_body.scrollTop <= 4)
+ : (log_body.scrollTop + log_body.clientHeight >= log_body.scrollHeight - 4);
if (this._log_follow !== at_follow_edge)
{
this._log_follow = at_follow_edge;
this._log_follow_btn.classList.toggle("active", this._log_follow);
}
});
- this._log_panel.inner().appendChild(body);
- this._log_body = body;
+ this._panel.inner().appendChild(log_body);
+ this._log_body = log_body;
+
+ // Metadata/details body. Keep polling running regardless of which tab is
+ // visible so cursors stay fresh. Free-form metadata gets the primary
+ // left-hand panel; core session fields sit beside it on the right.
+ // Use .tag() so child panels are real Components — PropTable reaches into
+ // its parent's DOM element through the Component API.
+ const meta_body_widget = this._panel.tag().classify("sessions-metadata-body");
+ const meta_body = meta_body_widget.inner();
+ const meta_layout = meta_body_widget.tag().classify("sessions-metadata-layout");
+ const metadata_panel = meta_layout.tag().classify("sessions-metadata-panel");
+ const details_panel = meta_layout.tag().classify("sessions-metadata-panel").classify("sessions-metadata-core-panel");
+
+ const metadata_heading = document.createElement("div");
+ metadata_heading.className = "sessions-metadata-heading";
+ metadata_heading.textContent = "Metadata";
+ metadata_panel.inner().appendChild(metadata_heading);
+
+ const has_metadata = session.metadata && Object.keys(session.metadata).length > 0;
+ if (has_metadata)
+ {
+ const meta_props = new PropTable(metadata_panel);
+ meta_props.add_object(session.metadata);
+ }
+ else
+ {
+ const empty = document.createElement("div");
+ empty.className = "sessions-log-empty";
+ empty.textContent = "No metadata.";
+ metadata_panel.inner().appendChild(empty);
+ }
- // Initial fetch + start polling
- this._fetch_log();
- this._log_poll_timer = setInterval(() => this._fetch_log(), 2000);
+ const details_heading = document.createElement("div");
+ details_heading.className = "sessions-metadata-heading";
+ details_heading.textContent = "Session Information";
+ details_panel.inner().appendChild(details_heading);
+
+ const detail_props = new PropTable(details_panel);
+ detail_props.add_object(this._session_detail_metadata(session));
+
+ const set_active_tab = (tab) => {
+ this._active_tab = tab;
+ const is_log = tab === "log";
+ log_tab.classList.toggle("active", is_log);
+ meta_tab.classList.toggle("active", !is_log);
+ log_body.style.display = is_log ? "" : "none";
+ meta_body.style.display = is_log ? "none" : "";
+ log_controls.style.display = is_log ? "" : "none";
+ };
+ log_tab.addEventListener("click", () => set_active_tab("log"));
+ meta_tab.addEventListener("click", () => set_active_tab("meta"));
+ set_active_tab(this._active_tab || "log");
+
+ // Initial HTTP fetch gives us the full history in one shot; after
+ // it returns we hand off to the WebSocket for live deltas. Mark
+ // the panel as "fetch done" so _resubscribe_log (fired from
+ // ws.onopen) can avoid racing a too-early subscribe with
+ // cursor=0 that'd cause a duplicate flush. No more setInterval
+ // — pushes arrive the moment an entry is appended. See
+ // _handle_ws_frame.
+ this._fetch_log().then(() => {
+ this._log_fetch_done = true;
+ this._subscribe_log();
+ });
}
async _fetch_log()
@@ -453,10 +1025,10 @@ export class Page extends ZenPage
if (cursor < this._log_cursor)
{
- // Cursor went backwards — session was reset. Full re-render.
- this._log_cursor = 0;
- this._log_body.replaceChildren();
- this._fetch_log();
+ // Cursor went backwards — session was reset. Resync via
+ // the shared helper so the WS-frame and HTTP-fetch paths
+ // stay in lockstep.
+ await this._resync_log_from_zero();
return;
}
@@ -471,7 +1043,12 @@ export class Page extends ZenPage
this._show_log_empty();
}
}
- catch (e) { /* ignore */ }
+ catch (e)
+ {
+ // quietly() can't wrap an awaited body, so the catch is open-coded
+ // here — same debug-log policy as the sync paths above.
+ console.debug("[sessions] fetch log:", e);
+ }
}
_show_log_empty()
@@ -520,6 +1097,30 @@ export class Page extends ZenPage
}
}
+ // Cap DOM size. Drop the oldest lines from whichever end of the
+ // container holds them — that's the bottom in newest-first mode,
+ // the top in oldest-first mode. The user can no longer scroll
+ // further back than MAX_LOG_LINES_IN_DOM until they switch
+ // sessions and replay from cursor 0.
+ const overflow = body.children.length - MAX_LOG_LINES_IN_DOM;
+ if (overflow > 0)
+ {
+ if (this._log_newest_first)
+ {
+ for (let i = 0; i < overflow; i++)
+ {
+ body.removeChild(body.lastElementChild);
+ }
+ }
+ else
+ {
+ for (let i = 0; i < overflow; i++)
+ {
+ body.removeChild(body.firstElementChild);
+ }
+ }
+ }
+
if (this._log_follow)
{
this._scroll_to_follow();
@@ -572,12 +1173,56 @@ export class Page extends ZenPage
if (entry.level)
{
+ const key = entry.level.toLowerCase();
+ const rank = LEVEL_RANK[key];
+ // Stamp the rank so _line_passes_filters can check it later
+ // without re-parsing the text. Unknown levels leave it unset.
+ if (rank !== undefined) { line.dataset.levelRank = String(rank); }
const lvl = document.createElement("span");
- lvl.className = "sessions-log-level sessions-log-level-" + entry.level.toLowerCase();
+ lvl.className = "sessions-log-level sessions-log-level-" + key;
lvl.textContent = entry.level;
line.appendChild(lvl);
}
+ // Always render the logger column (even if empty) so the message
+ // column stays aligned across rows whether or not a category is set.
+ const cat = document.createElement("span");
+ cat.className = "sessions-log-logger";
+ if (entry.logger)
+ {
+ cat.textContent = entry.logger;
+ cat.title = entry.logger;
+ }
+ line.appendChild(cat);
+
+ // Marker for UE_LOGFMT structured entries. The server pre-renders
+ // `format` against `fields` into `message`, but both raw pieces ride
+ // along in the JSON so we can flag them visually and let a future UI
+ // hook in for field-level drill-down. Tooltip shows the raw template
+ // plus the arguments bag so you can see exactly what UE sent.
+ if (entry.format)
+ {
+ const fmt_marker = document.createElement("span");
+ fmt_marker.className = "sessions-log-fmt-marker";
+ fmt_marker.textContent = "{\u2026}";
+ let tooltip = "format: " + entry.format;
+ if (entry.fields && Object.keys(entry.fields).length > 0)
+ {
+ try
+ {
+ tooltip += "\nfields: " + JSON.stringify(entry.fields, null, 2);
+ }
+ catch (_e)
+ {
+ // Shouldn't happen for server-produced JSON, but guard
+ // against self-referential structures just in case.
+ tooltip += "\nfields: <unserializable>";
+ }
+ }
+ fmt_marker.title = tooltip;
+ line.appendChild(fmt_marker);
+ }
+
if (entry.message)
{
const msg = document.createElement("span");
@@ -586,20 +1231,36 @@ export class Page extends ZenPage
line.appendChild(msg);
}
- if (entry.data && Object.keys(entry.data).length > 0)
+ if (!this._line_passes_filters(line))
{
- const data_span = document.createElement("span");
- data_span.className = "sessions-log-data";
- data_span.textContent = JSON.stringify(entry.data);
- line.appendChild(data_span);
+ line.style.display = "none";
}
+ return line;
+ }
+
+ // Shared predicate for both the initial render (_create_log_line) and
+ // full sweeps (_apply_log_filter). Keeps the two paths in sync.
+ _line_passes_filters(line)
+ {
+ if (this._log_min_level >= 0)
+ {
+ const rank_str = line.dataset.levelRank;
+ // Entries without a known level rank pass through — they may
+ // carry info that shouldn't be silently dropped (e.g. the
+ // synthetic "session ended" line or legacy entries without a
+ // level field).
+ if (rank_str !== undefined && rank_str !== "")
+ {
+ const rank = Number(rank_str);
+ if (!Number.isNaN(rank) && rank < this._log_min_level) { return false; }
+ }
+ }
if (this._log_filter && !line.textContent.toLowerCase().includes(this._log_filter))
{
- line.style.display = "none";
+ return false;
}
-
- return line;
+ return true;
}
_apply_log_filter()
@@ -608,17 +1269,9 @@ export class Page extends ZenPage
{
return;
}
- const filter = this._log_filter;
for (const line of this._log_body.querySelectorAll(".sessions-log-line"))
{
- if (!filter || line.textContent.toLowerCase().includes(filter))
- {
- line.style.display = "";
- }
- else
- {
- line.style.display = "none";
- }
+ line.style.display = this._line_passes_filters(line) ? "" : "none";
}
}
}