"use strict"; const state = { games: [], snaps: [], byId: {}, a: null, b: null, jobStatus: {}, 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])); } } // --- upload + jobs ---------------------------------------------------------------------------- const ACTIVE = new Set(["queued", "started"]); function setupUpload() { const form = $("upload-form"); $("upload-toggle").addEventListener("click", () => { form.hidden = !form.hidden; if (!form.hidden) $("upload-file").focus(); }); form.addEventListener("submit", submitUpload); } async function submitUpload(e) { e.preventDefault(); const file = $("upload-file").files[0]; if (!file) return; const msg = $("upload-msg"); const fd = new FormData(); fd.append("file", file); const game = $("upload-game").value.trim(); if (game) fd.append("game", game); $("upload-submit").disabled = true; msg.className = "upload-msg"; msg.textContent = "wysyłanie…"; try { const r = await fetch("/jobs", { method: "POST", body: fd }); if (!r.ok) { const detail = await r.json().catch(() => ({})); throw new Error(detail.detail || ("HTTP " + r.status)); } const job = await r.json(); msg.textContent = "zlecono #" + job.id + " — analiza w toku…"; $("upload-form").reset(); pollJobs(); // kick the poller so the new job shows up + tracks to completion } catch (err) { msg.className = "upload-msg err"; msg.textContent = "błąd: " + err.message; } finally { $("upload-submit").disabled = false; } } let _jobTimer = null; async function pollJobs() { let jobs; try { jobs = await jget("/jobs"); } catch { return; } // endpoint may be absent in a stripped deploy — fail quiet // when a tracked job flips to finished, a new snapshot is in the catalog → refresh the list let finished = false; for (const j of jobs) { if (state.jobStatus[j.id] && state.jobStatus[j.id] !== j.status && j.status === "finished") finished = true; state.jobStatus[j.id] = j.status; } renderJobs(jobs); if (finished) load(); clearTimeout(_jobTimer); if (jobs.some((j) => ACTIVE.has(j.status))) _jobTimer = setTimeout(pollJobs, 2500); } function jobBadge(status) { const cls = { queued: "b-q", started: "b-s", finished: "b-add", failed: "b-del" }[status] || "b-q"; return el("span", { class: "badge " + cls }, status); } function renderJobs(jobs) { const root = $("jobs"); root.innerHTML = ""; if (!jobs.length) return; root.append(el("div", { class: "jobs-title" }, "Zadania")); for (const j of jobs.slice(0, 8)) { const row = el("div", { class: "job", title: j.error || "" }, el("span", { class: "jname" }, j.source_name), jobBadge(j.status)); if (j.status === "finished" && j.snapshot_id != null) row.append(el("span", { class: "jlink", onclick: () => browse(j.snapshot_id) }, j.dll_name || "snapshot")); if (j.status === "failed" && j.error) row.append(el("span", { class: "jerr" }, j.error)); root.append(row); } } // --- 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 simBox = el("div", { class: "similar" }); out.append(simBox); loadSimilar(id, simBox); 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(); } async function loadSimilar(targetId, box) { let hits; try { hits = await jget("/snapshots/" + targetId + "/similar"); } catch { return; } // endpoint absent / single-snapshot catalog — just show nothing if (!hits.length) return; box.append(el("div", { class: "similar-title" }, "Podobne wersje (overlap powierzchni)")); for (const h of hits.slice(0, 6)) { const s = h.snapshot; const bar = el("span", { class: "simbar" }, el("span", { class: "simfill", style: "width:" + h.overall + "%" })); box.append(el("div", { class: "simrow" }, el("span", { class: "simscore" }, h.overall + "%"), bar, el("span", { class: "simname", title: "przejrzyj", onclick: () => browse(s.id) }, `${s.binary_name} [${s.engine || "?"}]`), h.fuzzy != null ? el("span", { class: "simfuzzy", title: "ssdeep binarki" }, "fuzzy " + h.fuzzy) : null, el("span", { class: "simdiff", title: "porównaj tę wersję z aktualną", onclick: () => { state.a = targetId; state.b = s.id; refreshSelection(); compare(); } }, "⇄ diff"))); } } // --- boot ------------------------------------------------------------------------------------- $("compare").addEventListener("click", compare); $("owner").addEventListener("keydown", (e) => { if (e.key === "Enter") compare(); }); setupUpload(); pollJobs(); load().catch((e) => { $("results").innerHTML = ""; $("results").append(el("div", { class: "err" }, "Nie udało się załadować: " + e.message)); });