/** * 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 = `
No service tunnels
Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail
`; _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, '"'); return ` ${escapeHtml(label)}
${truncate(hsUrl, 28)} ${hsUrl ? `` : ''}
${escapeHtml(localAddr)} ${t.localPort != null ? `` : ''}
${stateTag(t.state)} ${t.localPort != null && settings.latencyPingEnabled !== false ? `…ms` : ''}
${(t.state === 'error' || t.state === 'closed') ? ` ` : ''}
`; }).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 1–65535'); 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(); } ); }); }