From 4485f6b1b25c0aac985fdf20e297f3da8c3f4da2 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:23:57 +0200 Subject: [PATCH] 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. --- lib/speed-history.js | 20 +++ renderer/app.js | 159 ++++++++++++++++++++--- renderer/i18n.js | 6 + renderer/index.html | 52 ++++---- renderer/styles.css | 201 +++++++++++++++++------------- scripts/verify-public-release.mjs | 2 + tests/speed-history.test.js | 36 ++++++ tests/ui-smoke.js | 79 ++++++++---- 8 files changed, 397 insertions(+), 158 deletions(-) create mode 100644 lib/speed-history.js create mode 100644 tests/speed-history.test.js diff --git a/lib/speed-history.js b/lib/speed-history.js new file mode 100644 index 0000000..68f0143 --- /dev/null +++ b/lib/speed-history.js @@ -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); diff --git a/renderer/app.js b/renderer/app.js index 8191b5f..f083e38 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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); } diff --git a/renderer/i18n.js b/renderer/i18n.js index cdb1704..c600f7d 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -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'], diff --git a/renderer/index.html b/renderer/index.html index 59dce15..10abc61 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -54,6 +54,10 @@
+
+ + 0 B/s +
-
- -
- Bereite Accounts - 0 +
+
+ +
+ Bereite Accounts + 0 +
+
Keine Upload-Ziele ausgewählt
+
+
+
Gesamt0
+
Verbindungen0
+
Verbleibend0
+
Läuft0
+
Fertig0
+
Fehler0
+
Geschwindigkeit0 B/s
+
ETA--:--
-
Keine Upload-Ziele ausgewählt
-
Dateien ablegen, Ziele wählen und Uploads zentral steuern.
@@ -604,28 +619,6 @@
Entfernen
-
- Bereit - | - 0 kB/s - | - 0 B - | - ETA --:-- - | - Verbindungen 0 - | - Gesamt 0 - | - Verbleibend 0 - | - Läuft 0 - | - Fertig 0 - | - Fehler 0 -
-