mirror of
https://github.com/griches/PS5_DevTools.git
synced 2026-10-18 02:00:41 +02:00
A resident payload that attaches to homebrew titles as their debugger, writes a crash report (registers, every thread's backtrace, stack, modules, code at the fault, memory before the crash) and shows a TV notification, then hands the signal back. It samples every title's memory (direct/GPU, heap, other), CPU and threads, and serves a dashboard on port 9113. The host tool symbolicates reports against the matching build's ELF, including reports a player sends as JSON. Tested on firmware 13.60 with Super Mario Sunshine and SwitchWaker. Claude-Session: https://claude.ai/code/session_016xMYBaR9GfxtETyVf6er1P
333 lines
20 KiB
HTML
333 lines
20 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>PS5 DevTools</title>
|
|
<style>
|
|
:root {
|
|
--bg: #f6f7f9; --panel: #ffffff; --ink: #1b1f24; --muted: #5f6b7a; --line: #dde2e8;
|
|
--accent: #2459d6; --bad: #c62f3b; --good: #1f8a4c; --warn: #b26b00; --chip: #eef1f5;
|
|
--c1: #2459d6; --c2: #d6653a; --c3: #1f8a4c; --c4: #8b46c8; --c5: #b08a00; --c6: #0e8a95; --c7: #c62f86; --c8: #6b7480;
|
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root:not([data-theme="light"]) {
|
|
--bg: #111418; --panel: #181c22; --ink: #e6e9ee; --muted: #93a0b0; --line: #2a313a;
|
|
--accent: #6f9bff; --bad: #ff6b76; --good: #4cc982; --warn: #f0a640; --chip: #222831;
|
|
--c1: #6f9bff; --c2: #ff9466; --c3: #4cc982; --c4: #bb8cff; --c5: #e8c34a; --c6: #3fcfd9; --c7: #ff7ac2; --c8: #9aa5b3;
|
|
}
|
|
}
|
|
:root[data-theme="dark"] {
|
|
--bg: #111418; --panel: #181c22; --ink: #e6e9ee; --muted: #93a0b0; --line: #2a313a;
|
|
--accent: #6f9bff; --bad: #ff6b76; --good: #4cc982; --warn: #f0a640; --chip: #222831;
|
|
--c1: #6f9bff; --c2: #ff9466; --c3: #4cc982; --c4: #bb8cff; --c5: #e8c34a; --c6: #3fcfd9; --c7: #ff7ac2; --c8: #9aa5b3;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
header { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 2; }
|
|
header h1 { font-size: 16px; margin: 0; }
|
|
header .meta { color: var(--muted); font-size: 13px; display: flex; gap: 16px; flex-wrap: wrap; }
|
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); margin-right: 6px; vertical-align: middle; }
|
|
.dot.on { background: var(--good); } .dot.off { background: var(--bad); }
|
|
main { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 16px; padding: 16px; max-width: 1400px; margin: 0 auto; }
|
|
@media (max-width: 860px) { main { grid-template-columns: minmax(0, 1fr); } }
|
|
section { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 14px; min-width: 0; }
|
|
section + section { margin-top: 16px; }
|
|
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 0 0 10px; }
|
|
.titles button { display: block; width: 100%; text-align: left; background: none; border: 1px solid transparent; border-radius: 6px; padding: 8px; color: inherit; font: inherit; cursor: pointer; }
|
|
.titles button:hover { background: var(--chip); }
|
|
.titles button.sel { border-color: var(--accent); background: var(--chip); }
|
|
.titles .name { font-weight: 600; }
|
|
.small { font-size: 12px; color: var(--muted); }
|
|
.chip { display: inline-block; font-size: 11px; padding: 1px 7px; border-radius: 10px; background: var(--chip); color: var(--muted); margin-right: 4px; }
|
|
.chip.good { color: var(--good); } .chip.bad { color: var(--bad); } .chip.warn { color: var(--warn); }
|
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; margin-bottom: 12px; }
|
|
.stat { background: var(--chip); border-radius: 6px; padding: 8px 10px; }
|
|
.stat b { display: block; font-size: 18px; font-variant-numeric: tabular-nums; }
|
|
.stat span { font-size: 12px; color: var(--muted); }
|
|
.toolbar { display: flex; gap: 6px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
|
|
.toolbar button, .btn { background: var(--chip); color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; font: inherit; font-size: 12px; cursor: pointer; }
|
|
.toolbar button.sel { border-color: var(--accent); color: var(--accent); }
|
|
canvas { width: 100%; height: 220px; display: block; }
|
|
canvas.short { height: 110px; }
|
|
.legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 12px; color: var(--muted); margin: 6px 0 4px; }
|
|
.legend i { display: inline-block; width: 10px; height: 3px; margin-right: 5px; vertical-align: middle; }
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
th { color: var(--muted); font-weight: 500; font-size: 12px; }
|
|
tr.click { cursor: pointer; } tr.click:hover td { background: var(--chip); }
|
|
.mono { font-family: var(--mono); font-size: 12px; }
|
|
.wrap { overflow-x: auto; }
|
|
.empty { color: var(--muted); padding: 10px 0; }
|
|
dialog { width: min(1100px, calc(100vw - 32px)); max-height: calc(100vh - 32px); border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--ink); padding: 0; }
|
|
dialog::backdrop { background: rgba(0,0,0,.45); }
|
|
.dlg-head { display: flex; justify-content: space-between; gap: 10px; align-items: start; padding: 14px 16px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--panel); }
|
|
.dlg-body { padding: 14px 16px; }
|
|
.dlg-body h3 { font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; margin: 18px 0 6px; }
|
|
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 3px 14px; }
|
|
.kv dt { color: var(--muted); } .kv dd { margin: 0; overflow-wrap: anywhere; }
|
|
.regs { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 2px 14px; }
|
|
details summary { cursor: pointer; color: var(--muted); margin: 6px 0; }
|
|
.sym { color: var(--accent); }
|
|
.note { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>PS5 DevTools</h1>
|
|
<div class="meta">
|
|
<span><span id="conn" class="dot"></span><span id="conntext">connecting</span></span>
|
|
<span id="fw"></span><span id="up"></span>
|
|
</div>
|
|
</header>
|
|
<main>
|
|
<div>
|
|
<section>
|
|
<h2>Titles</h2>
|
|
<div id="titles" class="titles"><div class="empty">No titles seen yet.</div></div>
|
|
<div class="note">Every title is sampled. Homebrew titles (folders in /data/homebrew) are also watched for crashes.</div>
|
|
</section>
|
|
</div>
|
|
<div>
|
|
<section>
|
|
<h2 id="memtitle">Memory</h2>
|
|
<div id="stats" class="stats"></div>
|
|
<div class="toolbar" id="window">
|
|
<span class="small">Window</span>
|
|
<button data-w="300">5 min</button><button data-w="900" class="sel">15 min</button><button data-w="3600">1 hour</button>
|
|
</div>
|
|
<canvas id="memchart"></canvas>
|
|
<div class="legend" id="memlegend"></div>
|
|
<canvas id="cpuchart" class="short"></canvas>
|
|
<div class="legend"><span><i style="background:var(--c2)"></i>CPU, % of one core</span><span><i style="background:var(--c8)"></i>threads</span></div>
|
|
</section>
|
|
<section>
|
|
<h2>Crash reports</h2>
|
|
<div class="wrap"><table id="crashes"><thead><tr><th>When</th><th>Title</th><th>Signal</th><th>Where</th><th>Outcome</th></tr></thead><tbody></tbody></table></div>
|
|
<div id="symnote" class="note"></div>
|
|
</section>
|
|
<section>
|
|
<h2>System error history</h2>
|
|
<div class="wrap"><table id="errors"><thead><tr><th>When</th><th>Code</th><th>Title</th><th>Message</th></tr></thead><tbody></tbody></table></div>
|
|
<div class="note">The console's own record of every error dialog. CE-108255-1 is a title crash.</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
<dialog id="detail"><div class="dlg-head"><div id="dhead"></div><div><button class="btn" id="dsave">Download JSON</button> <button class="btn" id="dclose">Close</button></div></div><div class="dlg-body" id="dbody"></div></dialog>
|
|
<script>
|
|
"use strict";
|
|
const $ = (s, el = document) => el.querySelector(s);
|
|
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&": "&", "<": "<", ">": ">", '"': """}[c]));
|
|
const css = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
|
|
const mb = kb => kb >= 1048576 ? (kb / 1048576).toFixed(2) + " GiB" : (kb / 1024).toFixed(0) + " MiB";
|
|
const ago = s => s < 90 ? Math.round(s) + " s" : s < 5400 ? Math.round(s / 60) + " min" : (s / 3600).toFixed(1) + " h";
|
|
const version = v => v.split(".").map(Number).join(".");
|
|
const fmtTime = t => new Date(t * 1000).toLocaleString([], {month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit"});
|
|
|
|
let status = null, selected = null, windowS = 900, samples = {}, symbolicate = null;
|
|
|
|
async function api(path, opts) {
|
|
const r = await fetch(path, opts);
|
|
if (!r.ok) throw new Error(path + " " + r.status);
|
|
return r.headers.get("content-type")?.includes("json") ? r.json() : r.text();
|
|
}
|
|
|
|
function setConn(ok, text) {
|
|
$("#conn").className = "dot " + (ok ? "on" : "off");
|
|
$("#conntext").textContent = text;
|
|
}
|
|
|
|
async function refreshStatus() {
|
|
try {
|
|
status = await api("api/status");
|
|
setConn(true, "console connected");
|
|
$("#fw").textContent = "firmware " + status.firmware.replace(/^0*(\d+)(\d\d)(\d{4})$/, "$1.$2");
|
|
$("#up").textContent = "DevTools " + status.devtools + ", up " + ago(status.now - status.started);
|
|
renderTitles();
|
|
} catch (e) { setConn(false, "console not answering"); }
|
|
}
|
|
|
|
function renderTitles() {
|
|
const list = [...status.titles].sort((a, b) => (b.live - a.live) || (b.started - a.started));
|
|
if (!list.length) { $("#titles").innerHTML = '<div class="empty">No titles seen yet. Start one.</div>'; return; }
|
|
if (!selected || !list.find(t => t.pid === selected)) selected = (list.find(t => t.live) || list[0]).pid;
|
|
$("#titles").innerHTML = list.map(t => {
|
|
const chips = [t.live ? '<span class="chip good">running</span>' : '<span class="chip">ended</span>'];
|
|
if (t.watch && t.live) chips.push(t.traced ? '<span class="chip good">watching</span>' : t.trace_error ? '<span class="chip bad">attach failed</span>' : '<span class="chip warn">not attached</span>');
|
|
if (t.reports) chips.push('<span class="chip bad">' + t.reports + " crash" + (t.reports > 1 ? "es" : "") + "</span>");
|
|
return `<button data-pid="${t.pid}" class="${t.pid === selected ? "sel" : ""}"><div class="name">${esc(t.name)}</div>
|
|
<div class="small">${esc(t.title_id)} ${t.version ? "v" + esc(version(t.version)) : ""} · pid ${t.pid}</div><div>${chips.join("")}</div></button>`;
|
|
}).join("");
|
|
}
|
|
|
|
$("#titles").addEventListener("click", e => {
|
|
const b = e.target.closest("button[data-pid]");
|
|
if (!b) return;
|
|
selected = +b.dataset.pid;
|
|
renderTitles();
|
|
refreshMemory(true);
|
|
});
|
|
$("#window").addEventListener("click", e => {
|
|
const b = e.target.closest("button[data-w]");
|
|
if (!b) return;
|
|
windowS = +b.dataset.w;
|
|
document.querySelectorAll("#window button").forEach(x => x.classList.toggle("sel", x === b));
|
|
drawMemory();
|
|
});
|
|
|
|
async function refreshMemory(force) {
|
|
if (!selected) return;
|
|
const have = samples[selected] || [];
|
|
const since = force || !have.length ? 0 : have[have.length - 1][0];
|
|
try {
|
|
const r = await api("api/memory?pid=" + selected + "&since=" + since);
|
|
samples[selected] = (since ? have : []).concat(r.samples).slice(-3600);
|
|
} catch (e) { return; }
|
|
drawMemory();
|
|
}
|
|
|
|
function chart(canvas, series, opts) {
|
|
const dpr = window.devicePixelRatio || 1, w = canvas.clientWidth, h = canvas.clientHeight;
|
|
canvas.width = w * dpr; canvas.height = h * dpr;
|
|
const g = canvas.getContext("2d");
|
|
g.scale(dpr, dpr);
|
|
g.clearRect(0, 0, w, h);
|
|
const left = 64, right = 10, top = 8, bottom = 20, pw = w - left - right, ph = h - top - bottom;
|
|
const t1 = opts.t1, t0 = t1 - opts.window;
|
|
let max = opts.min || 1;
|
|
for (const s of series) for (const [t, v] of s.points) if (t >= t0 && v > max) max = v;
|
|
max *= 1.08;
|
|
g.strokeStyle = css("--line"); g.fillStyle = css("--muted"); g.font = "11px system-ui"; g.lineWidth = 1;
|
|
for (let i = 0; i <= 4; i++) {
|
|
const y = top + ph - ph * i / 4;
|
|
g.beginPath(); g.moveTo(left, y); g.lineTo(left + pw, y); g.stroke();
|
|
g.textAlign = "right"; g.fillText(opts.fmt(max * i / 4), left - 6, y + 4);
|
|
}
|
|
g.textAlign = "center";
|
|
for (let i = 0; i <= 5; i++) {
|
|
const t = t0 + opts.window * i / 5;
|
|
g.fillText(i === 5 ? "now" : "-" + ago(t1 - t), left + pw * i / 5, h - 5);
|
|
}
|
|
for (const s of series) {
|
|
g.strokeStyle = css(s.color); g.lineWidth = s.width || 1.6; g.beginPath();
|
|
let started = false;
|
|
for (const [t, v] of s.points) {
|
|
if (t < t0) continue;
|
|
const x = left + pw * (t - t0) / opts.window, y = top + ph - ph * v / max;
|
|
started ? g.lineTo(x, y) : g.moveTo(x, y);
|
|
started = true;
|
|
}
|
|
g.stroke();
|
|
}
|
|
}
|
|
|
|
function drawMemory() {
|
|
const t = status?.titles.find(x => x.pid === selected), s = samples[selected] || [];
|
|
$("#memtitle").textContent = t ? "Memory · " + t.name : "Memory";
|
|
if (!s.length) { $("#stats").innerHTML = '<div class="empty">No samples yet.</div>'; return; }
|
|
const last = s[s.length - 1], t1 = t && !t.live ? last[0] : status?.now || last[0];
|
|
const peak = Math.max(...s.map(x => x[1]));
|
|
// on the PS5, direct memory (what the GPU uses) is mapped as device memory and
|
|
// counts in the resident total; the heap is what is resident of the anonymous
|
|
// mappings (a title reserves far more address space than it touches)
|
|
const direct = x => x[5][4] + x[5][5] + x[5][8], heap = x => x[6][1], other = x => Math.max(0, x[1] - direct(x) - heap(x));
|
|
$("#stats").innerHTML = [
|
|
["resident", mb(last[1])], ["peak resident", mb(peak)], ["direct memory", mb(direct(last))],
|
|
["heap", mb(heap(last))], ["CPU", (last[3] / 10).toFixed(0) + "%"], ["threads", last[4]]
|
|
].map(([k, v]) => `<div class="stat"><b>${v}</b><span>${k}</span></div>`).join("");
|
|
const series = [
|
|
{name: "resident (all)", color: "--ink", width: 2.2, points: s.map(x => [x[0], x[1]])},
|
|
{name: "direct memory (GPU)", color: "--c4", points: s.map(x => [x[0], direct(x)])},
|
|
{name: "heap", color: "--c1", points: s.map(x => [x[0], heap(x)])},
|
|
{name: "other (code, files, stacks)", color: "--c3", points: s.map(x => [x[0], other(x)])},
|
|
];
|
|
chart($("#memchart"), series, {t1, window: windowS, fmt: mb});
|
|
$("#memlegend").innerHTML = series.map(x => `<span><i style="background:var(${x.color})"></i>${esc(x.name)}</span>`).join("");
|
|
chart($("#cpuchart"), [
|
|
{color: "--c2", points: s.map(x => [x[0], x[3] / 10])},
|
|
{color: "--c8", points: s.map(x => [x[0], x[4]])}
|
|
], {t1, window: windowS, fmt: v => v.toFixed(0), min: 10});
|
|
}
|
|
|
|
async function refreshCrashes() {
|
|
let list;
|
|
try { list = await api("api/crashes"); } catch (e) { return; }
|
|
const body = $("#crashes tbody");
|
|
if (!list.length) { body.innerHTML = '<tr><td colspan="5" class="empty">No crashes recorded. That is good news.</td></tr>'; return; }
|
|
body.innerHTML = list.map(c => `<tr class="click" data-id="${esc(c.id)}"><td>${esc(new Date(c.time).toLocaleString())}</td>
|
|
<td>${esc(c.title_name)}<div class="small">${esc(c.title_id)}</div></td><td>${esc(c.signal_name)}</td>
|
|
<td class="mono">${esc(c.where)}</td><td>${c.outcome === "crashed" ? '<span class="chip bad">crashed</span>' : c.outcome === "handled" ? '<span class="chip warn">handled by title</span>' : '<span class="chip">pending</span>'}</td></tr>`).join("");
|
|
}
|
|
$("#crashes").addEventListener("click", e => { const r = e.target.closest("tr[data-id]"); if (r) openCrash(r.dataset.id); });
|
|
|
|
function framesTable(frames, syms) {
|
|
return `<div class="wrap"><table><thead><tr><th>#</th><th>Address</th><th>Module + offset</th><th>Function</th><th>How</th></tr></thead><tbody>${
|
|
frames.map((f, i) => { const s = syms && syms[f.addr];
|
|
return `<tr><td>${i}</td><td class="mono">${esc(f.addr)}</td><td class="mono">${esc(f.module.split("/").pop())}+${esc(f.offset)}</td>
|
|
<td class="mono sym">${s ? esc(s.function) + '<div class="small">' + esc(s.location) + "</div>" : ""}</td><td class="small">${esc(f.how)}</td></tr>`; }).join("")
|
|
}</tbody></table></div>`;
|
|
}
|
|
|
|
let current = null;
|
|
async function openCrash(id) {
|
|
const c = await api("api/crash/" + encodeURIComponent(id));
|
|
current = c;
|
|
let syms = null;
|
|
if (symbolicate) { try { syms = (await api("api/symbolicate/" + encodeURIComponent(id))).frames; } catch (e) {} }
|
|
const crashed = c.threads.find(t => t.crashed);
|
|
$("#dhead").innerHTML = `<div style="font-size:16px;font-weight:600">${esc(c.title_name)}: ${esc(c.signal_name)} at <span class="mono">${esc(c.where)}</span></div>
|
|
<div class="small">${esc(c.time)} · ${esc(c.title_id)} v${esc(version(c.title_version || "0"))} · pid ${c.pid}</div>`;
|
|
const regs = Object.entries(c.registers).map(([k, v]) => `<div class="mono"><span class="small">${k}</span> ${esc(v)}</div>`).join("");
|
|
const mem = c.memory || [];
|
|
$("#dbody").innerHTML = `
|
|
<dl class="kv"><dt>Signal</dt><dd>${esc(c.signal_name)} (${c.signal}), code ${c.si_code}</dd>
|
|
<dt>Fault address</dt><dd class="mono">${esc(c.fault_addr)}</dd>
|
|
<dt>Thread</dt><dd>${esc(c.thread.name)} (${c.thread.id})</dd>
|
|
${c.title_frame ? `<dt>In the title's code</dt><dd class="mono">${esc(c.title_frame)}${(() => { const f = c.backtrace.find(f => f.module.endsWith("eboot.bin") && "eboot.bin+" + f.offset === c.title_frame); const s = f && syms && syms[f.addr]; return s ? ' <span class="sym">' + esc(s.function) + "</span> " + esc(s.location) : ""; })()}</dd>` : ""}
|
|
<dt>Outcome</dt><dd>${c.outcome === "crashed" ? "the title died of it" : c.outcome === "handled" ? "the title's own handler took it and carried on" : "pending"}</dd>
|
|
<dt>Running for</dt><dd>${ago(c.uptime_s)}</dd><dt>Firmware</dt><dd>${esc(c.firmware)}</dd></dl>
|
|
<h3>Backtrace</h3>${framesTable(c.backtrace, syms)}
|
|
${syms ? "" : '<div class="note">Function names come from the build\'s ELF, which only the title\'s developer has. Not the developer? Use <b>Download JSON</b> and send them the file: <span class="mono">tools/ps5devtools.py report FILE.json</span> reads it with names. On the machine that built the title, open this dashboard through <span class="mono">tools/ps5devtools.py serve</span>.</div>'}
|
|
<h3>Registers</h3><div class="regs">${regs}</div>
|
|
<h3>Code at the fault</h3>${c.code_at_pc ? `<div class="mono" style="overflow-wrap:anywhere">${esc(c.code_at_pc)}</div><div class="note">The bar marks the faulting instruction.</div>` : '<div class="note">Not available: only the title\'s own code can be read, and the fault was outside it.</div>'}
|
|
<h3>Memory in the two minutes before</h3><canvas id="crashmem" class="short"></canvas>
|
|
<h3>Threads (${c.threads.length})</h3>${c.threads.map(t => `<details${t.crashed ? " open" : ""}><summary>${esc(t.name)} · ${t.id} · <span class="mono">${esc(t.rip)}</span>${t.crashed ? " · crashed" : ""}</summary>${framesTable(t.backtrace, syms)}</details>`).join("")}
|
|
<h3>Modules (${c.modules.length})</h3><div class="wrap"><table><thead><tr><th>Path</th><th>Base</th><th>End</th></tr></thead><tbody>${
|
|
c.modules.map(m => `<tr><td class="mono">${esc(m.path)}</td><td class="mono">${esc(m.base)}</td><td class="mono">${esc(m.end)}</td></tr>`).join("")}</tbody></table></div>`;
|
|
$("#detail").showModal();
|
|
if (mem.length) chart($("#crashmem"), [{color: "--ink", width: 2, points: mem.map(x => [x[0], x[1]])}], {t1: mem[mem.length - 1][0], window: 120, fmt: mb});
|
|
}
|
|
$("#dclose").onclick = () => $("#detail").close();
|
|
$("#dsave").onclick = () => {
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(new Blob([JSON.stringify(current, null, 1)], {type: "application/json"}));
|
|
a.download = current.id + ".json"; a.click();
|
|
};
|
|
|
|
async function refreshErrors() {
|
|
let list;
|
|
try { list = await api("api/errors"); } catch (e) { return; }
|
|
// ticks are microseconds since 0001-01-01
|
|
list = list.map(x => ({...x.entry, unix: x.entry.tick / 1e6 - 62135596800})).sort((a, b) => b.unix - a.unix);
|
|
$("#errors tbody").innerHTML = list.slice(0, 50).map(e => `<tr><td>${fmtTime(e.unix)}</td>
|
|
<td class="mono">${esc(e.shortError)}</td><td>${esc(e.titleId)}</td><td>${esc((e.messageBuffer || "").split("\n")[0])}</td></tr>`).join("")
|
|
|| '<tr><td colspan="4" class="empty">None.</td></tr>';
|
|
}
|
|
|
|
async function start() {
|
|
try { await fetch("api/symbolicate", {method: "HEAD"}).then(r => { symbolicate = r.ok; }); } catch (e) {}
|
|
$("#symnote").textContent = symbolicate ? "Function names: from your builds' ELFs." : "";
|
|
await refreshStatus();
|
|
await refreshMemory(true);
|
|
refreshCrashes(); refreshErrors();
|
|
setInterval(async () => { await refreshStatus(); refreshMemory(false); }, 2000);
|
|
setInterval(refreshCrashes, 5000);
|
|
setInterval(refreshErrors, 30000);
|
|
addEventListener("resize", drawMemory);
|
|
}
|
|
start();
|
|
</script>
|
|
</body>
|
|
</html>
|