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:
+275
-3
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user