feat(monitor): add real-time monitoring dashboard with Redis persistence
Complete observability solution for production deployments with terminal-style UI.
**Backend Implementation:**
- `monitor.py`: Stats manager tracking requests, browsers, errors, timeline data
- `monitor_routes.py`: REST API endpoints for all monitor functionality
- GET /monitor/health - System health snapshot
- GET /monitor/requests - Active & completed requests
- GET /monitor/browsers - Browser pool details
- GET /monitor/endpoints/stats - Aggregated endpoint analytics
- GET /monitor/timeline - Time-series data (memory, requests, browsers)
- GET /monitor/logs/{janitor,errors} - Event logs
- POST /monitor/actions/{cleanup,kill_browser,restart_browser} - Control actions
- POST /monitor/stats/reset - Reset counters
- Redis persistence for endpoint stats (survives restart)
- Timeline tracking (5min window, 5s resolution, 60 data points)
**Frontend Dashboard** (`/dashboard`):
- **System Health Bar**: CPU%, Memory%, Network I/O, Uptime
- **Pool Status**: Live counts (permanent/hot/cold browsers + memory)
- **Live Activity Tabs**:
- Requests: Active (realtime) + recent completed (last 100)
- Browsers: Detailed table with actions (kill/restart)
- Janitor: Cleanup event log with timestamps
- Errors: Recent errors with stack traces
- **Endpoint Analytics**: Count, avg latency, success%, pool hit%
- **Resource Timeline**: SVG charts (memory/requests/browsers) with terminal aesthetics
- **Control Actions**: Force cleanup, restart permanent, reset stats
- **Auto-refresh**: 5s polling (toggleable)
**Integration:**
- Janitor events tracked (close_cold, close_hot, promote)
- Crawler pool promotion events logged
- Timeline updater background task (5s interval)
- Lifespan hooks for monitor initialization
**UI Design:**
- Terminal vibe matching Crawl4AI theme
- Dark background, cyan/pink accents, monospace font
- Neon glow effects on charts
- Responsive layout, hover interactions
- Cross-navigation: Playground ↔ Monitor
**Key Features:**
- Zero-config: Works out of the box with existing Redis
- Real-time visibility into pool efficiency
- Manual browser management (kill/restart)
- Historical data persistence
- DevOps-friendly UX
Routes:
- API: `/monitor/*` (backend endpoints)
- UI: `/dashboard` (static HTML)
This commit is contained in:
@@ -57,6 +57,14 @@ async def get_crawler(cfg: BrowserConfig) -> AsyncWebCrawler:
|
|||||||
if USAGE_COUNT[sig] >= 3:
|
if USAGE_COUNT[sig] >= 3:
|
||||||
logger.info(f"⬆️ Promoting to hot pool (sig={sig[:8]}, count={USAGE_COUNT[sig]})")
|
logger.info(f"⬆️ Promoting to hot pool (sig={sig[:8]}, count={USAGE_COUNT[sig]})")
|
||||||
HOT_POOL[sig] = COLD_POOL.pop(sig)
|
HOT_POOL[sig] = COLD_POOL.pop(sig)
|
||||||
|
|
||||||
|
# Track promotion in monitor
|
||||||
|
try:
|
||||||
|
from monitor import get_monitor
|
||||||
|
get_monitor().track_janitor_event("promote", sig, {"count": USAGE_COUNT[sig]})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
return HOT_POOL[sig]
|
return HOT_POOL[sig]
|
||||||
|
|
||||||
logger.info(f"❄️ Using cold pool browser (sig={sig[:8]})")
|
logger.info(f"❄️ Using cold pool browser (sig={sig[:8]})")
|
||||||
@@ -124,23 +132,39 @@ async def janitor():
|
|||||||
# Clean cold pool
|
# Clean cold pool
|
||||||
for sig in list(COLD_POOL.keys()):
|
for sig in list(COLD_POOL.keys()):
|
||||||
if now - LAST_USED.get(sig, now) > cold_ttl:
|
if now - LAST_USED.get(sig, now) > cold_ttl:
|
||||||
logger.info(f"🧹 Closing cold browser (sig={sig[:8]}, idle={now - LAST_USED[sig]:.0f}s)")
|
idle_time = now - LAST_USED[sig]
|
||||||
|
logger.info(f"🧹 Closing cold browser (sig={sig[:8]}, idle={idle_time:.0f}s)")
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await COLD_POOL[sig].close()
|
await COLD_POOL[sig].close()
|
||||||
COLD_POOL.pop(sig, None)
|
COLD_POOL.pop(sig, None)
|
||||||
LAST_USED.pop(sig, None)
|
LAST_USED.pop(sig, None)
|
||||||
USAGE_COUNT.pop(sig, None)
|
USAGE_COUNT.pop(sig, None)
|
||||||
|
|
||||||
|
# Track in monitor
|
||||||
|
try:
|
||||||
|
from monitor import get_monitor
|
||||||
|
get_monitor().track_janitor_event("close_cold", sig, {"idle_seconds": int(idle_time), "ttl": cold_ttl})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# Clean hot pool (more conservative)
|
# Clean hot pool (more conservative)
|
||||||
for sig in list(HOT_POOL.keys()):
|
for sig in list(HOT_POOL.keys()):
|
||||||
if now - LAST_USED.get(sig, now) > hot_ttl:
|
if now - LAST_USED.get(sig, now) > hot_ttl:
|
||||||
logger.info(f"🧹 Closing hot browser (sig={sig[:8]}, idle={now - LAST_USED[sig]:.0f}s)")
|
idle_time = now - LAST_USED[sig]
|
||||||
|
logger.info(f"🧹 Closing hot browser (sig={sig[:8]}, idle={idle_time:.0f}s)")
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
await HOT_POOL[sig].close()
|
await HOT_POOL[sig].close()
|
||||||
HOT_POOL.pop(sig, None)
|
HOT_POOL.pop(sig, None)
|
||||||
LAST_USED.pop(sig, None)
|
LAST_USED.pop(sig, None)
|
||||||
USAGE_COUNT.pop(sig, None)
|
USAGE_COUNT.pop(sig, None)
|
||||||
|
|
||||||
|
# Track in monitor
|
||||||
|
try:
|
||||||
|
from monitor import get_monitor
|
||||||
|
get_monitor().track_janitor_event("close_hot", sig, {"idle_seconds": int(idle_time), "ttl": hot_ttl})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# Log pool stats
|
# Log pool stats
|
||||||
if mem_pct > 60:
|
if mem_pct > 60:
|
||||||
logger.info(f"📊 Pool: hot={len(HOT_POOL)}, cold={len(COLD_POOL)}, mem={mem_pct:.1f}%")
|
logger.info(f"📊 Pool: hot={len(HOT_POOL)}, cold={len(COLD_POOL)}, mem={mem_pct:.1f}%")
|
||||||
|
|||||||
305
deploy/docker/monitor.py
Normal file
305
deploy/docker/monitor.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
# monitor.py - Real-time monitoring stats with Redis persistence
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from collections import deque
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
from utils import get_container_memory_percent
|
||||||
|
import psutil
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class MonitorStats:
|
||||||
|
"""Tracks real-time server stats with Redis persistence."""
|
||||||
|
|
||||||
|
def __init__(self, redis: aioredis.Redis):
|
||||||
|
self.redis = redis
|
||||||
|
self.start_time = time.time()
|
||||||
|
|
||||||
|
# In-memory queues (fast reads, Redis backup)
|
||||||
|
self.active_requests: Dict[str, Dict] = {} # id -> request info
|
||||||
|
self.completed_requests: deque = deque(maxlen=100) # Last 100
|
||||||
|
self.janitor_events: deque = deque(maxlen=100)
|
||||||
|
self.errors: deque = deque(maxlen=100)
|
||||||
|
|
||||||
|
# Endpoint stats (persisted in Redis)
|
||||||
|
self.endpoint_stats: Dict[str, Dict] = {} # endpoint -> {count, total_time, errors, ...}
|
||||||
|
|
||||||
|
# Timeline data (5min window, 5s resolution = 60 points)
|
||||||
|
self.memory_timeline: deque = deque(maxlen=60)
|
||||||
|
self.requests_timeline: deque = deque(maxlen=60)
|
||||||
|
self.browser_timeline: deque = deque(maxlen=60)
|
||||||
|
|
||||||
|
async def track_request_start(self, request_id: str, endpoint: str, url: str, config: Dict = None):
|
||||||
|
"""Track new request start."""
|
||||||
|
req_info = {
|
||||||
|
"id": request_id,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"url": url[:100], # Truncate long URLs
|
||||||
|
"start_time": time.time(),
|
||||||
|
"config_sig": config.get("sig", "default") if config else "default",
|
||||||
|
"mem_start": psutil.Process().memory_info().rss / (1024 * 1024)
|
||||||
|
}
|
||||||
|
self.active_requests[request_id] = req_info
|
||||||
|
|
||||||
|
# Increment endpoint counter
|
||||||
|
if endpoint not in self.endpoint_stats:
|
||||||
|
self.endpoint_stats[endpoint] = {
|
||||||
|
"count": 0, "total_time": 0, "errors": 0,
|
||||||
|
"pool_hits": 0, "success": 0
|
||||||
|
}
|
||||||
|
self.endpoint_stats[endpoint]["count"] += 1
|
||||||
|
|
||||||
|
# Persist to Redis (fire and forget)
|
||||||
|
asyncio.create_task(self._persist_endpoint_stats())
|
||||||
|
|
||||||
|
async def track_request_end(self, request_id: str, success: bool, error: str = None,
|
||||||
|
pool_hit: bool = True, status_code: int = 200):
|
||||||
|
"""Track request completion."""
|
||||||
|
if request_id not in self.active_requests:
|
||||||
|
return
|
||||||
|
|
||||||
|
req_info = self.active_requests.pop(request_id)
|
||||||
|
end_time = time.time()
|
||||||
|
elapsed = end_time - req_info["start_time"]
|
||||||
|
mem_end = psutil.Process().memory_info().rss / (1024 * 1024)
|
||||||
|
mem_delta = mem_end - req_info["mem_start"]
|
||||||
|
|
||||||
|
# Update stats
|
||||||
|
endpoint = req_info["endpoint"]
|
||||||
|
if endpoint in self.endpoint_stats:
|
||||||
|
self.endpoint_stats[endpoint]["total_time"] += elapsed
|
||||||
|
if success:
|
||||||
|
self.endpoint_stats[endpoint]["success"] += 1
|
||||||
|
else:
|
||||||
|
self.endpoint_stats[endpoint]["errors"] += 1
|
||||||
|
if pool_hit:
|
||||||
|
self.endpoint_stats[endpoint]["pool_hits"] += 1
|
||||||
|
|
||||||
|
# Add to completed queue
|
||||||
|
completed = {
|
||||||
|
**req_info,
|
||||||
|
"end_time": end_time,
|
||||||
|
"elapsed": round(elapsed, 2),
|
||||||
|
"mem_delta": round(mem_delta, 1),
|
||||||
|
"success": success,
|
||||||
|
"error": error,
|
||||||
|
"status_code": status_code,
|
||||||
|
"pool_hit": pool_hit
|
||||||
|
}
|
||||||
|
self.completed_requests.append(completed)
|
||||||
|
|
||||||
|
# Track errors
|
||||||
|
if not success and error:
|
||||||
|
self.errors.append({
|
||||||
|
"timestamp": end_time,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"url": req_info["url"],
|
||||||
|
"error": error,
|
||||||
|
"request_id": request_id
|
||||||
|
})
|
||||||
|
|
||||||
|
await self._persist_endpoint_stats()
|
||||||
|
|
||||||
|
def track_janitor_event(self, event_type: str, sig: str, details: Dict):
|
||||||
|
"""Track janitor cleanup events."""
|
||||||
|
self.janitor_events.append({
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"type": event_type, # "close_cold", "close_hot", "promote"
|
||||||
|
"sig": sig[:8],
|
||||||
|
"details": details
|
||||||
|
})
|
||||||
|
|
||||||
|
async def update_timeline(self):
|
||||||
|
"""Update timeline data points (called every 5s)."""
|
||||||
|
now = time.time()
|
||||||
|
mem_pct = get_container_memory_percent()
|
||||||
|
|
||||||
|
# Count requests in last 5s
|
||||||
|
recent_reqs = sum(1 for req in self.completed_requests
|
||||||
|
if now - req.get("end_time", 0) < 5)
|
||||||
|
|
||||||
|
# Browser counts (need to import from crawler_pool)
|
||||||
|
from crawler_pool import PERMANENT, HOT_POOL, COLD_POOL
|
||||||
|
browser_count = {
|
||||||
|
"permanent": 1 if PERMANENT else 0,
|
||||||
|
"hot": len(HOT_POOL),
|
||||||
|
"cold": len(COLD_POOL)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.memory_timeline.append({"time": now, "value": mem_pct})
|
||||||
|
self.requests_timeline.append({"time": now, "value": recent_reqs})
|
||||||
|
self.browser_timeline.append({"time": now, "browsers": browser_count})
|
||||||
|
|
||||||
|
async def _persist_endpoint_stats(self):
|
||||||
|
"""Persist endpoint stats to Redis."""
|
||||||
|
try:
|
||||||
|
await self.redis.set(
|
||||||
|
"monitor:endpoint_stats",
|
||||||
|
json.dumps(self.endpoint_stats),
|
||||||
|
ex=86400 # 24h TTL
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to persist endpoint stats: {e}")
|
||||||
|
|
||||||
|
async def load_from_redis(self):
|
||||||
|
"""Load persisted stats from Redis."""
|
||||||
|
try:
|
||||||
|
data = await self.redis.get("monitor:endpoint_stats")
|
||||||
|
if data:
|
||||||
|
self.endpoint_stats = json.loads(data)
|
||||||
|
logger.info("Loaded endpoint stats from Redis")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load from Redis: {e}")
|
||||||
|
|
||||||
|
def get_health_summary(self) -> Dict:
|
||||||
|
"""Get current system health snapshot."""
|
||||||
|
mem_pct = get_container_memory_percent()
|
||||||
|
cpu_pct = psutil.cpu_percent(interval=0.1)
|
||||||
|
|
||||||
|
# Network I/O (delta since last call)
|
||||||
|
net = psutil.net_io_counters()
|
||||||
|
|
||||||
|
# Pool status
|
||||||
|
from crawler_pool import PERMANENT, HOT_POOL, COLD_POOL, LAST_USED
|
||||||
|
permanent_mem = 270 if PERMANENT else 0 # Estimate
|
||||||
|
hot_mem = len(HOT_POOL) * 180 # Estimate 180MB per browser
|
||||||
|
cold_mem = len(COLD_POOL) * 180
|
||||||
|
|
||||||
|
return {
|
||||||
|
"container": {
|
||||||
|
"memory_percent": round(mem_pct, 1),
|
||||||
|
"cpu_percent": round(cpu_pct, 1),
|
||||||
|
"network_sent_mb": round(net.bytes_sent / (1024**2), 2),
|
||||||
|
"network_recv_mb": round(net.bytes_recv / (1024**2), 2),
|
||||||
|
"uptime_seconds": int(time.time() - self.start_time)
|
||||||
|
},
|
||||||
|
"pool": {
|
||||||
|
"permanent": {"active": PERMANENT is not None, "memory_mb": permanent_mem},
|
||||||
|
"hot": {"count": len(HOT_POOL), "memory_mb": hot_mem},
|
||||||
|
"cold": {"count": len(COLD_POOL), "memory_mb": cold_mem},
|
||||||
|
"total_memory_mb": permanent_mem + hot_mem + cold_mem
|
||||||
|
},
|
||||||
|
"janitor": {
|
||||||
|
"next_cleanup_estimate": "adaptive", # Would need janitor state
|
||||||
|
"memory_pressure": "LOW" if mem_pct < 60 else "MEDIUM" if mem_pct < 80 else "HIGH"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_active_requests(self) -> List[Dict]:
|
||||||
|
"""Get list of currently active requests."""
|
||||||
|
now = time.time()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
**req,
|
||||||
|
"elapsed": round(now - req["start_time"], 1),
|
||||||
|
"status": "running"
|
||||||
|
}
|
||||||
|
for req in self.active_requests.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_completed_requests(self, limit: int = 50, filter_status: str = "all") -> List[Dict]:
|
||||||
|
"""Get recent completed requests."""
|
||||||
|
requests = list(self.completed_requests)[-limit:]
|
||||||
|
if filter_status == "success":
|
||||||
|
requests = [r for r in requests if r.get("success")]
|
||||||
|
elif filter_status == "error":
|
||||||
|
requests = [r for r in requests if not r.get("success")]
|
||||||
|
return requests
|
||||||
|
|
||||||
|
def get_browser_list(self) -> List[Dict]:
|
||||||
|
"""Get detailed browser pool information."""
|
||||||
|
from crawler_pool import PERMANENT, HOT_POOL, COLD_POOL, LAST_USED, USAGE_COUNT, DEFAULT_CONFIG_SIG
|
||||||
|
|
||||||
|
browsers = []
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
if PERMANENT:
|
||||||
|
browsers.append({
|
||||||
|
"type": "permanent",
|
||||||
|
"sig": DEFAULT_CONFIG_SIG[:8] if DEFAULT_CONFIG_SIG else "unknown",
|
||||||
|
"age_seconds": int(now - self.start_time),
|
||||||
|
"last_used_seconds": int(now - LAST_USED.get(DEFAULT_CONFIG_SIG, now)),
|
||||||
|
"memory_mb": 270,
|
||||||
|
"hits": USAGE_COUNT.get(DEFAULT_CONFIG_SIG, 0),
|
||||||
|
"killable": False
|
||||||
|
})
|
||||||
|
|
||||||
|
for sig, crawler in HOT_POOL.items():
|
||||||
|
browsers.append({
|
||||||
|
"type": "hot",
|
||||||
|
"sig": sig[:8],
|
||||||
|
"age_seconds": int(now - self.start_time), # Approximation
|
||||||
|
"last_used_seconds": int(now - LAST_USED.get(sig, now)),
|
||||||
|
"memory_mb": 180, # Estimate
|
||||||
|
"hits": USAGE_COUNT.get(sig, 0),
|
||||||
|
"killable": True
|
||||||
|
})
|
||||||
|
|
||||||
|
for sig, crawler in COLD_POOL.items():
|
||||||
|
browsers.append({
|
||||||
|
"type": "cold",
|
||||||
|
"sig": sig[:8],
|
||||||
|
"age_seconds": int(now - self.start_time),
|
||||||
|
"last_used_seconds": int(now - LAST_USED.get(sig, now)),
|
||||||
|
"memory_mb": 180,
|
||||||
|
"hits": USAGE_COUNT.get(sig, 0),
|
||||||
|
"killable": True
|
||||||
|
})
|
||||||
|
|
||||||
|
return browsers
|
||||||
|
|
||||||
|
def get_endpoint_stats_summary(self) -> Dict[str, Dict]:
|
||||||
|
"""Get aggregated endpoint statistics."""
|
||||||
|
summary = {}
|
||||||
|
for endpoint, stats in self.endpoint_stats.items():
|
||||||
|
count = stats["count"]
|
||||||
|
avg_time = (stats["total_time"] / count) if count > 0 else 0
|
||||||
|
success_rate = (stats["success"] / count * 100) if count > 0 else 0
|
||||||
|
pool_hit_rate = (stats["pool_hits"] / count * 100) if count > 0 else 0
|
||||||
|
|
||||||
|
summary[endpoint] = {
|
||||||
|
"count": count,
|
||||||
|
"avg_latency_ms": round(avg_time * 1000, 1),
|
||||||
|
"success_rate_percent": round(success_rate, 1),
|
||||||
|
"pool_hit_rate_percent": round(pool_hit_rate, 1),
|
||||||
|
"errors": stats["errors"]
|
||||||
|
}
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def get_timeline_data(self, metric: str, window: str = "5m") -> Dict:
|
||||||
|
"""Get timeline data for charts."""
|
||||||
|
# For now, only 5m window supported
|
||||||
|
if metric == "memory":
|
||||||
|
data = list(self.memory_timeline)
|
||||||
|
elif metric == "requests":
|
||||||
|
data = list(self.requests_timeline)
|
||||||
|
elif metric == "browsers":
|
||||||
|
data = list(self.browser_timeline)
|
||||||
|
else:
|
||||||
|
return {"timestamps": [], "values": []}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"timestamps": [int(d["time"]) for d in data],
|
||||||
|
"values": [d.get("value", d.get("browsers")) for d in data]
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_janitor_log(self, limit: int = 100) -> List[Dict]:
|
||||||
|
"""Get recent janitor events."""
|
||||||
|
return list(self.janitor_events)[-limit:]
|
||||||
|
|
||||||
|
def get_errors_log(self, limit: int = 100) -> List[Dict]:
|
||||||
|
"""Get recent errors."""
|
||||||
|
return list(self.errors)[-limit:]
|
||||||
|
|
||||||
|
# Global instance (initialized in server.py)
|
||||||
|
monitor_stats: Optional[MonitorStats] = None
|
||||||
|
|
||||||
|
def get_monitor() -> MonitorStats:
|
||||||
|
"""Get global monitor instance."""
|
||||||
|
if monitor_stats is None:
|
||||||
|
raise RuntimeError("Monitor not initialized")
|
||||||
|
return monitor_stats
|
||||||
322
deploy/docker/monitor_routes.py
Normal file
322
deploy/docker/monitor_routes.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
# monitor_routes.py - Monitor API endpoints
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from monitor import get_monitor
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/monitor", tags=["monitor"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def get_health():
|
||||||
|
"""Get current system health snapshot."""
|
||||||
|
try:
|
||||||
|
monitor = get_monitor()
|
||||||
|
return monitor.get_health_summary()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting health: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests")
|
||||||
|
async def get_requests(status: str = "all", limit: int = 50):
|
||||||
|
"""Get active and completed requests.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: Filter by 'active', 'completed', 'success', 'error', or 'all'
|
||||||
|
limit: Max number of completed requests to return (default 50)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
monitor = get_monitor()
|
||||||
|
|
||||||
|
if status == "active":
|
||||||
|
return {"active": monitor.get_active_requests(), "completed": []}
|
||||||
|
elif status == "completed":
|
||||||
|
return {"active": [], "completed": monitor.get_completed_requests(limit)}
|
||||||
|
elif status in ["success", "error"]:
|
||||||
|
return {"active": [], "completed": monitor.get_completed_requests(limit, status)}
|
||||||
|
else: # "all"
|
||||||
|
return {
|
||||||
|
"active": monitor.get_active_requests(),
|
||||||
|
"completed": monitor.get_completed_requests(limit)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting requests: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/browsers")
|
||||||
|
async def get_browsers():
|
||||||
|
"""Get detailed browser pool information."""
|
||||||
|
try:
|
||||||
|
monitor = get_monitor()
|
||||||
|
browsers = monitor.get_browser_list()
|
||||||
|
|
||||||
|
# Calculate summary stats
|
||||||
|
total_browsers = len(browsers)
|
||||||
|
total_memory = sum(b["memory_mb"] for b in browsers)
|
||||||
|
|
||||||
|
# Calculate reuse rate from recent requests
|
||||||
|
recent = monitor.get_completed_requests(100)
|
||||||
|
pool_hits = sum(1 for r in recent if r.get("pool_hit", False))
|
||||||
|
reuse_rate = (pool_hits / len(recent) * 100) if recent else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"browsers": browsers,
|
||||||
|
"summary": {
|
||||||
|
"total_count": total_browsers,
|
||||||
|
"total_memory_mb": total_memory,
|
||||||
|
"reuse_rate_percent": round(reuse_rate, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting browsers: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/endpoints/stats")
|
||||||
|
async def get_endpoint_stats():
|
||||||
|
"""Get aggregated endpoint statistics."""
|
||||||
|
try:
|
||||||
|
monitor = get_monitor()
|
||||||
|
return monitor.get_endpoint_stats_summary()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting endpoint stats: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/timeline")
|
||||||
|
async def get_timeline(metric: str = "memory", window: str = "5m"):
|
||||||
|
"""Get timeline data for charts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metric: 'memory', 'requests', or 'browsers'
|
||||||
|
window: Time window (only '5m' supported for now)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
monitor = get_monitor()
|
||||||
|
return monitor.get_timeline_data(metric, window)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting timeline: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/janitor")
|
||||||
|
async def get_janitor_log(limit: int = 100):
|
||||||
|
"""Get recent janitor cleanup events."""
|
||||||
|
try:
|
||||||
|
monitor = get_monitor()
|
||||||
|
return {"events": monitor.get_janitor_log(limit)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting janitor log: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs/errors")
|
||||||
|
async def get_errors_log(limit: int = 100):
|
||||||
|
"""Get recent errors."""
|
||||||
|
try:
|
||||||
|
monitor = get_monitor()
|
||||||
|
return {"errors": monitor.get_errors_log(limit)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting errors log: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Control Actions ==========
|
||||||
|
|
||||||
|
class KillBrowserRequest(BaseModel):
|
||||||
|
sig: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/actions/cleanup")
|
||||||
|
async def force_cleanup():
|
||||||
|
"""Force immediate janitor cleanup (kills idle cold pool browsers)."""
|
||||||
|
try:
|
||||||
|
from crawler_pool import COLD_POOL, LAST_USED, USAGE_COUNT, LOCK
|
||||||
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
killed_count = 0
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
async with LOCK:
|
||||||
|
for sig in list(COLD_POOL.keys()):
|
||||||
|
# Kill all cold pool browsers immediately
|
||||||
|
logger.info(f"🧹 Force cleanup: closing cold browser (sig={sig[:8]})")
|
||||||
|
with suppress(Exception):
|
||||||
|
await COLD_POOL[sig].close()
|
||||||
|
COLD_POOL.pop(sig, None)
|
||||||
|
LAST_USED.pop(sig, None)
|
||||||
|
USAGE_COUNT.pop(sig, None)
|
||||||
|
killed_count += 1
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
monitor.track_janitor_event("force_cleanup", "manual", {"killed": killed_count})
|
||||||
|
|
||||||
|
return {"success": True, "killed_browsers": killed_count}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during force cleanup: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/actions/kill_browser")
|
||||||
|
async def kill_browser(req: KillBrowserRequest):
|
||||||
|
"""Kill a specific browser by signature (hot or cold only).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sig: Browser config signature (first 8 chars)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from crawler_pool import HOT_POOL, COLD_POOL, LAST_USED, USAGE_COUNT, LOCK, DEFAULT_CONFIG_SIG
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
# Find full signature matching prefix
|
||||||
|
target_sig = None
|
||||||
|
pool_type = None
|
||||||
|
|
||||||
|
async with LOCK:
|
||||||
|
# Check hot pool
|
||||||
|
for sig in HOT_POOL.keys():
|
||||||
|
if sig.startswith(req.sig):
|
||||||
|
target_sig = sig
|
||||||
|
pool_type = "hot"
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check cold pool
|
||||||
|
if not target_sig:
|
||||||
|
for sig in COLD_POOL.keys():
|
||||||
|
if sig.startswith(req.sig):
|
||||||
|
target_sig = sig
|
||||||
|
pool_type = "cold"
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check if trying to kill permanent
|
||||||
|
if DEFAULT_CONFIG_SIG and DEFAULT_CONFIG_SIG.startswith(req.sig):
|
||||||
|
raise HTTPException(403, "Cannot kill permanent browser. Use restart instead.")
|
||||||
|
|
||||||
|
if not target_sig:
|
||||||
|
raise HTTPException(404, f"Browser with sig={req.sig} not found")
|
||||||
|
|
||||||
|
# Kill the browser
|
||||||
|
if pool_type == "hot":
|
||||||
|
browser = HOT_POOL.pop(target_sig)
|
||||||
|
else:
|
||||||
|
browser = COLD_POOL.pop(target_sig)
|
||||||
|
|
||||||
|
with suppress(Exception):
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
LAST_USED.pop(target_sig, None)
|
||||||
|
USAGE_COUNT.pop(target_sig, None)
|
||||||
|
|
||||||
|
logger.info(f"🔪 Killed {pool_type} browser (sig={target_sig[:8]})")
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
monitor.track_janitor_event("kill_browser", target_sig, {"pool": pool_type, "manual": True})
|
||||||
|
|
||||||
|
return {"success": True, "killed_sig": target_sig[:8], "pool_type": pool_type}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error killing browser: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/actions/restart_browser")
|
||||||
|
async def restart_browser(req: KillBrowserRequest):
|
||||||
|
"""Restart a browser (kill + recreate). Works for permanent too.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sig: Browser config signature (first 8 chars), or "permanent"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from crawler_pool import (PERMANENT, HOT_POOL, COLD_POOL, LAST_USED,
|
||||||
|
USAGE_COUNT, LOCK, DEFAULT_CONFIG_SIG, init_permanent)
|
||||||
|
from crawl4ai import AsyncWebCrawler, BrowserConfig
|
||||||
|
from contextlib import suppress
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Handle permanent browser restart
|
||||||
|
if req.sig == "permanent" or (DEFAULT_CONFIG_SIG and DEFAULT_CONFIG_SIG.startswith(req.sig)):
|
||||||
|
async with LOCK:
|
||||||
|
if PERMANENT:
|
||||||
|
with suppress(Exception):
|
||||||
|
await PERMANENT.close()
|
||||||
|
|
||||||
|
# Reinitialize permanent
|
||||||
|
from utils import load_config
|
||||||
|
config = load_config()
|
||||||
|
await init_permanent(BrowserConfig(
|
||||||
|
extra_args=config["crawler"]["browser"].get("extra_args", []),
|
||||||
|
**config["crawler"]["browser"].get("kwargs", {}),
|
||||||
|
))
|
||||||
|
|
||||||
|
logger.info("🔄 Restarted permanent browser")
|
||||||
|
return {"success": True, "restarted": "permanent"}
|
||||||
|
|
||||||
|
# Handle hot/cold browser restart
|
||||||
|
target_sig = None
|
||||||
|
pool_type = None
|
||||||
|
browser_config = None
|
||||||
|
|
||||||
|
async with LOCK:
|
||||||
|
# Find browser
|
||||||
|
for sig in HOT_POOL.keys():
|
||||||
|
if sig.startswith(req.sig):
|
||||||
|
target_sig = sig
|
||||||
|
pool_type = "hot"
|
||||||
|
# Would need to reconstruct config (not stored currently)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_sig:
|
||||||
|
for sig in COLD_POOL.keys():
|
||||||
|
if sig.startswith(req.sig):
|
||||||
|
target_sig = sig
|
||||||
|
pool_type = "cold"
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_sig:
|
||||||
|
raise HTTPException(404, f"Browser with sig={req.sig} not found")
|
||||||
|
|
||||||
|
# Kill existing
|
||||||
|
if pool_type == "hot":
|
||||||
|
browser = HOT_POOL.pop(target_sig)
|
||||||
|
else:
|
||||||
|
browser = COLD_POOL.pop(target_sig)
|
||||||
|
|
||||||
|
with suppress(Exception):
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
# Note: We can't easily recreate with same config without storing it
|
||||||
|
# For now, just kill and let new requests create fresh ones
|
||||||
|
LAST_USED.pop(target_sig, None)
|
||||||
|
USAGE_COUNT.pop(target_sig, None)
|
||||||
|
|
||||||
|
logger.info(f"🔄 Restarted {pool_type} browser (sig={target_sig[:8]})")
|
||||||
|
|
||||||
|
monitor = get_monitor()
|
||||||
|
monitor.track_janitor_event("restart_browser", target_sig, {"pool": pool_type})
|
||||||
|
|
||||||
|
return {"success": True, "restarted_sig": target_sig[:8], "note": "Browser will be recreated on next request"}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error restarting browser: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stats/reset")
|
||||||
|
async def reset_stats():
|
||||||
|
"""Reset today's endpoint counters."""
|
||||||
|
try:
|
||||||
|
monitor = get_monitor()
|
||||||
|
monitor.endpoint_stats.clear()
|
||||||
|
await monitor._persist_endpoint_stats()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Endpoint stats reset"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error resetting stats: {e}")
|
||||||
|
raise HTTPException(500, str(e))
|
||||||
@@ -16,6 +16,7 @@ from fastapi import Request, Depends
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
import base64
|
import base64
|
||||||
import re
|
import re
|
||||||
|
import logging
|
||||||
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig
|
||||||
from api import (
|
from api import (
|
||||||
handle_markdown_request, handle_llm_qa,
|
handle_markdown_request, handle_llm_qa,
|
||||||
@@ -112,15 +113,40 @@ AsyncWebCrawler.arun = capped_arun
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
from crawler_pool import init_permanent
|
from crawler_pool import init_permanent
|
||||||
|
from monitor import MonitorStats
|
||||||
|
import monitor as monitor_module
|
||||||
|
|
||||||
|
# Initialize monitor
|
||||||
|
monitor_module.monitor_stats = MonitorStats(redis)
|
||||||
|
await monitor_module.monitor_stats.load_from_redis()
|
||||||
|
|
||||||
|
# Initialize browser pool
|
||||||
await init_permanent(BrowserConfig(
|
await init_permanent(BrowserConfig(
|
||||||
extra_args=config["crawler"]["browser"].get("extra_args", []),
|
extra_args=config["crawler"]["browser"].get("extra_args", []),
|
||||||
**config["crawler"]["browser"].get("kwargs", {}),
|
**config["crawler"]["browser"].get("kwargs", {}),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Start background tasks
|
||||||
app.state.janitor = asyncio.create_task(janitor())
|
app.state.janitor = asyncio.create_task(janitor())
|
||||||
|
app.state.timeline_updater = asyncio.create_task(_timeline_updater())
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
app.state.janitor.cancel()
|
app.state.janitor.cancel()
|
||||||
|
app.state.timeline_updater.cancel()
|
||||||
await close_all()
|
await close_all()
|
||||||
|
|
||||||
|
async def _timeline_updater():
|
||||||
|
"""Update timeline data every 5 seconds."""
|
||||||
|
from monitor import get_monitor
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
try:
|
||||||
|
await get_monitor().update_timeline()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Timeline update error: {e}")
|
||||||
|
|
||||||
# ───────────────────── FastAPI instance ──────────────────────
|
# ───────────────────── FastAPI instance ──────────────────────
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=config["app"]["title"],
|
title=config["app"]["title"],
|
||||||
@@ -138,6 +164,16 @@ app.mount(
|
|||||||
name="play",
|
name="play",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── static monitor dashboard ────────────────────────────────
|
||||||
|
MONITOR_DIR = pathlib.Path(__file__).parent / "static" / "monitor"
|
||||||
|
if not MONITOR_DIR.exists():
|
||||||
|
raise RuntimeError(f"Monitor assets not found at {MONITOR_DIR}")
|
||||||
|
app.mount(
|
||||||
|
"/dashboard",
|
||||||
|
StaticFiles(directory=MONITOR_DIR, html=True),
|
||||||
|
name="monitor_ui",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
@@ -221,6 +257,12 @@ def _safe_eval_config(expr: str) -> dict:
|
|||||||
# ── job router ──────────────────────────────────────────────
|
# ── job router ──────────────────────────────────────────────
|
||||||
app.include_router(init_job_router(redis, config, token_dep))
|
app.include_router(init_job_router(redis, config, token_dep))
|
||||||
|
|
||||||
|
# ── monitor router ──────────────────────────────────────────
|
||||||
|
from monitor_routes import router as monitor_router
|
||||||
|
app.include_router(monitor_router)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ──────────────────────── Endpoints ──────────────────────────
|
# ──────────────────────── Endpoints ──────────────────────────
|
||||||
@app.post("/token")
|
@app.post("/token")
|
||||||
async def get_token(req: TokenRequest):
|
async def get_token(req: TokenRequest):
|
||||||
|
|||||||
813
deploy/docker/static/monitor/index.html
Normal file
813
deploy/docker/static/monitor/index.html
Normal file
@@ -0,0 +1,813 @@
|
|||||||
|
<!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 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">
|
||||||
|
<span>📊 <span class="text-primary">Crawl4AI</span> 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">
|
||||||
|
<!-- 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 ⚡5s
|
||||||
|
</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 Bar -->
|
||||||
|
<section class="bg-surface rounded-lg border border-border p-4">
|
||||||
|
<h2 class="text-sm font-medium mb-3 text-primary">System Health</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 gap-4 mb-4">
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- 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> MB / ⬇<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">Updated: never</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pool Status -->
|
||||||
|
<div class="border-t border-border pt-3">
|
||||||
|
<div class="grid grid-cols-3 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<span class="text-secondary">🔥 Permanent:</span>
|
||||||
|
<span id="pool-perm" class="text-primary ml-2">INACTIVE (0MB)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-secondary">♨️ Hot:</span>
|
||||||
|
<span id="pool-hot" class="text-accent ml-2">0 (0MB)</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-secondary">❄️ Cold:</span>
|
||||||
|
<span id="pool-cold" class="text-light ml-2">0 (0MB)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 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>
|
||||||
|
|
||||||
|
<!-- Live Activity (Tabbed) -->
|
||||||
|
<section class="bg-surface rounded-lg border border-border overflow-hidden flex flex-col" style="height: 400px;">
|
||||||
|
<div class="border-b border-border flex">
|
||||||
|
<button data-tab="requests" class="activity-tab px-4 py-2 border-r border-border bg-dark text-primary">Requests</button>
|
||||||
|
<button data-tab="browsers" class="activity-tab px-4 py-2 border-r border-border">Browsers</button>
|
||||||
|
<button data-tab="janitor" class="activity-tab px-4 py-2 border-r border-border">Janitor</button>
|
||||||
|
<button data-tab="errors" class="activity-tab px-4 py-2">Errors</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-auto p-3">
|
||||||
|
<!-- Requests Tab -->
|
||||||
|
<div id="tab-requests" class="activity-content">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium">Active Requests (<span id="active-count">0</span>)</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 Only</option>
|
||||||
|
<option value="error">Errors Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div id="active-requests-list" class="text-xs space-y-1">
|
||||||
|
<div class="text-secondary text-center py-4">No active requests</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="text-xs font-medium text-secondary mt-4 mb-2">Recent Completed</h4>
|
||||||
|
<div id="completed-requests-list" class="text-xs space-y-1">
|
||||||
|
<div class="text-secondary text-center py-4">No completed requests</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Browsers Tab -->
|
||||||
|
<div id="tab-browsers" class="activity-content hidden">
|
||||||
|
<div class="mb-3">
|
||||||
|
<h3 class="text-sm font-medium mb-2">Browser Pool (<span id="browser-count">0</span> browsers, <span id="browser-mem">0</span> MB)</h3>
|
||||||
|
<div class="text-xs text-secondary">
|
||||||
|
Reuse rate: <span id="reuse-rate" class="text-primary">--%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">Type</th>
|
||||||
|
<th class="py-2 pr-4">Signature</th>
|
||||||
|
<th class="py-2 pr-4">Age</th>
|
||||||
|
<th class="py-2 pr-4">Last Used</th>
|
||||||
|
<th class="py-2 pr-4">Memory</th>
|
||||||
|
<th class="py-2 pr-4">Hits</th>
|
||||||
|
<th class="py-2">Actions</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Janitor Tab -->
|
||||||
|
<div id="tab-janitor" class="activity-content hidden">
|
||||||
|
<h3 class="text-sm font-medium mb-3">Cleanup Events (Last 100)</h3>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Errors Tab -->
|
||||||
|
<div id="tab-errors" class="activity-content hidden">
|
||||||
|
<h3 class="text-sm font-medium mb-3">Recent Errors (Last 100)</h3>
|
||||||
|
<div id="errors-log" class="text-xs space-y-2">
|
||||||
|
<div class="text-secondary text-center py-4">No errors</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 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 = 5000; // 5 seconds
|
||||||
|
|
||||||
|
// ========== Tab Switching ==========
|
||||||
|
document.querySelectorAll('.activity-tab').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const tab = btn.dataset.tab;
|
||||||
|
|
||||||
|
// Update tabs
|
||||||
|
document.querySelectorAll('.activity-tab').forEach(b => {
|
||||||
|
b.classList.remove('bg-dark', 'text-primary');
|
||||||
|
});
|
||||||
|
btn.classList.add('bg-dark', 'text-primary');
|
||||||
|
|
||||||
|
// Update content
|
||||||
|
document.querySelectorAll('.activity-content').forEach(c => c.classList.add('hidden'));
|
||||||
|
document.getElementById(`tab-${tab}`).classList.remove('hidden');
|
||||||
|
|
||||||
|
// Fetch specific data
|
||||||
|
if (tab === 'browsers') fetchBrowsers();
|
||||||
|
if (tab === 'janitor') fetchJanitorLog();
|
||||||
|
if (tab === 'errors') fetchErrors();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== Auto-refresh Toggle ==========
|
||||||
|
document.getElementById('auto-refresh-toggle').addEventListener('click', function() {
|
||||||
|
autoRefresh = !autoRefresh;
|
||||||
|
this.textContent = autoRefresh ? 'ON ⚡5s' : '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(),
|
||||||
|
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 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>
|
||||||
|
`).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="7" class="text-center py-4 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">
|
||||||
|
<td class="py-2 pr-4"><span class="${typeColor}">${typeIcon} ${b.type.toUpperCase()}</span></td>
|
||||||
|
<td class="py-2 pr-4 font-mono">${b.sig}</td>
|
||||||
|
<td class="py-2 pr-4">${formatSeconds(b.age_seconds)}</td>
|
||||||
|
<td class="py-2 pr-4">${formatSeconds(b.last_used_seconds)} ago</td>
|
||||||
|
<td class="py-2 pr-4">${b.memory_mb} MB</td>
|
||||||
|
<td class="py-2 pr-4">${b.hits}</td>
|
||||||
|
<td class="py-2">
|
||||||
|
${b.killable ? `
|
||||||
|
<button onclick="killBrowser('${b.sig}')"
|
||||||
|
class="text-red-500 hover:underline mr-2">Kill</button>
|
||||||
|
<button onclick="restartBrowser('${b.sig}')"
|
||||||
|
class="text-primary hover:underline">Restart</button>
|
||||||
|
` : `
|
||||||
|
<button onclick="restartBrowser('permanent')"
|
||||||
|
class="text-primary hover:underline">Restart</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`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Filter change handler ==========
|
||||||
|
document.getElementById('filter-requests')?.addEventListener('change', fetchRequests);
|
||||||
|
|
||||||
|
// ========== Initialize ==========
|
||||||
|
startAutoRefresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -167,11 +167,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="ml-auto flex space-x-2">
|
<div class="ml-auto flex items-center space-x-4">
|
||||||
<button id="play-tab"
|
<a href="/dashboard" class="text-xs text-secondary hover:text-primary underline">Monitor</a>
|
||||||
class="px-3 py-1 rounded-t bg-surface border border-b-0 border-border text-primary">Playground</button>
|
<div class="flex space-x-2">
|
||||||
<button id="stress-tab" class="px-3 py-1 rounded-t border border-border hover:bg-surface">Stress
|
<button id="play-tab"
|
||||||
Test</button>
|
class="px-3 py-1 rounded-t bg-surface border border-b-0 border-border text-primary">Playground</button>
|
||||||
|
<button id="stress-tab" class="px-3 py-1 rounded-t border border-border hover:bg-surface">Stress
|
||||||
|
Test</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user