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 @@
+
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);
}