Files
holesail-browser/extension/dashboard/pages/virtual-hosts.js
T
Raven Scott 6fcd9fcf2b
CI / Build & Test (push) Successful in 3m19s
eat: implement Holesail-Browser enhancement plan
- Add unit tests (hostname-validator, TLDs, payload-schemas) and integration tests for message handler registry
- Refactor native host message router into handler registry (handlers/state, tunnels, ssh, rdp, backup, ca, connections)
- Add ESLint config and npm test + lint steps in CI
- Dashboard: visibility-based refresh pause, configurable refresh interval (2s/5s/10s/paused)
- Accessibility: ARIA on nav and modals, focus trap and restore, prefers-reduced-motion
- Empty states: primary action buttons for virtual hosts, servers, service tunnels
- Native host rate limiting for backup and CA operations; update SECURITY.md
- CONTRIBUTING: "Adding a new dashboard page", dev workflow; add npm run dev script
2026-03-15 00:24:31 -04:00

252 lines
11 KiB
JavaScript

/**
* 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 };
});
// Snapshot checked checkboxes so selection survives the innerHTML swap
const _checkedHostnames = new Set();
tbody.querySelectorAll('.vhost-row-cb:checked').forEach(cb => _checkedHostnames.add(cb.dataset.hostname));
if (virtualHosts.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"><circle cx="12" cy="12" r="2"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/><line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/><line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/></svg>
<div class="empty-state-title">No virtual hosts</div>
<div class="empty-state-desc">Assign a hostname to an hs:// tunnel to browse it in your browser.</div>
<button type="button" class="btn btn-primary empty-state-btn" data-open-modal="modal-addVhost">Add your first virtual host</button>
</div>
</td></tr>`;
_updateVhostBulkBar();
return;
}
tbody.innerHTML = virtualHosts.map(v => {
const hostname = v.hostname || v.id || '';
const hsUrl = v.hsUrl || '';
const useTls = v.useTls === true;
const backend = (v.localHost && v.localPort != null) ? v.localHost + ':' + v.localPort : '—';
const openUrl = `https://${hostname}`;
const safeHostname = hostname.replace(/"/g, '&quot;');
const needsReconnect = v.state === 'error' || v.state === 'closed';
return `
<tr>
<td style="width:32px;"><input type="checkbox" class="vhost-row-cb" data-hostname="${safeHostname}"></td>
<td>
<span class="mono-chip">${escapeHtml(hostname)}</span>
${useTls ? '<span class="badge badge-cyan" style="margin-left:6px;font-size:10px;">TLS</span>' : ''}
</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<span class="mono" title="${escapeHtml(hsUrl)}" style="color:var(--text3);font-size:11px;">${truncate(hsUrl, 28)}</span>
<button class="btn-icon copy-btn" data-copy="${escapeHtml(hsUrl)}" title="Copy hs:// URL" style="flex-shrink:0;">
<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 class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(backend)}</td>
<td>
${stateTag(v.state)}
${v.localPort != null && settings.latencyPingEnabled !== false ? `<span class="latency-badge" data-ping-host="127.0.0.1" data-ping-port="${v.localPort}" style="margin-left:4px;font-size:10px;color:var(--text4);">…ms</span>` : ''}
</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
<a href="${openUrl}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open
</a>
${needsReconnect ? `<button class="btn btn-secondary btn-sm" data-reconnect-vhost="${safeHostname}" data-hs-url="${escapeHtml(hsUrl)}" data-use-tls="${useTls ? '1' : '0'}" title="Reconnect tunnel">
<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-.07-8.13"/></svg>
Reconnect
</button>` : ''}
<button class="btn btn-danger btn-sm" data-remove-vhost="${safeHostname}">
<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('.vhost-row-cb').forEach(cb => {
if (_checkedHostnames.has(cb.dataset.hostname)) cb.checked = true;
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;
const useTls = btn.dataset.useTls === '1';
btn.disabled = true;
btn.textContent = 'Reconnecting…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl, useTls } } },
(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');
const useTlsEl = $('addVhostUseTls');
let hostname = (hostnameEl?.value || '').trim();
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
const hsUrl = (hsUrlEl?.value || '').trim();
const useTls = useTlsEl ? useTlsEl.checked : false;
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, useTls } } },
(response) => {
if (btn) { btn.disabled = false; btn.textContent = 'Add Host'; }
if (response?.ok) {
if (hostnameEl) hostnameEl.value = '';
if (hsUrlEl) hsUrlEl.value = '';
if (useTlsEl) useTlsEl.checked = false;
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();
}
);
});
}