✨ FEATURE: Add real-time Service Dashboard

NEW FEATURE: Service Dashboard
- Real-time monitoring of all Claude OS services
- Visual status indicators (running/stopped/unknown)
- CPU and memory usage tracking
- Auto-refresh every 5 seconds (toggleable)
- Service health summary (healthy/degraded/critical)

BACKEND (mcp_server/server.py):
- Added /api/services/status endpoint
  - Monitors 6 services: MCP Server, Frontend, Redis, RQ Worker, Ollama, File Watcher
  - Returns real-time status, PID, CPU%, Memory%, ports
  - Includes health summary (total/running/stopped)

- Added /api/services/{service_type}/control endpoint
  - Placeholder for future service control
  - Returns manual commands for start/stop/restart

- Implemented process monitoring utilities:
  - check_process_running() - uses pgrep and ps for stats
  - check_port_listening() - uses lsof/netstat/ss for port checks

FRONTEND (ServiceDashboard.tsx):
- Beautiful dashboard with animated cards
- Service grid with 6 service cards
- Summary cards: Total, Running, Stopped, Health
- Auto-refresh toggle with visual indicator
- Manual refresh button
- Status badges with color coding:
  - 🟢 Green = Running
  - ⚪ Gray = Stopped
  - 🟡 Yellow = Unknown
- CPU/Memory usage display
- Port and PID information
- Clickable service URLs
- Help section with management commands

INTEGRATION (MainApp.tsx):
- Added "Services" tab to main navigation
- Imported ServiceDashboard component
- Updated tab state type to include 'services'
- Activity icon for services tab

FEATURES:
✅ Real-time status monitoring
✅ Auto-refresh every 5s
✅ Manual refresh button
✅ Health summary dashboard
✅ Service details (port, PID, CPU, memory)
✅ Visual status indicators
✅ Responsive grid layout
✅ Beautiful animations

USER EXPERIENCE:
- Auto-refresh can be toggled on/off
- Last updated timestamp shown
- Help text for service management
- Clean, professional UI matching Claude OS design

Files:
- mcp_server/server.py (+300 lines)
- frontend/src/components/ServiceDashboard.tsx (new file, 350 lines)
- frontend/src/pages/MainApp.tsx (+20 lines)
This commit is contained in:
Claude 2025-11-04 19:24:03 +00:00
parent 28f58f1c60
commit 98908541a7
No known key found for this signature in database
3 changed files with 627 additions and 2 deletions

View file

