/**
* Virtual Hosts page — renders the connections 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),
* data/hostname-validator.js (isValidVhostHostname)
*/
function _updateVhostBulkBar() {
const checked = document.querySelectorAll('#connectionsTable input[type="checkbox"]:checked');
const bar = $('vhostBulkBar');
const countEl = $('vhostBulkCount');
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 virtual hosts table with the latest state.
* @param {object} state - Full extension state from `fetchState()`.
*/
function updateConnectionsTable(state) {
const tbody = $('connectionsTable');
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 = `
No virtual hosts
Click "Add Host" to assign a hostname to an hs:// tunnel
|
`;
_updateVhostBulkBar();
return;
}
tbody.innerHTML = virtualHosts.map(v => {
const hostname = v.hostname || v.id || '';
const hsUrl = v.hsUrl || '';
const backend = (v.localHost && v.localPort != null) ? v.localHost + ':' + v.localPort : '—';
const openUrl = `https://${hostname}`;
const safeHostname = hostname.replace(/"/g, '"');
const needsReconnect = v.state === 'error' || v.state === 'closed';
return `
|
${escapeHtml(hostname)}
|
${truncate(hsUrl, 28)}
|
${escapeHtml(backend)} |
${stateTag(v.state)}
${v.localPort != null && settings.latencyPingEnabled !== false ? `…ms` : ''}
|
Open
${needsReconnect ? ` ` : ''}
|
`;
}).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);
});
tbody.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
});
tbody.querySelectorAll('[data-reconnect-vhost]').forEach(btn => {
btn.addEventListener('click', () => {
const hostname = btn.dataset.reconnectVhost;
const hsUrl = btn.dataset.hsUrl;
btn.disabled = true;
btn.textContent = 'Reconnecting…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
(response) => {
if (response?.ok) {
showToast('Tunnel reconnecting…', 'success');
} else {
showToast(response?.error || 'Reconnect failed', 'error');
}
refresh();
}
);
});
});
tbody.querySelectorAll('[data-remove-vhost]').forEach(btn => {
btn.addEventListener('click', () => {
const hostname = btn.dataset.removeVhost;
const nameEl = $('removeVhostName');
if (nameEl) nameEl.textContent = hostname;
$('removeVhostConfirm').dataset.hostname = hostname;
openModal('modal-removeVhost');
});
});
_updateVhostBulkBar();
}
/**
* Attach all event listeners for the Virtual Hosts page.
* Called once during dashboard initialisation.
*/
function setupVirtualHostEvents() {
$('vhostSelectAll')?.addEventListener('change', (e) => {
document.querySelectorAll('#connectionsTable .vhost-row-cb').forEach(cb => { cb.checked = e.target.checked; });
_updateVhostBulkBar();
});
$('btnVhostBulkRemove')?.addEventListener('click', () => {
const checked = Array.from(document.querySelectorAll('#connectionsTable .vhost-row-cb:checked'));
if (!checked.length) return;
const hostnames = checked.map(cb => cb.dataset.hostname);
if (!confirm('Remove ' + hostnames.length + ' virtual host(s)?')) return;
let done = 0;
for (const hostname of hostnames) {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'removeVirtualHost', payload: { hostname } } },
() => { done++; if (done === hostnames.length) { showToast(hostnames.length + ' host(s) removed', 'success'); refresh(); } }
);
}
});
$('addVhostBtn')?.addEventListener('click', () => openModal('modal-addVhost'));
$('addVhostSubmit')?.addEventListener('click', () => {
const hostnameEl = $('addVhostHostname');
const hsUrlEl = $('addVhostHsUrl');
let hostname = (hostnameEl?.value || '').trim();
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
const hsUrl = (hsUrlEl?.value || '').trim();
const hostnameValidation = isValidVhostHostname(hostname);
if (!hostnameValidation.ok) { showModalError('modal-addVhost', 'addVhostError', hostnameValidation.error); return; }
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addVhost', 'addVhostError', 'Enter a valid hs:// URL'); return; }
const btn = $('addVhostSubmit');
if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; }
function doAddVhost() {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
(response) => {
if (btn) { btn.disabled = false; btn.textContent = 'Add Host'; }
if (response?.ok) {
if (hostnameEl) hostnameEl.value = '';
if (hsUrlEl) hsUrlEl.value = '';
closeModal('modal-addVhost');
showToast('Virtual host added', 'success');
refresh();
} else {
showModalError('modal-addVhost', 'addVhostError', response?.error || 'Failed to add');
}
}
);
}
// Request host permissions synchronously within the user gesture so Chrome
// allows the permissions.request call. Non-hole.sail custom domains need
// explicit host permission for the extension to intercept their traffic.
const parts = hostname.split('.');
if (parts.length >= 3) {
const baseDomain = parts.slice(-2).join('.');
if (baseDomain !== 'hole.sail') {
const origin = '*://*.' + baseDomain + '/*';
chrome.permissions.request({ origins: [origin] }, () => {
void chrome.runtime.lastError;
doAddVhost();
});
return;
}
}
doAddVhost();
});
$('removeVhostConfirm')?.addEventListener('click', () => {
const hostname = $('removeVhostConfirm').dataset.hostname;
if (!hostname) return;
const btn = $('removeVhostConfirm');
btn.disabled = true; btn.textContent = 'Removing…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'removeVirtualHost', payload: { hostname } } },
(response) => {
btn.disabled = false; btn.textContent = 'Remove';
closeModal('modal-removeVhost');
if (response?.ok) {
showToast('Virtual host removed', 'success');
} else {
showToast(response?.error || 'Failed to remove', 'error');
}
refresh();
}
);
});
}