Files
crawl4ai/deploy/docker/static/monitor/index.html
unclecode 91f7b9d129 feat(docker): add multi-container cluster deployment with CLI management
Add comprehensive Docker cluster orchestration with horizontal scaling support.

CLI Commands:
- crwl server start/stop/restart/status/scale/logs
- Auto-detection: Single (N=1) → Swarm (N>1) → Compose (N>1 fallback)
- Support for 1-100 container replicas with zero-downtime scaling

Infrastructure:
- Nginx load balancing (round-robin API, sticky sessions monitoring)
- Redis-based container discovery via heartbeats (30s interval)
- Real-time monitoring dashboard with cluster-wide visibility
- WebSocket aggregation from all containers

Security & Stability Fixes (12 critical issues):
- Add timeout protection to browser pool locks (prevent deadlocks)
- Implement Redis retry logic with exponential backoff
- Add container ID validation (prevent Redis key injection)
- Add CLI input sanitization (prevent shell injection)
- Add file locking for state management (prevent corruption)
- Fix WebSocket resource leaks and connection cleanup
- Add graceful degradation and circuit breakers

Configuration:
- RedisTTLConfig dataclass with environment variable support
- Template-based docker-compose.yml and nginx.conf generation
- Comprehensive error handling with actionable messages

Documentation:
- AGENT.md: Complete DevOps context for AI assistants
- MULTI_CONTAINER_ARCHITECTURE.md: Technical architecture guide
- Reorganized docs into deploy/docker/docs/
2025-10-19 13:31:14 +08:00

