fix(dashboard): smooth latency badge updates, add ping settings
CI / Build & Test (push) Successful in 2m52s
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:
@@ -35,8 +35,29 @@ async function init() {
|
|||||||
setupRdpEvents();
|
setupRdpEvents();
|
||||||
setupBackupEvents();
|
setupBackupEvents();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
|
||||||
|
// Main state-refresh interval (fixed 2 s)
|
||||||
const _refreshInterval = setInterval(refresh, 2000);
|
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();
|
init();
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ const SETTINGS_DEFAULTS = {
|
|||||||
disableOnFileUrls: false,
|
disableOnFileUrls: false,
|
||||||
backupRetention: 5,
|
backupRetention: 5,
|
||||||
backupIntervalHours: 0,
|
backupIntervalHours: 0,
|
||||||
tunnelAutoReconnect: false
|
tunnelAutoReconnect: false,
|
||||||
|
latencyPingEnabled: true,
|
||||||
|
latencyPingIntervalMs: 5000
|
||||||
};
|
};
|
||||||
|
|
||||||
let currentState = null;
|
let currentState = null;
|
||||||
|
|||||||
@@ -335,6 +335,17 @@
|
|||||||
.badge-red { background: var(--red-dim); color: var(--red); }
|
.badge-red { background: var(--red-dim); color: var(--red); }
|
||||||
.badge-neutral { background: var(--elevated); color: var(--text3); }
|
.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 ────────────────────────── */
|
/* ── Overview list cards ────────────────────────── */
|
||||||
.ov-list-card {
|
.ov-list-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -604,6 +604,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 & API</div>
|
<div class="section-heading">Proxy & API</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ function updateServiceTunnelsTable(state) {
|
|||||||
const countEl = $('serviceTunnelCount');
|
const countEl = $('serviceTunnelCount');
|
||||||
if (countEl) countEl.textContent = tunnels.length;
|
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) {
|
if (tunnels.length === 0) {
|
||||||
tbody.innerHTML = `
|
tbody.innerHTML = `
|
||||||
<tr><td colspan="6">
|
<tr><td colspan="6">
|
||||||
@@ -95,6 +101,15 @@ function updateServiceTunnelsTable(state) {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).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 => {
|
tbody.querySelectorAll('.svc-row-cb').forEach(cb => {
|
||||||
cb.addEventListener('change', _updateSvcBulkBar);
|
cb.addEventListener('change', _updateSvcBulkBar);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ function updateSettingsUI() {
|
|||||||
$('toggleDebug')?.classList.toggle('active', settings.debug === true);
|
$('toggleDebug')?.classList.toggle('active', settings.debug === true);
|
||||||
$('toggleDisableFileUrls')?.classList.toggle('active', settings.disableOnFileUrls === true);
|
$('toggleDisableFileUrls')?.classList.toggle('active', settings.disableOnFileUrls === true);
|
||||||
$('toggleTheme')?.classList.toggle('active', document.documentElement.getAttribute('data-theme') === 'light');
|
$('toggleTheme')?.classList.toggle('active', document.documentElement.getAttribute('data-theme') === 'light');
|
||||||
|
$('toggleLatencyPing')?.classList.toggle('active', settings.latencyPingEnabled !== false);
|
||||||
const proxyPortEl = $('proxyPort');
|
const proxyPortEl = $('proxyPort');
|
||||||
const readyTimeoutMsEl = $('readyTimeoutMs');
|
const readyTimeoutMsEl = $('readyTimeoutMs');
|
||||||
const backupRetentionEl = $('backupRetention');
|
const backupRetentionEl = $('backupRetention');
|
||||||
const backupIntervalEl = $('backupIntervalHours');
|
const backupIntervalEl = $('backupIntervalHours');
|
||||||
|
const latencyPingIntervalEl = $('latencyPingIntervalMs');
|
||||||
if (proxyPortEl) proxyPortEl.value = settings.proxyPort ?? SETTINGS_DEFAULTS.proxyPort;
|
if (proxyPortEl) proxyPortEl.value = settings.proxyPort ?? SETTINGS_DEFAULTS.proxyPort;
|
||||||
if (readyTimeoutMsEl) readyTimeoutMsEl.value = settings.readyTimeoutMs ?? SETTINGS_DEFAULTS.readyTimeoutMs;
|
if (readyTimeoutMsEl) readyTimeoutMsEl.value = settings.readyTimeoutMs ?? SETTINGS_DEFAULTS.readyTimeoutMs;
|
||||||
if (backupRetentionEl) backupRetentionEl.value = settings.backupRetention ?? SETTINGS_DEFAULTS.backupRetention;
|
if (backupRetentionEl) backupRetentionEl.value = settings.backupRetention ?? SETTINGS_DEFAULTS.backupRetention;
|
||||||
if (backupIntervalEl) backupIntervalEl.value = settings.backupIntervalHours ?? SETTINGS_DEFAULTS.backupIntervalHours;
|
if (backupIntervalEl) backupIntervalEl.value = settings.backupIntervalHours ?? SETTINGS_DEFAULTS.backupIntervalHours;
|
||||||
|
if (latencyPingIntervalEl) latencyPingIntervalEl.value = (settings.latencyPingIntervalMs ?? SETTINGS_DEFAULTS.latencyPingIntervalMs) / 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
@@ -31,16 +34,23 @@ function saveSettings() {
|
|||||||
settings.tunnelAutoReconnect = $('toggleTunnelAutoReconnect')?.classList.contains('active') ?? SETTINGS_DEFAULTS.tunnelAutoReconnect;
|
settings.tunnelAutoReconnect = $('toggleTunnelAutoReconnect')?.classList.contains('active') ?? SETTINGS_DEFAULTS.tunnelAutoReconnect;
|
||||||
settings.debug = $('toggleDebug')?.classList.contains('active') ?? SETTINGS_DEFAULTS.debug;
|
settings.debug = $('toggleDebug')?.classList.contains('active') ?? SETTINGS_DEFAULTS.debug;
|
||||||
settings.disableOnFileUrls = $('toggleDisableFileUrls')?.classList.contains('active') ?? SETTINGS_DEFAULTS.disableOnFileUrls;
|
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.proxyPort = parseInt($('proxyPort')?.value, 10) || SETTINGS_DEFAULTS.proxyPort;
|
||||||
settings.readyTimeoutMs = parseInt($('readyTimeoutMs')?.value, 10) || SETTINGS_DEFAULTS.readyTimeoutMs;
|
settings.readyTimeoutMs = parseInt($('readyTimeoutMs')?.value, 10) || SETTINGS_DEFAULTS.readyTimeoutMs;
|
||||||
settings.backupRetention = Math.max(1, parseInt($('backupRetention')?.value, 10) || SETTINGS_DEFAULTS.backupRetention);
|
settings.backupRetention = Math.max(1, parseInt($('backupRetention')?.value, 10) || SETTINGS_DEFAULTS.backupRetention);
|
||||||
settings.backupIntervalHours = Math.max(0, parseInt($('backupIntervalHours')?.value, 10) || 0);
|
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(
|
chrome.runtime.sendMessage(
|
||||||
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
|
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
|
||||||
(response) => {
|
(response) => {
|
||||||
if (chrome.runtime.lastError) { showToast('Settings save failed: ' + chrome.runtime.lastError.message, 'error'); return; }
|
if (chrome.runtime.lastError) { showToast('Settings save failed: ' + chrome.runtime.lastError.message, 'error'); return; }
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
if (response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings };
|
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) {
|
if (response.requiresRestart) {
|
||||||
showToast('Settings saved — restart the native host for proxy port changes to take effect', 'warning');
|
showToast('Settings saved — restart the native host for proxy port changes to take effect', 'warning');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ function updateConnectionsTable(state) {
|
|||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
const virtualHosts = state.virtualHosts || [];
|
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) {
|
if (virtualHosts.length === 0) {
|
||||||
tbody.innerHTML = `
|
tbody.innerHTML = `
|
||||||
<tr><td colspan="6">
|
<tr><td colspan="6">
|
||||||
@@ -85,6 +91,15 @@ function updateConnectionsTable(state) {
|
|||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).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 => {
|
tbody.querySelectorAll('.vhost-row-cb').forEach(cb => {
|
||||||
cb.addEventListener('change', _updateVhostBulkBar);
|
cb.addEventListener('change', _updateVhostBulkBar);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,15 @@
|
|||||||
|
|
||||||
const _pingInFlight = new Set();
|
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() {
|
function _pingLatencyBadges() {
|
||||||
|
if (settings.latencyPingEnabled === false) return;
|
||||||
const badges = document.querySelectorAll('.latency-badge[data-ping-port]');
|
const badges = document.querySelectorAll('.latency-badge[data-ping-port]');
|
||||||
badges.forEach(badge => {
|
badges.forEach(badge => {
|
||||||
const port = parseInt(badge.dataset.pingPort, 10);
|
const port = parseInt(badge.dataset.pingPort, 10);
|
||||||
@@ -26,20 +34,25 @@ function _pingLatencyBadges() {
|
|||||||
const key = host + ':' + port;
|
const key = host + ':' + port;
|
||||||
if (_pingInFlight.has(key)) return;
|
if (_pingInFlight.has(key)) return;
|
||||||
_pingInFlight.add(key);
|
_pingInFlight.add(key);
|
||||||
|
// Dim badge while request is in-flight — avoids a hard jump on update
|
||||||
|
badge.classList.add('pinging');
|
||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(
|
||||||
{ target: 'holesail-native', action: 'send', payload: { type: 'pingTunnel', payload: { host, port } } },
|
{ target: 'holesail-native', action: 'send', payload: { type: 'pingTunnel', payload: { host, port } } },
|
||||||
(response) => {
|
(response) => {
|
||||||
_pingInFlight.delete(key);
|
_pingInFlight.delete(key);
|
||||||
void chrome.runtime.lastError;
|
void chrome.runtime.lastError;
|
||||||
if (!badge.isConnected) return;
|
if (!badge.isConnected) return;
|
||||||
|
badge.classList.remove('pinging');
|
||||||
if (response && response.ok) {
|
if (response && response.ok) {
|
||||||
const ms = response.latencyMs;
|
const ms = response.latencyMs;
|
||||||
const color = ms < 50 ? 'var(--green)' : ms < 200 ? 'var(--amber)' : 'var(--red)';
|
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.style.color = color;
|
||||||
badge.textContent = ms + 'ms';
|
|
||||||
} else {
|
} else {
|
||||||
|
if (badge.textContent !== '—') badge.textContent = '—';
|
||||||
badge.style.color = 'var(--text4)';
|
badge.style.color = 'var(--text4)';
|
||||||
badge.textContent = '—';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -93,6 +106,4 @@ async function refresh() {
|
|||||||
const sshCountEl = $('sshCount');
|
const sshCountEl = $('sshCount');
|
||||||
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
|
if (sshCountEl) sshCountEl.textContent = sshConnections.length;
|
||||||
refreshBackups();
|
refreshBackups();
|
||||||
// Ping latency badges after a short delay so the DOM has been updated
|
|
||||||
setTimeout(_pingLatencyBadges, 200);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user