// 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";
}
}
}