diff options
Diffstat (limited to 'src/zenserver/frontend/html/pages/orchestrator.js')
| -rw-r--r-- | src/zenserver/frontend/html/pages/orchestrator.js | 405 |
1 files changed, 405 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html/pages/orchestrator.js b/src/zenserver/frontend/html/pages/orchestrator.js new file mode 100644 index 000000000..24805c722 --- /dev/null +++ b/src/zenserver/frontend/html/pages/orchestrator.js @@ -0,0 +1,405 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" +import { Table } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("orchestrator"); + + // Agents section + const agents_section = this._collapsible_section("Compute Agents"); + this._agents_host = agents_section; + this._agents_table = null; + + // Clients section + const clients_section = this._collapsible_section("Connected Clients"); + this._clients_host = clients_section; + this._clients_table = null; + + // Event history + const history_section = this._collapsible_section("Worker Events"); + this._history_host = history_section; + this._history_table = null; + + const client_history_section = this._collapsible_section("Client Events"); + this._client_history_host = client_history_section; + this._client_history_table = null; + + this._ws_paused = false; + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) {} + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + // Initial fetch + await this._fetch_all(); + + // Connect WebSocket for live updates, fall back to polling + this._connect_ws(); + } + + _collapsible_section(name) + { + const section = this.add_section(name); + const container = section._parent.inner(); + const heading = container.firstElementChild; + + heading.style.cursor = "pointer"; + heading.style.userSelect = "none"; + + const indicator = document.createElement("span"); + indicator.textContent = " \u25BC"; + indicator.style.fontSize = "0.7em"; + heading.appendChild(indicator); + + let collapsed = false; + heading.addEventListener("click", (e) => { + if (e.target !== heading && e.target !== indicator) + { + return; + } + collapsed = !collapsed; + indicator.textContent = collapsed ? " \u25B6" : " \u25BC"; + let sibling = heading.nextElementSibling; + while (sibling) + { + sibling.style.display = collapsed ? "none" : ""; + sibling = sibling.nextElementSibling; + } + }); + + return section; + } + + async _fetch_all() + { + try + { + const [agents, history, clients, client_history] = await Promise.all([ + new Fetcher().resource("/orch/agents").json(), + new Fetcher().resource("/orch/history").param("limit", "50").json().catch(() => null), + new Fetcher().resource("/orch/clients").json().catch(() => null), + new Fetcher().resource("/orch/clients/history").param("limit", "50").json().catch(() => null), + ]); + + this._render_agents(agents); + if (history) + { + this._render_history(history.events || []); + } + if (clients) + { + this._render_clients(clients.clients || []); + } + if (client_history) + { + this._render_client_history(client_history.client_events || []); + } + } + catch (e) { /* service unavailable */ } + } + + _connect_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/orch/ws`); + + ws.onopen = () => { + if (this._poll_timer) + { + clearInterval(this._poll_timer); + this._poll_timer = null; + } + }; + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const data = JSON.parse(ev.data); + this._render_agents(data); + if (data.events) + { + this._render_history(data.events); + } + if (data.clients) + { + this._render_clients(data.clients); + } + if (data.client_events) + { + this._render_client_history(data.client_events); + } + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { + this._start_polling(); + setTimeout(() => this._connect_ws(), 3000); + }; + + ws.onerror = () => { /* onclose will fire */ }; + } + catch (e) + { + this._start_polling(); + } + } + + _start_polling() + { + if (!this._poll_timer) + { + this._poll_timer = setInterval(() => this._fetch_all(), 2000); + } + } + + _render_agents(data) + { + const workers = data.workers || []; + + if (this._agents_table) + { + this._agents_table.clear(); + } + else + { + this._agents_table = this._agents_host.add_widget( + Table, + ["hostname", "CPUs", "CPU usage", "memory", "queues", "pending", "running", "completed", "traffic", "last seen"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + if (workers.length === 0) + { + return; + } + + let totalCpus = 0, totalWeightedCpu = 0; + let totalMemUsed = 0, totalMemTotal = 0; + let totalQueues = 0, totalPending = 0, totalRunning = 0, totalCompleted = 0; + let totalRecv = 0, totalSent = 0; + + for (const w of workers) + { + const cpus = w.cpus || 0; + const cpuUsage = w.cpu_usage; + const memUsed = w.memory_used || 0; + const memTotal = w.memory_total || 0; + const queues = w.active_queues || 0; + const pending = w.actions_pending || 0; + const running = w.actions_running || 0; + const completed = w.actions_completed || 0; + const recv = w.bytes_received || 0; + const sent = w.bytes_sent || 0; + + totalCpus += cpus; + if (cpus > 0 && typeof cpuUsage === "number") + { + totalWeightedCpu += cpuUsage * cpus; + } + totalMemUsed += memUsed; + totalMemTotal += memTotal; + totalQueues += queues; + totalPending += pending; + totalRunning += running; + totalCompleted += completed; + totalRecv += recv; + totalSent += sent; + + const hostname = w.hostname || ""; + const row = this._agents_table.add_row( + hostname, + cpus > 0 ? Friendly.sep(cpus) : "-", + typeof cpuUsage === "number" ? cpuUsage.toFixed(1) + "%" : "-", + memTotal > 0 ? Friendly.bytes(memUsed) + " / " + Friendly.bytes(memTotal) : "-", + queues > 0 ? Friendly.sep(queues) : "-", + Friendly.sep(pending), + Friendly.sep(running), + Friendly.sep(completed), + this._format_traffic(recv, sent), + this._format_last_seen(w.dt), + ); + + // Link hostname to worker dashboard + if (w.uri) + { + const cell = row.get_cell(0); + cell.inner().textContent = ""; + cell.tag("a").text(hostname).attr("href", w.uri + "/dashboard/compute/").attr("target", "_blank"); + } + } + + // Total row + const total = this._agents_table.add_row( + "TOTAL", + Friendly.sep(totalCpus), + "", + totalMemTotal > 0 ? Friendly.bytes(totalMemUsed) + " / " + Friendly.bytes(totalMemTotal) : "-", + Friendly.sep(totalQueues), + Friendly.sep(totalPending), + Friendly.sep(totalRunning), + Friendly.sep(totalCompleted), + this._format_traffic(totalRecv, totalSent), + "", + ); + total.get_cell(0).style("fontWeight", "bold"); + } + + _render_clients(clients) + { + if (this._clients_table) + { + this._clients_table.clear(); + } + else + { + this._clients_table = this._clients_host.add_widget( + Table, + ["client ID", "hostname", "address", "last seen"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const c of clients) + { + this._clients_table.add_row( + c.id || "", + c.hostname || "", + c.address || "", + this._format_last_seen(c.dt), + ); + } + } + + _render_history(events) + { + if (this._history_table) + { + this._history_table.clear(); + } + else + { + this._history_table = this._history_host.add_widget( + Table, + ["time", "event", "worker", "hostname"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const evt of events) + { + this._history_table.add_row( + this._format_timestamp(evt.ts), + evt.type || "", + evt.worker_id || "", + evt.hostname || "", + ); + } + } + + _render_client_history(events) + { + if (this._client_history_table) + { + this._client_history_table.clear(); + } + else + { + this._client_history_table = this._client_history_host.add_widget( + Table, + ["time", "event", "client", "hostname"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const evt of events) + { + this._client_history_table.add_row( + this._format_timestamp(evt.ts), + evt.type || "", + evt.client_id || "", + evt.hostname || "", + ); + } + } + + _metric(parent, value, label, hero = false) + { + const m = parent.tag().classify("tile-metric"); + if (hero) + { + m.classify("tile-metric-hero"); + } + m.tag().classify("metric-value").text(value); + m.tag().classify("metric-label").text(label); + } + + _format_last_seen(dtMs) + { + if (dtMs == null) + { + return "-"; + } + const seconds = Math.floor(dtMs / 1000); + if (seconds < 60) + { + return seconds + "s ago"; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) + { + return minutes + "m " + (seconds % 60) + "s ago"; + } + const hours = Math.floor(minutes / 60); + return hours + "h " + (minutes % 60) + "m ago"; + } + + _format_traffic(recv, sent) + { + if (!recv && !sent) + { + return "-"; + } + return Friendly.bytes(recv) + " / " + Friendly.bytes(sent); + } + + _format_timestamp(ts) + { + if (!ts) + { + return "-"; + } + let date; + if (typeof ts === "number") + { + // .NET-style ticks: convert to Unix ms + const unixMs = (ts - 621355968000000000) / 10000; + date = new Date(unixMs); + } + else + { + date = new Date(ts); + } + if (isNaN(date.getTime())) + { + return "-"; + } + return date.toLocaleTimeString(); + } +} |