Files
bzzz/install/config-ui/app/setup/components/ServiceDeployment.tsx
anthonyrawlins be761cfe20 Enhance deployment system with retry functionality and improved UX
Major Improvements:
- Added retry deployment buttons in machine list for failed deployments
- Added retry button in SSH console modal footer for enhanced UX
- Enhanced deployment process with comprehensive cleanup of existing services
- Improved binary installation with password-based sudo authentication
- Updated configuration generation to include all required sections (agent, ai, network, security)
- Fixed deployment verification and error handling

Security Enhancements:
- Enhanced verifiedStopExistingServices with thorough cleanup process
- Improved binary copying with proper sudo authentication
- Added comprehensive configuration validation

UX Improvements:
- Users can retry deployments without re-running machine discovery
- Retry buttons available from both machine list and console modal
- Real-time deployment progress with detailed console output
- Clear error states with actionable retry options

Technical Changes:
- Modified ServiceDeployment.tsx with retry button components
- Enhanced api/setup_manager.go with improved deployment functions
- Updated main.go with command line argument support (--config, --setup)
- Added comprehensive zero-trust security validation system

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-31 10:23:27 +10:00

788 lines
30 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-eucalyptus-600" />
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-eucalyptus-600" />
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-eucalyptus-600 hover:text-eucalyptus-600"
>
Install
</button>
)}
{machine.sshStatus === 'connected' && machine.deployStatus === 'error' && (
<button
type="button"
onClick={() => deployToMachine(machine.id)}
className="text-amber-600 hover:text-amber-700 mr-2"
title="Retry deployment"
>
<ArrowPathIcon className="h-4 w-4 inline mr-1" />
Retry
</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-eucalyptus-600 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-eucalyptus-600 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-eucalyptus-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-eucalyptus-600 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 flex justify-between items-center">
<div className="text-xs text-gray-400">
💡 This console shows real-time deployment progress and SSH operations
</div>
{(() => {
const machine = machines.find(m => m.id === showConsole)
return machine?.sshStatus === 'connected' && machine?.deployStatus === 'error' && (
<button
type="button"
onClick={() => {
deployToMachine(showConsole!)
}}
className="ml-4 px-3 py-1 bg-amber-600 hover:bg-amber-700 text-white text-xs rounded-md flex items-center space-x-1 transition-colors"
title="Retry deployment"
>
<ArrowPathIcon className="h-3 w-3" />
<span>Retry Deployment</span>
</button>
)
})()}
</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>
)
}