/**
* 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 };
});
// Snapshot checked checkboxes so selection survives the innerHTML swap
const _checkedTunnelIds = new Set();
tbody.querySelectorAll('.svc-row-cb:checked').forEach(cb => _checkedTunnelIds.add(cb.dataset.tunnelId));
if (tunnels.length === 0) {
tbody.innerHTML = `
No service tunnels
Forward a remote hs:// peer to a local TCP port (e.g. database, API).
|
`;
_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 => {
if (_checkedTunnelIds.has(cb.dataset.tunnelId)) cb.checked = true;
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();
}
);
});
}