Files
Aidem-Media-DLL-Analysis/ams/api/static/app.js
Patryk Gensch 30b2b1011e UI: upload panel — POST /jobs with live status polling
Adds a "+ wgraj" control to the sidebar that uploads an ISO/ZIP/DLL to the
acquisition endpoint and tracks the job to completion, then refreshes the
version list so the new snapshot appears without a reload.

- index.html: upload form + #jobs panel in the sidebar
- app.js: submitUpload() (FormData → POST /jobs), pollJobs() (2.5s while any
  job is queued/started; finished → load(); failed → inline error)
- style.css: mini-btn / upload form / job rows + queued/started badges

Verified: node --check clean; uvicorn serves /ui assets 200 and GET /jobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:28:35 +02:00

301 lines
12 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, 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 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(); });
setupUpload();
pollJobs();
load().catch((e) => { $("results").innerHTML = ""; $("results").append(el("div", { class: "err" }, "Nie udało się załadować: " + e.message)); });