// Command Center SPA Router const sections = document.querySelectorAll('.panel'); const links = document.querySelectorAll('.nav-links a'); function navigate(hash) { const target = hash.replace('#', '') || 'agents'; sections.forEach(s => s.hidden = s.id !== target); links.forEach(l => l.classList.toggle('active', l.getAttribute('href') === '#' + target)); // Init config panel when navigated to if (target === 'config') { initConfigPanel(); } } links.forEach(l => l.addEventListener('click', e => { e.preventDefault(); window.location.hash = l.getAttribute('href'); })); window.addEventListener('hashchange', () => navigate(window.location.hash)); navigate(window.location.hash || '#agents'); // ── Config File Editor ──────────────────────────────────────────────── let configState = { files: [], currentFile: null, currentContent: '', currentPath: '', dirty: false, lastSaved: null, }; function initConfigPanel() { if (configState.files.length === 0) { loadFileList(); } } async function loadFileList() { const container = document.getElementById('config-files-container'); container.innerHTML = '

Loading files...

'; try { const res = await fetch('/api/config/list'); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); configState.files = data.files; renderFileList(data.files); } catch (err) { container.innerHTML = `

Failed to load files: ${err.message}

`; } } function renderFileList(files) { const container = document.getElementById('config-files-container'); const countEl = document.getElementById('config-count'); countEl.textContent = `${files.length} file${files.length !== 1 ? 's' : ''}`; if (files.length === 0) { container.innerHTML = '

No config files found.

'; return; } container.innerHTML = ''; files.forEach(f => { const item = document.createElement('div'); item.className = 'config-file-item'; if (configState.currentPath === f.path) { item.classList.add('selected'); } item.dataset.path = f.path; const nameSpan = document.createElement('span'); nameSpan.className = 'file-item-name'; nameSpan.textContent = f.label || f.filename; const metaSpan = document.createElement('span'); metaSpan.className = 'file-item-meta'; if (f.missing) { metaSpan.textContent = 'missing'; metaSpan.style.color = 'var(--text-muted)'; } else { metaSpan.textContent = formatFileSize(f.size) + ' · ' + f.line_count + ' lines'; } item.appendChild(nameSpan); item.appendChild(metaSpan); item.addEventListener('click', () => openFile(f.path)); container.appendChild(item); }); } function formatFileSize(bytes) { if (bytes === 0) return '0 B'; const units = ['B', 'KB', 'MB']; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), 2); return (bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[i]; } async function openFile(path) { configState.dirty = false; configState.currentPath = path; configState.lastSaved = null; // Show editor view document.getElementById('config-file-list').hidden = true; document.getElementById('config-editor').hidden = false; document.getElementById('config-editor-textarea').disabled = true; document.getElementById('config-editor-textarea').value = 'Loading...'; document.getElementById('editor-line-numbers').innerHTML = ''; document.getElementById('editor-filename').textContent = ''; document.getElementById('status-path').textContent = 'Loading...'; document.getElementById('status-meta').textContent = ''; document.getElementById('status-saved').textContent = ''; try { const res = await fetch('/api/config/read?path=' + encodeURIComponent(path)); if (!res.ok) { const err = await res.json(); throw new Error(err.error || `HTTP ${res.status}`); } const data = await res.json(); configState.currentFile = data; configState.currentContent = data.content; const filename = data.label || data.filename; document.getElementById('editor-filename').textContent = filename; document.getElementById('config-editor-textarea').value = data.content; document.getElementById('config-editor-textarea').disabled = false; document.getElementById('config-editor-textarea').focus(); // Status bar document.getElementById('status-path').textContent = data.path; document.getElementById('status-meta').textContent = formatFileSize(data.size) + ' · ' + data.line_count + ' lines'; document.getElementById('status-saved').textContent = data.last_modified ? 'Last modified: ' + formatTimestamp(data.last_modified) : ''; updateLineNumbers(); document.getElementById('config-editor-textarea').addEventListener('input', onEditorChange); document.getElementById('config-editor-textarea').addEventListener('scroll', syncScroll); document.getElementById('config-editor-textarea').addEventListener('keydown', handleEditorKeydown); // Highlight selected in file list renderFileList(configState.files); } catch (err) { showToast('Error loading file: ' + err.message, 'error'); document.getElementById('config-editor-textarea').value = ''; document.getElementById('config-editor-textarea').disabled = true; } } function onEditorChange() { configState.dirty = true; updateLineNumbers(); updateSaveButton(); } function updateSaveButton() { const btn = document.getElementById('config-save-btn'); if (configState.dirty) { btn.textContent = 'Save'; btn.style.background = 'var(--accent)'; } else { btn.textContent = 'Saved ✓'; btn.style.background = 'var(--success)'; } } function syncScroll() { const textarea = document.getElementById('config-editor-textarea'); const lineNumbers = document.getElementById('editor-line-numbers'); lineNumbers.scrollTop = textarea.scrollTop; } function handleEditorKeydown(e) { // Tab key support if (e.key === 'Tab') { e.preventDefault(); const textarea = document.getElementById('config-editor-textarea'); const start = textarea.selectionStart; const end = textarea.selectionEnd; const val = textarea.value; textarea.value = val.substring(0, start) + ' ' + val.substring(end); textarea.selectionStart = textarea.selectionEnd = start + 4; onEditorChange(); } // Ctrl+S / Cmd+S if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveFile(); } } function updateLineNumbers() { const textarea = document.getElementById('config-editor-textarea'); const lineNumbers = document.getElementById('editor-line-numbers'); const lines = textarea.value.split('\n'); const count = lines.length; let html = ''; for (let i = 1; i <= count; i++) { html += '
' + i + '
'; } lineNumbers.innerHTML = html; } function goBackToList() { if (configState.dirty) { if (!confirm('You have unsaved changes. Discard them?')) { return; } } document.getElementById('config-file-list').hidden = false; document.getElementById('config-editor').hidden = true; configState.currentFile = null; configState.currentContent = ''; configState.currentPath = ''; configState.dirty = false; renderFileList(configState.files); } async function saveFile() { if (!configState.dirty) return; const textarea = document.getElementById('config-editor-textarea'); const content = textarea.value; const path = configState.currentPath; // Confirmation dialog if (!confirm('Save changes to ' + (configState.currentFile?.label || path) + '?')) { return; } const btn = document.getElementById('config-save-btn'); btn.disabled = true; btn.textContent = 'Saving...'; try { const res = await fetch('/api/config/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, content }), }); const data = await res.json(); if (!res.ok) { throw new Error(data.error || `HTTP ${res.status}`); } configState.dirty = false; configState.lastSaved = new Date(); configState.currentContent = content; // Update status document.getElementById('status-meta').textContent = formatFileSize(data.size) + ' · ' + data.line_count + ' lines'; document.getElementById('status-saved').textContent = 'Saved: ' + formatTimestamp(new Date().toISOString()); showToast('File saved successfully. Backup: ' + data.backup_path, 'success'); updateSaveButton(); // Refresh file list metadata loadFileList(); } catch (err) { showToast('Save failed: ' + err.message, 'error'); } finally { btn.disabled = false; } } function formatTimestamp(iso) { try { const d = new Date(iso); return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } catch { return iso; } } function showToast(message, type) { const toast = document.getElementById('config-toast'); toast.textContent = message; toast.className = 'config-toast toast-' + (type || 'info'); toast.hidden = false; // Position toast const editor = document.querySelector('.config-editor'); if (!editor.hidden) { const rect = editor.getBoundingClientRect(); toast.style.top = (rect.top + 20) + 'px'; toast.style.left = '50%'; toast.style.transform = 'translateX(-50%)'; } clearTimeout(toast._timeout); toast._timeout = setTimeout(() => { toast.hidden = true; }, 4000); } // ── Event listeners ────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { document.getElementById('config-save-btn').addEventListener('click', saveFile); document.getElementById('config-back-btn').addEventListener('click', goBackToList); initNetworkDashboard(); }); // ── Network Dashboard ──────────────────────────────────────────────── let networkState = { autoRefresh: true, refreshInterval: 30000, // 30 seconds countdownInterval: null, refreshTimer: null, countdownRemaining: 0, data: null, expandedHost: null, }; function initNetworkDashboard() { // Tab switching document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); const tab = document.getElementById('tab-' + btn.dataset.tab); if (tab) tab.classList.add('active'); }); }); // Refresh button document.getElementById('network-refresh-btn').addEventListener('click', () => { forceRefresh(); }); // Auto-refresh toggle document.getElementById('auto-refresh-toggle').addEventListener('click', () => { networkState.autoRefresh = document.getElementById('auto-refresh-toggle').checked; if (networkState.autoRefresh) { startAutoRefresh(); } else { stopAutoRefresh(); } }); // Listen for navigation to network panel const networkLink = document.querySelector('.nav-links a[href="#network"]'); if (networkLink) { networkLink.addEventListener('click', () => { setTimeout(() => { if (!networkState.data) { fetchNetworkData(); } }, 100); }); } // Initial load (in case we start on network tab) fetchNetworkData(); } async function fetchNetworkData() { try { const res = await fetch('/api/network/refresh'); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); networkState.data = data; renderNetwork(data); updateFooter(data); if (networkState.autoRefresh) { startAutoRefresh(); } } catch (err) { showNetworkError('Failed to fetch network data: ' + err.message); } } function forceRefresh() { networkState.data = null; if (networkState.refreshTimer) { clearTimeout(networkState.refreshTimer); networkState.refreshTimer = null; } if (networkState.countdownInterval) { clearInterval(networkState.countdownInterval); networkState.countdownInterval = null; } document.getElementById('hosts-grid').innerHTML = '
Refreshing...
'; document.getElementById('services-tbody').innerHTML = 'Refreshing...'; document.getElementById('local-services-tbody').innerHTML = 'Refreshing...'; fetchNetworkData(); } function startAutoRefresh() { stopAutoRefresh(); networkState.countdownRemaining = 30; updateCountdownBar(); networkState.countdownInterval = setInterval(() => { networkState.countdownRemaining--; if (networkState.countdownRemaining <= 0) { networkState.countdownRemaining = 30; fetchNetworkData(); } updateCountdownBar(); }, 1000); networkState.refreshTimer = setTimeout(() => { // Safety timeout in case countdown misses fetchNetworkData(); }, networkState.refreshInterval + 1000); } function stopAutoRefresh() { if (networkState.countdownInterval) { clearInterval(networkState.countdownInterval); networkState.countdownInterval = null; } if (networkState.refreshTimer) { clearTimeout(networkState.refreshTimer); networkState.refreshTimer = null; } document.getElementById('countdown-fill').style.width = '0%'; } function updateCountdownBar() { const pct = (networkState.countdownRemaining / 30) * 100; document.getElementById('countdown-fill').style.width = pct + '%'; } function renderNetwork(data) { renderHosts(data.hosts || []); renderServices(data.services || []); renderLocalServices(data.local_services || []); renderSummary(data.hosts || []); } function renderSummary(hosts) { let online = 0, offline = 0, degraded = 0; hosts.forEach(h => { if (h.status === 'online') online++; else if (h.status === 'offline') offline++; else if (h.status === 'degraded') degraded++; else offline++; // unknown hosts counted as offline }); document.getElementById('hosts-online').textContent = online; document.getElementById('hosts-offline').textContent = offline; document.getElementById('hosts-degraded').textContent = degraded; document.getElementById('hosts-total').textContent = hosts.length; } function renderHosts(hosts) { const grid = document.getElementById('hosts-grid'); if (!hosts || hosts.length === 0) { grid.innerHTML = '
No hosts configured.
'; return; } grid.innerHTML = ''; hosts.forEach(host => { const card = document.createElement('div'); card.className = 'host-card status-' + (host.status || 'unknown'); card.dataset.host = host.name; const statusLabel = host.status === 'online' ? 'Online' : host.status === 'offline' ? 'Offline' : host.status === 'degraded' ? 'Degraded' : 'Unknown'; let latencyDisplay = host.latency_ms != null ? host.latency_ms.toFixed(1) + 'ms' : '—'; let osDisplay = host.os || ''; card.innerHTML = `
${escapeHtml(host.name)} ${statusLabel}
${escapeHtml(host.ip || '—')} ${osDisplay ? '' + escapeHtml(osDisplay) + '' : ''} ${latencyDisplay}
${host.role ? '
' + escapeHtml(host.role) + '
' : ''} ${host.ports && host.ports.length > 0 ? renderPorts(host.ports) : ''}
`; // Click to expand const expandBtn = card.querySelector('.host-expand-btn'); expandBtn.addEventListener('click', (e) => { e.stopPropagation(); const detailsId = 'host-details-' + escapeHtml(host.name).replace(/\s/g, '_'); const details = document.getElementById(detailsId); if (details) { details.classList.toggle('hidden'); expandBtn.textContent = details.classList.contains('hidden') ? 'Details' : 'Hide'; } }); grid.appendChild(card); }); } function renderPorts(ports) { if (!ports || ports.length === 0) return ''; let html = '
Ports:'; ports.forEach(p => { const openClass = p.open ? 'port-open' : 'port-closed'; html += `${escapeHtml(p.name)}:${p.port}`; }); html += '
'; return html; } function renderHostDetails(host) { let html = ''; const fields = [ ['Hostname', host.hostname], ['OS', host.os], ['Role', host.role], ['Local IP', host.local_ip], ['Tailscale IP', host.tailscale_ip], ['Public IP', host.public_ip], ['Status', host.status], ['Latency', host.latency_ms != null ? host.latency_ms.toFixed(1) + 'ms' : '—'], ]; fields.forEach(([label, val]) => { if (val) { html += ``; } }); if (host.ports && host.ports.length > 0) { html += ''; } html += '
${escapeHtml(label)}${escapeHtml(String(val))}
Ports'; host.ports.forEach(p => { const icon = p.open ? '✓' : '✗'; const cls = p.open ? 'port-open' : 'port-closed'; html += `${icon} ${escapeHtml(p.name)}:${p.port} `; }); html += '
'; return html; } function renderServices(services) { const tbody = document.getElementById('services-tbody'); if (!services || services.length === 0) { tbody.innerHTML = 'No services configured.'; return; } tbody.innerHTML = ''; services.forEach(svc => { const tr = document.createElement('tr'); const status = svc.status || 'unknown'; const statusLabel = status === 'online' ? 'Online' : status === 'offline' ? 'Offline' : 'Unknown'; const latency = svc.latency_ms != null ? svc.latency_ms.toFixed(1) + 'ms' : '—'; const url = svc.url || (svc.port ? ':' + svc.port : '—'); const category = svc.category || '—'; tr.innerHTML = ` ${escapeHtml(svc.name || '—')} ${escapeHtml(category)} ${escapeHtml(url)} ${statusLabel} ${latency} `; if (svc.error) { tr.innerHTML += `${escapeHtml(svc.error)}`; } tbody.appendChild(tr); }); } function renderLocalServices(services) { const tbody = document.getElementById('local-services-tbody'); if (!services || services.length === 0) { tbody.innerHTML = 'No local services detected.'; return; } tbody.innerHTML = ''; services.forEach(svc => { const tr = document.createElement('tr'); tr.innerHTML = ` ${escapeHtml(svc.local_address || '—')} ${escapeHtml(svc.port || '—')} ${escapeHtml(svc.process || '—')} `; tbody.appendChild(tr); }); } function updateFooter(data) { const cached = data.cached_at ? new Date(data.cached_at * 1000) : null; document.getElementById('last-updated').textContent = cached ? 'Last updated: ' + cached.toLocaleTimeString() : 'Last updated: —'; document.getElementById('cache-info').textContent = data.cache_ttl ? 'Cache: ' + data.cache_ttl + 's' : ''; } function showNetworkError(msg) { document.getElementById('hosts-grid').innerHTML = '
' + escapeHtml(msg) + '
'; document.getElementById('services-tbody').innerHTML = '' + escapeHtml(msg) + ''; document.getElementById('local-services-tbody').innerHTML = '' + escapeHtml(msg) + ''; } function escapeHtml(str) { if (str == null) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }