feat(monitor): implement code review fixes and real-time WebSocket monitoring

Backend Improvements (11 fixes applied):

Critical Fixes:
- Add lock protection for browser pool access in monitor stats
- Ensure async track_janitor_event across all call sites
- Improve error handling in monitor request tracking (already in place)

Important Fixes:
- Replace fire-and-forget Redis with background persistence worker
- Add time-based expiry for completed requests/errors (5min cleanup)
- Implement input validation for monitor route parameters
- Add 4s timeout to timeline updater to prevent hangs
- Add warning when killing browsers with active requests
- Implement monitor cleanup on shutdown with final persistence
- Document memory estimates with TODO for actual tracking

Frontend Enhancements:

WebSocket Real-time Updates:
- Add WebSocket endpoint at /monitor/ws for live monitoring
- Implement auto-reconnect with exponential backoff (max 5 attempts)
- Add graceful fallback to HTTP polling on WebSocket failure
- Send comprehensive updates every 2 seconds (health, requests, browsers, timeline, events)

UI/UX Improvements:
- Add live connection status indicator with pulsing animation
  - Green "Live" = WebSocket connected
  - Yellow "Connecting..." = Attempting connection
  - Blue "Polling" = Fallback to HTTP polling
  - Red "Disconnected" = Connection failed
- Restore original beautiful styling for all sections
- Improve request table layout with flex-grow for URL column
- Add browser type text labels alongside emojis
- Add flex layout to browser section header

Testing:
- Add test-websocket.py for WebSocket validation
- All 7 integration tests passing successfully

Summary: 563 additions across 6 files
This commit is contained in:
unclecode
2025-10-18 11:38:25 +08:00
parent aba4036ab6
commit 25507adb5b
6 changed files with 561 additions and 71 deletions

View File

@@ -35,6 +35,12 @@
}
.pulse-slow { animation: pulse-slow 2s ease-in-out infinite; }
@keyframes pulse-fast {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.1); }
}
.pulse-fast { animation: pulse-fast 1s ease-in-out infinite; }
@keyframes spin-slow {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
@@ -87,6 +93,14 @@
</h1>
<div class="ml-auto flex items-center space-x-4">
<!-- Connection Status -->
<div class="flex items-center space-x-2">
<div id="ws-status" class="flex items-center space-x-1">
<div class="w-2 h-2 rounded-full bg-gray-500" id="ws-indicator"></div>
<span class="text-xs text-secondary" id="ws-text">Connecting...</span>
</div>
</div>
<!-- Auto-refresh toggle -->
<div class="flex items-center space-x-2">
<label class="text-xs text-secondary">Auto-refresh:</label>
@@ -196,7 +210,7 @@
<!-- Browsers Section -->
<section class="bg-surface rounded-lg border border-border overflow-hidden flex flex-col" style="height: 350px;">
<div class="px-4 py-2 border-b border-border">
<div class="px-4 py-2 border-b border-border flex items-center justify-between">
<h3 class="text-sm font-medium text-primary">🌐 Browsers (<span id="browser-count">0</span>, <span id="browser-mem">0</span>MB)</h3>
<div class="text-xs text-secondary">Reuse: <span id="reuse-rate" class="text-primary">--%</span></div>
</div>
@@ -308,9 +322,279 @@
let autoRefresh = true;
let refreshInterval;
const REFRESH_RATE = 1000; // 1 second
let websocket = null;
let wsReconnectAttempts = 0;
const MAX_WS_RECONNECT = 5;
let useWebSocket = true; // Try WebSocket first, fallback to polling
// No more tabs - all sections visible at once!
// ========== WebSocket Connection ==========
function updateConnectionStatus(status, message) {
const indicator = document.getElementById('ws-indicator');
const text = document.getElementById('ws-text');
indicator.className = 'w-2 h-2 rounded-full';
if (status === 'connected') {
indicator.classList.add('bg-green-500', 'pulse-fast');
text.textContent = 'Live';
text.className = 'text-xs text-green-400';
} else if (status === 'connecting') {
indicator.classList.add('bg-yellow-500', 'pulse-slow');
text.textContent = 'Connecting...';
text.className = 'text-xs text-yellow-400';
} else if (status === 'polling') {
indicator.classList.add('bg-blue-500', 'pulse-slow');
text.textContent = 'Polling';
text.className = 'text-xs text-blue-400';
} else {
indicator.classList.add('bg-red-500');
text.textContent = message || 'Disconnected';
text.className = 'text-xs text-red-400';
}
}
function connectWebSocket() {
if (wsReconnectAttempts >= MAX_WS_RECONNECT) {
console.log('Max WebSocket reconnect attempts reached, falling back to polling');
useWebSocket = false;
updateConnectionStatus('polling');
startAutoRefresh();
return;
}
updateConnectionStatus('connecting');
wsReconnectAttempts++;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/monitor/ws`;
websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
console.log('WebSocket connected');
wsReconnectAttempts = 0;
updateConnectionStatus('connected');
stopAutoRefresh(); // Stop polling if running
};
websocket.onmessage = (event) => {
const data = JSON.parse(event.data);
updateDashboard(data);
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
websocket.onclose = () => {
console.log('WebSocket closed');
updateConnectionStatus('disconnected', 'Reconnecting...');
if (useWebSocket) {
setTimeout(connectWebSocket, 2000 * wsReconnectAttempts);
} else {
startAutoRefresh();
}
};
}
function updateDashboard(data) {
// Update all dashboard sections with WebSocket data
try {
if (data.health) {
updateHealthDisplay(data.health);
}
if (data.requests) {
updateRequestsDisplay(data.requests);
}
if (data.browsers) {
updateBrowsersDisplay(data.browsers);
}
if (data.janitor) {
updateJanitorDisplay(data.janitor);
}
if (data.errors && data.errors.length > 0) {
updateErrorsDisplay(data.errors);
}
} catch (e) {
console.error('Error updating dashboard:', e);
}
}
// Helper functions to update displays from WebSocket data
function updateHealthDisplay(health) {
const cpu = health.container.cpu_percent;
const mem = health.container.memory_percent;
document.getElementById('cpu-percent').textContent = cpu.toFixed(1) + '%';
document.getElementById('cpu-bar').style.width = Math.min(cpu, 100) + '%';
document.getElementById('cpu-bar').className = `progress-bar h-2 rounded-full ${cpu > 80 ? 'bg-red-500' : cpu > 60 ? 'bg-yellow-500' : 'bg-primary'}`;
document.getElementById('mem-percent').textContent = mem.toFixed(1) + '%';
document.getElementById('mem-bar').style.width = Math.min(mem, 100) + '%';
document.getElementById('mem-bar').className = `progress-bar h-2 rounded-full ${mem > 80 ? 'bg-red-500' : mem > 60 ? 'bg-yellow-500' : 'bg-accent'}`;
document.getElementById('net-sent').textContent = health.container.network_sent_mb.toFixed(1);
document.getElementById('net-recv').textContent = health.container.network_recv_mb.toFixed(1);
const uptime = formatUptime(health.container.uptime_seconds);
document.getElementById('uptime').textContent = uptime;
const perm = health.pool.permanent;
document.getElementById('pool-perm').textContent = `${perm.active ? 'ACTIVE' : 'INACTIVE'} (${perm.memory_mb}MB)`;
document.getElementById('pool-perm').className = perm.active ? 'text-primary ml-2' : 'text-secondary ml-2';
document.getElementById('pool-hot').textContent = `${health.pool.hot.count} (${health.pool.hot.memory_mb}MB)`;
document.getElementById('pool-cold').textContent = `${health.pool.cold.count} (${health.pool.cold.memory_mb}MB)`;
document.getElementById('janitor-status').textContent = health.janitor.next_cleanup_estimate;
const pressure = health.janitor.memory_pressure;
const pressureEl = document.getElementById('mem-pressure');
pressureEl.textContent = pressure;
pressureEl.className = pressure === 'HIGH' ? 'text-red-500' : pressure === 'MEDIUM' ? 'text-yellow-500' : 'text-green-500';
document.getElementById('last-update').textContent = 'Live: ' + new Date().toLocaleTimeString();
}
function updateRequestsDisplay(requests) {
// Update active requests count
const activeCount = document.getElementById('active-count');
if (activeCount) activeCount.textContent = requests.active.length;
// Update active requests list
const activeList = document.getElementById('active-requests-list');
if (activeList) {
if (requests.active.length === 0) {
activeList.innerHTML = '<div class="text-secondary text-center py-2">No active requests</div>';
} else {
activeList.innerHTML = requests.active.map(req => `
<div class="flex items-center justify-between p-2 bg-dark rounded border border-border">
<span class="text-primary">${req.id.substring(0, 8)}</span>
<span class="text-secondary">${req.endpoint}</span>
<span class="text-light truncate max-w-[200px]" title="${req.url}">${req.url}</span>
<span class="text-accent">${req.elapsed.toFixed(1)}s</span>
<span class="pulse-slow">⏳</span>
</div>
`).join('');
}
}
// Update completed requests
const completedList = document.getElementById('completed-requests-list');
if (completedList) {
if (requests.completed.length === 0) {
completedList.innerHTML = '<div class="text-secondary text-center py-2">No completed requests</div>';
} else {
completedList.innerHTML = requests.completed.map(req => `
<div class="flex items-center gap-3 p-2 bg-dark rounded">
<span class="text-secondary w-16 flex-shrink-0">${req.id.substring(0, 8)}</span>
<span class="text-secondary w-16 flex-shrink-0">${req.endpoint}</span>
<span class="text-light truncate flex-1" title="${req.url}">${req.url}</span>
<span class="w-12 flex-shrink-0 text-right">${req.elapsed.toFixed(2)}s</span>
<span class="text-secondary w-16 flex-shrink-0 text-right">${req.mem_delta > 0 ? '+' : ''}${req.mem_delta}MB</span>
<span class="w-12 flex-shrink-0 text-right">${req.success ? '✅' : '❌'} ${req.status_code}</span>
</div>
`).join('');
}
}
}
function updateBrowsersDisplay(browsers) {
const tbody = document.getElementById('browsers-table-body');
if (tbody) {
if (browsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-2 text-secondary">No browsers</td></tr>';
} else {
tbody.innerHTML = browsers.map(b => {
const typeIcon = b.type === 'permanent' ? '🔥' : b.type === 'hot' ? '♨️' : '❄️';
const typeColor = b.type === 'permanent' ? 'text-primary' : b.type === 'hot' ? 'text-accent' : 'text-light';
return `
<tr class="border-t border-border hover:bg-dark">
<td class="py-1 pr-2"><span class="${typeColor}">${typeIcon} ${b.type}</span></td>
<td class="py-1 pr-2 font-mono text-xs">${b.sig}</td>
<td class="py-1 pr-2">${formatSeconds(b.age_seconds || 0)}</td>
<td class="py-1 pr-2">${formatSeconds(b.last_used_seconds || 0)}</td>
<td class="py-1 pr-2">${b.hits}</td>
<td class="py-1">
${b.killable ? `
<button onclick="killBrowser('${b.sig}')" class="text-red-500 hover:underline text-xs">X</button>
` : `
<button onclick="restartBrowser('permanent')" class="text-primary hover:underline text-xs">↻</button>
`}
</td>
</tr>
`;
}).join('');
}
}
// Update browser count and total memory
const countEl = document.getElementById('browser-count');
if (countEl) countEl.textContent = browsers.length;
const memEl = document.getElementById('browser-mem');
if (memEl) {
const totalMem = browsers.reduce((sum, b) => sum + (b.memory_mb || 0), 0);
memEl.textContent = totalMem;
}
// Update reuse rate (if available from summary data)
// Note: WebSocket sends just browsers array, not summary
// Reuse rate calculation would need to be added to monitor.py
const reuseEl = document.getElementById('reuse-rate');
if (reuseEl) {
reuseEl.textContent = '---%'; // Not available in real-time yet
}
}
function updateJanitorDisplay(events) {
const janitorLog = document.getElementById('janitor-log');
if (janitorLog) {
if (events.length === 0) {
janitorLog.innerHTML = '<div class="text-secondary text-center py-4">No events yet</div>';
} else {
janitorLog.innerHTML = events.slice(0, 10).reverse().map(evt => {
const time = new Date(evt.timestamp * 1000).toLocaleTimeString();
const icon = evt.type === 'close_cold' ? '🧹❄️' : evt.type === 'close_hot' ? '🧹♨️' : '⬆️';
const details = JSON.stringify(evt.details);
return `<div class="p-2 bg-dark rounded">
<span class="text-secondary">${time}</span>
<span>${icon}</span>
<span class="text-primary">${evt.type}</span>
<span class="text-secondary">sig=${evt.sig}</span>
<span class="text-xs text-secondary ml-2">${details}</span>
</div>`;
}).join('');
}
}
}
function updateErrorsDisplay(errors) {
const errorLog = document.getElementById('errors-log');
if (errorLog) {
if (errors.length === 0) {
errorLog.innerHTML = '<div class="text-secondary text-center py-4">No errors</div>';
} else {
errorLog.innerHTML = errors.slice(0, 10).reverse().map(err => {
const time = new Date(err.timestamp * 1000).toLocaleTimeString();
return `<div class="p-2 bg-dark rounded border border-red-500">
<div class="flex justify-between">
<span class="text-secondary">${time}</span>
<span class="text-red-500">${err.endpoint}</span>
</div>
<div class="text-xs text-light mt-1">${err.url}</div>
<div class="text-xs text-red-400 mt-1 font-mono">${err.error}</div>
</div>`;
}).join('');
}
}
}
// ========== Auto-refresh Toggle ==========
document.getElementById('auto-refresh-toggle').addEventListener('click', function() {
autoRefresh = !autoRefresh;
@@ -426,13 +710,13 @@
completedList.innerHTML = '<div class="text-secondary text-center py-2">No completed requests</div>';
} else {
completedList.innerHTML = data.completed.map(req => `
<div class="flex items-center justify-between p-2 bg-dark rounded">
<span class="text-secondary">${req.id.substring(0, 8)}</span>
<span class="text-secondary">${req.endpoint}</span>
<span class="text-light truncate max-w-[180px]" title="${req.url}">${req.url}</span>
<span>${req.elapsed.toFixed(2)}s</span>
<span class="text-secondary">${req.mem_delta > 0 ? '+' : ''}${req.mem_delta}MB</span>
<span>${req.success ? '✅' : '❌'} ${req.status_code}</span>
<div class="flex items-center gap-3 p-2 bg-dark rounded">
<span class="text-secondary w-16 flex-shrink-0">${req.id.substring(0, 8)}</span>
<span class="text-secondary w-16 flex-shrink-0">${req.endpoint}</span>
<span class="text-light truncate flex-1" title="${req.url}">${req.url}</span>
<span class="w-12 flex-shrink-0 text-right">${req.elapsed.toFixed(2)}s</span>
<span class="text-secondary w-16 flex-shrink-0 text-right">${req.mem_delta > 0 ? '+' : ''}${req.mem_delta}MB</span>
<span class="w-12 flex-shrink-0 text-right">${req.success ? '✅' : '❌'} ${req.status_code}</span>
</div>
`).join('');
}
@@ -460,7 +744,7 @@
return `
<tr class="border-t border-border hover:bg-dark">
<td class="py-1 pr-2"><span class="${typeColor}">${typeIcon}</span></td>
<td class="py-1 pr-2"><span class="${typeColor}">${typeIcon} ${b.type}</span></td>
<td class="py-1 pr-2 font-mono text-xs">${b.sig}</td>
<td class="py-1 pr-2">${formatSeconds(b.age_seconds)}</td>
<td class="py-1 pr-2">${formatSeconds(b.last_used_seconds)}</td>
@@ -779,7 +1063,8 @@
document.getElementById('filter-requests')?.addEventListener('change', fetchRequests);
// ========== Initialize ==========
startAutoRefresh();
// Try WebSocket first, fallback to polling on failure
connectWebSocket();
</script>
</body>
</html>