/**
* 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), ui/toast.js (copyToClipboard, showToast),
* pages/ssh.js (openAddSshModal), pages/rdp.js (openAddRdpModal),
* core/state.js (sshConnections, rdpConnections), data/install-commands.js (getInstallCommand, detectPlatform, INSTALL_SCRIPT_BASE_URL)
*/
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.scrollArea = this.body?.closest('.ov-scroll-area') || null;
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 = `
| ${this.emptyMsg} |
`;
this.scrollArea?.classList.add('ov-empty');
return;
}
this.scrollArea?.classList.remove('ov-empty');
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;
/** Current platform for onboarding card (win32 | macos | linux). Set by detectPlatform or switcher. */
let _onboardingPlatform = null;
/**
* Update the onboarding card command and label for the given platform.
* @param {'macos'|'linux'|'win32'} platform
*/
function _updateOnboardingCommand(platform) {
if (typeof getInstallCommand === 'undefined' || typeof INSTALL_SCRIPT_BASE_URL === 'undefined') return;
const { label, command } = getInstallCommand(INSTALL_SCRIPT_BASE_URL, platform);
const labelEl = $('onboardingPlatformLabel');
const cmdEl = $('onboardingCommand');
if (labelEl) labelEl.textContent = label;
if (cmdEl) cmdEl.textContent = command;
}
/**
* 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 `
| ${escapeHtml(h)} |
${stateTag(v.state)} |
Open |
`;
}
});
_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) : `${escapeHtml(String(s.port || '—'))}`;
return `
| ${display} |
${stateTag(s.state)} |
${escapeHtml(short)} |
`;
}
});
_ovSvc = new OvList({
bodyId: 'ovSvcBody', sentinelId: 'ovSvcSentinel',
searchId: 'ovSvcSearch', subtitleId: 'ovSvcSubtitle',
cols: 3, emptyMsg: 'No service tunnels',
rowFn: t => `
| ${escapeHtml(t.label || '—')} |
${escapeHtml(String(t.localPort || '—'))} |
${stateTag(t.state)} |
`
});
_ovSsh = new OvList({
bodyId: 'ovSshBody', sentinelId: 'ovSshSentinel',
searchId: 'ovSshSearch', subtitleId: 'ovSshSubtitle',
cols: 2, emptyMsg: 'No SSH connections',
rowFn: c => `
| ${escapeHtml(c.label || c.hsUrl || '—')} |
${escapeHtml(c.username || '—')} |
`
});
_ovRdp = new OvList({
bodyId: 'ovRdpBody', sentinelId: 'ovRdpSentinel',
searchId: 'ovRdpSearch', subtitleId: 'ovRdpSubtitle',
cols: 2, emptyMsg: 'No remote desktops',
rowFn: c => `
| ${escapeHtml(c.label || '—')} |
${escapeHtml((c.type || 'vnc').toUpperCase())} |
`
});
// 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'));
// Onboarding card (native host not found): Copy, Check again, platform switcher
const copyBtn = $('onboardingCopyBtn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const cmdEl = $('onboardingCommand');
if (cmdEl && typeof copyToClipboard === 'function') copyToClipboard(cmdEl.textContent, copyBtn);
});
}
const checkAgainBtn = $('onboardingCheckAgainBtn');
if (checkAgainBtn) {
checkAgainBtn.addEventListener('click', async () => {
if (typeof refresh !== 'function') return;
const state = await refresh();
if (state?.hostConnected) {
if (typeof showToast === 'function') showToast('Native host connected', 'success');
} else {
if (typeof showToast === 'function') showToast('Still disconnected. Install the native host using the command above.', 'default');
}
});
}
const switchMacLinux = $('onboardingSwitchMacLinux');
if (switchMacLinux) {
switchMacLinux.addEventListener('click', () => {
_onboardingPlatform = 'macos';
_updateOnboardingCommand('macos');
});
}
const switchWin = $('onboardingSwitchWin');
if (switchWin) {
switchWin.addEventListener('click', () => {
_onboardingPlatform = 'win32';
_updateOnboardingCommand('win32');
});
}
}
/**
* 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();
// Onboarding card: show when native host is disconnected
const onboardingEl = $('onboardingNativeHost');
if (onboardingEl) {
if (!state.hostConnected) {
onboardingEl.style.display = 'block';
if (_onboardingPlatform == null && typeof detectPlatform === 'function') _onboardingPlatform = detectPlatform();
_updateOnboardingCommand(_onboardingPlatform || 'linux');
} else {
onboardingEl.style.display = 'none';
_onboardingPlatform = null;
}
}
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`);
}