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
+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) {