// 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 =
`
| 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()}`;
}
}
}