aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend/html/pages/sessions.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/zenserver/frontend/html/pages/sessions.js')
-rw-r--r--src/zenserver/frontend/html/pages/sessions.js617
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";
+ }
+ }
}
}