diff --git a/app/modules/speedtest/i18n/de.json b/app/modules/speedtest/i18n/de.json index 573d4d65..1a98cf2b 100644 --- a/app/modules/speedtest/i18n/de.json +++ b/app/modules/speedtest/i18n/de.json @@ -23,5 +23,7 @@ "run_speedtest": "Speedtest starten", "speedtest_running": "Läuft...", "speedtest_complete": "Speedtest abgeschlossen", - "speedtest_timeout": "Speedtest dauert länger als erwartet. Aktualisiere die Seite." + "speedtest_timeout": "Speedtest dauert länger als erwartet. Aktualisiere die Seite.", + "speedtest_empty_title": "Noch keine Speedtest-Ergebnisse", + "speedtest_empty_desc": "Automatische Tests erscheinen hier, sobald der Zeitplan läuft. Du kannst auch einen manuellen Test starten." } diff --git a/app/modules/speedtest/i18n/en.json b/app/modules/speedtest/i18n/en.json index 6496c928..258f14fb 100644 --- a/app/modules/speedtest/i18n/en.json +++ b/app/modules/speedtest/i18n/en.json @@ -23,5 +23,7 @@ "run_speedtest": "Run Speedtest", "speedtest_running": "Running...", "speedtest_complete": "Speedtest complete", - "speedtest_timeout": "Speedtest is taking longer than expected. Refresh to check." + "speedtest_timeout": "Speedtest is taking longer than expected. Refresh to check.", + "speedtest_empty_title": "No speedtest results yet", + "speedtest_empty_desc": "Automated tests will appear here once the schedule runs. You can also start a manual test." } diff --git a/app/modules/speedtest/i18n/es.json b/app/modules/speedtest/i18n/es.json index 03b59513..bc633df9 100644 --- a/app/modules/speedtest/i18n/es.json +++ b/app/modules/speedtest/i18n/es.json @@ -23,5 +23,7 @@ "run_speedtest": "Ejecutar Speedtest", "speedtest_running": "Ejecutando...", "speedtest_complete": "Speedtest completado", - "speedtest_timeout": "El speedtest está tardando más de lo esperado. Actualiza para comprobar." + "speedtest_timeout": "El speedtest está tardando más de lo esperado. Actualiza para comprobar.", + "speedtest_empty_title": "Aún no hay resultados de speedtest", + "speedtest_empty_desc": "Los tests automáticos aparecerán aquí cuando se ejecute la programación. También puedes iniciar un test manual." } diff --git a/app/modules/speedtest/i18n/fr.json b/app/modules/speedtest/i18n/fr.json index 8cfeba38..d370dad0 100644 --- a/app/modules/speedtest/i18n/fr.json +++ b/app/modules/speedtest/i18n/fr.json @@ -23,5 +23,7 @@ "run_speedtest": "Lancer le Speedtest", "speedtest_running": "En cours...", "speedtest_complete": "Speedtest terminé", - "speedtest_timeout": "Le speedtest prend plus de temps que prévu. Actualisez pour vérifier." + "speedtest_timeout": "Le speedtest prend plus de temps que prévu. Actualisez pour vérifier.", + "speedtest_empty_title": "Pas encore de résultats de speedtest", + "speedtest_empty_desc": "Les tests automatiques apparaîtront ici une fois le planning actif. Vous pouvez aussi lancer un test manuel." } diff --git a/app/modules/speedtest/i18n/template.json b/app/modules/speedtest/i18n/template.json index 7680676e..bd4a52cd 100644 --- a/app/modules/speedtest/i18n/template.json +++ b/app/modules/speedtest/i18n/template.json @@ -23,5 +23,7 @@ "run_speedtest": "", "speedtest_running": "", "speedtest_complete": "", - "speedtest_timeout": "" + "speedtest_timeout": "", + "speedtest_empty_title": "", + "speedtest_empty_desc": "" } diff --git a/app/modules/speedtest/static/style.css b/app/modules/speedtest/static/style.css index 83effba3..2f1957ec 100644 --- a/app/modules/speedtest/static/style.css +++ b/app/modules/speedtest/static/style.css @@ -43,11 +43,6 @@ margin-bottom: var(--space-xl, 24px); transition: all 0.2s ease; } -.speedtest-chart-card:hover { - border-color: var(--accent-purple, #a855f7); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(168,85,247,0.15); -} .speedtest-chart-wrap { position: relative; width: 100%; @@ -133,6 +128,37 @@ align-items: center; } +/* Empty state */ +.speedtest-empty-state { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 48px 24px; +} +.speedtest-empty-title { + font-size: 1.1em; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} +.speedtest-empty-desc { + font-size: 0.85em; + color: var(--text-secondary); + max-width: 360px; + line-height: 1.5; +} +.speedtest-empty-state.speedtest-empty-error .speedtest-empty-title, +.speedtest-empty-state.speedtest-empty-error .speedtest-empty-desc, +.speedtest-empty-state.speedtest-empty-error .btn, +.speedtest-empty-state.speedtest-empty-error [data-lucide] { + display: none; +} +.speedtest-empty-state.speedtest-empty-error::after { + content: attr(data-error); + color: var(--text-muted); +} + /* Responsive */ @media (max-width: 768px) { .speedtest-chart-card { diff --git a/app/static/css/main.css b/app/static/css/main.css index 6e539816..fe16dff2 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -742,6 +742,7 @@ body.is-offline .offline-banner { display: flex; } /* Value color utilities (used across channel, BNetzA, speedtest tables) */ .val-good { color: var(--good); } .val-tolerated { color: var(--tolerated); } +.val-bad { color: var(--crit); } .val-warn { color: var(--warn); } .val-crit { color: var(--crit); } /* Per-metric health coloring via data attributes (channel tables) */ diff --git a/app/static/js/speedtest.js b/app/static/js/speedtest.js index 6604d61e..a7b72236 100644 --- a/app/static/js/speedtest.js +++ b/app/static/js/speedtest.js @@ -5,6 +5,7 @@ var _speedtestAllData = []; var _speedtestVisible = 50; var _speedtestSortCol = 'timestamp'; var _speedtestSortDir = 'desc'; +var _signalCache = {}; function formatSpeedtestTimestamp(ts) { if (!ts) return ''; @@ -32,14 +33,15 @@ function loadSpeedtestHistory() { if (moreWrap) moreWrap.style.display = 'none'; _speedtestRawData = []; _speedtestAllData = []; + _signalCache = {}; _speedtestVisible = 50; fetch('/api/speedtest?count=2000') .then(function(r) { return r.json(); }) .then(function(data) { if (loading) loading.style.display = 'none'; if (!data || data.length === 0) { - noData.textContent = T.speedtest_no_data || 'No speedtest data.'; - noData.style.display = 'block'; + noData.classList.remove('speedtest-empty-error'); + noData.style.display = ''; return; } _speedtestRawData = data; @@ -47,8 +49,9 @@ function loadSpeedtestHistory() { }) .catch(function() { if (loading) loading.style.display = 'none'; - noData.textContent = T.network_error || 'Error'; - noData.style.display = 'block'; + noData.classList.add('speedtest-empty-error'); + noData.setAttribute('data-error', T.network_error || 'Error'); + noData.style.display = ''; }); } @@ -69,8 +72,8 @@ function filterSpeedtestData() { if (_speedtestAllData.length === 0) { if (table) table.style.display = 'none'; if (noData) { - noData.textContent = T.speedtest_no_data || 'No speedtest data.'; - noData.style.display = 'block'; + noData.classList.remove('speedtest-empty-error'); + noData.style.display = ''; } var cc = document.getElementById('speedtest-chart-container'); if (cc) cc.style.display = 'none'; @@ -186,6 +189,76 @@ function renderSpeedtestRows() { if (typeof lucide !== 'undefined') lucide.createIcons(); } +function _renderSignalDetail(data, container) { + container.textContent = ''; + if (!data.found) { + var noDataSpan = document.createElement('span'); + noDataSpan.className = 'st-sig-no-data'; + noDataSpan.textContent = data.message || T.signal_no_snapshot; + container.appendChild(noDataSpan); + return; + } + var healthClass = 'health-' + (data.health || 'unknown'); + var healthLabels = {good: T.health_good || 'Good', tolerated: T.health_tolerated || 'Tolerated', marginal: T.health_marginal || 'Marginal', critical: T.health_critical || 'Critical'}; + var healthLabel = healthLabels[data.health] || data.health; + var items = [ + {label: T.signal_health || 'Health', value: healthLabel, badge: healthClass}, + {label: T.signal_ds_power || 'DS Power', value: data.ds_power_min + ' / ' + data.ds_power_avg + ' / ' + data.ds_power_max + ' dBmV'}, + {label: T.signal_ds_snr || 'DS SNR', value: data.ds_snr_min + ' / ' + data.ds_snr_avg + ' dB'}, + {label: T.signal_us_power || 'US Power', value: data.us_power_min + ' / ' + data.us_power_avg + ' / ' + data.us_power_max + ' dBmV'}, + {label: T.signal_errors || 'Errors', value: (data.ds_correctable_errors || 0).toLocaleString() + ' ' + (T.signal_corr || 'corr.') + ' / ' + (data.ds_uncorrectable_errors || 0).toLocaleString() + ' ' + (T.signal_uncorr || 'uncorr.')}, + {label: (T.signal_ds_channels || 'DS') + ' / ' + (T.signal_us_channels || 'US'), value: (data.ds_total || 0) + ' / ' + (data.us_total || 0)} + ]; + items.forEach(function(item) { + var div = document.createElement('div'); + div.className = 'st-sig-item'; + var lbl = document.createElement('span'); + lbl.className = 'st-sig-label'; + lbl.textContent = item.label; + div.appendChild(lbl); + if (item.badge) { + var badge = document.createElement('span'); + badge.className = 'st-health-badge ' + item.badge; + badge.textContent = item.value; + div.appendChild(badge); + } else { + var val = document.createElement('span'); + val.className = 'st-sig-value'; + val.textContent = item.value; + div.appendChild(val); + } + container.appendChild(div); + }); + if (data.us_channels && data.us_channels.length > 0) { + var modsDiv = document.createElement('div'); + modsDiv.className = 'st-us-mods'; + var modsLabel = document.createElement('span'); + modsLabel.className = 'st-sig-label'; + modsLabel.textContent = (T.signal_us_modulation || 'US Modulation') + ': '; + modsDiv.appendChild(modsLabel); + for (var c = 0; c < data.us_channels.length; c++) { + var ch = data.us_channels[c]; + var chSpan = document.createElement('span'); + chSpan.textContent = 'Ch' + (ch.channel_id || c) + ': ' + (ch.modulation || '?'); + modsDiv.appendChild(chSpan); + } + container.appendChild(modsDiv); + } + var snapDiv = document.createElement('div'); + snapDiv.className = 'st-sig-item'; + var snapLabel = document.createElement('span'); + snapLabel.className = 'st-sig-label'; + snapLabel.textContent = T.signal_snapshot_time || 'Snapshot'; + snapDiv.appendChild(snapLabel); + var snapVal = document.createElement('span'); + snapVal.className = 'st-sig-value'; + snapVal.style.fontSize = '0.85em'; + snapVal.style.color = 'var(--muted)'; + snapVal.textContent = data.snapshot_timestamp || ''; + snapDiv.appendChild(snapVal); + container.appendChild(snapDiv); +} + function toggleSpeedtestSignal(btn) { var id = btn.getAttribute('data-id'); var parentRow = btn.closest('tr'); @@ -196,55 +269,42 @@ function toggleSpeedtestSignal(btn) { btn.classList.remove('open'); return; } - // Create detail row and fetch data + // Create detail row and populate (from cache or fetch) btn.classList.add('open'); var newRow = document.createElement('tr'); newRow.className = 'st-signal-row'; var cols = parentRow.children.length; var td = document.createElement('td'); td.colSpan = cols; - td.innerHTML = '
...
'; + var detailDiv = document.createElement('div'); + detailDiv.className = 'st-signal-detail'; + var loadSpan = document.createElement('span'); + loadSpan.className = 'st-sig-no-data'; + loadSpan.style.textAlign = 'center'; + loadSpan.textContent = '...'; + detailDiv.appendChild(loadSpan); + td.appendChild(detailDiv); newRow.appendChild(td); parentRow.after(newRow); - fetch('/api/speedtest/' + id + '/signal') - .then(function(r) { return r.json(); }) - .then(function(data) { - var container = newRow.querySelector('.st-signal-detail'); - if (!data.found) { - container.innerHTML = '' + escapeHtml(data.message || T.signal_no_snapshot) + ''; - return; - } - var healthClass = 'health-' + (data.health || 'unknown'); - var healthLabel = {good: T.health_good || 'Good', tolerated: T.health_tolerated || 'Tolerated', marginal: T.health_marginal || 'Marginal', critical: T.health_critical || 'Critical'}[data.health] || data.health; - var html = '
' + (T.signal_health || 'Health') + '' - + '' + escapeHtml(healthLabel) + '
' - + '
' + (T.signal_ds_power || 'DS Power') + '' - + '' + data.ds_power_min + ' / ' + data.ds_power_avg + ' / ' + data.ds_power_max + ' dBmV
' - + '
' + (T.signal_ds_snr || 'DS SNR') + '' - + '' + data.ds_snr_min + ' / ' + data.ds_snr_avg + ' dB
' - + '
' + (T.signal_us_power || 'US Power') + '' - + '' + data.us_power_min + ' / ' + data.us_power_avg + ' / ' + data.us_power_max + ' dBmV
' - + '
' + (T.signal_errors || 'Errors') + '' - + '' + (data.ds_correctable_errors || 0).toLocaleString() + ' ' + (T.signal_corr || 'corr.') + ' / ' - + (data.ds_uncorrectable_errors || 0).toLocaleString() + ' ' + (T.signal_uncorr || 'uncorr.') + '
' - + '
' + (T.signal_ds_channels || 'DS') + ' / ' + (T.signal_us_channels || 'US') + '' - + '' + (data.ds_total || 0) + ' / ' + (data.us_total || 0) + '
'; - if (data.us_channels && data.us_channels.length > 0) { - html += '
' + (T.signal_us_modulation || 'US Modulation') + ': '; - for (var c = 0; c < data.us_channels.length; c++) { - var ch = data.us_channels[c]; - html += 'Ch' + (ch.channel_id || c) + ': ' + escapeHtml(ch.modulation || '?') + ''; - } - html += '
'; - } - html += '
' + (T.signal_snapshot_time || 'Snapshot') + '' - + '' + escapeHtml(data.snapshot_timestamp || '') + '
'; - container.innerHTML = html; - }) - .catch(function() { - var container = newRow.querySelector('.st-signal-detail'); - if (container) { container.textContent = ''; var errSpan = document.createElement('span'); errSpan.className = 'st-sig-no-data'; errSpan.textContent = T.signal_error_loading || 'Error loading signal data'; container.appendChild(errSpan); } - }); + + var container = newRow.querySelector('.st-signal-detail'); + if (_signalCache[id]) { + _renderSignalDetail(_signalCache[id], container); + } else { + fetch('/api/speedtest/' + id + '/signal') + .then(function(r) { return r.json(); }) + .then(function(data) { + _signalCache[id] = data; + _renderSignalDetail(data, container); + }) + .catch(function() { + container.textContent = ''; + var errSpan = document.createElement('span'); + errSpan.className = 'st-sig-no-data'; + errSpan.textContent = T.signal_error_loading || 'Error loading signal data'; + container.appendChild(errSpan); + }); + } } function renderSpeedtestChart() { @@ -298,9 +358,11 @@ function renderSpeedtestChart() { // Grid lines + left Y axis labels (speed) var cs = getComputedStyle(document.documentElement); var mutedColor = cs.getPropertyValue('--muted').trim() || '#888'; - ctx.strokeStyle = 'rgba(255,255,255,0.07)'; + var gridColor = cs.getPropertyValue('--border-subtle').trim() || 'rgba(255,255,255,0.07)'; + ctx.strokeStyle = gridColor; ctx.lineWidth = 1; - ctx.font = '11px monospace'; + var monoFont = cs.getPropertyValue('--font-mono').trim() || 'monospace'; + ctx.font = '11px ' + monoFont; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; var gridLines = 5; @@ -319,7 +381,7 @@ function renderSpeedtestChart() { ctx.fillText(pingVal.toFixed(0), w - padR + 6, gy); } ctx.fillStyle = mutedColor; - ctx.font = '10px monospace'; + ctx.font = '10px ' + monoFont; ctx.textAlign = 'center'; ctx.save(); ctx.translate(12, padT + ch / 2); @@ -333,7 +395,7 @@ function renderSpeedtestChart() { ctx.restore(); // X axis labels (timestamps) ctx.fillStyle = mutedColor; - ctx.font = '10px monospace'; + ctx.font = '10px ' + monoFont; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; var labelCount = Math.min(6, data.length); @@ -403,17 +465,17 @@ function renderSpeedtestChart() { drawLine(uls, ySpeed, '#22c55e', ['rgba(34,197,94,0.3)', 'rgba(34,197,94,0)']); drawLine(dls, ySpeed, '#a855f7', ['rgba(168,85,247,0.3)', 'rgba(168,85,247,0)']); drawLine(pings, yPing, '#f59e0b', 'rgba(245,158,11,0.10)'); - // Hover interaction + // Hover / touch interaction var tooltip = document.getElementById('speedtest-chart-tooltip'); // Move tooltip to body so it's never clipped if (tooltip.parentElement !== document.body) document.body.appendChild(tooltip); tooltip.style.position = 'fixed'; - function onMouseMove(e) { + function showTooltipAt(clientX, clientY) { var rect = canvas.getBoundingClientRect(); var scaleX = w / rect.width; var scaleY = h / rect.height; - var mx = (e.clientX - rect.left) * scaleX; - var my = (e.clientY - rect.top) * scaleY; + var mx = (clientX - rect.left) * scaleX; + var my = (clientY - rect.top) * scaleY; if (mx < padL || mx > w - padR || my < padT || my > padT + ch) { tooltip.style.display = 'none'; return; } @@ -422,20 +484,64 @@ function renderSpeedtestChart() { if (idx < 0) idx = 0; if (idx >= data.length) idx = data.length - 1; tooltip.style.display = 'block'; - tooltip.innerHTML = '' + escapeHtml(formatSpeedtestTimestamp(data[idx].timestamp)) + '
' - + ' ' + (T.speedtest_dl || 'DL') + ': ' + dls[idx].toFixed(2) + ' Mbps
' - + ' ' + (T.speedtest_ul || 'UL') + ': ' + uls[idx].toFixed(2) + ' Mbps
' - + ' ' + (T.speedtest_ping || 'Ping') + ': ' + pings[idx].toFixed(1) + ' ms'; - tooltip.style.left = (e.clientX + 14) + 'px'; - tooltip.style.top = (e.clientY - 10) + 'px'; + tooltip.textContent = ''; + var strong = document.createElement('strong'); + strong.textContent = formatSpeedtestTimestamp(data[idx].timestamp); + tooltip.appendChild(strong); + var lines = [ + {color: '#a855f7', sym: '\u25BC', label: T.speedtest_dl || 'DL', val: dls[idx].toFixed(2) + ' Mbps'}, + {color: '#22c55e', sym: '\u25B2', label: T.speedtest_ul || 'UL', val: uls[idx].toFixed(2) + ' Mbps'}, + {color: '#f59e0b', sym: '\u25CF', label: T.speedtest_ping || 'Ping', val: pings[idx].toFixed(1) + ' ms'} + ]; + lines.forEach(function(line) { + tooltip.appendChild(document.createElement('br')); + var span = document.createElement('span'); + span.style.color = line.color; + span.textContent = line.sym; + tooltip.appendChild(span); + tooltip.appendChild(document.createTextNode(' ' + line.label + ': ' + line.val)); + }); + // Position with edge detection (horizontal + vertical) + var tipW = tooltip.offsetWidth || 160; + var tipH = tooltip.offsetHeight || 60; + var leftPos = clientX + 14; + if (leftPos + tipW > window.innerWidth - 8) { + leftPos = clientX - tipW - 14; + } + var topPos = clientY - 10; + if (topPos + tipH > window.innerHeight - 8) { + topPos = clientY - tipH - 14; + } + tooltip.style.left = leftPos + 'px'; + tooltip.style.top = topPos + 'px'; } + function onMouseMove(e) { showTooltipAt(e.clientX, e.clientY); } function onMouseLeave() { tooltip.style.display = 'none'; } + function onTouchMove(e) { + if (e.touches.length === 1) { + e.preventDefault(); + var touch = e.touches[0]; + showTooltipAt(touch.clientX, touch.clientY); + } + } + function onTouchEnd() { tooltip.style.display = 'none'; } + // Clean up old handlers if (canvas._chartMoveHandler) canvas.removeEventListener('mousemove', canvas._chartMoveHandler); if (canvas._chartLeaveHandler) canvas.removeEventListener('mouseleave', canvas._chartLeaveHandler); + if (canvas._chartTouchMoveHandler) canvas.removeEventListener('touchmove', canvas._chartTouchMoveHandler); + if (canvas._chartTouchEndHandler) { + canvas.removeEventListener('touchend', canvas._chartTouchEndHandler); + canvas.removeEventListener('touchcancel', canvas._chartTouchEndHandler); + } canvas._chartMoveHandler = onMouseMove; canvas._chartLeaveHandler = onMouseLeave; + canvas._chartTouchMoveHandler = onTouchMove; + canvas._chartTouchEndHandler = onTouchEnd; canvas.addEventListener('mousemove', onMouseMove); canvas.addEventListener('mouseleave', onMouseLeave); + canvas.addEventListener('touchmove', onTouchMove, {passive: false}); + canvas.addEventListener('touchend', onTouchEnd); + canvas.addEventListener('touchcancel', onTouchEnd); } // Resize handler for speedtest chart only @@ -448,7 +554,10 @@ function showMoreSpeedtest() { renderSpeedtestRows(); } -function _setRunBtnState(btn, loading, origHTML) { +var _runElapsedTimer = null; + +function _setRunBtnState(btn, loading) { + if (_runElapsedTimer) { clearInterval(_runElapsedTimer); _runElapsedTimer = null; } if (loading) { btn.disabled = true; btn.textContent = ''; @@ -456,7 +565,13 @@ function _setRunBtnState(btn, loading, origHTML) { icon.setAttribute('data-lucide', 'loader-2'); icon.className = 'spin'; btn.appendChild(icon); - btn.appendChild(document.createTextNode(' ' + (T.speedtest_running || 'Running...'))); + var textNode = document.createTextNode(' ' + (T.speedtest_running || 'Running...') + ' 0s'); + btn.appendChild(textNode); + var startTime = Date.now(); + _runElapsedTimer = setInterval(function() { + var elapsed = Math.round((Date.now() - startTime) / 1000); + textNode.textContent = ' ' + (T.speedtest_running || 'Running...') + ' ' + elapsed + 's'; + }, 1000); } else { btn.disabled = false; btn.textContent = ''; diff --git a/app/templates/index.html b/app/templates/index.html index a8e8ff17..705a84d0 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1688,7 +1688,12 @@

{{ t.import_incidents }}

- +