🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
760 lines
29 KiB
TypeScript
760 lines
29 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import {
|
|
ServerIcon,
|
|
ExclamationTriangleIcon,
|
|
CheckCircleIcon,
|
|
XCircleIcon,
|
|
PlayIcon,
|
|
StopIcon,
|
|
TrashIcon,
|
|
DocumentTextIcon,
|
|
ArrowPathIcon,
|
|
CloudArrowDownIcon,
|
|
Cog6ToothIcon,
|
|
XMarkIcon,
|
|
ComputerDesktopIcon
|
|
} from '@heroicons/react/24/outline'
|
|
|
|
interface Machine {
|
|
id: string
|
|
hostname: string
|
|
ip: string
|
|
os: string
|
|
osVersion: string
|
|
sshStatus: 'unknown' | 'connected' | 'failed' | 'testing'
|
|
deployStatus: 'not_deployed' | 'installing' | 'running' | 'stopped' | 'error'
|
|
selected: boolean
|
|
lastSeen?: string
|
|
deployProgress?: number
|
|
deployStep?: string
|
|
systemInfo?: {
|
|
cpu: number
|
|
memory: number
|
|
disk: number
|
|
}
|
|
}
|
|
|
|
interface ServiceDeploymentProps {
|
|
systemInfo: any
|
|
configData: any
|
|
onComplete: (data: any) => void
|
|
onBack?: () => void
|
|
isCompleted: boolean
|
|
}
|
|
|
|
export default function ServiceDeployment({
|
|
systemInfo,
|
|
configData,
|
|
onComplete,
|
|
onBack,
|
|
isCompleted
|
|
}: ServiceDeploymentProps) {
|
|
const [machines, setMachines] = useState<Machine[]>([])
|
|
const [isDiscovering, setIsDiscovering] = useState(false)
|
|
const [discoveryProgress, setDiscoveryProgress] = useState(0)
|
|
const [discoveryStatus, setDiscoveryStatus] = useState('')
|
|
const [showLogs, setShowLogs] = useState<string | null>(null)
|
|
const [deploymentLogs, setDeploymentLogs] = useState<{[key: string]: string[]}>({})
|
|
const [showConsole, setShowConsole] = useState<string | null>(null)
|
|
const [consoleLogs, setConsoleLogs] = useState<{[key: string]: string[]}>({})
|
|
|
|
const [config, setConfig] = useState({
|
|
deploymentMethod: 'systemd',
|
|
autoStart: true,
|
|
healthCheckInterval: 30,
|
|
selectedMachines: [] as string[]
|
|
})
|
|
|
|
// Initialize with current machine
|
|
useEffect(() => {
|
|
const currentMachine: Machine = {
|
|
id: 'localhost',
|
|
hostname: systemInfo?.network?.hostname || 'localhost',
|
|
ip: configData?.network?.primaryIP || '127.0.0.1',
|
|
os: systemInfo?.os || 'linux',
|
|
osVersion: 'Current Host',
|
|
sshStatus: 'connected',
|
|
deployStatus: 'running', // Already running since we're in setup
|
|
selected: true,
|
|
systemInfo: {
|
|
cpu: systemInfo?.cpu_cores || 0,
|
|
memory: Math.round((systemInfo?.memory_mb || 0) / 1024),
|
|
disk: systemInfo?.storage?.free_space_gb || 0
|
|
}
|
|
}
|
|
setMachines([currentMachine])
|
|
setConfig(prev => ({ ...prev, selectedMachines: ['localhost'] }))
|
|
}, [systemInfo, configData])
|
|
|
|
const discoverMachines = async () => {
|
|
setIsDiscovering(true)
|
|
setDiscoveryProgress(0)
|
|
setDiscoveryStatus('Initializing network scan...')
|
|
|
|
try {
|
|
// Simulate progress updates during discovery
|
|
const progressInterval = setInterval(() => {
|
|
setDiscoveryProgress(prev => {
|
|
const newProgress = prev + 10
|
|
if (newProgress <= 30) {
|
|
setDiscoveryStatus('Scanning network subnet...')
|
|
} else if (newProgress <= 60) {
|
|
setDiscoveryStatus('Checking SSH accessibility...')
|
|
} else if (newProgress <= 90) {
|
|
setDiscoveryStatus('Gathering system information...')
|
|
} else {
|
|
setDiscoveryStatus('Finalizing discovery...')
|
|
}
|
|
return Math.min(newProgress, 95)
|
|
})
|
|
}, 200)
|
|
|
|
const response = await fetch('/api/setup/discover-machines', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
subnet: configData?.network?.allowedIPs?.[0] || '192.168.1.0/24',
|
|
sshKey: configData?.security?.sshPublicKey
|
|
})
|
|
})
|
|
|
|
clearInterval(progressInterval)
|
|
setDiscoveryProgress(100)
|
|
|
|
if (response.ok) {
|
|
const result = await response.json()
|
|
setDiscoveryStatus(`Found ${result.machines?.length || 0} machines`)
|
|
|
|
const discoveredMachines: Machine[] = result.machines.map((m: any) => ({
|
|
id: m.ip,
|
|
hostname: m.hostname || 'Unknown',
|
|
ip: m.ip,
|
|
os: m.os || 'unknown',
|
|
osVersion: m.os_version || 'Unknown',
|
|
sshStatus: 'unknown',
|
|
deployStatus: 'not_deployed',
|
|
selected: false,
|
|
lastSeen: new Date().toISOString(),
|
|
systemInfo: m.system_info
|
|
}))
|
|
|
|
// Merge with existing machines (keep localhost)
|
|
setMachines(prev => {
|
|
const localhost = prev.find(m => m.id === 'localhost')
|
|
return localhost ? [localhost, ...discoveredMachines] : discoveredMachines
|
|
})
|
|
} else {
|
|
setDiscoveryStatus('Discovery failed - check network configuration')
|
|
}
|
|
} catch (error) {
|
|
console.error('Discovery failed:', error)
|
|
setDiscoveryStatus('Discovery error - network unreachable')
|
|
} finally {
|
|
setTimeout(() => {
|
|
setIsDiscovering(false)
|
|
setDiscoveryProgress(0)
|
|
setDiscoveryStatus('')
|
|
}, 2000)
|
|
}
|
|
}
|
|
|
|
const testSSHConnection = async (machineId: string) => {
|
|
setMachines(prev => prev.map(m =>
|
|
m.id === machineId ? { ...m, sshStatus: 'testing' } : m
|
|
))
|
|
|
|
try {
|
|
const machine = machines.find(m => m.id === machineId)
|
|
const response = await fetch('/api/setup/test-ssh', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
ip: machine?.ip,
|
|
sshKey: configData?.security?.sshPrivateKey,
|
|
sshUsername: configData?.security?.sshUsername || 'ubuntu',
|
|
sshPassword: configData?.security?.sshPassword,
|
|
sshPort: configData?.security?.sshPort || 22
|
|
})
|
|
})
|
|
|
|
const result = await response.json()
|
|
setMachines(prev => prev.map(m =>
|
|
m.id === machineId ? {
|
|
...m,
|
|
sshStatus: result.success ? 'connected' : 'failed',
|
|
os: result.os || m.os,
|
|
osVersion: result.os_version || m.osVersion,
|
|
systemInfo: result.system_info || m.systemInfo
|
|
} : m
|
|
))
|
|
} catch (error) {
|
|
setMachines(prev => prev.map(m =>
|
|
m.id === machineId ? { ...m, sshStatus: 'failed' } : m
|
|
))
|
|
}
|
|
}
|
|
|
|
const deployToMachine = async (machineId: string) => {
|
|
setMachines(prev => prev.map(m =>
|
|
m.id === machineId ? {
|
|
...m,
|
|
deployStatus: 'installing',
|
|
deployProgress: 0,
|
|
deployStep: 'Initializing deployment...'
|
|
} : m
|
|
))
|
|
|
|
const logs: string[] = []
|
|
const consoleLogs: string[] = [`🚀 Starting deployment to ${machines.find(m => m.id === machineId)?.hostname} (${machines.find(m => m.id === machineId)?.ip})`]
|
|
setDeploymentLogs(prev => ({ ...prev, [machineId]: logs }))
|
|
setConsoleLogs(prev => ({ ...prev, [machineId]: consoleLogs }))
|
|
|
|
// Open console if not already showing
|
|
if (!showConsole) {
|
|
setShowConsole(machineId)
|
|
}
|
|
|
|
// Real-time console logging helper
|
|
const addConsoleLog = (message: string) => {
|
|
const timestamp = new Date().toLocaleTimeString()
|
|
const logMessage = `[${timestamp}] ${message}`
|
|
setConsoleLogs(prev => ({
|
|
...prev,
|
|
[machineId]: [...(prev[machineId] || []), logMessage]
|
|
}))
|
|
}
|
|
|
|
// Simulate progress updates
|
|
const progressSteps = [
|
|
{ progress: 10, step: 'Establishing SSH connection...' },
|
|
{ progress: 30, step: 'Copying BZZZ binary...' },
|
|
{ progress: 60, step: 'Creating systemd service...' },
|
|
{ progress: 80, step: 'Starting service...' },
|
|
{ progress: 100, step: 'Deployment complete!' }
|
|
]
|
|
|
|
const updateProgress = (stepIndex: number) => {
|
|
if (stepIndex < progressSteps.length) {
|
|
const { progress, step } = progressSteps[stepIndex]
|
|
setMachines(prev => prev.map(m =>
|
|
m.id === machineId ? {
|
|
...m,
|
|
deployProgress: progress,
|
|
deployStep: step
|
|
} : m
|
|
))
|
|
logs.push(`📦 ${step}`)
|
|
addConsoleLog(`📦 ${step}`)
|
|
setDeploymentLogs(prev => ({ ...prev, [machineId]: [...(prev[machineId] || []), `📦 ${step}`] }))
|
|
}
|
|
}
|
|
|
|
try {
|
|
const machine = machines.find(m => m.id === machineId)
|
|
addConsoleLog(`🚀 Starting deployment to ${machine?.hostname}...`)
|
|
addConsoleLog(`📡 Sending deployment request to backend API...`)
|
|
|
|
// Set initial progress
|
|
setMachines(prev => prev.map(m =>
|
|
m.id === machineId ? {
|
|
...m,
|
|
deployProgress: 10,
|
|
deployStep: 'Contacting backend API...'
|
|
} : m
|
|
))
|
|
const response = await fetch('/api/setup/deploy-service', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
ip: machine?.ip,
|
|
sshKey: configData?.security?.sshPrivateKey,
|
|
sshUsername: configData?.security?.sshUsername || 'ubuntu',
|
|
sshPassword: configData?.security?.sshPassword,
|
|
sshPort: configData?.security?.sshPort || 22,
|
|
config: {
|
|
ports: {
|
|
api: configData?.network?.bzzzPort || 8080,
|
|
mcp: configData?.network?.mcpPort || 3000,
|
|
webui: configData?.network?.webUIPort || 8080,
|
|
p2p: configData?.network?.p2pPort || 7000
|
|
},
|
|
security: configData?.security,
|
|
autoStart: config.autoStart
|
|
}
|
|
})
|
|
})
|
|
|
|
const result = await response.json()
|
|
addConsoleLog(`📨 Received response from backend API`)
|
|
|
|
if (result.success) {
|
|
setMachines(prev => prev.map(m =>
|
|
m.id === machineId ? {
|
|
...m,
|
|
deployStatus: 'running',
|
|
deployProgress: 100,
|
|
deployStep: 'Running'
|
|
} : m
|
|
))
|
|
logs.push('✅ Deployment completed successfully')
|
|
addConsoleLog('✅ Deployment completed successfully!')
|
|
|
|
// Show actual backend steps if provided
|
|
if (result.steps) {
|
|
result.steps.forEach((step: string) => {
|
|
logs.push(step)
|
|
addConsoleLog(`📋 ${step}`)
|
|
})
|
|
}
|
|
addConsoleLog(`🎉 CHORUS:agents service is now running on ${machine?.hostname}`)
|
|
} else {
|
|
setMachines(prev => prev.map(m =>
|
|
m.id === machineId ? {
|
|
...m,
|
|
deployStatus: 'error',
|
|
deployProgress: 0,
|
|
deployStep: 'Failed'
|
|
} : m
|
|
))
|
|
logs.push(`❌ Deployment failed: ${result.error}`)
|
|
addConsoleLog(`❌ Deployment failed: ${result.error}`)
|
|
addConsoleLog(`💡 Note: This was a real backend error, not simulated progress`)
|
|
}
|
|
} catch (error) {
|
|
setMachines(prev => prev.map(m =>
|
|
m.id === machineId ? {
|
|
...m,
|
|
deployStatus: 'error',
|
|
deployProgress: 0,
|
|
deployStep: 'Error'
|
|
} : m
|
|
))
|
|
logs.push(`❌ Deployment error: ${error}`)
|
|
addConsoleLog(`❌ Deployment error: ${error}`)
|
|
}
|
|
|
|
setDeploymentLogs(prev => ({ ...prev, [machineId]: logs }))
|
|
}
|
|
|
|
const toggleMachineSelection = (machineId: string) => {
|
|
setMachines(prev => prev.map(m =>
|
|
m.id === machineId ? { ...m, selected: !m.selected } : m
|
|
))
|
|
|
|
setConfig(prev => ({
|
|
...prev,
|
|
selectedMachines: machines
|
|
.map(m => m.id === machineId ? { ...m, selected: !m.selected } : m)
|
|
.filter(m => m.selected)
|
|
.map(m => m.id)
|
|
}))
|
|
}
|
|
|
|
const deployToSelected = async () => {
|
|
const selectedMachines = machines.filter(m => m.selected && m.sshStatus === 'connected')
|
|
for (const machine of selectedMachines) {
|
|
if (machine.deployStatus === 'not_deployed') {
|
|
await deployToMachine(machine.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
const removeMachine = (machineId: string) => {
|
|
// Don't allow removing localhost
|
|
if (machineId === 'localhost') return
|
|
|
|
setMachines(prev => prev.filter(m => m.id !== machineId))
|
|
setConfig(prev => ({
|
|
...prev,
|
|
selectedMachines: prev.selectedMachines.filter(id => id !== machineId)
|
|
}))
|
|
|
|
// Clean up logs for removed machine
|
|
setDeploymentLogs(prev => {
|
|
const { [machineId]: removed, ...rest } = prev
|
|
return rest
|
|
})
|
|
}
|
|
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case 'connected': return <CheckCircleIcon className="h-5 w-5 text-green-500" />
|
|
case 'failed': return <XCircleIcon className="h-5 w-5 text-red-500" />
|
|
case 'testing': return <ArrowPathIcon className="h-5 w-5 text-blue-500 animate-spin" />
|
|
case 'running': return <CheckCircleIcon className="h-5 w-5 text-green-500" />
|
|
case 'installing': return <ArrowPathIcon className="h-5 w-5 text-blue-500 animate-spin" />
|
|
case 'error': return <XCircleIcon className="h-5 w-5 text-red-500" />
|
|
case 'stopped': return <StopIcon className="h-5 w-5 text-yellow-500" />
|
|
default: return <ServerIcon className="h-5 w-5 text-gray-400" />
|
|
}
|
|
}
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
onComplete({
|
|
deployment: {
|
|
...config,
|
|
machines: machines.filter(m => m.selected).map(m => ({
|
|
id: m.id,
|
|
ip: m.ip,
|
|
hostname: m.hostname,
|
|
deployStatus: m.deployStatus
|
|
}))
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
|
|
{/* OS Support Caution */}
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
<div className="flex items-start">
|
|
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-600 mt-0.5 mr-3 flex-shrink-0" />
|
|
<div>
|
|
<h3 className="text-sm font-medium text-yellow-800">Operating System Support</h3>
|
|
<p className="text-sm text-yellow-700 mt-1">
|
|
CHORUS:agents automated deployment supports <strong>Linux distributions that use systemd by default</strong> (Ubuntu 16+, CentOS 7+, Debian 8+, RHEL 7+, etc.).
|
|
For other operating systems or init systems, you'll need to manually deploy the CHORUS:agents binary and configure services on your cluster.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Network Discovery */}
|
|
<div className="card">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-medium text-gray-900 flex items-center">
|
|
<ServerIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
|
Machine Discovery
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
onClick={discoverMachines}
|
|
disabled={isDiscovering}
|
|
className="btn-outline flex items-center"
|
|
>
|
|
<ArrowPathIcon className={`h-4 w-4 mr-2 ${isDiscovering ? 'animate-spin' : ''}`} />
|
|
{isDiscovering ? 'Discovering...' : 'Discover Machines'}
|
|
</button>
|
|
</div>
|
|
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
Scan network subnet: {configData?.network?.allowedIPs?.[0] || '192.168.1.0/24'}
|
|
</p>
|
|
|
|
{/* Discovery Progress */}
|
|
{isDiscovering && (
|
|
<div className="mb-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-gray-700">{discoveryStatus}</span>
|
|
<span className="text-sm text-gray-500">{discoveryProgress}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className="bg-bzzz-primary h-2 rounded-full transition-all duration-300 ease-out"
|
|
style={{ width: `${discoveryProgress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Machine Table */}
|
|
<div className="card">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-medium text-gray-900">Cluster Machines</h3>
|
|
<button
|
|
type="button"
|
|
onClick={deployToSelected}
|
|
disabled={machines.filter(m => m.selected && m.sshStatus === 'connected').length === 0}
|
|
className="btn-primary flex items-center"
|
|
>
|
|
<CloudArrowDownIcon className="h-4 w-4 mr-2" />
|
|
Deploy to Selected
|
|
</button>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Select
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Machine
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Operating System
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
IP Address
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
SSH Status
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Deploy Status
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Remove
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{machines.map((machine) => (
|
|
<tr key={machine.id} className={machine.selected ? 'bg-blue-50' : ''}>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<input
|
|
type="checkbox"
|
|
checked={machine.selected}
|
|
onChange={() => toggleMachineSelection(machine.id)}
|
|
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300 rounded"
|
|
/>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">{machine.hostname}</div>
|
|
{machine.systemInfo && (
|
|
<div className="text-xs text-gray-500">
|
|
{machine.systemInfo.cpu} cores • {machine.systemInfo.memory}GB RAM • {machine.systemInfo.disk}GB disk
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="text-sm text-gray-900">{machine.os}</div>
|
|
<div className="text-xs text-gray-500">{machine.osVersion}</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{machine.ip}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
{getStatusIcon(machine.sshStatus)}
|
|
<span className="ml-2 text-sm text-gray-900 capitalize">
|
|
{machine.sshStatus.replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
{getStatusIcon(machine.deployStatus)}
|
|
<div className="ml-2 flex-1">
|
|
<div className="text-sm text-gray-900 capitalize">
|
|
{machine.deployStatus.replace('_', ' ')}
|
|
</div>
|
|
{machine.deployStatus === 'installing' && (
|
|
<div className="mt-1">
|
|
<div className="text-xs text-gray-500 mb-1">
|
|
{machine.deployStep || 'Deploying...'}
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${machine.deployProgress || 0}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-gray-500 mt-1">
|
|
{machine.deployProgress || 0}%
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
|
{machine.id !== 'localhost' && machine.sshStatus !== 'connected' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => testSSHConnection(machine.id)}
|
|
className="text-blue-600 hover:text-blue-900"
|
|
disabled={machine.sshStatus === 'testing'}
|
|
>
|
|
Test SSH
|
|
</button>
|
|
)}
|
|
|
|
{machine.sshStatus === 'connected' && machine.deployStatus === 'not_deployed' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => deployToMachine(machine.id)}
|
|
className="text-green-600 hover:text-green-900"
|
|
>
|
|
Install
|
|
</button>
|
|
)}
|
|
|
|
{machine.deployStatus !== 'not_deployed' && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowLogs(machine.id)}
|
|
className="text-gray-600 hover:text-gray-900 mr-2"
|
|
title="View deployment logs"
|
|
>
|
|
<DocumentTextIcon className="h-4 w-4 inline" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowConsole(machine.id)}
|
|
className="text-blue-600 hover:text-blue-900"
|
|
title="Open deployment console"
|
|
>
|
|
<ComputerDesktopIcon className="h-4 w-4 inline" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
{machine.id !== 'localhost' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => removeMachine(machine.id)}
|
|
className="text-red-600 hover:text-red-900 p-1 rounded hover:bg-red-50"
|
|
title="Remove machine"
|
|
>
|
|
<XMarkIcon className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{machines.length === 0 && (
|
|
<div className="text-center py-8">
|
|
<ServerIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
|
<p className="text-gray-500">No machines discovered yet. Click "Discover Machines" to scan your network.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Deployment Configuration */}
|
|
<div className="card">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
|
<Cog6ToothIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
|
Deployment Configuration
|
|
</h3>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="flex items-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={config.autoStart}
|
|
onChange={(e) => setConfig(prev => ({ ...prev, autoStart: e.target.checked }))}
|
|
className="mr-2"
|
|
/>
|
|
Auto-start services after deployment
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Health Check Interval (seconds)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={config.healthCheckInterval}
|
|
onChange={(e) => setConfig(prev => ({ ...prev, healthCheckInterval: parseInt(e.target.value) }))}
|
|
min="10"
|
|
max="300"
|
|
className="input-field"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Logs Modal */}
|
|
{showLogs && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-96 overflow-auto">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h3 className="text-lg font-medium">Deployment Logs - {machines.find(m => m.id === showLogs)?.hostname}</h3>
|
|
<button onClick={() => setShowLogs(null)} className="text-gray-400 hover:text-gray-600">
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="bg-gray-900 text-green-400 p-4 rounded font-mono text-sm max-h-64 overflow-y-auto">
|
|
{deploymentLogs[showLogs]?.map((log, index) => (
|
|
<div key={index}>{log}</div>
|
|
)) || <div>No logs available</div>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Virtual Console Modal */}
|
|
{showConsole && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-gray-900 rounded-lg overflow-hidden max-w-4xl w-full max-h-[80vh] flex flex-col">
|
|
<div className="bg-gray-800 px-4 py-3 flex justify-between items-center border-b border-gray-700">
|
|
<div className="flex items-center">
|
|
<ComputerDesktopIcon className="h-5 w-5 text-green-400 mr-2" />
|
|
<h3 className="text-lg font-medium text-white">
|
|
SSH Console - {machines.find(m => m.id === showConsole)?.hostname}
|
|
</h3>
|
|
<span className="ml-2 text-sm text-gray-400">
|
|
({machines.find(m => m.id === showConsole)?.ip})
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="flex items-center space-x-1">
|
|
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
|
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowConsole(null)}
|
|
className="text-gray-400 hover:text-white ml-4"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex-1 p-4 font-mono text-sm overflow-y-auto bg-gray-900">
|
|
<div className="text-green-400 space-y-1">
|
|
{consoleLogs[showConsole]?.length > 0 ? (
|
|
consoleLogs[showConsole].map((log, index) => (
|
|
<div key={index} className="whitespace-pre-wrap">{log}</div>
|
|
))
|
|
) : (
|
|
<div className="text-gray-500">Waiting for deployment to start...</div>
|
|
)}
|
|
{/* Blinking cursor */}
|
|
<div className="inline-block w-2 h-4 bg-green-400 animate-pulse"></div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-800 px-4 py-2 border-t border-gray-700">
|
|
<div className="text-xs text-gray-400">
|
|
💡 This console shows real-time deployment progress and SSH operations
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-between pt-6 border-t border-gray-200">
|
|
<div>
|
|
{onBack && (
|
|
<button type="button" onClick={onBack} className="btn-outline">
|
|
Back
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button type="submit" className="btn-primary">
|
|
{isCompleted ? 'Continue' : 'Next: Cluster Formation'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)
|
|
} |