// ── Command Center SPA Router ─────────────────────────────────────── const sections = document.querySelectorAll('.panel'); const navLinks = document.querySelectorAll('.nav-links a'); const bottomNavItems = document.querySelectorAll('.bottom-nav .nav-item'); function navigate(hash) { const target = hash.replace('#', '') || 'dashboard'; sections.forEach(s => s.hidden = s.id !== target); // Desktop sidebar navLinks.forEach(l => l.classList.toggle('active', l.getAttribute('href') === '#' + target)); // Mobile bottom nav bottomNavItems.forEach(item => item.classList.toggle('active', item.dataset.tab === target)); // Init panels on first visit if (target === 'config') initConfigPanel(); if (target === 'kanban') initKanban(); if (target === 'dashboard') loadDashboard(); if (target === 'agents') setTimeout(loadAgentProfiles, 100); if (target === 'network') { if (!window._networkInited) { window._networkInited = true; initNetworkDashboard(); } } if (target === 'services') loadServices(); } // Desktop sidebar clicks navLinks.forEach(l => l.addEventListener('click', e => { e.preventDefault(); window.location.hash = l.getAttribute('href'); })); // Mobile bottom nav clicks bottomNavItems.forEach(item => item.addEventListener('click', e => { e.preventDefault(); window.location.hash = '#' + item.dataset.tab; })); window.addEventListener('hashchange', () => navigate(window.location.hash)); navigate(window.location.hash || '#dashboard'); // ── Utility ───────────────────────────────────────────────────────── function escapeHtml(text) { if (text == null) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // ── Dashboard Panel ───────────────────────────────────────────────── async function loadDashboard() { try { const res = await fetch('/api/dashboard'); const data = await res.json(); document.getElementById('dash-agents-val').textContent = data.agents?.online + '/' + data.agents?.total; document.getElementById('dash-hosts-val').textContent = data.network?.hosts ?? '—'; document.getElementById('dash-files-val').textContent = data.config?.editable_files ?? '—'; document.getElementById('dash-msgs-val').textContent = data.chat?.total_messages ?? '—'; document.getElementById('dash-tasks-val').textContent = data.kanban?.done_tasks ?? '—'; document.getElementById('dash-boards-val').textContent = data.kanban?.total_boards ?? '—'; } catch (err) { console.error('Dashboard load failed:', err); } } async function refreshDashboard() { document.querySelectorAll('.dash-card-value').forEach(el => el.textContent = '…'); loadDashboard(); } // ── 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: ' + escapeHtml(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; 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(); 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); 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'); document.getElementById('editor-line-numbers').scrollTop = textarea.scrollTop; } function handleEditorKeydown(e) { if (e.key === 'Tab') { e.preventDefault(); const textarea = document.getElementById('config-editor-textarea'); const start = textarea.selectionStart, end = textarea.selectionEnd; const val = textarea.value; textarea.value = val.substring(0, start) + ' ' + val.substring(end); textarea.selectionStart = textarea.selectionEnd = start + 4; onEditorChange(); } if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveFile(); } } function updateLineNumbers() { const textarea = document.getElementById('config-editor-textarea'); const lines = textarea.value.split('\n'); let html = ''; for (let i = 1; i <= lines.length; i++) html += '
' + i + '
'; document.getElementById('editor-line-numbers').innerHTML = html; } function goBackToList() { if (configState.dirty && !confirm('Unsaved changes. Discard?')) 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; 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; 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. Backup: ' + data.backup_path, 'success'); updateSaveButton(); 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; 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); } // ── Agent Chat ──────────────────────────────────────────────────── let chatState = { profiles: [], activeProfile: null, messages: {}, sending: false, }; const PROFILE_ICONS = { coder: '💻', assistant: '🤖', artist: '🎨', kanban: '📋', watchdog: '🛡️' }; async function loadAgentProfiles() { try { const res = await fetch('/api/agents'); if (!res.ok) throw new Error('HTTP ' + res.status); const data = await res.json(); chatState.profiles = data.agents || []; renderProfileTabs(); if (chatState.activeProfile) { selectProfile(chatState.activeProfile); } else if (chatState.profiles.length > 0) { const online = chatState.profiles.find(p => p.status === 'online'); selectProfile(online || chatState.profiles[0]); } } catch (err) { console.error('Failed to load profiles:', err); } } function renderProfileTabs() { const container = document.getElementById('chat-profile-tabs'); container.innerHTML = ''; chatState.profiles.forEach(p => { const tab = document.createElement('button'); tab.className = 'chat-profile-tab'; if (p.profile === chatState.activeProfile?.profile) tab.classList.add('active'); const icon = PROFILE_ICONS[p.profile] || '🤖'; tab.innerHTML = ' ' + icon + ' ' + (p.label || p.profile); tab.title = p.status === 'online' ? 'Online' : 'Offline'; tab.addEventListener('click', () => selectProfile(p)); container.appendChild(tab); }); } function selectProfile(profile) { chatState.activeProfile = profile; document.querySelectorAll('.chat-profile-tab').forEach(tab => { const txt = tab.textContent.trim(); tab.classList.toggle('active', txt.includes(profile.label || profile.profile)); }); const msgArea = document.getElementById('chat-messages'); msgArea.innerHTML = ''; if (!chatState.messages[profile.profile]) showWelcome(profile); document.getElementById('chat-input').disabled = false; document.getElementById('chat-input').focus(); updateSendButton(); loadConversationHistory(profile.profile); } function showWelcome(profile) { const msgArea = document.getElementById('chat-messages'); const icon = PROFILE_ICONS[profile.profile] || '🤖'; msgArea.innerHTML = '
' + icon + '

