feat: show update download telemetry
Calculate download throughput and remaining time from verified installer bytes and display downloaded size, total size, speed, and ETA beneath the progress track while retaining the last values after cancellation.
This commit is contained in:
+8
-1
@@ -241,6 +241,7 @@ async function prepareUpdate(onProgress, options = {}) {
|
|||||||
activeAbort = new AbortController();
|
activeAbort = new AbortController();
|
||||||
const signal = activeAbort.signal;
|
const signal = activeAbort.signal;
|
||||||
const fetchImpl = options.fetchImpl || fetch;
|
const fetchImpl = options.fetchImpl || fetch;
|
||||||
|
const now = typeof options.now === 'function' ? options.now : Date.now;
|
||||||
let stagedInstallerPath = '';
|
let stagedInstallerPath = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -284,6 +285,7 @@ async function prepareUpdate(onProgress, options = {}) {
|
|||||||
const totalBytes = manifest.size;
|
const totalBytes = manifest.size;
|
||||||
let downloadedBytes = 0;
|
let downloadedBytes = 0;
|
||||||
let lastReportedPercent = -1;
|
let lastReportedPercent = -1;
|
||||||
|
const downloadStartedAt = now();
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
|
|
||||||
const DOWNLOAD_STALL_MS = 45000;
|
const DOWNLOAD_STALL_MS = 45000;
|
||||||
@@ -312,12 +314,17 @@ async function prepareUpdate(onProgress, options = {}) {
|
|||||||
downloadedBytes += value.length;
|
downloadedBytes += value.length;
|
||||||
const percent = Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100)));
|
const percent = Math.max(0, Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100)));
|
||||||
if (onProgress && percent !== lastReportedPercent) {
|
if (onProgress && percent !== lastReportedPercent) {
|
||||||
|
const elapsedMs = Math.max(1, now() - downloadStartedAt);
|
||||||
|
const bytesPerSecond = Math.round((downloadedBytes * 1000) / elapsedMs);
|
||||||
|
const etaSeconds = bytesPerSecond > 0 ? Math.ceil(Math.max(0, totalBytes - downloadedBytes) / bytesPerSecond) : null;
|
||||||
lastReportedPercent = percent;
|
lastReportedPercent = percent;
|
||||||
onProgress({
|
onProgress({
|
||||||
stage: 'downloading',
|
stage: 'downloading',
|
||||||
percent,
|
percent,
|
||||||
bytesDownloaded: downloadedBytes,
|
bytesDownloaded: downloadedBytes,
|
||||||
bytesTotal: totalBytes
|
bytesTotal: totalBytes,
|
||||||
|
bytesPerSecond,
|
||||||
|
etaSeconds
|
||||||
});
|
});
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7497,6 +7497,7 @@ function showUpdateBanner(info) {
|
|||||||
installButton.textContent = 'Jetzt installieren';
|
installButton.textContent = 'Jetzt installieren';
|
||||||
}
|
}
|
||||||
_setUpdateProgress(0, 'Bereit zum Download', 'ready');
|
_setUpdateProgress(0, 'Bereit zum Download', 'ready');
|
||||||
|
_setUpdateProgressDetails(null);
|
||||||
_setUpdateDialogBusy(false);
|
_setUpdateDialogBusy(false);
|
||||||
_syncHeaderUpdateState();
|
_syncHeaderUpdateState();
|
||||||
_setUpdateDialogVisible(true);
|
_setUpdateDialogVisible(true);
|
||||||
@@ -7517,6 +7518,7 @@ function handleUpdateProgress(data) {
|
|||||||
_updateInstallBusy = true;
|
_updateInstallBusy = true;
|
||||||
_setUpdateDialogBusy(true, true);
|
_setUpdateDialogBusy(true, true);
|
||||||
_setUpdateProgress(percent, `Download ${percent}%`, 'downloading');
|
_setUpdateProgress(percent, `Download ${percent}%`, 'downloading');
|
||||||
|
_setUpdateProgressDetails(progress);
|
||||||
if (button) button.textContent = `Download ${percent}%`;
|
if (button) button.textContent = `Download ${percent}%`;
|
||||||
} else if (progress.stage === 'verifying') {
|
} else if (progress.stage === 'verifying') {
|
||||||
_updateInstallBusy = true;
|
_updateInstallBusy = true;
|
||||||
@@ -7708,6 +7710,22 @@ function _setUpdateProgress(percent, text, state = 'ready') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _setUpdateProgressDetails(progress) {
|
||||||
|
const details = document.getElementById('updateProgressDetails');
|
||||||
|
if (!details) return;
|
||||||
|
const downloaded = Math.max(0, Number(progress?.bytesDownloaded) || 0);
|
||||||
|
const total = Math.max(0, Number(progress?.bytesTotal) || 0);
|
||||||
|
if (total <= 0) {
|
||||||
|
details.textContent = '';
|
||||||
|
details.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bytesPerSecond = Math.max(0, Number(progress?.bytesPerSecond) || 0);
|
||||||
|
const etaSeconds = Number.isFinite(Number(progress?.etaSeconds)) ? Math.max(0, Number(progress.etaSeconds)) : 0;
|
||||||
|
details.textContent = `${formatSize(downloaded)} / ${formatSize(total)} · ${formatSize(bytesPerSecond)}/s · ETA ${formatTime(etaSeconds)}`;
|
||||||
|
details.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function installKnownUpdate() {
|
async function installKnownUpdate() {
|
||||||
if (_updateInstallBusy) return;
|
if (_updateInstallBusy) return;
|
||||||
if (!_knownUpdateInfo || !_knownUpdateInfo.available) {
|
if (!_knownUpdateInfo || !_knownUpdateInfo.available) {
|
||||||
@@ -7717,6 +7735,7 @@ async function installKnownUpdate() {
|
|||||||
_updateInstallBusy = true;
|
_updateInstallBusy = true;
|
||||||
_setUpdateDialogBusy(true, true);
|
_setUpdateDialogBusy(true, true);
|
||||||
_setUpdateProgress(0, 'Download 0%', 'downloading');
|
_setUpdateProgress(0, 'Download 0%', 'downloading');
|
||||||
|
_setUpdateProgressDetails(null);
|
||||||
const message = document.getElementById('updateMessage');
|
const message = document.getElementById('updateMessage');
|
||||||
const button = document.getElementById('installUpdateBtn');
|
const button = document.getElementById('installUpdateBtn');
|
||||||
if (message) message.hidden = true;
|
if (message) message.hidden = true;
|
||||||
|
|||||||
+4
-1
@@ -161,7 +161,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="update-progress" aria-live="polite">
|
<div class="update-progress" aria-live="polite">
|
||||||
<div class="update-progress-track"><span id="updateProgressBar" role="progressbar" aria-label="Update-Fortschritt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%"></span></div>
|
<div class="update-progress-track"><span id="updateProgressBar" role="progressbar" aria-label="Update-Fortschritt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-valuetext="0%"></span></div>
|
||||||
<span id="updateProgressText"></span>
|
<div class="update-progress-footer">
|
||||||
|
<span id="updateProgressDetails" hidden></span>
|
||||||
|
<span id="updateProgressText"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="update-dialog-actions">
|
<div class="update-dialog-actions">
|
||||||
<button class="btn btn-secondary" id="dismissUpdateBtn">Abbrechen</button>
|
<button class="btn btn-secondary" id="dismissUpdateBtn">Abbrechen</button>
|
||||||
|
|||||||
+20
-1
@@ -2515,10 +2515,29 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; }
|
|||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.update-progress-footer {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateProgressDetails {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
#updateProgressText {
|
#updateProgressText {
|
||||||
min-width: 34px;
|
min-width: 34px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
justify-self: end;
|
margin-left: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
|
|||||||
@@ -176,4 +176,5 @@ test('header occupies its final geometry before asynchronous initialization', ()
|
|||||||
assert.match(css, /\.header-update-button\.update-available:hover\s*\{[^}]*background:\s*var\(--success-end\);[^}]*color:\s*#000;/su);
|
assert.match(css, /\.header-update-button\.update-available:hover\s*\{[^}]*background:\s*var\(--success-end\);[^}]*color:\s*#000;/su);
|
||||||
assert.match(css, /\.update-dialog\s*\{[^}]*width:\s*min\(576px,\s*100%\);/su);
|
assert.match(css, /\.update-dialog\s*\{[^}]*width:\s*min\(576px,\s*100%\);/su);
|
||||||
assert.match(css, /\.update-release-notes\s*\{[^}]*height:\s*min\(220px,\s*42vh\);/su);
|
assert.match(css, /\.update-release-notes\s*\{[^}]*height:\s*min\(220px,\s*42vh\);/su);
|
||||||
|
assert.match(html, /class="update-progress-footer"[\s\S]*id="updateProgressDetails"[\s\S]*id="updateProgressText"/u);
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-1
@@ -2460,7 +2460,7 @@ setTimeout(async () => {
|
|||||||
|
|
||||||
const busyUpdateState = await wc.executeJavaScript(\`(() => {
|
const busyUpdateState = await wc.executeJavaScript(\`(() => {
|
||||||
showUpdateBanner({ remoteVersion: '9.9.9' });
|
showUpdateBanner({ remoteVersion: '9.9.9' });
|
||||||
handleUpdateProgress({ stage: 'downloading', percent: 50 });
|
handleUpdateProgress({ stage: 'downloading', percent: 50, bytesDownloaded: 40 * 1024 * 1024, bytesTotal: 100 * 1024 * 1024, bytesPerSecond: 8 * 1024 * 1024, etaSeconds: 8 });
|
||||||
const overlay = document.getElementById('updateBanner');
|
const overlay = document.getElementById('updateBanner');
|
||||||
document.getElementById('updateCloseBtn').click();
|
document.getElementById('updateCloseBtn').click();
|
||||||
document.getElementById('dismissUpdateBtn').click();
|
document.getElementById('dismissUpdateBtn').click();
|
||||||
@@ -2480,6 +2480,7 @@ setTimeout(async () => {
|
|||||||
messageText: document.getElementById('updateMessage').textContent,
|
messageText: document.getElementById('updateMessage').textContent,
|
||||||
progressLabel: progress.getAttribute('aria-label'),
|
progressLabel: progress.getAttribute('aria-label'),
|
||||||
progressText: progress.getAttribute('aria-valuetext'),
|
progressText: progress.getAttribute('aria-valuetext'),
|
||||||
|
progressDetails: document.getElementById('updateProgressDetails').textContent,
|
||||||
progressState: progress.dataset.state,
|
progressState: progress.dataset.state,
|
||||||
progressColor: getComputedStyle(progress).backgroundColor,
|
progressColor: getComputedStyle(progress).backgroundColor,
|
||||||
progressTrackWidth: progress.parentElement.getBoundingClientRect().width,
|
progressTrackWidth: progress.parentElement.getBoundingClientRect().width,
|
||||||
@@ -2489,6 +2490,7 @@ setTimeout(async () => {
|
|||||||
})()\`);
|
})()\`);
|
||||||
check('Busy update keeps its progress dialog open with a dangerous download cancellation action', busyUpdateState.display === 'flex' && busyUpdateState.hidden === 'false' && busyUpdateState.closeDisabled === true && busyUpdateState.dismissDisabled === false && busyUpdateState.dismissText === 'Download abbrechen' && busyUpdateState.dismissDanger === true && busyUpdateState.headerHidden === false);
|
check('Busy update keeps its progress dialog open with a dangerous download cancellation action', busyUpdateState.display === 'flex' && busyUpdateState.hidden === 'false' && busyUpdateState.closeDisabled === true && busyUpdateState.dismissDisabled === false && busyUpdateState.dismissText === 'Download abbrechen' && busyUpdateState.dismissDanger === true && busyUpdateState.headerHidden === false);
|
||||||
check('Update progress exposes an accessible live value', busyUpdateState.progressLabel === 'Update-Fortschritt' && busyUpdateState.progressText === 'Download 50%');
|
check('Update progress exposes an accessible live value', busyUpdateState.progressLabel === 'Update-Fortschritt' && busyUpdateState.progressText === 'Download 50%');
|
||||||
|
check('Update progress shows downloaded size, total size, speed, and ETA', busyUpdateState.progressDetails === '40.0 MB / 100.0 MB · 8.0 MB/s · ETA 00:08');
|
||||||
check('Update download progress is green, wide, and places its status below the line', busyUpdateState.progressState === 'downloading' && busyUpdateState.progressColor === 'rgb(117, 211, 155)' && busyUpdateState.progressTrackWidth >= 390 && busyUpdateState.progressTextTop >= busyUpdateState.progressTrackBottom);
|
check('Update download progress is green, wide, and places its status below the line', busyUpdateState.progressState === 'downloading' && busyUpdateState.progressColor === 'rgb(117, 211, 155)' && busyUpdateState.progressTrackWidth >= 390 && busyUpdateState.progressTextTop >= busyUpdateState.progressTrackBottom);
|
||||||
check('Busy update preserves the available version subtitle while progress stays below the bar', busyUpdateState.messageHidden === false && busyUpdateState.messageText === 'Update v9.9.9 verfügbar');
|
check('Busy update preserves the available version subtitle while progress stays below the bar', busyUpdateState.messageHidden === false && busyUpdateState.messageText === 'Update v9.9.9 verfügbar');
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ test('buffered installer downloads yield between progress updates so the rendere
|
|||||||
let preparationFinished = false;
|
let preparationFinished = false;
|
||||||
let rendererObservedProgressBeforeFinish = false;
|
let rendererObservedProgressBeforeFinish = false;
|
||||||
let rendererObservationScheduled = false;
|
let rendererObservationScheduled = false;
|
||||||
|
let nowMs = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prepareUpdate(value => {
|
await prepareUpdate(value => {
|
||||||
@@ -121,6 +122,7 @@ test('buffered installer downloads yield between progress updates so the rendere
|
|||||||
latestYmlUrl: 'https://update.invalid/latest.yml'
|
latestYmlUrl: 'https://update.invalid/latest.yml'
|
||||||
},
|
},
|
||||||
tempDir,
|
tempDir,
|
||||||
|
now: () => nowMs,
|
||||||
fetchImpl: async url => url.endsWith('latest.yml')
|
fetchImpl: async url => url.endsWith('latest.yml')
|
||||||
? {
|
? {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -132,9 +134,12 @@ test('buffered installer downloads yield between progress updates so the rendere
|
|||||||
status: 200,
|
status: 200,
|
||||||
body: {
|
body: {
|
||||||
getReader: () => ({
|
getReader: () => ({
|
||||||
read: async () => readerIndex < chunks.length
|
read: async () => {
|
||||||
? { done: false, value: chunks[readerIndex++] }
|
nowMs += 1000;
|
||||||
: { done: true }
|
return readerIndex < chunks.length
|
||||||
|
? { done: false, value: chunks[readerIndex++] }
|
||||||
|
: { done: true };
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +152,10 @@ test('buffered installer downloads yield between progress updates so the rendere
|
|||||||
progress.filter(value => value.stage === 'downloading').map(value => value.percent),
|
progress.filter(value => value.stage === 'downloading').map(value => value.percent),
|
||||||
[25, 50, 75, 100]
|
[25, 50, 75, 100]
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
progress.filter(value => value.stage === 'downloading').map(value => [value.bytesPerSecond, value.etaSeconds]),
|
||||||
|
[[32768, 3], [32768, 2], [32768, 1], [32768, 0]]
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user