// Copyright Epic Games, Inc. All Rights Reserved. // Log viewer: filterable list of captured Logging.LogMessage events. import { getLogs } from "./api.js"; import { escapeHtml } from "./util.js"; // UE ELogVerbosity::Type values — lower number = more severe. const VERBOSITY_LABELS = [ "NoLogging", "Fatal", "Error", "Warning", "Display", "Log", "Verbose", "VeryVerbose", "All", ]; function verbosityLabel(v) { return VERBOSITY_LABELS[v] || `V${v}`; } function verbosityClass(v) { switch (v) { case 1: return "vb-fatal"; case 2: return "vb-error"; case 3: return "vb-warn"; case 4: return "vb-display"; case 5: return "vb-log"; case 6: case 7: return "vb-verbose"; default: return "vb-other"; } } function formatTime(us) { const totalMs = Math.floor(us / 1000); const ms = totalMs % 1000; const totalS = Math.floor(totalMs / 1000); const s = totalS % 60; const totalM = Math.floor(totalS / 60); const m = totalM % 60; const h = Math.floor(totalM / 60); if (h > 0) { return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(ms).padStart(3, "0")}`; } return `${m}:${String(s).padStart(2, "0")}.${String(ms).padStart(3, "0")}`; } export class LogsView { constructor(model, containerEl) { this.model = model; this.container = containerEl; this.minVerbosity = 0; this.category = ""; this.textFilter = ""; this.loaded = false; this.rendering = null; this.container.innerHTML = `
Verbosity
Category
Search
Time Verbosity Category Message Source
`; const catSelect = this.container.querySelector("#log-category"); if ((this.model.bookmarks || []).length > 0) { const bmOpt = document.createElement("option"); bmOpt.value = "bookmarks"; bmOpt.textContent = "(bookmarks only)"; catSelect.appendChild(bmOpt); } for (let i = 0; i < this.model.logCategories.length; i++) { const c = this.model.logCategories[i]; const opt = document.createElement("option"); opt.value = String(i); opt.textContent = c.name || `category ${i}`; catSelect.appendChild(opt); } this.container.querySelector("#log-verbosity").addEventListener("change", (e) => { this.minVerbosity = Number(e.target.value) || 0; this.refresh(); }); this.container.querySelector("#log-category").addEventListener("change", (e) => { this.category = e.target.value; this.refresh(); }); this.container.querySelector("#log-search").addEventListener("input", (e) => { this.textFilter = e.target.value.toLowerCase(); this.renderFiltered(); }); } async ensureLoaded() { if (this.loaded) return; this.loaded = true; await this.refresh(); } async refresh() { // "(bookmarks only)" is a synthetic category that displays just the // bookmark rows without hitting the /api/logs endpoint. if (this.category === "bookmarks") { this.result = { entries: [], total: 0, returned: 0 }; this.renderFiltered(); return; } const opts = { minVerbosity: this.minVerbosity, limit: 5000, }; if (this.category !== "") { opts.category = Number(this.category); } try { this.result = await getLogs(opts); } catch (e) { this.container.querySelector("#logs-tbody").innerHTML = `Failed to load logs: ${escapeHtml(e.message)}`; return; } this.renderFiltered(); } renderFiltered() { if (!this.result) return; const entries = this.result.entries || []; const filter = this.textFilter; const tbody = this.container.querySelector("#logs-tbody"); const count = this.container.querySelector("#log-count"); // Bookmarks are interleaved into the display list when the category // filter is "All" or "(bookmarks only)". Any other category is log- // specific so bookmarks are hidden to avoid confusion. const showBookmarks = (this.category === "" || this.category === "bookmarks"); const bookmarks = showBookmarks ? (this.model.bookmarks || []) : []; // Build a combined, time-sorted row list. Each item keeps its kind // so we can render log and bookmark rows differently. const items = []; for (const e of entries) { items.push({ kind: "log", time: e.time_us, entry: e }); } for (const b of bookmarks) { items.push({ kind: "bookmark", time: b.time_us, entry: b }); } items.sort((a, b) => a.time - b.time); const rows = []; let shown = 0; for (const it of items) { if (it.kind === "log") { const e = it.entry; if (filter && !e.message.toLowerCase().includes(filter)) continue; const cat = this.model.logCategories[e.category_index] || { name: "(unknown)" }; const file = e.file ? String(e.file).split(/[\\/]/).pop() : ""; rows.push( `` + `${formatTime(e.time_us)}` + `${escapeHtml(verbosityLabel(e.verbosity))}` + `${escapeHtml(cat.name)}` + `${escapeHtml(e.message)}` + `${escapeHtml(file)}${e.line ? ":" + e.line : ""}` + ``, ); } else { const b = it.entry; if (filter && !b.text.toLowerCase().includes(filter)) continue; const file = b.file ? String(b.file).split(/[\\/]/).pop() : ""; rows.push( `` + `${formatTime(b.time_us)}` + `BOOKMARK` + `—` + `${escapeHtml(b.text)}` + `${escapeHtml(file)}${b.line ? ":" + b.line : ""}` + ``, ); } shown++; } tbody.innerHTML = rows.join("") || `No entries match the current filter.`; const total = (this.result.total || 0) + bookmarks.length; const returned = (this.result.returned || 0) + bookmarks.length; if (total > returned) { count.textContent = `${shown.toLocaleString()} shown · ${returned.toLocaleString()} of ${total.toLocaleString()} loaded`; } else { count.textContent = `${shown.toLocaleString()} of ${total.toLocaleString()}`; } } }