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 ────────────────────────────────────────────────
+270
View File
@@ -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; }