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
This commit is contained in:
2026-06-01 23:50:38 -04:00
parent 54af25d2da
commit ca1cb63f65
2 changed files with 545 additions and 3 deletions
+275 -3
View File
@@ -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 = `
<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;
// 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 = `
<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;
// 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 = `
<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() {
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 ────────────────────────────────────────────────