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
+225
View File
@@ -61,6 +61,7 @@ function refreshLocalizedRuntimeUi() {
const activeRecentTab = document.querySelector('.recent-tab.active');
const hint = document.getElementById('recentFilesHint');
if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links');
if (_activeBatchCompletionReport) renderBatchCompletionReport(_activeBatchCompletionReport);
}
// Dropdown options for "Add Account" modal: value -> label
@@ -511,6 +512,228 @@ const modalController = (() => {
return { open, close, isOpen };
})();
const _shownBatchCompletionReportIds = new Set();
let _activeBatchCompletionReport = null;
let _batchCompletionReportUiReady = false;
function batchReportNumber(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 ? number : 0;
}
function batchReportInteger(value) {
return Math.max(0, Math.trunc(batchReportNumber(value)));
}
function setBatchCompletionValue(id, value) {
const element = document.getElementById(id);
if (element) element.textContent = batchReportInteger(value).toLocaleString(getUiLocale());
}
function getBatchCompletionOutcome(report) {
const files = report?.files || {};
const jobs = report?.jobs || {};
const cleanup = report?.cleanup || {};
return batchReportInteger(files.partiallySucceeded) > 0
|| batchReportInteger(files.failed) > 0
|| batchReportInteger(jobs.failed) > 0
|| batchReportInteger(jobs.skipped) > 0
|| batchReportInteger(jobs.aborted) > 0
|| batchReportInteger(cleanup.blocked) > 0
|| batchReportInteger(cleanup.failed) > 0
|| (Array.isArray(report?.errors) && report.errors.length > 0)
? 'mixed'
: 'success';
}
function getBatchErrorCategoryLabel(category) {
const labels = {
network: 'Netzwerk',
'hoster-transient': 'Temporärer Hosterfehler',
'file-rejected': 'Datei abgelehnt',
'account-error': 'Account-Fehler',
aborted: 'Abgebrochen',
unknown: 'Unbekannt'
};
return localizeUiText(labels[String(category || '')] || 'Unbekannt');
}
function getBatchErrorStatusLabel(status) {
const labels = {
done: 'Erfolgreich',
error: 'Fehlgeschlagen',
skipped: 'Übersprungen',
aborted: 'Abgebrochen'
};
return localizeUiText(labels[String(status || '')] || 'Fehlgeschlagen');
}
function renderBatchCompletionHosters(report) {
const body = document.getElementById('batchCompletionHostersBody');
if (!body) return;
const rows = Object.entries(report?.hosters && typeof report.hosters === 'object' ? report.hosters : {}).map(([hoster, values]) => {
const row = document.createElement('tr');
row.dataset.hoster = hoster;
const host = document.createElement('th');
host.scope = 'row';
host.textContent = getHosterLabel(hoster);
row.appendChild(host);
[
batchReportInteger(values?.total).toLocaleString(getUiLocale()),
batchReportInteger(values?.succeeded).toLocaleString(getUiLocale()),
batchReportInteger(values?.failed).toLocaleString(getUiLocale()),
batchReportInteger(values?.skipped).toLocaleString(getUiLocale()),
batchReportInteger(values?.aborted).toLocaleString(getUiLocale()),
formatBytes(batchReportNumber(values?.successfulBytes))
].forEach(value => {
const cell = document.createElement('td');
cell.textContent = value;
row.appendChild(cell);
});
return row;
});
body.replaceChildren(...rows);
}
function renderBatchCompletionErrors(report) {
const section = document.getElementById('batchCompletionErrorsSection');
const list = document.getElementById('batchCompletionErrorsList');
const count = document.getElementById('batchCompletionErrorsCount');
const more = document.getElementById('batchCompletionErrorsMore');
if (!section || !list || !count || !more) return;
const errors = Array.isArray(report?.errors) ? report.errors : [];
section.hidden = errors.length === 0;
count.textContent = errors.length.toLocaleString(getUiLocale());
const items = errors.slice(0, 5).map(error => {
const item = document.createElement('li');
const head = document.createElement('div');
head.className = 'batch-completion-error-head';
const file = document.createElement('strong');
file.className = 'batch-completion-error-file';
file.textContent = String(error?.fileName || localizeUiText('Unbekannt'));
const hoster = document.createElement('span');
hoster.textContent = getHosterLabel(String(error?.hoster || ''));
head.append(file, hoster);
const meta = document.createElement('div');
meta.className = 'batch-completion-error-meta';
const status = document.createElement('span');
status.textContent = getBatchErrorStatusLabel(error?.status);
const category = document.createElement('span');
category.textContent = getBatchErrorCategoryLabel(error?.category);
meta.append(status, category);
const attempt = batchReportInteger(error?.attempt);
const maxAttempts = batchReportInteger(error?.maxAttempts);
if (attempt > 0 || maxAttempts > 0) {
const attemptLabel = document.createElement('span');
attemptLabel.textContent = `${localizeUiText('Versuch')} ${attempt}${maxAttempts > 0 ? `/${maxAttempts}` : ''}`;
meta.appendChild(attemptLabel);
}
if (error?.remoteCommitUncertain === true) {
const uncertain = document.createElement('span');
uncertain.className = 'batch-completion-error-uncertain';
uncertain.textContent = localizeUiText('Remote-Abschluss unklar');
meta.appendChild(uncertain);
}
const message = document.createElement('p');
message.className = 'batch-completion-error-message';
message.textContent = String(error?.message || localizeUiText('Unbekannter Fehler'));
item.append(head, meta, message);
return item;
});
list.replaceChildren(...items);
const remaining = Math.max(0, errors.length - items.length);
more.hidden = remaining === 0;
more.textContent = remaining === 1 ? localizeUiText('1 weiterer Fehler') : localizeUiText(`${remaining} weitere Fehler`);
}
function renderBatchCompletionReport(report) {
const modal = document.getElementById('batchCompletionModal');
if (!modal || !report) return false;
_activeBatchCompletionReport = report;
const outcome = getBatchCompletionOutcome(report);
modal.dataset.reportId = String(report.reportId);
modal.dataset.outcome = outcome;
const outcomeLabel = document.getElementById('batchCompletionOutcome');
if (outcomeLabel) outcomeLabel.textContent = localizeUiText(outcome === 'success' ? 'Erfolgreich' : 'Mit Problemen');
const fileCount = batchReportInteger(report.files?.total);
const jobCount = batchReportInteger(report.jobs?.total);
const summary = document.getElementById('batchCompletionSummary');
if (summary) summary.textContent = `${fileCount.toLocaleString(getUiLocale())} ${localizeUiText(fileCount === 1 ? 'Datei' : 'Dateien')} · ${jobCount.toLocaleString(getUiLocale())} ${localizeUiText(jobCount === 1 ? 'Auftrag' : 'Aufträge')} · ${formatDateTime(report.completedAt).text}`;
setBatchCompletionValue('batchCompletionFilesTotal', report.files?.total);
setBatchCompletionValue('batchCompletionFilesFullySucceeded', report.files?.fullySucceeded);
setBatchCompletionValue('batchCompletionFilesPartiallySucceeded', report.files?.partiallySucceeded);
setBatchCompletionValue('batchCompletionFilesFailed', report.files?.failed);
setBatchCompletionValue('batchCompletionJobsTotal', report.jobs?.total);
setBatchCompletionValue('batchCompletionJobsSucceeded', report.jobs?.succeeded);
setBatchCompletionValue('batchCompletionJobsFailed', report.jobs?.failed);
setBatchCompletionValue('batchCompletionJobsSkipped', report.jobs?.skipped);
setBatchCompletionValue('batchCompletionJobsAborted', report.jobs?.aborted);
setBatchCompletionValue('batchCompletionCleanupRequested', report.cleanup?.requested);
setBatchCompletionValue('batchCompletionCleanupDeleted', report.cleanup?.deleted);
setBatchCompletionValue('batchCompletionCleanupBlocked', report.cleanup?.blocked);
setBatchCompletionValue('batchCompletionCleanupFailed', report.cleanup?.failed);
const duration = document.getElementById('batchCompletionDuration');
const bytes = document.getElementById('batchCompletionSuccessfulBytes');
const speed = document.getElementById('batchCompletionAverageSpeed');
if (duration) duration.textContent = formatDuration(Math.round(batchReportNumber(report.durationSec)));
if (bytes) bytes.textContent = formatBytes(batchReportNumber(report.transfer?.successfulBytes));
if (speed) speed.textContent = `${formatBytes(batchReportNumber(report.transfer?.averageBytesPerSecond))}/s`;
renderBatchCompletionHosters(report);
renderBatchCompletionErrors(report);
uiLocalizer.translate(modal);
return true;
}
function closeBatchCompletionReport() {
modalController.close('batchCompletionModal', { fallbackFocus: '#addFilesBtn' });
}
function showBatchCompletionReport(report) {
const reportId = typeof report?.reportId === 'string' ? report.reportId.trim() : '';
if (!reportId || _shownBatchCompletionReportIds.has(reportId)) return false;
_shownBatchCompletionReportIds.add(reportId);
if (!renderBatchCompletionReport({ ...report, reportId })) return false;
return modalController.open('batchCompletionModal', {
initialFocus: '#batchCompletionHeaderCloseBtn',
fallbackFocus: '#addFilesBtn',
onEscape: closeBatchCompletionReport
});
}
async function exportVisibleBatchCompletionReport(format, button) {
const reportId = _activeBatchCompletionReport?.reportId;
if (!reportId || !window.api?.exportBatchCompletionReport) return;
button.disabled = true;
try {
const result = await window.api.exportBatchCompletionReport(reportId, format);
if (result?.ok) showCopyToast(format === 'json' ? 'JSON-Bericht exportiert' : 'Fehler-CSV exportiert');
else if (!result?.canceled) await showAppAlert(result?.error || 'Batch-Bericht konnte nicht exportiert werden.', 'Export fehlgeschlagen');
} catch (error) {
await showAppAlert(getLocalizedErrorDetail(error), 'Export fehlgeschlagen');
} finally {
button.disabled = false;
}
}
function setupBatchCompletionReportUi() {
if (_batchCompletionReportUiReady) return;
_batchCompletionReportUiReady = true;
document.getElementById('batchCompletionHeaderCloseBtn')?.addEventListener('click', closeBatchCompletionReport);
document.getElementById('batchCompletionCloseBtn')?.addEventListener('click', closeBatchCompletionReport);
const jsonButton = document.getElementById('batchCompletionExportJsonBtn');
const csvButton = document.getElementById('batchCompletionExportCsvBtn');
jsonButton?.addEventListener('click', () => exportVisibleBatchCompletionReport('json', jsonButton));
csvButton?.addEventListener('click', () => exportVisibleBatchCompletionReport('csv', csvButton));
window.api?.onUploadBatchReport?.(showBatchCompletionReport);
}
async function showLastBatchCompletionReport() {
if (!window.api?.getLastBatchCompletionReport) return;
try {
showBatchCompletionReport(await window.api.getLastBatchCompletionReport());
} catch {}
}
// Session-specific files for the "Files" panel (resets each session)
let sessionFilesData = [];
let _recentSeqCounter = 0;
@@ -566,6 +789,7 @@ async function init() {
renderRecentUploadsPanel();
updateUploadView();
updateStatusBar();
await showLastBatchCompletionReport();
const interruptedCount = queueJobs.filter(job => job.interrupted).length;
if (interruptedCount > 0) showCopyToast(interruptedCount === 1 ? '1 unterbrochener Upload kann fortgesetzt werden.' : `${interruptedCount} unterbrochene Uploads können fortgesetzt werden.`, 7000);
@@ -8584,6 +8808,7 @@ function updateStatsPanel() {
window.api.onUpdateAvailable(showUpdateBanner);
window.api.onUpdateProgress(handleUpdateProgress);
window.api.onPrepareClose(prepareForWindowClose);
setupBatchCompletionReportUi();
setupAppAlertListeners();
init().then(() => {
window.api.signalCloseHandshakeReady();
+28
View File
@@ -63,6 +63,32 @@
['Hoster-Limits automatisch hochskalieren', 'Automatically scale host limits'],
['Hoster', 'Host'],
['Versuch', 'Attempt'],
['Batch abgeschlossen', 'Batch complete'],
['Der Upload-Batch wurde abgeschlossen.', 'The upload batch has completed.'],
['Mit Problemen', 'Completed with issues'],
['Übertragung', 'Transfer'],
['Dauer', 'Duration'],
['Erfolgreich übertragen', 'Successfully transferred'],
['Durchschnitt', 'Average'],
['Vollständig erfolgreich', 'Fully successful'],
['Teilweise erfolgreich', 'Partially successful'],
['Auftrag', 'Job'],
['Aufträge', 'Jobs'],
['Angefordert', 'Requested'],
['Gelöscht', 'Deleted'],
['Blockiert', 'Blocked'],
['Hosterübersicht', 'Host overview'],
['Übertragen', 'Transferred'],
['Fehlerbeispiele', 'Error examples'],
['Fehler-CSV exportieren', 'Export error CSV'],
['Temporärer Hosterfehler', 'Temporary host error'],
['Datei abgelehnt', 'File rejected'],
['Account-Fehler', 'Account error'],
['Remote-Abschluss unklar', 'Remote completion uncertain'],
['1 weiterer Fehler', '1 more error'],
['JSON-Bericht exportiert', 'JSON report exported'],
['Fehler-CSV exportiert', 'Error CSV exported'],
['Batch-Bericht konnte nicht exportiert werden.', 'The batch report could not be exported.'],
['Importieren', 'Import'],
['In Zwischenablage', 'To clipboard'],
['In diesem Lauf hochgeladen:', 'Uploaded during this run:'],
@@ -772,6 +798,7 @@
[/^Update-Server Antwort war kein JSON (.+)$/, 'Update server response was not JSON $1'],
[/^(\d+) Links kopiert$/, '$1 links copied'],
[/^(\d+) Link kopiert$/, '$1 link copied'],
[/^(\d+) weitere Fehler$/, '$1 more errors'],
[/^Wirklich alle (\d+) Links aus diesem Panel entfernen\?$/, 'Remove all $1 links from this panel?'],
[/^Ein ausgewählter Eintrag wird aus diesem Panel entfernt\.$/, 'One selected entry will be removed from this panel.'],
[/^(\d+) ausgewählte Einträge werden aus diesem Panel entfernt\.$/, '$1 selected entries will be removed from this panel.'],
@@ -865,6 +892,7 @@
[/^Restart in (\d+)s\.\.\.$/, 'Neustart in $1s...'],
[/^(\d+) job reset for upload$/, '$1 Job zum erneuten Upload zurückgesetzt'],
[/^(\d+) jobs reset for upload$/, '$1 Jobs zum erneuten Upload zurückgesetzt'],
[/^(\d+) more errors$/, '$1 weitere Fehler'],
[/^(\d+) history entry will be permanently removed\.$/, '$1 Verlaufseintrag wird dauerhaft entfernt.'],
[/^(\d+) history entries will be permanently removed\.$/, '$1 Verlaufseinträge werden dauerhaft entfernt.'],
[/^Active on port (\d+) — 1 client connected$/, 'Aktiv auf Port $1 — 1 Client verbunden'],
+89
View File
@@ -667,6 +667,95 @@
<button class="btn btn-xs btn-secondary" id="cancelStartupResumeBtn">Abbrechen</button>
</div>
<div class="modal-overlay" id="batchCompletionModal" style="display:none" aria-hidden="true">
<div class="modal-card batch-completion-card" role="dialog" aria-modal="true" aria-labelledby="batchCompletionTitle" aria-describedby="batchCompletionSummary" tabindex="-1">
<div class="modal-header batch-completion-header">
<div class="batch-completion-heading">
<span class="batch-completion-outcome" id="batchCompletionOutcome">Erfolgreich</span>
<h3 id="batchCompletionTitle">Batch abgeschlossen</h3>
<p id="batchCompletionSummary">Der Upload-Batch wurde abgeschlossen.</p>
</div>
<button class="icon-btn" id="batchCompletionHeaderCloseBtn" aria-label="Schließen">&times;</button>
</div>
<div class="modal-body batch-completion-body">
<dl class="batch-completion-transfer" aria-label="Übertragung">
<div><dt>Dauer</dt><dd id="batchCompletionDuration">00:00:00</dd></div>
<div><dt>Erfolgreich übertragen</dt><dd id="batchCompletionSuccessfulBytes">0 B</dd></div>
<div><dt>Durchschnitt</dt><dd id="batchCompletionAverageSpeed">0 B/s</dd></div>
</dl>
<div class="batch-completion-metric-sections">
<section class="batch-completion-section" aria-labelledby="batchCompletionFilesTitle">
<h4 id="batchCompletionFilesTitle">Dateien</h4>
<dl class="batch-completion-metrics">
<div><dt>Gesamt</dt><dd id="batchCompletionFilesTotal">0</dd></div>
<div><dt>Vollständig erfolgreich</dt><dd id="batchCompletionFilesFullySucceeded">0</dd></div>
<div><dt>Teilweise erfolgreich</dt><dd id="batchCompletionFilesPartiallySucceeded">0</dd></div>
<div><dt>Fehlgeschlagen</dt><dd id="batchCompletionFilesFailed">0</dd></div>
</dl>
</section>
<section class="batch-completion-section" aria-labelledby="batchCompletionJobsTitle">
<h4 id="batchCompletionJobsTitle">Aufträge</h4>
<dl class="batch-completion-metrics batch-completion-job-metrics">
<div><dt>Gesamt</dt><dd id="batchCompletionJobsTotal">0</dd></div>
<div><dt>Erfolgreich</dt><dd id="batchCompletionJobsSucceeded">0</dd></div>
<div><dt>Fehlgeschlagen</dt><dd id="batchCompletionJobsFailed">0</dd></div>
<div><dt>Übersprungen</dt><dd id="batchCompletionJobsSkipped">0</dd></div>
<div><dt>Abgebrochen</dt><dd id="batchCompletionJobsAborted">0</dd></div>
</dl>
</section>
<section class="batch-completion-section" aria-labelledby="batchCompletionCleanupTitle">
<h4 id="batchCompletionCleanupTitle">Quelldateien</h4>
<dl class="batch-completion-metrics">
<div><dt>Angefordert</dt><dd id="batchCompletionCleanupRequested">0</dd></div>
<div><dt>Gelöscht</dt><dd id="batchCompletionCleanupDeleted">0</dd></div>
<div><dt>Blockiert</dt><dd id="batchCompletionCleanupBlocked">0</dd></div>
<div><dt>Fehlgeschlagen</dt><dd id="batchCompletionCleanupFailed">0</dd></div>
</dl>
</section>
</div>
<section class="batch-completion-section batch-completion-hosters" aria-labelledby="batchCompletionHostersTitle">
<h4 id="batchCompletionHostersTitle">Hosterübersicht</h4>
<div class="batch-completion-table-scroll" tabindex="0">
<table>
<thead>
<tr>
<th scope="col">Hoster</th>
<th scope="col">Aufträge</th>
<th scope="col">Erfolgreich</th>
<th scope="col">Fehlgeschlagen</th>
<th scope="col">Übersprungen</th>
<th scope="col">Abgebrochen</th>
<th scope="col">Übertragen</th>
</tr>
</thead>
<tbody id="batchCompletionHostersBody"></tbody>
</table>
</div>
</section>
<section class="batch-completion-section batch-completion-errors" id="batchCompletionErrorsSection" aria-labelledby="batchCompletionErrorsTitle" hidden>
<div class="batch-completion-section-heading">
<h4 id="batchCompletionErrorsTitle">Fehlerbeispiele</h4>
<span id="batchCompletionErrorsCount">0</span>
</div>
<ol id="batchCompletionErrorsList"></ol>
<p id="batchCompletionErrorsMore" hidden></p>
</section>
</div>
<div class="modal-footer batch-completion-footer">
<div class="batch-completion-export-actions">
<button class="btn btn-secondary" id="batchCompletionExportJsonBtn">JSON exportieren</button>
<button class="btn btn-secondary" id="batchCompletionExportCsvBtn">Fehler-CSV exportieren</button>
</div>
<button class="btn btn-primary" id="batchCompletionCloseBtn">Schließen</button>
</div>
</div>
</div>
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none" aria-hidden="true">
<div class="shutdown-box" role="dialog" aria-modal="true" aria-labelledby="shutdownMessage" tabindex="-1">
<p id="shutdownMessage">System wird heruntergefahren in <span id="shutdownSeconds">60</span>s...</p>
+233
View File
@@ -735,6 +735,239 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel
gap: 8px;
margin-bottom: 12px;
}
.batch-completion-card {
width: min(920px, 100%);
max-height: min(calc(100vh - 48px), 820px);
}
.batch-completion-header {
align-items: flex-start;
}
.batch-completion-heading {
min-width: 0;
}
.batch-completion-outcome {
display: inline-flex;
align-items: center;
min-height: 22px;
margin-bottom: 7px;
padding: 3px 8px;
border: 1px solid rgba(52, 211, 153, .38);
border-radius: 999px;
background: rgba(52, 211, 153, .12);
color: var(--success);
font-size: 11px;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
}
#batchCompletionModal[data-outcome="mixed"] .batch-completion-outcome {
border-color: rgba(245, 158, 11, .42);
background: rgba(245, 158, 11, .12);
color: var(--warning);
}
.batch-completion-body {
display: grid;
gap: 12px;
min-width: 0;
overflow-x: hidden;
}
.batch-completion-transfer,
.batch-completion-metrics {
margin: 0;
}
.batch-completion-transfer {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.batch-completion-transfer > div,
.batch-completion-metrics > div {
min-width: 0;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255, 255, 255, .025);
}
.batch-completion-transfer > div {
padding: 10px 12px;
}
.batch-completion-transfer dt,
.batch-completion-metrics dt {
color: var(--text-muted);
font-size: 11px;
}
.batch-completion-transfer dd,
.batch-completion-metrics dd {
margin: 4px 0 0;
color: var(--text-primary);
font-size: 16px;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.batch-completion-metric-sections {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.batch-completion-section {
min-width: 0;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: rgba(255, 255, 255, .018);
}
.batch-completion-section h4 {
margin: 0 0 9px;
font-size: 13px;
}
.batch-completion-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.batch-completion-metrics > div {
padding: 8px 9px;
}
.batch-completion-job-metrics > div:first-child {
grid-column: 1 / -1;
}
.batch-completion-table-scroll {
max-width: 100%;
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.batch-completion-table-scroll:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.batch-completion-table-scroll table {
width: 100%;
min-width: 650px;
border-collapse: collapse;
}
.batch-completion-table-scroll th,
.batch-completion-table-scroll td {
padding: 7px 9px;
border-bottom: 1px solid var(--border);
text-align: right;
white-space: nowrap;
}
.batch-completion-table-scroll th {
color: var(--text-muted);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
.batch-completion-table-scroll th:first-child,
.batch-completion-table-scroll td:first-child {
text-align: left;
}
.batch-completion-table-scroll tbody tr:last-child td {
border-bottom: 0;
}
.batch-completion-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.batch-completion-section-heading h4 {
margin-bottom: 0;
}
.batch-completion-section-heading span {
min-width: 24px;
padding: 2px 7px;
border-radius: 999px;
background: rgba(239, 68, 68, .14);
color: var(--danger);
text-align: center;
font-size: 11px;
font-weight: 700;
}
.batch-completion-errors ol {
display: grid;
gap: 8px;
margin: 10px 0 0;
padding: 0;
list-style: none;
}
.batch-completion-errors li {
min-width: 0;
padding: 9px 10px;
border: 1px solid rgba(239, 68, 68, .22);
border-radius: 8px;
background: rgba(239, 68, 68, .055);
}
.batch-completion-error-head,
.batch-completion-error-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
}
.batch-completion-error-file {
overflow-wrap: anywhere;
}
.batch-completion-error-meta {
margin-top: 4px;
color: var(--text-muted);
font-size: 11px;
}
.batch-completion-error-message {
margin: 7px 0 0;
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.batch-completion-error-uncertain {
color: var(--warning);
font-weight: 700;
}
#batchCompletionErrorsMore {
margin: 9px 0 0;
color: var(--text-muted);
font-size: 12px;
}
.batch-completion-footer,
.batch-completion-export-actions {
display: flex;
align-items: center;
gap: 8px;
}
.batch-completion-footer {
flex-wrap: wrap;
}
.batch-completion-export-actions {
flex-wrap: wrap;
}
@media (max-width: 820px) {
.batch-completion-card {
max-height: calc(100vh - 24px);
}
.batch-completion-metric-sections {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.batch-completion-metric-sections > :last-child {
grid-column: 1 / -1;
}
}
@media (max-width: 620px) {
.batch-completion-transfer,
.batch-completion-metric-sections {
grid-template-columns: 1fr;
}
.batch-completion-metric-sections > :last-child {
grid-column: auto;
}
.batch-completion-footer,
.batch-completion-export-actions {
align-items: stretch;
width: 100%;
}
.batch-completion-footer > .btn,
.batch-completion-export-actions .btn {
flex: 1 1 auto;
}
}
.hoster-modal-list {
display: grid;
gap: 10px;