' + (profile.label || profile.profile) + '

' + (profile.status === 'online' ? 'This agent is online. Type a message to start a conversation.' : 'This agent is offline. Start the gateway to chat.') + '

'; } async function loadConversationHistory(profileName) { try { const res = await fetch('/api/agents/' + encodeURIComponent(profileName) + '/history?limit=50'); if (!res.ok) return; const data = await res.json(); const messages = data.messages || []; chatState.messages[profileName] = messages; if (chatState.activeProfile?.profile === profileName) renderMessages(messages); } catch (err) { console.error('Failed to load history:', err); } } function renderMessages(messages) { const msgArea = document.getElementById('chat-messages'); msgArea.innerHTML = ''; if (!messages || messages.length === 0) { if (chatState.activeProfile) showWelcome(chatState.activeProfile); return; } messages.forEach(msg => appendMessageBubble(msg, false)); scrollToBottom(); } function appendMessageBubble(msg, animate) { const msgArea = document.getElementById('chat-messages'); const isUser = msg.role === 'user'; const div = document.createElement('div'); div.className = 'chat-message ' + (isUser ? 'user' : 'agent'); if (!animate) div.style.animation = 'none'; const icon = chatState.activeProfile ? (PROFILE_ICONS[chatState.activeProfile.profile] || '🤖') : '🤖'; const time = msg.created_at ? new Date(msg.created_at * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; div.innerHTML = '
' + escapeHtml(msg.content) + '
' + (isUser ? '' : '' + icon + '') + '' + (isUser ? 'You' : (chatState.activeProfile?.label || 'Agent')) + '' + (time ? ' · ' + time + '' : '') + '
'; msgArea.appendChild(div); scrollToBottom(); } function scrollToBottom() { document.getElementById('chat-messages').scrollTop = document.getElementById('chat-messages').scrollHeight; } function showTypingIndicator(show) { document.getElementById('chat-typing').hidden = !show; } async function sendMessage() { if (chatState.sending) return; const input = document.getElementById('chat-input'); const message = input.value.trim(); if (!message || !chatState.activeProfile) return; chatState.sending = true; input.disabled = true; document.getElementById('chat-send-btn').disabled = true; document.getElementById('chat-send-btn').textContent = '...'; appendMessageBubble({ role: 'user', content: message, created_at: Date.now() / 1000 }); input.value = ''; input.style.height = 'auto'; showTypingIndicator(true); try { const res = await fetch('/api/agents/' + encodeURIComponent(chatState.activeProfile.profile) + '/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }), }); const data = await res.json(); if (!res.ok) { appendMessageBubble({ role: 'agent', content: '⚠️ Error: ' + (data.error || 'HTTP ' + res.status), created_at: Date.now() / 1000 }); } else { appendMessageBubble({ role: 'agent', content: data.response, created_at: Date.now() / 1000 }); } } catch (err) { appendMessageBubble({ role: 'agent', content: '⚠️ Network error: ' + err.message, created_at: Date.now() / 1000 }); } finally { chatState.sending = false; showTypingIndicator(false); input.disabled = false; document.getElementById('chat-send-btn').disabled = false; document.getElementById('chat-send-btn').textContent = 'Send'; input.focus(); } } function updateSendButton() { const input = document.getElementById('chat-input'); const btn = document.getElementById('chat-send-btn'); btn.disabled = !input.value.trim() || !chatState.activeProfile || chatState.sending; } // ── Event Listeners (shared) ─────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { // Config editor listeners const configSaveBtn = document.getElementById('config-save-btn'); const configBackBtn = document.getElementById('config-back-btn'); if (configSaveBtn) configSaveBtn.addEventListener('click', saveFile); if (configBackBtn) configBackBtn.addEventListener('click', goBackToList); // Chat input const chatInput = document.getElementById('chat-input'); const sendBtn = document.getElementById('chat-send-btn'); if (chatInput) { chatInput.addEventListener('input', () => { updateSendButton(); chatInput.style.height = 'auto'; chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px'; }); chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); } if (sendBtn) sendBtn.addEventListener('click', sendMessage); }); // ── Kanban Panel ────────────────────────────────────────────────── let kanbanState = { boards: [], currentSlug: '' }; async function initKanban() { try { const res = await fetch('/api/kanban/boards'); const data = await res.json(); kanbanState.boards = data.boards || []; const select = document.getElementById('kanban-board-select'); select.innerHTML = ''; data.boards.forEach(b => { const opt = document.createElement('option'); opt.value = b.slug; opt.textContent = b.name + ' (' + b.task_count + ' tasks)'; select.appendChild(opt); }); // Restore selection if previously set if (kanbanState.currentSlug) { select.value = kanbanState.currentSlug; loadKanbanBoard(); } } catch (err) { console.error('Kanban init failed:', err); } } async function loadKanbanBoard() { const select = document.getElementById('kanban-board-select'); const slug = select.value; if (!slug) { document.getElementById('kanban-task-list').innerHTML = '

