+116
-82
@@ -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>
|
||||
|
||||
+190
-108
@@ -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 servers = state.servers || [];
|
||||
const virtualHosts = state.virtualHosts || [];
|
||||
const serviceTunnels = state.serviceTunnels || [];
|
||||
|
||||
// Sidebar status
|
||||
const dot = $('sidebarDot');
|
||||
@@ -888,18 +1052,16 @@ 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 : '—');
|
||||
setText('dashTabs', state.proxyPort != null ? state.proxyPort : '—');
|
||||
|
||||
const caIcon = $('caStatIcon');
|
||||
if (caIcon) {
|
||||
@@ -916,121 +1078,41 @@ function updateDashboard(state) {
|
||||
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';
|
||||
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}` : '—');
|
||||
|
||||
// Last updated timestamp
|
||||
const lu = $('overviewLastUpdated');
|
||||
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
|
||||
// ── Nav badges ────────────────────────────────────────────────────────────
|
||||
setText('swarmCount', servers.length);
|
||||
setText('connCount', virtualHosts.length);
|
||||
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');
|
||||
setText('sshCount', sshConnections.length);
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user