Files
holesail-browser/extension/dashboard/pages/overview.js
T
Raven Scott f1e98a7edd
CI / Build & Test (push) Successful in 2m54s
docs: add CONTRIBUTING.md, CHANGELOG.md, and JSDoc to entire codebase
Add docs/CONTRIBUTING.md covering the build system, dev workflow, all
npm scripts, how to add new native host message types, code style, and
debugging guidance.

Add CHANGELOG.md at the project root documenting all features and fixes
across the 1.0.0 release.

Add JSDoc (@param, @returns) to all previously undocumented exported
functions across 35 JS files:
- native-host/holesail-manager/ (index, virtual-hosts, service-tunnels,
  servers, port-allocator)
- native-host top-level managers (startup, connect-proxy, https-proxy,
  certificate-authority, ssh-manager, rdp-manager)
- extension/background/ (logs, native-messaging, proxy, message-router)
- extension/dashboard/core/ (utils, navigation, init)
- extension/dashboard/ui/ (modal, toast, state-tag)
- extension/dashboard/pages/ (all 10 page files)
- extension/dashboard/refresh.js, events.js
- extension/dashboard/data/hostname-validator.js
- scripts/ (build-host, run-install)
2026-03-01 00:40:53 -05:00

274 lines
11 KiB
JavaScript

