// 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 =
`events:` +
`threads:` +
`duration:` +
`parse:`;
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("
Session
");
rows.push("");
const row = (k, v) => v && rows.push(`- ${k}
- ${escapeHtml(v)}
`);
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("
");
rows.push("Threads
");
rows.push("");
rows.push(`| Name | TID | System ID | Scopes | `);
rows.push("
");
for (const t of threads) {
rows.push(
`| ${escapeHtml(t.name || "")} | ` +
`${t.thread_id} | ` +
`${t.system_id} | ` +
`${formatNum(t.scope_count || 0)} |
`,
);
}
rows.push("
");
if (channels && channels.length) {
rows.push("Trace channels
");
rows.push("| Name | State |
");
for (const c of channels) {
const cls = c.enabled ? "chan-enabled" : "chan-disabled";
const ro = c.readonly ? `read-only` : "";
rows.push(`| ${escapeHtml(c.name || "")} | ${c.enabled ? "enabled" : "disabled"}${ro} |
`);
}
rows.push("
");
}
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 ? `▾` : "";
const groupCb = collapsible ? `` : "";
parts.push(``);
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(
``,
);
}
}
// 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(
``,
);
}
}
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(
``,
);
}
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(
`` +
`${escapeHtml(m.name)}` +
`${formatNum(m.count)}` +
`
`,
);
}
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();