diff options
Diffstat (limited to 'src/zenserver/frontend/html/pages/compute.js')
| -rw-r--r-- | src/zenserver/frontend/html/pages/compute.js | 693 |
1 files changed, 693 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html/pages/compute.js b/src/zenserver/frontend/html/pages/compute.js new file mode 100644 index 000000000..ab3d49c27 --- /dev/null +++ b/src/zenserver/frontend/html/pages/compute.js @@ -0,0 +1,693 @@ +// 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" + +const MAX_HISTORY_POINTS = 60; + +// Windows FILETIME: 100ns ticks since 1601-01-01 +const FILETIME_EPOCH_OFFSET_MS = 11644473600000n; +function filetimeToDate(ticks) +{ + if (!ticks) return null; + const ms = BigInt(ticks) / 10000n - FILETIME_EPOCH_OFFSET_MS; + return new Date(Number(ms)); +} + +function formatTime(date) +{ + if (!date) return "-"; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function formatDuration(startDate, endDate) +{ + if (!startDate || !endDate) return "-"; + const ms = endDate - startDate; + if (ms < 0) return "-"; + if (ms < 1000) return ms + " ms"; + if (ms < 60000) return (ms / 1000).toFixed(2) + " s"; + const m = Math.floor(ms / 60000); + const s = ((ms % 60000) / 1000).toFixed(0).padStart(2, "0"); + return `${m}m ${s}s`; +} + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("compute"); + + this._history = { timestamps: [], pending: [], running: [], completed: [], cpu: [] }; + this._selected_worker = null; + this._chart_js = null; + this._queue_chart = null; + this._cpu_chart = 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; + }); + + // Action Queue section + const queue_section = this._collapsible_section("Action Queue"); + this._queue_grid = queue_section.tag().classify("grid").classify("stats-tiles"); + this._chart_host = queue_section; + + // Performance Metrics section + const perf_section = this._collapsible_section("Performance Metrics"); + this._perf_host = perf_section; + this._perf_grid = null; + + // Workers section + const workers_section = this._collapsible_section("Workers"); + this._workers_host = workers_section; + this._workers_table = null; + this._worker_detail_container = null; + + // Queues section + const queues_section = this._collapsible_section("Queues"); + this._queues_host = queues_section; + this._queues_table = null; + + // Action History section + const history_section = this._collapsible_section("Recent Actions"); + this._history_host = history_section; + this._history_table = null; + + // System Resources section + const sys_section = this._collapsible_section("System Resources"); + this._sys_grid = sys_section.tag().classify("grid").classify("stats-tiles"); + + // Load Chart.js dynamically + this._load_chartjs(); + + // Initial fetch + await this._fetch_all(); + + // Poll + this._poll_timer = setInterval(() => { + if (!this._ws_paused) + { + this._fetch_all(); + } + }, 2000); + } + + _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 _load_chartjs() + { + if (window.Chart) + { + this._chart_js = window.Chart; + this._init_charts(); + return; + } + + try + { + const script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"; + script.onload = () => { + this._chart_js = window.Chart; + this._init_charts(); + }; + document.head.appendChild(script); + } + catch (e) { /* Chart.js not available */ } + } + + _init_charts() + { + if (!this._chart_js) + { + return; + } + + // Queue history chart + { + const card = this._chart_host.tag().classify("card"); + card.tag().classify("card-title").text("Action Queue History"); + const container = card.tag(); + container.style("position", "relative").style("height", "300px").style("marginTop", "20px"); + const canvas = document.createElement("canvas"); + container.inner().appendChild(canvas); + + this._queue_chart = new this._chart_js(canvas.getContext("2d"), { + type: "line", + data: { + labels: [], + datasets: [ + { label: "Pending", data: [], borderColor: "#f0883e", backgroundColor: "rgba(240, 136, 62, 0.1)", tension: 0.4, fill: true }, + { label: "Running", data: [], borderColor: "#58a6ff", backgroundColor: "rgba(88, 166, 255, 0.1)", tension: 0.4, fill: true }, + { label: "Completed", data: [], borderColor: "#3fb950", backgroundColor: "rgba(63, 185, 80, 0.1)", tension: 0.4, fill: true }, + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: true, labels: { color: "#8b949e" } } }, + scales: { x: { display: false }, y: { beginAtZero: true, ticks: { color: "#8b949e" }, grid: { color: "#21262d" } } } + } + }); + } + + // CPU sparkline (will be appended to CPU card later) + this._cpu_canvas = document.createElement("canvas"); + this._cpu_chart = new this._chart_js(this._cpu_canvas.getContext("2d"), { + type: "line", + data: { + labels: [], + datasets: [{ + data: [], + borderColor: "#58a6ff", + backgroundColor: "rgba(88, 166, 255, 0.15)", + borderWidth: 1.5, + tension: 0.4, + fill: true, + pointRadius: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false, min: 0, max: 100 } } + } + }); + } + + async _fetch_all() + { + try + { + const [stats, sysinfo, workers_data, queues_data, history_data] = await Promise.all([ + new Fetcher().resource("/stats/compute").json().catch(() => null), + new Fetcher().resource("/compute/sysinfo").json().catch(() => null), + new Fetcher().resource("/compute/workers").json().catch(() => null), + new Fetcher().resource("/compute/queues").json().catch(() => null), + new Fetcher().resource("/compute/jobs/history").param("limit", "50").json().catch(() => null), + ]); + + if (stats) + { + this._render_queue_stats(stats); + this._update_queue_chart(stats); + this._render_perf(stats); + } + if (sysinfo) + { + this._render_sysinfo(sysinfo); + } + if (workers_data) + { + this._render_workers(workers_data); + } + if (queues_data) + { + this._render_queues(queues_data); + } + if (history_data) + { + this._render_action_history(history_data); + } + } + catch (e) { /* service unavailable */ } + } + + _render_queue_stats(data) + { + const grid = this._queue_grid; + grid.inner().innerHTML = ""; + + const tiles = [ + { title: "Pending Actions", value: data.actions_pending || 0, label: "waiting to be scheduled" }, + { title: "Running Actions", value: data.actions_submitted || 0, label: "currently executing" }, + { title: "Completed Actions", value: data.actions_complete || 0, label: "results available" }, + ]; + + for (const t of tiles) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text(t.title); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(t.value), t.label, true); + } + } + + _update_queue_chart(data) + { + const h = this._history; + h.timestamps.push(new Date().toLocaleTimeString()); + h.pending.push(data.actions_pending || 0); + h.running.push(data.actions_submitted || 0); + h.completed.push(data.actions_complete || 0); + + while (h.timestamps.length > MAX_HISTORY_POINTS) + { + h.timestamps.shift(); + h.pending.shift(); + h.running.shift(); + h.completed.shift(); + } + + if (this._queue_chart) + { + this._queue_chart.data.labels = h.timestamps; + this._queue_chart.data.datasets[0].data = h.pending; + this._queue_chart.data.datasets[1].data = h.running; + this._queue_chart.data.datasets[2].data = h.completed; + this._queue_chart.update("none"); + } + } + + _render_perf(data) + { + if (!this._perf_grid) + { + this._perf_grid = this._perf_host.tag().classify("grid").classify("stats-tiles"); + } + const grid = this._perf_grid; + grid.inner().innerHTML = ""; + + const retired = data.actions_retired || {}; + + // Completion rate card + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Completion Rate"); + const body = tile.tag().classify("tile-columns"); + + const left = body.tag().classify("tile-metrics"); + this._metric(left, this._fmt_rate(retired.rate_1), "1 min rate", true); + this._metric(left, this._fmt_rate(retired.rate_5), "5 min rate"); + this._metric(left, this._fmt_rate(retired.rate_15), "15 min rate"); + + const right = body.tag().classify("tile-metrics"); + this._metric(right, Friendly.sep(retired.count || 0), "total retired", true); + this._metric(right, this._fmt_rate(retired.rate_mean), "mean rate"); + } + } + + _fmt_rate(rate) + { + if (rate == null) return "-"; + return rate.toFixed(2) + "/s"; + } + + _render_workers(data) + { + const workerIds = data.workers || []; + + if (this._workers_table) + { + this._workers_table.clear(); + } + else + { + this._workers_table = this._workers_host.add_widget( + Table, + ["name", "platform", "cores", "timeout", "functions", "worker ID"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + if (workerIds.length === 0) + { + return; + } + + // Fetch each worker's descriptor + Promise.all( + workerIds.map(id => + new Fetcher().resource("/compute/workers", id).json() + .then(desc => ({ id, desc })) + .catch(() => ({ id, desc: null })) + ) + ).then(results => { + this._workers_table.clear(); + for (const { id, desc } of results) + { + const name = desc ? (desc.name || "-") : "-"; + const host = desc ? (desc.host || "-") : "-"; + const cores = desc ? (desc.cores != null ? desc.cores : "-") : "-"; + const timeout = desc ? (desc.timeout != null ? desc.timeout + "s" : "-") : "-"; + const functions = desc ? (desc.functions ? desc.functions.length : 0) : "-"; + + const row = this._workers_table.add_row( + "", + host, + String(cores), + String(timeout), + String(functions), + id, + ); + + // Make name clickable to expand detail + const cell = row.get_cell(0); + cell.tag().text(name).on_click(() => this._toggle_worker_detail(id, desc)); + + // Highlight selected + if (id === this._selected_worker) + { + row.style("background", "var(--theme_p3)"); + } + } + + this._worker_descriptors = Object.fromEntries(results.map(r => [r.id, r.desc])); + + // Re-render detail if still selected + if (this._selected_worker && this._worker_descriptors[this._selected_worker]) + { + this._show_worker_detail(this._selected_worker, this._worker_descriptors[this._selected_worker]); + } + else if (this._selected_worker) + { + this._selected_worker = null; + this._clear_worker_detail(); + } + }); + } + + _toggle_worker_detail(id, desc) + { + if (this._selected_worker === id) + { + this._selected_worker = null; + this._clear_worker_detail(); + return; + } + this._selected_worker = id; + this._show_worker_detail(id, desc); + } + + _clear_worker_detail() + { + if (this._worker_detail_container) + { + this._worker_detail_container._parent.inner().remove(); + this._worker_detail_container = null; + } + } + + _show_worker_detail(id, desc) + { + this._clear_worker_detail(); + if (!desc) + { + return; + } + + const section = this._workers_host.add_section(desc.name || id); + this._worker_detail_container = section; + + // Basic info table + const info_table = section.add_widget( + Table, ["property", "value"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + const fields = [ + ["Worker ID", id], + ["Path", desc.path || "-"], + ["Platform", desc.host || "-"], + ["Build System", desc.buildsystem_version || "-"], + ["Cores", desc.cores != null ? String(desc.cores) : "-"], + ["Timeout", desc.timeout != null ? desc.timeout + "s" : "-"], + ]; + for (const [label, value] of fields) + { + info_table.add_row(label, value); + } + + // Functions + const functions = desc.functions || []; + if (functions.length > 0) + { + const fn_section = section.add_section("Functions"); + const fn_table = fn_section.add_widget( + Table, ["name", "version"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + for (const f of functions) + { + fn_table.add_row(f.name || "-", f.version || "-"); + } + } + + // Executables + const executables = desc.executables || []; + if (executables.length > 0) + { + const exec_section = section.add_section("Executables"); + const exec_table = exec_section.add_widget( + Table, ["path", "hash", "size"], Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric + ); + let totalSize = 0; + for (const e of executables) + { + exec_table.add_row(e.name || "-", e.hash || "-", e.size != null ? Friendly.bytes(e.size) : "-"); + totalSize += e.size || 0; + } + const total_row = exec_table.add_row("TOTAL", "", Friendly.bytes(totalSize)); + total_row.get_cell(0).style("fontWeight", "bold"); + total_row.get_cell(2).style("fontWeight", "bold"); + } + + // Files + const files = desc.files || []; + if (files.length > 0) + { + const files_section = section.add_section("Files"); + const files_table = files_section.add_widget( + Table, ["name", "hash"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + for (const f of files) + { + files_table.add_row(typeof f === "string" ? f : (f.name || "-"), typeof f === "string" ? "" : (f.hash || "")); + } + } + + // Directories + const dirs = desc.dirs || []; + if (dirs.length > 0) + { + const dirs_section = section.add_section("Directories"); + for (const d of dirs) + { + dirs_section.tag().classify("detail-tag").text(d); + } + } + + // Environment + const env = desc.environment || []; + if (env.length > 0) + { + const env_section = section.add_section("Environment"); + for (const e of env) + { + env_section.tag().classify("detail-tag").text(e); + } + } + } + + _render_queues(data) + { + const queues = data.queues || []; + + if (this._queues_table) + { + this._queues_table.clear(); + } + else + { + this._queues_table = this._queues_host.add_widget( + Table, + ["ID", "status", "active", "completed", "failed", "abandoned", "cancelled", "token"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + for (const q of queues) + { + const id = q.queue_id != null ? String(q.queue_id) : "-"; + const status = q.state === "cancelled" ? "cancelled" + : q.state === "draining" ? "draining" + : q.is_complete ? "complete" : "active"; + + this._queues_table.add_row( + id, + status, + String(q.active_count ?? 0), + String(q.completed_count ?? 0), + String(q.failed_count ?? 0), + String(q.abandoned_count ?? 0), + String(q.cancelled_count ?? 0), + q.queue_token || "-", + ); + } + } + + _render_action_history(data) + { + const entries = data.history || []; + + if (this._history_table) + { + this._history_table.clear(); + } + else + { + this._history_table = this._history_host.add_widget( + Table, + ["LSN", "queue", "status", "function", "started", "finished", "duration", "worker ID", "action ID"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + // Entries arrive oldest-first; reverse to show newest at top + for (const entry of [...entries].reverse()) + { + const lsn = entry.lsn != null ? String(entry.lsn) : "-"; + const queueId = entry.queueId ? String(entry.queueId) : "-"; + const status = entry.succeeded == null ? "unknown" + : entry.succeeded ? "ok" : "failed"; + const desc = entry.actionDescriptor || {}; + const fn = desc.Function || "-"; + const startDate = filetimeToDate(entry.time_Running); + const endDate = filetimeToDate(entry.time_Completed ?? entry.time_Failed); + + this._history_table.add_row( + lsn, + queueId, + status, + fn, + formatTime(startDate), + formatTime(endDate), + formatDuration(startDate, endDate), + entry.workerId || "-", + entry.actionId || "-", + ); + } + } + + _render_sysinfo(data) + { + const grid = this._sys_grid; + grid.inner().innerHTML = ""; + + // CPU card + { + const cpuUsage = data.cpu_usage || 0; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("CPU Usage"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, cpuUsage.toFixed(1) + "%", "percent", true); + + // Progress bar + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", cpuUsage + "%"); + + // CPU sparkline + this._history.cpu.push(cpuUsage); + while (this._history.cpu.length > MAX_HISTORY_POINTS) this._history.cpu.shift(); + if (this._cpu_chart) + { + const sparkContainer = body.tag(); + sparkContainer.style("position", "relative").style("height", "60px").style("marginTop", "12px"); + sparkContainer.inner().appendChild(this._cpu_canvas); + + this._cpu_chart.data.labels = this._history.cpu.map(() => ""); + this._cpu_chart.data.datasets[0].data = this._history.cpu; + this._cpu_chart.update("none"); + } + + // CPU details + this._stat_row(body, "Packages", data.cpu_count != null ? String(data.cpu_count) : "-"); + this._stat_row(body, "Physical Cores", data.core_count != null ? String(data.core_count) : "-"); + this._stat_row(body, "Logical Processors", data.lp_count != null ? String(data.lp_count) : "-"); + } + + // Memory card + { + const memUsed = data.memory_used || 0; + const memTotal = data.memory_total || 1; + const memPercent = (memUsed / memTotal) * 100; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Memory"); + const body = tile.tag().classify("tile-metrics"); + this._stat_row(body, "Used", Friendly.bytes(memUsed)); + this._stat_row(body, "Total", Friendly.bytes(memTotal)); + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", memPercent + "%"); + } + + // Disk card + { + const diskUsed = data.disk_used || 0; + const diskTotal = data.disk_total || 1; + const diskPercent = (diskUsed / diskTotal) * 100; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Disk"); + const body = tile.tag().classify("tile-metrics"); + this._stat_row(body, "Used", Friendly.bytes(diskUsed)); + this._stat_row(body, "Total", Friendly.bytes(diskTotal)); + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", diskPercent + "%"); + } + } + + _stat_row(parent, label, value) + { + const row = parent.tag().classify("stats-row"); + row.tag().classify("stats-label").text(label); + row.tag().classify("stats-value").text(value); + } + + _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); + } +} |