Select a board to view tasks...

'; document.getElementById('ks-done').textContent = '0'; document.getElementById('ks-blocked').textContent = '0'; document.getElementById('ks-ready').textContent = '0'; document.getElementById('ks-todo').textContent = '0'; document.getElementById('ks-total').textContent = '0'; return; } kanbanState.currentSlug = slug; try { const res = await fetch('/api/kanban/board/' + encodeURIComponent(slug)); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'HTTP ' + res.status); renderKanbanTasks(data.tasks || []); } catch (err) { document.getElementById('kanban-task-list').innerHTML = '

' + escapeHtml(err.message) + '

'; } } function renderKanbanTasks(tasks) { const list = document.getElementById('kanban-task-list'); if (!tasks || tasks.length === 0) { list.innerHTML = '

No tasks in this board.

'; document.getElementById('ks-done').textContent = '0'; document.getElementById('ks-blocked').textContent = '0'; document.getElementById('ks-ready').textContent = '0'; document.getElementById('ks-todo').textContent = '0'; document.getElementById('ks-total').textContent = '0'; return; } // Summary const counts = { done: 0, blocked: 0, ready: 0, todo: 0, total: tasks.length }; tasks.forEach(t => { if (counts[t.status] !== undefined) counts[t.status]++; }); document.getElementById('ks-done').textContent = counts.done; document.getElementById('ks-blocked').textContent = counts.blocked; document.getElementById('ks-ready').textContent = counts.ready; document.getElementById('ks-todo').textContent = counts.todo; document.getElementById('ks-total').textContent = counts.total; // Task cards const statusIcons = { done: '✅', blocked: '🚫', ready: '🟡', todo: '⬜' }; list.innerHTML = ''; tasks.forEach(t => { const card = document.createElement('div'); card.className = 'kanban-task-card'; const icon = statusIcons[t.status] || '⬜'; const statusLabel = t.status || 'todo'; card.innerHTML = '
' + icon + '
' + '
' + '
' + escapeHtml(t.title) + '
' + '
' + (t.assignee ? '👤 ' + escapeHtml(t.assignee) + '' : '') + '' + statusLabel + '' + '
' + (t.body ? '
' + escapeHtml(t.body) + '
' : '') + '
'; card.title = 'Click to view details (ID: ' + t.id + ')'; card.addEventListener('click', () => { // Show task details in a simple way showToast(t.id + ': ' + t.title + ' [' + t.status + ']', 'info'); }); list.appendChild(card); }); } // ── Services Panel ───────────────────────────────────────────────── async function loadServices() { const tbody = document.getElementById('services-tbody-svc'); tbody.innerHTML = 'Loading services...'; try { const res = await fetch('/api/services'); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'HTTP ' + res.status); renderServicesList(data.services || []); } catch (err) { tbody.innerHTML = '' + escapeHtml(err.message) + ''; } } function renderServicesList(services) { const tbody = document.getElementById('services-tbody-svc'); if (!services || services.length === 0) { tbody.innerHTML = 'No Hermes services found.'; return; } tbody.innerHTML = ''; services.forEach(s => { const tr = document.createElement('tr'); const isActive = s.active === 'active'; const badgeClass = 'svc-active-' + s.active; const statusLabel = s.active + '/' + s.sub; tr.innerHTML = '' + escapeHtml(s.name) + '' + '' + escapeHtml(statusLabel) + '' + '' + escapeHtml(s.sub || '—') + '' + '' + (!isActive ? '' : '') + (isActive ? '' : '') + (isActive ? '' : '') + ''; tbody.appendChild(tr); }); } async function serviceAction(serviceName, action) { if (!confirm(action + ' ' + serviceName + '?')) return; try { const res = await fetch('/api/services/' + encodeURIComponent(serviceName) + '/action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }), }); const data = await res.json(); if (!res.ok) { alert('Failed: ' + (data.error || 'HTTP ' + res.status)); return; } showToast(action + ' ' + serviceName + ' — done', 'success'); setTimeout(loadServices, 1500); } catch (err) { alert('Error: ' + err.message); } } // ── Network Dashboard ──────────────────────────────────────────────── let networkState = { autoRefresh: true, refreshInterval: 30000, countdownInterval: null, refreshTimer: null, countdownRemaining: 0, data: null, }; function initNetworkDashboard() { 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'); }); }); document.getElementById('network-refresh-btn').addEventListener('click', forceRefresh); document.getElementById('auto-refresh-toggle').addEventListener('click', () => { networkState.autoRefresh = document.getElementById('auto-refresh-toggle').checked; if (networkState.autoRefresh) startAutoRefresh(); else stopAutoRefresh(); }); 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: ' + 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(() => { 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++; }); 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) : '') + '
' + ''; 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 => { 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 => { html += '' + (p.open ? '✓' : '✗') + ' ' + 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) + ''; }