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