diff --git a/extension/dashboard/core/init.js b/extension/dashboard/core/init.js index c0d64f0..50ecac6 100644 --- a/extension/dashboard/core/init.js +++ b/extension/dashboard/core/init.js @@ -35,8 +35,29 @@ async function init() { setupRdpEvents(); setupBackupEvents(); await refresh(); + + // Main state-refresh interval (fixed 2 s) const _refreshInterval = setInterval(refresh, 2000); - window.addEventListener('beforeunload', () => clearInterval(_refreshInterval), { once: true }); + + // Latency-ping interval — driven by settings.latencyPingIntervalMs. + // Re-created whenever settings change so the interval stays in sync. + let _pingInterval = null; + function _restartPingInterval() { + if (_pingInterval) clearInterval(_pingInterval); + _pingInterval = null; + if (settings.latencyPingEnabled === false) return; + const ms = (settings.latencyPingIntervalMs > 0 ? settings.latencyPingIntervalMs : 5000); + _pingInterval = setInterval(_pingLatencyBadges, ms); + } + _restartPingInterval(); + + // Expose restart so settings.js can call it after saving + window._restartPingInterval = _restartPingInterval; + + window.addEventListener('beforeunload', () => { + clearInterval(_refreshInterval); + if (_pingInterval) clearInterval(_pingInterval); + }, { once: true }); } init(); diff --git a/extension/dashboard/core/state.js b/extension/dashboard/core/state.js index af0a2d4..ce010eb 100644 --- a/extension/dashboard/core/state.js +++ b/extension/dashboard/core/state.js @@ -11,7 +11,9 @@ const SETTINGS_DEFAULTS = { disableOnFileUrls: false, backupRetention: 5, backupIntervalHours: 0, - tunnelAutoReconnect: false + tunnelAutoReconnect: false, + latencyPingEnabled: true, + latencyPingIntervalMs: 5000 }; let currentState = null; diff --git a/extension/dashboard/dashboard.css b/extension/dashboard/dashboard.css index 44bcbee..a656070 100644 --- a/extension/dashboard/dashboard.css +++ b/extension/dashboard/dashboard.css @@ -335,6 +335,17 @@ .badge-red { background: var(--red-dim); color: var(--red); } .badge-neutral { background: var(--elevated); color: var(--text3); } + /* ── Latency badges ─────────────────────────────── */ + .latency-badge { + transition: color 0.4s ease, opacity 0.3s ease; + display: inline-block; + min-width: 32px; + text-align: right; + } + .latency-badge.pinging { + opacity: 0.45; + } + /* ── Overview list cards ────────────────────────── */ .ov-list-card { display: flex; diff --git a/extension/dashboard/dashboard.html b/extension/dashboard/dashboard.html index de8314d..e678d8e 100644 --- a/extension/dashboard/dashboard.html +++ b/extension/dashboard/dashboard.html @@ -604,6 +604,26 @@ +
Latency Ping
+
+
+
Enable Latency Ping
+
Periodically ping tunnel ports to display live latency badges on virtual hosts and service tunnels
+
+
+
+
+
+
+
+
Ping Interval (seconds)
+
How often to ping tunnel ports for latency. Minimum 1 s, default 5 s.
+
+
+ +
+
+
Proxy & API
diff --git a/extension/dashboard/pages/service-tunnels.js b/extension/dashboard/pages/service-tunnels.js index cac8b88..ff9ba74 100644 --- a/extension/dashboard/pages/service-tunnels.js +++ b/extension/dashboard/pages/service-tunnels.js @@ -30,6 +30,12 @@ function updateServiceTunnelsTable(state) { const countEl = $('serviceTunnelCount'); if (countEl) countEl.textContent = tunnels.length; + // Snapshot latency badge values before re-render so they survive the innerHTML swap + const _latencySnapshot = {}; + tbody.querySelectorAll('.latency-badge[data-ping-port]').forEach(b => { + _latencySnapshot[b.dataset.pingHost + ':' + b.dataset.pingPort] = { text: b.textContent, color: b.style.color }; + }); + if (tunnels.length === 0) { tbody.innerHTML = ` @@ -95,6 +101,15 @@ function updateServiceTunnelsTable(state) { `; }).join(''); + // Restore latency badge values immediately so there's no flash back to "…ms" + tbody.querySelectorAll('.latency-badge[data-ping-port]').forEach(b => { + const snap = _latencySnapshot[b.dataset.pingHost + ':' + b.dataset.pingPort]; + if (snap && snap.text && snap.text !== '…ms') { + b.textContent = snap.text; + b.style.color = snap.color; + } + }); + tbody.querySelectorAll('.svc-row-cb').forEach(cb => { cb.addEventListener('change', _updateSvcBulkBar); }); diff --git a/extension/dashboard/pages/settings.js b/extension/dashboard/pages/settings.js index d7049a0..7224619 100644 --- a/extension/dashboard/pages/settings.js +++ b/extension/dashboard/pages/settings.js @@ -15,14 +15,17 @@ function updateSettingsUI() { $('toggleDebug')?.classList.toggle('active', settings.debug === true); $('toggleDisableFileUrls')?.classList.toggle('active', settings.disableOnFileUrls === true); $('toggleTheme')?.classList.toggle('active', document.documentElement.getAttribute('data-theme') === 'light'); + $('toggleLatencyPing')?.classList.toggle('active', settings.latencyPingEnabled !== false); const proxyPortEl = $('proxyPort'); const readyTimeoutMsEl = $('readyTimeoutMs'); const backupRetentionEl = $('backupRetention'); const backupIntervalEl = $('backupIntervalHours'); + const latencyPingIntervalEl = $('latencyPingIntervalMs'); if (proxyPortEl) proxyPortEl.value = settings.proxyPort ?? SETTINGS_DEFAULTS.proxyPort; if (readyTimeoutMsEl) readyTimeoutMsEl.value = settings.readyTimeoutMs ?? SETTINGS_DEFAULTS.readyTimeoutMs; if (backupRetentionEl) backupRetentionEl.value = settings.backupRetention ?? SETTINGS_DEFAULTS.backupRetention; if (backupIntervalEl) backupIntervalEl.value = settings.backupIntervalHours ?? SETTINGS_DEFAULTS.backupIntervalHours; + if (latencyPingIntervalEl) latencyPingIntervalEl.value = (settings.latencyPingIntervalMs ?? SETTINGS_DEFAULTS.latencyPingIntervalMs) / 1000; } function saveSettings() { @@ -31,16 +34,23 @@ function saveSettings() { settings.tunnelAutoReconnect = $('toggleTunnelAutoReconnect')?.classList.contains('active') ?? SETTINGS_DEFAULTS.tunnelAutoReconnect; settings.debug = $('toggleDebug')?.classList.contains('active') ?? SETTINGS_DEFAULTS.debug; settings.disableOnFileUrls = $('toggleDisableFileUrls')?.classList.contains('active') ?? SETTINGS_DEFAULTS.disableOnFileUrls; + settings.latencyPingEnabled = $('toggleLatencyPing')?.classList.contains('active') ?? SETTINGS_DEFAULTS.latencyPingEnabled; settings.proxyPort = parseInt($('proxyPort')?.value, 10) || SETTINGS_DEFAULTS.proxyPort; settings.readyTimeoutMs = parseInt($('readyTimeoutMs')?.value, 10) || SETTINGS_DEFAULTS.readyTimeoutMs; settings.backupRetention = Math.max(1, parseInt($('backupRetention')?.value, 10) || SETTINGS_DEFAULTS.backupRetention); settings.backupIntervalHours = Math.max(0, parseInt($('backupIntervalHours')?.value, 10) || 0); + const pingSecs = parseFloat($('latencyPingIntervalMs')?.value); + settings.latencyPingIntervalMs = isNaN(pingSecs) || pingSecs <= 0 + ? SETTINGS_DEFAULTS.latencyPingIntervalMs + : Math.round(pingSecs * 1000); chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } }, (response) => { if (chrome.runtime.lastError) { showToast('Settings save failed: ' + chrome.runtime.lastError.message, 'error'); return; } if (response && response.ok) { if (response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings }; + // Restart the ping interval so the new interval / enabled state takes effect immediately + if (typeof window._restartPingInterval === 'function') window._restartPingInterval(); if (response.requiresRestart) { showToast('Settings saved — restart the native host for proxy port changes to take effect', 'warning'); } else { diff --git a/extension/dashboard/pages/virtual-hosts.js b/extension/dashboard/pages/virtual-hosts.js index bf4b8a8..3ea27c8 100644 --- a/extension/dashboard/pages/virtual-hosts.js +++ b/extension/dashboard/pages/virtual-hosts.js @@ -28,6 +28,12 @@ function updateConnectionsTable(state) { if (!tbody) return; const virtualHosts = state.virtualHosts || []; + // Snapshot latency badge values before re-render so they survive the innerHTML swap + const _latencySnapshot = {}; + tbody.querySelectorAll('.latency-badge[data-ping-port]').forEach(b => { + _latencySnapshot[b.dataset.pingHost + ':' + b.dataset.pingPort] = { text: b.textContent, color: b.style.color }; + }); + if (virtualHosts.length === 0) { tbody.innerHTML = ` @@ -85,6 +91,15 @@ function updateConnectionsTable(state) { `; }).join(''); + // Restore latency badge values immediately so there's no flash back to "…ms" + tbody.querySelectorAll('.latency-badge[data-ping-port]').forEach(b => { + const snap = _latencySnapshot[b.dataset.pingHost + ':' + b.dataset.pingPort]; + if (snap && snap.text && snap.text !== '…ms') { + b.textContent = snap.text; + b.style.color = snap.color; + } + }); + tbody.querySelectorAll('.vhost-row-cb').forEach(cb => { cb.addEventListener('change', _updateVhostBulkBar); }); diff --git a/extension/dashboard/refresh.js b/extension/dashboard/refresh.js index acbd0ff..441c152 100644 --- a/extension/dashboard/refresh.js +++ b/extension/dashboard/refresh.js @@ -17,7 +17,15 @@ const _pingInFlight = new Set(); +/** + * Ping all latency badges visible in the DOM. + * Updates badge text/color in-place with a CSS transition so the table never + * reflows. Skips badges that already have an in-flight request for the same + * host:port key. Respects the `latencyPingEnabled` and `latencyPingIntervalMs` + * settings — callers should check `latencyPingEnabled` before calling this. + */ function _pingLatencyBadges() { + if (settings.latencyPingEnabled === false) return; const badges = document.querySelectorAll('.latency-badge[data-ping-port]'); badges.forEach(badge => { const port = parseInt(badge.dataset.pingPort, 10); @@ -26,20 +34,25 @@ function _pingLatencyBadges() { const key = host + ':' + port; if (_pingInFlight.has(key)) return; _pingInFlight.add(key); + // Dim badge while request is in-flight — avoids a hard jump on update + badge.classList.add('pinging'); chrome.runtime.sendMessage( { target: 'holesail-native', action: 'send', payload: { type: 'pingTunnel', payload: { host, port } } }, (response) => { _pingInFlight.delete(key); void chrome.runtime.lastError; if (!badge.isConnected) return; + badge.classList.remove('pinging'); if (response && response.ok) { const ms = response.latencyMs; const color = ms < 50 ? 'var(--green)' : ms < 200 ? 'var(--amber)' : 'var(--red)'; + // Only update if the value actually changed to avoid unnecessary repaints + const next = ms + 'ms'; + if (badge.textContent !== next) badge.textContent = next; badge.style.color = color; - badge.textContent = ms + 'ms'; } else { + if (badge.textContent !== '—') badge.textContent = '—'; badge.style.color = 'var(--text4)'; - badge.textContent = '—'; } } ); @@ -93,6 +106,4 @@ async function refresh() { const sshCountEl = $('sshCount'); if (sshCountEl) sshCountEl.textContent = sshConnections.length; refreshBackups(); - // Ping latency badges after a short delay so the DOM has been updated - setTimeout(_pingLatencyBadges, 200); }