Files
holesail-browser/extension/dashboard/pages/service-tunnels.js
T
Raven Scott 4ed688a315
CI / Build & Test (push) Successful in 2m52s
fix(dashboard): smooth latency badge updates, add ping settings
- 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
2026-03-01 00:46:37 -05:00

259 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Service Tunnels page — renders the service tunnels table, manages bulk selection,
* and wires up add/edit/remove modal events.
* Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
* ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
*/
function _updateSvcBulkBar() {
const checked = document.querySelectorAll('#serviceTunnelsTable input[type="checkbox"]:checked');
const bar = $('svcBulkBar');
const countEl = $('svcBulkCount');
if (!bar) return;
if (checked.length > 0) {
bar.style.display = 'flex';
if (countEl) countEl.textContent = checked.length + ' selected';
} else {
bar.style.display = 'none';
}
}
/**
* Re-render the service tunnels table with the latest state.
* @param {object} state - Full extension state from `fetchState()`.
*/
function updateServiceTunnelsTable(state) {
const tbody = $('serviceTunnelsTable');
if (!tbody) return;
const tunnels = state.serviceTunnels || [];
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">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<div class="empty-state-title">No service tunnels</div>
<div class="empty-state-desc">Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail</div>
</div>
</td></tr>`;
_updateSvcBulkBar();
return;
}
tbody.innerHTML = tunnels.map(t => {
const id = t.id || '';
const label = t.label || id;
const hsUrl = t.hsUrl || '';
const localAddr = t.localPort != null ? `127.0.0.1:${t.localPort}` : '—';
const safeId = id.replace(/"/g, '&quot;');
return `
<tr>
<td style="width:32px;"><input type="checkbox" class="svc-row-cb" data-tunnel-id="${safeId}"></td>
<td style="font-weight:600;color:var(--text);">${escapeHtml(label)}</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<span class="mono" title="${escapeHtml(hsUrl)}" style="font-size:11px;color:var(--text3);">${truncate(hsUrl, 28)}</span>
${hsUrl ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(hsUrl)}" title="Copy hs:// key">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span class="copy-tooltip">Copied!</span>
</button>` : ''}
</div>
</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<span style="font-weight:600;color:var(--cyan);font-family:'JetBrains Mono',monospace;font-size:13px;">${escapeHtml(localAddr)}</span>
${t.localPort != null ? `<button class="btn-icon copy-btn" data-copy="${escapeHtml(localAddr)}" title="Copy local address">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<span class="copy-tooltip">Copied!</span>
</button>` : ''}
</div>
</td>
<td>
${stateTag(t.state)}
${t.localPort != null ? `<span class="latency-badge" data-ping-host="127.0.0.1" data-ping-port="${t.localPort}" style="margin-left:4px;font-size:10px;color:var(--text4);">…ms</span>` : ''}
</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
${(t.state === 'error' || t.state === 'closed') ? `
<button class="btn btn-ghost btn-sm" data-reconnect-svc="${safeId}" data-hsurl="${escapeHtml(hsUrl)}" data-label="${escapeHtml(label)}" data-localport="${t.localPort != null ? t.localPort : ''}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Reconnect
</button>` : ''}
<button class="btn btn-ghost btn-sm" data-edit-service-tunnel="${safeId}" title="Edit tunnel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit
</button>
<button class="btn btn-danger btn-sm" data-remove-service-tunnel="${safeId}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/></svg>
Remove
</button>
</div>
</td>
</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);
});
tbody.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
});
tbody.querySelectorAll('[data-reconnect-svc]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.reconnectSvc;
const hsUrl = btn.dataset.hsurl;
const label = btn.dataset.label;
const localPort = parseInt(btn.dataset.localport, 10);
btn.disabled = true;
btn.textContent = 'Reconnecting…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'updateServiceTunnel', payload: { tunnelId, hsUrl, label, localPort } } },
(response) => {
if (chrome.runtime.lastError) { showToast('Reconnect failed: ' + chrome.runtime.lastError.message, 'error'); return; }
if (response?.ok) {
showToast('Service tunnel reconnecting…', 'success');
} else {
showToast(response?.error || 'Reconnect failed', 'error');
}
refresh();
}
);
});
});
tbody.querySelectorAll('[data-edit-service-tunnel]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.editServiceTunnel;
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
if (!tunnel) return;
$('serviceTunnelEditId').value = tunnelId;
$('serviceTunnelLabel').value = tunnel.label || '';
$('serviceTunnelHsUrl').value = tunnel.hsUrl || '';
$('serviceTunnelLocalPort').value = tunnel.localPort != null ? tunnel.localPort : '';
const titleEl = $('modal-addServiceTunnel-title');
if (titleEl) titleEl.textContent = 'Edit Service Tunnel';
const submitEl = $('serviceTunnelSubmit');
if (submitEl) submitEl.textContent = 'Save';
openModal('modal-addServiceTunnel');
});
});
tbody.querySelectorAll('[data-remove-service-tunnel]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.removeServiceTunnel;
const tunnel = (state.serviceTunnels || []).find(t => t.id === tunnelId);
const nameEl = $('removeServiceTunnelName');
if (nameEl) nameEl.textContent = (tunnel && tunnel.label) || tunnelId;
$('removeServiceTunnelConfirm').dataset.tunnelId = tunnelId;
openModal('modal-removeServiceTunnel');
});
});
_updateSvcBulkBar();
}
/**
* Attach all event listeners for the Service Tunnels page.
* Called once during dashboard initialisation.
*/
function setupServiceTunnelEvents() {
$('svcSelectAll')?.addEventListener('change', (e) => {
document.querySelectorAll('#serviceTunnelsTable .svc-row-cb').forEach(cb => { cb.checked = e.target.checked; });
_updateSvcBulkBar();
});
$('btnSvcBulkRemove')?.addEventListener('click', () => {
const checked = Array.from(document.querySelectorAll('#serviceTunnelsTable .svc-row-cb:checked'));
if (!checked.length) return;
const ids = checked.map(cb => cb.dataset.tunnelId);
if (!confirm('Remove ' + ids.length + ' service tunnel(s)?')) return;
let done = 0;
for (const tunnelId of ids) {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServiceTunnel', payload: { tunnelId } } },
() => { done++; if (done === ids.length) { showToast(ids.length + ' tunnel(s) removed', 'success'); refresh(); } }
);
}
});
$('addServiceTunnelBtn')?.addEventListener('click', () => {
$('serviceTunnelEditId').value = '';
$('serviceTunnelLabel').value = '';
$('serviceTunnelHsUrl').value = '';
$('serviceTunnelLocalPort').value = '';
const titleEl = $('modal-addServiceTunnel-title');
if (titleEl) titleEl.textContent = 'Add Service Tunnel';
const submitEl = $('serviceTunnelSubmit');
if (submitEl) submitEl.textContent = 'Connect';
openModal('modal-addServiceTunnel');
});
$('serviceTunnelSubmit')?.addEventListener('click', () => {
const label = ($('serviceTunnelLabel')?.value || '').trim();
const hsUrl = ($('serviceTunnelHsUrl')?.value || '').trim();
const localPort = parseInt($('serviceTunnelLocalPort')?.value, 10);
const editId = ($('serviceTunnelEditId')?.value || '').trim();
if (!label) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Label is required'); return; }
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Enter a valid hs:// key'); return; }
if (!localPort || localPort < 1 || localPort > 65535) { showModalError('modal-addServiceTunnel', 'serviceTunnelError', 'Local port must be 165535'); return; }
const btn = $('serviceTunnelSubmit');
if (btn) { btn.disabled = true; btn.textContent = 'Connecting…'; }
const type = editId ? 'updateServiceTunnel' : 'startServiceTunnel';
const payload = editId ? { tunnelId: editId, label, hsUrl, localPort } : { label, hsUrl, localPort };
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type, payload } },
(response) => {
if (btn) { btn.disabled = false; btn.textContent = editId ? 'Save' : 'Connect'; }
if (response?.ok) {
closeModal('modal-addServiceTunnel');
showToast(editId ? 'Tunnel updated' : 'Service tunnel connected', 'success');
refresh();
} else {
showModalError('modal-addServiceTunnel', 'serviceTunnelError', response?.error || 'Failed to connect');
}
}
);
});
$('removeServiceTunnelConfirm')?.addEventListener('click', () => {
const tunnelId = $('removeServiceTunnelConfirm').dataset.tunnelId;
if (!tunnelId) return;
const btn = $('removeServiceTunnelConfirm');
btn.disabled = true; btn.textContent = 'Removing…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServiceTunnel', payload: { tunnelId } } },
(response) => {
btn.disabled = false; btn.textContent = 'Remove';
closeModal('modal-removeServiceTunnel');
if (response?.ok) showToast('Service tunnel removed', 'success');
else showToast(response?.error || 'Failed to remove', 'error');
refresh();
}
);
});
}