aboutsummaryrefslogtreecommitdiff
path: root/src/zen/frontend/html/trace.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/zen/frontend/html/trace.js')
-rw-r--r--src/zen/frontend/html/trace.js577
1 files changed, 577 insertions, 0 deletions
diff --git a/src/zen/frontend/html/trace.js b/src/zen/frontend/html/trace.js
new file mode 100644
index 000000000..2910da15d
--- /dev/null
+++ b/src/zen/frontend/html/trace.js
@@ -0,0 +1,577 @@
+// Copyright Epic Games, Inc. All Rights Reserved.
+// Entry point: boots the viewer, owns the model, wires tabs / sidebar /
+// search / threads list / session panel.
+
+import { getSession, getThreads, getChannels, getScopeStats, getScopeNames, getLogCategories, getBookmarks, getRegions, getCsvCategories, getCsvStats } from "./api.js";
+import { Timeline } from "./timeline.js";
+import { StatsView } from "./stats.js";
+import { MemoryView } from "./memory.js";
+import { LogsView } from "./logs.js";
+import { CsvStatsView } from "./csvstats.js";
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, (c) => ({
+ "&": "&amp;",
+ "<": "&lt;",
+ ">": "&gt;",
+ "\"": "&quot;",
+ "'": "&#39;",
+ }[c]));
+}
+
+function formatTimeMs(us) {
+ if (us < 1000) return `${us} µs`;
+ if (us < 1_000_000) return `${(us / 1000).toFixed(2)} ms`;
+ return `${(us / 1_000_000).toFixed(2)} s`;
+}
+
+function formatNum(n) {
+ return Number(n).toLocaleString();
+}
+
+function stripNul(s) {
+ return (s || "").replace(/\u0000/g, "");
+}
+
+function getThemePreference() {
+ const params = new URLSearchParams(window.location.search);
+ const theme = params.get("theme");
+ if (theme === "dark" || theme === "light" || theme === "system") {
+ return theme;
+ }
+ const stored = window.localStorage.getItem("zen-trace-theme");
+ if (stored === "dark" || stored === "light" || stored === "system") {
+ return stored;
+ }
+ return "system";
+}
+
+function applyTheme(theme) {
+ document.documentElement.setAttribute("data-theme", theme);
+ window.localStorage.setItem("zen-trace-theme", theme);
+ const url = new URL(window.location.href);
+ if (theme === "system") {
+ url.searchParams.delete("theme");
+ } else {
+ url.searchParams.set("theme", theme);
+ }
+ window.history.replaceState({}, "", url);
+ const btn = document.getElementById("theme-toggle");
+ if (btn) {
+ btn.textContent = theme === "dark" ? "Dark" : theme === "light" ? "Light" : "System";
+ btn.title = `Theme: ${theme}. Click to cycle.`;
+ }
+}
+
+function setupThemeToggle() {
+ const btn = document.getElementById("theme-toggle");
+ if (!btn) {
+ return;
+ }
+ const themes = ["system", "dark", "light"];
+ let theme = getThemePreference();
+ applyTheme(theme);
+ btn.addEventListener("click", () => {
+ const index = themes.indexOf(theme);
+ theme = themes[(index + 1) % themes.length];
+ applyTheme(theme);
+ });
+}
+
+async function main() {
+ setupThemeToggle();
+ const loadingEl = document.getElementById("loading");
+ try {
+ const [session, threads, channels, scopeStats, scopeNames, logCategories, bookmarks, regionsResponse, csvCategories, csvStats] = await Promise.all([
+ getSession(),
+ getThreads(),
+ getChannels(),
+ getScopeStats(),
+ getScopeNames(),
+ getLogCategories(),
+ getBookmarks(),
+ getRegions(),
+ getCsvCategories(),
+ getCsvStats(),
+ ]);
+
+ // Normalize strings (tourist sometimes leaves trailing NULs in FieldStr).
+ for (const t of threads) t.name = stripNul(t.name);
+ session.app_name = stripNul(session.app_name);
+ session.project_name = stripNul(session.project_name);
+ session.branch = stripNul(session.branch);
+ session.build_version = stripNul(session.build_version);
+ session.platform = stripNul(session.platform);
+ session.command_line = stripNul(session.command_line);
+ for (const s of scopeStats) s.name = stripNul(s.name);
+ for (let i = 0; i < scopeNames.length; i++) scopeNames[i] = stripNul(scopeNames[i]);
+ for (const c of logCategories) c.name = stripNul(c.name);
+ for (const b of bookmarks) {
+ b.text = stripNul(b.text);
+ b.file = stripNul(b.file);
+ }
+ const regionCategories = regionsResponse && regionsResponse.categories ? regionsResponse.categories : [];
+ for (const cat of regionCategories) {
+ cat.name = stripNul(cat.name);
+ for (const r of cat.regions) {
+ r.name = stripNul(r.name);
+ }
+ }
+ for (const cat of csvCategories) cat.name = stripNul(cat.name);
+ for (const s of csvStats) s.name = stripNul(s.name);
+
+ // Precompute name → id for highlight lookups.
+ const scopeNameIds = new Map();
+ for (let i = 0; i < scopeNames.length; i++) {
+ scopeNameIds.set(scopeNames[i], i);
+ }
+
+ const model = { session, threads, channels, scopeStats, scopeNames, scopeNameIds, logCategories, bookmarks, regionCategories, csvCategories, csvStats };
+
+ renderHeader(model);
+ renderSessionView(model);
+
+ const timeline = new Timeline({
+ canvas: document.getElementById("timeline-canvas"),
+ tooltip: document.getElementById("tooltip"),
+ selectionEl: document.getElementById("selection-panel"),
+ viewportInfoEl: document.getElementById("viewport-info"),
+ zoomResetBtn: document.getElementById("zoom-reset"),
+ model,
+ onScopeSelect: (name) => {
+ timeline.setHighlightName(name);
+ stats.selectByName(name);
+ },
+ });
+
+ const stats = new StatsView(
+ document.getElementById("stats-tbody"),
+ document.querySelector(".stats-table thead tr"),
+ model,
+ (name) => {
+ timeline.setHighlightName(name);
+ timeline.jumpToScopeName(name);
+ },
+ );
+
+ const memoryView = new MemoryView(model, document.getElementById("memory-content"));
+ const logsView = new LogsView(model, document.getElementById("logs-content"));
+ const csvView = new CsvStatsView(model, document.getElementById("csv-content"));
+
+ const threadsListApi = renderThreadsList(model, timeline);
+ renderRegionCategories(model, timeline);
+ setupTabs(memoryView, logsView, csvView);
+ setupSearch(model, timeline, stats);
+
+ const bookmarksToggle = document.getElementById("bookmarks-toggle");
+ bookmarksToggle.addEventListener("change", () => {
+ timeline.setBookmarksVisible(bookmarksToggle.checked);
+ });
+
+ const lodToggle = document.getElementById("lod-toggle");
+ lodToggle.addEventListener("change", () => {
+ timeline.setLodEnabled(lodToggle.checked);
+ });
+
+ // Enable all threads that actually have captured scopes by default; if
+ // none do, enable every thread so the swimlanes still show up empty.
+ const withScopes = model.threads.filter((t) => t.scope_count > 0).map((t) => t.thread_id);
+ const initialEnabled = withScopes.length > 0 ? withScopes : model.threads.map((t) => t.thread_id);
+ for (const id of initialEnabled) {
+ const cb = document.querySelector(`.thread-row input[data-tid="${id}"]`);
+ if (cb) cb.checked = true;
+ }
+ threadsListApi.syncAllGroupCheckboxes();
+ timeline.setEnabledThreads(initialEnabled);
+
+ // "deselect all / select all" toggle for the Threads panel
+ const toggleAllBtn = document.getElementById("threads-toggle-all");
+ const threadsList = document.getElementById("threads-list");
+ toggleAllBtn.addEventListener("click", () => {
+ const allBoxes = threadsList.querySelectorAll(".thread-row input[type=checkbox]");
+ const anyChecked = Array.from(allBoxes).some((cb) => cb.checked);
+ const newState = !anyChecked;
+ for (const cb of allBoxes) {
+ cb.checked = newState;
+ }
+ // Sync group checkboxes
+ for (const gcb of threadsList.querySelectorAll(".group-checkbox")) {
+ gcb.checked = newState;
+ gcb.indeterminate = false;
+ }
+ toggleAllBtn.textContent = newState ? "deselect all" : "select all";
+ const enabled = [];
+ if (newState) {
+ for (const cb of allBoxes) {
+ enabled.push(Number(cb.dataset.tid));
+ }
+ }
+ timeline.setEnabledThreads(enabled);
+ });
+
+ loadingEl.classList.add("hidden");
+ } catch (e) {
+ loadingEl.textContent = `Failed to load trace: ${e.message}`;
+ console.error(e);
+ }
+}
+
+function renderHeader(model) {
+ const { session } = model;
+ const hdrFile = document.getElementById("hdr-file");
+ hdrFile.textContent = session.file_path || "";
+ hdrFile.title = session.file_path || "";
+
+ const stats = document.getElementById("hdr-stats");
+ stats.innerHTML =
+ `<span><span class="k">events:</span><span class="v"></span></span>` +
+ `<span><span class="k">threads:</span><span class="v"></span></span>` +
+ `<span><span class="k">duration:</span><span class="v"></span></span>` +
+ `<span><span class="k">parse:</span><span class="v"></span></span>`;
+ const vs = stats.querySelectorAll(".v");
+ vs[0].textContent = formatNum(session.total_events || 0);
+ vs[1].textContent = formatNum(model.threads.length);
+ vs[2].textContent = formatTimeMs((session.trace_end_us || 0) - (session.trace_start_us || 0));
+ vs[3].textContent = `${session.parse_time_ms} ms`;
+}
+
+function renderSessionView(model) {
+ const { session, threads, channels } = model;
+ const el = document.getElementById("session-content");
+
+ const rows = [];
+ rows.push("<h2>Session</h2>");
+ rows.push("<dl>");
+ const row = (k, v) => v && rows.push(`<dt>${k}</dt><dd>${escapeHtml(v)}</dd>`);
+ row("File", session.file_path);
+ row("Size", `${formatNum(session.file_size)} bytes`);
+ row("Events", formatNum(session.total_events));
+ row("Parse time", `${session.parse_time_ms} ms`);
+ row("Platform", session.platform);
+ row("App", session.app_name);
+ row("Project", session.project_name);
+ row("Branch", session.branch);
+ row("Build", session.build_version);
+ if (session.changelist) row("Changelist", String(session.changelist));
+ row("Command line", session.command_line);
+ rows.push("</dl>");
+
+ rows.push("<h2>Threads</h2>");
+ rows.push("<table><thead><tr>");
+ rows.push(`<th>Name</th><th class="num">TID</th><th class="num">System ID</th><th class="num">Scopes</th>`);
+ rows.push("</tr></thead><tbody>");
+ for (const t of threads) {
+ rows.push(
+ `<tr><td>${escapeHtml(t.name || "")}</td>` +
+ `<td class="num">${t.thread_id}</td>` +
+ `<td class="num">${t.system_id}</td>` +
+ `<td class="num">${formatNum(t.scope_count || 0)}</td></tr>`,
+ );
+ }
+ rows.push("</tbody></table>");
+
+ if (channels && channels.length) {
+ rows.push("<h2>Trace channels</h2>");
+ rows.push("<table><thead><tr><th>Name</th><th>State</th></tr></thead><tbody>");
+ for (const c of channels) {
+ const cls = c.enabled ? "chan-enabled" : "chan-disabled";
+ const ro = c.readonly ? `<span class="chan-readonly">read-only</span>` : "";
+ rows.push(`<tr><td>${escapeHtml(c.name || "")}</td><td class="${cls}">${c.enabled ? "enabled" : "disabled"}${ro}</td></tr>`);
+ }
+ rows.push("</tbody></table>");
+ }
+
+ el.innerHTML = rows.join("");
+}
+
+function renderThreadsList(model, timeline) {
+ const list = document.getElementById("threads-list");
+ const cmp = (a, b) => {
+ // SortHint first (UE sets low values for important threads like GameThread)
+ if (a.sort_hint !== b.sort_hint) return a.sort_hint - b.sort_hint;
+ // Then threads with scopes before empty ones
+ if ((a.scope_count > 0) !== (b.scope_count > 0)) return b.scope_count - a.scope_count;
+ // Then by thread ID (lower = created earlier, main thread is typically first)
+ if (a.thread_id !== b.thread_id) return a.thread_id - b.thread_id;
+ return (a.name || "").localeCompare(b.name || "", undefined, { numeric: true });
+ };
+
+ // Build groups: ungrouped threads, named groups, and lanes
+ const lanes = model.threads.filter(t => t.is_lane).sort(cmp);
+ const grouped = new Map(); // groupName → [threads]
+ const ungrouped = [];
+ for (const t of model.threads) {
+ if (t.is_lane) continue;
+ const g = t.group || "";
+ if (g) {
+ if (!grouped.has(g)) grouped.set(g, []);
+ grouped.get(g).push(t);
+ } else {
+ ungrouped.push(t);
+ }
+ }
+ ungrouped.sort(cmp);
+ for (const [, threads] of grouped) threads.sort(cmp);
+
+ // Sort group names naturally
+ const groupNames = Array.from(grouped.keys()).sort((a, b) =>
+ a.localeCompare(b, undefined, { numeric: true }));
+
+ const collapsed = new Set();
+ const parts = [];
+
+ function renderGroup(label, threads, collapsible) {
+ if (threads.length === 0) return;
+ const collapseAttr = collapsible ? ` data-group="${label}"` : "";
+ const chevron = collapsible ? `<span class="group-chevron">&#x25BE;</span>` : "";
+ const groupCb = collapsible ? `<input type="checkbox" class="group-checkbox" data-group-toggle="${label}">` : "";
+ parts.push(`<div class="thread-group-header"${collapseAttr}>${groupCb}${chevron}${label} (${threads.length})</div>`);
+ for (const t of threads) {
+ const emptyCls = t.scope_count === 0 ? " empty" : "";
+ const laneCls = t.is_lane ? " lane" : "";
+ const groupAttr = collapsible ? ` data-group-member="${label}"` : "";
+ parts.push(
+ `<label class="thread-row${emptyCls}${laneCls}"${groupAttr}>` +
+ `<input type="checkbox" data-tid="${t.thread_id}">` +
+ `<span class="thread-name"></span>` +
+ `<span class="thread-count">${formatNum(t.scope_count || 0)}</span>` +
+ `</label>`,
+ );
+ }
+ }
+
+ // Ungrouped threads first, then named groups, then lanes
+ if (ungrouped.length > 0 && (grouped.size > 0 || lanes.length > 0)) {
+ renderGroup("Threads", ungrouped, false);
+ } else {
+ // No groups at all — render without a header
+ for (const t of ungrouped) {
+ const emptyCls = t.scope_count === 0 ? " empty" : "";
+ parts.push(
+ `<label class="thread-row${emptyCls}">` +
+ `<input type="checkbox" data-tid="${t.thread_id}">` +
+ `<span class="thread-name"></span>` +
+ `<span class="thread-count">${formatNum(t.scope_count || 0)}</span>` +
+ `</label>`,
+ );
+ }
+ }
+ for (const name of groupNames) renderGroup(name, grouped.get(name), true);
+ renderGroup("Lanes", lanes, lanes.length > 0);
+
+ list.innerHTML = parts.join("");
+
+ function syncTimeline() {
+ const enabled = new Set();
+ for (const box of list.querySelectorAll(".thread-row input[type=checkbox]")) {
+ if (box.checked) enabled.add(Number(box.dataset.tid));
+ }
+ timeline.setEnabledThreads(Array.from(enabled));
+ }
+
+ function syncGroupCheckbox(groupName) {
+ const members = list.querySelectorAll(`[data-group-member="${groupName}"] input[type=checkbox]`);
+ const gcb = list.querySelector(`.group-checkbox[data-group-toggle="${groupName}"]`);
+ if (!gcb || members.length === 0) return;
+ const checkedCount = Array.from(members).filter((c) => c.checked).length;
+ gcb.checked = checkedCount === members.length;
+ gcb.indeterminate = checkedCount > 0 && checkedCount < members.length;
+ }
+
+ function syncAllGroupCheckboxes() {
+ for (const gcb of list.querySelectorAll(".group-checkbox")) {
+ syncGroupCheckbox(gcb.dataset.groupToggle);
+ }
+ }
+
+ // Wire up collapsible group headers (click on the label area, not checkbox)
+ for (const hdr of list.querySelectorAll(".thread-group-header[data-group]")) {
+ hdr.addEventListener("click", (e) => {
+ // Don't collapse when clicking the group checkbox
+ if (e.target.classList.contains("group-checkbox")) return;
+ const group = hdr.dataset.group;
+ const isCollapsed = collapsed.has(group);
+ if (isCollapsed) {
+ collapsed.delete(group);
+ hdr.classList.remove("collapsed");
+ } else {
+ collapsed.add(group);
+ hdr.classList.add("collapsed");
+ }
+ for (const row of list.querySelectorAll(`[data-group-member="${group}"]`)) {
+ row.style.display = isCollapsed ? "" : "none";
+ }
+ });
+ }
+
+ // Wire up group checkboxes — toggle all children
+ for (const gcb of list.querySelectorAll(".group-checkbox")) {
+ gcb.addEventListener("change", () => {
+ const group = gcb.dataset.groupToggle;
+ const checked = gcb.checked;
+ for (const row of list.querySelectorAll(`[data-group-member="${group}"]`)) {
+ const cb = row.querySelector("input[type=checkbox]");
+ if (cb) cb.checked = checked;
+ }
+ syncTimeline();
+ });
+ }
+
+ // Wire up thread checkboxes
+ for (const row of list.querySelectorAll(".thread-row")) {
+ const cb = row.querySelector("input");
+ const nameEl = row.querySelector(".thread-name");
+ const tid = Number(cb.dataset.tid);
+ const thread = model.threads.find((t) => t.thread_id === tid);
+ nameEl.textContent = (thread && thread.name) || `tid ${tid}`;
+ cb.addEventListener("change", () => {
+ const groupMember = row.dataset.groupMember;
+ if (groupMember) syncGroupCheckbox(groupMember);
+ syncTimeline();
+ });
+ }
+
+ return { syncAllGroupCheckboxes };
+}
+
+function renderRegionCategories(model, timeline) {
+ const categories = model.regionCategories || [];
+ if (categories.length === 0) return;
+
+ const panel = document.getElementById("regions-panel");
+ panel.hidden = false;
+
+ const list = document.getElementById("regions-list");
+ const parts = [];
+ for (let i = 0; i < categories.length; i++) {
+ const cat = categories[i];
+ const count = cat.regions ? cat.regions.length : 0;
+ parts.push(
+ `<label class="thread-row">` +
+ `<input type="checkbox" data-cat-idx="${i}" checked>` +
+ `<span class="thread-name"></span>` +
+ `<span class="thread-count">${formatNum(count)}</span>` +
+ `</label>`,
+ );
+ }
+ list.innerHTML = parts.join("");
+
+ // Set label text via DOM to avoid XSS
+ for (const row of list.querySelectorAll(".thread-row")) {
+ const cb = row.querySelector("input");
+ const nameEl = row.querySelector(".thread-name");
+ const idx = Number(cb.dataset.catIdx);
+ nameEl.textContent = categories[idx].name || "Uncategorized";
+ }
+
+ function syncTimeline() {
+ const enabled = new Set();
+ for (const cb of list.querySelectorAll("input[type=checkbox]")) {
+ if (cb.checked) enabled.add(Number(cb.dataset.catIdx));
+ }
+ timeline.setEnabledRegionCategories(enabled);
+ }
+
+ for (const cb of list.querySelectorAll("input[type=checkbox]")) {
+ cb.addEventListener("change", syncTimeline);
+ }
+
+ // "deselect all / select all" toggle
+ const toggleBtn = document.getElementById("regions-toggle-all");
+ toggleBtn.addEventListener("click", () => {
+ const allBoxes = list.querySelectorAll("input[type=checkbox]");
+ const anyChecked = Array.from(allBoxes).some((cb) => cb.checked);
+ const newState = !anyChecked;
+ for (const cb of allBoxes) cb.checked = newState;
+ toggleBtn.textContent = newState ? "deselect all" : "select all";
+ syncTimeline();
+ });
+
+ // Initial state: all enabled
+ const allIndices = new Set(categories.map((_, i) => i));
+ timeline.setEnabledRegionCategories(allIndices);
+}
+
+function setupTabs(memoryView, logsView, csvView) {
+ const tabs = document.querySelectorAll(".tab");
+ const views = document.querySelectorAll(".view");
+ const validTabs = new Set(Array.from(tabs, (tab) => tab.dataset.tab));
+
+ function activateTab(key, updateUrl = true) {
+ for (const tab of tabs) {
+ tab.classList.toggle("active", tab.dataset.tab === key);
+ }
+ for (const view of views) {
+ view.hidden = view.dataset.view !== key;
+ }
+ if (updateUrl) {
+ const url = new URL(window.location.href);
+ url.searchParams.set("tab", key);
+ window.history.replaceState({}, "", url);
+ }
+ if (key === "memory" && memoryView) {
+ memoryView.ensureLoaded();
+ }
+ if (key === "logs" && logsView) {
+ logsView.ensureLoaded();
+ }
+ if (key === "csv" && csvView) {
+ csvView.ensureLoaded();
+ }
+ }
+
+ for (const tab of tabs) {
+ tab.addEventListener("click", () => activateTab(tab.dataset.tab));
+ }
+
+ const initialTab = new URLSearchParams(window.location.search).get("tab");
+ if (initialTab && validTabs.has(initialTab)) {
+ activateTab(initialTab, false);
+ }
+}
+
+function setupSearch(model, timeline, stats) {
+ const input = document.getElementById("search-input");
+ const results = document.getElementById("search-results");
+
+ function render() {
+ const q = input.value.trim().toLowerCase();
+ if (!q) {
+ results.innerHTML = "";
+ timeline.setHighlightName(null);
+ return;
+ }
+ const matches = [];
+ for (const s of model.scopeStats) {
+ if (s.name.toLowerCase().includes(q)) {
+ matches.push(s);
+ if (matches.length >= 50) break;
+ }
+ }
+ const parts = [];
+ for (const m of matches) {
+ parts.push(
+ `<div class="hit" data-name="${escapeHtml(m.name)}">` +
+ `<span class="hit-name">${escapeHtml(m.name)}</span>` +
+ `<span class="hit-count">${formatNum(m.count)}</span>` +
+ `</div>`,
+ );
+ }
+ results.innerHTML = parts.join("");
+ for (const hit of results.querySelectorAll(".hit")) {
+ hit.addEventListener("click", () => {
+ const name = hit.dataset.name;
+ timeline.setHighlightName(name);
+ timeline.jumpToScopeName(name);
+ stats.selectByName(name);
+ });
+ }
+ if (matches.length > 0) {
+ timeline.setHighlightName(matches[0].name);
+ }
+ }
+
+ input.addEventListener("input", render);
+}
+
+main();