CRITICAL FIXES: 1. Stop button now resets database FIRST (before pkill) - Database cleanup happens even if coordinator crashed - Prevents stale 'running' chunks blocking restart - Uses Node.js sqlite library (not CLI - Docker compatible) 2. UI enhancement - 4-state display - ⚡ Processing (running > 0) - ⏳ Pending (pending > 0, running = 0) - ✅ Complete (all completed) - ⏸️ Idle (no work queued) [NEW] - Shows pending chunk count when present TECHNICAL DETAILS: - Replaced sqlite3 CLI calls with proper Node.js API - Fixed permissions: chown 1001:1001 cluster/ for container write - Database-first logic: reset → pkill → verify - Detailed logging for each operation step FILES CHANGED: - app/api/cluster/control/route.ts (database operations refactored) - app/cluster/page.tsx (4-state UI display) VERIFIED: - Stop button successfully reset 3 'running' chunks → 'pending' - UI correctly shows Idle state after Stop - Container logs show detailed operation flow - Database operations work in Docker environment DEPLOYMENT: - Container rebuilt with fixed code - Tested with real stale database (3 running chunks) - All operations working correctly
362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
|
||
interface ClusterStatus {
|
||
cluster: {
|
||
totalCores: number
|
||
activeCores: number
|
||
cpuUsage: number
|
||
activeWorkers: number
|
||
totalWorkers: number
|
||
workerProcesses: number
|
||
status: string
|
||
}
|
||
workers: Array<{
|
||
name: string
|
||
host: string
|
||
cpuUsage: number
|
||
loadAverage: string
|
||
activeProcesses: number
|
||
status: string
|
||
}>
|
||
exploration: {
|
||
totalCombinations: number
|
||
testedCombinations: number
|
||
progress: number
|
||
chunks: {
|
||
total: number
|
||
completed: number
|
||
running: number
|
||
pending: number
|
||
}
|
||
}
|
||
topStrategies: Array<{
|
||
rank: number
|
||
pnl_per_1k: number
|
||
win_rate: number
|
||
trades: number
|
||
profit_factor: number
|
||
max_drawdown: number
|
||
params: {
|
||
flip_threshold: number
|
||
ma_gap: number
|
||
adx_min: number
|
||
long_pos_max: number
|
||
short_pos_min: number
|
||
}
|
||
}>
|
||
recommendation: string
|
||
lastUpdate: string
|
||
}
|
||
|
||
export default function ClusterPage() {
|
||
const [status, setStatus] = useState<ClusterStatus | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [controlLoading, setControlLoading] = useState(false)
|
||
const [controlMessage, setControlMessage] = useState<string | null>(null)
|
||
|
||
const fetchStatus = async () => {
|
||
try {
|
||
const res = await fetch('/api/cluster/status')
|
||
if (!res.ok) throw new Error('Failed to fetch')
|
||
const data = await res.json()
|
||
setStatus(data)
|
||
setError(null)
|
||
} catch (err: any) {
|
||
setError(err.message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleControl = async (action: 'start' | 'stop') => {
|
||
setControlLoading(true)
|
||
setControlMessage(null)
|
||
try {
|
||
const res = await fetch('/api/cluster/control', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ action })
|
||
})
|
||
const data = await res.json()
|
||
setControlMessage(data.message || (data.success ? `Cluster ${action}ed` : 'Operation failed'))
|
||
|
||
// Refresh status after control action
|
||
setTimeout(() => fetchStatus(), 2000)
|
||
} catch (err: any) {
|
||
setControlMessage(`Error: ${err.message}`)
|
||
} finally {
|
||
setControlLoading(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
fetchStatus()
|
||
const interval = setInterval(fetchStatus, 30000) // Refresh every 30s
|
||
return () => clearInterval(interval)
|
||
}, [])
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-900 text-white p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
<h1 className="text-3xl font-bold mb-8">🖥️ EPYC Cluster Status</h1>
|
||
<div className="text-gray-400">Loading cluster status...</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-900 text-white p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
<h1 className="text-3xl font-bold mb-8">🖥️ EPYC Cluster Status</h1>
|
||
<div className="bg-red-900/20 border border-red-500 rounded p-4">
|
||
<p className="text-red-400">Error: {error}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!status) return null
|
||
|
||
const getStatusColor = (statusStr: string) => {
|
||
if (statusStr === 'active') return 'text-green-400'
|
||
if (statusStr === 'idle') return 'text-yellow-400'
|
||
return 'text-red-400'
|
||
}
|
||
|
||
const getStatusBg = (statusStr: string) => {
|
||
if (statusStr === 'active') return 'bg-green-900/20 border-green-500'
|
||
if (statusStr === 'idle') return 'bg-yellow-900/20 border-yellow-500'
|
||
return 'bg-red-900/20 border-red-500'
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-900 text-white p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
{/* Back Button */}
|
||
<a
|
||
href="/"
|
||
className="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6 transition-colors group"
|
||
>
|
||
<svg className="w-5 h-5 mr-2 group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||
</svg>
|
||
<span className="font-medium">Back to Dashboard</span>
|
||
</a>
|
||
|
||
<div className="flex justify-between items-center mb-8">
|
||
<h1 className="text-3xl font-bold">🖥️ EPYC Cluster Status</h1>
|
||
<div className="flex gap-3">
|
||
{status.cluster.status === 'idle' ? (
|
||
<button
|
||
onClick={() => handleControl('start')}
|
||
disabled={controlLoading}
|
||
className="px-6 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 rounded text-sm font-semibold transition-colors"
|
||
>
|
||
{controlLoading ? '⏳ Starting...' : '▶️ Start Cluster'}
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => handleControl('stop')}
|
||
disabled={controlLoading}
|
||
className="px-6 py-2 bg-red-600 hover:bg-red-700 disabled:bg-gray-600 rounded text-sm font-semibold transition-colors"
|
||
>
|
||
{controlLoading ? '⏳ Stopping...' : '⏹️ Stop Cluster'}
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={fetchStatus}
|
||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm"
|
||
>
|
||
🔄 Refresh
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Control Message */}
|
||
{controlMessage && (
|
||
<div className="mb-4 p-4 bg-blue-900/20 border border-blue-500 rounded">
|
||
<p className="text-blue-300">{controlMessage}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Cluster Overview */}
|
||
<div className={`border rounded-lg p-6 mb-6 ${getStatusBg(status.cluster.status)}`}>
|
||
<h2 className="text-xl font-semibold mb-4">Cluster Overview</h2>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
<div>
|
||
<div className="text-gray-400 text-sm">Status</div>
|
||
<div className={`text-2xl font-bold ${getStatusColor(status.cluster.status)}`}>
|
||
{status.cluster.status.toUpperCase()}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-gray-400 text-sm">CPU Usage</div>
|
||
<div className="text-2xl font-bold">{status.cluster.cpuUsage.toFixed(1)}%</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-gray-400 text-sm">Active Cores</div>
|
||
<div className="text-2xl font-bold">{status.cluster.activeCores} / {status.cluster.totalCores}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-gray-400 text-sm">Workers</div>
|
||
<div className="text-2xl font-bold">{status.cluster.activeWorkers} / {status.cluster.totalWorkers}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Worker Details */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||
{status.workers.map((worker) => (
|
||
<div key={worker.name} className={`border rounded-lg p-4 ${getStatusBg(worker.status)}`}>
|
||
<h3 className="font-semibold mb-2">{worker.name}</h3>
|
||
<div className="text-sm text-gray-400 mb-3">{worker.host}</div>
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-400">CPU:</span>
|
||
<span className="font-mono">{worker.cpuUsage.toFixed(1)}%</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-400">Load:</span>
|
||
<span className="font-mono">{worker.loadAverage}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-gray-400">Processes:</span>
|
||
<span className="font-mono">{worker.activeProcesses}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Exploration Progress */}
|
||
<div className="border border-blue-500 bg-blue-900/20 rounded-lg p-6 mb-6">
|
||
<h2 className="text-xl font-semibold mb-4">📊 Parameter Exploration</h2>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||
<div>
|
||
<div className="text-gray-400 text-sm">Total Combinations</div>
|
||
<div className="text-lg font-bold">{status.exploration.totalCombinations.toLocaleString()}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-gray-400 text-sm">Tested</div>
|
||
<div className="text-lg font-bold">{status.exploration.testedCombinations.toLocaleString()}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-gray-400 text-sm">Chunks</div>
|
||
<div className="text-lg font-bold">
|
||
{status.exploration.chunks.completed} / {status.exploration.chunks.total}
|
||
{status.exploration.chunks.running > 0 && (
|
||
<span className="text-yellow-400 ml-2">({status.exploration.chunks.running} running)</span>
|
||
)}
|
||
{status.exploration.chunks.pending > 0 && status.exploration.chunks.running === 0 && (
|
||
<span className="text-gray-400 ml-2">({status.exploration.chunks.pending} pending)</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-gray-400 text-sm">Status</div>
|
||
<div className="text-lg font-bold">
|
||
{status.exploration.chunks.running > 0 ? (
|
||
<span className="text-yellow-400">⚡ Processing</span>
|
||
) : status.exploration.chunks.pending > 0 ? (
|
||
<span className="text-blue-400">⏳ Pending</span>
|
||
) : status.exploration.chunks.completed === status.exploration.chunks.total && status.exploration.chunks.total > 0 ? (
|
||
<span className="text-green-400">✅ Complete</span>
|
||
) : (
|
||
<span className="text-gray-400">⏸️ Idle</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="w-full bg-gray-700 rounded-full h-4">
|
||
<div
|
||
className="bg-blue-500 h-4 rounded-full transition-all"
|
||
style={{ width: `${status.exploration.progress}%` }}
|
||
/>
|
||
</div>
|
||
<div className="text-right text-sm text-gray-400 mt-1">
|
||
{status.exploration.progress.toFixed(2)}% complete
|
||
{status.exploration.testedCombinations > 0 && (
|
||
<span className="ml-3">({status.exploration.testedCombinations.toLocaleString()} strategies tested)</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Recommendation */}
|
||
{status.recommendation && (
|
||
<div className="border border-purple-500 bg-purple-900/20 rounded-lg p-6 mb-6">
|
||
<h2 className="text-xl font-semibold mb-4">🎯 AI Recommendation</h2>
|
||
<div className="whitespace-pre-line text-gray-300 leading-relaxed">
|
||
{status.recommendation}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Top Strategies */}
|
||
{status.topStrategies.length > 0 && (
|
||
<div className="border border-gray-700 rounded-lg p-6">
|
||
<h2 className="text-xl font-semibold mb-4">🏆 Top Strategies</h2>
|
||
<div className="space-y-3">
|
||
{status.topStrategies.map((strategy) => (
|
||
<div key={strategy.rank} className="bg-gray-800 rounded p-4">
|
||
<div className="flex justify-between items-start mb-2">
|
||
<div className="text-lg font-semibold">#{strategy.rank}</div>
|
||
<div className="text-right">
|
||
<div className="text-2xl font-bold text-green-400">
|
||
${strategy.pnl_per_1k.toFixed(2)}
|
||
</div>
|
||
<div className="text-sm text-gray-400">per $1k</div>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||
<div>
|
||
<span className="text-gray-400">Win Rate:</span>{' '}
|
||
<span className="font-semibold">{(strategy.win_rate * 100).toFixed(1)}%</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-400">Trades:</span>{' '}
|
||
<span className="font-semibold">{strategy.trades}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-400">PF:</span>{' '}
|
||
<span className="font-semibold">{strategy.profit_factor.toFixed(2)}x</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-gray-400">Max DD:</span>{' '}
|
||
<span className="font-semibold text-red-400">
|
||
${Math.abs(strategy.max_drawdown).toFixed(0)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<details className="mt-3">
|
||
<summary className="cursor-pointer text-blue-400 text-sm hover:text-blue-300">
|
||
Show Parameters
|
||
</summary>
|
||
<div className="mt-2 grid grid-cols-2 md:grid-cols-3 gap-2 text-xs font-mono bg-gray-900 p-3 rounded">
|
||
<div>flip: {strategy.params.flip_threshold}</div>
|
||
<div>ma_gap: {strategy.params.ma_gap}</div>
|
||
<div>adx: {strategy.params.adx_min}</div>
|
||
<div>long_pos: {strategy.params.long_pos_max}</div>
|
||
<div>short_pos: {strategy.params.short_pos_min}</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-6 text-center text-sm text-gray-500">
|
||
Last updated: {new Date(status.lastUpdate).toLocaleString()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|