3f086b7849
- 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.
772 lines
37 KiB
JavaScript
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>';
|
|
}
|