Initial module scaffold
This commit is contained in:
421
scripts/apps/audit-report-app.js
Normal file
421
scripts/apps/audit-report-app.js
Normal file
@@ -0,0 +1,421 @@
|
||||
export class StorageAuditReportApp extends foundry.applications.api.ApplicationV2 {
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: "kosmos-storage-audit-report",
|
||||
classes: ["kosmos-storage-audit"],
|
||||
tag: "div",
|
||||
actions: {
|
||||
runAnalysis: StorageAuditReportApp.#onRunAnalysis,
|
||||
toggleShowAll: StorageAuditReportApp.#onToggleShowAll
|
||||
},
|
||||
window: {
|
||||
title: "Kosmos Storage Audit",
|
||||
icon: "fa-solid fa-broom-ball",
|
||||
resizable: true
|
||||
},
|
||||
position: {
|
||||
width: 760,
|
||||
height: 680
|
||||
}
|
||||
};
|
||||
|
||||
#analysis = null;
|
||||
#loading = false;
|
||||
#showAll = false;
|
||||
#progress = null;
|
||||
|
||||
async _prepareContext() {
|
||||
const findings = this.#analysis?.findings ?? [];
|
||||
const visibleFindings = this.#showAll ? findings : findings.filter(f => f.severity !== "info");
|
||||
const groupedFindings = groupFindings(visibleFindings);
|
||||
return {
|
||||
loading: this.#loading,
|
||||
hasAnalysis: !!this.#analysis,
|
||||
showAll: this.#showAll,
|
||||
progress: this.#progress,
|
||||
summary: this.#summarize(this.#analysis),
|
||||
groupedFindings,
|
||||
findings: visibleFindings.slice(0, 100)
|
||||
};
|
||||
}
|
||||
|
||||
async _renderHTML(context) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "storage-audit";
|
||||
container.innerHTML = `
|
||||
<section class="storage-audit__hero">
|
||||
<div>
|
||||
<h2>Kosmos Storage Audit</h2>
|
||||
<p>Prueft Medienreferenzen und markiert primaer benutzerrelevante Risiken in den Foundry-Roots <code>data</code> und <code>public</code>.</p>
|
||||
<p>Orphans werden bewusst nicht global fuer den gesamten <code>data</code>-Root behauptet, sondern nur in klar weltlokalen oder explizit riskanten Bereichen.</p>
|
||||
<p>Weltverweise auf Modul- oder Systemassets gelten nicht pauschal als Problem. Relevant sind vor allem Ziele, die im owning Paket selbst nicht sichtbar referenziert werden.</p>
|
||||
</div>
|
||||
<div class="storage-audit__actions">
|
||||
<button type="button" class="button" data-action="runAnalysis" ${context.loading ? "disabled" : ""}>
|
||||
<i class="fa-solid fa-magnifying-glass"></i>
|
||||
<span>${context.loading ? "Analysiere..." : "Analyse Starten"}</span>
|
||||
</button>
|
||||
<button type="button" class="button" data-action="toggleShowAll" ${context.hasAnalysis ? "" : "disabled"}>
|
||||
<i class="fa-solid fa-filter"></i>
|
||||
<span>${context.showAll ? "Nur Warnungen" : "Alle Findings"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
${renderProgress(context.progress, context.loading)}
|
||||
${renderSummary(context.summary)}
|
||||
${renderGroupedFindingList(context.groupedFindings, context.hasAnalysis, context.loading, context.showAll)}
|
||||
${renderFindingList(context.findings, context.hasAnalysis, context.loading, context.showAll)}
|
||||
`;
|
||||
return container;
|
||||
}
|
||||
|
||||
_replaceHTML(result, content) {
|
||||
content.replaceChildren(result);
|
||||
}
|
||||
|
||||
async runAnalysis() {
|
||||
this.#loading = true;
|
||||
this.#progress = {
|
||||
phase: "start",
|
||||
label: "Initialisiere Analyse",
|
||||
files: 0,
|
||||
sources: 0,
|
||||
references: 0,
|
||||
findings: 0,
|
||||
currentSource: null
|
||||
};
|
||||
await this.render({ force: true });
|
||||
try {
|
||||
const api = game.modules.get("kosmos-storage-audit")?.api;
|
||||
this.#analysis = await api.runRuntimeAnalysis({
|
||||
onProgress: update => this.#updateProgress(update)
|
||||
});
|
||||
const count = this.#analysis.findings.length;
|
||||
ui.notifications.info(`Kosmos Storage Audit abgeschlossen: ${count} Findings.`);
|
||||
} catch (error) {
|
||||
console.error("kosmos-storage-audit | analysis failed", error);
|
||||
ui.notifications.error(`Kosmos Storage Audit fehlgeschlagen: ${error.message}`);
|
||||
} finally {
|
||||
this.#loading = false;
|
||||
await this.render({ force: true });
|
||||
}
|
||||
}
|
||||
|
||||
toggleShowAll() {
|
||||
this.#showAll = !this.#showAll;
|
||||
return this.render({ force: true });
|
||||
}
|
||||
|
||||
#summarize(analysis) {
|
||||
if (!analysis) return null;
|
||||
const bySeverity = { high: 0, warning: 0, info: 0 };
|
||||
const byKind = {};
|
||||
const grouped = {
|
||||
brokenReferences: 0,
|
||||
packageReferences: 0,
|
||||
orphans: 0
|
||||
};
|
||||
for (const finding of analysis.findings) {
|
||||
bySeverity[finding.severity] = (bySeverity[finding.severity] ?? 0) + 1;
|
||||
byKind[finding.kind] = (byKind[finding.kind] ?? 0) + 1;
|
||||
}
|
||||
grouped.brokenReferences = new Set(
|
||||
analysis.findings.filter(f => f.kind === "broken-reference").map(f => f.target.locator ?? formatTarget(f.target))
|
||||
).size;
|
||||
grouped.packageReferences = new Set(
|
||||
analysis.findings.filter(f => f.kind === "non-package-to-package-reference").map(f => f.target.locator ?? formatTarget(f.target))
|
||||
).size;
|
||||
grouped.orphans = analysis.findings.filter(f => f.kind === "orphan-file").length;
|
||||
return {
|
||||
files: analysis.files.length,
|
||||
references: analysis.references.length,
|
||||
findings: analysis.findings.length,
|
||||
bySeverity,
|
||||
byKind,
|
||||
grouped
|
||||
};
|
||||
}
|
||||
|
||||
#updateProgress(update) {
|
||||
this.#progress = {
|
||||
...(this.#progress ?? {}),
|
||||
...update
|
||||
};
|
||||
this.render({ force: true });
|
||||
}
|
||||
|
||||
static #onRunAnalysis(_event, _button) {
|
||||
return this.runAnalysis();
|
||||
}
|
||||
|
||||
static #onToggleShowAll(_event, _button) {
|
||||
return this.toggleShowAll();
|
||||
}
|
||||
}
|
||||
|
||||
function renderProgress(progress, loading) {
|
||||
if (!loading || !progress) return "";
|
||||
return `
|
||||
<section class="storage-audit__progress">
|
||||
<div class="storage-audit__progress-bar">
|
||||
<div class="storage-audit__progress-fill phase-${escapeHtml(progress.phase ?? "start")}"></div>
|
||||
</div>
|
||||
<div class="storage-audit__progress-meta">
|
||||
<strong>${escapeHtml(progress.label ?? "Analysiere")}</strong>
|
||||
<span>Dateien: ${progress.files ?? 0}</span>
|
||||
<span>Quellen: ${progress.sources ?? 0}</span>
|
||||
<span>Referenzen: ${progress.references ?? 0}</span>
|
||||
${progress.findings != null ? `<span>Findings: ${progress.findings}</span>` : ""}
|
||||
</div>
|
||||
${progress.currentSource ? `<p class="storage-audit__progress-source">Aktuell: ${escapeHtml(progress.currentSource)}</p>` : ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
if (!summary) {
|
||||
return `
|
||||
<section class="storage-audit__summary">
|
||||
<p>Noch keine Analyse ausgefuehrt.</p>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
const kindLines = Object.entries(summary.byKind)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([kind, count]) => `<li><code>${kind}</code><span>${count}</span></li>`)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<section class="storage-audit__summary">
|
||||
<div class="storage-audit__stats">
|
||||
<article><span>Dateien</span><strong>${summary.files}</strong></article>
|
||||
<article><span>Referenzen</span><strong>${summary.references}</strong></article>
|
||||
<article><span>Findings</span><strong>${summary.findings}</strong></article>
|
||||
<article><span>High</span><strong>${summary.bySeverity.high}</strong></article>
|
||||
<article><span>Warning</span><strong>${summary.bySeverity.warning}</strong></article>
|
||||
</div>
|
||||
<div class="storage-audit__kinds">
|
||||
<h3>Findings nach Typ</h3>
|
||||
<ul>${kindLines || "<li><span>Keine Findings</span><span>0</span></li>"}</ul>
|
||||
</div>
|
||||
<div class="storage-audit__kinds">
|
||||
<h3>Arbeitsbloecke</h3>
|
||||
<ul>
|
||||
<li><span>Deduplizierte fehlende Ziele</span><span>${summary.grouped?.brokenReferences ?? 0}</span></li>
|
||||
<li><span>Unverankerte Paketziele</span><span>${summary.grouped?.packageReferences ?? 0}</span></li>
|
||||
<li><span>Orphan-Kandidaten</span><span>${summary.grouped?.orphans ?? 0}</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGroupedFindingList(groupedFindings, hasAnalysis, loading, showAll) {
|
||||
if (loading || !hasAnalysis) return "";
|
||||
|
||||
const sections = [];
|
||||
if (groupedFindings.nonPackageToPackage.length) {
|
||||
sections.push(renderGroupedSection(
|
||||
"Unverankerte Paketziele",
|
||||
"Diese Ziele liegen in Modul- oder Systemordnern, werden aus Weltdaten referenziert, sind im owning Paket selbst aber derzeit nicht als Referenz sichtbar.",
|
||||
groupedFindings.nonPackageToPackage,
|
||||
group => `
|
||||
<article class="storage-audit__finding severity-${group.severity}">
|
||||
<header>
|
||||
<span class="storage-audit__severity">${group.severity}</span>
|
||||
<code>${group.kind}</code>
|
||||
<strong>${group.count} Referenzen</strong>
|
||||
</header>
|
||||
<p><code>${escapeHtml(group.target)}</code></p>
|
||||
<p>${escapeHtml(group.reason)}</p>
|
||||
<dl>
|
||||
<div><dt>Owner-Paket</dt><dd><code>${escapeHtml(group.ownerLabel)}</code></dd></div>
|
||||
<div><dt>Bewertung</dt><dd>${escapeHtml(group.explanation)}</dd></div>
|
||||
</dl>
|
||||
<p class="storage-audit__recommendation">${escapeHtml(group.recommendation)}</p>
|
||||
${renderSampleSources(group.sources)}
|
||||
</article>
|
||||
`
|
||||
));
|
||||
}
|
||||
|
||||
if (groupedFindings.brokenReferences.length) {
|
||||
sections.push(renderGroupedSection(
|
||||
"Kaputte Ziele",
|
||||
"Diese Dateien fehlen, werden aber weiterhin referenziert.",
|
||||
groupedFindings.brokenReferences,
|
||||
group => `
|
||||
<article class="storage-audit__finding severity-${group.severity}">
|
||||
<header>
|
||||
<span class="storage-audit__severity">${group.severity}</span>
|
||||
<code>${group.kind}</code>
|
||||
<strong>${group.count} Referenzen</strong>
|
||||
</header>
|
||||
<p><code>${escapeHtml(group.target)}</code></p>
|
||||
<p>${escapeHtml(group.reason)}</p>
|
||||
<p class="storage-audit__recommendation">${escapeHtml(group.recommendation)}</p>
|
||||
${renderSampleSources(group.sources)}
|
||||
</article>
|
||||
`
|
||||
));
|
||||
}
|
||||
|
||||
if (showAll && groupedFindings.orphans.length) {
|
||||
sections.push(renderGroupedSection(
|
||||
"Orphan-Kandidaten",
|
||||
"Diese Dateien haben im aktuellen Analysekontext keine eingehende Referenz.",
|
||||
groupedFindings.orphans,
|
||||
group => `
|
||||
<article class="storage-audit__finding severity-${group.severity}">
|
||||
<header>
|
||||
<span class="storage-audit__severity">${group.severity}</span>
|
||||
<code>${group.kind}</code>
|
||||
</header>
|
||||
<p><code>${escapeHtml(group.target)}</code></p>
|
||||
<p>${escapeHtml(group.reason)}</p>
|
||||
<p class="storage-audit__recommendation">${escapeHtml(group.recommendation)}</p>
|
||||
</article>
|
||||
`
|
||||
));
|
||||
}
|
||||
|
||||
if (!sections.length) {
|
||||
return `
|
||||
<section class="storage-audit__grouped">
|
||||
<div class="storage-audit__group-header">
|
||||
<h3>Arbeitsansicht</h3>
|
||||
<p>Keine gruppierten ${showAll ? "" : "warnenden "}Findings vorhanden.</p>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
return `<section class="storage-audit__grouped">${sections.join("")}</section>`;
|
||||
}
|
||||
|
||||
function renderGroupedSection(title, description, groups, renderGroup) {
|
||||
const items = groups.slice(0, 12).map(renderGroup).join("");
|
||||
return `
|
||||
<section class="storage-audit__group">
|
||||
<div class="storage-audit__group-header">
|
||||
<h3>${title}</h3>
|
||||
<p>${description}</p>
|
||||
</div>
|
||||
<div class="storage-audit__list">${items}</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFindingList(findings, hasAnalysis, loading, showAll) {
|
||||
if (loading) {
|
||||
return `<section class="storage-audit__list"><p>Analyse laeuft...</p></section>`;
|
||||
}
|
||||
if (!hasAnalysis) {
|
||||
return `<section class="storage-audit__list"><p>Die Analyse kann direkt aus dieser Ansicht gestartet werden.</p></section>`;
|
||||
}
|
||||
if (!findings.length) {
|
||||
return `<section class="storage-audit__list"><p>Keine ${showAll ? "" : "warnenden "}Findings gefunden.</p></section>`;
|
||||
}
|
||||
|
||||
const items = findings.map(finding => `
|
||||
<article class="storage-audit__finding severity-${finding.severity}">
|
||||
<header>
|
||||
<span class="storage-audit__severity">${finding.severity}</span>
|
||||
<code>${finding.kind}</code>
|
||||
</header>
|
||||
<p>${escapeHtml(finding.reason)}</p>
|
||||
<dl>
|
||||
<div><dt>Ziel</dt><dd><code>${escapeHtml(finding.target.locator ?? `${finding.target.storage}:${finding.target.path}`)}</code></dd></div>
|
||||
${finding.source ? `<div><dt>Quelle</dt><dd>${escapeHtml(finding.source.sourceLabel)}</dd></div>` : ""}
|
||||
</dl>
|
||||
<p class="storage-audit__recommendation">${escapeHtml(finding.recommendation)}</p>
|
||||
</article>
|
||||
`).join("");
|
||||
|
||||
return `
|
||||
<section class="storage-audit__list storage-audit__list--raw">
|
||||
<h3>Einzelfaelle</h3>
|
||||
${items}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderSampleSources(sources) {
|
||||
if (!sources.length) return "";
|
||||
const rows = sources.map(source => `<li>${escapeHtml(source)}</li>`).join("");
|
||||
return `<div class="storage-audit__samples"><span>Beispiele</span><ul>${rows}</ul></div>`;
|
||||
}
|
||||
|
||||
function groupFindings(findings) {
|
||||
return {
|
||||
brokenReferences: groupByTarget(findings.filter(f => f.kind === "broken-reference")),
|
||||
nonPackageToPackage: groupByTarget(findings.filter(f => f.kind === "non-package-to-package-reference")),
|
||||
orphans: findings
|
||||
.filter(f => f.kind === "orphan-file")
|
||||
.sort(compareFindings)
|
||||
.map(finding => ({
|
||||
kind: finding.kind,
|
||||
severity: finding.severity,
|
||||
target: finding.target.locator ?? formatTarget(finding.target),
|
||||
reason: finding.reason,
|
||||
recommendation: finding.recommendation
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function groupByTarget(findings) {
|
||||
const grouped = new Map();
|
||||
for (const finding of findings) {
|
||||
const target = finding.target.locator ?? formatTarget(finding.target);
|
||||
const ownerLabel = deriveOwnerLabel(target);
|
||||
const current = grouped.get(target) ?? {
|
||||
kind: finding.kind,
|
||||
severity: finding.severity,
|
||||
target,
|
||||
count: 0,
|
||||
ownerLabel,
|
||||
explanation: "Weltdaten zeigen auf ein Paketasset, fuer das in Manifesten und Paket-Packs keine sichtbare Eigenreferenz gefunden wurde.",
|
||||
reason: finding.reason,
|
||||
recommendation: finding.recommendation,
|
||||
sources: new Set()
|
||||
};
|
||||
current.count += 1;
|
||||
if (finding.severity === "high") current.severity = "high";
|
||||
if (finding.source?.sourceLabel) current.sources.add(finding.source.sourceLabel);
|
||||
grouped.set(target, current);
|
||||
}
|
||||
return [...grouped.values()]
|
||||
.map(group => ({ ...group, sources: [...group.sources].slice(0, 5) }))
|
||||
.sort((a, b) => compareSeverity(a.severity, b.severity) || (b.count - a.count) || a.target.localeCompare(b.target));
|
||||
}
|
||||
|
||||
function deriveOwnerLabel(target) {
|
||||
const [, path] = String(target).split(":", 2);
|
||||
const parts = (path ?? "").split("/");
|
||||
if ((parts[0] === "modules") || (parts[0] === "systems")) {
|
||||
return `${parts[0]}:${parts[1] ?? "unknown"}`;
|
||||
}
|
||||
return "unbekannt";
|
||||
}
|
||||
|
||||
function compareFindings(a, b) {
|
||||
return compareSeverity(a.severity, b.severity)
|
||||
|| formatTarget(a.target).localeCompare(formatTarget(b.target));
|
||||
}
|
||||
|
||||
function compareSeverity(a, b) {
|
||||
const order = { high: 0, warning: 1, info: 2 };
|
||||
return (order[a] ?? 9) - (order[b] ?? 9);
|
||||
}
|
||||
|
||||
function formatTarget(target) {
|
||||
return target.locator ?? `${target.storage}:${target.path}`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """);
|
||||
}
|
||||
Reference in New Issue
Block a user