diff options
Diffstat (limited to 'src/zenserver/frontend/html/pages/sessions.js')
| -rw-r--r-- | src/zenserver/frontend/html/pages/sessions.js | 617 |
1 files changed, 590 insertions, 27 deletions
diff --git a/src/zenserver/frontend/html/pages/sessions.js b/src/zenserver/frontend/html/pages/sessions.js index 95533aa96..c74ede14e 100644 --- a/src/zenserver/frontend/html/pages/sessions.js +++ b/src/zenserver/frontend/html/pages/sessions.js @@ -4,58 +4,621 @@ import { ZenPage } from "./page.js" import { Fetcher } from "../util/fetcher.js" -import { Table } from "../util/widgets.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"); - const data = await new Fetcher().resource("/sessions/").json(); - const sessions = data.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) { - section.tag().classify("empty-state").text("No active sessions."); + 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; } - const columns = [ - "id", - "created", - "updated", - "metadata", - ]; - const table = section.add_widget(Table, columns, Table.Flag_FitLeft); + 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; - for (const session of sessions) + 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) { - const created = session.created_at ? new Date(session.created_at).toLocaleString() : "-"; - const updated = session.updated_at ? new Date(session.updated_at).toLocaleString() : "-"; - const meta = this._format_metadata(session.metadata); + return; + } + body.replaceChildren(); + const empty = document.createElement("div"); + empty.className = "sessions-log-empty"; + empty.textContent = "No log entries."; + body.appendChild(empty); + } - const row = table.add_row( - session.id || "-", - created, - updated, - meta, - ); + _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(); } } - _format_metadata(metadata) + _scroll_to_follow() { - if (!metadata || Object.keys(metadata).length === 0) + if (!this._log_body) { - return "-"; + return; + } + if (this._log_newest_first) + { + this._log_body.scrollTop = 0; } + else + { + this._log_body.scrollTop = this._log_body.scrollHeight; + } + } - return Object.entries(metadata) - .map(([k, v]) => `${k}: ${v}`) - .join(", "); + _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"; + } + } } } |