Files
Aidem-Media-DLL-Analysis/ams/api/static/app.js
Patryk Gensch 4542763936 Add Command Center web UI (no build step)
Static HTML/CSS/JS served by FastAPI (mounted at /ui, / redirects there),
talking to the existing JSON API — no node/npm, no bundler.

- games/versions sidebar with A/B version selectors
- visual 4-axis diff (types/methods/events/fields, +/- struct_layout) with
  +/-/~ rows, per-axis counts, class (owner) filter, moved-methods section
- single-snapshot browser (tabs + live filter)
- app.py mounts StaticFiles(html=True) last so API routes win; / -> /ui/

Smoke-tested live on uvicorn: /, /ui/ and assets serve 200; UI wiring drives
the same /games and /diff endpoints verified end-to-end. app.js passes
`node --check`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:37:03 +02:00

213 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const state = { games: [], snaps: [], byId: {}, a: null, b: null, axes: { types: true, methods: true, events: true, fields: true, layout: false } };
// tiny DOM helper
function el(tag, props, ...kids) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(props || {})) {
if (k === "class") e.className = v;
else if (k === "html") e.innerHTML = v;
else if (k.startsWith("on") && typeof v === "function") e.addEventListener(k.slice(2), v);
else if (v !== false && v != null) e.setAttribute(k, v === true ? "" : v);
}
for (const k of kids.flat()) {
if (k == null || k === false) continue;
e.append(k.nodeType ? k : document.createTextNode(String(k)));
}
return e;
}
const $ = (id) => document.getElementById(id);
async function jget(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(url + " → " + r.status);
return r.json();
}
// --- axis configuration (mirrors ams.diff / ams.render) --------------------------------------
const AXES = [
{ key: "types", title: "Typy",
fmt: (t) => `${t.script_name}${t.cpp_class || "?"} (size ${t.object_size})${t.via_module_iface ? " ⟨iface⟩" : ""}`,
name: (t) => t.script_name },
{ key: "methods", title: "Metody",
fmt: (m) => `${m.owner} · ${m.name} (id ${m.id})`, name: (m) => `${m.owner}.${m.name}` },
{ key: "events", title: "Eventy",
fmt: (e) => `${e.owner} · ${e.name} (#${e.order})`, name: (e) => `${e.owner}.${e.name}` },
{ key: "fields", title: "Pola skryptowe",
fmt: (f) => `${f.owner} · ${f.name} : ${f.type}`, name: (f) => `${f.owner}.${f.name}` },
{ key: "struct_layout", uikey: "layout", title: "Layout C++ (bonus)",
fmt: (x) => `${x.owner} @${hex(x.offset)} size ${x.size}${x.is_vtable ? " vtable" : ""}`,
name: (x) => `${x.owner}@${x.offset}` },
];
const hex = (n) => "0x" + Number(n).toString(16);
// --- sidebar ----------------------------------------------------------------------------------
async function load() {
const [games, snaps] = await Promise.all([jget("/games"), jget("/snapshots")]);
state.games = games; state.snaps = snaps;
state.byId = Object.fromEntries(snaps.map((s) => [s.id, s]));
renderSidebar();
renderAxesControl();
}
function renderSidebar() {
const root = $("games"); root.innerHTML = "";
const byGame = new Map();
for (const s of state.snaps) {
const g = s.game_id == null ? 0 : s.game_id;
if (!byGame.has(g)) byGame.set(g, []);
byGame.get(g).push(s);
}
const gameName = (id) => (state.games.find((g) => g.id === id) || {}).name || "(bez gry)";
for (const [gid, list] of byGame) {
const box = el("div", { class: "game" }, el("div", { class: "game-name" }, gameName(gid)));
for (const s of list) box.append(snapRow(s));
root.append(box);
}
}
function snapRow(s) {
const mkAB = (slot) => el("button", {
class: state[slot] === s.id ? "on" : "", title: "ustaw jako " + slot.toUpperCase(),
onclick: () => { state[slot] = state[slot] === s.id ? null : s.id; refreshSelection(); },
}, slot.toUpperCase());
return el("div", { class: "snap", "data-id": s.id },
el("div", { class: "info", onclick: () => browse(s.id) },
el("div", { class: "bin" }, s.binary_name,
el("span", { class: "pill" }, `${s.engine || "?"}/${s.compiler || "?"}`)),
el("div", { class: "meta" }, `T${s.n_types} · M${s.n_methods} · E${s.n_events} · F${s.n_fields}`)),
el("div", { class: "ab" }, mkAB("a"), mkAB("b")));
}
function refreshSelection() {
document.querySelectorAll(".snap").forEach((row) => {
const id = Number(row.dataset.id);
const [a, b] = row.querySelectorAll(".ab button");
a.className = state.a === id ? "on" : "";
b.className = state.b === id ? "on" : "";
});
const lbl = (id) => { const s = state.byId[id]; return s ? `${s.binary_name} [${s.engine}]` : "—"; };
$("slot-a").innerHTML = "A: "; $("slot-a").append(el("em", {}, lbl(state.a)));
$("slot-b").innerHTML = "B: "; $("slot-b").append(el("em", {}, lbl(state.b)));
$("compare").disabled = !(state.a && state.b);
}
function renderAxesControl() {
const box = $("axes"); box.innerHTML = "";
for (const ax of AXES) {
const k = ax.uikey || ax.key;
box.append(el("label", {},
el("input", { type: "checkbox", ...(state.axes[k] ? { checked: true } : {}),
onchange: (e) => { state.axes[k] = e.target.checked; } }), " " + ax.title.split(" ")[0]));
}
}
// --- diff -------------------------------------------------------------------------------------
async function compare() {
if (!state.a || !state.b) return;
const owner = $("owner").value.trim();
const q = new URLSearchParams({ old: state.a, new: state.b });
if (owner) q.set("owner", owner);
const out = $("results"); out.innerHTML = "ładowanie…";
try {
const d = await jget("/diff?" + q.toString());
renderDiff(d, owner);
} catch (e) {
out.innerHTML = ""; out.append(el("div", { class: "err" }, "Błąd: " + e.message));
}
}
function renderDiff(d, owner) {
const out = $("results"); out.innerHTML = "";
const bf = d.binary.from, bt = d.binary.to;
out.append(el("div", { class: "diff-head" },
"from ", el("b", {}, `${bf.name} [${bf.engine}/${bf.compiler}]`),
" → to ", el("b", {}, `${bt.name} [${bt.engine}/${bt.compiler}]`),
owner ? ` (klasa: ${owner})` : ""));
let any = false;
for (const ax of AXES) {
const k = ax.uikey || ax.key;
if (!state.axes[k]) continue;
const block = d[ax.key];
if (!block) continue;
const n = block.added.length + block.removed.length + block.changed.length;
if (n === 0) continue;
any = true;
out.append(axisCard(ax, block));
}
if (d.moved_methods && d.moved_methods.length) {
any = true;
out.append(movedCard(d.moved_methods));
}
if (!any) out.append(el("div", { class: "empty" }, "Brak różnic w wybranych osiach."));
}
function badge(cls, label, n) { return el("span", { class: "badge " + cls }, `${label} ${n}`); }
function axisCard(ax, block) {
const body = el("div", { class: "body" });
const sortByName = (arr) => arr.slice().sort((x, y) => ax.name(x).localeCompare(ax.name(y)));
for (const it of sortByName(block.added)) body.append(el("div", { class: "row r-add" }, ax.fmt(it)));
for (const it of sortByName(block.removed)) body.append(el("div", { class: "row r-del" }, ax.fmt(it)));
for (const ch of block.changed.slice().sort((x, y) => ax.name(x.item).localeCompare(ax.name(y.item)))) {
const deltas = Object.entries(ch.changes).map(([f, v]) => `${f}: ${v[0]}${v[1]}`).join(", ");
body.append(el("div", { class: "row r-chg" }, ax.name(ch.item), " ", el("span", { class: "delta" }, deltas)));
}
return el("details", { class: "axis", open: true },
el("summary", {}, el("span", { class: "title" }, ax.title),
badge("b-add", "+", block.added.length),
badge("b-del", "", block.removed.length),
badge("b-chg", "~", block.changed.length)),
body);
}
function movedCard(moves) {
const body = el("div", { class: "body" });
for (const m of moves.slice().sort((a, b) => a.name.localeCompare(b.name)))
body.append(el("div", { class: "row moved" }, `${m.name}: ${m.from_owners.join(",")}${m.to_owners.join(",")}`));
return el("details", { class: "axis", open: true },
el("summary", {}, el("span", { class: "title" }, "Metody przeniesione w hierarchii"),
badge("b-chg", "↦", moves.length)), body);
}
// --- single-snapshot browse -------------------------------------------------------------------
async function browse(id) {
const out = $("results"); out.innerHTML = "ładowanie…";
let snap;
try { snap = await jget("/snapshots/" + id); }
catch (e) { out.innerHTML = ""; out.append(el("div", { class: "err" }, "Błąd: " + e.message)); return; }
const d = snap.data;
const tabs = [
["Typy", (d.types || []).map((t) => `${t.script_name}${t.cpp_class || "?"} (size ${t.object_size})`)],
["Metody", (d.methods || []).map((m) => `${m.owner} · ${m.name} (id ${m.id})`)],
["Eventy", (d.events || []).map((e) => `${e.owner} · ${e.name}`)],
["Pola", (d.fields || []).map((f) => `${f.owner} · ${f.name} : ${f.type}`)],
];
out.innerHTML = "";
out.append(el("div", { class: "diff-head" }, "Przegląd: ", el("b", {}, `${snap.binary_name} [${snap.engine}/${snap.compiler}]`)));
const filter = el("input", { class: "owner browse-filter", placeholder: "filtruj…", oninput: () => render() });
const tabbar = el("div", {});
const list = el("div", {});
let active = 0;
function render() {
tabbar.innerHTML = "";
tabs.forEach(([name, items], i) =>
tabbar.append(el("span", { class: "btab" + (i === active ? " on" : ""), onclick: () => { active = i; render(); } },
`${name} (${items.length})`)));
const q = filter.value.trim().toLowerCase();
list.innerHTML = "";
const items = tabs[active][1].filter((s) => !q || s.toLowerCase().includes(q));
for (const s of items) list.append(el("div", { class: "bitem" }, s));
if (!items.length) list.append(el("div", { class: "empty" }, "—"));
}
out.append(tabbar, filter, list);
render();
}
// --- boot -------------------------------------------------------------------------------------
$("compare").addEventListener("click", compare);
$("owner").addEventListener("keydown", (e) => { if (e.key === "Enter") compare(); });
load().catch((e) => { $("results").innerHTML = ""; $("results").append(el("div", { class: "err" }, "Nie udało się załadować: " + e.message)); });