// Copyright Epic Games, Inc. All Rights Reserved. "use strict"; import { ZenPage } from "./page.js" import { Fetcher } from "../util/fetcher.js" import { Table, PropTable } from "../util/widgets.js" 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(); } return d.toLocaleString(); } function fmt_time(iso) { if (!iso) { return ""; } return new Date(iso).toLocaleTimeString(); } //////////////////////////////////////////////////////////////////////////////// export class Page extends ZenPage { generate_crumbs() {} async main() { this.set_title("sessions"); this._status = this.get_param("status", "active"); const section = this.add_section("Sessions"); section._parent.inner().classList.add("sessions-section"); this._init_status_tabs(section, this._status); 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."); this._selected_id = this._self_id; this._selected_row = null; this._page_size = 25; 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._render_sessions(sessions); this._connect_ws(); } _render_sessions(sessions) { const status = this._status; // Clear existing table content this._table_host.inner().replaceChildren(); 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; } let columns; if (status === "all") { columns = ["id", "appname", "mode", "created", "last activity"]; } else if (status === "ended") { columns = ["id", "appname", "mode", "created", "ended"]; } else { columns = ["id", "appname", "mode", "created", "updated"]; } this._last_sessions = sessions; const total = sessions.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 table = new Table(this._table_host, columns, Table.Flag_FitLeft); let new_selected_row = null; let new_selected_session = null; for (const session of visible) { 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 || "-"; let row_values; if (status === "all") { const last_activity = session.ended_at ? ended : updated; row_values = [session.id || "-", session.appname || "-", mode, created, last_activity]; } else if (status === "ended") { row_values = [session.id || "-", session.appname || "-", mode, created, ended]; } else { row_values = [session.id || "-", session.appname || "-", mode, created, updated]; } const row = table.add_row(...row_values); 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); } if (this._self_id && session.id === this._self_id) { const pill = document.createElement("span"); pill.className = "sessions-self-pill"; pill.textContent = "this"; row.get_cell(1).inner().prepend(pill); } // 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 const row_elem = row.inner(); for (const cell of row_elem.children) { cell.style.cursor = "pointer"; cell.addEventListener("click", () => this._select_session(row, session)); } } this._selected_row = null; if (new_selected_row) { this._select_session(new_selected_row, new_selected_session); } if (this._page_size > 0 && total > this._page_size) { const footer = document.createElement("div"); footer.className = "sessions-pager"; const make_btn = (label, enabled, on_click) => { const btn = document.createElement("button"); btn.className = "history-tab"; btn.textContent = label; btn.disabled = !enabled; if (enabled) { btn.addEventListener("click", on_click); } return btn; }; footer.appendChild(make_btn("\u25C0", this._page > 0, () => { this._page--; this._render_sessions(sessions); })); const label = document.createElement("span"); label.className = "sessions-pager-label"; label.textContent = `${this._page + 1} / ${page_count}`; footer.appendChild(label); footer.appendChild(make_btn("\u25B6", this._page < page_count - 1, () => { this._page++; this._render_sessions(sessions); })); this._table_host.inner().appendChild(footer); } } _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() { try { 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; } document.addEventListener("zen-ws-toggle", (e) => { this._ws_paused = e.detail.paused; }); ws.onmessage = (ev) => { if (this._ws_paused) { return; } try { 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); } catch (e) { /* ignore parse errors */ } }; ws.onclose = () => { this._ws = null; }; ws.onerror = () => { ws.close(); }; this._ws = ws; } catch (e) { /* WebSocket not available */ } } _init_status_tabs(host, active_status) { 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); 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); }; make_tab("Active", "active"); make_tab("Ended", "ended"); make_tab("All", "all"); } _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"); } // 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) { props.add_property("mode", session.mode); } 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) { // Stop any existing poll if (this._log_poll_timer) { clearInterval(this._log_poll_timer); this._log_poll_timer = null; } this._log_session_id = session_id; this._log_cursor = 0; // monotonic cursor for incremental fetching this._log_follow = true; this._log_newest_first = true; this._log_panel.inner().style.display = ""; this._log_panel.inner().replaceChildren(); // Header 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 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(); }); header.appendChild(filter_input); this._log_filter = ""; const spacer = document.createElement("span"); spacer.style.flex = "1"; header.appendChild(spacer); 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(); }); header.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(); } }); header.appendChild(follow_btn); this._log_follow_btn = follow_btn; this._log_panel.inner().appendChild(header); // Log body const body = document.createElement("div"); body.className = "sessions-log-body"; body.addEventListener("scroll", () => { const at_follow_edge = this._log_newest_first ? (body.scrollTop <= 4) : (body.scrollTop + body.clientHeight >= 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; // Initial fetch + start polling this._fetch_log(); this._log_poll_timer = setInterval(() => this._fetch_log(), 2000); } 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. Full re-render. this._log_cursor = 0; this._log_body.replaceChildren(); this._fetch_log(); 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) { /* ignore */ } } _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)); } } 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 lvl = document.createElement("span"); lvl.className = "sessions-log-level sessions-log-level-" + entry.level.toLowerCase(); lvl.textContent = entry.level; line.appendChild(lvl); } if (entry.message) { const msg = document.createElement("span"); msg.className = "sessions-log-msg"; msg.textContent = entry.message; line.appendChild(msg); } if (entry.data && Object.keys(entry.data).length > 0) { const data_span = document.createElement("span"); data_span.className = "sessions-log-data"; data_span.textContent = JSON.stringify(entry.data); line.appendChild(data_span); } if (this._log_filter && !line.textContent.toLowerCase().includes(this._log_filter)) { line.style.display = "none"; } return line; } _apply_log_filter() { if (!this._log_body) { 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"; } } } }