/**
* Overview page — summary stats, quick-action cards, and virtualized connection lists.
* Uses the OvList class for infinite-scroll, searchable tables of connections and servers.
* Depends on: core/utils.js ($, escapeHtml), ui/state-tag.js (stateTag),
* ui/modal.js (openModal), pages/ssh.js (openAddSshModal),
* pages/rdp.js (openAddRdpModal), core/state.js (sshConnections, rdpConnections)
*/
const OV_PAGE = 20; // rows per page
/**
* Virtualized, searchable, infinite-scroll list for the Overview page.
* Renders rows in pages of OV_PAGE, loading more when the sentinel element scrolls into view.
*/
class OvList {
constructor({ bodyId, sentinelId, searchId, subtitleId, rowFn, emptyMsg, cols }) {
this.body = $(bodyId);
this.sentinel = $(sentinelId);
this.searchEl = $(searchId);
this.subtitleEl = $(subtitleId);
this.rowFn = rowFn;
this.emptyMsg = emptyMsg;
this.cols = cols;
this.data = [];
this.filtered = [];
this.rendered = 0;
this._debounce = null;
this._observer = null;
this._initObserver();
this._initSearch();
}
_initObserver() {
if (!this.sentinel) return;
this._observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) this._appendPage();
}, { threshold: 0 });
this._observer.observe(this.sentinel);
}
_initSearch() {
if (!this.searchEl) return;
this.searchEl.addEventListener('input', () => {
clearTimeout(this._debounce);
this._debounce = setTimeout(() => this._applyFilter(), 180);
});
}
setData(data, subtitleText) {
this.data = data;
if (this.subtitleEl) this.subtitleEl.textContent = subtitleText;
this._applyFilter();
}
_applyFilter() {
const q = (this.searchEl?.value || '').trim().toLowerCase();
this.filtered = q
? this.data.filter(item => JSON.stringify(item).toLowerCase().includes(q))
: this.data.slice();
this._reset();
}
_reset() {
if (!this.body) return;
this.rendered = 0;
this.body.innerHTML = '';
if (this.filtered.length === 0) {
this.body.innerHTML = `<tr><td colspan="${this.cols}" class="empty">${this.emptyMsg}</td></tr>`;
return;
}
this._appendPage();
}
_appendPage() {
if (!this.body || this.rendered >= this.filtered.length) return;
const next = this.filtered.slice(this.rendered, this.rendered + OV_PAGE);
this.body.insertAdjacentHTML('beforeend', next.map(this.rowFn).join(''));
this.rendered += next.length;
}
}
// Instances — created once, reused on every updateDashboard call
let _ovVhost = null;
let _ovServers = null;
let _ovSvc = null;
let _ovSsh = null;
let _ovRdp = null;
/**
* Create and wire up the OvList instances for virtual hosts and server tunnels.
* Called once during dashboard initialisation.
*/
function initOvLists() {
_ovVhost = new OvList({
bodyId: 'recentConnections', sentinelId: 'ovVhostSentinel',
searchId: 'ovVhostSearch', subtitleId: 'ovVhostSubtitle',
cols: 3, emptyMsg: 'No virtual hosts',
rowFn: v => {
const h = v.hostname || v.id || '';
return `<tr>
<td style="font-size:12px;"><span class="mono-chip">${escapeHtml(h)}</span></td>
<td>${stateTag(v.state)}</td>
<td><a href="https://${escapeHtml(h)}" target="_blank" rel="noopener"
class="btn btn-ghost btn-sm" style="padding:3px 8px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
style="width:11px;height:11px;">
<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></td>
</tr>`;
}
});
_ovServers = new OvList({
bodyId: 'ovServersBody', sentinelId: 'ovServersSentinel',
searchId: 'ovServersSearch', subtitleId: 'ovServersSubtitle',
cols: 3, emptyMsg: 'No server tunnels',
rowFn: s => {
const key = s.url || s.hsUrl || '';
const short = key.length > 22 ? key.slice(0, 9) + '…' + key.slice(-7) : key;
const display = s.label ? escapeHtml(s.label) : `<span class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(String(s.port || '—'))}</span>`;
return `<tr>
<td style="font-size:12px;">${display}</td>
<td>${stateTag(s.state)}</td>
<td class="mono" style="font-size:11px;color:var(--text3);"
title="${escapeHtml(key)}">${escapeHtml(short)}</td>
</tr>`;
}
});
_ovSvc = new OvList({
bodyId: 'ovSvcBody', sentinelId: 'ovSvcSentinel',
searchId: 'ovSvcSearch', subtitleId: 'ovSvcSubtitle',
cols: 3, emptyMsg: 'No service tunnels',
rowFn: t => `<tr>
<td style="font-size:12px;">${escapeHtml(t.label || '—')}</td>
<td class="mono" style="font-size:12px;">${escapeHtml(String(t.localPort || '—'))}</td>
<td>${stateTag(t.state)}</td>
</tr>`
});
_ovSsh = new OvList({
bodyId: 'ovSshBody', sentinelId: 'ovSshSentinel',
searchId: 'ovSshSearch', subtitleId: 'ovSshSubtitle',
cols: 2, emptyMsg: 'No SSH connections',
rowFn: c => `<tr>
<td style="font-size:12px;">${escapeHtml(c.label || c.hsUrl || '—')}</td>
<td class="mono" style="font-size:12px;color:var(--text3);">${escapeHtml(c.username || '—')}</td>
</tr>`
});
_ovRdp = new OvList({
bodyId: 'ovRdpBody', sentinelId: 'ovRdpSentinel',
searchId: 'ovRdpSearch', subtitleId: 'ovRdpSubtitle',
cols: 2, emptyMsg: 'No remote desktops',
rowFn: c => `<tr>
<td style="font-size:12px;">${escapeHtml(c.label || '—')}</td>
<td><span class="badge badge-neutral" style="font-size:10px;padding:2px 6px;">
${escapeHtml((c.type || 'vnc').toUpperCase())}</span></td>
</tr>`
});
// Quick action buttons
$('qaAddVhost') ?.addEventListener('click', () => openModal('modal-addVhost'));
$('qaAddServer')?.addEventListener('click', () => {
const editIdEl = $('serverEditId');
if (editIdEl) editIdEl.value = '';
const labelEl = $('startServerLabel');
if (labelEl) labelEl.value = '';
const portEl = $('startServerPort');
if (portEl) portEl.value = '3000';
const hostEl = $('startServerHost');
if (hostEl) hostEl.value = '127.0.0.1';
const secureEl = $('startServerSecure');
if (secureEl) secureEl.checked = true;
const tcpEl = $('startServerProtocolTcp');
if (tcpEl) tcpEl.checked = true;
const titleEl = $('modal-startServer-title');
if (titleEl) titleEl.textContent = 'Start Server Tunnel';
const submitEl = $('startServerSubmit');
if (submitEl) submitEl.textContent = 'Start Server';
openModal('modal-startServer');
});
$('qaAddSvc') ?.addEventListener('click', () => openModal('modal-addServiceTunnel'));
$('qaAddSsh') ?.addEventListener('click', () => openAddSshModal(null));
$('qaAddRdp') ?.addEventListener('click', () => openAddRdpModal(null));
$('qaInstallCA') ?.addEventListener('click', () => openModal('modal-installCA'));
}
/**
* Render the Overview page with the latest extension state.
* Updates summary stat cards, quick-action buttons, and the connection/server lists.
* @param {object} state - Full extension state from `fetchState()`.
*/
function updateDashboard(state) {
currentState = state;
const servers = state.servers || [];
const virtualHosts = state.virtualHosts || [];
const serviceTunnels = state.serviceTunnels || [];
const dot = $('sidebarDot');
const statusText = $('sidebarStatus');
if (state.hostConnected) {
dot?.classList.add('connected');
dot?.classList.remove('disconnected');
if (statusText) statusText.textContent = 'Connected';
} else {
dot?.classList.remove('connected');
dot?.classList.add('disconnected');
if (statusText) statusText.textContent = 'Disconnected';
}
const setText = (id, val) => { const el = $(id); if (el) el.textContent = val; };
setText('dashConnections', virtualHosts.length);
setText('dashSwarms', servers.length);
setText('dashServiceTunnels', serviceTunnels.length);
setText('dashSsh', sshConnections.length);
setText('dashRdp', rdpConnections.length);
setText('dashUptime', state.caInstalled ? 'Trusted' : 'Not trusted');
const caIcon = $('caStatIcon');
if (caIcon) {
caIcon.style.background = state.caInstalled ? 'var(--green-dim)' : 'var(--red-dim)';
caIcon.style.color = state.caInstalled ? 'var(--green)' : 'var(--red)';
}
const ovHostDot = $('ovHostDot');
if (ovHostDot) {
ovHostDot.classList.toggle('connected', !!state.hostConnected);
ovHostDot.classList.toggle('disconnected', !state.hostConnected);
}
setText('ovHostLabel', state.hostConnected ? 'Connected' : 'Disconnected');
const ovCaDot = $('ovCaDot');
if (ovCaDot) {
ovCaDot.style.background = state.caInstalled ? 'var(--green)' : 'var(--red)';
ovCaDot.style.boxShadow = state.caInstalled ? '0 0 5px var(--green)' : 'none';
}
setText('ovCaLabel', state.caInstalled ? 'Installed & trusted' : 'Not installed');
setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—');
setText('ovConnectLabel', state.connectProxyPort != null ? `port ${state.connectProxyPort}` : '—');
if (state.trafficStats) {
const { bytesIn, bytesOut, requests } = state.trafficStats;
const fmt = (n) => n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : n + ' B';
setText('ovTrafficLabel', `↑ ${fmt(bytesIn)}${fmt(bytesOut)} (${requests} req)`);
}
const lu = $('overviewLastUpdated');
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
setText('swarmCount', servers.length);
setText('connCount', virtualHosts.length);
setText('serviceTunnelCount', serviceTunnels.length);
setText('sshCount', sshConnections.length);
setText('rdpCount', rdpConnections.length);
setText('tabCount', state.caInstalled ? 'CA ✓' : 'CA');
if (!_ovVhost) initOvLists();
const sub = (n, unit, readyArr) => {
if (n === 0) return `No ${unit}s`;
const r = readyArr.filter(x => x.state === 'ready').length;
return `${n} ${unit}${n !== 1 ? 's' : ''}${r} ready`;
};
_ovVhost .setData(virtualHosts, sub(virtualHosts.length, 'host', virtualHosts));
_ovServers.setData(servers, sub(servers.length, 'tunnel', servers));
_ovSvc .setData(serviceTunnels, sub(serviceTunnels.length, 'tunnel', serviceTunnels));
_ovSsh .setData(sshConnections, sshConnections.length === 0
? 'No SSH connections' : `${sshConnections.length} saved`);
_ovRdp .setData(rdpConnections, rdpConnections.length === 0
? 'No remote desktops' : `${rdpConnections.length} saved`);
}