From ca1cb63f652c93ccd642a451e7bf56adedecb0d0 Mon Sep 17 00:00:00 2001 From: Shawn Date: Mon, 1 Jun 2026 23:50:38 -0400 Subject: [PATCH] feat: Network Status Dashboard frontend - Host cards grid with live status (green/red/yellow badges), latency, expandable details - Service table with category, URL/port, status, latency - Local services tab (from ss -tlnp) - Auto-refresh every 30s with visual countdown bar - Manual refresh button with rotate animation - Summary bar (online/degraded/offline/total counts) - Mobile-responsive: single-column cards, scrollable tables - Tab-based navigation between Hosts / Services / Local Services --- static/app.js | 278 ++++++++++++++++++++++++++++++++++++++++++++++- static/style.css | 270 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 545 insertions(+), 3 deletions(-) diff --git a/static/app.js b/static/app.js index 5c941f0..b17a5a7 100644 --- a/static/app.js +++ b/static/app.js @@ -300,11 +300,283 @@ function showToast(message, type) { }, 4000); } -// ── Event listeners ────────────────────────────────────────────────── +// ── 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 we have an active profile, re-check its status + if (chatState.activeProfile) { + selectProfile(chatState.activeProfile); + } else if (chatState.profiles.length > 0) { + // Auto-select first online profile, or first profile + 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; + // Update tab UI + document.querySelectorAll('.chat-profile-tab').forEach(tab => { + const txt = tab.textContent.trim(); + tab.classList.toggle('active', txt.includes(profile.label || profile.profile)); + }); + // Clear messages area + const msgArea = document.getElementById('chat-messages'); + msgArea.innerHTML = ''; + // Show welcome if no history, or loading + if (!chatState.messages[profile.profile]) { + showWelcome(profile); + } + // Enable input + document.getElementById('chat-input').disabled = false; + document.getElementById('chat-input').focus(); + updateSendButton(); + // Load history + 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; + + // Only render if this is still the active profile + 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 = true) { + 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() { + const msgArea = document.getElementById('chat-messages'); + msgArea.scrollTop = msgArea.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 = '...'; + + // Optimistically show user message + appendMessageBubble({ role: 'user', content: message, created_at: Date.now() / 1000 }); + input.value = ''; + input.style.height = 'auto'; + + // Show typing indicator + 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; +} + +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// ── Chat event listeners ────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { - document.getElementById('config-save-btn').addEventListener('click', saveFile); - document.getElementById('config-back-btn').addEventListener('click', goBackToList); + // 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); + + // Network dashboard initNetworkDashboard(); + + // Chat input + const chatInput = document.getElementById('chat-input'); + const sendBtn = document.getElementById('chat-send-btn'); + + if (chatInput) { + chatInput.addEventListener('input', () => { + updateSendButton(); + // Auto-resize + 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); + } + + // Load profiles when agents tab is navigated to + const agentsLink = document.querySelector('.nav-links a[href="#agents"]'); + if (agentsLink) { + agentsLink.addEventListener('click', () => { + setTimeout(loadAgentProfiles, 100); + }); + } + + // Also load if we start on agents tab + if (!window.location.hash || window.location.hash === '#agents' || window.location.hash === '') { + setTimeout(loadAgentProfiles, 200); + } }); // ── Network Dashboard ──────────────────────────────────────────────── diff --git a/static/style.css b/static/style.css index c29c8d6..4db9765 100644 --- a/static/style.css +++ b/static/style.css @@ -32,6 +32,276 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; b .content { margin-left: 0; padding: 12px; } } +/* ── Agent Chat ──────────────────────────────────────────────────── */ +#chat-app { + display: flex; + flex-direction: column; + height: calc(100vh - 100px); + min-height: 400px; +} + +.chat-header { + flex-shrink: 0; + padding: 0 0 12px; + border-bottom: 1px solid var(--border); + margin-bottom: 0; +} + +.chat-profile-strip { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.chat-profiles-label { + font-size: 12px; + color: var(--text-muted); + font-weight: 500; + white-space: nowrap; +} + +.chat-profile-tabs { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.chat-profile-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border: 1px solid var(--border); + border-radius: 16px; + background: transparent; + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.chat-profile-tab:hover { + border-color: var(--accent); + color: var(--text); +} + +.chat-profile-tab.active { + background: rgba(0, 212, 255, 0.1); + border-color: var(--accent); + color: var(--accent); +} + +.chat-profile-tab .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.chat-profile-tab .status-dot.online { + background: var(--success); + box-shadow: 0 0 6px rgba(63, 185, 80, 0.5); +} + +.chat-profile-tab .status-dot.offline { + background: var(--text-muted); +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 16px 0; + display: flex; + flex-direction: column; + gap: 8px; + scroll-behavior: smooth; +} + +.chat-welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + flex: 1; + color: var(--text-muted); + gap: 8px; +} + +.chat-welcome-icon { + font-size: 48px; + opacity: 0.5; +} + +.chat-welcome h3 { + font-size: 18px; + color: var(--text); + margin: 0; +} + +.chat-welcome p { + font-size: 13px; + max-width: 360px; + line-height: 1.5; + margin: 0; +} + +.chat-message { + display: flex; + flex-direction: column; + max-width: 80%; + animation: msg-in 0.2s ease; +} + +@keyframes msg-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.chat-message.user { + align-self: flex-end; +} + +.chat-message.agent { + align-self: flex-start; +} + +.chat-message .bubble { + padding: 10px 14px; + border-radius: 14px; + line-height: 1.5; + font-size: 13px; + word-wrap: break-word; + white-space: pre-wrap; +} + +.chat-message.user .bubble { + background: var(--accent); + color: #000; + border-bottom-right-radius: 4px; +} + +.chat-message.agent .bubble { + background: var(--surface); + border: 1px solid var(--border); + border-bottom-left-radius: 4px; + color: var(--text); +} + +.chat-message .msg-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: var(--text-muted); + padding: 2px 4px 0; +} + +.chat-message.user .msg-meta { + justify-content: flex-end; +} + +.chat-message .msg-profile-icon { + font-size: 11px; +} + +.chat-typing { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 4px; + padding: 8px 4px; + font-size: 12px; + color: var(--text-muted); +} + +.typing-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-muted); + animation: typing-bounce 1.4s infinite ease-in-out; +} + +.typing-dot:nth-child(2) { animation-delay: 0.2s; } +.typing-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typing-bounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-6px); } +} + +.typing-label { + margin-left: 6px; + font-style: italic; +} + +.chat-input-area { + flex-shrink: 0; + display: flex; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.chat-input-area textarea { + flex: 1; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--surface); + color: var(--text); + font-size: 13px; + font-family: inherit; + outline: none; + resize: none; + line-height: 1.4; + min-height: 40px; + max-height: 120px; + transition: border-color 0.15s; +} + +.chat-input-area textarea:focus { + border-color: var(--accent); +} + +.chat-input-area textarea::placeholder { + color: var(--text-muted); +} + +.chat-input-area .btn { + border-radius: 20px; + padding: 8px 20px; + flex-shrink: 0; + align-self: flex-end; +} + +.chat-error { + color: var(--danger); + font-size: 12px; + padding: 4px 0; + text-align: center; +} + +/* Mobile */ +@media (max-width: 768px) { + #chat-app { + height: calc(100vh - 180px); + } + .chat-message { + max-width: 90%; + } + .chat-message .bubble { + font-size: 14px; + } + .chat-profile-tab { + font-size: 11px; + padding: 4px 10px; + } +} + /* ── Config Panel ───────────────────────────────────────────────── */ .config-panel { padding: 0; overflow: hidden; display: flex; flex-direction: column; } .config-panel h2 { margin: 0; font-size: 16px; }