Files
hermes-command-center/static/app.js
T
shawn 3f086b7849 Fix Firefox mobile panel centering and agent profile loading
- Fix auth middleware: make GET /api/agents public so agent profiles
  load in Firefox mobile (browser doesn't send Basic Auth on XHR)
- Fix CSS: use display: grid; justify-items: center on .content
  instead of margin: 0 auto on .panel — Firefox fails to honor
  margin: auto with percentage width inside flex containers
- Add box-sizing: border-box to panel elements
- Bump cache busters (v=13 for CSS, v=2 for JS)

Root cause: Firefox mobile on Pixel 4a rendered .panel with
width:72%; margin:0 auto shifted 242px right (flush to edge).
CSS Grid centering resolves the cross-browser rendering difference.
2026-06-02 17:18:17 -04:00

772 lines
37 KiB
JavaScript

// ── 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 = '<p class="loading-text">Loading files...</p>';
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 = '<p class="error-text">Failed to load files: ' + escapeHtml(err.message) + '</p>';
}
}
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 = '<p class="empty-text">No config files found.</p>';
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 += '<div class="ln">' + i + '</div>';
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: '📋' };
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 = '<span class="status-dot ' + p.status + '"></span> ' + 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 = '<div class="chat-welcome"><div class="chat-welcome-icon">' + icon + '</div><h3>' + (profile.label || profile.profile) + '</h3><p>' + (profile.status === 'online' ? 'This agent is online. Type a message to start a conversation.' : 'This agent is offline. Start the gateway to chat.') + '</p></div>';
}
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 = '<div class="bubble">' + escapeHtml(msg.content) + '</div><div class="msg-meta">' + (isUser ? '' : '<span class="msg-profile-icon">' + icon + '</span>') + '<span>' + (isUser ? 'You' : (chatState.activeProfile?.label || 'Agent')) + '</span>' + (time ? ' <span>· ' + time + '</span>' : '') + '</div>';
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 = '<option value="">Select a board...</option>';
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 = '<p class="loading-text">Select a board to view tasks...</p>';
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 = '<p class="error-text">' + escapeHtml(err.message) + '</p>';
}
}
function renderKanbanTasks(tasks) {
const list = document.getElementById('kanban-task-list');
if (!tasks || tasks.length === 0) {
list.innerHTML = '<p class="empty-text">No tasks in this board.</p>';
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 = '<div class="kanban-task-status status-' + statusLabel + '">' + icon + '</div>' +
'<div class="kanban-task-info">' +
'<div class="kanban-task-title">' + escapeHtml(t.title) + '</div>' +
'<div class="kanban-task-meta">' +
(t.assignee ? '<span>👤 ' + escapeHtml(t.assignee) + '</span>' : '') +
'<span class="status-badge status-' + statusLabel + '">' + statusLabel + '</span>' +
'</div>' +
(t.body ? '<div class="kanban-task-body">' + escapeHtml(t.body) + '</div>' : '') +
'</div>';
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 = '<tr><td colspan="4" class="loading-text">Loading services...</td></tr>';
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 = '<tr><td colspan="4" class="error-text">' + escapeHtml(err.message) + '</td></tr>';
}
}
function renderServicesList(services) {
const tbody = document.getElementById('services-tbody-svc');
if (!services || services.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-text">No Hermes services found.</td></tr>';
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 = '<td class="svc-name">' + escapeHtml(s.name) + '</td>' +
'<td><span class="svc-status-badge ' + badgeClass + '">' + escapeHtml(statusLabel) + '</span></td>' +
'<td>' + escapeHtml(s.sub || '—') + '</td>' +
'<td class="service-actions">' +
(!isActive ? '<button class="service-action-btn start" onclick="serviceAction(\'' + escapeHtml(s.name) + "','start'" + ')">▶ Start</button>' : '') +
(isActive ? '<button class="service-action-btn restart" onclick="serviceAction(\'' + escapeHtml(s.name) + "','restart'" + ')">↻ Restart</button>' : '') +
(isActive ? '<button class="service-action-btn stop" onclick="serviceAction(\'' + escapeHtml(s.name) + "','stop'" + ')">⏹ Stop</button>' : '') +
'</td>';
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 = '<div class="loading-text">Refreshing...</div>';
document.getElementById('services-tbody').innerHTML = '<tr><td colspan="5" class="loading-text">Refreshing...</td></tr>';
document.getElementById('local-services-tbody').innerHTML = '<tr><td colspan="3" class="loading-text">Refreshing...</td></tr>';
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 = '<div class="empty-text">No hosts configured.</div>'; 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 = '<div class="host-card-header"><div class="host-name-row"><span class="host-name">' + escapeHtml(host.name) + '</span><span class="status-badge status-' + (host.status || 'unknown') + '">' + statusLabel + '</span></div><div class="host-meta"><span class="host-ip">' + escapeHtml(host.ip || '—') + '</span>' + (osDisplay ? '<span class="host-os">' + escapeHtml(osDisplay) + '</span>' : '') + '<span class="host-latency">' + latencyDisplay + '</span></div></div>' +
'<div class="host-card-body">' + (host.role ? '<div class="host-role">' + escapeHtml(host.role) + '</div>' : '') + (host.ports && host.ports.length > 0 ? renderPorts(host.ports) : '') +
'<button class="btn btn-sm host-expand-btn" data-host="' + escapeHtml(host.name) + '">Details</button></div>' +
'<div class="host-details hidden" id="host-details-' + escapeHtml(host.name).replace(/\s/g, '_') + '"><div class="host-details-content">' + renderHostDetails(host) + '</div></div>';
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 = '<div class="host-ports"><span class="ports-label">Ports:</span>';
ports.forEach(p => { html += '<span class="port-badge ' + (p.open ? 'port-open' : 'port-closed') + '" title="' + escapeHtml(p.name) + ':' + p.port + '">' + escapeHtml(p.name) + ':' + p.port + '</span>'; });
html += '</div>';
return html;
}
function renderHostDetails(host) {
let html = '<table class="details-table">';
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 += '<tr><td class="dt-label">' + escapeHtml(label) + '</td><td class="dt-value">' + escapeHtml(String(val)) + '</td></tr>'; });
if (host.ports && host.ports.length > 0) {
html += '<tr><td class="dt-label">Ports</td><td class="dt-value">';
host.ports.forEach(p => { html += '<span class="port-badge ' + (p.open ? 'port-open' : 'port-closed') + '">' + (p.open ? '✓' : '✗') + ' ' + escapeHtml(p.name) + ':' + p.port + '</span> '; });
html += '</td></tr>';
}
html += '</table>';
return html;
}
function renderServices(services) {
const tbody = document.getElementById('services-tbody');
if (!services || services.length === 0) { tbody.innerHTML = '<tr><td colspan="5" class="empty-text">No services configured.</td></tr>'; 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 = '<td class="svc-name">' + escapeHtml(svc.name || '—') + '</td><td><span class="svc-category">' + escapeHtml(category) + '</span></td><td class="svc-url">' + escapeHtml(url) + '</td><td><span class="status-badge status-' + status + '">' + statusLabel + '</span></td><td class="svc-latency">' + latency + '</td>';
if (svc.error) tr.innerHTML += '<td class="svc-error" colspan="5"><small>' + escapeHtml(svc.error) + '</small></td>';
tbody.appendChild(tr);
});
}
function renderLocalServices(services) {
const tbody = document.getElementById('local-services-tbody');
if (!services || services.length === 0) { tbody.innerHTML = '<tr><td colspan="3" class="empty-text">No local services detected.</td></tr>'; return; }
tbody.innerHTML = '';
services.forEach(svc => {
const tr = document.createElement('tr');
tr.innerHTML = '<td>' + escapeHtml(svc.local_address || '—') + '</td><td>' + escapeHtml(svc.port || '—') + '</td><td>' + escapeHtml(svc.process || '—') + '</td>';
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 = '<div class="error-text">' + escapeHtml(msg) + '</div>';
document.getElementById('services-tbody').innerHTML = '<tr><td colspan="5" class="error-text">' + escapeHtml(msg) + '</td></tr>';
document.getElementById('local-services-tbody').innerHTML = '<tr><td colspan="3" class="error-text">' + escapeHtml(msg) + '</td></tr>';
}