// Copyright Epic Games, Inc. All Rights Reserved. "use strict"; 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 = ''; const ICON_CHEVRON_DOWN = ''; function fmt_date(iso) { if (!iso) { return "-"; } const d = new Date(iso); const now = new Date(); if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()) { return d.toLocaleTimeString([], TIME_OPTS); } return d.toLocaleString([], DATE_OPTS); } function fmt_time(iso) { if (!iso) { return ""; } return new Date(iso).toLocaleTimeString([], TIME_OPTS); } //////////////////////////////////////////////////////////////////////////////// export class Page extends ZenPage { generate_crumbs() {} async main() { this.set_title("sessions"); this._status = this.get_param("status", "all"); const section = this.add_section("Sessions"); 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; // 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 = 10; this._page = 0; 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(); } _render_sessions(sessions) { const status = this._status; // 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) { const labels = { active: "No active sessions.", ended: "No ended sessions.", all: "No sessions." }; this._table_host.tag().classify("empty-state").text(labels[status] || labels.all); this._selected_row = null; return; } // 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") { 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") { last_col = { name: "ended", key: "ended_at", kind: "date", get: date_val("ended_at") }; } else { last_col = { name: "updated", key: "updated_at", kind: "date", get: date_val("updated_at") }; } 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 ? grouped.parents.slice(start, start + this._page_size) : grouped.parents; 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; 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 = [appname, mode, platform, id_display, created, last_activity]; } else if (status === "ended") { row_values = [appname, mode, platform, id_display, created, ended]; } else { 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 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"; 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-pill sessions-self-pill"; pill.textContent = "this"; 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 if (this._selected_id && session.id === this._selected_id) { new_selected_row = row; new_selected_session = session; } // Table rows use display:contents so we attach click to each cell 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; if (new_selected_row) { this._select_session(new_selected_row, new_selected_session); } this._render_pager(total, page_count); } _build_session_groups(sessions) { const by_id = new Map(); for (const session of sessions) { if (session.id) { by_id.set(session.id, session); } } 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) { group = []; children.set(parent_id, group); } group.push(session); } else { parents.push(session); } } // 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; } } } return { parents, children }; } // 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; } _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) { if (this._status === "active") { return all_sessions.filter(s => !s.ended_at); } if (this._status === "ended") { return all_sessions.filter(s => s.ended_at); } return all_sessions; } _connect_ws() { quietly("ws connect", () => { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${proto}//${location.host}/sessions/ws`); // 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; }); ws.onmessage = (ev) => { if (this._ws_paused) { return; } // 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") { 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); }); } }; 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); } } } // 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"; row.appendChild(tabs_el); const make_tab = (label, mode) => { 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) { const changed = (this._selected_id !== session.id) || !this._log_session_id; // Deselect previous if (this._selected_row) { for (const cell of this._selected_row.inner().children) { cell.classList.remove("sessions-selected"); } } this._selected_row = row; this._selected_id = session.id; for (const cell of row.inner().children) { cell.classList.add("sessions-selected"); } // Rebuild the bottom panel only when the selection actually changes. if (changed) { this._show_session_panel(session); } } _show_session_panel(session) { // 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) { this._unsubscribe_log(); } 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._panel.inner().style.display = ""; this._panel.inner().replaceChildren(); // Header with tab strip, filter, and log-view controls const header = document.createElement("div"); header.className = "sessions-log-header"; 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"; filter_input.className = "sessions-log-filter"; filter_input.placeholder = "Filter\u2026"; filter_input.addEventListener("input", () => { this._log_filter = filter_input.value.toLowerCase(); this._apply_log_filter(); }); log_controls.appendChild(filter_input); const order_btn = document.createElement("button"); order_btn.className = "history-tab active"; order_btn.textContent = "Newest first"; order_btn.addEventListener("click", () => { this._log_newest_first = !this._log_newest_first; order_btn.classList.toggle("active", this._log_newest_first); this._reorder_log(); }); log_controls.appendChild(order_btn); const follow_btn = document.createElement("button"); follow_btn.className = "history-tab active"; follow_btn.textContent = "Follow"; follow_btn.addEventListener("click", () => { this._log_follow = !this._log_follow; follow_btn.classList.toggle("active", this._log_follow); if (this._log_follow && this._log_body) { this._scroll_to_follow(); } }); log_controls.appendChild(follow_btn); this._log_follow_btn = follow_btn; // 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 log_body = document.createElement("div"); log_body.className = "sessions-log-body"; log_body.addEventListener("scroll", () => { const at_follow_edge = this._log_newest_first ? (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._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); } 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() { if (!this._log_session_id) { return; } try { const data = await new Fetcher() .resource("/sessions/" + this._log_session_id + "/log") .param("cursor", String(this._log_cursor)) .json(); const entries = data.entries || []; const cursor = data.cursor || 0; const count = data.count || 0; if (cursor < this._log_cursor) { // 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; } this._log_cursor = cursor; if (entries.length > 0) { this._append_log_entries(entries); } else if (count === 0 && !this._log_body.hasChildNodes()) { this._show_log_empty(); } } 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() { const body = this._log_body; if (!body) { return; } body.replaceChildren(); const empty = document.createElement("div"); empty.className = "sessions-log-empty"; empty.textContent = "No log entries."; body.appendChild(empty); } _append_log_entries(entries) { const body = this._log_body; if (!body) { return; } // Remove the "No log entries." placeholder if present const empty_el = body.querySelector(".sessions-log-empty"); if (empty_el) { empty_el.remove(); } if (this._log_newest_first) { // Prepend in reverse so newest ends up at the top const first_child = body.firstChild; for (let i = entries.length - 1; i >= 0; i--) { body.insertBefore(this._create_log_line(entries[i]), first_child); } } else { for (const entry of entries) { body.appendChild(this._create_log_line(entry)); } } // 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(); } } _scroll_to_follow() { if (!this._log_body) { return; } if (this._log_newest_first) { this._log_body.scrollTop = 0; } else { this._log_body.scrollTop = this._log_body.scrollHeight; } } _reorder_log() { const body = this._log_body; if (!body) { return; } // Reverse all log line elements in place const lines = Array.from(body.querySelectorAll(".sessions-log-line")); for (const line of lines) { body.prepend(line); } this._scroll_to_follow(); } _create_log_line(entry) { const line = document.createElement("div"); line.className = "sessions-log-line"; const ts = document.createElement("span"); ts.className = "sessions-log-ts"; ts.textContent = fmt_time(entry.timestamp); line.appendChild(ts); 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-" + 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: "; } } fmt_marker.title = tooltip; line.appendChild(fmt_marker); } if (entry.message) { const msg = document.createElement("span"); msg.className = "sessions-log-msg"; msg.textContent = entry.message; line.appendChild(msg); } if (!this._line_passes_filters(line)) { 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)) { return false; } return true; } _apply_log_filter() { if (!this._log_body) { return; } for (const line of this._log_body.querySelectorAll(".sessions-log-line")) { line.style.display = this._line_passes_filters(line) ? "" : "none"; } } }