#!/usr/bin/env python3
"""
Web dashboard for the Amicus Synapse cluster.
Provides real-time visualization of:
- Node status and activity
- Task execution metrics
- Cluster health
- Performance charts
"""
import json
import time
from pathlib import Path
from flask import Flask, jsonify, render_template_string
from flask_cors import CORS
# Import Amicus components
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from src.amicus.core import get_state_file, read_with_lock
from src.amicus.metrics import get_metrics_collector
app = Flask(__name__)
CORS(app) # Enable CORS for development
# HTML template with embedded CSS and JavaScript
DASHBOARD_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>Amicus Synapse Dashboard</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0e27;
color: #e0e0e0;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 30px;
border-radius: 12px;
margin-bottom: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
color: white;
}
.header .status {
display: flex;
gap: 20px;
margin-top: 15px;
}
.status-item {
background: rgba(255,255,255,0.1);
padding: 10px 20px;
border-radius: 8px;
backdrop-filter: blur(10px);
}
.status-item .label {
font-size: 0.85em;
opacity: 0.8;
text-transform: uppercase;
letter-spacing: 1px;
}
.status-item .value {
font-size: 1.8em;
font-weight: bold;
margin-top: 5px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 20px;
}
.card {
background: #1a1f3a;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
border: 1px solid #2a2f4a;
}
.card h2 {
font-size: 1.3em;
margin-bottom: 20px;
color: #a78bfa;
display: flex;
align-items: center;
gap: 10px;
}
.node-card {
background: #1e2338;
padding: 15px;
border-radius: 8px;
margin-bottom: 10px;
border-left: 4px solid #667eea;
transition: all 0.3s ease;
}
.node-card:hover {
transform: translateX(5px);
border-left-color: #a78bfa;
}
.node-card.inactive {
opacity: 0.5;
border-left-color: #666;
}
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.node-id {
font-weight: bold;
font-size: 1.1em;
color: #a78bfa;
}
.node-status {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85em;
font-weight: bold;
text-transform: uppercase;
}
.node-status.active {
background: #10b981;
color: white;
}
.node-status.inactive {
background: #6b7280;
color: white;
}
.node-meta {
display: flex;
gap: 15px;
font-size: 0.9em;
color: #9ca3af;
margin-top: 8px;
}
.node-meta span {
display: flex;
align-items: center;
gap: 5px;
}
.metric-row {
display: flex;
justify-content: space-between;
padding: 10px;
border-bottom: 1px solid #2a2f4a;
}
.metric-row:last-child {
border-bottom: none;
}
.metric-label {
color: #9ca3af;
}
.metric-value {
font-weight: bold;
color: #a78bfa;
}
.chart-container {
position: relative;
height: 300px;
margin-top: 20px;
}
.empty-state {
text-align: center;
padding: 40px;
color: #6b7280;
}
.connection-status {
position: fixed;
top: 20px;
right: 20px;
padding: 10px 20px;
border-radius: 20px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
z-index: 1000;
}
.connection-status.connected {
background: #10b981;
color: white;
}
.connection-status.disconnected {
background: #ef4444;
color: white;
}
.pulse {
width: 10px;
height: 10px;
border-radius: 50%;
background: white;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.task-item {
background: #1e2338;
padding: 12px;
border-radius: 6px;
margin-bottom: 8px;
border-left: 3px solid #667eea;
}
.task-item.completed {
border-left-color: #10b981;
opacity: 0.7;
}
.task-item.pending {
border-left-color: #f59e0b;
}
</style>
</head>
<body>
<div class="connection-status disconnected" id="connectionStatus">
<span class="pulse"></span>
<span id="connectionText">Connecting...</span>
</div>
<div class="header">
<h1>๐ง Amicus Synapse Dashboard</h1>
<div class="status">
<div class="status-item">
<div class="label">Active Nodes</div>
<div class="value" id="activeNodes">0</div>
</div>
<div class="status-item">
<div class="label">Pending Tasks</div>
<div class="value" id="pendingTasks">0</div>
</div>
<div class="status-item">
<div class="label">Completed</div>
<div class="value" id="completedTasks">0</div>
</div>
<div class="status-item">
<div class="label">Uptime</div>
<div class="value" id="uptime">--</div>
</div>
</div>
</div>
<div class="grid">
<div class="card" style="grid-column: span 1;">
<h2>๐ฅ๏ธ Cluster Nodes</h2>
<div id="nodesList"></div>
</div>
<div class="card">
<h2>๐ Task Activity</h2>
<div class="chart-container">
<canvas id="taskChart"></canvas>
</div>
</div>
</div>
<div class="grid">
<div class="card">
<h2>โก Performance Metrics</h2>
<div id="performanceMetrics"></div>
</div>
<div class="card">
<h2>๐ Recent Tasks</h2>
<div id="recentTasks"></div>
</div>
</div>
<script>
let ws = null;
let taskChart = null;
let metricsData = { completed: 0, pending: 0, failed: 0 };
let startTime = Date.now();
// Initialize chart
const ctx = document.getElementById('taskChart').getContext('2d');
taskChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Tasks Completed',
data: [],
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
tension: 0.4,
fill: true
}, {
label: 'Tasks Pending',
data: [],
borderColor: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: { color: '#e0e0e0' }
}
},
scales: {
y: {
beginAtZero: true,
ticks: { color: '#9ca3af' },
grid: { color: '#2a2f4a' }
},
x: {
ticks: { color: '#9ca3af' },
grid: { color: '#2a2f4a' }
}
}
}
});
// Connect to WebSocket
function connectWebSocket() {
ws = new WebSocket("ws://localhost:8765");
ws.onopen = () => {
// WebSocket connected
updateConnectionStatus(true);
ws.send(JSON.stringify({type: "register", node_id: "dashboard"}));
ws.send(JSON.stringify({type: "subscribe", topic: "state.update"}));
ws.send(JSON.stringify({type: "subscribe", topic: "task.completed"}));
ws.send(JSON.stringify({type: "subscribe", topic: "task.claimed"}));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
// Message received
// Real-time updates handled here
if (msg.type === 'state.update') {
fetchData();
}
};
ws.onclose = () => {
// WebSocket disconnected
updateConnectionStatus(false);
setTimeout(connectWebSocket, 3000);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
}
function updateConnectionStatus(connected) {
const status = document.getElementById('connectionStatus');
const text = document.getElementById('connectionText');
if (connected) {
status.className = 'connection-status connected';
text.textContent = 'Connected';
} else {
status.className = 'connection-status disconnected';
text.textContent = 'Disconnected';
}
}
// Fetch data from REST API
async function fetchData() {
try {
// Fetch cluster state
const stateRes = await fetch('/api/state');
const state = await stateRes.json();
updateClusterState(state);
// Fetch metrics
const metricsRes = await fetch('/api/metrics');
const metrics = await metricsRes.json();
updateMetrics(metrics);
} catch (error) {
console.error("Error fetching data:", error);
}
}
function updateClusterState(state) {
// Update header stats
const nodes = state.cluster_nodes || {};
const activeCount = Object.values(nodes).filter(n => n.status !== 'terminated').length;
document.getElementById('activeNodes').textContent = activeCount;
const tasks = state.next_steps || [];
const pendingCount = tasks.filter(t => t.status === 'pending').length;
const completedCount = tasks.filter(t => t.status === 'completed').length;
document.getElementById('pendingTasks').textContent = pendingCount;
document.getElementById('completedTasks').textContent = completedCount;
// Update uptime
const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000);
const hours = Math.floor(uptimeSeconds / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
document.getElementById('uptime').textContent = `${hours}h ${minutes}m`;
// Update nodes list
updateNodesList(nodes);
// Update recent tasks
updateRecentTasks(tasks);
// Update chart
updateChart(completedCount, pendingCount);
}
function updateNodesList(nodes) {
const container = document.getElementById('nodesList');
if (Object.keys(nodes).length === 0) {
container.innerHTML = '<div class="empty-state">No active nodes</div>';
return;
}
container.innerHTML = Object.entries(nodes).map(([id, node]) => {
const isActive = node.status !== 'terminated';
const lastHeartbeat = node.last_heartbeat || 0;
const secondsAgo = Math.floor((Date.now() / 1000) - lastHeartbeat);
return `
<div class="node-card ${isActive ? '' : 'inactive'}">
<div class="node-header">
<span class="node-id">${id}</span>
<span class="node-status ${isActive ? 'active' : 'inactive'}">
${isActive ? 'โ Active' : 'โ Inactive'}
</span>
</div>
<div class="node-meta">
<span>๐ค ${node.role || 'unknown'}</span>
<span>๐ค ${node.model || 'unknown'}</span>
<span>โฑ๏ธ ${secondsAgo}s ago</span>
</div>
</div>
`;
}).join('');
}
function updateRecentTasks(tasks) {
const container = document.getElementById('recentTasks');
if (tasks.length === 0) {
container.innerHTML = '<div class="empty-state">No recent tasks</div>';
return;
}
// Show last 5 tasks
container.innerHTML = tasks.slice(-5).reverse().map(task => {
const statusClass = task.status === 'completed' ? 'completed' :
task.status === 'pending' ? 'pending' : '';
return `
<div class="task-item ${statusClass}">
<div style="font-weight: bold; margin-bottom: 5px;">
${task.task || 'Unnamed task'}
</div>
<div style="font-size: 0.85em; color: #9ca3af;">
${task.assigned_to || 'Unassigned'} ยท ${task.status || 'unknown'}
</div>
</div>
`;
}).join('');
}
function updateMetrics(metrics) {
const container = document.getElementById('performanceMetrics');
container.innerHTML = `
<div class="metric-row">
<span class="metric-label">Total Events</span>
<span class="metric-value">${metrics.total_events || 0}</span>
</div>
<div class="metric-row">
<span class="metric-label">Unique Metrics</span>
<span class="metric-value">${metrics.unique_metrics || 0}</span>
</div>
<div class="metric-row">
<span class="metric-label">Tracked Nodes</span>
<span class="metric-value">${metrics.unique_nodes || 0}</span>
</div>
<div class="metric-row">
<span class="metric-label">Database Size</span>
<span class="metric-value">${formatBytes(metrics.db_size_bytes || 0)}</span>
</div>
<div class="metric-row">
<span class="metric-label">Time Range</span>
<span class="metric-value">${(metrics.time_range_hours || 0).toFixed(1)}h</span>
</div>
`;
}
function updateChart(completed, pending) {
const now = new Date().toLocaleTimeString();
if (taskChart.data.labels.length > 20) {
taskChart.data.labels.shift();
taskChart.data.datasets[0].data.shift();
taskChart.data.datasets[1].data.shift();
}
taskChart.data.labels.push(now);
taskChart.data.datasets[0].data.push(completed);
taskChart.data.datasets[1].data.push(pending);
taskChart.update('none');
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Initialize
connectWebSocket();
fetchData();
setInterval(fetchData, 5000); // Refresh every 5 seconds
</script>
</body>
</html>
"""
@app.route('/')
def index():
"""Serve the dashboard HTML"""
return render_template_string(DASHBOARD_HTML)
@app.route('/api/state')
def get_state():
"""Get current cluster state"""
try:
state_file = get_state_file()
state_data = read_with_lock(state_file)
if not state_data:
return jsonify({
'cluster_nodes': {},
'next_steps': [],
'summary': 'No state available'
})
return jsonify(state_data)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/metrics')
def get_metrics():
"""Get metrics statistics"""
try:
collector = get_metrics_collector()
stats = collector.get_stats()
return jsonify(stats)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/metrics/recent')
def get_recent_metrics():
"""Get recent metric events"""
try:
collector = get_metrics_collector()
since = time.time() - 3600 # Last hour
events = collector.query(since=since, limit=100)
return jsonify(events)
except Exception as e:
return jsonify({'error': str(e)}), 500
if __name__ == '__main__':
print("๐ Starting Amicus Dashboard on http://localhost:5000")
print("๐ Make sure WebSocket server is running on ws://localhost:8765")
app.run(host='0.0.0.0', port=5000, debug=True)