feat: add secure batch completion reports

Generate immutable post-cleanup summaries only after queue, history, and recovery finalization. Add bilingual accessible report UI, host and cleanup metrics, report-bound JSON and sanitized error CSV exports, renderer reload recovery, duplicate-filename-safe accounting, and public-source verification coverage.
This commit is contained in:
Sucukdeluxe
2026-08-17 00:18:20 +02:00
parent c8170b8556
commit 329d22e5b7
18 changed files with 1282 additions and 36 deletions
+188
View File
@@ -0,0 +1,188 @@
const { classifyErrorCategory } = require('./stats');
const { redactLogText } = require('./support-bundle');
function number(value) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function integer(value) {
return Math.max(0, Math.trunc(number(value)));
}
function iso(value, fallback) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
}
function text(value, secrets, limit = 500) {
const source = value instanceof Error ? value.message : String(value ?? '');
return String(redactLogText(source, secrets) || '').slice(0, limit);
}
function redactPosixPaths(value) {
let output = '';
let index = 0;
while (index < value.length) {
const previous = value[index - 1] || '';
if (value[index] !== '/' || (index > 0 && !/[\s=:([{]/.test(previous))) {
output += value[index++];
continue;
}
let end = index + 1;
while (end < value.length && !/[\s"'<>|]/.test(value[end])) end++;
const candidate = value.slice(index, end);
if (candidate.slice(1).includes('/')) {
output += '<redacted-path>';
index = end;
continue;
}
output += value[index++];
}
return output;
}
function errorText(value, secrets) {
return redactPosixPaths(text(value, secrets)
.replace(/https?:\/\/[^\s"'<>]+/gi, '<redacted-url>'))
.replace(/\b[A-Za-z0-9_-]{24,}\b/g, '<redacted>');
}
function fileName(value, secrets) {
const name = String(value ?? '').split(/[\\/]/).pop() || '';
return text(name, secrets, 260);
}
function createJobTotals() {
return { total: 0, succeeded: 0, failed: 0, skipped: 0, aborted: 0 };
}
function createHostTotals() {
return { ...createJobTotals(), successfulBytes: 0 };
}
function addStatus(target, status) {
target.total++;
if (status === 'done') target.succeeded++;
else if (status === 'skipped') target.skipped++;
else if (status === 'aborted') target.aborted++;
else target.failed++;
}
function buildCleanupTotals(outcomes) {
const totals = { requested: 0, deleted: 0, blocked: 0, failed: 0 };
for (const value of Array.isArray(outcomes) ? outcomes : []) {
const outcome = String(value || 'failed');
if (outcome === 'setting-disabled') continue;
totals.requested++;
if (outcome === 'deleted') totals.deleted++;
else if (outcome === 'blocked' || outcome === 'source-changed' || outcome === 'source-missing' || outcome === 'unsafe-source-type') totals.blocked++;
else totals.failed++;
}
return totals;
}
function deepFreeze(value) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
Object.values(value).forEach(deepFreeze);
return Object.freeze(value);
}
function buildBatchCompletionReport(input = {}) {
const summary = input.summary && typeof input.summary === 'object' ? input.summary : {};
const secrets = Array.isArray(input.secrets) ? input.secrets : [];
const completedAt = iso(input.completedAt, new Date().toISOString());
const startedAt = iso(input.startedAt ?? summary.timestamp, completedAt);
const durationSec = Math.max(0, (new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000);
const files = { total: 0, fullySucceeded: 0, partiallySucceeded: 0, failed: 0 };
const jobs = createJobTotals();
const hostMap = new Map();
const errors = [];
let successfulBytes = 0;
for (const file of Array.isArray(summary.files) ? summary.files : []) {
const results = Array.isArray(file?.results) ? file.results : [];
if (results.length === 0) continue;
files.total++;
const size = number(file?.size);
const successful = results.filter(result => result?.status === 'done').length;
if (successful === results.length) files.fullySucceeded++;
else if (successful > 0) files.partiallySucceeded++;
else files.failed++;
const safeFileName = fileName(file?.name ?? file?.fileName, secrets);
for (const result of results) {
const status = String(result?.status || 'error');
const hoster = text(result?.hoster || 'unknown', secrets, 120) || 'unknown';
if (!hostMap.has(hoster)) hostMap.set(hoster, createHostTotals());
const host = hostMap.get(hoster);
addStatus(jobs, status);
addStatus(host, status);
if (status === 'done') {
successfulBytes += size;
host.successfulBytes += size;
}
if (status === 'error' || result?.remoteCommitUncertain === true) {
const message = errorText(result?.error || 'Unknown error', secrets);
errors.push({
jobId: text(result?.jobId, secrets, 160),
fileName: safeFileName,
hoster,
status,
category: classifyErrorCategory(message),
attempt: integer(result?.attempt),
maxAttempts: integer(result?.maxAttempts),
remoteCommitUncertain: result?.remoteCommitUncertain === true,
message
});
}
}
}
const hosters = Object.fromEntries([...hostMap.entries()].sort(([left], [right]) => left.localeCompare(right)));
const batchId = text(summary.id, secrets, 160);
const report = {
reportId: text(input.reportId || `report-${batchId || completedAt}`, secrets, 200),
batchId,
startedAt,
completedAt,
generatedAt: completedAt,
durationSec,
files,
jobs,
cleanup: buildCleanupTotals(input.cleanupOutcomes),
transfer: {
successfulBytes,
averageBytesPerSecond: durationSec > 0 ? successfulBytes / durationSec : 0
},
hosters,
errors
};
return deepFreeze(report);
}
function csvCell(value) {
let output = value === null || value === undefined ? '' : String(value);
if (/^[\u0000-\u0020]*[=+\-@]/.test(output)) output = `'${output}`;
return /[",\r\n]/.test(output) ? `"${output.replace(/"/g, '""')}"` : output;
}
function buildBatchErrorCsv(report) {
const rows = [['Job ID', 'File name', 'Host', 'Status', 'Category', 'Attempt', 'Max attempts', 'Remote commit uncertain', 'Message']];
for (const error of Array.isArray(report?.errors) ? report.errors : []) {
rows.push([
error.jobId,
error.fileName,
error.hoster,
error.status,
error.category,
integer(error.attempt),
integer(error.maxAttempts),
error.remoteCommitUncertain === true ? 'true' : 'false',
error.message
]);
}
return `${rows.map(row => row.map(csvCell).join(',')).join('\n')}\n`;
}
module.exports = { buildBatchCompletionReport, buildBatchErrorCsv };