469 lines
21 KiB
JavaScript
469 lines
21 KiB
JavaScript
"use strict";
|
|
const $ = (id) => document.getElementById(id);
|
|
let snapshots = [],
|
|
selected = null,
|
|
compareFrom = null,
|
|
functionFrom = null,
|
|
info = { plugins: [] };
|
|
let activeGame = null, activeVersion = null, assetPage = 0, assetRequest = 0, allRuns = [];
|
|
const versionKey = r => JSON.stringify([r.repository, r.version, r.edition || "", r.generation || ""]);
|
|
let functionReport = null, functionParams = null, functionPage = 0;
|
|
const size = (n) => {
|
|
let u = ["B", "KiB", "MiB", "GiB", "TiB"],
|
|
i = 0;
|
|
while (n >= 1024 && i < 4) {
|
|
n /= 1024;
|
|
i++;
|
|
}
|
|
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
|
|
};
|
|
const notice = (text, error = false) => {
|
|
$("notice").textContent = text;
|
|
$("notice").className = error ? "error" : "";
|
|
};
|
|
async function api(path, options = {}) {
|
|
const response = await fetch("/api/" + path, {
|
|
...options,
|
|
headers: { "X-Verstack-Client": "1", ...options.headers },
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw Error(text);
|
|
}
|
|
return response.json();
|
|
}
|
|
const post = (path, data) =>
|
|
api(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
function cell(row, text, className) {
|
|
const td = document.createElement("td");
|
|
td.textContent = text ?? "";
|
|
if (className) td.className = className;
|
|
row.append(td);
|
|
return td;
|
|
}
|
|
function button(parent, label, action) {
|
|
const b = document.createElement("button");
|
|
b.textContent = label;
|
|
b.setAttribute("aria-label", label);
|
|
b.onclick = () => Promise.resolve().then(action).catch((e) => notice(e.message, true));
|
|
parent.append(b);
|
|
return b;
|
|
}
|
|
function render() {
|
|
const filter = $("filter").value.toLowerCase();
|
|
$("games").replaceChildren();
|
|
const games = [...new Set(snapshots.map(s => s.release.repository))].sort();
|
|
for (const game of games.filter(g => g.toLowerCase().includes(filter))) {
|
|
const group = snapshots.filter(s => s.release.repository === game);
|
|
const card = document.createElement("article"); card.className = "game-card";
|
|
const icon = document.createElement("div"); icon.className = "game-icon"; icon.textContent = game.slice(0,2).toUpperCase();
|
|
card.append(icon);
|
|
button(card, game, () => { location.hash = "game=" + encodeURIComponent(game); });
|
|
const meta = document.createElement("p"); meta.textContent = `${new Set(group.map(s => versionKey(s.release))).size} versions · ${group.length} outputs`;
|
|
card.append(meta); $("games").append(card);
|
|
}
|
|
$("empty").hidden = $("games").children.length > 0;
|
|
$("game-home").hidden = !!activeGame;
|
|
$("version-list").hidden = !activeGame || !!activeVersion;
|
|
$("details").hidden = !activeVersion;
|
|
$("library-title").textContent = activeVersion ? activeVersion.version : activeGame || "Game library";
|
|
$("library-subtitle").textContent = activeVersion ? [activeGame, activeVersion.edition, activeVersion.generation && `Generation ${activeVersion.generation}`].filter(Boolean).join(" / ") : activeGame ? "Select a version to explore its media, files, and processing history." : "Select a game to browse imported versions and their analysis artifacts.";
|
|
$("breadcrumbs").replaceChildren();
|
|
button($("breadcrumbs"), "All games", () => { location.hash = "library"; });
|
|
if (activeGame) button($("breadcrumbs"), activeGame, () => { location.hash = "game=" + encodeURIComponent(activeGame); });
|
|
if (activeVersion) { const span = document.createElement("span"); span.textContent = activeVersion.version; $("breadcrumbs").append(span); }
|
|
$("snapshots").replaceChildren();
|
|
const versions = new Map();
|
|
for (const s of snapshots.filter(s => s.release.repository === activeGame)) {
|
|
const key = versionKey(s.release);
|
|
if (!versions.has(key)) versions.set(key, []);
|
|
versions.get(key).push(s);
|
|
}
|
|
for (const group of versions.values()) {
|
|
const s = group.find(s => s.layer === "original") || group[0];
|
|
const row = document.createElement("tr");
|
|
button(cell(row, ""), s.release.version, () => show(s));
|
|
cell(row, `${s.release.edition || "Standard"} / ${s.release.generation || "Unknown"}`);
|
|
cell(row, s.release.released_at || "Unknown");
|
|
cell(row, group.length + " outputs");
|
|
cell(row, group.reduce((n,s) => n + (s.entry_count || 0),0) + " artifacts");
|
|
button(
|
|
cell(row, ""),
|
|
compareFrom === s.id ? "Selected baseline" : "Compare",
|
|
async () => {
|
|
if (!compareFrom) {
|
|
compareFrom = s.id;
|
|
notice("Select the second snapshot to compare.");
|
|
render();
|
|
return;
|
|
}
|
|
const before = compareFrom;
|
|
compareFrom = null;
|
|
const changes = await api(
|
|
"compare?" + new URLSearchParams({ before, after: s.id }),
|
|
);
|
|
$("comparison").hidden = false;
|
|
$("comparison-title").textContent = "Snapshot comparison";
|
|
$("function-results").hidden = true;
|
|
$("diff").hidden = false;
|
|
$("diff").textContent = changes.length
|
|
? changes.map((c) => `${c.kind.padEnd(15)} ${c.path}`).join("\n")
|
|
: "No file or metadata differences.";
|
|
$("comparison-note").textContent =
|
|
"File identity comparison. Extraction revisions and layers can affect results; unmatched files are not proof of publisher additions.";
|
|
notice(`Comparison complete: ${changes.length} file or metadata differences.`);
|
|
render();
|
|
},
|
|
);
|
|
$("snapshots").append(row);
|
|
}
|
|
$("metrics").replaceChildren();
|
|
const scoped = snapshots.filter(s => activeVersion ? versionKey(s.release) === versionKey(activeVersion) : !activeGame || s.release.repository === activeGame);
|
|
const metrics = activeVersion ? [["Job outputs",scoped.length],["Artifacts",scoped.reduce((n,s)=>n+(s.entry_count||0),0)],["Retained",size(scoped.reduce((n,s)=>n+s.logical_bytes,0))]] : [["Games",activeGame ? 1 : games.length],["Versions",new Set(scoped.map(s => versionKey(s.release))).size],["Retained",size(scoped.reduce((n,s) => n+s.logical_bytes,0))]];
|
|
for (const [label,value] of metrics) {
|
|
const div = document.createElement("div"); div.className = "metric";
|
|
const strong = document.createElement("strong"); strong.textContent = value;
|
|
const span = document.createElement("span"); span.textContent = label; div.append(strong,span); $("metrics").append(div);
|
|
}
|
|
}
|
|
function navigate() {
|
|
assetRequest++;
|
|
const params = new URLSearchParams(location.hash.slice(1));
|
|
activeGame = params.get("game"); activeVersion = null;
|
|
const id = params.get("version");
|
|
if (id) {
|
|
const s = snapshots.find(s => s.id === id && s.release.repository === activeGame);
|
|
if (s) { activeVersion = s.release; selected = s; }
|
|
}
|
|
render();
|
|
$("import").hidden = location.hash !== "#import";
|
|
$("library").hidden = ["#import", "#jobs"].includes(location.hash);
|
|
$("preview").replaceChildren(); $("preview-panel").hidden = true;
|
|
if (activeVersion) {
|
|
assetPage = 0;
|
|
const group = snapshots.filter(s => versionKey(s.release) === versionKey(activeVersion));
|
|
$("source-filter").replaceChildren(new Option("All job outputs", ""));
|
|
$("process-source").replaceChildren();
|
|
for (const s of group) {
|
|
const run = allRuns.find(r => r.id === s.run);
|
|
const label = `${s.layer} · ${run?.operation || "Import / analysis"} · ${s.id.slice(0,8)}`;
|
|
$("source-filter").append(new Option(label,s.id));
|
|
$("process-source").append(new Option(label,s.id));
|
|
}
|
|
$("process-source").value = selected.id;
|
|
$("warnings").textContent = [...new Set(group.flatMap(s => s.warnings))].join(" ");
|
|
renderAssets();
|
|
}
|
|
renderRuns();
|
|
}
|
|
window.addEventListener("hashchange", navigate);
|
|
async function refresh() {
|
|
snapshots = await api("library");
|
|
snapshots.sort((a, b) => b.imported_at - a.imported_at);
|
|
render();
|
|
await refreshRuns();
|
|
navigate();
|
|
}
|
|
async function refreshRuns() {
|
|
allRuns = await api("runs");
|
|
renderRuns();
|
|
}
|
|
function renderRuns() {
|
|
$("runs").replaceChildren();
|
|
const group = snapshots.filter(s => activeVersion && versionKey(s.release) === versionKey(activeVersion));
|
|
const ids = new Set(group.map(s => s.id)), runs = new Set(group.map(s => s.run));
|
|
for (const r of allRuns.filter(r => !activeVersion || runs.has(r.id) || r.inputs.some(id => ids.has(id))).sort((a,b) => b.started_at-a.started_at)) {
|
|
const row = document.createElement("tr");
|
|
cell(row,new Date(r.started_at*1000).toLocaleString()); cell(row,`${r.operation} (${r.tool_version})`);
|
|
cell(row,`${r.state} / ${r.stage}`); cell(row,size(r.bytes_processed)); cell(row,r.error || r.output || "In progress"); $("runs").append(row);
|
|
}
|
|
$("jobs").hidden = !activeVersion && location.hash !== "#jobs";
|
|
}
|
|
function show(s) {
|
|
const hash = "game=" + encodeURIComponent(s.release.repository) + "&version=" + encodeURIComponent(s.id);
|
|
if (location.hash.slice(1) === hash) navigate(); else location.hash = hash;
|
|
}
|
|
async function renderAssets() {
|
|
if (!activeVersion) return;
|
|
const request = ++assetRequest;
|
|
$("assets").replaceChildren(); $("gallery").replaceChildren();
|
|
$("asset-status").textContent = "Loading artifacts…";
|
|
$("asset-previous").disabled = $("asset-next").disabled = true;
|
|
try {
|
|
const result = await api("artifacts?" + new URLSearchParams({...activeVersion, source:$("source-filter").value, search:$("asset-filter").value, kind:$("media-kind").value, page:assetPage}));
|
|
if (request !== assetRequest) return;
|
|
assetPage = result.page;
|
|
$("asset-status").textContent = result.total ? `${result.total} artifacts · Page ${assetPage+1} of ${Math.ceil(result.total/24)}` : "No artifacts match this view. Try All files or another filter.";
|
|
$("asset-previous").disabled = !assetPage;
|
|
$("asset-next").disabled = (assetPage+1)*24 >= result.total;
|
|
$("file-table").hidden = !result.items.some(item => !item.media_type);
|
|
for (const item of result.items) {
|
|
const e = item.entry, s = snapshots.find(s => s.id === item.snapshot);
|
|
if (item.media_type) { renderMedia(item); continue; }
|
|
const row = document.createElement("tr");
|
|
cell(row, e.path);
|
|
cell(row, `${e.kind} · ${s.layer} · ${s.id.slice(0,8)}`);
|
|
cell(row, size(e.size));
|
|
cell(
|
|
row,
|
|
e.artifact ? e.artifact.slice(0, 23) + "…" : e.link_target || "",
|
|
"hash",
|
|
);
|
|
const actions = cell(row, "");
|
|
if (e.kind === "file") {
|
|
const a = document.createElement("a");
|
|
a.textContent = "Download";
|
|
a.href =
|
|
"/api/file/" + s.id + "?" + new URLSearchParams({ path: e.path });
|
|
a.download = e.path.split("/").pop();
|
|
actions.append(a, document.createTextNode(" "));
|
|
button(actions, "Inspect", async () => {
|
|
const extension = e.path.split(".").pop().toLowerCase();
|
|
const tag = ["png", "jpg", "jpeg", "gif", "webp"].includes(extension)
|
|
? "img"
|
|
: ["mp4", "webm"].includes(extension)
|
|
? "video"
|
|
: ["wav", "ogg", "mp3"].includes(extension)
|
|
? "audio"
|
|
: null;
|
|
if (tag) {
|
|
const media = document.createElement(tag);
|
|
media.src = a.href + "&inline=true";
|
|
if (tag === "img") media.alt = e.path;
|
|
else media.controls = true;
|
|
$("preview").replaceChildren(media);
|
|
$("preview-title").textContent = e.path;
|
|
$("preview-panel").hidden = false;
|
|
return;
|
|
}
|
|
if (!e.size) {
|
|
$("preview").textContent = "Empty file.";
|
|
} else {
|
|
const response = await fetch(a.href, {
|
|
headers: { Range: "bytes=0-65535" },
|
|
});
|
|
if (!response.ok) throw Error("Preview failed");
|
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
const text = new TextDecoder().decode(bytes);
|
|
const printable = bytes.every(
|
|
(b) => b === 9 || b === 10 || b === 13 || (b >= 32 && b < 127),
|
|
);
|
|
$("preview").textContent = printable
|
|
? text
|
|
: Array.from(
|
|
{ length: Math.ceil(bytes.length / 16) },
|
|
(_, i) =>
|
|
`${(i * 16).toString(16).padStart(8, "0")} ${Array.from(bytes.slice(i * 16, i * 16 + 16), (b) => b.toString(16).padStart(2, "0")).join(" ")}`,
|
|
).join("\n");
|
|
}
|
|
$("preview-title").textContent = e.path + " — first 64 KiB";
|
|
$("preview-panel").hidden = false;
|
|
});
|
|
if (e.path === "function-comparison.json")
|
|
button(actions, "View report", async () => {
|
|
const response = await fetch(a.href);
|
|
if (!response.ok) throw Error("Report download failed");
|
|
showFunctionReport(await response.json(), null);
|
|
});
|
|
if (e.path.endsWith("/functions.json"))
|
|
button(actions, "Compare functions", async () => {
|
|
if (!functionFrom) {
|
|
functionFrom = { before: s.id, before_path: e.path };
|
|
notice(
|
|
"Function baseline selected. Open another analysis snapshot and select its function inventory.",
|
|
);
|
|
return;
|
|
}
|
|
const params = { ...functionFrom, after: s.id, after_path: e.path };
|
|
functionFrom = null;
|
|
const report = await api(
|
|
"functions/compare?" + new URLSearchParams(params),
|
|
);
|
|
showFunctionReport(report, params);
|
|
notice(`Function comparison complete: ${report.matches.length} exact matches.`);
|
|
});
|
|
}
|
|
$("assets").append(row);
|
|
}
|
|
} catch(e) { if (request === assetRequest) { $("asset-status").textContent = "Could not load artifacts. Use Refresh to retry."; notice(e.message,true); } }
|
|
}
|
|
function renderMedia(item) {
|
|
const e = item.entry, kind = item.media_type.split("/")[0];
|
|
const card = document.createElement("article"); card.className = "media-card";
|
|
const frame = document.createElement("div"); frame.className = "media-frame " + kind;
|
|
const media = document.createElement(kind === "image" ? "img" : kind);
|
|
const url = "/api/file/" + item.snapshot + "?" + new URLSearchParams({path:e.path});
|
|
if (kind === "image") { media.alt = e.path; media.loading = "lazy"; media.decoding = "async"; }
|
|
else { media.controls = true; media.preload = "none"; }
|
|
media.src = url + "&inline=true";
|
|
media.onerror = () => { const error = document.createElement("p"); error.className = "preview-error"; error.textContent = "Preview unavailable in this browser. Download the original to open it."; media.replaceWith(error); };
|
|
frame.append(media); card.append(frame);
|
|
const title = document.createElement("h3"); title.textContent = e.path.split("/").pop(); title.title = e.path;
|
|
const path = document.createElement("p"); path.className = "media-path"; path.textContent = e.path;
|
|
const meta = document.createElement("p"); meta.className = "muted"; meta.textContent = `${size(e.size)} · ${item.layer} · ${item.snapshot.slice(0,8)}`;
|
|
card.append(title,path,meta);
|
|
if (kind === "image") button(card,"Enlarge", () => {
|
|
const full = document.createElement("img"); full.src = url + "&inline=true"; full.alt = e.path;
|
|
$("preview").replaceChildren(full); $("preview-title").textContent = e.path; $("preview-panel").hidden = false;
|
|
$("preview-panel").scrollIntoView({behavior:"smooth"});
|
|
});
|
|
const link = document.createElement("a"); link.href = url; link.download = e.path.split("/").pop(); link.textContent = "Download"; card.append(link);
|
|
$("gallery").append(card);
|
|
}
|
|
|
|
function showFunctionReport(report, params) {
|
|
functionReport = report;
|
|
functionParams = params;
|
|
functionPage = 0;
|
|
$("comparison").hidden = false;
|
|
$("comparison-title").textContent = "Function comparison";
|
|
$("comparison-note").textContent = report.caveat;
|
|
$("function-results").hidden = false;
|
|
$("diff").hidden = true;
|
|
$("save-comparison").disabled = !params;
|
|
const labels = (report.sources || []).map(s => `${s.release.repository} ${s.release.version} ${s.release.edition} (snapshot ${s.snapshot})`);
|
|
$("function-summary").textContent = `${labels.join(" → ")}. ${report.matches.length} exact matches; ${report.unmatched_after.length} unmatched target functions; ${report.unmatched_before.length} unmatched baseline functions.`;
|
|
renderFunctions();
|
|
}
|
|
function renderFunctions() {
|
|
if (!functionReport) return;
|
|
const view = $("function-view").value;
|
|
const query = $("function-filter").value.toLowerCase();
|
|
const label = f => f ? `${f.name} @ ${f.address}` : "—";
|
|
const rows = functionReport[view].map(f => view === "matches"
|
|
? [label(f.before), label(f.after), "Unique exact body ≥32 bytes"]
|
|
: view === "unmatched_after"
|
|
? ["—", label(f), functionReport.unmatched_after_reasons[f.address]]
|
|
: [label(f), "—", "No match by this method"])
|
|
.filter(row => row.join(" ").toLowerCase().includes(query));
|
|
functionPage = Math.min(functionPage, Math.max(0, Math.ceil(rows.length / 100) - 1));
|
|
$("function-rows").replaceChildren();
|
|
for (const values of rows.slice(functionPage * 100, (functionPage + 1) * 100)) {
|
|
const tr = document.createElement("tr");
|
|
values.forEach(v => cell(tr, v));
|
|
$("function-rows").append(tr);
|
|
}
|
|
$("function-page").textContent = `${rows.length} results · Page ${functionPage + 1} of ${Math.max(1, Math.ceil(rows.length / 100))}`;
|
|
$("function-previous").disabled = !functionPage;
|
|
$("function-next").disabled = (functionPage + 1) * 100 >= rows.length;
|
|
}
|
|
$("function-view").onchange = $("function-filter").oninput = () => { functionPage = 0; renderFunctions(); };
|
|
$("function-previous").onclick = () => { functionPage--; renderFunctions(); };
|
|
$("function-next").onclick = () => { functionPage++; renderFunctions(); };
|
|
$("save-comparison").onclick = async () => {
|
|
if (!functionParams) return;
|
|
$("save-comparison").disabled = true;
|
|
try {
|
|
const saved = await post("functions/compare", functionParams);
|
|
functionParams = null;
|
|
await refresh();
|
|
show(saved);
|
|
notice("Comparison report committed and verified.");
|
|
} catch (e) { notice(e.message, true); $("save-comparison").disabled = false; }
|
|
};
|
|
$("import-form").onsubmit = async (event) => {
|
|
event.preventDefault();
|
|
const form = event.target;
|
|
const data = new FormData(form);
|
|
const submit = form.querySelector("button[type=submit]");
|
|
submit.disabled = true;
|
|
notice(
|
|
"Importing. Progress appears in Processing runs; the snapshot publishes after verification.",
|
|
);
|
|
try {
|
|
const file = data.get("file");
|
|
let s;
|
|
if (file && file.name) {
|
|
const q = new URLSearchParams({
|
|
repository: data.get("repository"),
|
|
version: data.get("version"),
|
|
edition: data.get("edition"),
|
|
generation: data.get("generation"),
|
|
filename: file.name,
|
|
released_at: data.get("released_at") || "",
|
|
});
|
|
s = await api("upload?" + q, { method: "POST", body: file });
|
|
} else {
|
|
s = await post("import", {
|
|
path: data.get("path"),
|
|
release: {
|
|
repository: data.get("repository"),
|
|
version: data.get("version"),
|
|
edition: data.get("edition"),
|
|
generation: data.get("generation"),
|
|
released_at: data.get("released_at") || null,
|
|
},
|
|
});
|
|
}
|
|
await refresh();
|
|
show(s);
|
|
notice("Snapshot committed and retained file bytes verified.");
|
|
} catch (e) {
|
|
notice(e.message, true);
|
|
} finally {
|
|
submit.disabled = false;
|
|
}
|
|
};
|
|
$("refresh-artifacts").onclick = () => refresh().catch(e => notice(e.message,true));
|
|
$("refresh").onclick = () => refresh().catch((e) => notice(e.message, true));
|
|
$("filter").oninput = render;
|
|
let filterTimer;
|
|
$("asset-filter").oninput = () => { clearTimeout(filterTimer); filterTimer = setTimeout(() => { assetPage=0; renderAssets(); },200); };
|
|
$("source-filter").onchange = $("media-kind").onchange = () => { assetPage=0; renderAssets(); };
|
|
$("asset-previous").onclick = () => { assetPage--; renderAssets(); };
|
|
$("asset-next").onclick = () => { assetPage++; renderAssets(); };
|
|
$("close-preview").onclick = () => { $("preview-panel").hidden=true; $("preview").replaceChildren(); };
|
|
$("process-source").onchange = () => { selected = snapshots.find(s => s.id === $("process-source").value); };
|
|
$("process").onclick = async () => {
|
|
if (!selected || !$("plugin").value) return;
|
|
const b = $("process");
|
|
b.disabled = true;
|
|
notice("Plugin running. The input snapshot remains available.");
|
|
try {
|
|
const s = await post("process", {
|
|
snapshot: selected.id,
|
|
plugin: $("plugin").value,
|
|
});
|
|
await refresh();
|
|
show(s);
|
|
notice("Processing output committed and verified.");
|
|
} catch (e) {
|
|
notice(e.message, true);
|
|
} finally {
|
|
b.disabled = false;
|
|
}
|
|
};
|
|
$("verify").onclick = async () => {
|
|
if (!selected) return;
|
|
try {
|
|
notice("Reading and verifying retained bytes…");
|
|
await post("verify/" + selected.id, {});
|
|
notice("All retained file hashes verified.");
|
|
} catch (e) {
|
|
notice(e.message, true);
|
|
}
|
|
};
|
|
(async () => {
|
|
try {
|
|
info = await api("info");
|
|
$("roots").textContent =
|
|
"Allowed server roots: " + info.import_roots.join(", ");
|
|
for (const name of info.plugins) {
|
|
const o = document.createElement("option");
|
|
o.value = name;
|
|
o.textContent = name;
|
|
$("plugin").append(o);
|
|
}
|
|
$("process").disabled = !info.plugins.length;
|
|
await refresh();
|
|
notice("Connected. Shared archive ready.");
|
|
} catch (e) {
|
|
notice(e.message, true);
|
|
}
|
|
})();
|
|
setInterval(() => refreshRuns().catch(() => {}), 2000);
|