@ -0,0 +1,326 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import {
Activity,
Server,
Database,
Zap,
Eye,
RefreshCw,
CheckCircle2,
XCircle,
AlertCircle,
Cpu,
MemoryStick
} from 'lucide-react';
import axios from 'axios';
interface Service {
name: string;
type: string;
port: number | null;
running: boolean;
status: string;
pid: number | null;
cpu: number;
memory: number;
description: string;
}
interface ServicesResponse {
services: Service[];
summary: {
total: number;
running: number;
stopped: number;
health: 'healthy' | 'degraded' | 'critical';
};
timestamp: number;
}
const SERVICE_ICONS: Record<string, any> = {
mcp_server: Server,
frontend: Activity,
redis: Database,
rq_worker: Zap,
ollama: Cpu,
file_watcher: Eye
};
const STATUS_COLORS = {
running: {
bg: 'bg-green-500/20',
border: 'border-green-500',
text: 'text-green-400',
icon: CheckCircle2
},
stopped: {
bg: 'bg-gray-500/20',
border: 'border-gray-500',
text: 'text-gray-400',
icon: XCircle
},
unknown: {
bg: 'bg-yellow-500/20',
border: 'border-yellow-500',
text: 'text-yellow-400',
icon: AlertCircle
}
};
export default function ServiceDashboard() {
const [autoRefresh, setAutoRefresh] = useState(true);
// Fetch services status
const { data: servicesData, isLoading, refetch } = useQuery<ServicesResponse>({
queryKey: ['services-status'],
queryFn: async () => {
const response = await axios.get('/api/services/status');
return response.data;
},
refetchInterval: autoRefresh ? 5000 : false, // Refresh every 5 seconds if enabled
});
const services = servicesData?.services || [];
const summary = servicesData?.summary;
const getHealthColor = (health?: string) => {
switch (health) {
case 'healthy':
return 'text-green-400';
case 'degraded':
return 'text-yellow-400';
case 'critical':
return 'text-red-400';
default:
return 'text-gray-400';
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold gradient-text flex items-center gap-2">
<Activity className="w-6 h-6" />
Service Dashboard
</h2>
<p className="text-light-grey text-sm mt-1">
Real-time monitoring of Claude OS services
</p>
</div>
<div className="flex items-center gap-3">
{/* Auto-refresh toggle */}
<button
onClick={() => setAutoRefresh(!autoRefresh)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
autoRefresh
? 'bg-electric-teal/20 text-electric-teal border border-electric-teal/50'
: 'bg-cool-blue/10 text-light-grey border border-electric-teal/20'
}`}
>
<RefreshCw className={`w-4 h-4 ${autoRefresh ? 'animate-spin' : ''}`} />
<span className="text-sm font-medium">
{autoRefresh ? 'Auto-refresh ON' : 'Auto-refresh OFF'}
</span>
</button>
{/* Manual refresh */}
<button
onClick={() => refetch()}
disabled={isLoading}
className="btn-secondary flex items-center gap-2"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{/* Summary Cards */}
{summary && (
<div className="grid grid-cols-4 gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-panel p-4"
>
<div className="flex items-center justify-between">
<div>
<div className="text-light-grey text-sm">Total Services</div>
<div className="text-3xl font-bold text-white mt-1">{summary.total}</div>
</div>
<Server className="w-8 h-8 text-electric-teal opacity-50" />
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="glass-panel p-4"
>
<div className="flex items-center justify-between">
<div>
<div className="text-light-grey text-sm">Running</div>
<div className="text-3xl font-bold text-green-400 mt-1">{summary.running}</div>
</div>
<CheckCircle2 className="w-8 h-8 text-green-400 opacity-50" />
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="glass-panel p-4"
>
<div className="flex items-center justify-between">
<div>
<div className="text-light-grey text-sm">Stopped</div>
<div className="text-3xl font-bold text-gray-400 mt-1">{summary.stopped}</div>
</div>
<XCircle className="w-8 h-8 text-gray-400 opacity-50" />
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="glass-panel p-4"
>
<div className="flex items-center justify-between">
<div>
<div className="text-light-grey text-sm">System Health</div>
<div className={`text-2xl font-bold mt-1 capitalize ${getHealthColor(summary.health)}`}>
{summary.health}
</div>
</div>
<Activity className={`w-8 h-8 opacity-50 ${getHealthColor(summary.health)}`} />
</div>
</motion.div>
</div>
)}
{/* Services Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((service, index) => {
const Icon = SERVICE_ICONS[service.type] || Server;
const statusConfig = STATUS_COLORS[service.status] || STATUS_COLORS.unknown;
const StatusIcon = statusConfig.icon;
return (
<motion.div
key={service.type}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
className={`glass-panel p-5 border-2 ${service.running ? 'border-green-500/30' : 'border-gray-500/30'}`}
>
{/* Service Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${service.running ? 'bg-green-500/20' : 'bg-gray-500/20'}`}>
<Icon className={`w-5 h-5 ${service.running ? 'text-green-400' : 'text-gray-400'}`} />
</div>
<div>
<h3 className="font-bold text-white">{service.name}</h3>
<p className="text-xs text-light-grey">{service.description}</p>
</div>
</div>
{/* Status Badge */}
<div className={`flex items-center gap-1 px-2 py-1 rounded-full ${statusConfig.bg} border ${statusConfig.border}`}>
<StatusIcon className={`w-3 h-3 ${statusConfig.text}`} />
<span className={`text-xs font-medium ${statusConfig.text} capitalize`}>
{service.status}
</span>
</div>
</div>
{/* Service Details */}
<div className="space-y-2">
{service.port && (
<div className="flex items-center justify-between text-sm">
<span className="text-light-grey">Port:</span>
<span className="text-white font-mono">{service.port}</span>
</div>
)}
{service.pid && (
<div className="flex items-center justify-between text-sm">
<span className="text-light-grey">PID:</span>
<span className="text-white font-mono">{service.pid}</span>
</div>
)}
{service.running && service.cpu !== undefined && (
<div className="flex items-center justify-between text-sm">
<span className="text-light-grey flex items-center gap-1">
<Cpu className="w-3 h-3" />
CPU:
</span>
<span className="text-white font-mono">{service.cpu.toFixed(1)}%</span>
</div>
)}
{service.running && service.memory !== undefined && (
<div className="flex items-center justify-between text-sm">
<span className="text-light-grey flex items-center gap-1">
<MemoryStick className="w-3 h-3" />
Memory:
</span>
<span className="text-white font-mono">{service.memory.toFixed(1)}%</span>
</div>
)}
</div>
{/* Service URL (if applicable) */}
{service.port && service.running && (
<div className="mt-4 pt-4 border-t border-electric-teal/20">
<a
href={`http://localhost:${service.port}`}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-electric-teal hover:text-electric-teal/80 transition-colors font-mono"
>
http://localhost:{service.port}
</a>
</div>
)}
</motion.div>
);
})}
</div>
{/* Help Text */}
<div className="glass-panel p-4 border border-electric-teal/30">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-electric-teal flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-white mb-1">Service Management</h4>
<p className="text-sm text-light-grey">
To start/stop services, use the management scripts:
</p>
<div className="mt-2 space-y-1">
<code className="text-xs text-electric-teal block">./start_all_services.sh</code>
<code className="text-xs text-electric-teal block">./stop_all_services.sh</code>
<code className="text-xs text-electric-teal block">./restart_services.sh</code>
</div>
</div>
</div>
</div>
{/* Last Updated */}
{servicesData && (
<div className="text-center text-xs text-light-grey">
Last updated: {new Date(servicesData.timestamp * 1000).toLocaleTimeString()}
</div>
)}
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Home, Settings, Database, MessageSquare, Plus, Trash2, FolderOpen, Zap, Edit } from 'lucide-react';
import { Home, Settings, Database, MessageSquare, Plus, Trash2, FolderOpen, Zap, Edit, Activity } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import axios from 'axios';
@ -8,6 +8,7 @@ import ChatInterface from '../components/ChatInterface';
import KBManagement from '../components/KBManagement';
import ProjectSetup from '../components/ProjectSetup';
import DirectoryPicker from '../components/DirectoryPicker';
import ServiceDashboard from '../components/ServiceDashboard';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8051';
@ -31,7 +32,7 @@ interface ProjectMCP {
export default function MainApp() {
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [selectedMCP, setSelectedMCP] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'mcps' | 'chat'>('overview');
const [activeTab, setActiveTab] = useState<'overview' | 'mcps' | 'chat' | 'services'>('overview');
const [showCreateProject, setShowCreateProject] = useState(false);
const [showProjectSetup, setShowProjectSetup] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
@ -322,6 +323,17 @@ export default function MainApp() {
<MessageSquare className="w-4 h-4 inline mr-2" />
Chat
</button>
<button
onClick={() => setActiveTab('services')}
className={`px-4 py-2 rounded-lg font-semibold transition-all ${
activeTab === 'services'
? 'bg-electric-teal text-deep-night'
: 'text-light-grey hover:text-white'
}`}
>
<Activity className="w-4 h-4 inline mr-2" />
Services
</button>
</div>
{/* Tab Content */}
@ -410,6 +422,8 @@ export default function MainApp() {
</div>
</div>
)
) : activeTab === 'services' ? (
<ServiceDashboard />
) : (
selectedMCP ? (
<ChatInterface

View file

@ -1605,6 +1605,291 @@ async def api_list_ollama_models():
raise HTTPException(status_code=503, detail=str(e))
# ============================================================================
# SERVICE DASHBOARD ENDPOINTS (NEW - Real-time service monitoring)
# ============================================================================
import subprocess
import shutil
class ServiceControlRequest(BaseModel):
"""Request model for service control."""
action: str # start, stop, restart
def check_process_running(process_name: str) -> dict:
"""
Check if a process is running by name.
Returns:
dict with status, pid, memory, cpu
"""
try:
# Use pgrep to find process
result = subprocess.run(
["pgrep", "-f", process_name],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0 and result.stdout.strip():
pids = result.stdout.strip().split('\n')
pid = pids[0] # Get first matching PID
# Get process stats using ps
ps_result = subprocess.run(
["ps", "-p", pid, "-o", "pid,%cpu,%mem,command"],
capture_output=True,
text=True,
timeout=2
)
if ps_result.returncode == 0:
lines = ps_result.stdout.strip().split('\n')
if len(lines) > 1:
parts = lines[1].split(None, 3)
return {
"running": True,
"pid": int(pid),
"cpu": float(parts[1]) if len(parts) > 1 else 0.0,
"memory": float(parts[2]) if len(parts) > 2 else 0.0,
"status": "running"
}
return {
"running": False,
"pid": None,
"cpu": 0.0,
"memory": 0.0,
"status": "stopped"
}
except Exception as e:
logger.warning(f"Failed to check process {process_name}: {e}")
return {
"running": False,
"pid": None,
"cpu": 0.0,
"memory": 0.0,
"status": "unknown",
"error": str(e)
}
def check_port_listening(port: int) -> bool:
"""Check if a port is being listened on."""
try:
# Use netstat or ss to check if port is listening
result = subprocess.run(
["sh", "-c", f"lsof -i :{port} -t || netstat -tuln | grep :{port} || ss -tuln | grep :{port}"],
capture_output=True,
text=True,
timeout=2
)
return result.returncode == 0 and result.stdout.strip() != ""
except Exception as e:
logger.warning(f"Failed to check port {port}: {e}")
return False
@app.get("/api/services/status")
async def api_get_services_status():
"""
Get status of all Claude OS services.
Returns real-time status for:
- MCP Server (this server)
- Frontend (Vite dev server)
- Redis
- RQ Worker
- Ollama
- File Watcher
"""
services = []
# 1. MCP Server (always running since we're responding)
mcp_status = {
"name": "MCP Server",
"type": "mcp_server",
"port": Config.MCP_SERVER_PORT,
"running": True,
"status": "running",
"pid": os.getpid(),
"cpu": 0.0,
"memory": 0.0,
"description": "Main API server and MCP endpoint"
}
services.append(mcp_status)
# 2. Frontend (Vite dev server on port 5173)
frontend_port = 5173
frontend_process = check_process_running("vite")
frontend_status = {
"name": "Frontend",
"type": "frontend",
"port": frontend_port,
"running": frontend_process["running"] or check_port_listening(frontend_port),
"status": frontend_process["status"],
"pid": frontend_process.get("pid"),
"cpu": frontend_process.get("cpu", 0.0),
"memory": frontend_process.get("memory", 0.0),
"description": "React web interface"
}
services.append(frontend_status)
# 3. Redis (port 6379)
redis_port = 6379
redis_process = check_process_running("redis-server")
redis_status = {
"name": "Redis",
"type": "redis",
"port": redis_port,
"running": redis_process["running"] or check_port_listening(redis_port),
"status": redis_process["status"],
"pid": redis_process.get("pid"),
"cpu": redis_process.get("cpu", 0.0),
"memory": redis_process.get("memory", 0.0),
"description": "Job queue and caching"
}
services.append(redis_status)
# 4. RQ Worker
rq_process = check_process_running("rq worker")
rq_status = {
"name": "RQ Worker",
"type": "rq_worker",
"port": None,
"running": rq_process["running"],
"status": rq_process["status"],
"pid": rq_process.get("pid"),
"cpu": rq_process.get("cpu", 0.0),
"memory": rq_process.get("memory", 0.0),
"description": "Background job processor"
}
services.append(rq_status)
# 5. Ollama (port 11434)
ollama_port = 11434
ollama_running = False
try:
response = requests.get(f"{Config.OLLAMA_HOST}/api/tags", timeout=1)
ollama_running = response.status_code == 200
except:
ollama_running = check_port_listening(ollama_port)
ollama_process = check_process_running("ollama")
ollama_status = {
"name": "Ollama",
"type": "ollama",
"port": ollama_port,
"running": ollama_running or ollama_process["running"],
"status": "running" if ollama_running else ollama_process["status"],
"pid": ollama_process.get("pid"),
"cpu": ollama_process.get("cpu", 0.0),
"memory": ollama_process.get("memory", 0.0),
"description": "Local AI model server"
}
services.append(ollama_status)
# 6. File Watcher
try:
watcher = get_global_watcher()
watcher_status_data = watcher.get_status()
watcher_running = watcher_status_data.get("is_running", False)
except:
watcher_running = False
watcher_status = {
"name": "File Watcher",
"type": "file_watcher",
"port": None,
"running": watcher_running,
"status": "running" if watcher_running else "stopped",
"pid": None,
"cpu": 0.0,
"memory": 0.0,
"description": "Automatic folder sync"
}
services.append(watcher_status)
# Calculate summary
total = len(services)
running = sum(1 for s in services if s["running"])
stopped = total - running
return {
"services": services,
"summary": {
"total": total,
"running": running,
"stopped": stopped,
"health": "healthy" if running >= total - 1 else ("degraded" if running > 0 else "critical")
},
"timestamp": time.time()
}
@app.post("/api/services/{service_type}/control")
async def api_control_service(service_type: str, request: ServiceControlRequest):
"""
Control a service (start/stop/restart).
Note: This is a basic implementation. In production, you'd want more
sophisticated service management (systemd, supervisord, etc.)
"""
action = request.action.lower()
if action not in ["start", "stop", "restart"]:
raise HTTPException(status_code=400, detail=f"Invalid action: {action}")
# For now, return a helpful message about manual control
# In a production system, you'd integrate with systemd or supervisord
if service_type == "mcp_server":
return {
"success": False,
"message": "Cannot control MCP server from itself. Use ./start.sh or stop_all_services.sh",
"service": service_type
}
service_commands = {
"frontend": {
"start": "cd frontend && npm run dev &",
"stop": "pkill -f 'vite'",
"script": "frontend/package.json"
},
"redis": {
"start": "redis-server --daemonize yes",
"stop": "redis-cli shutdown",
"script": None
},
"rq_worker": {
"start": "rq worker &",
"stop": "pkill -f 'rq worker'",
"script": None
},
"ollama": {
"start": "ollama serve &",
"stop": "pkill -f ollama",
"script": None
}
}
if service_type not in service_commands:
raise HTTPException(status_code=404, detail=f"Unknown service: {service_type}")
commands = service_commands[service_type]
return {
"success": False,
"message": f"Service control not fully implemented yet. Manual command:",
"manual_command": commands.get(action),
"service": service_type,
"action": action,
"note": "Use start_all_services.sh or stop_all_services.sh for full control"
}
@app.get("/api/browse-directory")
async def api_browse_directory(path: str = None):
"""REST API: Browse directories and return subdirectories."""