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/zenserver/frontend | |
| 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/zenserver/frontend')
| -rw-r--r-- | src/zenserver/frontend/html/pages/platform_icons.js | 61 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/pages/sessions.js | 1015 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/theme.js | 108 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/util/compactbinary.js | 10 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/zen.css | 421 |
5 files changed, 1288 insertions, 327 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"; } } } diff --git a/src/zenserver/frontend/html/theme.js b/src/zenserver/frontend/html/theme.js index 52ca116ab..7382d3ea0 100644 --- a/src/zenserver/frontend/html/theme.js +++ b/src/zenserver/frontend/html/theme.js @@ -4,18 +4,25 @@ // Persists choice in localStorage. Applies data-theme attribute on <html>. (function() { - var KEY = 'zen-theme'; - - function getStored() { - try { return localStorage.getItem(KEY); } catch (e) { return null; } + // Wrap localStorage so a single key's get/set/clear all swallow the + // SecurityError that fires in private-mode / cookies-disabled browsers. + // `clear` removes the key entirely (used for theme to reset to system + // preference); `set` stores the raw value passed (callers serialize). + function safeStorage(key) { + return { + get: function() { + try { return localStorage.getItem(key); } catch (e) { return null; } + }, + set: function(value) { + try { localStorage.setItem(key, value); } catch (e) {} + }, + clear: function() { + try { localStorage.removeItem(key); } catch (e) {} + }, + }; } - function setStored(value) { - try { - if (value) localStorage.setItem(KEY, value); - else localStorage.removeItem(KEY); - } catch (e) {} - } + var themeStore = safeStorage('zen-theme'); function apply(theme) { if (theme) @@ -30,32 +37,53 @@ } // Apply stored preference immediately (before paint) - var stored = getStored(); - apply(stored); + apply(themeStore.get()); + + // Wide-mode preference: persisted across sessions, applied before paint + // so the layout doesn't flash at the default width on reload. Lifts the + // 1400px #container cap and the body's horizontal padding so the main + // content fills the viewport edge-to-edge. + var wideStore = safeStorage('zen-wide'); + function getWide() { return wideStore.get() === 'true'; } + function setWide(value) { + if (value) wideStore.set('true'); + else wideStore.clear(); + } + function applyWide(wide) { + if (wide) document.documentElement.setAttribute('data-wide', 'true'); + else document.documentElement.removeAttribute('data-wide'); + } + applyWide(getWide()); + + // Double-chevron SVGs for the wide toggle — outward when content is + // narrow (click to fill the viewport), inward when wide (click to snap + // back to the 1400px cap). currentColor so button styles tint it. + var ICON_WIDEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="8 6 2 12 8 18"/><polyline points="16 6 22 12 16 18"/></svg>'; + var ICON_NARROW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 6 10 12 4 18"/><polyline points="20 6 14 12 20 18"/></svg>'; // Create toggle button once DOM is ready function createToggle() { var btn = document.createElement('button'); btn.id = 'zen_theme_toggle'; + btn.className = 'zen-floating-toggle'; btn.title = 'Toggle theme'; function updateIcon() { - var effective = getEffective(getStored()); + var effective = getEffective(themeStore.get()); // Show sun in dark mode (click to go light), moon in light mode (click to go dark) btn.textContent = effective === 'dark' ? '\u2600' : '\u263E'; - var isManual = getStored() != null; + var isManual = themeStore.get() != null; btn.title = isManual ? 'Theme: ' + effective + ' (click to change, double-click for system)' : 'Theme: system (click to change)'; } btn.addEventListener('click', function() { - var current = getStored(); - var effective = getEffective(current); + var effective = getEffective(themeStore.get()); // Toggle to the opposite var next = effective === 'dark' ? 'light' : 'dark'; - setStored(next); + themeStore.set(next); apply(next); updateIcon(); }); @@ -63,26 +91,26 @@ btn.addEventListener('dblclick', function(e) { e.preventDefault(); // Reset to system preference - setStored(null); + themeStore.clear(); apply(null); updateIcon(); }); // Update icon when system preference changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() { - if (!getStored()) updateIcon(); + if (!themeStore.get()) updateIcon(); }); updateIcon(); document.body.appendChild(btn); // WebSocket pause/play toggle - var WS_KEY = 'zen-ws-paused'; + var wsStore = safeStorage('zen-ws-paused'); var wsBtn = document.createElement('button'); wsBtn.id = 'zen_ws_toggle'; + wsBtn.className = 'zen-floating-toggle'; - var initialPaused = false; - try { initialPaused = localStorage.getItem(WS_KEY) === 'true'; } catch (e) {} + var initialPaused = wsStore.get() === 'true'; function updateWsIcon(paused) { wsBtn.dataset.paused = paused ? 'true' : 'false'; @@ -92,21 +120,43 @@ updateWsIcon(initialPaused); - // Fire initial event so pages pick up persisted state - document.addEventListener('DOMContentLoaded', function() { - if (initialPaused) { - document.dispatchEvent(new CustomEvent('zen-ws-toggle', { detail: { paused: true } })); - } - }); + // No initial event is dispatched: createToggle runs at (or after) + // DOMContentLoaded, so any listener gated on DOMContentLoaded would + // not fire. Page scripts read localStorage('zen-ws-paused') directly + // for their initial paused state and subscribe to zen-ws-toggle for + // subsequent transitions. wsBtn.addEventListener('click', function() { var paused = wsBtn.dataset.paused !== 'true'; - try { localStorage.setItem(WS_KEY, paused ? 'true' : 'false'); } catch (e) {} + wsStore.set(paused ? 'true' : 'false'); updateWsIcon(paused); document.dispatchEvent(new CustomEvent('zen-ws-toggle', { detail: { paused: paused } })); }); document.body.appendChild(wsBtn); + + // Wide-mode toggle. Sits to the left of the pause and theme toggles. + var wideBtn = document.createElement('button'); + wideBtn.id = 'zen_wide_toggle'; + wideBtn.className = 'zen-floating-toggle'; + + function updateWideIcon(wide) { + wideBtn.dataset.wide = wide ? 'true' : 'false'; + wideBtn.innerHTML = wide ? ICON_NARROW : ICON_WIDEN; + wideBtn.title = wide ? 'Narrow the main content' : 'Fill the viewport width'; + wideBtn.setAttribute('aria-label', wide ? 'Narrow content' : 'Widen content'); + } + + updateWideIcon(getWide()); + + wideBtn.addEventListener('click', function() { + var wide = !getWide(); + setWide(wide); + applyWide(wide); + updateWideIcon(wide); + }); + + document.body.appendChild(wideBtn); } if (document.readyState === 'loading') diff --git a/src/zenserver/frontend/html/util/compactbinary.js b/src/zenserver/frontend/html/util/compactbinary.js index 270c96a2f..bd5bf95b3 100644 --- a/src/zenserver/frontend/html/util/compactbinary.js +++ b/src/zenserver/frontend/html/util/compactbinary.js @@ -334,7 +334,7 @@ CbFieldView.prototype.clone = function() { const ret = new CbFieldView() ret._type = this._type; - ret._name = ret._name; + ret._name = this._name; ret._data_view = new Uint8Array(this._data_view); return ret; } @@ -352,8 +352,10 @@ CbObjectView.prototype[Symbol.iterator] = function() var data_view = this.get_payload(); const [payload_size, varint_len] = VarInt.read_uint(data_view); + // Empty object — return a proper empty iterator, not a bare `{}` which + // would crash `for...of` with "undefined is not a function". if (payload_size == 0) - return {}; + return [][Symbol.iterator](); data_view = data_view.subarray(varint_len, payload_size + varint_len); var uniform_type = CbFieldType.HasFieldType; @@ -467,8 +469,10 @@ CbArrayView.prototype[Symbol.iterator] = function() data_view = data_view.subarray(varint_len, payload_size + varint_len); const item_count_bytes = VarInt.measure(data_view); + // Empty array — return a proper empty iterator, not a bare `{}` which + // would crash `for...of` with "undefined is not a function". if (item_count_bytes >= payload_size) - return {}; + return [][Symbol.iterator](); data_view = data_view.subarray(item_count_bytes); var uniform_type = CbFieldType.HasFieldType; diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css index d3c6c9036..46714a83d 100644 --- a/src/zenserver/frontend/html/zen.css +++ b/src/zenserver/frontend/html/zen.css @@ -2,64 +2,12 @@ /* theme -------------------------------------------------------------------- */ -/* system preference (default) */ -@media (prefers-color-scheme: light) { - :root { - --theme_g0: #1f2328; - --theme_g1: #656d76; - --theme_g2: #d0d7de; - --theme_g3: #f6f8fa; - --theme_g4: #ffffff; - - --theme_p0: #0969da; - --theme_p4: #ddf4ff; - --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); - --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); - --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); - - --theme_ln: var(--theme_p0); - --theme_er: #ffebe9; - - --theme_ok: #1a7f37; - --theme_warn: #9a6700; - --theme_fail: #cf222e; - - --theme_bright: #1f2328; - --theme_faint: #6e7781; - --theme_border_subtle: #d8dee4; - --theme_highlight: #b8860b44; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --theme_g0: #c9d1d9; - --theme_g1: #8b949e; - --theme_g2: #30363d; - --theme_g3: #161b22; - --theme_g4: #0d1117; - - --theme_p0: #58a6ff; - --theme_p4: #1c2128; - --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); - --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); - --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); - - --theme_ln: #58a6ff; - --theme_er: #1c1c1c; - - --theme_ok: #3fb950; - --theme_warn: #d29922; - --theme_fail: #f85149; - - --theme_bright: #f0f6fc; - --theme_faint: #6e7681; - --theme_border_subtle: #21262d; - --theme_highlight: #e3b341aa; - } -} - -/* manual overrides (higher specificity than media queries) */ +/* Light tokens apply to the explicit data-theme="light" override and as the + default when no system preference matches the dark @media query below. + Dark tokens apply to data-theme="dark" and (when no explicit preference is + set) to dark system preference. Selector lists keep each token list defined + exactly once. */ +:root, :root[data-theme="light"] { --theme_g0: #1f2328; --theme_g1: #656d76; @@ -67,6 +15,10 @@ --theme_g3: #f6f8fa; --theme_g4: #ffffff; + /* surface backgrounds: bg0 matches the body, bg1 is one step raised */ + --theme_bg0: var(--theme_g4); + --theme_bg1: var(--theme_g3); + --theme_p0: #0969da; --theme_p4: #ddf4ff; --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); @@ -86,6 +38,32 @@ --theme_highlight: #b8860b44; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --theme_g0: #c9d1d9; + --theme_g1: #8b949e; + --theme_g2: #30363d; + --theme_g3: #161b22; + --theme_g4: #0d1117; + + --theme_p0: #58a6ff; + --theme_p4: #1c2128; + + --theme_ln: #58a6ff; + --theme_er: #1c1c1c; + + --theme_ok: #3fb950; + --theme_warn: #d29922; + --theme_fail: #f85149; + + --theme_bright: #f0f6fc; + --theme_faint: #6e7681; + --theme_border_subtle: #21262d; + --theme_highlight: #e3b341aa; + } +} + +/* Manual data-theme="dark" wins over system preference. */ :root[data-theme="dark"] { --theme_g0: #c9d1d9; --theme_g1: #8b949e; @@ -95,9 +73,6 @@ --theme_p0: #58a6ff; --theme_p4: #1c2128; - --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); - --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); - --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); --theme_ln: #58a6ff; --theme_er: #1c1c1c; @@ -114,10 +89,12 @@ /* theme toggle ------------------------------------------------------------- */ -#zen_ws_toggle { +/* Shared shape for the fixed top-right utility buttons (theme, wide, ws-pause). + Per-button rules below add only the `right` offset and any glyph-specific + typography (font-size for emoji buttons, padding-zero for SVG buttons). */ +.zen-floating-toggle { position: fixed; top: 16px; - right: 60px; z-index: 10; width: 36px; height: 36px; @@ -129,43 +106,48 @@ display: flex; align-items: center; justify-content: center; - font-size: 18px; - line-height: 1; transition: color 0.15s, background 0.15s, border-color 0.15s; user-select: none; } -#zen_ws_toggle:hover { +.zen-floating-toggle:hover { color: var(--theme_g0); background: var(--theme_p4); border-color: var(--theme_g1); } #zen_theme_toggle { - position: fixed; - top: 16px; right: 16px; - z-index: 10; - width: 36px; - height: 36px; - border-radius: 6px; - border: 1px solid var(--theme_g2); - background: var(--theme_g3); - color: var(--theme_g1); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; font-size: 18px; line-height: 1; - transition: color 0.15s, background 0.15s, border-color 0.15s; - user-select: none; } -#zen_theme_toggle:hover { - color: var(--theme_g0); - background: var(--theme_p4); - border-color: var(--theme_g1); +#zen_ws_toggle { + right: 60px; + font-size: 18px; + line-height: 1; +} + +#zen_wide_toggle { + right: 104px; + padding: 0; +} + +#zen_wide_toggle svg { + width: 18px; + height: 18px; + display: block; +} + +/* Wide mode: lift the 1400px cap on the main container and drop the body's + horizontal padding so content fills the viewport edge-to-edge. Vertical + body padding stays so content doesn't touch the top of the viewport. */ +html[data-wide="true"] body { + padding-left: 0; + padding-right: 0; +} +html[data-wide="true"] #container { + max-width: none; } /* page --------------------------------------------------------------------- */ @@ -342,15 +324,39 @@ a { height: calc(100vh - 80px); } -.sessions-layout { +.sessions-header-row { display: flex; - gap: 1.5em; - align-items: flex-start; - flex-shrink: 0; + align-items: center; + gap: 12px; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.sessions-list-filter { + padding: 6px 12px; + font-size: 14px; + font-family: inherit; + border: 1px solid var(--theme_g2); + border-radius: 6px; + background: var(--theme_bg1); + color: var(--theme_bright); + outline: none; + width: 240px; + max-width: 100%; +} +.sessions-list-filter:focus { + border-color: var(--theme_ln); + background: var(--theme_bg0); +} +.sessions-list-filter::placeholder { + color: var(--theme_g1); } .sessions-table { - flex: 1; + /* Natural height so the bottom panel sits right below the last row. The + section's column-flex lets .sessions-log-panel (flex: 1) absorb the + remaining vertical space for log viewing. */ + flex-shrink: 0; min-width: 0; } @@ -358,31 +364,157 @@ a { text-align: right; } -.sessions-table .zen_table > div > div:nth-child(2) { +/* appname (col 1), mode (col 2), platform (col 3) read better left-aligned */ +.sessions-table .zen_table > div > div:nth-child(1), +.sessions-table .zen_table > div > div:nth-child(2), +.sessions-table .zen_table > div > div:nth-child(3) { + text-align: left; +} + +/* id (col 4) is a hex string — monospace */ +.sessions-table .zen_table > div > div:nth-child(4) { font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; } -.sessions-detail { - width: 600px; - flex-shrink: 0; - font-size: 13px; +/* Platform-column icon: sized to the table row height, picks up theme color + via currentColor. Unknown platforms fall back to a plain text label. */ +.platform-icon { + display: inline-flex; + align-items: center; + color: var(--theme_g0); + opacity: 0.85; +} +.platform-icon svg { + width: 16px; + height: 16px; + fill: currentColor; } -.sessions-detail h3 { - margin: 0 0 0.6em 0; - font-size: 13px; - text-transform: uppercase; - letter-spacing: 0.5px; +/* Clickable column headers: the first row gets hover affordance. */ +.sessions-table .zen_table > div:first-child > div:hover { + color: var(--theme_g0); +} +.sessions-table .zen_table > div:first-child > div.sessions-sort-active { + color: var(--theme_ln); +} + +.sessions-group-toggle, +.sessions-group-toggle-spacer, +.sessions-group-child-spacer { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + margin-right: 4px; color: var(--theme_g1); + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; } -.sessions-detail .zen_table { - margin-bottom: 1em; +.sessions-group-toggle { + border: 0; + padding: 0; + background: transparent; + cursor: pointer; +} + +.sessions-group-toggle:hover { + color: var(--theme_ln); +} + +.sessions-child-row { + background-color: color-mix(in srgb, var(--theme_bg1) 75%, transparent); +} + +.sessions-child-row:first-child { + padding-left: 14px; } -.sessions-detail-placeholder { +/* Bottom-panel tab strip (Log / Metadata). Lives inside + .sessions-log-header alongside the log-view controls. */ +.sessions-panel-tabs { + display: flex; + gap: 2px; +} +.sessions-panel-tab { + background: transparent; + border: none; + border-bottom: 2px solid transparent; + padding: 4px 10px; color: var(--theme_g1); - font-style: italic; + font: inherit; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; +} +.sessions-panel-tab:hover { + color: var(--theme_g0); +} +.sessions-panel-tab.active { + color: var(--theme_ln); + border-bottom-color: var(--theme_ln); +} + +/* Log-only controls (filter, newest-first, follow). Wrapped so we can hide + them as a group when the Metadata tab is active. Natural width — the + sibling .sessions-log-spacer does the pushing. */ +.sessions-log-controls { + display: flex; + align-items: center; + gap: 8px; +} + +/* Flex spacer between the tab strip and the right-hand controls. Keeps the + Expand button flush right even when log_controls is hidden. */ +.sessions-log-spacer { + flex: 1; +} + +.sessions-metadata-body { + padding: 10px 12px; + overflow-y: auto; + flex: 1; + min-height: 0; +} + +.sessions-metadata-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(280px, max-content); + gap: 16px; + align-items: start; +} + +.sessions-metadata-panel { + min-width: 0; +} + +.sessions-metadata-core-panel { + border-left: 1px solid var(--theme_g3); + padding-left: 16px; +} + +.sessions-metadata-heading { + margin: 10px 0 6px; + color: var(--theme_ln); + font-weight: 600; +} + +.sessions-metadata-heading:first-child { + margin-top: 0; +} + +@media (max-width: 900px) { + .sessions-metadata-layout { + grid-template-columns: 1fr; + } + + .sessions-metadata-core-panel { + border-left: 0; + border-top: 1px solid var(--theme_g3); + padding-left: 0; + padding-top: 10px; + } } .sessions-selected { @@ -415,7 +547,6 @@ a { color: var(--theme_g1); } .sessions-log-filter { - margin-left: 12px; padding: 6px 12px; font-size: 14px; font-family: inherit; @@ -433,6 +564,37 @@ a { .sessions-log-filter::placeholder { color: var(--theme_g1); } +.sessions-log-level-filter { + padding: 4px 8px; + font-size: 12px; + font-family: inherit; + border: 1px solid var(--theme_g2); + border-radius: 6px; + background: var(--theme_bg1); + color: var(--theme_bright); + outline: none; + cursor: pointer; +} +.sessions-log-level-filter:focus { + border-color: var(--theme_ln); +} + +/* Chevron-icon variant of .history-tab for the log-panel expand toggle. + Overrides the text-button padding/letterspacing so the icon sits in a + roughly square pill. Double-chevron up when collapsed, down when + expanded — see ICON_CHEVRON_* in sessions.js. */ +.sessions-panel-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + letter-spacing: 0; +} +.sessions-panel-toggle svg { + width: 14px; + height: 14px; + display: block; +} .sessions-log-body { flex: 1; min-height: 0; @@ -474,29 +636,49 @@ a { font-weight: 600; } .sessions-log-level-info { color: var(--theme_ln); } -.sessions-log-level-warn { color: #d29922; } -.sessions-log-level-error { color: #f85149; } +.sessions-log-level-warn { color: var(--theme_warn); } +.sessions-log-level-error { color: var(--theme_fail); } .sessions-log-level-debug { color: var(--theme_g1); } +.sessions-log-logger { + flex-shrink: 0; + width: 12em; + color: var(--theme_ln); + opacity: 0.75; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .sessions-log-msg { white-space: pre-wrap; word-break: break-all; } -.sessions-log-data { - color: var(--theme_g1); - white-space: pre-wrap; - word-break: break-all; +.sessions-log-fmt-marker { + flex-shrink: 0; + color: var(--theme_ln); + opacity: 0.6; + font-weight: 600; + cursor: help; + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; } -.sessions-self-pill { +.sessions-pill { display: inline-block; font-size: 0.7em; font-weight: 600; padding: 1px 6px; - margin-right: 6px; + margin-left: 6px; border-radius: 8px; + vertical-align: middle; + text-transform: uppercase; + letter-spacing: 0.3px; +} +.sessions-self-pill { background-color: var(--theme_p4); color: var(--theme_g0); - vertical-align: middle; +} +.sessions-log-indicator-pill { + background-color: var(--theme_g2); + color: var(--theme_g0); } .objectstore-bucket-detail { @@ -535,6 +717,17 @@ a { opacity: 0.7; } +/* Pager lives in the header row, to the right of the filter input. No + top margin since it's on the same baseline as tabs/filter. */ +.sessions-header-pager { + display: flex; + align-items: center; + gap: 6px; +} +.sessions-header-pager:empty { + display: none; +} + /* expandable cell ---------------------------------------------------------- */ .zen_expand_icon { |