fix(dashboard): smooth latency badge updates, add ping settings
CI / Build & Test (push) Successful in 2m52s

- Snapshot and restore latency badge values across innerHTML re-renders
  so badges never flash back to "…ms" on the 2-second state refresh cycle
- Add CSS transition (color 0.4s ease) and in-flight opacity dim (.pinging)
  to .latency-badge for smooth color changes
- Decouple latency pinging from the state refresh cycle — pings now run on
  their own independent interval instead of being triggered by refresh()
- Add two new settings under a "Latency Ping" section in Settings:
  - Enable Latency Ping toggle (latencyPingEnabled, default: true)
  - Ping Interval in seconds (latencyPingIntervalMs, default: 5s)
- Restart ping interval immediately on settings save so changes take effect
  without a page reload
This commit is contained in:
Raven Scott
2026-03-01 00:46:37 -05:00
parent f1e98a7edd
commit 4ed688a315
8 changed files with 111 additions and 6 deletions
+22 -1
View File
@@ -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();
+3 -1
View File
@@ -11,7 +11,9 @@ const SETTINGS_DEFAULTS = {
disableOnFileUrls: false,
backupRetention: 5,
backupIntervalHours: 0,
tunnelAutoReconnect: false
tunnelAutoReconnect: false,
latencyPingEnabled: true,
latencyPingIntervalMs: 5000
};
let currentState = null;
+11
View File
@@ -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;
+20
View File
@@ -604,6 +604,26 @@
</div>
</div>
<div class="section-heading">Latency Ping</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Enable Latency Ping</div>
<div class="setting-desc">Periodically ping tunnel ports to display live latency badges on virtual hosts and service tunnels</div>
</div>
<div class="setting-control">
<div class="toggle" id="toggleLatencyPing" data-setting="latencyPingEnabled"></div>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Ping Interval (seconds)</div>
<div class="setting-desc">How often to ping tunnel ports for latency. Minimum 1 s, default 5 s.</div>
</div>
<div class="setting-control">
<input type="number" id="latencyPingIntervalMs" min="1" max="60" step="0.5" class="input" style="width:90px;" placeholder="5">
</div>
</div>
<div class="section-heading">Proxy &amp; API</div>
<div class="setting-row">
<div class="setting-info">
@@ -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 = `
<tr><td colspan="6">
@@ -95,6 +101,15 @@ function updateServiceTunnelsTable(state) {
</tr>`;
}).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);
});
+10
View File
@@ -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 {
@@ -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 = `
<tr><td colspan="6">
@@ -85,6 +91,15 @@ function updateConnectionsTable(state) {
</tr>`;
}).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);
});
+15 -4
View File
@@ -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);
}