dashboard updates test
CI / Build & Test (push) Successful in 3m15s

This commit is contained in:
Raven Scott
2026-02-28 18:20:12 -05:00
parent 1b23cffb48
commit 678e7e237c
2 changed files with 306 additions and 190 deletions
+116 -82
View File
@@ -89,8 +89,9 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 18px;
line-height: 1;
}
.sidebar-logo-icon svg { width: 18px; height: 18px; color: var(--cyan); }
.sidebar-logo-text { line-height: 1.2; }
.sidebar-logo-name { font-size: 15px; font-weight: 700; color: var(--text); letter-spacing: -0.02em; }
.sidebar-logo-version { font-size: 11px; color: var(--text4); margin-top: 1px; }
@@ -314,6 +315,42 @@
.badge-red { background: var(--red-dim); color: var(--red); }
.badge-neutral { background: var(--elevated); color: var(--text3); }
/* ── Overview list cards ────────────────────────── */
#page-dashboard {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.ov-list-card {
display: flex;
flex-direction: column;
min-height: 0;
/* Each card takes equal share of remaining vertical space */
}
.ov-scroll-area {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 80px;
}
.ov-table thead th {
position: sticky;
top: 0;
background: var(--card);
z-index: 1;
}
.ov-sentinel { height: 1px; }
/* Make the two grid rows share available height evenly */
#page-dashboard > div[style*="grid-template-columns:1fr 1fr"] {
flex: 1;
min-height: 0;
}
#page-dashboard > div[style*="grid-template-columns:1fr 1fr 1fr"] {
flex: 1;
min-height: 0;
}
/* ── Tables ────────────────────────────────────── */
.table { width: 100%; border-collapse: collapse; }
.table th {
@@ -845,12 +882,7 @@
<!-- ── Sidebar ──────────────────────────────────── -->
<nav class="sidebar">
<div class="sidebar-logo">
<div class="sidebar-logo-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M3 17l4-8 4 4 4-6 4 10"/>
<path d="M3 21h18"/>
</svg>
</div>
<div class="sidebar-logo-icon"></div>
<div class="sidebar-logo-text">
<div class="sidebar-logo-name">Holesail</div>
<div class="sidebar-logo-version">v1.0.0 · hole.sail</div>
@@ -1042,138 +1074,140 @@
</div>
</div>
<!-- Two-column layout: Virtual Hosts + right column -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px;">
<!-- Lists: 2-col top row, 3-col bottom row — each card has fixed height + scroll -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:10px;">
<!-- Virtual Hosts preview -->
<div class="card">
<div class="card-header">
<div>
<!-- Virtual Hosts -->
<div class="card ov-list-card">
<div class="card-header" style="padding-bottom:8px;">
<div style="min-width:0;">
<div class="card-title">Virtual Hosts</div>
<div class="card-subtitle" id="ovVhostSubtitle">No virtual hosts configured</div>
<div class="card-subtitle" id="ovVhostSubtitle"></div>
</div>
<button class="btn btn-ghost btn-sm" data-page-link="connections">View all →</button>
</div>
<div class="card-body flush">
<table class="table">
<thead><tr><th>Hostname</th><th>State</th><th>Open</th></tr></thead>
<tbody id="recentConnections">
<tr><td colspan="3" class="empty">No virtual hosts yet</td></tr>
</tbody>
<div style="padding:0 12px 8px;">
<input class="input ov-search" id="ovVhostSearch" placeholder="Filter…" autocomplete="off" style="height:28px;font-size:12px;">
</div>
<div class="ov-scroll-area">
<table class="table ov-table">
<thead><tr><th>Hostname</th><th>State</th><th></th></tr></thead>
<tbody id="recentConnections"></tbody>
</table>
<div id="ovVhostSentinel" class="ov-sentinel"></div>
</div>
</div>
<!-- Server Tunnels preview -->
<div class="card">
<div class="card-header">
<div>
<!-- Server Tunnels -->
<div class="card ov-list-card">
<div class="card-header" style="padding-bottom:8px;">
<div style="min-width:0;">
<div class="card-title">Server Tunnels</div>
<div class="card-subtitle" id="ovServersSubtitle">No server tunnels running</div>
<div class="card-subtitle" id="ovServersSubtitle"></div>
</div>
<button class="btn btn-ghost btn-sm" data-page-link="swarms">View all →</button>
</div>
<div class="card-body flush">
<table class="table">
<div style="padding:0 12px 8px;">
<input class="input ov-search" id="ovServersSearch" placeholder="Filter…" autocomplete="off" style="height:28px;font-size:12px;">
</div>
<div class="ov-scroll-area">
<table class="table ov-table">
<thead><tr><th>Port</th><th>State</th><th>hs:// Key</th></tr></thead>
<tbody id="ovServersBody">
<tr><td colspan="3" class="empty">No server tunnels yet</td></tr>
</tbody>
<tbody id="ovServersBody"></tbody>
</table>
<div id="ovServersSentinel" class="ov-sentinel"></div>
</div>
</div>
</div>
<!-- Second row: Service Tunnels + SSH + Remote Desktop -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:12px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:10px;">
<!-- Service Tunnels preview -->
<div class="card">
<div class="card-header">
<div>
<!-- Service Tunnels -->
<div class="card ov-list-card">
<div class="card-header" style="padding-bottom:8px;">
<div style="min-width:0;">
<div class="card-title">Service Tunnels</div>
<div class="card-subtitle" id="ovSvcSubtitle">No service tunnels</div>
<div class="card-subtitle" id="ovSvcSubtitle"></div>
</div>
<button class="btn btn-ghost btn-sm" data-page-link="service-tunnels">View all →</button>
</div>
<div class="card-body flush">
<table class="table">
<div style="padding:0 12px 8px;">
<input class="input ov-search" id="ovSvcSearch" placeholder="Filter…" autocomplete="off" style="height:28px;font-size:12px;">
</div>
<div class="ov-scroll-area">
<table class="table ov-table">
<thead><tr><th>Label</th><th>Port</th><th>State</th></tr></thead>
<tbody id="ovSvcBody">
<tr><td colspan="3" class="empty">None</td></tr>
</tbody>
<tbody id="ovSvcBody"></tbody>
</table>
<div id="ovSvcSentinel" class="ov-sentinel"></div>
</div>
</div>
<!-- SSH preview -->
<div class="card">
<div class="card-header">
<div>
<!-- SSH Connections -->
<div class="card ov-list-card">
<div class="card-header" style="padding-bottom:8px;">
<div style="min-width:0;">
<div class="card-title">SSH Connections</div>
<div class="card-subtitle" id="ovSshSubtitle">No SSH connections saved</div>
<div class="card-subtitle" id="ovSshSubtitle"></div>
</div>
<button class="btn btn-ghost btn-sm" data-page-link="ssh">View all →</button>
</div>
<div class="card-body flush">
<table class="table">
<div style="padding:0 12px 8px;">
<input class="input ov-search" id="ovSshSearch" placeholder="Filter…" autocomplete="off" style="height:28px;font-size:12px;">
</div>
<div class="ov-scroll-area">
<table class="table ov-table">
<thead><tr><th>Label</th><th>User</th></tr></thead>
<tbody id="ovSshBody">
<tr><td colspan="2" class="empty">None</td></tr>
</tbody>
<tbody id="ovSshBody"></tbody>
</table>
<div id="ovSshSentinel" class="ov-sentinel"></div>
</div>
</div>
<!-- Remote Desktop preview -->
<div class="card">
<div class="card-header">
<div>
<!-- Remote Desktops -->
<div class="card ov-list-card">
<div class="card-header" style="padding-bottom:8px;">
<div style="min-width:0;">
<div class="card-title">Remote Desktops</div>
<div class="card-subtitle" id="ovRdpSubtitle">No remote desktops saved</div>
<div class="card-subtitle" id="ovRdpSubtitle"></div>
</div>
<button class="btn btn-ghost btn-sm" data-page-link="rdp">View all →</button>
</div>
<div class="card-body flush">
<table class="table">
<div style="padding:0 12px 8px;">
<input class="input ov-search" id="ovRdpSearch" placeholder="Filter…" autocomplete="off" style="height:28px;font-size:12px;">
</div>
<div class="ov-scroll-area">
<table class="table ov-table">
<thead><tr><th>Label</th><th>Type</th></tr></thead>
<tbody id="ovRdpBody">
<tr><td colspan="2" class="empty">None</td></tr>
</tbody>
<tbody id="ovRdpBody"></tbody>
</table>
<div id="ovRdpSentinel" class="ov-sentinel"></div>
</div>
</div>
</div>
<!-- Quick actions -->
<div class="card">
<div class="card-header">
<div class="card-title">Quick Actions</div>
</div>
<div class="card-body" style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-secondary btn-sm" data-page-link="connections" id="qaAddVhost">
<div class="card" style="flex-shrink:0;">
<div class="card-header"><div class="card-title">Quick Actions</div></div>
<div class="card-body" style="display:flex;gap:8px;flex-wrap:wrap;padding-top:10px;padding-bottom:10px;">
<button class="btn btn-secondary btn-sm" id="qaAddVhost">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Virtual Host
</button>
<button class="btn btn-secondary btn-sm" data-page-link="swarms" id="qaAddServer">
<button class="btn btn-secondary btn-sm" id="qaAddServer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Server Tunnel
</button>
<button class="btn btn-secondary btn-sm" data-page-link="service-tunnels" id="qaAddSvc">
<button class="btn btn-secondary btn-sm" id="qaAddSvc">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Service Tunnel
</button>
<button class="btn btn-secondary btn-sm" data-page-link="ssh" id="qaAddSsh">
<button class="btn btn-secondary btn-sm" id="qaAddSsh">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add SSH Connection
</button>
<button class="btn btn-secondary btn-sm" data-page-link="backups" id="qaBackup">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17,8 12,3 7,8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Create Backup
<button class="btn btn-secondary btn-sm" id="qaAddRdp">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Remote Desktop
</button>
<button class="btn btn-secondary btn-sm" data-page-link="logs" id="qaLogs">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
View Logs
<button class="btn btn-secondary btn-sm" id="qaInstallCA">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Install Root CA
</button>
</div>
</div>
+180 -98
View File
@@ -868,12 +868,176 @@ async function fetchState() {
});
}
// ── Overview infinite-scroll + debounced filter engine ───────────────────────
//
// Each list is managed by an OvList instance:
// - holds the full dataset and current filter string
// - renders PAGE_SIZE rows at a time into a <tbody>
// - an IntersectionObserver on a sentinel <div> appends the next page when
// the sentinel scrolls into view (infinite scroll)
// - a debounced input handler resets the list on every keystroke
const OV_PAGE = 20; // rows per page
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; // (item) => HTML string
this.emptyMsg = emptyMsg;
this.cols = cols; // colspan for empty row
this.data = []; // full unfiltered dataset
this.filtered = []; // after filter applied
this.rendered = 0; // rows currently in DOM
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;
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.hsUrl || '';
const short = key.length > 22 ? key.slice(0, 9) + '…' + key.slice(-7) : key;
return `<tr>
<td class="mono" style="font-size:12px;">${escapeHtml(String(s.port || '—'))}</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 → open existing modals directly
$('qaAddVhost') ?.addEventListener('click', () => openModal('modal-addVhost'));
$('qaAddServer') ?.addEventListener('click', () => openModal('modal-startServer'));
$('qaAddSvc') ?.addEventListener('click', () => openModal('modal-addServiceTunnel'));
$('qaAddSsh') ?.addEventListener('click', () => openModal('modal-addSsh'));
$('qaAddRdp') ?.addEventListener('click', () => openModal('modal-addRdp'));
$('qaInstallCA') ?.addEventListener('click', () => openModal('modal-installCA'));
}
// ── Dashboard / Overview ─────────────────────────────────────────────────────
function updateDashboard(state) {
currentState = state;
const servers = state.servers || [];
const virtualHosts = state.virtualHosts || [];
const serviceTunnels = state.serviceTunnels || [];
// Sidebar status
const dot = $('sidebarDot');
@@ -888,17 +1052,15 @@ function updateDashboard(state) {
if (statusText) statusText.textContent = 'Disconnected';
}
const serviceTunnels = state.serviceTunnels || [];
const setText = (id, val) => { const el = $(id); if (el) el.textContent = val; };
// ── Stat cards ────────────────────────────────────────────────────────────
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');
// Keep legacy dashTabs in sync (used by proxy & CA page)
setText('dashTabs', state.proxyPort != null ? state.proxyPort : '—');
const caIcon = $('caStatIcon');
@@ -923,7 +1085,6 @@ function updateDashboard(state) {
setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—');
setText('ovConnectLabel', state.connectProxyPort != null ? `port ${state.connectProxyPort}` : '—');
// Last updated timestamp
const lu = $('overviewLastUpdated');
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
@@ -935,102 +1096,23 @@ function updateDashboard(state) {
setText('rdpCount', rdpConnections.length);
setText('tabCount', state.caInstalled ? 'CA ✓' : 'CA');
// ── Virtual Hosts preview ─────────────────────────────────────────────────
const recentConnBody = $('recentConnections');
if (recentConnBody) {
const ovVhostSub = $('ovVhostSubtitle');
if (virtualHosts.length === 0) {
if (ovVhostSub) ovVhostSub.textContent = 'No virtual hosts configured';
recentConnBody.innerHTML = `<tr><td colspan="3" class="empty">No virtual hosts yet — <button class="btn btn-ghost btn-sm" id="addVhostFromEmpty" style="display:inline-flex;">Add one</button></td></tr>`;
$('addVhostFromEmpty')?.addEventListener('click', () => openModal('modal-addVhost'));
} else {
const ready = virtualHosts.filter(v => v.state === 'ready').length;
if (ovVhostSub) ovVhostSub.textContent = `${virtualHosts.length} host${virtualHosts.length !== 1 ? 's' : ''}${ready} ready`;
recentConnBody.innerHTML = virtualHosts.slice(0, 6).map(v => {
const hostname = v.hostname || v.id || '';
return `<tr>
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
<td>${stateTag(v.state)}</td>
<td><a href="https://${escapeHtml(hostname)}" 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>`;
}).join('');
}
}
// ── Initialise list engines on first call ─────────────────────────────────
if (!_ovVhost) initOvLists();
// ── Server Tunnels preview ────────────────────────────────────────────────
const ovServersBody = $('ovServersBody');
if (ovServersBody) {
const ovSrvSub = $('ovServersSubtitle');
if (servers.length === 0) {
if (ovSrvSub) ovSrvSub.textContent = 'No server tunnels running';
ovServersBody.innerHTML = `<tr><td colspan="3" class="empty">No server tunnels yet</td></tr>`;
} else {
const ready = servers.filter(s => s.state === 'ready').length;
if (ovSrvSub) ovSrvSub.textContent = `${servers.length} tunnel${servers.length !== 1 ? 's' : ''}${ready} ready`;
ovServersBody.innerHTML = servers.slice(0, 6).map(s => {
const key = s.hsUrl || '';
const short = key.length > 24 ? key.slice(0, 10) + '…' + key.slice(-8) : key;
return `<tr>
<td class="mono" style="font-size:12px;">${escapeHtml(String(s.port || '—'))}</td>
<td>${stateTag(s.state)}</td>
<td class="mono" style="font-size:11px;color:var(--text3);" title="${escapeHtml(key)}">${escapeHtml(short)}</td>
</tr>`;
}).join('');
}
}
// ── Feed data into each list (subtitle auto-computed) ─────────────────────
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`;
};
// ── Service Tunnels preview ───────────────────────────────────────────────
const ovSvcBody = $('ovSvcBody');
if (ovSvcBody) {
const ovSvcSub = $('ovSvcSubtitle');
if (serviceTunnels.length === 0) {
if (ovSvcSub) ovSvcSub.textContent = 'No service tunnels';
ovSvcBody.innerHTML = `<tr><td colspan="3" class="empty">None</td></tr>`;
} else {
const ready = serviceTunnels.filter(t => t.state === 'ready').length;
if (ovSvcSub) ovSvcSub.textContent = `${serviceTunnels.length} tunnel${serviceTunnels.length !== 1 ? 's' : ''}${ready} ready`;
ovSvcBody.innerHTML = serviceTunnels.slice(0, 5).map(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>`).join('');
}
}
// ── SSH preview ───────────────────────────────────────────────────────────
const ovSshBody = $('ovSshBody');
if (ovSshBody) {
const ovSshSub = $('ovSshSubtitle');
if (sshConnections.length === 0) {
if (ovSshSub) ovSshSub.textContent = 'No SSH connections saved';
ovSshBody.innerHTML = `<tr><td colspan="2" class="empty">None</td></tr>`;
} else {
if (ovSshSub) ovSshSub.textContent = `${sshConnections.length} saved connection${sshConnections.length !== 1 ? 's' : ''}`;
ovSshBody.innerHTML = sshConnections.slice(0, 5).map(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>`).join('');
}
}
// ── Remote Desktop preview ────────────────────────────────────────────────
const ovRdpBody = $('ovRdpBody');
if (ovRdpBody) {
const ovRdpSub = $('ovRdpSubtitle');
if (rdpConnections.length === 0) {
if (ovRdpSub) ovRdpSub.textContent = 'No remote desktops saved';
ovRdpBody.innerHTML = `<tr><td colspan="2" class="empty">None</td></tr>`;
} else {
if (ovRdpSub) ovRdpSub.textContent = `${rdpConnections.length} saved connection${rdpConnections.length !== 1 ? 's' : ''}`;
ovRdpBody.innerHTML = rdpConnections.slice(0, 5).map(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>`).join('');
}
}
_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`);
}
// ── Virtual Hosts table ──────────────────────────────────────────────────────