feat: add unified upload telemetry

Move upload activity out of the global footer into a compact eight-row sidebar panel, add vertical metric transitions, and draw a smoothed green upload-speed sparkline in the header. Localize the new surfaces, cover responsive Electron behavior, and extend the strict public source allowlist for the new bounded speed-history module and its tests.
This commit is contained in:
Sucukdeluxe
2026-08-11 04:34:12 +02:00
parent feac1d06c5
commit b57b72327c
8 changed files with 397 additions and 158 deletions
+20
View File
@@ -0,0 +1,20 @@
(function (root) {
'use strict';
function updateSpeedHistory(state, target, maxSamples = 160) {
const nextTarget = Number.isFinite(Number(target)) ? Math.max(0, Number(target)) : 0;
const previous = Number.isFinite(Number(state.display)) ? Math.max(0, Number(state.display)) : 0;
const alpha = nextTarget >= previous ? 0.45 : 0.12;
const display = previous + (nextTarget - previous) * alpha;
state.display = display < 1 ? 0 : display;
if (!Array.isArray(state.history)) state.history = [];
state.history.push(state.display);
const limit = Math.max(1, Math.floor(Number(maxSamples) || 160));
if (state.history.length > limit) state.history.splice(0, state.history.length - limit);
return state;
}
const api = { updateSpeedHistory };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else if (root) root.SpeedHistory = api;
})(typeof window !== 'undefined' ? window : this);
+139 -20
View File
@@ -274,6 +274,8 @@ let settingsBaseline = '';
let settingsDirty = false;
let settingsSaving = false;
let lastUploadStats = { state: 'idle', globalSpeedKbs: 0, totalBytes: 0, elapsed: 0, activeJobs: 0 };
const uploadSpeedState = { display: 0, history: [] };
let uploadSpeedTimer = null;
const AUTO_CHECK_PREF_KEY = 'autoHealthCheckBeforeUpload';
const QUEUE_COL_WIDTHS_KEY = 'queueColumnWidthsPx';
const STARTABLE_QUEUE_STATUSES = new Set(['preview', 'queued', 'error', 'aborted', 'skipped']);
@@ -369,6 +371,7 @@ async function init() {
renderAccounts();
setupListeners();
setupDragDrop();
initUploadSpeedSparkline();
restoreQueueColumnWidths();
loadHistory();
_refreshSessionFailedSnapshot();
@@ -541,6 +544,7 @@ function _isHistoryTabActive() {
if (nextView) nextView.classList.add('active');
activeTab = tab;
syncTabIndicator(tab);
syncUploadSpeedSparklineVisibility(tab.dataset.view);
const activeSidebarButton = nextView?.querySelector('.view-sidebar-navigation > .view-sidebar-item.active, .settings-navigation > .settings-nav-button.active');
_syncSidebarIndicator(activeSidebarButton, true);
if (tab.dataset.view === 'history' && (_historyDirty || !_historyEverLoaded)) {
@@ -3596,6 +3600,133 @@ function updateHistorySidebarSummary() {
if (retention && select) retention.textContent = labels[select.value] || labels.all;
}
function _setUploadTelemetryText(id, value) {
const element = document.getElementById(id);
if (!element) return;
const text = String(value);
element.textContent = text;
element.setAttribute('aria-label', text);
}
function _setRollingUploadMetric(id, value) {
const element = document.getElementById(id);
if (!element) return;
const numericValue = Number(value) || 0;
const nextText = numericValue.toLocaleString(getUiLocale());
const previousValue = Number(element.dataset.numericValue) || 0;
if (previousValue === numericValue) {
element.setAttribute('aria-label', nextText);
return;
}
const direction = numericValue > previousValue ? 'up' : 'down';
const previousText = element.getAttribute('aria-label') || previousValue.toLocaleString(getUiLocale());
const outgoing = document.createElement('span');
const incoming = document.createElement('span');
outgoing.textContent = previousText;
incoming.textContent = nextText;
outgoing.className = 'upload-rolling-outgoing';
incoming.className = 'upload-rolling-incoming';
element.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel()));
element.dataset.numericValue = String(numericValue);
element.dataset.direction = direction;
element.setAttribute('aria-label', nextText);
element.replaceChildren(outgoing, incoming);
const distance = direction === 'up' ? -1 : 1;
const options = { duration: 320, easing: 'cubic-bezier(.2, .8, .2, 1)', fill: 'forwards' };
const outgoingAnimation = outgoing.animate([
{ transform: 'translateY(0)', opacity: 1 },
{ transform: `translateY(${distance * 100}%)`, opacity: 0 }
], options);
const incomingAnimation = incoming.animate([
{ transform: `translateY(${-distance * 100}%)`, opacity: 0 },
{ transform: 'translateY(0)', opacity: 1 }
], options);
Promise.allSettled([outgoingAnimation.finished, incomingAnimation.finished]).then(() => {
if (!incoming.isConnected || incoming.parentElement !== element) return;
const settled = document.createElement('span');
settled.textContent = nextText;
element.replaceChildren(settled);
element.dataset.direction = 'none';
});
}
function formatUploadSpeed(kbs) {
return !kbs || kbs <= 0 ? '0 B/s' : formatSpeed(kbs);
}
function syncUploadSpeedSparklineVisibility(view) {
const widget = document.getElementById('uploadSpeedSparkline');
if (!widget) return;
const activeView = view || document.querySelector('.tab.active')?.dataset.view;
widget.classList.toggle('is-hidden', activeView !== 'upload');
}
function drawUploadSpeedSparkline() {
const canvas = document.getElementById('uploadSpeedCanvas');
if (!canvas) return;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
if (width <= 0 || height <= 0) return;
const scale = Math.max(1, window.devicePixelRatio || 1);
const pixelWidth = Math.round(width * scale);
const pixelHeight = Math.round(height * scale);
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const context = canvas.getContext('2d');
context.setTransform(scale, 0, 0, scale, 0, 0);
context.clearRect(0, 0, width, height);
const values = uploadSpeedState.history;
if (values.length < 2) return;
const maximum = Math.max(1, ...values);
const step = width / Math.max(1, values.length - 1);
const y = value => height - 2 - (value / maximum) * (height - 4);
context.beginPath();
values.forEach((value, index) => {
const x = index * step;
if (index === 0) context.moveTo(x, y(value));
else context.lineTo(x, y(value));
});
context.lineTo(width, height);
context.lineTo(0, height);
context.closePath();
const fill = context.createLinearGradient(0, 0, 0, height);
fill.addColorStop(0, 'rgba(117, 211, 155, .22)');
fill.addColorStop(1, 'rgba(117, 211, 155, 0)');
context.fillStyle = fill;
context.fill();
context.beginPath();
values.forEach((value, index) => {
const x = index * step;
if (index === 0) context.moveTo(x, y(value));
else context.lineTo(x, y(value));
});
context.strokeStyle = window.getComputedStyle(document.documentElement).getPropertyValue('--success').trim() || '#75d39b';
context.lineWidth = 1.5;
context.lineJoin = 'round';
context.lineCap = 'round';
context.stroke();
}
function updateUploadSpeedSparkline() {
const speedKbs = Math.max(0, Number(lastUploadStats.globalSpeedKbs) || 0);
window.SpeedHistory.updateSpeedHistory(uploadSpeedState, speedKbs * 1024);
_setUploadTelemetryText('uploadSpeedValue', formatUploadSpeed(speedKbs));
drawUploadSpeedSparkline();
}
function initUploadSpeedSparkline() {
if (uploadSpeedTimer !== null) return;
syncUploadSpeedSparklineVisibility();
updateUploadSpeedSparkline();
uploadSpeedTimer = window.setInterval(updateUploadSpeedSparkline, 250);
window.addEventListener('resize', drawUploadSpeedSparkline);
window.addEventListener('beforeunload', () => window.clearInterval(uploadSpeedTimer), { once: true });
}
function updateStatusBar() {
const stats = _computeQueueStats();
@@ -3603,26 +3734,14 @@ function updateStatusBar() {
? Math.round(stats.bytesRemaining / (lastUploadStats.globalSpeedKbs * 1024))
: 0;
const stateText = lastUploadStats.state === 'uploading'
? 'Upload läuft...'
: lastUploadStats.state === 'stopping'
? 'Stoppt nach aktiven Uploads...'
: uploading
? 'Upload vorbereitet...'
: 'Bereit';
document.getElementById('sbState').textContent = stateText;
document.getElementById('sbSpeed').textContent = formatSpeed(lastUploadStats.globalSpeedKbs || 0);
const uploadedSize = _sessionUploadedBytes + stats.inProgressBytes;
const totalSize = Math.max(stats.totalSize, _sessionTotalBytes);
document.getElementById('sbTotal').textContent = `${formatSize(uploadedSize)} / ${formatSize(totalSize)}`;
document.getElementById('sbEta').textContent = `ETA ${etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'}`;
document.getElementById('sbConnections').textContent = `Verbindungen ${lastUploadStats.activeJobs || 0}`;
document.getElementById('sbQueueCount').textContent = `Gesamt ${stats.total}`;
document.getElementById('sbRemainingCount').textContent = `Verbleibend ${stats.remaining}`;
document.getElementById('sbInProgressCount').textContent = `Läuft ${stats.inProgress}`;
document.getElementById('sbDoneCount').textContent = `Fertig ${_sessionDoneCount}`;
document.getElementById('sbErrorCount').textContent = `Fehler ${_sessionErrorCount}`;
_setRollingUploadMetric('uploadTelemetryTotal', stats.total);
_setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0);
_setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining);
_setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress);
_setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount);
_setRollingUploadMetric('uploadTelemetryFailed', _sessionErrorCount);
_setUploadTelemetryText('uploadTelemetrySpeed', formatUploadSpeed(lastUploadStats.globalSpeedKbs || 0));
_setUploadTelemetryText('uploadTelemetryEta', etaSeconds > 0 ? formatTime(etaSeconds) : '--:--');
updateUploadSidebarSummary(stats);
}
+6
View File
@@ -61,6 +61,12 @@
['Sitzung', 'Session'],
['Speicherstatus', 'Save status'],
['Statistik', 'Statistics'],
['Upload-Statistik', 'Upload statistics'],
['Aktuelle Upload-Geschwindigkeit', 'Current upload speed'],
['Gesamt', 'Total'],
['Verbindungen', 'Connections'],
['Verbleibend', 'Remaining'],
['Läuft', 'Running'],
['Suche Aktualisierungen', 'Checking for updates'],
['Verbleibend:', 'Remaining:'],
['Zuletzt erzeugte Upload-Links', 'Recently generated upload links'],
+23 -29
View File
@@ -54,6 +54,10 @@
</div>
<div class="header-spacer" aria-hidden="true"></div>
<div class="header-cluster header-utilities">
<div class="upload-speed-sparkline" id="uploadSpeedSparkline" title="Aktuelle Upload-Geschwindigkeit" aria-label="Aktuelle Upload-Geschwindigkeit">
<canvas id="uploadSpeedCanvas" width="232" height="44" aria-hidden="true"></canvas>
<strong id="uploadSpeedValue">0 B/s</strong>
</div>
<button class="header-update-button" id="headerUpdateBtn" title="Nach Aktualisierungen suchen" aria-label="Nach Aktualisierungen suchen" data-tooltip="Nach Aktualisierungen suchen" hidden>
<svg class="header-action-icon" aria-hidden="true"><use href="#icon-download"></use></svg>
<span class="header-update-label">Update</span>
@@ -195,15 +199,26 @@
<span class="view-sidebar-badge" id="uploadSidebarErrorCount">0</span>
</button>
</nav>
<div class="view-sidebar-section">
<span class="view-sidebar-section-label">Verfügbarkeit</span>
<div class="view-sidebar-summary">
<span>Bereite Accounts</span>
<strong id="uploadSidebarAccountsCount">0</strong>
<div class="upload-sidebar-lower">
<div class="view-sidebar-section upload-availability" id="uploadAvailability">
<span class="view-sidebar-section-label">Verfügbarkeit</span>
<div class="view-sidebar-summary">
<span>Bereite Accounts</span>
<strong id="uploadSidebarAccountsCount">0</strong>
</div>
<div class="view-sidebar-summary view-sidebar-summary-block hoster-summary" id="hosterSummary">Keine Upload-Ziele ausgewählt</div>
</div>
<div class="upload-telemetry" id="uploadTelemetry" aria-label="Upload-Statistik">
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Gesamt</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryTotal" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbindungen</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryConnections" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Verbleibend</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRemaining" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Läuft</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryRunning" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Fertig</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryCompleted" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Fehler</span><strong class="upload-telemetry-value upload-rolling-value" id="uploadTelemetryFailed" data-numeric-value="0" aria-label="0"><span>0</span></strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">Geschwindigkeit</span><strong class="upload-telemetry-value" id="uploadTelemetrySpeed" aria-label="0 B/s">0 B/s</strong></div>
<div class="upload-telemetry-row"><span class="upload-telemetry-label">ETA</span><strong class="upload-telemetry-value" id="uploadTelemetryEta" aria-label="--:--">--:--</strong></div>
</div>
<div class="view-sidebar-summary view-sidebar-summary-block hoster-summary" id="hosterSummary">Keine Upload-Ziele ausgewählt</div>
</div>
<div class="view-sidebar-footnote">Dateien ablegen, Ziele wählen und Uploads zentral steuern.</div>
</aside>
<main class="view-main upload-main">
<div class="upload-toolbar">
@@ -604,28 +619,6 @@
<div class="ctx-item" data-action="recent-delete">Entfernen</div>
</div>
<div class="statusbar" id="statusbar">
<span class="sb-state" id="sbState">Bereit</span>
<span class="sb-separator">|</span>
<span class="sb-speed" id="sbSpeed">0 kB/s</span>
<span class="sb-separator">|</span>
<span class="sb-total" id="sbTotal">0 B</span>
<span class="sb-separator">|</span>
<span class="sb-eta" id="sbEta">ETA --:--</span>
<span class="sb-separator">|</span>
<span class="sb-connections" id="sbConnections">Verbindungen 0</span>
<span class="sb-separator">|</span>
<span class="sb-queue-count" id="sbQueueCount">Gesamt 0</span>
<span class="sb-separator">|</span>
<span class="sb-remaining-count" id="sbRemainingCount">Verbleibend 0</span>
<span class="sb-separator">|</span>
<span class="sb-progress-count" id="sbInProgressCount">Läuft 0</span>
<span class="sb-separator">|</span>
<span class="sb-done-count" id="sbDoneCount">Fertig 0</span>
<span class="sb-separator">|</span>
<span class="sb-error-count" id="sbErrorCount">Fehler 0</span>
</div>
<div class="copy-toast" id="copyToast"></div>
<div class="shutdown-overlay" id="shutdownOverlay" style="display:none">
@@ -667,6 +660,7 @@
<script src="../lib/coalesced-set.js"></script>
<script src="../lib/throttle-timer.js"></script>
<script src="../lib/serialized-runner.js"></script>
<script src="../lib/speed-history.js"></script>
<script src="account-submit.js"></script>
<script src="account-status.js"></script>
<script src="i18n.js"></script>
+114 -87
View File
@@ -1614,30 +1614,6 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
.empty-state { color: var(--text-dim); text-align: center; padding: 40px; font-size: 14px; }
/* Statusbar */
.statusbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 4px 16px;
background: #0a0a14;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
flex-shrink: 0;
}
.sb-separator { color: var(--text-dim); }
.sb-speed { color: var(--link-color); font-weight: 500; }
.sb-total { color: var(--text); }
.sb-eta,
.sb-connections,
.sb-queue-count,
.sb-remaining-count,
.sb-progress-count,
.sb-error-count { color: var(--text-muted); }
.sb-state { flex: 1; }
/* Copy toast */
.copy-toast {
position: fixed;
@@ -1890,6 +1866,40 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
flex: 0 0 auto;
}
.upload-speed-sparkline {
width: 184px;
height: 30px;
display: grid;
grid-template-columns: minmax(0, 1fr) 62px;
align-items: center;
gap: 7px;
padding: 3px 8px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-input);
transition: opacity .16s ease;
}
.upload-speed-sparkline.is-hidden {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
.upload-speed-sparkline canvas {
width: 100%;
height: 22px;
display: block;
}
.upload-speed-sparkline strong {
color: var(--text);
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
.header-spacer {
flex: 1 1 auto;
min-width: 8px;
@@ -2525,6 +2535,61 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
border-top: 1px solid var(--border);
}
.upload-sidebar-lower {
display: grid;
gap: 12px;
margin-top: auto;
}
.upload-sidebar-lower .view-sidebar-section {
margin-top: 0;
}
.upload-telemetry {
display: grid;
gap: 6px;
padding: 12px 10px 0;
border-top: 1px solid var(--border);
}
.upload-telemetry-row {
min-height: 15px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--text-dim);
font-size: 11px;
line-height: 15px;
}
.upload-telemetry-value {
min-width: 58px;
color: var(--text);
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: right;
white-space: nowrap;
}
.upload-rolling-value {
height: 15px;
overflow: hidden;
position: relative;
}
.upload-rolling-value > span {
width: 100%;
height: 15px;
display: block;
}
.upload-rolling-value > .upload-rolling-outgoing,
.upload-rolling-value > .upload-rolling-incoming {
position: absolute;
inset: 0;
}
.view-sidebar-summary {
display: flex;
align-items: center;
@@ -3386,66 +3451,6 @@ input[type="checkbox"] {
background: var(--bg-active);
}
.statusbar {
min-height: 28px;
gap: 12px;
padding: 3px 10px;
border-top: 1px solid var(--border);
background: var(--bg-primary);
flex-wrap: nowrap;
overflow-x: auto;
}
.statusbar > span:not(.sb-separator) {
min-height: 18px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--text-muted);
white-space: nowrap;
}
.statusbar > span:not(.sb-separator)::before {
content: "";
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--text-dim);
flex: 0 0 auto;
}
.statusbar .sb-state {
flex: 1 1 auto;
}
.statusbar > span.sb-state::before,
.statusbar > span.sb-done-count::before {
background: #43d17b;
box-shadow: 0 0 6px rgba(67, 209, 123, .55);
}
.statusbar .sb-speed::before,
.statusbar .sb-progress-count::before {
background: var(--accent);
}
.statusbar .sb-error-count::before {
background: var(--danger);
}
.statusbar .sb-remaining-count::before,
.statusbar .sb-eta::before {
background: var(--warning);
}
.sb-separator {
display: none;
}
.copy-toast {
bottom: 44px;
border-radius: 6px;
@@ -3489,6 +3494,10 @@ input[type="checkbox"] {
text-overflow: ellipsis;
}
.upload-speed-sparkline {
width: 150px;
}
.stats-grid {
gap: 12px;
}
@@ -3513,6 +3522,17 @@ input[type="checkbox"] {
display: none;
}
.upload-sidebar-lower {
display: none;
}
.upload-speed-sparkline {
width: 126px;
grid-template-columns: minmax(0, 1fr) 56px;
gap: 4px;
padding-inline: 6px;
}
.view-sidebar-navigation {
gap: 6px;
}
@@ -3607,6 +3627,16 @@ input[type="checkbox"] {
width: 32px;
}
.upload-speed-sparkline {
width: 64px;
display: flex;
justify-content: flex-end;
}
.upload-speed-sparkline canvas {
display: none;
}
.settings-layout {
height: 100%;
grid-template-columns: 176px minmax(0, 1fr);
@@ -3685,9 +3715,6 @@ input[type="checkbox"] {
min-width: 120px;
}
.statusbar > span:not(.sb-state):not(.sb-speed):not(.sb-error-count) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
+2
View File
@@ -41,6 +41,7 @@ const sourceFiles = [
'lib/serialized-runner.js',
'lib/settings-backup.js',
'lib/settings-import-gate.js',
'lib/speed-history.js',
'lib/startup-renderer.js',
'lib/stats.js',
'lib/support-bundle.js',
@@ -110,6 +111,7 @@ const sourceFiles = [
'tests/serialized-runner.test.js',
'tests/settings-backup.test.js',
'tests/settings-import-gate.test.js',
'tests/speed-history.test.js',
'tests/startup-renderer.test.js',
'tests/stats.test.js',
'tests/support-bundle.test.js',
+36
View File
@@ -0,0 +1,36 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { updateSpeedHistory } = require('../lib/speed-history');
test('speed history smooths rising and falling samples independently', () => {
const state = { display: 0, history: [] };
updateSpeedHistory(state, 1000);
assert.equal(state.display, 450);
updateSpeedHistory(state, 0);
assert.equal(state.display, 396);
assert.deepEqual(state.history, [450, 396]);
});
test('speed history stays bounded and discards its oldest samples', () => {
const state = { display: 0, history: [] };
updateSpeedHistory(state, 100, 3);
updateSpeedHistory(state, 200, 3);
updateSpeedHistory(state, 300, 3);
updateSpeedHistory(state, 400, 3);
assert.equal(state.history.length, 3);
assert.equal(state.history.at(-1), state.display);
assert.ok(state.history[0] > 0);
});
test('speed history normalizes invalid and negative target values', () => {
const state = { display: 50, history: [] };
updateSpeedHistory(state, -100);
updateSpeedHistory(state, Number.NaN);
assert.deepEqual(state.history, [44, 38.72]);
});
+57 -22
View File
@@ -164,7 +164,7 @@ setTimeout(async () => {
await captureVisual('00-language-picker.png');
await wc.executeJavaScript('document.getElementById("upload-tab").click()');
const unchangedValues = await wc.executeJavaScript('(() => { setUiLanguage("de"); const nodes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); let node = walker.nextNode(); while (node) { if (node.nodeValue.trim()) nodes.push({ node, source: node.nodeValue.trim() }); node = walker.nextNode(); } const attributes = [...document.querySelectorAll("[title],[aria-label],[placeholder],[data-tooltip]")].flatMap(element => ["title", "aria-label", "placeholder", "data-tooltip"].filter(name => element.hasAttribute(name)).map(name => ({ element, name, source: element.getAttribute(name).trim() }))); setUiLanguage("en"); const unchanged = nodes.filter(entry => entry.source === entry.node.nodeValue.trim()).map(entry => entry.source); unchanged.push(...attributes.filter(entry => entry.source === entry.element.getAttribute(entry.name).trim()).map(entry => entry.source)); return [...new Set(unchanged.filter(value => /[A-Za-zÄÖÜäöüß]{2}/.test(value)))].sort(); })()');
const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI-HOSTER UPLOAD', 'Markdown', 'Multi-Hoster Upload', 'OK', 'Plaintext', 'Port', 'Server', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'mp4,mkv,avi']);
const neutralUiValues = new Set(['0 kB/s', 'Accounts', 'BBCode', 'CSV', 'Changelog', 'ETA', 'ETA --:--', 'FileUploader Log', 'HTML', 'JSON', 'Label (optional)', 'Link', 'Log', 'Logs & Support', 'MB/s', 'MHU2-…', 'MULTI-HOSTER UPLOAD', 'Markdown', 'Multi-Hoster Upload', 'OK', 'Plaintext', 'Port', 'Server', 'Status', 'Update', 'Upload', 'Uploads', 'Verbose Logging', 'Webhook', 'account-rotation.log', 'debug.log', 'doodstream-debug.log', 'fileuploader.log', 'mp4,mkv,avi']);
const unexpectedUnchangedValues = unchangedValues.filter(value => !neutralUiValues.has(value) && !value.includes('Multi-Hoster-Uploader'));
if (process.env.AUDIT_I18N_UNCHANGED === '1' || unexpectedUnchangedValues.length) console.log('Unchanged i18n values: ' + JSON.stringify(unchangedValues, null, 2));
check('Every mounted human-facing value is translated or explicitly language-neutral', unexpectedUnchangedValues.length === 0);
@@ -176,9 +176,13 @@ setTimeout(async () => {
check('English default leaves no German interface copy behind', englishResidue.length === 0);
const englishSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History');
const englishTelemetryLabels = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Running|Completed|Failed|Speed|ETA');
const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()');
check('English labels fit every main view without horizontal overflow', englishLayoutFits === true);
await wc.executeJavaScript('document.querySelector(".tab[data-view=accounts]")?.click()');
const hiddenAccountSparkline = await wc.executeJavaScript('document.getElementById("uploadSpeedSparkline")?.classList.contains("is-hidden")');
check('Upload speed sparkline stays reserved but hidden outside Uploads', hiddenAccountSparkline === true);
const englishEmptyAccountHosterLabel = await wc.executeJavaScript('(() => { const container = document.getElementById("accountsSidebarHosters"); return [container?.getAttribute("data-empty-label"), getComputedStyle(container, "::after").content].join("|"); })()');
check('Empty account hoster sidebar renders its localized English label', englishEmptyAccountHosterLabel === 'No hosts yet|"No hosts yet"');
await wc.executeJavaScript('document.querySelector(".tab[data-view=upload]")?.click()');
@@ -220,8 +224,8 @@ setTimeout(async () => {
const bodyBackground = await wc.executeJavaScript('getComputedStyle(document.body).backgroundColor');
check('App shell uses the dark reference canvas', bodyBackground === 'rgb(15, 15, 15)');
const shellDensity = await wc.executeJavaScript('(() => ({ header: document.querySelector(".app-header")?.getBoundingClientRect().height, status: document.getElementById("statusbar")?.getBoundingClientRect().height, sidebar: document.querySelector("#upload-view > .view-sidebar")?.getBoundingClientRect().width }))()');
check('App shell keeps a compact desktop density', shellDensity.header <= 50 && shellDensity.status <= 30 && shellDensity.sidebar <= 230);
const shellDensity = await wc.executeJavaScript('(() => ({ header: document.querySelector(".app-header")?.getBoundingClientRect().height, sidebar: document.querySelector("#upload-view > .view-sidebar")?.getBoundingClientRect().width }))()');
check('App shell keeps a compact desktop density', shellDensity.header <= 50 && shellDensity.sidebar <= 230);
const tabLabels = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.textContent.trim()).join("|")');
check('Current tab labels present', tabLabels === 'Upload|Accounts|Einstellungen|Verlauf');
@@ -272,10 +276,8 @@ setTimeout(async () => {
const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled');
check('Start button disabled initially', startDisabled === true);
const sbState = await wc.executeJavaScript('document.getElementById("sbState")?.textContent');
check('Statusbar: Bereit', sbState === 'Bereit');
const readyDotColor = await wc.executeJavaScript('getComputedStyle(document.getElementById("sbState"), "::before").backgroundColor');
check('Ready status uses a green indicator', readyDotColor === 'rgb(67, 209, 123)');
const legacyStatusbar = await wc.executeJavaScript('document.getElementById("statusbar")');
check('Legacy bottom statusbar is removed', legacyStatusbar === null);
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
check('Version label present', version && version.startsWith('v'));
@@ -295,8 +297,14 @@ setTimeout(async () => {
const localizedRecentTabs = await wc.executeJavaScript('[...document.querySelectorAll(".recent-tab")].map(el => el.textContent.trim()).join("|")');
check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik');
const localizedStatusbar = await wc.executeJavaScript('["sbConnections", "sbQueueCount", "sbRemainingCount", "sbInProgressCount", "sbDoneCount", "sbErrorCount"].map(id => document.getElementById(id)?.textContent).join("|")');
check('Statusbar labels are consistently German', localizedStatusbar === 'Verbindungen 0|Gesamt 0|Verbleibend 0|Läuft 0|Fertig 0|Fehler 0');
const localizedTelemetry = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
check('Upload telemetry exposes all eight German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Läuft|Fertig|Fehler|Geschwindigkeit|ETA');
const initialTelemetryValues = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-value")].map(el => el.getAttribute("aria-label") || el.textContent.trim()).join("|")');
check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0|0|0|0 B/s|--:--');
const speedSparklineState = await wc.executeJavaScript('(() => { const widget = document.getElementById("uploadSpeedSparkline"); const canvas = document.getElementById("uploadSpeedCanvas"); const rect = canvas?.getBoundingClientRect(); return [Boolean(widget), widget?.classList.contains("is-hidden"), rect?.width > 0, rect?.height > 0, document.getElementById("uploadSpeedValue")?.textContent].join("|"); })()');
check('Upload header exposes the visible speed sparkline', speedSparklineState === 'true|false|true|true|0 B/s');
const toolbarLabels = await wc.executeJavaScript('[...document.querySelectorAll("#queueCommandBar .toolbar-btn")].map(el => el.getAttribute("aria-label")).join("|")');
check('Upload toolbar actions have German accessible names', toolbarLabels === 'Alle Uploads starten|Ausgewählte Uploads starten|Ausgewählte Datei erneut hochladen|Ausgewählten Upload abbrechen|Aktive Uploads beenden und stoppen|Alle Uploads abbrechen|Ganz nach oben|Nach oben|Nach unten|Ganz nach unten');
@@ -304,8 +312,35 @@ setTimeout(async () => {
const uploadWorkspaceLayout = await wc.executeJavaScript('(() => { const view = document.getElementById("upload-view"); const sidebar = view?.querySelector(":scope > .view-sidebar"); const main = view?.querySelector(":scope > .view-main"); if (!sidebar || !main) return false; const sidebarRect = sidebar.getBoundingClientRect(); const mainRect = main.getBoundingClientRect(); return sidebarRect.width > 0 && mainRect.width > 0 && sidebarRect.right <= mainRect.left; })()');
check('Upload view separates sidebar and main workspace', uploadWorkspaceLayout === true);
const uploadSidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#upload-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#upload-view .view-sidebar-section")?.getBoundingClientRect(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55 && document.getElementById("uploadSidebarAccountsCount")); })()');
check('Upload sidebar keeps availability information in its lower area', uploadSidebarInformation === true);
const uploadSidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#upload-view > .view-sidebar")?.getBoundingClientRect(); const availability = document.getElementById("uploadAvailability")?.getBoundingClientRect(); const telemetry = document.getElementById("uploadTelemetry")?.getBoundingClientRect(); return Boolean(sidebar && availability && telemetry && availability.bottom <= telemetry.top && telemetry.bottom <= sidebar.bottom + 1 && document.getElementById("uploadSidebarAccountsCount")); })()');
check('Upload sidebar stacks availability above bottom telemetry', uploadSidebarInformation === true);
const telemetryUpdate = await wc.executeJavaScript(\`(() => {
queueJobs = [
{ id: 'telemetry-running', status: 'uploading', bytesTotal: 4096, bytesUploaded: 1024 },
{ id: 'telemetry-waiting', status: 'queued', bytesTotal: 2048, bytesUploaded: 0 },
{ id: 'telemetry-done', status: 'done', bytesTotal: 1024, bytesUploaded: 1024 },
{ id: 'telemetry-error', status: 'error', bytesTotal: 1024, bytesUploaded: 0 }
];
_sessionDoneCount = 7;
_sessionErrorCount = 2;
lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 2, activeJobs: 1, state: 'uploading' };
updateStatusBar();
const total = document.getElementById('uploadTelemetryTotal');
const rolling = total?.querySelectorAll(':scope > span').length;
return {
values: ['Total', 'Connections', 'Remaining', 'Running', 'Completed', 'Failed', 'Speed', 'Eta'].map(key => {
const element = document.getElementById('uploadTelemetry' + key);
return element?.getAttribute('aria-label') || element?.textContent.trim();
}).join('|'),
rolling,
direction: total?.dataset.direction
};
})()\`);
check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|1|7|2|2 kB/s|00:03');
check('Changing integer telemetry rolls vertically', telemetryUpdate.rolling === 2 && telemetryUpdate.direction === 'up');
await new Promise(resolve => setTimeout(resolve, 360));
await wc.executeJavaScript('queueJobs = []; _sessionDoneCount = 0; _sessionErrorCount = 0; lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 0, activeJobs: 0, state: "idle" }; updateStatusBar();');
const uploadSidebarBorders = await wc.executeJavaScript('(() => { const items = [...document.querySelectorAll("#upload-view .view-sidebar-item")]; const inactive = items.filter(item => !item.classList.contains("active")); const inactiveBorders = inactive.every(item => { const style = getComputedStyle(item); return style.borderTopWidth === "1px" && style.borderTopStyle === "solid" && !style.borderTopColor.endsWith(", 0)"); }); const active = items.find(item => item.classList.contains("active")); const indicator = document.querySelector("#upload-view .view-sidebar-indicator"); const activeTransparent = active && getComputedStyle(active).backgroundColor === "rgba(0, 0, 0, 0)"; const indicatorVisible = indicator && getComputedStyle(indicator).borderTopWidth === "1px" && !getComputedStyle(indicator).borderTopColor.endsWith(", 0)"); return [items.length, inactiveBorders, activeTransparent, indicatorVisible].join("|"); })()');
check('Upload sidebar filters keep individual borders and move the active surface to the indicator', uploadSidebarBorders === '5|true|true|true');
@@ -326,8 +361,8 @@ setTimeout(async () => {
check('Upload sidebar indicator remains visibly in motion while gliding up', uploadIndicatorMovingUp === true);
await new Promise(resolve => setTimeout(resolve, 170));
const uploadFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("upload-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
check('Upload view and statusbar fit inside the viewport', uploadFrameFit === true);
const uploadFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("upload-view")?.getBoundingClientRect(); return Boolean(view && view.bottom <= window.innerHeight + 1); })()');
check('Upload view fits inside the viewport', uploadFrameFit === true);
await captureVisual('01-upload.png');
@@ -478,8 +513,8 @@ setTimeout(async () => {
const accountSidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#accounts-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#accounts-view .view-sidebar-hoster-section")?.getBoundingClientRect(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55); })()');
check('Account sidebar keeps hoster information in its lower area', accountSidebarInformation === true);
const accountsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("accounts-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
check('Accounts view and statusbar fit inside the viewport', accountsFrameFit === true);
const accountsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("accounts-view")?.getBoundingClientRect(); return Boolean(view && view.bottom <= window.innerHeight + 1); })()');
check('Accounts view fits inside the viewport', accountsFrameFit === true);
const accountHeaderControlHeights = await wc.executeJavaScript('(() => [document.getElementById("accountsRunHealthCheckBtn"), document.querySelector(".accounts-auto-check"), document.getElementById("addAccountBtn")].map(element => element?.getBoundingClientRect().height || 0))()');
check('Accounts header actions share one rendered height', accountHeaderControlHeights.every(height => height > 0 && Math.abs(height - accountHeaderControlHeights[0]) <= 0.5));
@@ -865,8 +900,8 @@ setTimeout(async () => {
const settingsReadingWidth = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); if (!activePage) return 0; return activePage.getBoundingClientRect().width; })()');
check('Active settings page keeps a readable content width', settingsReadingWidth > 0 && settingsReadingWidth <= 760);
const settingsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("settings-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
check('Settings view and statusbar fit inside the viewport', settingsFrameFit === true);
const settingsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("settings-view")?.getBoundingClientRect(); return Boolean(view && view.bottom <= window.innerHeight + 1); })()');
check('Settings view fits inside the viewport', settingsFrameFit === true);
if (process.env.MHU_SETTINGS_SCREENSHOT) {
const screenshotPage = process.env.MHU_SETTINGS_SCREENSHOT_PAGE;
@@ -1352,8 +1387,8 @@ setTimeout(async () => {
historyFixture = [{ timestamp: '2026-08-10T10:00:00.000Z', files: [{ name: 'ok.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/ok' }] }, { name: 'bad.bin', results: [{ status: 'error', hoster: 'byse.sx', error: 'Zugang abgelehnt' }] }, { name: 'stopped.bin', results: [{ status: 'aborted', hoster: 'doodstream.com' }] }] }];
await wc.executeJavaScript('loadHistory().then(() => { window.confirm = window.__historyOriginalConfirm; delete window.__historyOriginalConfirm; })');
const historyFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()');
check('History view and statusbar fit inside the viewport', historyFrameFit === true);
const historyFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view")?.getBoundingClientRect(); return Boolean(view && view.bottom <= window.innerHeight + 1); })()');
check('History view fits inside the viewport', historyFrameFit === true);
await captureVisual('04-history.png');
@@ -1491,7 +1526,7 @@ setTimeout(async () => {
const close = document.getElementById('updateCloseBtn');
const install = document.getElementById('installUpdateBtn');
const initialFocus = document.activeElement?.id;
const backgroundInert = document.querySelector('.app-header')?.inert === true && document.querySelector('.view.active')?.inert === true && document.getElementById('statusbar')?.inert === true;
const backgroundInert = document.querySelector('.app-header')?.inert === true && document.querySelector('.view.active')?.inert === true;
install.focus();
install.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
const forwardFocus = document.activeElement?.id;
@@ -1516,8 +1551,8 @@ setTimeout(async () => {
const updateHeaderHint = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.textContent?.trim(), button?.getAttribute("aria-label"), button?.dataset.tooltip].join("|"); })()');
check('Available update gives the header action a matching hint', updateHeaderHint === 'Update verfügbar|Update v9.9.9 verfügbar. Klicken zum Installieren.|Update v9.9.9 verfügbar. Klicken zum Installieren.');
const updateDialogDismissed = await wc.executeJavaScript('document.getElementById("dismissUpdateBtn")?.click(); (() => { const overlay = document.getElementById("updateBanner"); return [overlay?.style.display, overlay?.getAttribute("aria-hidden"), document.activeElement?.id, document.querySelector(".app-header")?.inert, document.querySelector(".view.active")?.inert, document.getElementById("statusbar")?.inert].join("|"); })()');
check('Update dialog closes and restores focus and background', updateDialogDismissed === 'none|true|headerUpdateBtn|false|false|false');
const updateDialogDismissed = await wc.executeJavaScript('document.getElementById("dismissUpdateBtn")?.click(); (() => { const overlay = document.getElementById("updateBanner"); return [overlay?.style.display, overlay?.getAttribute("aria-hidden"), document.activeElement?.id, document.querySelector(".app-header")?.inert, document.querySelector(".view.active")?.inert].join("|"); })()');
check('Update dialog closes and restores focus and background', updateDialogDismissed === 'none|true|headerUpdateBtn|false|false');
const busyUpdateState = await wc.executeJavaScript(\`(() => {
showUpdateBanner({ remoteVersion: '9.9.9' });