claude-mpm 4.2.9__py3-none-any.whl → 4.2.11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. claude_mpm/VERSION +1 -1
  2. claude_mpm/cli/commands/dashboard.py +59 -126
  3. claude_mpm/cli/commands/monitor.py +71 -212
  4. claude_mpm/cli/commands/run.py +33 -33
  5. claude_mpm/dashboard/static/css/code-tree.css +8 -16
  6. claude_mpm/dashboard/static/dist/components/code-tree.js +1 -1
  7. claude_mpm/dashboard/static/dist/components/file-viewer.js +2 -0
  8. claude_mpm/dashboard/static/dist/components/module-viewer.js +1 -1
  9. claude_mpm/dashboard/static/dist/components/unified-data-viewer.js +1 -1
  10. claude_mpm/dashboard/static/dist/dashboard.js +1 -1
  11. claude_mpm/dashboard/static/dist/socket-client.js +1 -1
  12. claude_mpm/dashboard/static/js/components/code-tree.js +692 -114
  13. claude_mpm/dashboard/static/js/components/file-viewer.js +538 -0
  14. claude_mpm/dashboard/static/js/components/module-viewer.js +26 -0
  15. claude_mpm/dashboard/static/js/components/unified-data-viewer.js +166 -14
  16. claude_mpm/dashboard/static/js/dashboard.js +108 -91
  17. claude_mpm/dashboard/static/js/socket-client.js +9 -7
  18. claude_mpm/dashboard/templates/index.html +2 -7
  19. claude_mpm/hooks/claude_hooks/hook_handler.py +1 -11
  20. claude_mpm/hooks/claude_hooks/services/connection_manager.py +54 -59
  21. claude_mpm/hooks/claude_hooks/services/connection_manager_http.py +112 -72
  22. claude_mpm/services/agents/deployment/agent_template_builder.py +0 -1
  23. claude_mpm/services/cli/unified_dashboard_manager.py +354 -0
  24. claude_mpm/services/monitor/__init__.py +20 -0
  25. claude_mpm/services/monitor/daemon.py +256 -0
  26. claude_mpm/services/monitor/event_emitter.py +279 -0
  27. claude_mpm/services/monitor/handlers/__init__.py +20 -0
  28. claude_mpm/services/monitor/handlers/code_analysis.py +334 -0
  29. claude_mpm/services/monitor/handlers/dashboard.py +298 -0
  30. claude_mpm/services/monitor/handlers/hooks.py +491 -0
  31. claude_mpm/services/monitor/management/__init__.py +18 -0
  32. claude_mpm/services/monitor/management/health.py +124 -0
  33. claude_mpm/services/monitor/management/lifecycle.py +298 -0
  34. claude_mpm/services/monitor/server.py +442 -0
  35. claude_mpm/tools/code_tree_analyzer.py +33 -17
  36. {claude_mpm-4.2.9.dist-info → claude_mpm-4.2.11.dist-info}/METADATA +1 -1
  37. {claude_mpm-4.2.9.dist-info → claude_mpm-4.2.11.dist-info}/RECORD +41 -36
  38. claude_mpm/cli/commands/socketio_monitor.py +0 -233
  39. claude_mpm/scripts/socketio_daemon.py +0 -571
  40. claude_mpm/scripts/socketio_daemon_hardened.py +0 -937
  41. claude_mpm/scripts/socketio_daemon_wrapper.py +0 -78
  42. claude_mpm/scripts/socketio_server_manager.py +0 -349
  43. claude_mpm/services/cli/dashboard_launcher.py +0 -423
  44. claude_mpm/services/cli/socketio_manager.py +0 -595
  45. claude_mpm/services/dashboard/stable_server.py +0 -1020
  46. claude_mpm/services/socketio/monitor_server.py +0 -505
  47. {claude_mpm-4.2.9.dist-info → claude_mpm-4.2.11.dist-info}/WHEEL +0 -0
  48. {claude_mpm-4.2.9.dist-info → claude_mpm-4.2.11.dist-info}/entry_points.txt +0 -0
  49. {claude_mpm-4.2.9.dist-info → claude_mpm-4.2.11.dist-info}/licenses/LICENSE +0 -0
  50. {claude_mpm-4.2.9.dist-info → claude_mpm-4.2.11.dist-info}/top_level.txt +0 -0
