Files

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