CI / Build & Test (push) Successful in 3m1s
Snapshot checked row IDs before each innerHTML swap in the virtual hosts, service tunnels, and server tunnels tables so that periodic refresh cycles no longer reset multi-selection state.
245 lines
11 KiB
JavaScript
245 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">Click "Add Host" to assign a hostname to an hs:// tunnel</div>
|
|
</div>
|
|
</td></tr>`;
|
|
_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 `
|
|
<tr>
|
|
<td style="width:32px;"><input type="checkbox" class="vhost-row-cb" data-hostname="${safeHostname}"></td>
|
|
<td>
|
|
<span class="mono-chip">${escapeHtml(hostname)}</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)}" 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;
|
|
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();
|
|
}
|
|
);
|
|
});
|
|
}
|