@@ -1,1020 +0,0 @@
1
- """
2
- Stable Dashboard Server for claude-mpm.
3
-
4
- WHY: This module provides a simple, stable HTTP + SocketIO server that works
5
- across all installation methods (direct, pip, pipx, homebrew, npm).
6
-
7
- DESIGN DECISIONS:
8
- - Uses proven python-socketio + aiohttp combination
9
- - Automatically finds dashboard files across installation methods
10
- - Provides both HTTP endpoints and SocketIO real-time features
11
- - Simple mock AST analysis to avoid complex backend dependencies
12
- - Graceful fallbacks for missing dependencies
13
- """
14
-
15
- import asyncio
16
- import glob
17
- import json
18
- import logging
19
- import os
20
- import sys
21
- import time
22
- import traceback
23
- from collections import deque
24
- from datetime import datetime
25
- from pathlib import Path
26
- from typing import Any, Deque, Dict, Optional
27
-
28
- try:
29
- import aiohttp
30
- import socketio
31
- from aiohttp import web
32
-
33
- DEPENDENCIES_AVAILABLE = True
34
- except ImportError:
35
- DEPENDENCIES_AVAILABLE = False
36
- socketio = None
37
- aiohttp = None
38
- web = None
39
-
40
- # Set up logging
41
- logging.basicConfig(
42
- level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
43
- )
44
- logger = logging.getLogger(__name__)
45
-
46
-
47
- def find_dashboard_files() -> Optional[Path]:
48
- """Find dashboard files across different installation methods."""
49
- # Try different possible locations
50
- possible_locations = [
51
- # Development/direct install
52
- Path(__file__).parent.parent.parent / "dashboard",
53
- # Current working directory (for development)
54
- Path.cwd() / "src" / "claude_mpm" / "dashboard",
55
- # Pip install in current Python environment
56
- Path(sys.prefix)
57
- / "lib"
58
- / f"python{sys.version_info.major}.{sys.version_info.minor}"
59
- / "site-packages"
60
- / "claude_mpm"
61
- / "dashboard",
62
- # User site-packages
63
- Path.home()
64
- / ".local"
65
- / "lib"
66
- / f"python{sys.version_info.major}.{sys.version_info.minor}"
67
- / "site-packages"
68
- / "claude_mpm"
69
- / "dashboard",
70
- ]
71
-
72
- # Add glob patterns for different Python versions
73
- python_patterns = [
74
- f"/opt/homebrew/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages/claude_mpm/dashboard",
75
- f"/usr/local/lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages/claude_mpm/dashboard",
76
- ]
77
-
78
- # Check direct paths first
79
- for location in possible_locations:
80
- if location.exists() and (location / "templates" / "index.html").exists():
81
- return location
82
-
83
- # Check pattern-based paths
84
- for pattern in python_patterns:
85
- matches = glob.glob(pattern)
86
- for match in matches:
87
- path = Path(match)
88
- if path.exists() and (path / "templates" / "index.html").exists():
89
- return path
90
-
91
- # Fallback: try to find via module import
92
- try:
93
- import claude_mpm.dashboard
94
-
95
- module_path = Path(claude_mpm.dashboard.__file__).parent
96
- if (module_path / "templates" / "index.html").exists():
97
- return module_path
98
- except ImportError:
99
- pass
100
-
101
- return None
102
-
103
-
104
- def create_mock_ast_data(file_path: str, file_name: str) -> Dict[str, Any]:
105
- """Create mock AST analysis data."""
106
- ext = file_name.split(".")[-1].lower() if "." in file_name else ""
107
-
108
- elements = []
109
- if ext == "py":
110
- elements = [
111
- {
112
- "name": "MockClass",
113
- "type": "class",
114
- "line": 10,
115
- "complexity": 2,
116
- "docstring": "Mock class for demonstration",
117
- "methods": [
118
- {"name": "__init__", "type": "method", "line": 11, "complexity": 1},
119
- {
120
- "name": "mock_method",
121
- "type": "method",
122
- "line": 15,
123
- "complexity": 1,
124
- },
125
- ],
126
- },
127
- {
128
- "name": "mock_function",
129
- "type": "function",
130
- "line": 20,
131
- "complexity": 1,
132
- "docstring": "Mock function for demonstration",
133
- },
134
- ]
135
- elif ext in ["js", "ts", "jsx", "tsx"]:
136
- elements = [
137
- {
138
- "name": "MockClass",
139
- "type": "class",
140
- "line": 5,
141
- "complexity": 2,
142
- "methods": [
143
- {
144
- "name": "constructor",
145
- "type": "method",
146
- "line": 6,
147
- "complexity": 1,
148
- },
149
- {
150
- "name": "mockMethod",
151
- "type": "method",
152
- "line": 10,
153
- "complexity": 1,
154
- },
155
- ],
156
- },
157
- {"name": "mockFunction", "type": "function", "line": 15, "complexity": 1},
158
- ]
159
-
160
- return {
161
- "path": file_path,
162
- "elements": elements,
163
- "complexity": sum(e.get("complexity", 1) for e in elements),
164
- "lines": 50,
165
- "stats": {
166
- "classes": len([e for e in elements if e["type"] == "class"]),
167
- "functions": len([e for e in elements if e["type"] == "function"]),
168
- "methods": sum(len(e.get("methods", [])) for e in elements),
169
- "lines": 50,
170
- },
171
- }
172
-
173
-
174
- class StableDashboardServer:
175
- """Stable dashboard server that works across all installation methods."""
176
-
177
- def __init__(self, host: str = "localhost", port: int = 8765, debug: bool = False):
178
- self.host = host
179
- self.port = port
180
- self.debug = debug
181
- self.dashboard_path = None
182
- self.app = None
183
- self.sio = None
184
- self.server_runner = None
185
- self.server_site = None
186
-
187
- # Event storage with circular buffer (keep last 500 events)
188
- self.event_history: Deque[Dict[str, Any]] = deque(maxlen=500)
189
- self.event_count = 0
190
- self.server_start_time = time.time()
191
- self.last_event_time = None
192
- self.connected_clients = set()
193
-
194
- # Resilience features
195
- self.retry_count = 0
196
- self.max_retries = 3
197
- self.health_check_failures = 0
198
- self.is_healthy = True
199
-
200
- # Persistent event storage (optional)
201
- self.persist_events = (
202
- os.environ.get("CLAUDE_MPM_PERSIST_EVENTS", "false").lower() == "true"
203
- )
204
- self.event_log_path = Path.home() / ".claude" / "dashboard_events.jsonl"
205
- if self.persist_events:
206
- self.event_log_path.parent.mkdir(parents=True, exist_ok=True)
207
-
208
- def setup(self) -> bool:
209
- """Set up the server components."""
210
- if not DEPENDENCIES_AVAILABLE:
211
- print(
212
- "❌ Error: Missing dependencies. Install with: pip install aiohttp python-socketio"
213
- )
214
- return False
215
-
216
- # Find dashboard files only if not already set (for testing)
217
- if not self.dashboard_path:
218
- self.dashboard_path = find_dashboard_files()
219
- if not self.dashboard_path:
220
- print("❌ Error: Could not find dashboard files")
221
- print("Please ensure Claude MPM is properly installed")
222
- return False
223
-
224
- # Validate that the dashboard path has the required files
225
- template_path = self.dashboard_path / "templates" / "index.html"
226
- static_path = self.dashboard_path / "static"
227
-
228
- if not template_path.exists():
229
- print(f"❌ Error: Dashboard template not found at {template_path}")
230
- print("Please ensure Claude MPM dashboard files are properly installed")
231
- return False
232
-
233
- if not static_path.exists():
234
- print(f"❌ Error: Dashboard static files not found at {static_path}")
235
- print("Please ensure Claude MPM dashboard files are properly installed")
236
- return False
237
-
238
- if self.debug:
239
- print(f"🔍 Debug: Dashboard path resolved to: {self.dashboard_path}")
240
- print("🔍 Debug: Checking for required files...")
241
- template_exists = (
242
- self.dashboard_path / "templates" / "index.html"
243
- ).exists()
244
- static_exists = (self.dashboard_path / "static").exists()
245
- print(f" - templates/index.html: {template_exists}")
246
- print(f" - static directory: {static_exists}")
247
-
248
- print(f"📁 Using dashboard files from: {self.dashboard_path}")
249
-
250
- # Create SocketIO server with improved timeout settings
251
- logger_enabled = self.debug # Only enable verbose logging in debug mode
252
- self.sio = socketio.AsyncServer(
253
- cors_allowed_origins="*",
254
- logger=logger_enabled,
255
- engineio_logger=logger_enabled,
256
- ping_interval=30, # Match client's 30 second ping interval
257
- ping_timeout=60, # Match client's 60 second timeout
258
- max_http_buffer_size=1e8, # Allow larger messages
259
- )
260
- # Create app WITHOUT any static file handlers to prevent directory listing
261
- # This is critical - we only want explicit routes we define
262
- self.app = web.Application()
263
- self.sio.attach(self.app)
264
- print("✅ SocketIO server created and attached")
265
-
266
- # Set up routes
267
- self._setup_routes()
268
- self._setup_socketio_events()
269
-
270
- print("✅ Server setup complete!")
271
-
272
- return True
273
-
274
- def _setup_routes(self):
275
- """Set up HTTP routes."""
276
- # IMPORTANT: Only add explicit routes, never add static file serving for root
277
- # This prevents aiohttp from serving directory listings
278
- self.app.router.add_get("/", self._serve_dashboard)
279
- self.app.router.add_get(
280
- "/index.html", self._serve_dashboard
281
- ) # Also handle /index.html
282
- self.app.router.add_get("/static/{path:.*}", self._serve_static)
283
- self.app.router.add_get("/api/directory/list", self._list_directory)
284
- self.app.router.add_get("/api/file/read", self._read_file)
285
- self.app.router.add_get("/version.json", self._serve_version)
286
-
287
- # New resilience endpoints
288
- self.app.router.add_get("/health", self._health_check)
289
- self.app.router.add_get("/api/status", self._serve_status)
290
- self.app.router.add_get("/api/events/history", self._serve_event_history)
291
-
292
- # CRITICAL: Add the missing /api/events endpoint for receiving events
293
- self.app.router.add_post("/api/events", self._receive_event)
294
-
295
- def _setup_socketio_events(self):
296
- """Set up SocketIO event handlers."""
297
-
298
- @self.sio.event
299
- async def connect(sid, environ):
300
- self.connected_clients.add(sid)
301
- if self.debug:
302
- print(f"✅ SocketIO client connected: {sid}")
303
- user_agent = environ.get("HTTP_USER_AGENT", "Unknown")
304
- # Truncate long user agents for readability
305
- if len(user_agent) > 80:
306
- user_agent = user_agent[:77] + "..."
307
- print(f" Client info: {user_agent}")
308
-
309
- # Send connection confirmation
310
- await self.sio.emit(
311
- "connection_test", {"status": "connected", "server": "stable"}, room=sid
312
- )
313
-
314
- # Send recent event history to new client
315
- if self.event_history:
316
- # Send last 20 events to catch up new client
317
- recent_events = list(self.event_history)[-20:]
318
- for event in recent_events:
319
- await self.sio.emit("claude_event", event, room=sid)
320
-
321
- @self.sio.event
322
- async def disconnect(sid):
323
- self.connected_clients.discard(sid)
324
- if self.debug:
325
- print(f"📤 SocketIO client disconnected: {sid}")
326
-
327
- @self.sio.event
328
- async def code_analyze_file(sid, data):
329
- if self.debug:
330
- print(
331
- f"📡 Received file analysis request from {sid}: {data.get('path', 'unknown')}"
332
- )
333
-
334
- file_path = data.get("path", "")
335
- file_name = file_path.split("/")[-1] if file_path else "unknown"
336
-
337
- # Create mock response
338
- response = create_mock_ast_data(file_path, file_name)
339
-
340
- if self.debug:
341
- print(
342
- f"📤 Sending analysis response: {len(response['elements'])} elements"
343
- )
344
- await self.sio.emit("code:file:analyzed", response, room=sid)
345
-
346
- # CRITICAL: Handle the actual event name with colons that the client sends
347
- @self.sio.on("code:analyze:file")
348
- async def handle_code_analyze_file(sid, data):
349
- if self.debug:
350
- print(
351
- f"📡 Received code:analyze:file from {sid}: {data.get('path', 'unknown')}"
352
- )
353
-
354
- file_path = data.get("path", "")
355
- file_name = file_path.split("/")[-1] if file_path else "unknown"
356
-
357
- # Create mock response
358
- response = create_mock_ast_data(file_path, file_name)
359
-
360
- if self.debug:
361
- print(
362
- f"📤 Sending analysis response: {len(response['elements'])} elements"
363
- )
364
- await self.sio.emit("code:file:analyzed", response, room=sid)
365
-
366
- # Handle other events the dashboard sends
367
- @self.sio.event
368
- async def get_git_branch(sid, data):
369
- if self.debug:
370
- print(f"📡 Received git branch request from {sid}: {data}")
371
- await self.sio.emit(
372
- "git_branch_response", {"branch": "main", "path": data}, room=sid
373
- )
374
-
375
- @self.sio.event
376
- async def request_status(sid, data):
377
- if self.debug:
378
- print(f"📡 Received status request from {sid}")
379
- await self.sio.emit(
380
- "status_response", {"status": "running", "server": "stable"}, room=sid
381
- )
382
-
383
- # Handle the event with dots (SocketIO converts colons to dots sometimes)
384
- @self.sio.event
385
- async def request_dot_status(sid, data):
386
- if self.debug:
387
- print(f"📡 Received request.status from {sid}")
388
- await self.sio.emit(
389
- "status_response", {"status": "running", "server": "stable"}, room=sid
390
- )
391
-
392
- @self.sio.event
393
- async def code_discover_top_level(sid, data):
394
- if self.debug:
395
- print(f"📡 Received top-level discovery request from {sid}")
396
- await self.sio.emit("code:top_level:discovered", {"status": "ok"}, room=sid)
397
-
398
- # Mock event generator when no real events
399
- @self.sio.event
400
- async def request_mock_event(sid, data):
401
- """Generate a mock event for testing."""
402
- if self.debug:
403
- print(f"📡 Mock event requested by {sid}")
404
-
405
- mock_event = self._create_mock_event()
406
- # Store and broadcast like a real event
407
- self.event_count += 1
408
- self.last_event_time = datetime.now()
409
- self.event_history.append(mock_event)
410
- await self.sio.emit("claude_event", mock_event)
411
-
412
- def _create_mock_event(self) -> Dict[str, Any]:
413
- """Create a mock event for testing/demo purposes."""
414
- import random
415
-
416
- event_types = ["file", "command", "test", "build", "deploy"]
417
- event_subtypes = ["start", "progress", "complete", "error", "warning"]
418
-
419
- return {
420
- "type": random.choice(event_types),
421
- "subtype": random.choice(event_subtypes),
422
- "timestamp": datetime.now().isoformat(),
423
- "source": "mock",
424
- "data": {
425
- "message": f"Mock {random.choice(['operation', 'task', 'process'])} {random.choice(['started', 'completed', 'in progress'])}",
426
- "file": f"/path/to/file_{random.randint(1, 100)}.py",
427
- "line": random.randint(1, 500),
428
- "progress": random.randint(0, 100),
429
- },
430
- "session_id": "mock-session",
431
- "server_event_id": self.event_count + 1,
432
- }
433
-
434
- async def _start_mock_event_generator(self):
435
- """Start generating mock events if no real events for a while."""
436
- try:
437
- while True:
438
- await asyncio.sleep(30) # Check every 30 seconds
439
-
440
- # If no events in last 60 seconds and clients connected, generate mock
441
- if self.connected_clients and (
442
- not self.last_event_time
443
- or (datetime.now() - self.last_event_time).total_seconds() > 60
444
- ):
445
- if self.debug:
446
- print("⏰ No recent events, generating mock event")
447
-
448
- mock_event = self._create_mock_event()
449
- self.event_count += 1
450
- self.last_event_time = datetime.now()
451
- self.event_history.append(mock_event)
452
-
453
- await self.sio.emit("claude_event", mock_event)
454
- except asyncio.CancelledError:
455
- pass
456
- except Exception as e:
457
- logger.error(f"Mock event generator error: {e}")
458
-
459
- async def _serve_dashboard(self, request):
460
- """Serve the main dashboard HTML with fallback."""
461
- dashboard_file = (
462
- self.dashboard_path / "templates" / "index.html"
463
- if self.dashboard_path
464
- else None
465
- )
466
-
467
- # Try to serve actual dashboard
468
- if dashboard_file and dashboard_file.exists():
469
- try:
470
- with open(dashboard_file, encoding="utf-8") as f:
471
- content = f.read()
472
- return web.Response(text=content, content_type="text/html")
473
- except Exception as e:
474
- logger.error(f"Error reading dashboard template: {e}")
475
- # Fall through to fallback HTML
476
-
477
- # Fallback HTML if template missing or error
478
- fallback_html = """
479
- <!DOCTYPE html>
480
- <html lang="en">
481
- <head>
482
- <meta charset="UTF-8">
483
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
484
- <title>Claude MPM Dashboard - Fallback Mode</title>
485
- <style>
486
- body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 20px; background: #1e1e1e; color: #e0e0e0; }
487
- .container { max-width: 1200px; margin: 0 auto; }
488
- .header { background: #2d2d2d; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
489
- .status { background: #2d2d2d; padding: 15px; border-radius: 8px; margin-bottom: 20px; }
490
- .status.healthy { border-left: 4px solid #4caf50; }
491
- .status.degraded { border-left: 4px solid #ff9800; }
492
- .events { background: #2d2d2d; padding: 20px; border-radius: 8px; }
493
- .event { background: #1e1e1e; padding: 10px; margin: 10px 0; border-radius: 4px; }
494
- h1 { color: #fff; margin: 0; }
495
- .subtitle { color: #999; margin-top: 5px; }
496
- .metric { display: inline-block; margin-right: 20px; }
497
- .metric-label { color: #999; font-size: 12px; }
498
- .metric-value { color: #fff; font-size: 20px; font-weight: bold; }
499
- </style>
500
- </head>
501
- <body>
502
- <div class="container">
503
- <div class="header">
504
- <h1>Claude MPM Dashboard</h1>
505
- <div class="subtitle">Fallback Mode - Template not found</div>
506
- </div>
507
-
508
- <div id="status" class="status healthy">
509
- <h3>Server Status</h3>
510
- <div class="metric">
511
- <div class="metric-label">Health</div>
512
- <div class="metric-value" id="health">Loading...</div>
513
- </div>
514
- <div class="metric">
515
- <div class="metric-label">Uptime</div>
516
- <div class="metric-value" id="uptime">Loading...</div>
517
- </div>
518
- <div class="metric">
519
- <div class="metric-label">Events</div>
520
- <div class="metric-value" id="events">Loading...</div>
521
- </div>
522
- </div>
523
-
524
- <div class="events">
525
- <h3>Recent Events</h3>
526
- <div id="event-list">
527
- <div class="event">Waiting for events...</div>
528
- </div>
529
- </div>
530
- </div>
531
-
532
- <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
533
- <script>
534
- // Fallback dashboard JavaScript
535
- const socket = io();
536
-
537
- // Update status periodically
538
- async function updateStatus() {
539
- try {
540
- const response = await fetch('/api/status');
541
- const data = await response.json();
542
-
543
- document.getElementById('health').textContent = data.status;
544
- document.getElementById('uptime').textContent = data.uptime.human;
545
- document.getElementById('events').textContent = data.events.total;
546
-
547
- const statusDiv = document.getElementById('status');
548
- statusDiv.className = data.status === 'running' ? 'status healthy' : 'status degraded';
549
- } catch (e) {
550
- console.error('Failed to fetch status:', e);
551
- }
552
- }
553
-
554
- // Listen for events
555
- socket.on('claude_event', (event) => {
556
- const eventList = document.getElementById('event-list');
557
- const eventDiv = document.createElement('div');
558
- eventDiv.className = 'event';
559
- eventDiv.textContent = JSON.stringify(event, null, 2);
560
- eventList.insertBefore(eventDiv, eventList.firstChild);
561
-
562
- // Keep only last 10 events
563
- while (eventList.children.length > 10) {
564
- eventList.removeChild(eventList.lastChild);
565
- }
566
- });
567
-
568
- socket.on('connect', () => {
569
- console.log('Connected to dashboard server');
570
- });
571
-
572
- // Initial load and periodic updates
573
- updateStatus();
574
- setInterval(updateStatus, 5000);
575
- </script>
576
- </body>
577
- </html>
578
- """
579
-
580
- logger.warning("Serving fallback dashboard HTML")
581
- return web.Response(text=fallback_html, content_type="text/html")
582
-
583
- async def _serve_static(self, request):
584
- """Serve static files."""
585
- file_path = request.match_info["path"]
586
- static_file = self.dashboard_path / "static" / file_path
587
-
588
- if static_file.exists() and static_file.is_file():
589
- content_type = (
590
- "text/javascript"
591
- if file_path.endswith(".js")
592
- else "text/css" if file_path.endswith(".css") else "text/plain"
593
- )
594
- with open(static_file) as f:
595
- content = f.read()
596
- return web.Response(text=content, content_type=content_type)
597
- return web.Response(text="File not found", status=404)
598
-
599
- async def _list_directory(self, request):
600
- """List directory contents."""
601
- path = request.query.get("path", ".")
602
- abs_path = os.path.abspath(os.path.expanduser(path))
603
-
604
- result = {"path": abs_path, "exists": os.path.exists(abs_path), "contents": []}
605
-
606
- if os.path.exists(abs_path) and os.path.isdir(abs_path):
607
- try:
608
- for item in sorted(os.listdir(abs_path)):
609
- item_path = os.path.join(abs_path, item)
610
- result["contents"].append(
611
- {
612
- "name": item,
613
- "path": item_path,
614
- "is_directory": os.path.isdir(item_path),
615
- "is_file": os.path.isfile(item_path),
616
- "is_code_file": item.endswith(
617
- (".py", ".js", ".ts", ".jsx", ".tsx")
618
- ),
619
- }
620
- )
621
- except PermissionError:
622
- result["error"] = "Permission denied"
623
-
624
- return web.json_response(result)
625
-
626
- async def _read_file(self, request):
627
- """Read file content for source viewer."""
628
- file_path = request.query.get("path", "")
629
-
630
- if not file_path:
631
- return web.json_response({"error": "No path provided"}, status=400)
632
-
633
- abs_path = os.path.abspath(os.path.expanduser(file_path))
634
-
635
- # Security check - ensure file is within the project
636
- try:
637
- # Get the project root (current working directory)
638
- project_root = os.getcwd()
639
- # Ensure the path is within the project
640
- if not abs_path.startswith(project_root):
641
- return web.json_response({"error": "Access denied"}, status=403)
642
- except Exception:
643
- pass # Allow read if we can't determine project root
644
-
645
- if not os.path.exists(abs_path):
646
- return web.json_response({"error": "File not found"}, status=404)
647
-
648
- if not os.path.isfile(abs_path):
649
- return web.json_response({"error": "Not a file"}, status=400)
650
-
651
- try:
652
- # Determine file type
653
- file_ext = os.path.splitext(abs_path)[1].lower()
654
- is_json = file_ext in [".json", ".jsonl", ".geojson"]
655
-
656
- # Read file with appropriate encoding
657
- encodings = ["utf-8", "latin-1", "cp1252"]
658
- content = None
659
-
660
- for encoding in encodings:
661
- try:
662
- with open(abs_path, encoding=encoding) as f:
663
- content = f.read()
664
- break
665
- except UnicodeDecodeError:
666
- continue
667
-
668
- if content is None:
669
- return web.json_response({"error": "Could not decode file"}, status=400)
670
-
671
- # Format JSON files for better readability
672
- formatted_content = content
673
- is_valid_json = False
674
- if is_json:
675
- try:
676
- import json
677
-
678
- parsed = json.loads(content)
679
- formatted_content = json.dumps(parsed, indent=2, sort_keys=False)
680
- is_valid_json = True
681
- except json.JSONDecodeError:
682
- # Not valid JSON, return as-is
683
- is_valid_json = False
684
-
685
- return web.json_response(
686
- {
687
- "path": abs_path,
688
- "name": os.path.basename(abs_path),
689
- "content": formatted_content,
690
- "lines": len(formatted_content.splitlines()),
691
- "size": os.path.getsize(abs_path),
692
- "type": "json" if is_json else "text",
693
- "is_valid_json": is_valid_json,
694
- }
695
- )
696
-
697
- except PermissionError:
698
- return web.json_response({"error": "Permission denied"}, status=403)
699
- except Exception as e:
700
- return web.json_response({"error": str(e)}, status=500)
701
-
702
- async def _health_check(self, request):
703
- """Health check endpoint for monitoring."""
704
- uptime = time.time() - self.server_start_time
705
- status = "healthy" if self.is_healthy else "degraded"
706
-
707
- health_info = {
708
- "status": status,
709
- "uptime_seconds": round(uptime, 2),
710
- "connected_clients": len(self.connected_clients),
711
- "event_count": self.event_count,
712
- "last_event": (
713
- self.last_event_time.isoformat() if self.last_event_time else None
714
- ),
715
- "retry_count": self.retry_count,
716
- "health_check_failures": self.health_check_failures,
717
- "event_history_size": len(self.event_history),
718
- }
719
-
720
- status_code = 200 if self.is_healthy else 503
721
- return web.json_response(health_info, status=status_code)
722
-
723
- async def _serve_status(self, request):
724
- """Detailed server status endpoint."""
725
- uptime = time.time() - self.server_start_time
726
-
727
- status_info = {
728
- "server": "stable",
729
- "version": "4.2.3",
730
- "status": "running" if self.is_healthy else "degraded",
731
- "uptime": {
732
- "seconds": round(uptime, 2),
733
- "human": self._format_uptime(uptime),
734
- },
735
- "connections": {
736
- "active": len(self.connected_clients),
737
- "clients": list(self.connected_clients),
738
- },
739
- "events": {
740
- "total": self.event_count,
741
- "buffered": len(self.event_history),
742
- "last_received": (
743
- self.last_event_time.isoformat() if self.last_event_time else None
744
- ),
745
- },
746
- "features": [
747
- "http",
748
- "socketio",
749
- "event_bridge",
750
- "health_monitoring",
751
- "auto_retry",
752
- "event_history",
753
- "graceful_degradation",
754
- ],
755
- "resilience": {
756
- "retry_count": self.retry_count,
757
- "max_retries": self.max_retries,
758
- "health_failures": self.health_check_failures,
759
- "persist_events": self.persist_events,
760
- },
761
- }
762
- return web.json_response(status_info)
763
-
764
- async def _serve_event_history(self, request):
765
- """Serve recent event history."""
766
- limit = int(request.query.get("limit", "100"))
767
- events = list(self.event_history)[-limit:]
768
- return web.json_response(
769
- {"events": events, "count": len(events), "total_events": self.event_count}
770
- )
771
-
772
- async def _receive_event(self, request):
773
- """Receive events from hook system via HTTP POST."""
774
- try:
775
- # Parse event data
776
- data = await request.json()
777
-
778
- # Add server metadata
779
- event = {
780
- **data,
781
- "received_at": datetime.now().isoformat(),
782
- "server_event_id": self.event_count + 1,
783
- }
784
-
785
- # Update tracking
786
- self.event_count += 1
787
- self.last_event_time = datetime.now()
788
-
789
- # Store in circular buffer
790
- self.event_history.append(event)
791
-
792
- # Persist to disk if enabled
793
- if self.persist_events:
794
- try:
795
- with open(self.event_log_path, "a") as f:
796
- f.write(json.dumps(event) + "\n")
797
- except Exception as e:
798
- logger.error(f"Failed to persist event: {e}")
799
-
800
- # Emit to all connected SocketIO clients
801
- if self.sio and self.connected_clients:
802
- await self.sio.emit("claude_event", event)
803
- if self.debug:
804
- print(
805
- f"📡 Forwarded event to {len(self.connected_clients)} clients"
806
- )
807
-
808
- # Return success response
809
- return web.json_response(
810
- {
811
- "status": "received",
812
- "event_id": event["server_event_id"],
813
- "clients_notified": len(self.connected_clients),
814
- }
815
- )
816
-
817
- except json.JSONDecodeError as e:
818
- logger.error(f"Invalid JSON in event request: {e}")
819
- return web.json_response(
820
- {"error": "Invalid JSON", "details": str(e)}, status=400
821
- )
822
- except Exception as e:
823
- logger.error(f"Error processing event: {e}")
824
- if self.debug:
825
- traceback.print_exc()
826
- return web.json_response(
827
- {"error": "Failed to process event", "details": str(e)}, status=500
828
- )
829
-
830
- async def _serve_version(self, request):
831
- """Serve version information."""
832
- version_info = {
833
- "version": "4.2.3",
834
- "server": "stable",
835
- "features": ["http", "socketio", "event_bridge", "resilience"],
836
- "status": "running" if self.is_healthy else "degraded",
837
- }
838
- return web.json_response(version_info)
839
-
840
- def _format_uptime(self, seconds: float) -> str:
841
- """Format uptime in human-readable format."""
842
- days = int(seconds // 86400)
843
- hours = int((seconds % 86400) // 3600)
844
- minutes = int((seconds % 3600) // 60)
845
- secs = int(seconds % 60)
846
-
847
- parts = []
848
- if days > 0:
849
- parts.append(f"{days}d")
850
- if hours > 0:
851
- parts.append(f"{hours}h")
852
- if minutes > 0:
853
- parts.append(f"{minutes}m")
854
- parts.append(f"{secs}s")
855
-
856
- return " ".join(parts)
857
-
858
- def run(self):
859
- """Run the server with automatic restart on crash."""
860
- restart_attempts = 0
861
- max_restart_attempts = 5
862
-
863
- while restart_attempts < max_restart_attempts:
864
- try:
865
- print(
866
- f"🔧 Setting up server... (attempt {restart_attempts + 1}/{max_restart_attempts})"
867
- )
868
-
869
- # Reset health status on restart
870
- self.is_healthy = True
871
- self.health_check_failures = 0
872
-
873
- if not self.setup():
874
- if not DEPENDENCIES_AVAILABLE:
875
- print("❌ Missing required dependencies")
876
- return False
877
-
878
- # Continue with fallback mode even if dashboard files not found
879
- print("⚠️ Dashboard files not found - running in fallback mode")
880
- print(
881
- " Server will provide basic functionality and receive events"
882
- )
883
-
884
- # Set up minimal server without dashboard files
885
- self.sio = socketio.AsyncServer(
886
- cors_allowed_origins="*",
887
- logger=self.debug,
888
- engineio_logger=self.debug,
889
- ping_interval=30,
890
- ping_timeout=60,
891
- max_http_buffer_size=1e8,
892
- )
893
- self.app = web.Application()
894
- self.sio.attach(self.app)
895
- self._setup_routes()
896
- self._setup_socketio_events()
897
-
898
- return self._run_with_resilience()
899
-
900
- except Exception as e:
901
- restart_attempts += 1
902
- logger.error(f"Server crashed: {e}")
903
- if self.debug:
904
- traceback.print_exc()
905
-
906
- if restart_attempts < max_restart_attempts:
907
- wait_time = min(
908
- 2**restart_attempts, 30
909
- ) # Exponential backoff, max 30s
910
- print(f"🔄 Restarting server in {wait_time} seconds...")
911
- time.sleep(wait_time)
912
- else:
913
- print(
914
- f"❌ Server failed after {max_restart_attempts} restart attempts"
915
- )
916
- return False
917
-
918
- return False
919
-
920
- def _run_with_resilience(self):
921
- """Run server with port conflict resolution and error handling."""
922
-
923
- print(f"🚀 Starting stable dashboard server at http://{self.host}:{self.port}")
924
- print("✅ Server ready: HTTP + SocketIO with resilience features")
925
- print("🛡️ Resilience features enabled:")
926
- print(" - Automatic restart on crash")
927
- print(" - Health monitoring endpoint (/health)")
928
- print(" - Event history buffer (500 events)")
929
- print(" - Graceful degradation")
930
- print(" - Connection retry logic")
931
- print("📡 SocketIO events:")
932
- print(" - claude_event (real-time events from hooks)")
933
- print(" - code:analyze:file (code analysis)")
934
- print(" - connection management")
935
- print("🌐 HTTP endpoints:")
936
- print(" - GET / (dashboard)")
937
- print(" - GET /health (health check)")
938
- print(" - POST /api/events (receive hook events)")
939
- print(" - GET /api/status (detailed status)")
940
- print(" - GET /api/events/history (event history)")
941
- print(" - GET /api/directory/list")
942
- print(" - GET /api/file/read")
943
- print(f"\n🔗 Open in browser: http://{self.host}:{self.port}")
944
- print("\n Press Ctrl+C to stop the server\n")
945
-
946
- # Try to start server with port conflict handling
947
- max_port_attempts = 10
948
- original_port = self.port
949
-
950
- for attempt in range(max_port_attempts):
951
- try:
952
- # Use the print_func parameter to control access log output
953
- if self.debug:
954
- web.run_app(self.app, host=self.host, port=self.port)
955
- else:
956
- web.run_app(
957
- self.app,
958
- host=self.host,
959
- port=self.port,
960
- access_log=None,
961
- print=lambda *args: None, # Suppress startup messages in non-debug mode
962
- )
963
- return True # Server started successfully
964
- except KeyboardInterrupt:
965
- print("\n🛑 Server stopped by user")
966
- return True
967
- except OSError as e:
968
- error_str = str(e)
969
- if (
970
- "[Errno 48]" in error_str
971
- or "Address already in use" in error_str
972
- or "address already in use" in error_str.lower()
973
- ):
974
- # Port is already in use
975
- if attempt < max_port_attempts - 1:
976
- self.port += 1
977
- print(
978
- f"⚠️ Port {self.port - 1} is in use, trying port {self.port}..."
979
- )
980
- # Recreate the app with new port
981
- self.setup()
982
- else:
983
- print(
984
- f"❌ Could not find available port after {max_port_attempts} attempts"
985
- )
986
- print(f" Ports {original_port} to {self.port} are all in use")
987
- print(
988
- "\n💡 Tip: Check if another dashboard instance is running"
989
- )
990
- print(" You can stop it with: claude-mpm dashboard stop")
991
- return False
992
- else:
993
- # Other OS error
994
- print(f"❌ Server error: {e}")
995
- if self.debug:
996
- import traceback
997
-
998
- traceback.print_exc()
999
- return False
1000
- except Exception as e:
1001
- print(f"❌ Unexpected server error: {e}")
1002
- if self.debug:
1003
- import traceback
1004
-
1005
- traceback.print_exc()
1006
- else:
1007
- print("\n💡 Run with --debug flag for more details")
1008
- return False
1009
-
1010
- return True
1011
-
1012
-
1013
- def create_stable_server(
1014
- dashboard_path: Optional[Path] = None, **kwargs
1015
- ) -> StableDashboardServer:
1016
- """Create a stable dashboard server instance."""
1017
- server = StableDashboardServer(**kwargs)
1018
- if dashboard_path:
1019
- server.dashboard_path = dashboard_path
1020
- return server