+ Kliknij nazwę wersji, by przejrzeć jej powierzchnię.
diff --git a/README.md b/README.md index bbe383e..021bfef 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,12 @@ uvicorn ams.api.app:create_app --factory --reload # serwer Endpointy: `POST/GET /games`, `POST/GET /snapshots` (import deduplikowany po sha256), `GET /diff?old=&new=[&owner=]`, `GET /health`. Testy: `pytest` (11, w tym integracyjne na golden pair). +## Front — Command Center + +Po starcie serwera otwórz **http://127.0.0.1:8000/** (`/` → `/ui/`). Statyczny UI bez build-stepu +(czysty HTML/CSS/JS w `ams/api/static/`, serwowany przez FastAPI): lista gier/wersji, wybór dwóch +wersji (A/B), wizualny diff po 4 osiach z filtrem klasy i przeglądarka pojedynczej powierzchni. + ## Format snapshotu `schema_version`, `binary{name,sha256,engine,compiler,factory_addr}`, oraz listy diff --git a/ams/api/app.py b/ams/api/app.py index 8ce5885..ee5ac64 100644 --- a/ams/api/app.py +++ b/ams/api/app.py @@ -7,12 +7,18 @@ Run with uvicorn's factory mode (no import-time DB side effects): from __future__ import annotations +from pathlib import Path + from fastapi import FastAPI +from fastapi.responses import RedirectResponse +from fastapi.staticfiles import StaticFiles from .. import __version__ from .db import configure, init_db from .routes import diff, games, snapshots +_STATIC = Path(__file__).parent / "static" + def create_app(database_url: str | None = None) -> FastAPI: configure(database_url) @@ -27,4 +33,11 @@ def create_app(database_url: str | None = None) -> FastAPI: def health() -> dict[str, str]: return {"status": "ok", "version": __version__} + @app.get("/", include_in_schema=False) + def root() -> RedirectResponse: + return RedirectResponse("/ui/") + + # The command-center UI (static HTML/CSS/JS, no build step) is served last so API + # routes take precedence. + app.mount("/ui", StaticFiles(directory=str(_STATIC), html=True), name="ui") return app diff --git a/ams/api/static/app.js b/ams/api/static/app.js new file mode 100644 index 0000000..991e461 --- /dev/null +++ b/ams/api/static/app.js @@ -0,0 +1,212 @@ +"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)); }); diff --git a/ams/api/static/index.html b/ams/api/static/index.html new file mode 100644 index 0000000..e88c039 --- /dev/null +++ b/ams/api/static/index.html @@ -0,0 +1,41 @@ + + +
+ + +