diff options
Diffstat (limited to 'src/zen/frontend/html/trace.js')
| -rw-r--r-- | src/zen/frontend/html/trace.js | 577 |
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) => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + }[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">▾</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(); |