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>
This commit is contained in:
Patryk Gensch
2026-05-30 23:37:03 +02:00
parent bd03c56e98
commit 4542763936
5 changed files with 352 additions and 0 deletions

View File

@@ -71,6 +71,12 @@ uvicorn ams.api.app:create_app --factory --reload # serwer
Endpointy: `POST/GET /games`, `POST/GET /snapshots` (import deduplikowany po sha256), 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). `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 ## Format snapshotu
`schema_version`, `binary{name,sha256,engine,compiler,factory_addr}`, oraz listy `schema_version`, `binary{name,sha256,engine,compiler,factory_addr}`, oraz listy

View File

@@ -7,12 +7,18 @@ Run with uvicorn's factory mode (no import-time DB side effects):
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from .. import __version__ from .. import __version__
from .db import configure, init_db from .db import configure, init_db
from .routes import diff, games, snapshots from .routes import diff, games, snapshots
_STATIC = Path(__file__).parent / "static"
def create_app(database_url: str | None = None) -> FastAPI: def create_app(database_url: str | None = None) -> FastAPI:
configure(database_url) configure(database_url)
@@ -27,4 +33,11 @@ def create_app(database_url: str | None = None) -> FastAPI:
def health() -> dict[str, str]: def health() -> dict[str, str]:
return {"status": "ok", "version": __version__} 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 return app

212
ams/api/static/app.js Normal file
View File

@@ -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)); });

41
ams/api/static/index.html Normal file
View File

@@ -0,0 +1,41 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Aidem Media — Command Center</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="topbar">
<div class="brand">⌖ Aidem&nbsp;Media <span>Command Center</span></div>
<div class="sub">Piklib / BlooMoo — analiza różnicowa powierzchni silnika</div>
</header>
<div class="layout">
<aside id="sidebar" class="sidebar">
<div class="panel-title">Gry / wersje</div>
<div id="games" class="games"></div>
</aside>
<main class="main">
<div class="controls">
<div class="slots">
<span class="slot" id="slot-a">A: <em></em></span>
<span class="arrow"></span>
<span class="slot" id="slot-b">B: <em></em></span>
</div>
<input id="owner" class="owner" placeholder="filtr klasy, np. CMC_Animo" autocomplete="off">
<span id="axes" class="axes"></span>
<button id="compare" class="compare" disabled>Porównaj A→B</button>
</div>
<div id="results" class="results">
<div class="hint">Wybierz dwie wersje (A i B) z panelu po lewej, a potem „Porównaj”.<br>
Kliknij nazwę wersji, by przejrzeć jej powierzchnię.</div>
</div>
</main>
</div>
<script src="app.js"></script>
</body>
</html>

80
ams/api/static/style.css Normal file
View File

@@ -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); }