From 454276393641761c1165f7dd57eb041f5c0b837f Mon Sep 17 00:00:00 2001 From: Patryk Gensch <43010113+patryk025@users.noreply.github.com> Date: Sat, 30 May 2026 23:37:03 +0200 Subject: [PATCH] Add Command Center web UI (no build step) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 6 ++ ams/api/app.py | 13 +++ ams/api/static/app.js | 212 ++++++++++++++++++++++++++++++++++++++ ams/api/static/index.html | 41 ++++++++ ams/api/static/style.css | 80 ++++++++++++++ 5 files changed, 352 insertions(+) create mode 100644 ams/api/static/app.js create mode 100644 ams/api/static/index.html create mode 100644 ams/api/static/style.css 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 @@ + + + + + + Aidem Media — Command Center + + + +
+
⌖ Aidem Media Command Center
+
Piklib / BlooMoo — analiza różnicowa powierzchni silnika
+
+ +
+ + +
+
+
+ A: + + B: +
+ + + +
+
+
Wybierz dwie wersje (A i B) z panelu po lewej, a potem „Porównaj”.
+ Kliknij nazwę wersji, by przejrzeć jej powierzchnię.
+
+
+
+ + + + diff --git a/ams/api/static/style.css b/ams/api/static/style.css new file mode 100644 index 0000000..14c254e --- /dev/null +++ b/ams/api/static/style.css @@ -0,0 +1,80 @@ +:root { + --bg: #0d1117; --panel: #131a23; --panel2: #1a2330; --border: #243140; + --fg: #c9d4e0; --dim: #6b7c8f; --accent: #4ea3ff; --accent2: #1f6feb; + --add: #3fb950; --del: #f85149; --chg: #d29922; --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { background: var(--bg); color: var(--fg); font: 13px/1.45 var(--mono); } + +.topbar { display: flex; align-items: baseline; gap: 16px; padding: 10px 18px; + background: linear-gradient(180deg, #131c28, #0d1117); border-bottom: 1px solid var(--border); } +.brand { font-size: 16px; font-weight: 600; letter-spacing: .5px; color: #eaf2fb; } +.brand span { color: var(--accent); font-weight: 400; } +.sub { color: var(--dim); font-size: 12px; } + +.layout { display: grid; grid-template-columns: 320px 1fr; height: calc(100% - 47px); } +.sidebar { border-right: 1px solid var(--border); overflow-y: auto; background: var(--panel); } +.panel-title { padding: 10px 14px; color: var(--dim); text-transform: uppercase; font-size: 11px; + letter-spacing: 1px; position: sticky; top: 0; background: var(--panel); border-bottom: 1px solid var(--border); } + +.game { padding: 8px 10px 4px; } +.game-name { color: #eaf2fb; font-weight: 600; padding: 4px 4px; } +.snap { display: flex; align-items: center; gap: 6px; padding: 6px 8px; margin: 3px 0; + border: 1px solid var(--border); border-radius: 6px; background: var(--panel2); } +.snap:hover { border-color: var(--accent2); } +.snap .info { flex: 1; min-width: 0; cursor: pointer; } +.snap .bin { color: var(--fg); } +.snap .meta { color: var(--dim); font-size: 11px; } +.snap .pill { display: inline-block; padding: 0 5px; border-radius: 4px; background: #20303f; color: var(--dim); margin-left: 4px; } +.snap .ab { display: flex; gap: 4px; } +.snap .ab button { width: 24px; height: 24px; border: 1px solid var(--border); background: #16202c; + color: var(--dim); border-radius: 5px; cursor: pointer; font-family: var(--mono); } +.snap .ab button.on { background: var(--accent2); color: #fff; border-color: var(--accent); } + +.main { display: flex; flex-direction: column; min-width: 0; } +.controls { display: flex; align-items: center; gap: 12px; padding: 10px 16px; flex-wrap: wrap; + border-bottom: 1px solid var(--border); background: var(--panel); } +.slots { color: var(--dim); } +.slot em { color: var(--accent); font-style: normal; } +.arrow { color: var(--dim); margin: 0 4px; } +.owner { background: #0c141d; border: 1px solid var(--border); color: var(--fg); + padding: 5px 8px; border-radius: 6px; font-family: var(--mono); width: 220px; } +.axes { display: flex; gap: 8px; color: var(--dim); } +.axes label { cursor: pointer; user-select: none; } +.compare { margin-left: auto; background: var(--accent2); color: #fff; border: 0; + padding: 7px 14px; border-radius: 6px; cursor: pointer; font-family: var(--mono); } +.compare:disabled { background: #223; color: #566; cursor: not-allowed; } + +.results { padding: 16px; overflow-y: auto; } +.hint { color: var(--dim); text-align: center; margin-top: 60px; line-height: 1.8; } +.diff-head { color: var(--dim); margin-bottom: 14px; } +.diff-head b { color: var(--fg); } + +.axis { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 14px; overflow: hidden; } +.axis > summary { list-style: none; cursor: pointer; padding: 9px 14px; background: var(--panel2); + display: flex; align-items: center; gap: 10px; } +.axis > summary::-webkit-details-marker { display: none; } +.axis .title { font-weight: 600; color: #eaf2fb; } +.badge { font-size: 11px; padding: 1px 7px; border-radius: 10px; } +.b-add { background: rgba(63,185,80,.15); color: var(--add); } +.b-del { background: rgba(248,81,73,.15); color: var(--del); } +.b-chg { background: rgba(210,153,34,.15); color: var(--chg); } +.axis .body { padding: 6px 14px 12px; } +.row { padding: 2px 0 2px 18px; position: relative; white-space: pre-wrap; word-break: break-word; } +.row::before { position: absolute; left: 0; font-weight: 700; } +.r-add::before { content: "+"; color: var(--add); } +.r-del::before { content: "−"; color: var(--del); } +.r-chg::before { content: "~"; color: var(--chg); } +.r-del { color: var(--dim); } +.delta { color: var(--chg); } +.empty { color: var(--dim); font-style: italic; } +.moved { color: var(--accent); } + +.browse-filter { margin-bottom: 10px; } +.btab { display: inline-block; padding: 4px 10px; margin-right: 6px; border: 1px solid var(--border); + border-radius: 6px; cursor: pointer; color: var(--dim); } +.btab.on { background: var(--accent2); color: #fff; border-color: var(--accent); } +.bitem { padding: 1px 0; } +.bitem .k { color: var(--dim); } +.err { color: var(--del); }