1241 lines
59 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crawl4AI Monitor</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#4EFFFF',
primarydim: '#09b5a5',
accent: '#F380F5',
dark: '#070708',
light: '#E8E9ED',
secondary: '#D5CEBF',
codebg: '#1E1E1E',
surface: '#202020',
border: '#3F3F44',
},
fontFamily: {
mono: ['Fira Code', 'monospace'],
},
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
<style>
@keyframes pulse-slow {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.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); }
}
.spin-slow { animation: spin-slow 3s linear infinite; }
/* Progress bar animation */
.progress-bar {
transition: width 0.3s ease;
}
/* Sparkline styles */
.sparkline {
stroke-linecap: round;
stroke-linejoin: round;
}
/* Table hover */
tbody tr:hover {
background-color: rgba(78, 255, 255, 0.05);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #070708;
}
::-webkit-scrollbar-thumb {
background: #3F3F44;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4EFFFF;
}
</style>
</head>
<body class="bg-dark text-light font-mono min-h-screen flex flex-col" style="font-feature-settings: 'calt' 0;">
<!-- Header -->
<header class="border-b border-border px-4 py-2 flex items-center">
<h1 class="text-lg font-medium flex items-center space-x-4">
<img src="/static/assets/logo.png" alt="Crawl4AI" class="h-8">
<span class="text-secondary">Monitor</span>
<a href="https://github.com/unclecode/crawl4ai" target="_blank" class="flex space-x-1">
<img src="https://img.shields.io/github/stars/unclecode/crawl4ai?style=social" alt="GitHub stars" class="h-5">
</a>
</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>
<button id="auto-refresh-toggle" class="px-2 py-1 rounded text-xs bg-primary text-dark">
ON ⚡1s
</button>
</div>
<!-- Navigation -->
<a href="/playground" class="text-xs text-secondary hover:text-primary underline">Playground</a>
</div>
</header>
<!-- Main Content -->
<main class="flex-1 overflow-auto p-4 space-y-4">
<!-- System Health & Infrastructure (side by side) -->
<div class="grid grid-cols-2 gap-4">
<!-- System Health -->
<section class="bg-surface rounded-lg border border-border p-3">
<h2 class="text-sm font-medium mb-2 text-primary">System Health</h2>
<!-- Row 1: CPU and Memory -->
<div class="grid grid-cols-2 gap-3 mb-2">
<!-- CPU -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-secondary">CPU</span>
<span id="cpu-percent" class="text-light">--%</span>
</div>
<div class="w-full bg-dark rounded-full h-2">
<div id="cpu-bar" class="progress-bar h-2 rounded-full bg-primary" style="width: 0%"></div>
</div>
</div>
<!-- Memory -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-secondary">Memory</span>
<span id="mem-percent" class="text-light">--%</span>
</div>
<div class="w-full bg-dark rounded-full h-2">
<div id="mem-bar" class="progress-bar h-2 rounded-full bg-accent" style="width: 0%"></div>
</div>
</div>
</div>
<!-- Row 2: Network and Uptime -->
<div class="grid grid-cols-2 gap-3 mb-2">
<!-- Network -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-secondary">Network</span>
<span id="net-io" class="text-light">--</span>
</div>
<div class="text-xs text-secondary"><span id="net-sent">0</span> / ⬇<span id="net-recv">0</span> MB</div>
</div>
<!-- Uptime -->
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-secondary">Uptime</span>
<span id="uptime" class="text-light">--</span>
</div>
<div class="text-xs text-secondary" id="last-update">Live: --:--:--</div>
</div>
</div>
<!-- Pool Status -->
<div class="border-t border-border pt-2">
<div class="grid grid-cols-3 gap-3 text-xs">
<div>
<span class="text-secondary">🔥 Permanent:</span>
<span id="pool-perm" class="text-primary ml-1">INACTIVE (0MB)</span>
</div>
<div>
<span class="text-secondary">♨️ Hot:</span>
<span id="pool-hot" class="text-accent ml-1">0 (0MB)</span>
</div>
<div>
<span class="text-secondary">❄️ Cold:</span>
<span id="pool-cold" class="text-light ml-1">0 (0MB)</span>
</div>
</div>
<div class="mt-1 text-xs text-secondary">
<span>Janitor: </span><span id="janitor-status">adaptive</span> |
<span>Memory pressure: </span><span id="mem-pressure">LOW</span>
</div>
</div>
</section>
<!-- Infrastructure Section -->
<section id="containers-section" class="bg-surface rounded-lg border border-border p-3" style="display: none;">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-medium text-primary">📦 Infrastructure</h2>
<div class="flex items-center space-x-2">
<span class="text-xs text-secondary">Mode:</span>
<span id="deployment-mode" class="text-xs text-primary font-medium">single</span>
<span class="text-xs text-secondary">|</span>
<span class="text-xs text-secondary">Containers:</span>
<span id="container-count" class="text-xs text-accent font-medium">1</span>
</div>
</div>
<!-- Container Filter Buttons -->
<div id="container-filters" class="flex flex-wrap gap-2 mb-3">
<button class="container-filter-btn px-3 py-1 rounded text-xs bg-primary text-dark font-medium" data-container="all">
All
</button>
</div>
<!-- Container Grid -->
<div id="containers-grid" class="grid grid-cols-3 gap-3 text-xs">
<!-- Containers will be populated here -->
</div>
</section>
</div>
<!-- Live Activity Grid (2x2) -->
<div class="grid grid-cols-2 gap-4">
<!-- Requests 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 flex items-center justify-between">
<h3 class="text-sm font-medium text-primary">📝 Requests (<span id="active-count">0</span> active)</h3>
<select id="filter-requests" class="bg-dark border border-border rounded px-2 py-1 text-xs">
<option value="all">All</option>
<option value="success">Success</option>
<option value="error">Errors</option>
</select>
</div>
<div class="flex-1 overflow-auto p-3 space-y-2">
<div id="active-requests-list" class="text-xs space-y-1 mb-3">
<div class="text-secondary text-center py-2">No active requests</div>
</div>
<h4 class="text-xs font-medium text-secondary border-t border-border pt-2 mb-2">Recent Completed</h4>
<div id="completed-requests-list" class="text-xs space-y-1">
<div class="text-secondary text-center py-2">No completed requests</div>
</div>
</div>
</section>
<!-- 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 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>
<div class="flex-1 overflow-auto p-3">
<table class="w-full text-xs">
<thead class="border-b border-border">
<tr class="text-secondary text-left">
<th class="py-1 pr-2">Type</th>
<th class="py-1 pr-2">Sig</th>
<th class="py-1 pr-2">Age</th>
<th class="py-1 pr-2">Used</th>
<th class="py-1 pr-2">Hits</th>
<th class="py-1 pr-2">Container</th>
<th class="py-1">Act</th>
</tr>
</thead>
<tbody id="browsers-table-body">
<tr><td colspan="7" class="text-center py-4 text-secondary">No browsers</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Janitor Section -->
<section class="bg-surface rounded-lg border border-border overflow-hidden flex flex-col" style="height: 300px;">
<div class="px-4 py-2 border-b border-border">
<h3 class="text-sm font-medium text-primary">🧹 Janitor Events</h3>
</div>
<div class="flex-1 overflow-auto p-3">
<div id="janitor-log" class="text-xs space-y-1 font-mono">
<div class="text-secondary text-center py-4">No events yet</div>
</div>
</div>
</section>
<!-- Errors Section -->
<section class="bg-surface rounded-lg border border-border overflow-hidden flex flex-col" style="height: 300px;">
<div class="px-4 py-2 border-b border-border">
<h3 class="text-sm font-medium text-primary">❌ Errors</h3>
</div>
<div class="flex-1 overflow-auto p-3">
<div id="errors-log" class="text-xs space-y-1">
<div class="text-secondary text-center py-4">No errors</div>
</div>
</div>
</section>
</div>
<!-- Endpoint Analytics & Timeline (Side by side) -->
<div class="grid grid-cols-2 gap-4">
<!-- Endpoint Analytics -->
<section class="bg-surface rounded-lg border border-border p-4">
<h2 class="text-sm font-medium mb-3 text-primary">Endpoint Analytics</h2>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead class="border-b border-border">
<tr class="text-secondary text-left">
<th class="py-2 pr-4">Endpoint</th>
<th class="py-2 pr-4 text-right">Count</th>
<th class="py-2 pr-4 text-right">Avg Latency</th>
<th class="py-2 pr-4 text-right">Success%</th>
<th class="py-2 pr-4 text-right">Pool%</th>
</tr>
</thead>
<tbody id="endpoints-table-body">
<tr><td colspan="5" class="text-center py-4 text-secondary">No data</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Resource Timeline -->
<section class="bg-surface rounded-lg border border-border p-4">
<div class="flex items-center justify-between mb-3">
<h2 class="text-sm font-medium text-primary">Resource Timeline (5min)</h2>
<select id="timeline-metric" class="bg-dark border border-border rounded px-2 py-1 text-xs">
<option value="memory">Memory %</option>
<option value="requests">Requests/5s</option>
<option value="browsers">Browser Count</option>
</select>
</div>
<svg id="timeline-chart" class="w-full" style="height: 120px;" viewBox="0 0 400 120">
<!-- Chart will be drawn here -->
<text x="200" y="60" text-anchor="middle" fill="#D5CEBF" font-size="12">Loading...</text>
</svg>
</section>
</div>
<!-- Control Actions -->
<section class="bg-surface rounded-lg border border-accent p-4">
<h2 class="text-sm font-medium mb-3 text-accent">Control Actions</h2>
<div class="flex flex-wrap gap-2">
<button id="btn-force-cleanup" class="px-3 py-1 bg-accent text-dark rounded text-xs hover:opacity-90">
🧹 Force Cleanup
</button>
<button id="btn-restart-perm" class="px-3 py-1 bg-primary text-dark rounded text-xs hover:opacity-90">
🔄 Restart Permanent
</button>
<button id="btn-reset-stats" class="px-3 py-1 border border-border rounded text-xs hover:bg-dark">
📊 Reset Stats
</button>
<div class="ml-auto text-xs text-secondary" id="action-status"></div>
</div>
</section>
</main>
<script>
// ========== State Management ==========
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() {
// Clean up existing connection first to prevent resource leaks
if (websocket) {
try {
websocket.close();
} catch (e) {
console.error('Error closing old WebSocket:', e);
}
websocket = null;
}
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`;
try {
websocket = new WebSocket(wsUrl);
} catch (e) {
console.error('Failed to create WebSocket:', e);
setTimeout(() => connectWebSocket(), 2000 * wsReconnectAttempts);
return;
}
// Set connection timeout to prevent indefinite connection attempts
const connectionTimeout = setTimeout(() => {
if (websocket && websocket.readyState === WebSocket.CONNECTING) {
console.log('WebSocket connection timeout');
websocket.close();
}
}, 5000);
websocket.onopen = () => {
clearTimeout(connectionTimeout);
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) => {
clearTimeout(connectionTimeout);
console.error('WebSocket error:', error);
};
websocket.onclose = (event) => {
clearTimeout(connectionTimeout);
console.log(`WebSocket closed: code=${event.code}, reason=${event.reason}`);
updateConnectionStatus('disconnected', 'Reconnecting...');
websocket = null; // Clear reference
if (useWebSocket && wsReconnectAttempts < MAX_WS_RECONNECT) {
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) {
// Filter requests based on current container filter
const filteredActive = currentContainerFilter === 'all'
? requests.active
: requests.active.filter(r => r.container_id === currentContainerFilter);
const filteredCompleted = currentContainerFilter === 'all'
? requests.completed
: requests.completed.filter(r => r.container_id === currentContainerFilter);
// Update active requests count
const activeCount = document.getElementById('active-count');
if (activeCount) activeCount.textContent = filteredActive.length;
// Update active requests list
const activeList = document.getElementById('active-requests-list');
if (activeList) {
if (filteredActive.length === 0) {
activeList.innerHTML = '<div class="text-secondary text-center py-2">No active requests</div>';
} else {
activeList.innerHTML = filteredActive.map(req => `
<div class="flex items-center justify-between p-2 bg-dark rounded border border-border">
<span class="text-accent text-xs">${getContainerLabel(req.container_id)}</span>
<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 (filteredCompleted.length === 0) {
completedList.innerHTML = '<div class="text-secondary text-center py-2">No completed requests</div>';
} else {
completedList.innerHTML = filteredCompleted.map(req => `
<div class="flex items-center gap-3 p-2 bg-dark rounded">
<span class="text-accent text-xs w-12 flex-shrink-0">${getContainerLabel(req.container_id)}</span>
<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';
// Check if should display based on filter
const shouldDisplay = currentContainerFilter === 'all' ||
b.container_id === currentContainerFilter;
if (!shouldDisplay) return '';
// Find container label (C-1, C-2, etc)
const containerLabel = getContainerLabel(b.container_id);
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 pr-2 text-accent text-xs">${containerLabel}</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) {
// Filter events based on current container filter
const filtered = currentContainerFilter === 'all'
? events
: events.filter(e => e.container_id === currentContainerFilter);
if (filtered.length === 0) {
janitorLog.innerHTML = '<div class="text-secondary text-center py-4">No events yet</div>';
} else {
janitorLog.innerHTML = filtered.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);
const containerLabel = getContainerLabel(evt.container_id);
return `<div class="p-2 bg-dark rounded">
<span class="text-accent text-xs">${containerLabel}</span>
<span class="text-secondary ml-2">${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;
this.textContent = autoRefresh ? 'ON ⚡1s' : 'OFF';
this.classList.toggle('bg-primary');
this.classList.toggle('bg-dark');
this.classList.toggle('text-dark');
this.classList.toggle('text-light');
if (autoRefresh) {
startAutoRefresh();
} else {
stopAutoRefresh();
}
});
function startAutoRefresh() {
fetchAll();
refreshInterval = setInterval(fetchAll, REFRESH_RATE);
}
function stopAutoRefresh() {
if (refreshInterval) clearInterval(refreshInterval);
}
// ========== Data Fetching ==========
async function fetchAll() {
await Promise.all([
fetchHealth(),
fetchRequests(),
fetchBrowsers(),
fetchJanitorLog(),
fetchErrors(),
fetchEndpointStats(),
fetchTimeline()
]);
}
async function fetchHealth() {
try {
const res = await fetch('/monitor/health');
const data = await res.json();
// Container metrics
const cpu = data.container.cpu_percent;
const mem = data.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 = data.container.network_sent_mb.toFixed(1);
document.getElementById('net-recv').textContent = data.container.network_recv_mb.toFixed(1);
const uptime = formatUptime(data.container.uptime_seconds);
document.getElementById('uptime').textContent = uptime;
// Pool status
const perm = data.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 =
`${data.pool.hot.count} (${data.pool.hot.memory_mb}MB)`;
document.getElementById('pool-cold').textContent =
`${data.pool.cold.count} (${data.pool.cold.memory_mb}MB)`;
// Janitor
document.getElementById('janitor-status').textContent = data.janitor.next_cleanup_estimate;
const pressure = data.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 = 'Updated: ' + new Date().toLocaleTimeString();
} catch (e) {
console.error('Failed to fetch health:', e);
}
}
async function fetchRequests() {
try {
const filter = document.getElementById('filter-requests')?.value || 'all';
const res = await fetch(`/monitor/requests?status=${filter}&limit=50`);
const data = await res.json();
// Active requests
const activeList = document.getElementById('active-requests-list');
document.getElementById('active-count').textContent = data.active.length;
if (data.active.length === 0) {
activeList.innerHTML = '<div class="text-secondary text-center py-2">No active requests</div>';
} else {
activeList.innerHTML = data.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('');
}
// Completed requests
const completedList = document.getElementById('completed-requests-list');
if (data.completed.length === 0) {
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 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('');
}
} catch (e) {
console.error('Failed to fetch requests:', e);
}
}
async function fetchBrowsers() {
try {
const res = await fetch('/monitor/browsers');
const data = await res.json();
document.getElementById('browser-count').textContent = data.summary.total_count;
document.getElementById('browser-mem').textContent = data.summary.total_memory_mb;
document.getElementById('reuse-rate').textContent = data.summary.reuse_rate_percent.toFixed(1) + '%';
const tbody = document.getElementById('browsers-table-body');
if (data.browsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-2 text-secondary">No browsers</td></tr>';
} else {
tbody.innerHTML = data.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)}</td>
<td class="py-1 pr-2">${formatSeconds(b.last_used_seconds)}</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('');
}
} catch (e) {
console.error('Failed to fetch browsers:', e);
}
}
async function fetchJanitorLog() {
try {
const res = await fetch('/monitor/logs/janitor?limit=100');
const data = await res.json();
const logEl = document.getElementById('janitor-log');
if (data.events.length === 0) {
logEl.innerHTML = '<div class="text-secondary text-center py-4">No events yet</div>';
} else {
logEl.innerHTML = data.events.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('');
}
} catch (e) {
console.error('Failed to fetch janitor log:', e);
}
}
async function fetchErrors() {
try {
const res = await fetch('/monitor/logs/errors?limit=100');
const data = await res.json();
const logEl = document.getElementById('errors-log');
if (data.errors.length === 0) {
logEl.innerHTML = '<div class="text-secondary text-center py-4">No errors</div>';
} else {
logEl.innerHTML = data.errors.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('');
}
} catch (e) {
console.error('Failed to fetch errors:', e);
}
}
async function fetchEndpointStats() {
try {
const res = await fetch('/monitor/endpoints/stats');
const data = await res.json();
const tbody = document.getElementById('endpoints-table-body');
const endpoints = Object.entries(data);
if (endpoints.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center py-4 text-secondary">No data</td></tr>';
} else {
tbody.innerHTML = endpoints.map(([endpoint, stats]) => `
<tr class="border-t border-border">
<td class="py-2 pr-4 text-primary">${endpoint}</td>
<td class="py-2 pr-4 text-right">${stats.count}</td>
<td class="py-2 pr-4 text-right">${stats.avg_latency_ms}ms</td>
<td class="py-2 pr-4 text-right ${stats.success_rate_percent >= 99 ? 'text-green-500' : 'text-yellow-500'}">
${stats.success_rate_percent.toFixed(1)}%
</td>
<td class="py-2 pr-4 text-right ${stats.pool_hit_rate_percent >= 90 ? 'text-green-500' : 'text-yellow-500'}">
${stats.pool_hit_rate_percent.toFixed(1)}%
</td>
</tr>
`).join('');
}
} catch (e) {
console.error('Failed to fetch endpoint stats:', e);
}
}
async function fetchTimeline() {
try {
const metric = document.getElementById('timeline-metric').value;
const res = await fetch(`/monitor/timeline?metric=${metric}`);
const data = await res.json();
drawTimeline(data, metric);
} catch (e) {
console.error('Failed to fetch timeline:', e);
}
}
function drawTimeline(data, metric) {
const svg = document.getElementById('timeline-chart');
const width = 400;
const height = 120;
const padding = 20;
// Clear previous chart
svg.innerHTML = '';
if (!data.values || data.values.length === 0) {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', width / 2);
text.setAttribute('y', height / 2);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('fill', '#D5CEBF');
text.setAttribute('font-size', '12');
text.textContent = 'No data';
svg.appendChild(text);
return;
}
// Handle browsers metric (nested data)
let values = data.values;
if (metric === 'browsers') {
// Sum all browser types
values = values.map(v => (v.permanent || 0) + (v.hot || 0) + (v.cold || 0));
}
const maxValue = Math.max(...values, 1);
const minValue = 0;
// Draw grid lines
for (let i = 0; i <= 4; i++) {
const y = padding + (height - 2 * padding) * (i / 4);
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', padding);
line.setAttribute('y1', y);
line.setAttribute('x2', width - padding);
line.setAttribute('y2', y);
line.setAttribute('stroke', '#3F3F44');
line.setAttribute('stroke-width', '1');
line.setAttribute('stroke-dasharray', '2,2');
svg.appendChild(line);
}
// Draw line
if (values.length > 1) {
const points = values.map((v, i) => {
const x = padding + (width - 2 * padding) * (i / (values.length - 1));
const y = height - padding - ((v - minValue) / (maxValue - minValue)) * (height - 2 * padding);
return `${x},${y}`;
}).join(' ');
const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
polyline.setAttribute('points', points);
polyline.setAttribute('fill', 'none');
polyline.setAttribute('stroke', '#4EFFFF');
polyline.setAttribute('stroke-width', '2');
polyline.classList.add('sparkline');
svg.appendChild(polyline);
// Add glow effect
const polylineGlow = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
polylineGlow.setAttribute('points', points);
polylineGlow.setAttribute('fill', 'none');
polylineGlow.setAttribute('stroke', '#4EFFFF');
polylineGlow.setAttribute('stroke-width', '4');
polylineGlow.setAttribute('opacity', '0.3');
polylineGlow.classList.add('sparkline');
svg.insertBefore(polylineGlow, polyline);
}
// Y-axis labels
const labelMax = document.createElementNS('http://www.w3.org/2000/svg', 'text');
labelMax.setAttribute('x', '5');
labelMax.setAttribute('y', padding);
labelMax.setAttribute('fill', '#D5CEBF');
labelMax.setAttribute('font-size', '10');
labelMax.textContent = maxValue.toFixed(0);
svg.appendChild(labelMax);
const labelMin = document.createElementNS('http://www.w3.org/2000/svg', 'text');
labelMin.setAttribute('x', '5');
labelMin.setAttribute('y', height - padding);
labelMin.setAttribute('fill', '#D5CEBF');
labelMin.setAttribute('font-size', '10');
labelMin.textContent = minValue.toFixed(0);
svg.appendChild(labelMin);
}
// Timeline metric selector
document.getElementById('timeline-metric').addEventListener('change', fetchTimeline);
// ========== Control Actions ==========
async function killBrowser(sig) {
if (!confirm(`Kill browser ${sig}?`)) return;
try {
const res = await fetch('/monitor/actions/kill_browser', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({sig})
});
const data = await res.json();
showActionStatus(data.success ? `✅ Killed ${sig}` : `❌ Failed to kill`, data.success);
if (data.success) fetchBrowsers();
} catch (e) {
showActionStatus('❌ Error: ' + e.message, false);
}
}
async function restartBrowser(sig) {
if (!confirm(`Restart browser ${sig}?`)) return;
try {
const res = await fetch('/monitor/actions/restart_browser', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({sig})
});
const data = await res.json();
showActionStatus(data.success ? `✅ Restarted ${sig}` : `❌ Failed to restart`, data.success);
if (data.success) fetchBrowsers();
} catch (e) {
showActionStatus('❌ Error: ' + e.message, false);
}
}
document.getElementById('btn-force-cleanup').addEventListener('click', async () => {
if (!confirm('Force cleanup all cold pool browsers?')) return;
try {
const res = await fetch('/monitor/actions/cleanup', {method: 'POST'});
const data = await res.json();
showActionStatus(`✅ Killed ${data.killed_browsers} browsers`, true);
fetchAll();
} catch (e) {
showActionStatus('❌ Error: ' + e.message, false);
}
});
document.getElementById('btn-restart-perm').addEventListener('click', async () => {
if (!confirm('Restart permanent browser? This will briefly interrupt service.')) return;
try {
const res = await fetch('/monitor/actions/restart_browser', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({sig: 'permanent'})
});
const data = await res.json();
showActionStatus(data.success ? '✅ Permanent browser restarted' : '❌ Failed', data.success);
fetchAll();
} catch (e) {
showActionStatus('❌ Error: ' + e.message, false);
}
});
document.getElementById('btn-reset-stats').addEventListener('click', async () => {
if (!confirm('Reset all endpoint statistics?')) return;
try {
const res = await fetch('/monitor/stats/reset', {method: 'POST'});
const data = await res.json();
showActionStatus('✅ Stats reset', true);
fetchEndpointStats();
} catch (e) {
showActionStatus('❌ Error: ' + e.message, false);
}
});
function showActionStatus(msg, success) {
const el = document.getElementById('action-status');
el.textContent = msg;
el.className = success ? 'ml-auto text-xs text-green-500' : 'ml-auto text-xs text-red-500';
setTimeout(() => el.textContent = '', 3000);
}
// ========== Utility Functions ==========
function formatUptime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
function formatSeconds(seconds) {
if (seconds < 60) return `${seconds}s`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}m ${s}s`;
}
// ========== Containers Management ==========
let currentContainerFilter = 'all';
let containerMapping = {}; // Maps container_id to label (C-1, C-2, etc)
// Helper to get container label from ID or hostname
function getContainerLabel(containerId) {
// Try direct lookup first (works for both hostname and id)
if (containerMapping[containerId]) {
return containerMapping[containerId];
}
// Fallback: show first 8 chars of container ID
return containerId?.substring(0, 8) || 'unknown';
}
async function fetchContainers() {
try {
const res = await fetch('/monitor/containers');
const data = await res.json();
document.getElementById('deployment-mode').textContent = data.mode;
document.getElementById('container-count').textContent = data.count;
// Build container ID to label mapping
// Use hostname as primary key (friendly name like "crawl4ai-1")
// Also map id for backwards compatibility
containerMapping = {};
data.containers.forEach((c, i) => {
const label = `C-${i+1}`;
containerMapping[c.hostname] = label; // Map hostname
containerMapping[c.id] = label; // Also map id
});
// Show section only if multi-container
const section = document.getElementById('containers-section');
if (data.count > 1) {
section.style.display = 'block';
// Update filter buttons
const filtersDiv = document.getElementById('container-filters');
filtersDiv.innerHTML = `
<button class="container-filter-btn px-3 py-1 rounded text-xs ${currentContainerFilter === 'all' ? 'bg-primary text-dark' : 'bg-dark text-secondary'} font-medium" data-container="all">All</button>
${data.containers.map((c, i) => `
<button class="container-filter-btn px-3 py-1 rounded text-xs ${currentContainerFilter === c.id ? 'bg-primary text-dark' : 'bg-dark text-secondary'}" data-container="${c.id}">C-${i+1}</button>
`).join('')}
`;
// Add click handlers to filter buttons
document.querySelectorAll('.container-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentContainerFilter = btn.dataset.container;
fetchContainers(); // Refresh to update button styles
// Re-fetch all data with filter applied
fetchRequests();
fetchBrowsers();
fetchJanitorLogs();
fetchErrorLogs();
});
});
// Update containers grid
const grid = document.getElementById('containers-grid');
grid.innerHTML = data.containers.map((c, i) => `
<div class="p-3 bg-dark rounded border ${currentContainerFilter === c.id || currentContainerFilter === 'all' ? 'border-primary' : 'border-border'}">
<div class="flex items-center justify-between mb-2">
<span class="text-primary font-medium">C-${i+1}</span>
<span class="text-xs ${c.healthy ? 'text-accent' : 'text-red-500'}">${c.healthy ? '🟢' : '🔴'}</span>
</div>
<div class="text-xs text-secondary truncate" title="${c.hostname}">${c.hostname}</div>
</div>
`).join('');
} else {
section.style.display = 'none';
}
} catch (e) {
console.error('Failed to fetch containers:', e);
}
}
// ========== Filter change handler ==========
document.getElementById('filter-requests')?.addEventListener('change', fetchRequests);
// ========== Initialize ==========
// Fetch containers info on load
fetchContainers();
// Try WebSocket first, fallback to polling on failure
connectWebSocket();
</script>
</body>
</html>