Save current BZZZ config-ui state before CHORUS branding update
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import SetupPage from './setup/page'
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect to setup page
|
||||
router.push('/setup')
|
||||
}, [router])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-bzzz-primary mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Redirecting to setup...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
// Serve setup page directly at root to avoid redirect loops
|
||||
return <SetupPage />
|
||||
}
|
||||
@@ -1,6 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
CpuChipIcon,
|
||||
SparklesIcon,
|
||||
CurrencyDollarIcon,
|
||||
ServerIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
ArrowPathIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
interface GPUInfo {
|
||||
name: string
|
||||
memory: string
|
||||
type: string
|
||||
driver: string
|
||||
}
|
||||
|
||||
interface AIConfig {
|
||||
// OpenAI Configuration
|
||||
openaiEnabled: boolean
|
||||
openaiApiKey: string
|
||||
openaiOrganization: string
|
||||
openaiDefaultModel: string
|
||||
|
||||
// Cost Management
|
||||
dailyCostLimit: number
|
||||
monthlyCostLimit: number
|
||||
costAlerts: boolean
|
||||
|
||||
// Local AI (Ollama/Parallama)
|
||||
localAIEnabled: boolean
|
||||
localAIType: 'ollama' | 'parallama'
|
||||
localAIEndpoint: string
|
||||
localAIModels: string[]
|
||||
|
||||
// GPU Configuration
|
||||
gpuAcceleration: boolean
|
||||
preferredGPU: string
|
||||
maxGPUMemory: number
|
||||
|
||||
// Model Selection
|
||||
preferredProvider: 'openai' | 'local' | 'hybrid'
|
||||
fallbackEnabled: boolean
|
||||
}
|
||||
|
||||
interface AIConfigurationProps {
|
||||
systemInfo: any
|
||||
@@ -17,35 +64,535 @@ export default function AIConfiguration({
|
||||
onBack,
|
||||
isCompleted
|
||||
}: AIConfigurationProps) {
|
||||
const [config, setConfig] = useState({
|
||||
const [config, setConfig] = useState<AIConfig>({
|
||||
openaiEnabled: false,
|
||||
openaiApiKey: '',
|
||||
defaultModel: 'gpt-5',
|
||||
dailyCostLimit: 100,
|
||||
monthlyCostLimit: 1000,
|
||||
ollamaEnabled: true
|
||||
openaiOrganization: '',
|
||||
openaiDefaultModel: 'gpt-4',
|
||||
|
||||
dailyCostLimit: 50,
|
||||
monthlyCostLimit: 500,
|
||||
costAlerts: true,
|
||||
|
||||
localAIEnabled: true,
|
||||
localAIType: 'ollama',
|
||||
localAIEndpoint: 'http://localhost:11434',
|
||||
localAIModels: ['llama2', 'codellama'],
|
||||
|
||||
gpuAcceleration: false,
|
||||
preferredGPU: '',
|
||||
maxGPUMemory: 8,
|
||||
|
||||
preferredProvider: 'local',
|
||||
fallbackEnabled: true
|
||||
})
|
||||
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const [validatingOpenAI, setValidatingOpenAI] = useState(false)
|
||||
const [validatingLocal, setValidatingLocal] = useState(false)
|
||||
const [openaiValid, setOpenaiValid] = useState<boolean | null>(null)
|
||||
const [localAIValid, setLocalAIValid] = useState<boolean | null>(null)
|
||||
|
||||
// Initialize configuration from existing data
|
||||
useEffect(() => {
|
||||
if (configData.ai) {
|
||||
setConfig(prev => ({ ...prev, ...configData.ai }))
|
||||
}
|
||||
|
||||
// Auto-detect GPU capabilities
|
||||
if (systemInfo?.gpus?.length > 0) {
|
||||
const hasNVIDIA = systemInfo.gpus.some((gpu: GPUInfo) => gpu.type === 'nvidia')
|
||||
const hasAMD = systemInfo.gpus.some((gpu: GPUInfo) => gpu.type === 'amd')
|
||||
|
||||
if (hasNVIDIA) {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
gpuAcceleration: true,
|
||||
localAIType: 'parallama', // Parallama typically better for NVIDIA
|
||||
preferredGPU: systemInfo.gpus.find((gpu: GPUInfo) => gpu.type === 'nvidia')?.name || ''
|
||||
}))
|
||||
} else if (hasAMD) {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
gpuAcceleration: true,
|
||||
localAIType: 'ollama', // Ollama works well with AMD
|
||||
preferredGPU: systemInfo.gpus.find((gpu: GPUInfo) => gpu.type === 'amd')?.name || ''
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [systemInfo, configData])
|
||||
|
||||
const validateOpenAI = async () => {
|
||||
if (!config.openaiApiKey) {
|
||||
setOpenaiValid(false)
|
||||
return
|
||||
}
|
||||
|
||||
setValidatingOpenAI(true)
|
||||
try {
|
||||
// This would be a real API validation in production
|
||||
// For now, just simulate validation
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
setOpenaiValid(true)
|
||||
} catch (error) {
|
||||
setOpenaiValid(false)
|
||||
} finally {
|
||||
setValidatingOpenAI(false)
|
||||
}
|
||||
}
|
||||
|
||||
const validateLocalAI = async () => {
|
||||
if (!config.localAIEndpoint) {
|
||||
setLocalAIValid(false)
|
||||
return
|
||||
}
|
||||
|
||||
setValidatingLocal(true)
|
||||
try {
|
||||
const response = await fetch(`${config.localAIEndpoint}/api/tags`)
|
||||
setLocalAIValid(response.ok)
|
||||
} catch (error) {
|
||||
setLocalAIValid(false)
|
||||
} finally {
|
||||
setValidatingLocal(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getGPURecommendations = () => {
|
||||
if (!systemInfo?.gpus?.length) {
|
||||
return {
|
||||
recommendation: 'No GPU detected. CPU-only processing will be used.',
|
||||
type: 'info',
|
||||
details: 'Consider adding a GPU for better AI performance.'
|
||||
}
|
||||
}
|
||||
|
||||
const gpus = systemInfo.gpus
|
||||
const nvidiaGPUs = gpus.filter((gpu: GPUInfo) => gpu.type === 'nvidia')
|
||||
const amdGPUs = gpus.filter((gpu: GPUInfo) => gpu.type === 'amd')
|
||||
|
||||
if (nvidiaGPUs.length > 0) {
|
||||
return {
|
||||
recommendation: 'NVIDIA GPU detected - Parallama recommended for optimal performance',
|
||||
type: 'success',
|
||||
details: `${nvidiaGPUs[0].name} with ${nvidiaGPUs[0].memory} VRAM detected. Parallama provides excellent NVIDIA GPU acceleration.`
|
||||
}
|
||||
}
|
||||
|
||||
if (amdGPUs.length > 0) {
|
||||
return {
|
||||
recommendation: 'AMD GPU detected - Ollama with ROCm support recommended',
|
||||
type: 'warning',
|
||||
details: `${amdGPUs[0].name} detected. Ollama provides good AMD GPU support through ROCm.`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
recommendation: 'Integrated GPU detected - Limited AI acceleration available',
|
||||
type: 'warning',
|
||||
details: 'Integrated GPUs provide limited AI acceleration. Consider a dedicated GPU for better performance.'
|
||||
}
|
||||
}
|
||||
|
||||
const getRecommendedModels = () => {
|
||||
const memoryGB = systemInfo?.memory_mb ? Math.round(systemInfo.memory_mb / 1024) : 8
|
||||
|
||||
if (memoryGB >= 32) {
|
||||
return ['llama2:70b', 'codellama:34b', 'mixtral:8x7b']
|
||||
} else if (memoryGB >= 16) {
|
||||
return ['llama2:13b', 'codellama:13b', 'llama2:7b']
|
||||
} else {
|
||||
return ['llama2:7b', 'codellama:7b', 'phi2']
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Validate that at least one AI provider is configured
|
||||
if (!config.openaiEnabled && !config.localAIEnabled) {
|
||||
alert('Please enable at least one AI provider (OpenAI or Local AI)')
|
||||
return
|
||||
}
|
||||
|
||||
onComplete({ ai: config })
|
||||
}
|
||||
|
||||
const gpuRecommendation = getGPURecommendations()
|
||||
const recommendedModels = getRecommendedModels()
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
AI Integration
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Configure OpenAI API, Ollama/Parallama, and cost management settings.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-yellow-800">
|
||||
This component is under development. AI configuration will be implemented here.
|
||||
{/* GPU Detection & Recommendations */}
|
||||
{systemInfo?.gpus && (
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<CpuChipIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
GPU Configuration
|
||||
</h3>
|
||||
|
||||
<div className={`p-4 rounded-lg border mb-4 ${
|
||||
gpuRecommendation.type === 'success' ? 'bg-green-50 border-green-200' :
|
||||
gpuRecommendation.type === 'warning' ? 'bg-yellow-50 border-yellow-200' :
|
||||
'bg-blue-50 border-blue-200'
|
||||
}`}>
|
||||
<div className="flex items-start">
|
||||
<InformationCircleIcon className={`h-5 w-5 mt-0.5 mr-2 ${
|
||||
gpuRecommendation.type === 'success' ? 'text-green-600' :
|
||||
gpuRecommendation.type === 'warning' ? 'text-yellow-600' :
|
||||
'text-blue-600'
|
||||
}`} />
|
||||
<div>
|
||||
<div className={`font-medium ${
|
||||
gpuRecommendation.type === 'success' ? 'text-green-800' :
|
||||
gpuRecommendation.type === 'warning' ? 'text-yellow-800' :
|
||||
'text-blue-800'
|
||||
}`}>
|
||||
{gpuRecommendation.recommendation}
|
||||
</div>
|
||||
<div className={`text-sm mt-1 ${
|
||||
gpuRecommendation.type === 'success' ? 'text-green-700' :
|
||||
gpuRecommendation.type === 'warning' ? 'text-yellow-700' :
|
||||
'text-blue-700'
|
||||
}`}>
|
||||
{gpuRecommendation.details}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{systemInfo.gpus.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="gpuAcceleration"
|
||||
checked={config.gpuAcceleration}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, gpuAcceleration: e.target.checked }))}
|
||||
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="gpuAcceleration" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Enable GPU acceleration for AI processing
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{config.gpuAcceleration && (
|
||||
<div>
|
||||
<label className="label">Preferred GPU</label>
|
||||
<select
|
||||
value={config.preferredGPU}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, preferredGPU: e.target.value }))}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Auto-select</option>
|
||||
{systemInfo.gpus.map((gpu: GPUInfo, index: number) => (
|
||||
<option key={index} value={gpu.name}>
|
||||
{gpu.name} ({gpu.type.toUpperCase()}) - {gpu.memory}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local AI Configuration */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<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" />
|
||||
Local AI (Ollama/Parallama)
|
||||
</h3>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="localAIEnabled"
|
||||
checked={config.localAIEnabled}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, localAIEnabled: e.target.checked }))}
|
||||
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="localAIEnabled" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Enable Local AI
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.localAIEnabled && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Local AI Provider</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-all ${
|
||||
config.localAIType === 'ollama'
|
||||
? 'border-bzzz-primary bg-bzzz-primary bg-opacity-10'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => setConfig(prev => ({ ...prev, localAIType: 'ollama' }))}
|
||||
>
|
||||
<div className="font-medium text-gray-900">Ollama</div>
|
||||
<div className="text-sm text-gray-600">Open-source, self-hosted AI models</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Best for: AMD GPUs, CPU-only setups</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-all ${
|
||||
config.localAIType === 'parallama'
|
||||
? 'border-bzzz-primary bg-bzzz-primary bg-opacity-10'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => setConfig(prev => ({ ...prev, localAIType: 'parallama' }))}
|
||||
>
|
||||
<div className="font-medium text-gray-900">Parallama</div>
|
||||
<div className="text-sm text-gray-600">Optimized for parallel processing</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Best for: NVIDIA GPUs, high performance</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">API Endpoint</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="url"
|
||||
value={config.localAIEndpoint}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, localAIEndpoint: e.target.value }))}
|
||||
placeholder="http://localhost:11434"
|
||||
className="input-field flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={validateLocalAI}
|
||||
disabled={validatingLocal}
|
||||
className="btn-outline whitespace-nowrap"
|
||||
>
|
||||
{validatingLocal ? (
|
||||
<ArrowPathIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Test'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{localAIValid === true && (
|
||||
<div className="flex items-center mt-1 text-green-600 text-sm">
|
||||
<CheckCircleIcon className="h-4 w-4 mr-1" />
|
||||
Connection successful
|
||||
</div>
|
||||
)}
|
||||
{localAIValid === false && (
|
||||
<div className="flex items-center mt-1 text-red-600 text-sm">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 mr-1" />
|
||||
Connection failed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Recommended Models for your system</label>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-2">Based on your system memory ({Math.round(systemInfo?.memory_mb / 1024 || 8)} GB):</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recommendedModels.map((model, index) => (
|
||||
<span key={index} className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-xs">
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OpenAI Configuration */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 flex items-center">
|
||||
<SparklesIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
OpenAI API
|
||||
</h3>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="openaiEnabled"
|
||||
checked={config.openaiEnabled}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, openaiEnabled: e.target.checked }))}
|
||||
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="openaiEnabled" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Enable OpenAI API
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.openaiEnabled && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">API Key</label>
|
||||
<div className="flex space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={config.openaiApiKey}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, openaiApiKey: e.target.value }))}
|
||||
placeholder="sk-..."
|
||||
className="input-field pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={validateOpenAI}
|
||||
disabled={validatingOpenAI || !config.openaiApiKey}
|
||||
className="btn-outline whitespace-nowrap"
|
||||
>
|
||||
{validatingOpenAI ? (
|
||||
<ArrowPathIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
'Validate'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{openaiValid === true && (
|
||||
<div className="flex items-center mt-1 text-green-600 text-sm">
|
||||
<CheckCircleIcon className="h-4 w-4 mr-1" />
|
||||
API key valid
|
||||
</div>
|
||||
)}
|
||||
{openaiValid === false && (
|
||||
<div className="flex items-center mt-1 text-red-600 text-sm">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 mr-1" />
|
||||
Invalid API key
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Organization (Optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.openaiOrganization}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, openaiOrganization: e.target.value }))}
|
||||
placeholder="org-..."
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Default Model</label>
|
||||
<select
|
||||
value={config.openaiDefaultModel}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, openaiDefaultModel: e.target.value }))}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="gpt-4">GPT-4</option>
|
||||
<option value="gpt-4-turbo">GPT-4 Turbo</option>
|
||||
<option value="gpt-3.5-turbo">GPT-3.5 Turbo</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cost Management */}
|
||||
{config.openaiEnabled && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<CurrencyDollarIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
Cost Management
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Daily Cost Limit ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.dailyCostLimit}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, dailyCostLimit: parseFloat(e.target.value) || 0 }))}
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Monthly Cost Limit ($)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.monthlyCostLimit}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, monthlyCostLimit: parseFloat(e.target.value) || 0 }))}
|
||||
min="0"
|
||||
step="0.01"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="costAlerts"
|
||||
checked={config.costAlerts}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, costAlerts: e.target.checked }))}
|
||||
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="costAlerts" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Send alerts when approaching cost limits
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider Preference */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Provider Preference</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="label">Preferred AI Provider</label>
|
||||
<select
|
||||
value={config.preferredProvider}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, preferredProvider: e.target.value as 'openai' | 'local' | 'hybrid' }))}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="local">Local AI Only</option>
|
||||
<option value="openai">OpenAI Only</option>
|
||||
<option value="hybrid">Hybrid (Local first, OpenAI fallback)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="fallbackEnabled"
|
||||
checked={config.fallbackEnabled}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, fallbackEnabled: e.target.checked }))}
|
||||
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="fallbackEnabled" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Enable automatic fallback between providers
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between pt-6 border-t border-gray-200">
|
||||
<div>
|
||||
{onBack && (
|
||||
@@ -54,7 +601,12 @@ export default function AIConfiguration({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn-primary">
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!config.openaiEnabled && !config.localAIEnabled}
|
||||
>
|
||||
{isCompleted ? 'Continue' : 'Next: Resource Allocation'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
ServerStackIcon,
|
||||
PlusIcon,
|
||||
MagnifyingGlassIcon,
|
||||
WifiIcon,
|
||||
ComputerDesktopIcon,
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
UserGroupIcon,
|
||||
KeyIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
interface DiscoveredNode {
|
||||
id: string
|
||||
hostname: string
|
||||
ip: string
|
||||
port: number
|
||||
version: string
|
||||
capabilities: string[]
|
||||
status: 'online' | 'offline' | 'pending'
|
||||
lastSeen: Date
|
||||
}
|
||||
|
||||
interface ClusterConfig {
|
||||
mode: 'create' | 'join'
|
||||
networkId: string
|
||||
clusterName: string
|
||||
nodeRole: 'coordinator' | 'worker' | 'hybrid'
|
||||
joinKey?: string
|
||||
targetNode?: string
|
||||
autoDiscovery: boolean
|
||||
encryption: boolean
|
||||
redundancy: number
|
||||
}
|
||||
|
||||
interface ClusterFormationProps {
|
||||
systemInfo: any
|
||||
@@ -17,32 +53,480 @@ export default function ClusterFormation({
|
||||
onBack,
|
||||
isCompleted
|
||||
}: ClusterFormationProps) {
|
||||
const [config, setConfig] = useState({
|
||||
clusterMode: 'create',
|
||||
networkId: 'bzzz-cluster-001'
|
||||
const [config, setConfig] = useState<ClusterConfig>({
|
||||
mode: 'create',
|
||||
networkId: '',
|
||||
clusterName: '',
|
||||
nodeRole: 'hybrid',
|
||||
autoDiscovery: true,
|
||||
encryption: true,
|
||||
redundancy: 2
|
||||
})
|
||||
|
||||
const [discoveredNodes, setDiscoveredNodes] = useState<DiscoveredNode[]>([])
|
||||
const [scanning, setScanning] = useState(false)
|
||||
const [generatingKey, setGeneratingKey] = useState(false)
|
||||
const [clusterKey, setClusterKey] = useState('')
|
||||
|
||||
// Initialize configuration
|
||||
useEffect(() => {
|
||||
if (configData.cluster) {
|
||||
setConfig(prev => ({ ...prev, ...configData.cluster }))
|
||||
}
|
||||
|
||||
// Generate default network ID based on hostname
|
||||
if (!config.networkId && systemInfo?.network?.hostname) {
|
||||
const hostname = systemInfo.network.hostname
|
||||
const timestamp = Date.now().toString(36).slice(-4)
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
networkId: `bzzz-${hostname}-${timestamp}`,
|
||||
clusterName: `${hostname} BZZZ Cluster`
|
||||
}))
|
||||
}
|
||||
}, [systemInfo, configData])
|
||||
|
||||
// Auto-discover nodes when joining
|
||||
useEffect(() => {
|
||||
if (config.mode === 'join' && config.autoDiscovery) {
|
||||
scanForNodes()
|
||||
}
|
||||
}, [config.mode, config.autoDiscovery])
|
||||
|
||||
const scanForNodes = async () => {
|
||||
setScanning(true)
|
||||
try {
|
||||
// This would be a real mDNS/network scan in production
|
||||
// Simulating discovery for demo
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
const mockNodes: DiscoveredNode[] = [
|
||||
{
|
||||
id: 'node-001',
|
||||
hostname: 'ironwood',
|
||||
ip: '192.168.1.72',
|
||||
port: 8080,
|
||||
version: '2.0.0',
|
||||
capabilities: ['coordinator', 'storage', 'compute'],
|
||||
status: 'online',
|
||||
lastSeen: new Date()
|
||||
},
|
||||
{
|
||||
id: 'node-002',
|
||||
hostname: 'walnut',
|
||||
ip: '192.168.1.27',
|
||||
port: 8080,
|
||||
version: '2.0.0',
|
||||
capabilities: ['worker', 'compute'],
|
||||
status: 'online',
|
||||
lastSeen: new Date()
|
||||
}
|
||||
]
|
||||
|
||||
setDiscoveredNodes(mockNodes)
|
||||
} catch (error) {
|
||||
console.error('Node discovery failed:', error)
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const generateClusterKey = async () => {
|
||||
setGeneratingKey(true)
|
||||
try {
|
||||
// Generate a secure cluster key
|
||||
const key = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
setClusterKey(key)
|
||||
} catch (error) {
|
||||
// Fallback key generation
|
||||
const key = Math.random().toString(36).substr(2, 32)
|
||||
setClusterKey(key)
|
||||
} finally {
|
||||
setGeneratingKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getNodeRoleDescription = (role: string) => {
|
||||
switch (role) {
|
||||
case 'coordinator':
|
||||
return 'Manages cluster state and coordinates tasks. Requires stable network connection.'
|
||||
case 'worker':
|
||||
return 'Executes tasks assigned by coordinators. Can be dynamically added/removed.'
|
||||
case 'hybrid':
|
||||
return 'Can act as both coordinator and worker. Recommended for most deployments.'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getSystemRecommendation = () => {
|
||||
const memoryGB = systemInfo?.memory_mb ? Math.round(systemInfo.memory_mb / 1024) : 8
|
||||
const cpuCores = systemInfo?.cpu_cores || 4
|
||||
const hasGPU = systemInfo?.gpus?.length > 0
|
||||
|
||||
if (memoryGB >= 16 && cpuCores >= 8) {
|
||||
return {
|
||||
role: 'coordinator',
|
||||
reason: 'High-performance system suitable for cluster coordination'
|
||||
}
|
||||
} else if (hasGPU) {
|
||||
return {
|
||||
role: 'hybrid',
|
||||
reason: 'GPU acceleration available - good for both coordination and compute tasks'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
role: 'worker',
|
||||
reason: 'Resource-optimized configuration for task execution'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onComplete({ cluster: config })
|
||||
|
||||
const clusterData = {
|
||||
...config,
|
||||
clusterKey: config.mode === 'create' ? clusterKey : undefined,
|
||||
systemInfo: {
|
||||
hostname: systemInfo?.network?.hostname,
|
||||
ip: systemInfo?.network?.private_ips?.[0],
|
||||
capabilities: systemInfo?.gpus?.length > 0 ? ['compute', 'gpu'] : ['compute']
|
||||
}
|
||||
}
|
||||
|
||||
onComplete({ cluster: clusterData })
|
||||
}
|
||||
|
||||
const recommendation = getSystemRecommendation()
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Cluster Formation
|
||||
{/* Cluster Mode Selection */}
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<ServerStackIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
Cluster Mode
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Create a new cluster or join an existing BZZZ network.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-yellow-800">
|
||||
This component is under development. Cluster formation will be implemented here.
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-all ${
|
||||
config.mode === 'create'
|
||||
? 'border-bzzz-primary bg-bzzz-primary bg-opacity-10'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => setConfig(prev => ({ ...prev, mode: 'create' }))}
|
||||
>
|
||||
<div className="flex items-center mb-2">
|
||||
<PlusIcon className="h-5 w-5 text-bzzz-primary mr-2" />
|
||||
<div className="font-medium text-gray-900">Create New Cluster</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Start a new BZZZ cluster and become the initial coordinator node.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-all ${
|
||||
config.mode === 'join'
|
||||
? 'border-bzzz-primary bg-bzzz-primary bg-opacity-10'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => setConfig(prev => ({ ...prev, mode: 'join' }))}
|
||||
>
|
||||
<div className="flex items-center mb-2">
|
||||
<UserGroupIcon className="h-5 w-5 text-bzzz-primary mr-2" />
|
||||
<div className="font-medium text-gray-900">Join Existing Cluster</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Connect to an existing BZZZ cluster as a worker or coordinator node.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Cluster Configuration */}
|
||||
{config.mode === 'create' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">New Cluster Configuration</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="label">Cluster Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.clusterName}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, clusterName: e.target.value }))}
|
||||
placeholder="My BZZZ Cluster"
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Network ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.networkId}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, networkId: e.target.value }))}
|
||||
placeholder="bzzz-cluster-001"
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Unique identifier for your cluster network
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Cluster Security Key</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={clusterKey}
|
||||
onChange={(e) => setClusterKey(e.target.value)}
|
||||
placeholder="Click generate or enter custom key"
|
||||
className="input-field flex-1"
|
||||
readOnly={!clusterKey}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateClusterKey}
|
||||
disabled={generatingKey}
|
||||
className="btn-outline whitespace-nowrap"
|
||||
>
|
||||
{generatingKey ? (
|
||||
<ArrowPathIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<KeyIcon className="h-4 w-4 mr-1" />
|
||||
Generate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
This key will be required for other nodes to join your cluster
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Join Cluster Configuration */}
|
||||
{config.mode === 'join' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Available Clusters</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={scanForNodes}
|
||||
disabled={scanning}
|
||||
className="btn-outline text-sm"
|
||||
>
|
||||
{scanning ? (
|
||||
<>
|
||||
<ArrowPathIcon className="h-4 w-4 animate-spin mr-1" />
|
||||
Scanning...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MagnifyingGlassIcon className="h-4 w-4 mr-1" />
|
||||
Scan Network
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{discoveredNodes.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{discoveredNodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={`border rounded-lg p-4 cursor-pointer transition-all ${
|
||||
config.targetNode === node.id
|
||||
? 'border-bzzz-primary bg-bzzz-primary bg-opacity-10'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => setConfig(prev => ({ ...prev, targetNode: node.id }))}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<ComputerDesktopIcon className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<span className="font-medium text-gray-900">{node.hostname}</span>
|
||||
<span className={`ml-2 status-indicator ${
|
||||
node.status === 'online' ? 'status-online' : 'status-offline'
|
||||
}`}>
|
||||
{node.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{node.ip}:{node.port} • Version {node.version}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{node.capabilities.map((cap, index) => (
|
||||
<span key={index} className="bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs">
|
||||
{cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<WifiIcon className="h-5 w-5 text-bzzz-primary" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<MagnifyingGlassIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">
|
||||
{scanning ? 'Scanning for BZZZ clusters...' : 'No clusters found. Click scan to search for available clusters.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.targetNode && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<label className="label">Cluster Join Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={config.joinKey || ''}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, joinKey: e.target.value }))}
|
||||
placeholder="Enter cluster security key"
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Enter the security key provided by the cluster administrator
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Role Configuration */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Node Role</h3>
|
||||
|
||||
{/* System Recommendation */}
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-600 mr-2 mt-0.5" />
|
||||
<div>
|
||||
<div className="font-medium text-blue-800">
|
||||
Recommended: {recommendation.role.charAt(0).toUpperCase() + recommendation.role.slice(1)}
|
||||
</div>
|
||||
<div className="text-sm text-blue-700 mt-1">
|
||||
{recommendation.reason}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{['coordinator', 'worker', 'hybrid'].map((role) => (
|
||||
<div
|
||||
key={role}
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-all ${
|
||||
config.nodeRole === role
|
||||
? 'border-bzzz-primary bg-bzzz-primary bg-opacity-10'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => setConfig(prev => ({ ...prev, nodeRole: role as any }))}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="nodeRole"
|
||||
value={role}
|
||||
checked={config.nodeRole === role}
|
||||
onChange={() => setConfig(prev => ({ ...prev, nodeRole: role as any }))}
|
||||
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<div className="font-medium text-gray-900 capitalize">{role}</div>
|
||||
<div className="text-sm text-gray-600">{getNodeRoleDescription(role)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Advanced Options</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoDiscovery"
|
||||
checked={config.autoDiscovery}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, autoDiscovery: e.target.checked }))}
|
||||
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="autoDiscovery" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Enable automatic node discovery (mDNS)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="encryption"
|
||||
checked={config.encryption}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, encryption: e.target.checked }))}
|
||||
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="encryption" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Enable end-to-end encryption for cluster communication
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Redundancy Level</label>
|
||||
<select
|
||||
value={config.redundancy}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, redundancy: parseInt(e.target.value) }))}
|
||||
className="input-field"
|
||||
>
|
||||
<option value={1}>Low (1 replica)</option>
|
||||
<option value={2}>Medium (2 replicas)</option>
|
||||
<option value={3}>High (3 replicas)</option>
|
||||
</select>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Number of replicas for critical cluster data
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Summary */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<CheckCircleIcon className="h-5 w-5 text-blue-600 mr-2" />
|
||||
<span className="text-blue-800 font-medium">Configuration Summary</span>
|
||||
</div>
|
||||
<div className="text-blue-700 text-sm space-y-1">
|
||||
<p>• Mode: {config.mode === 'create' ? 'Create new cluster' : 'Join existing cluster'}</p>
|
||||
<p>• Role: {config.nodeRole}</p>
|
||||
<p>• Hostname: {systemInfo?.network?.hostname || 'Unknown'}</p>
|
||||
<p>• IP Address: {systemInfo?.network?.private_ips?.[0] || 'Unknown'}</p>
|
||||
{config.mode === 'create' && <p>• Cluster: {config.clusterName}</p>}
|
||||
{config.encryption && <p>• Security: Encrypted communication enabled</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between pt-6 border-t border-gray-200">
|
||||
<div>
|
||||
{onBack && (
|
||||
@@ -51,7 +535,15 @@ export default function ClusterFormation({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn-primary">
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
(config.mode === 'create' && (!config.clusterName || !config.networkId || !clusterKey)) ||
|
||||
(config.mode === 'join' && (!config.targetNode || !config.joinKey))
|
||||
}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isCompleted ? 'Continue' : 'Next: Testing & Validation'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
301
install/config-ui/app/setup/components/LicenseValidation.tsx
Normal file
301
install/config-ui/app/setup/components/LicenseValidation.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
KeyIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
UserIcon,
|
||||
DocumentTextIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
interface LicenseValidationProps {
|
||||
systemInfo: any
|
||||
configData: any
|
||||
onComplete: (data: any) => void
|
||||
onBack?: () => void
|
||||
isCompleted: boolean
|
||||
}
|
||||
|
||||
interface LicenseData {
|
||||
email: string
|
||||
licenseKey: string
|
||||
organizationName?: string
|
||||
acceptedAt?: string
|
||||
}
|
||||
|
||||
export default function LicenseValidation({
|
||||
systemInfo,
|
||||
configData,
|
||||
onComplete,
|
||||
onBack,
|
||||
isCompleted
|
||||
}: LicenseValidationProps) {
|
||||
const [licenseData, setLicenseData] = useState<LicenseData>({
|
||||
email: configData?.license?.email || '',
|
||||
licenseKey: configData?.license?.licenseKey || '',
|
||||
organizationName: configData?.license?.organizationName || ''
|
||||
})
|
||||
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
valid: boolean
|
||||
message: string
|
||||
details?: any
|
||||
} | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Email validation function
|
||||
const isValidEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
// Check if form is ready for validation
|
||||
const canValidate = licenseData.email &&
|
||||
isValidEmail(licenseData.email) &&
|
||||
licenseData.licenseKey
|
||||
|
||||
const validateLicense = async () => {
|
||||
if (!licenseData.email || !licenseData.licenseKey) {
|
||||
setError('Both email and license key are required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidEmail(licenseData.email)) {
|
||||
setError('Please enter a valid email address')
|
||||
return
|
||||
}
|
||||
|
||||
setValidating(true)
|
||||
setError('')
|
||||
setValidationResult(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/setup/license/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: licenseData.email,
|
||||
licenseKey: licenseData.licenseKey,
|
||||
organizationName: licenseData.organizationName
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (response.ok && result.valid) {
|
||||
setValidationResult({
|
||||
valid: true,
|
||||
message: result.message || 'License validated successfully',
|
||||
details: result.details
|
||||
})
|
||||
} else {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
message: result.message || 'License validation failed',
|
||||
details: result.details
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('License validation error:', error)
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
message: 'Failed to validate license. Please check your connection and try again.'
|
||||
})
|
||||
} finally {
|
||||
setValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!licenseData.email || !licenseData.licenseKey) {
|
||||
setError('Both email and license key are required')
|
||||
return
|
||||
}
|
||||
|
||||
if (!validationResult?.valid) {
|
||||
setError('Please validate your license before continuing')
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
onComplete({
|
||||
license: {
|
||||
...licenseData,
|
||||
validatedAt: new Date().toISOString(),
|
||||
validationDetails: validationResult.details
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
|
||||
{/* License Information */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<KeyIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">License Information</h3>
|
||||
{validationResult?.valid && <CheckCircleIcon className="h-5 w-5 text-green-500 ml-2" />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<UserIcon className="h-5 w-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<input
|
||||
type="email"
|
||||
value={licenseData.email}
|
||||
onChange={(e) => setLicenseData(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="your-email@company.com"
|
||||
className={`w-full pl-10 pr-4 py-3 border rounded-lg focus:ring-bzzz-primary focus:border-bzzz-primary ${
|
||||
licenseData.email && !isValidEmail(licenseData.email)
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{licenseData.email && !isValidEmail(licenseData.email) ? (
|
||||
<p className="text-sm text-red-600 mt-1">Please enter a valid email address</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
The email address associated with your CHORUS:agents license
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
License Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<KeyIcon className="h-5 w-5 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={licenseData.licenseKey}
|
||||
onChange={(e) => setLicenseData(prev => ({ ...prev, licenseKey: e.target.value }))}
|
||||
placeholder="BZZZ-XXXX-XXXX-XXXX-XXXX"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-bzzz-primary focus:border-bzzz-primary font-mono"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Your unique CHORUS:agents license key (found in your purchase confirmation email)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Organization Name (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={licenseData.organizationName}
|
||||
onChange={(e) => setLicenseData(prev => ({ ...prev, organizationName: e.target.value }))}
|
||||
placeholder="Your Company Name"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-bzzz-primary focus:border-bzzz-primary"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Optional: Organization name for license tracking
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={validateLicense}
|
||||
disabled={validating || !canValidate}
|
||||
className={`w-full py-3 px-4 rounded-lg font-medium transition-colors ${
|
||||
validating || !canValidate
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-bzzz-primary text-white hover:bg-bzzz-primary-dark'
|
||||
}`}
|
||||
>
|
||||
{validating ? 'Validating License...' : 'Validate License'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Result */}
|
||||
{validationResult && (
|
||||
<div className={`card ${validationResult.valid ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}`}>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
{validationResult.valid ? (
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
||||
) : (
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className={`text-sm font-medium ${validationResult.valid ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{validationResult.valid ? 'License Valid' : 'License Invalid'}
|
||||
</h4>
|
||||
<p className={`text-sm mt-1 ${validationResult.valid ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{validationResult.message}
|
||||
</p>
|
||||
|
||||
{validationResult.valid && validationResult.details && (
|
||||
<div className="mt-3 text-sm text-green-700">
|
||||
<p><strong>License Type:</strong> {validationResult.details.licenseType || 'Standard'}</p>
|
||||
<p><strong>Max Nodes:</strong> {validationResult.details.maxNodes || 'Unlimited'}</p>
|
||||
<p><strong>Expires:</strong> {validationResult.details.expiresAt || 'Never'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center text-red-600 text-sm">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 mr-1" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License Information */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<DocumentTextIcon className="h-5 w-5 text-blue-500 mt-0.5 mr-2" />
|
||||
<div className="text-sm">
|
||||
<h4 className="font-medium text-blue-800 mb-1">Need a License?</h4>
|
||||
<p className="text-blue-700">
|
||||
If you don't have a CHORUS:agents license yet, you can:
|
||||
</p>
|
||||
<ul className="text-blue-700 mt-1 space-y-1 ml-4">
|
||||
<li>• Visit <a href="https://chorus.services/bzzz" target="_blank" className="underline hover:no-underline">chorus.services/bzzz</a> to purchase a license</li>
|
||||
<li>• Contact our sales team at <a href="mailto:sales@chorus.services" className="underline hover:no-underline">sales@chorus.services</a></li>
|
||||
<li>• Request a trial license for evaluation purposes</li>
|
||||
</ul>
|
||||
</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"
|
||||
disabled={!validationResult?.valid}
|
||||
className={`${validationResult?.valid ? 'btn-primary' : 'btn-disabled'}`}
|
||||
>
|
||||
{isCompleted ? 'Continue' : 'Next: System Detection'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
GlobeAltIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
InformationCircleIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
interface NetworkInterface {
|
||||
name: string
|
||||
ip: string
|
||||
status: string
|
||||
speed?: string
|
||||
}
|
||||
|
||||
interface NetworkConfig {
|
||||
primaryInterface: string
|
||||
primaryIP: string
|
||||
bzzzPort: number
|
||||
mcpPort: number
|
||||
webUIPort: number
|
||||
p2pPort: number
|
||||
autoFirewall: boolean
|
||||
allowedIPs: string[]
|
||||
dnsServers: string[]
|
||||
}
|
||||
|
||||
interface NetworkConfigurationProps {
|
||||
systemInfo: any
|
||||
@@ -17,36 +44,354 @@ export default function NetworkConfiguration({
|
||||
onBack,
|
||||
isCompleted
|
||||
}: NetworkConfigurationProps) {
|
||||
const [config, setConfig] = useState({
|
||||
subnet: '192.168.1.0/24',
|
||||
primaryInterface: 'eth0',
|
||||
const [config, setConfig] = useState<NetworkConfig>({
|
||||
primaryInterface: '',
|
||||
primaryIP: '',
|
||||
bzzzPort: 8080,
|
||||
mcpPort: 3000,
|
||||
webUIPort: 8080,
|
||||
autoFirewall: true
|
||||
p2pPort: 7000,
|
||||
autoFirewall: true,
|
||||
allowedIPs: ['192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12'],
|
||||
dnsServers: ['8.8.8.8', '8.8.4.4']
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState<string[]>([])
|
||||
const [portConflicts, setPortConflicts] = useState<string[]>([])
|
||||
|
||||
// Initialize with system info and existing config
|
||||
useEffect(() => {
|
||||
if (systemInfo?.network) {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
primaryInterface: systemInfo.network.interfaces?.[0] || prev.primaryInterface,
|
||||
primaryIP: systemInfo.network.private_ips?.[0] || prev.primaryIP
|
||||
}))
|
||||
}
|
||||
|
||||
if (configData.network) {
|
||||
setConfig(prev => ({ ...prev, ...configData.network }))
|
||||
}
|
||||
}, [systemInfo, configData])
|
||||
|
||||
// Validate configuration
|
||||
useEffect(() => {
|
||||
validateConfiguration()
|
||||
}, [config])
|
||||
|
||||
const validateConfiguration = () => {
|
||||
const newErrors: string[] = []
|
||||
const conflicts: string[] = []
|
||||
|
||||
// Check for port conflicts
|
||||
const ports = [config.bzzzPort, config.mcpPort, config.webUIPort, config.p2pPort]
|
||||
const uniquePorts = new Set(ports)
|
||||
if (uniquePorts.size !== ports.length) {
|
||||
conflicts.push('Port numbers must be unique')
|
||||
}
|
||||
|
||||
// Check port ranges
|
||||
ports.forEach((port, index) => {
|
||||
const portNames = ['BZZZ API', 'MCP Server', 'Web UI', 'P2P Network']
|
||||
if (port < 1024) {
|
||||
newErrors.push(`${portNames[index]} port should be above 1024 to avoid requiring root privileges`)
|
||||
}
|
||||
if (port > 65535) {
|
||||
newErrors.push(`${portNames[index]} port must be below 65536`)
|
||||
}
|
||||
})
|
||||
|
||||
// Validate IP addresses in allowed IPs
|
||||
config.allowedIPs.forEach(ip => {
|
||||
if (ip && !isValidCIDR(ip)) {
|
||||
newErrors.push(`Invalid CIDR notation: ${ip}`)
|
||||
}
|
||||
})
|
||||
|
||||
// Validate DNS servers
|
||||
config.dnsServers.forEach(dns => {
|
||||
if (dns && !isValidIPAddress(dns)) {
|
||||
newErrors.push(`Invalid DNS server IP: ${dns}`)
|
||||
}
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
setPortConflicts(conflicts)
|
||||
}
|
||||
|
||||
const isValidCIDR = (cidr: string): boolean => {
|
||||
const regex = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/
|
||||
return regex.test(cidr)
|
||||
}
|
||||
|
||||
const isValidIPAddress = (ip: string): boolean => {
|
||||
const regex = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||
if (!regex.test(ip)) return false
|
||||
return ip.split('.').every(part => parseInt(part) >= 0 && parseInt(part) <= 255)
|
||||
}
|
||||
|
||||
const handlePortChange = (field: keyof NetworkConfig, value: string) => {
|
||||
const numValue = parseInt(value) || 0
|
||||
setConfig(prev => ({ ...prev, [field]: numValue }))
|
||||
}
|
||||
|
||||
const handleArrayChange = (field: 'allowedIPs' | 'dnsServers', index: number, value: string) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[field]: prev[field].map((item, i) => i === index ? value : item)
|
||||
}))
|
||||
}
|
||||
|
||||
const addArrayItem = (field: 'allowedIPs' | 'dnsServers') => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[field]: [...prev[field], '']
|
||||
}))
|
||||
}
|
||||
|
||||
const removeArrayItem = (field: 'allowedIPs' | 'dnsServers', index: number) => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
[field]: prev[field].filter((_, i) => i !== index)
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onComplete({ network: config })
|
||||
|
||||
if (errors.length === 0 && portConflicts.length === 0) {
|
||||
onComplete({ network: config })
|
||||
}
|
||||
}
|
||||
|
||||
const isFormValid = errors.length === 0 && portConflicts.length === 0
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Network Configuration
|
||||
{/* Network Interface Selection */}
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<GlobeAltIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
Network Interface
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Configure your cluster's network settings and firewall rules.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-yellow-800">
|
||||
This component is under development. Network configuration will be implemented here.
|
||||
|
||||
{systemInfo?.network?.interfaces && (
|
||||
<div className="space-y-3">
|
||||
<label className="label">Primary Network Interface</label>
|
||||
<select
|
||||
value={config.primaryInterface}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, primaryInterface: e.target.value }))}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="">Select network interface</option>
|
||||
{systemInfo.network.interfaces.map((interfaceName: string, index: number) => (
|
||||
<option key={index} value={interfaceName}>
|
||||
{interfaceName} - {systemInfo.network.private_ips[index] || 'Unknown IP'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{config.primaryInterface && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Primary IP: {systemInfo.network.private_ips?.[systemInfo.network.interfaces.indexOf(config.primaryInterface)] || 'Unknown'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Port Configuration */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<ServerIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
Port Configuration
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">BZZZ API Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.bzzzPort}
|
||||
onChange={(e) => handlePortChange('bzzzPort', e.target.value)}
|
||||
min="1024"
|
||||
max="65535"
|
||||
className="input-field"
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-1">Main BZZZ HTTP API endpoint</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">MCP Server Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.mcpPort}
|
||||
onChange={(e) => handlePortChange('mcpPort', e.target.value)}
|
||||
min="1024"
|
||||
max="65535"
|
||||
className="input-field"
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-1">Model Context Protocol server</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Web UI Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.webUIPort}
|
||||
onChange={(e) => handlePortChange('webUIPort', e.target.value)}
|
||||
min="1024"
|
||||
max="65535"
|
||||
className="input-field"
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-1">Web interface port</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">P2P Network Port</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.p2pPort}
|
||||
onChange={(e) => handlePortChange('p2pPort', e.target.value)}
|
||||
min="1024"
|
||||
max="65535"
|
||||
className="input-field"
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-1">Peer-to-peer communication</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{portConflicts.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 mr-2" />
|
||||
<span className="text-red-800 font-medium">Port Conflicts</span>
|
||||
</div>
|
||||
{portConflicts.map((conflict, index) => (
|
||||
<p key={index} className="text-red-700 text-sm mt-1">{conflict}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security & Access Control */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<ShieldCheckIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
Security & Access Control
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoFirewall"
|
||||
checked={config.autoFirewall}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, autoFirewall: e.target.checked }))}
|
||||
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="autoFirewall" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Automatically configure firewall rules
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Allowed IP Ranges (CIDR)</label>
|
||||
{config.allowedIPs.map((ip, index) => (
|
||||
<div key={index} className="flex items-center space-x-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={ip}
|
||||
onChange={(e) => handleArrayChange('allowedIPs', index, e.target.value)}
|
||||
placeholder="192.168.1.0/24"
|
||||
className="input-field flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeArrayItem('allowedIPs', index)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addArrayItem('allowedIPs')}
|
||||
className="text-bzzz-primary hover:text-bzzz-primary/80 text-sm"
|
||||
>
|
||||
+ Add IP Range
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DNS Configuration */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">DNS Configuration</h3>
|
||||
|
||||
<div>
|
||||
<label className="label">DNS Servers</label>
|
||||
{config.dnsServers.map((dns, index) => (
|
||||
<div key={index} className="flex items-center space-x-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={dns}
|
||||
onChange={(e) => handleArrayChange('dnsServers', index, e.target.value)}
|
||||
placeholder="8.8.8.8"
|
||||
className="input-field flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeArrayItem('dnsServers', index)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addArrayItem('dnsServers')}
|
||||
className="text-bzzz-primary hover:text-bzzz-primary/80 text-sm"
|
||||
>
|
||||
+ Add DNS Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Errors */}
|
||||
{errors.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 mr-2" />
|
||||
<span className="text-red-800 font-medium">Configuration Issues</span>
|
||||
</div>
|
||||
{errors.map((error, index) => (
|
||||
<p key={index} className="text-red-700 text-sm">{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration Summary */}
|
||||
{isFormValid && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-600 mr-2" />
|
||||
<span className="text-blue-800 font-medium">Configuration Summary</span>
|
||||
</div>
|
||||
<div className="text-blue-700 text-sm space-y-1">
|
||||
<p>• Primary interface: {config.primaryInterface}</p>
|
||||
<p>• BZZZ API will be available on port {config.bzzzPort}</p>
|
||||
<p>• MCP server will run on port {config.mcpPort}</p>
|
||||
<p>• Web UI will be accessible on port {config.webUIPort}</p>
|
||||
<p>• P2P network will use port {config.p2pPort}</p>
|
||||
{config.autoFirewall && <p>• Firewall rules will be configured automatically</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-between pt-6 border-t border-gray-200">
|
||||
<div>
|
||||
{onBack && (
|
||||
@@ -55,7 +400,12 @@ export default function NetworkConfiguration({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn-primary">
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isFormValid}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isCompleted ? 'Continue' : 'Next: Security Setup'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
CodeBracketIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ArrowPathIcon,
|
||||
ExclamationTriangleIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
interface RepositoryProvider {
|
||||
name: string
|
||||
displayName: string
|
||||
description: string
|
||||
requiresBaseURL: boolean
|
||||
defaultBaseURL?: string
|
||||
}
|
||||
|
||||
interface RepositoryConfig {
|
||||
provider: string
|
||||
baseURL: string
|
||||
accessToken: string
|
||||
owner: string
|
||||
repository: string
|
||||
}
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface RepositoryConfigurationProps {
|
||||
systemInfo: any
|
||||
configData: any
|
||||
onComplete: (data: any) => void
|
||||
onBack?: () => void
|
||||
isCompleted: boolean
|
||||
}
|
||||
|
||||
export default function RepositoryConfiguration({
|
||||
systemInfo,
|
||||
configData,
|
||||
onComplete,
|
||||
onBack,
|
||||
isCompleted
|
||||
}: RepositoryConfigurationProps) {
|
||||
const [providers, setProviders] = useState<RepositoryProvider[]>([])
|
||||
const [config, setConfig] = useState<RepositoryConfig>({
|
||||
provider: '',
|
||||
baseURL: '',
|
||||
accessToken: '',
|
||||
owner: '',
|
||||
repository: ''
|
||||
})
|
||||
const [validation, setValidation] = useState<ValidationResult | null>(null)
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [showToken, setShowToken] = useState(false)
|
||||
const [loadingProviders, setLoadingProviders] = useState(true)
|
||||
|
||||
// Load existing config from configData if available
|
||||
useEffect(() => {
|
||||
if (configData.repository) {
|
||||
setConfig({ ...configData.repository })
|
||||
}
|
||||
}, [configData])
|
||||
|
||||
// Load supported providers
|
||||
useEffect(() => {
|
||||
loadProviders()
|
||||
}, [])
|
||||
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/setup/repository/providers')
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
const providerList = result.providers || []
|
||||
|
||||
// Map provider names to full provider objects
|
||||
const providersData: RepositoryProvider[] = providerList.map((name: string) => {
|
||||
switch (name.toLowerCase()) {
|
||||
case 'gitea':
|
||||
return {
|
||||
name: 'gitea',
|
||||
displayName: 'Gitea',
|
||||
description: 'Self-hosted Git service with issue tracking',
|
||||
requiresBaseURL: true,
|
||||
defaultBaseURL: 'http://gitea.local'
|
||||
}
|
||||
case 'github':
|
||||
return {
|
||||
name: 'github',
|
||||
displayName: 'GitHub',
|
||||
description: 'Cloud-based Git repository hosting service',
|
||||
requiresBaseURL: false,
|
||||
defaultBaseURL: 'https://api.github.com'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
name: name.toLowerCase(),
|
||||
displayName: name,
|
||||
description: 'Git repository service',
|
||||
requiresBaseURL: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setProviders(providersData)
|
||||
|
||||
// Set default provider if none selected
|
||||
if (!config.provider && providersData.length > 0) {
|
||||
const defaultProvider = providersData.find(p => p.name === 'gitea') || providersData[0]
|
||||
handleProviderChange(defaultProvider.name)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load providers:', error)
|
||||
} finally {
|
||||
setLoadingProviders(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProviderChange = (provider: string) => {
|
||||
const providerData = providers.find(p => p.name === provider)
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
provider,
|
||||
baseURL: providerData?.defaultBaseURL || prev.baseURL
|
||||
}))
|
||||
setValidation(null)
|
||||
}
|
||||
|
||||
const handleInputChange = (field: keyof RepositoryConfig, value: string) => {
|
||||
setConfig(prev => ({ ...prev, [field]: value }))
|
||||
setValidation(null)
|
||||
}
|
||||
|
||||
const validateRepository = async () => {
|
||||
if (!config.provider || !config.accessToken || !config.owner || !config.repository) {
|
||||
setValidation({
|
||||
valid: false,
|
||||
error: 'Please fill in all required fields'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setValidating(true)
|
||||
setValidation(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/setup/repository/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (response.ok && result.valid) {
|
||||
setValidation({
|
||||
valid: true,
|
||||
message: result.message || 'Repository connection successful'
|
||||
})
|
||||
} else {
|
||||
setValidation({
|
||||
valid: false,
|
||||
error: result.error || 'Validation failed'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setValidation({
|
||||
valid: false,
|
||||
error: 'Network error: Unable to validate repository'
|
||||
})
|
||||
} finally {
|
||||
setValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (validation?.valid) {
|
||||
onComplete({ repository: config })
|
||||
} else {
|
||||
validateRepository()
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProvider = providers.find(p => p.name === config.provider)
|
||||
const isFormValid = config.provider && config.accessToken && config.owner && config.repository &&
|
||||
(!selectedProvider?.requiresBaseURL || config.baseURL)
|
||||
|
||||
if (loadingProviders) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<ArrowPathIcon className="h-8 w-8 text-bzzz-primary animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading repository providers...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Repository Provider Selection */}
|
||||
<div className="bg-gray-50 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4 flex items-center">
|
||||
<CodeBracketIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
Repository Provider
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.name}
|
||||
className={`border-2 rounded-lg p-4 cursor-pointer transition-all ${
|
||||
config.provider === provider.name
|
||||
? 'border-bzzz-primary bg-bzzz-primary bg-opacity-10'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
onClick={() => handleProviderChange(provider.name)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="provider"
|
||||
value={provider.name}
|
||||
checked={config.provider === provider.name}
|
||||
onChange={() => handleProviderChange(provider.name)}
|
||||
className="h-4 w-4 text-bzzz-primary focus:ring-bzzz-primary border-gray-300"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<div className="font-medium text-gray-900">{provider.displayName}</div>
|
||||
<div className="text-sm text-gray-600">{provider.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration Form */}
|
||||
{config.provider && (
|
||||
<div className="space-y-6">
|
||||
{/* Base URL (for providers that require it) */}
|
||||
{selectedProvider?.requiresBaseURL && (
|
||||
<div>
|
||||
<label className="label">
|
||||
Base URL *
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={config.baseURL}
|
||||
onChange={(e) => handleInputChange('baseURL', e.target.value)}
|
||||
placeholder={`e.g., ${selectedProvider.defaultBaseURL || 'https://git.example.com'}`}
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
The base URL for your {selectedProvider.displayName} instance
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Access Token */}
|
||||
<div>
|
||||
<label className="label">
|
||||
Access Token *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={config.accessToken}
|
||||
onChange={(e) => handleInputChange('accessToken', e.target.value)}
|
||||
placeholder={`Your ${selectedProvider?.displayName} access token`}
|
||||
className="input-field pr-10"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showToken ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{selectedProvider?.name === 'github'
|
||||
? 'Generate a personal access token with repo and admin:repo_hook permissions'
|
||||
: 'Generate an access token with repository read/write permissions'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Owner/Organization */}
|
||||
<div>
|
||||
<label className="label">
|
||||
Owner/Organization *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.owner}
|
||||
onChange={(e) => handleInputChange('owner', e.target.value)}
|
||||
placeholder="username or organization"
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
The username or organization that owns the repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Repository Name */}
|
||||
<div>
|
||||
<label className="label">
|
||||
Repository Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.repository}
|
||||
onChange={(e) => handleInputChange('repository', e.target.value)}
|
||||
placeholder="repository-name"
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
The name of the repository for task management
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Validation Section */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<h4 className="text-md font-medium text-gray-900 mb-3">Connection Test</h4>
|
||||
|
||||
{validation && (
|
||||
<div className={`flex items-center p-3 rounded-lg mb-4 ${
|
||||
validation.valid
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}>
|
||||
{validation.valid ? (
|
||||
<CheckCircleIcon className="h-5 w-5 text-green-600 mr-2" />
|
||||
) : (
|
||||
<XCircleIcon className="h-5 w-5 text-red-600 mr-2" />
|
||||
)}
|
||||
<span className={`text-sm ${
|
||||
validation.valid ? 'text-green-800' : 'text-red-800'
|
||||
}`}>
|
||||
{validation.valid ? validation.message : validation.error}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={validateRepository}
|
||||
disabled={!isFormValid || validating}
|
||||
className="btn-outline w-full sm:w-auto"
|
||||
>
|
||||
{validating ? (
|
||||
<>
|
||||
<ArrowPathIcon className="h-4 w-4 animate-spin mr-2" />
|
||||
Testing Connection...
|
||||
</>
|
||||
) : (
|
||||
'Test Repository Connection'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!isFormValid && (
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
Please fill in all required fields to test the connection
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<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"
|
||||
disabled={!validation?.valid}
|
||||
className="btn-primary"
|
||||
>
|
||||
{validation?.valid
|
||||
? (isCompleted ? 'Continue' : 'Next: Network Configuration')
|
||||
: 'Validate & Continue'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
KeyIcon,
|
||||
LockClosedIcon,
|
||||
ServerIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
DocumentDuplicateIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
interface SecuritySetupProps {
|
||||
systemInfo: any
|
||||
@@ -10,6 +22,25 @@ interface SecuritySetupProps {
|
||||
isCompleted: boolean
|
||||
}
|
||||
|
||||
interface SecurityConfig {
|
||||
sshKeyType: 'generate' | 'existing' | 'manual'
|
||||
sshPublicKey: string
|
||||
sshPrivateKey: string
|
||||
sshUsername: string
|
||||
sshPassword: string
|
||||
sshPort: number
|
||||
enableTLS: boolean
|
||||
tlsCertType: 'self-signed' | 'letsencrypt' | 'existing'
|
||||
tlsCertPath: string
|
||||
tlsKeyPath: string
|
||||
authMethod: 'token' | 'certificate' | 'hybrid'
|
||||
clusterSecret: string
|
||||
accessPolicy: 'open' | 'restricted' | 'invite-only'
|
||||
enableFirewall: boolean
|
||||
allowedPorts: string[]
|
||||
trustedIPs: string[]
|
||||
}
|
||||
|
||||
export default function SecuritySetup({
|
||||
systemInfo,
|
||||
configData,
|
||||
@@ -17,29 +48,616 @@ export default function SecuritySetup({
|
||||
onBack,
|
||||
isCompleted
|
||||
}: SecuritySetupProps) {
|
||||
const [config, setConfig] = useState({
|
||||
console.log('SecuritySetup: Component rendered with configData:', configData)
|
||||
|
||||
const [config, setConfig] = useState<SecurityConfig>({
|
||||
sshKeyType: 'generate',
|
||||
sshPublicKey: '',
|
||||
sshPrivateKey: '',
|
||||
sshUsername: 'ubuntu',
|
||||
sshPassword: '',
|
||||
sshPort: 22,
|
||||
enableTLS: true,
|
||||
authMethod: 'token'
|
||||
tlsCertType: 'self-signed',
|
||||
tlsCertPath: '',
|
||||
tlsKeyPath: '',
|
||||
authMethod: 'token',
|
||||
clusterSecret: '',
|
||||
accessPolicy: 'restricted',
|
||||
enableFirewall: true,
|
||||
allowedPorts: ['22', '8080', '8090', '9100', '3000'],
|
||||
trustedIPs: [],
|
||||
...configData?.security // Load saved security config if exists
|
||||
})
|
||||
|
||||
const [showPrivateKey, setShowPrivateKey] = useState(false)
|
||||
const [showClusterSecret, setShowClusterSecret] = useState(false)
|
||||
const [showSSHPassword, setShowSSHPassword] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [validation, setValidation] = useState<{[key: string]: boolean}>({})
|
||||
const [portsInitialized, setPortsInitialized] = useState(false)
|
||||
|
||||
// Generate cluster secret on mount if not exists
|
||||
useEffect(() => {
|
||||
if (!config.clusterSecret) {
|
||||
generateClusterSecret()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update firewall ports based on network configuration from previous step
|
||||
useEffect(() => {
|
||||
console.log('SecuritySetup: configData changed', {
|
||||
hasNetwork: !!configData?.network,
|
||||
portsInitialized,
|
||||
hasSavedSecurity: !!configData?.security?.allowedPorts,
|
||||
networkConfig: configData?.network
|
||||
})
|
||||
|
||||
// If we have network config and haven't initialized ports yet, AND we don't have saved security config
|
||||
if (configData?.network && !portsInitialized && !configData?.security?.allowedPorts) {
|
||||
const networkConfig = configData.network
|
||||
const networkPorts = [
|
||||
networkConfig.bzzzPort?.toString(),
|
||||
networkConfig.mcpPort?.toString(),
|
||||
networkConfig.webUIPort?.toString(),
|
||||
networkConfig.p2pPort?.toString()
|
||||
].filter(port => port && port !== 'undefined')
|
||||
|
||||
console.log('SecuritySetup: Auto-populating ports', { networkPorts, networkConfig })
|
||||
|
||||
// Include standard ports plus network configuration ports
|
||||
const standardPorts = ['22', '8090'] // SSH and setup interface
|
||||
const allPorts = [...new Set([...standardPorts, ...networkPorts])]
|
||||
|
||||
console.log('SecuritySetup: Setting allowed ports to', allPorts)
|
||||
setConfig(prev => ({ ...prev, allowedPorts: allPorts }))
|
||||
setPortsInitialized(true)
|
||||
}
|
||||
}, [configData, portsInitialized])
|
||||
|
||||
const generateClusterSecret = () => {
|
||||
const secret = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
setConfig(prev => ({ ...prev, clusterSecret: secret }))
|
||||
}
|
||||
|
||||
const generateSSHKeys = async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
// In a real implementation, this would call the backend to generate SSH keys
|
||||
// For now, simulate the process
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Mock generated keys (in real implementation, these would come from backend)
|
||||
const mockPublicKey = `ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC... chorus@${systemInfo?.network?.hostname || 'localhost'}`
|
||||
const mockPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY-----
|
||||
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAFwwAAAAd...
|
||||
-----END OPENSSH PRIVATE KEY-----`
|
||||
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
sshPublicKey: mockPublicKey,
|
||||
sshPrivateKey: mockPrivateKey
|
||||
}))
|
||||
|
||||
setValidation(prev => ({ ...prev, sshKeys: true }))
|
||||
} catch (error) {
|
||||
console.error('Failed to generate SSH keys:', error)
|
||||
setValidation(prev => ({ ...prev, sshKeys: false }))
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to clipboard:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onComplete({ security: config })
|
||||
|
||||
// Validate required fields
|
||||
const newValidation: {[key: string]: boolean} = {}
|
||||
|
||||
if (config.sshKeyType === 'generate' && !config.sshPublicKey) {
|
||||
newValidation.sshKeys = false
|
||||
} else if (config.sshKeyType === 'existing' && !config.sshPublicKey) {
|
||||
newValidation.sshKeys = false
|
||||
} else {
|
||||
newValidation.sshKeys = true
|
||||
}
|
||||
|
||||
if (config.enableTLS && config.tlsCertType === 'existing' && (!config.tlsCertPath || !config.tlsKeyPath)) {
|
||||
newValidation.tlsCert = false
|
||||
} else {
|
||||
newValidation.tlsCert = true
|
||||
}
|
||||
|
||||
if (!config.clusterSecret) {
|
||||
newValidation.clusterSecret = false
|
||||
} else {
|
||||
newValidation.clusterSecret = true
|
||||
}
|
||||
|
||||
if (config.sshKeyType === 'manual' && (!config.sshUsername || !config.sshPassword)) {
|
||||
newValidation.sshCredentials = false
|
||||
} else {
|
||||
newValidation.sshCredentials = true
|
||||
}
|
||||
|
||||
setValidation(newValidation)
|
||||
|
||||
// Check if all validations pass
|
||||
const isValid = Object.values(newValidation).every(v => v)
|
||||
|
||||
if (isValid) {
|
||||
onComplete({ security: config })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Security Setup
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Configure authentication, SSH access, and security certificates.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-yellow-800">
|
||||
This component is under development. Security configuration will be implemented here.
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
|
||||
{/* SSH Key Configuration */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<KeyIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">SSH Key Management</h3>
|
||||
{validation.sshKeys === true && <CheckCircleIcon className="h-5 w-5 text-green-500 ml-2" />}
|
||||
{validation.sshKeys === false && <XCircleIcon className="h-5 w-5 text-red-500 ml-2" />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">SSH Key Type</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="generate"
|
||||
checked={config.sshKeyType === 'generate'}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, sshKeyType: e.target.value as any }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
Generate new SSH key pair
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="existing"
|
||||
checked={config.sshKeyType === 'existing'}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, sshKeyType: e.target.value as any }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
Use existing SSH key
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="manual"
|
||||
checked={config.sshKeyType === 'manual'}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, sshKeyType: e.target.value as any }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
Configure manually with SSH username/password
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.sshKeyType === 'generate' && (
|
||||
<div className="space-y-4">
|
||||
{!config.sshPublicKey ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateSSHKeys}
|
||||
disabled={generating}
|
||||
className="btn-primary"
|
||||
>
|
||||
{generating ? 'Generating Keys...' : 'Generate SSH Key Pair'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Public Key</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={config.sshPublicKey}
|
||||
readOnly
|
||||
className="w-full p-3 border border-gray-300 rounded-lg bg-gray-50 font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(config.sshPublicKey)}
|
||||
className="absolute top-2 right-2 p-1 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<DocumentDuplicateIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Private Key</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={showPrivateKey ? config.sshPrivateKey : '••••••••••••••••••••••••••••••••'}
|
||||
readOnly
|
||||
className="w-full p-3 border border-gray-300 rounded-lg bg-gray-50 font-mono text-sm"
|
||||
rows={6}
|
||||
/>
|
||||
<div className="absolute top-2 right-2 flex space-x-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPrivateKey(!showPrivateKey)}
|
||||
className="p-1 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showPrivateKey ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(config.sshPrivateKey)}
|
||||
className="p-1 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<DocumentDuplicateIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-600 mt-1">⚠️ Store this private key securely. It cannot be recovered.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.sshKeyType === 'existing' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">SSH Public Key</label>
|
||||
<textarea
|
||||
value={config.sshPublicKey}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, sshPublicKey: e.target.value }))}
|
||||
placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQC..."
|
||||
className="w-full p-3 border border-gray-300 rounded-lg font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.sshKeyType === 'manual' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-600 mt-0.5" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-yellow-800">Manual SSH Configuration</h4>
|
||||
<p className="text-sm text-yellow-700 mt-1">
|
||||
Provide SSH credentials for cluster machines. SSH keys will be automatically generated and deployed using these credentials.
|
||||
<strong> Passwords are only used during setup and are not stored.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
SSH Username <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.sshUsername}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, sshUsername: e.target.value }))}
|
||||
placeholder="ubuntu"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-bzzz-primary focus:border-bzzz-primary"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Exact SSH username for cluster machines
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
SSH Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={config.sshPort}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, sshPort: parseInt(e.target.value) || 22 }))}
|
||||
min="1"
|
||||
max="65535"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-bzzz-primary focus:border-bzzz-primary"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
SSH port number (default: 22)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
SSH Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showSSHPassword ? 'text' : 'password'}
|
||||
value={config.sshPassword}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, sshPassword: e.target.value }))}
|
||||
placeholder="Enter SSH password for cluster machines"
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-lg focus:ring-bzzz-primary focus:border-bzzz-primary"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSSHPassword(!showSSHPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showSSHPassword ? (
|
||||
<EyeSlashIcon className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
SSH password for the specified username (used only during setup)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* TLS/SSL Configuration */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<LockClosedIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">TLS/SSL Configuration</h3>
|
||||
{validation.tlsCert === true && <CheckCircleIcon className="h-5 w-5 text-green-500 ml-2" />}
|
||||
{validation.tlsCert === false && <XCircleIcon className="h-5 w-5 text-red-500 ml-2" />}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enableTLS}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, enableTLS: e.target.checked }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
Enable TLS encryption for cluster communication
|
||||
</label>
|
||||
|
||||
{config.enableTLS && (
|
||||
<div className="space-y-4 ml-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Certificate Type</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="self-signed"
|
||||
checked={config.tlsCertType === 'self-signed'}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, tlsCertType: e.target.value as any }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
Generate self-signed certificate
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="letsencrypt"
|
||||
checked={config.tlsCertType === 'letsencrypt'}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, tlsCertType: e.target.value as any }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
Use Let's Encrypt (requires domain)
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="existing"
|
||||
checked={config.tlsCertType === 'existing'}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, tlsCertType: e.target.value as any }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
Use existing certificate
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.tlsCertType === 'existing' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Certificate Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.tlsCertPath}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, tlsCertPath: e.target.value }))}
|
||||
placeholder="/path/to/certificate.crt"
|
||||
className="w-full p-3 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Private Key Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.tlsKeyPath}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, tlsKeyPath: e.target.value }))}
|
||||
placeholder="/path/to/private.key"
|
||||
className="w-full p-3 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Authentication Method */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<ShieldCheckIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Authentication Method</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Authentication Type</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="token"
|
||||
checked={config.authMethod === 'token'}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, authMethod: e.target.value as any }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
API Token-based authentication
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="certificate"
|
||||
checked={config.authMethod === 'certificate'}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, authMethod: e.target.value as any }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
Certificate-based authentication
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
value="hybrid"
|
||||
checked={config.authMethod === 'hybrid'}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, authMethod: e.target.value as any }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
Hybrid (Token + Certificate)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Cluster Secret</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showClusterSecret ? "text" : "password"}
|
||||
value={config.clusterSecret}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, clusterSecret: e.target.value }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg font-mono"
|
||||
placeholder="Cluster authentication secret"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex space-x-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowClusterSecret(!showClusterSecret)}
|
||||
className="p-1 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{showClusterSecret ? <EyeSlashIcon className="h-4 w-4" /> : <EyeIcon className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateClusterSecret}
|
||||
className="p-1 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<KeyIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{validation.clusterSecret === false && (
|
||||
<p className="text-sm text-red-600 mt-1">Cluster secret is required</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Control */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<ServerIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Access Control</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Access Policy</label>
|
||||
<select
|
||||
value={config.accessPolicy}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, accessPolicy: e.target.value as any }))}
|
||||
className="w-full p-3 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="open">Open (Anyone can join cluster)</option>
|
||||
<option value="restricted">Restricted (Require authentication)</option>
|
||||
<option value="invite-only">Invite Only (Manual approval required)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enableFirewall}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, enableFirewall: e.target.checked }))}
|
||||
className="mr-2"
|
||||
/>
|
||||
Enable firewall configuration
|
||||
</label>
|
||||
|
||||
{config.enableFirewall && (
|
||||
<div className="ml-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Allowed Ports</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.allowedPorts.join(', ')}
|
||||
onChange={(e) => setConfig(prev => ({
|
||||
...prev,
|
||||
allowedPorts: e.target.value.split(',').map(p => p.trim()).filter(p => p)
|
||||
}))}
|
||||
placeholder="22, 8080, 8090, 9100, 3000"
|
||||
className="w-full p-3 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
{configData?.network && (
|
||||
<p className="text-sm text-green-600 mt-1 flex items-center">
|
||||
<CheckCircleIcon className="h-4 w-4 mr-1" />
|
||||
Ports automatically configured from Network Settings: {[
|
||||
configData.network.bzzzPort,
|
||||
configData.network.mcpPort,
|
||||
configData.network.webUIPort,
|
||||
configData.network.p2pPort
|
||||
].filter(p => p).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Comma-separated list of ports to allow through the firewall
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Summary */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-blue-500 mt-0.5 mr-2" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-800">Security Summary</h4>
|
||||
<ul className="text-sm text-blue-700 mt-1 space-y-1">
|
||||
<li>• SSH access: {config.sshKeyType === 'generate' ? 'New key pair will be generated' : config.sshKeyType === 'existing' ? 'Using provided key' : 'Manual configuration'}</li>
|
||||
<li>• TLS encryption: {config.enableTLS ? 'Enabled' : 'Disabled'}</li>
|
||||
<li>• Authentication: {config.authMethod}</li>
|
||||
<li>• Access policy: {config.accessPolicy}</li>
|
||||
<li>• Firewall: {config.enableFirewall ? 'Enabled' : 'Disabled'}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +670,11 @@ export default function SecuritySetup({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="btn-primary">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={config.sshKeyType === 'generate' && !config.sshPublicKey}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isCompleted ? 'Continue' : 'Next: AI Integration'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
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
|
||||
@@ -17,32 +51,698 @@ export default function ServiceDeployment({
|
||||
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
|
||||
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 })
|
||||
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">
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Service Deployment
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Deploy and configure BZZZ services with monitoring and health checks.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-yellow-800">
|
||||
This component is under development. Service deployment will be implemented here.
|
||||
|
||||
{/* 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 && (
|
||||
|
||||
@@ -12,56 +12,33 @@ import {
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
interface SystemInfo {
|
||||
hostname: string
|
||||
os: {
|
||||
os: string
|
||||
architecture: string
|
||||
cpu_cores: number
|
||||
memory_mb: number
|
||||
gpus: Array<{
|
||||
name: string
|
||||
version: string
|
||||
arch: string
|
||||
}
|
||||
hardware: {
|
||||
cpu: {
|
||||
cores: number
|
||||
model: string
|
||||
}
|
||||
memory: {
|
||||
total: number
|
||||
available: number
|
||||
}
|
||||
storage: {
|
||||
total: number
|
||||
available: number
|
||||
}
|
||||
gpus: Array<{
|
||||
type: string
|
||||
name: string
|
||||
memory: number
|
||||
}>
|
||||
}
|
||||
memory: string
|
||||
driver: string
|
||||
type: string
|
||||
}>
|
||||
network: {
|
||||
interfaces: Array<{
|
||||
name: string
|
||||
ip: string
|
||||
mac: string
|
||||
speed: string
|
||||
status: string
|
||||
}>
|
||||
primary_interface: string
|
||||
primary_ip: string
|
||||
hostname: string
|
||||
interfaces: string[]
|
||||
public_ip?: string
|
||||
private_ips: string[]
|
||||
docker_bridge?: string
|
||||
}
|
||||
software: {
|
||||
docker: {
|
||||
installed: boolean
|
||||
version?: string
|
||||
}
|
||||
ollama: {
|
||||
installed: boolean
|
||||
type?: 'ollama' | 'parallama'
|
||||
version?: string
|
||||
}
|
||||
bzzz: {
|
||||
installed: boolean
|
||||
version?: string
|
||||
}
|
||||
storage: {
|
||||
total_space_gb: number
|
||||
free_space_gb: number
|
||||
mount_path: string
|
||||
}
|
||||
docker: {
|
||||
available: boolean
|
||||
version?: string
|
||||
compose_available: boolean
|
||||
swarm_mode: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,10 +70,10 @@ export default function SystemDetection({
|
||||
const refreshSystemInfo = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const response = await fetch('/api/system/detect')
|
||||
const response = await fetch('/api/setup/system')
|
||||
if (response.ok) {
|
||||
const info = await response.json()
|
||||
setDetectedInfo(info)
|
||||
const result = await response.json()
|
||||
setDetectedInfo(result.system_info)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to detect system info:', error)
|
||||
@@ -115,13 +92,6 @@ export default function SystemDetection({
|
||||
}
|
||||
}
|
||||
|
||||
const formatMemory = (bytes: number) => {
|
||||
return `${Math.round(bytes / (1024 ** 3))} GB`
|
||||
}
|
||||
|
||||
const formatStorage = (bytes: number) => {
|
||||
return `${Math.round(bytes / (1024 ** 3))} GB`
|
||||
}
|
||||
|
||||
const getStatusColor = (condition: boolean) => {
|
||||
return condition ? 'text-green-600' : 'text-red-600'
|
||||
@@ -181,12 +151,12 @@ export default function SystemDetection({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">Hostname</div>
|
||||
<div className="text-lg text-gray-900">{detectedInfo.hostname}</div>
|
||||
<div className="text-lg text-gray-900">{detectedInfo.network.hostname}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">Operating System</div>
|
||||
<div className="text-lg text-gray-900">
|
||||
{detectedInfo.os.name} {detectedInfo.os.version} ({detectedInfo.os.arch})
|
||||
{detectedInfo.os} ({detectedInfo.architecture})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,14 +175,13 @@ export default function SystemDetection({
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">CPU</div>
|
||||
<div className="text-gray-900">
|
||||
{detectedInfo.hardware.cpu.cores} cores - {detectedInfo.hardware.cpu.model}
|
||||
{detectedInfo.cpu_cores} cores
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">Memory</div>
|
||||
<div className="text-gray-900">
|
||||
{formatMemory(detectedInfo.hardware.memory.total)} total, {' '}
|
||||
{formatMemory(detectedInfo.hardware.memory.available)} available
|
||||
{Math.round(detectedInfo.memory_mb / 1024)} GB total
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,15 +198,15 @@ export default function SystemDetection({
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">Disk Space</div>
|
||||
<div className="text-gray-900">
|
||||
{formatStorage(detectedInfo.hardware.storage.total)} total, {' '}
|
||||
{formatStorage(detectedInfo.hardware.storage.available)} available
|
||||
{detectedInfo.storage.total_space_gb} GB total, {' '}
|
||||
{detectedInfo.storage.free_space_gb} GB available
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-bzzz-primary h-2 rounded-full"
|
||||
style={{
|
||||
width: `${((detectedInfo.hardware.storage.total - detectedInfo.hardware.storage.available) / detectedInfo.hardware.storage.total) * 100}%`
|
||||
width: `${((detectedInfo.storage.total_space_gb - detectedInfo.storage.free_space_gb) / detectedInfo.storage.total_space_gb) * 100}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -246,21 +215,21 @@ export default function SystemDetection({
|
||||
</div>
|
||||
|
||||
{/* GPU Information */}
|
||||
{detectedInfo.hardware.gpus.length > 0 && (
|
||||
{detectedInfo.gpus && detectedInfo.gpus.length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<ServerIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
GPU Configuration ({detectedInfo.hardware.gpus.length} GPU{detectedInfo.hardware.gpus.length !== 1 ? 's' : ''})
|
||||
GPU Configuration ({detectedInfo.gpus.length} GPU{detectedInfo.gpus.length !== 1 ? 's' : ''})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{detectedInfo.hardware.gpus.map((gpu, index) => (
|
||||
{detectedInfo.gpus.map((gpu, index) => (
|
||||
<div key={index} className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="font-medium text-gray-900">{gpu.name}</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{gpu.type.toUpperCase()} • {Math.round(gpu.memory / (1024 ** 3))} GB VRAM
|
||||
{gpu.type.toUpperCase()} • {gpu.memory} • {gpu.driver}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -277,30 +246,30 @@ export default function SystemDetection({
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">Primary Interface</div>
|
||||
<div className="text-gray-900">
|
||||
{detectedInfo.network.primary_interface} ({detectedInfo.network.primary_ip})
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-700">Hostname</div>
|
||||
<div className="text-gray-900">{detectedInfo.network.hostname}</div>
|
||||
</div>
|
||||
|
||||
{detectedInfo.network.interfaces.length > 1 && (
|
||||
{detectedInfo.network.private_ips && detectedInfo.network.private_ips.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">All Interfaces</div>
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">Private IP Addresses</div>
|
||||
<div className="space-y-2">
|
||||
{detectedInfo.network.interfaces.map((interface_, index) => (
|
||||
{detectedInfo.network.private_ips.map((ip, index) => (
|
||||
<div key={index} className="flex justify-between items-center text-sm">
|
||||
<span>{interface_.name}</span>
|
||||
<span className="text-gray-600">{interface_.ip}</span>
|
||||
<span className={`status-indicator ${
|
||||
interface_.status === 'up' ? 'status-online' : 'status-offline'
|
||||
}`}>
|
||||
{interface_.status}
|
||||
</span>
|
||||
<span>{ip}</span>
|
||||
<span className="status-indicator status-online">active</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detectedInfo.network.public_ip && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">Public IP</div>
|
||||
<div className="text-gray-900">{detectedInfo.network.public_ip}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -312,21 +281,21 @@ export default function SystemDetection({
|
||||
{[
|
||||
{
|
||||
name: 'Docker',
|
||||
installed: detectedInfo.software.docker.installed,
|
||||
version: detectedInfo.software.docker.version,
|
||||
installed: detectedInfo.docker.available,
|
||||
version: detectedInfo.docker.version,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: detectedInfo.software.ollama.type === 'parallama' ? 'Parallama' : 'Ollama',
|
||||
installed: detectedInfo.software.ollama.installed,
|
||||
version: detectedInfo.software.ollama.version,
|
||||
name: 'Docker Compose',
|
||||
installed: detectedInfo.docker.compose_available,
|
||||
version: undefined,
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'BZZZ',
|
||||
installed: detectedInfo.software.bzzz.installed,
|
||||
version: detectedInfo.software.bzzz.version,
|
||||
required: true
|
||||
name: 'Docker Swarm',
|
||||
installed: detectedInfo.docker.swarm_mode,
|
||||
version: undefined,
|
||||
required: false
|
||||
}
|
||||
].map((software, index) => {
|
||||
const StatusIcon = getStatusIcon(software.installed)
|
||||
@@ -365,20 +334,16 @@ export default function SystemDetection({
|
||||
{[
|
||||
{
|
||||
check: 'Minimum memory (2GB required)',
|
||||
passed: detectedInfo.hardware.memory.total >= 2 * 1024 ** 3,
|
||||
warning: detectedInfo.hardware.memory.total < 4 * 1024 ** 3
|
||||
passed: detectedInfo.memory_mb >= 2048,
|
||||
warning: detectedInfo.memory_mb < 4096
|
||||
},
|
||||
{
|
||||
check: 'Available disk space (10GB required)',
|
||||
passed: detectedInfo.hardware.storage.available >= 10 * 1024 ** 3
|
||||
passed: detectedInfo.storage.free_space_gb >= 10
|
||||
},
|
||||
{
|
||||
check: 'Docker installed and running',
|
||||
passed: detectedInfo.software.docker.installed
|
||||
},
|
||||
{
|
||||
check: 'BZZZ binaries installed',
|
||||
passed: detectedInfo.software.bzzz.installed
|
||||
passed: detectedInfo.docker.available
|
||||
}
|
||||
].map((validation, index) => {
|
||||
const StatusIcon = getStatusIcon(validation.passed)
|
||||
@@ -427,9 +392,9 @@ export default function SystemDetection({
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
className="btn-primary"
|
||||
disabled={!detectedInfo.software.docker.installed || !detectedInfo.software.bzzz.installed}
|
||||
disabled={!detectedInfo.docker.available}
|
||||
>
|
||||
{isCompleted ? 'Continue' : 'Next: Network Configuration'}
|
||||
{isCompleted ? 'Continue' : 'Next: Repository Setup'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
181
install/config-ui/app/setup/components/TermsAndConditions.tsx
Normal file
181
install/config-ui/app/setup/components/TermsAndConditions.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline'
|
||||
|
||||
interface TermsAndConditionsProps {
|
||||
systemInfo: any
|
||||
configData: any
|
||||
onComplete: (data: any) => void
|
||||
onBack?: () => void
|
||||
isCompleted: boolean
|
||||
}
|
||||
|
||||
export default function TermsAndConditions({
|
||||
systemInfo,
|
||||
configData,
|
||||
onComplete,
|
||||
onBack,
|
||||
isCompleted
|
||||
}: TermsAndConditionsProps) {
|
||||
const [agreed, setAgreed] = useState(configData?.terms?.agreed || false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!agreed) {
|
||||
setError('You must agree to the Terms and Conditions to continue')
|
||||
return
|
||||
}
|
||||
|
||||
setError('')
|
||||
onComplete({
|
||||
terms: {
|
||||
agreed: true,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
|
||||
{/* Terms and Conditions Content */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<DocumentTextIcon className="h-6 w-6 text-bzzz-primary mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">CHORUS:agents Software License Agreement</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6 max-h-96 overflow-y-auto">
|
||||
<div className="prose prose-sm max-w-none text-gray-700">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-3">1. License Grant</h4>
|
||||
<p className="mb-4">
|
||||
Subject to the terms and conditions of this Agreement, Chorus Services grants you a non-exclusive,
|
||||
non-transferable license to use CHORUS:agents (the "Software") for distributed AI coordination and task management.
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-3">2. Permitted Uses</h4>
|
||||
<ul className="list-disc list-inside mb-4 space-y-1">
|
||||
<li>Install and operate CHORUS:agents on your infrastructure</li>
|
||||
<li>Configure cluster nodes for distributed processing</li>
|
||||
<li>Integrate with supported AI models and services</li>
|
||||
<li>Use for commercial and non-commercial purposes</li>
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-3">3. Restrictions</h4>
|
||||
<ul className="list-disc list-inside mb-4 space-y-1">
|
||||
<li>You may not redistribute, sublicense, or sell the Software</li>
|
||||
<li>You may not reverse engineer or decompile the Software</li>
|
||||
<li>You may not use the Software for illegal or harmful purposes</li>
|
||||
<li>You may not remove or modify proprietary notices</li>
|
||||
</ul>
|
||||
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-3">4. Data Privacy</h4>
|
||||
<p className="mb-4">
|
||||
CHORUS:agents processes data locally on your infrastructure. Chorus Services does not collect or store
|
||||
your operational data. Telemetry data may be collected for software improvement purposes.
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-3">5. Support and Updates</h4>
|
||||
<p className="mb-4">
|
||||
Licensed users receive access to software updates, security patches, and community support.
|
||||
Premium support tiers are available separately.
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-3">6. Disclaimer of Warranty</h4>
|
||||
<p className="mb-4">
|
||||
THE SOFTWARE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. CHORUS SERVICES DISCLAIMS
|
||||
ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY AND FITNESS
|
||||
FOR A PARTICULAR PURPOSE.
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-3">7. Limitation of Liability</h4>
|
||||
<p className="mb-4">
|
||||
IN NO EVENT SHALL CHORUS SERVICES BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL,
|
||||
OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OF THE SOFTWARE.
|
||||
</p>
|
||||
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-3">8. Termination</h4>
|
||||
<p className="mb-4">
|
||||
This license is effective until terminated. You may terminate it at any time by
|
||||
uninstalling the Software. Chorus Services may terminate this license if you
|
||||
violate any terms of this Agreement.
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 mt-6">
|
||||
<div className="flex">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-blue-500 mt-0.5 mr-2" />
|
||||
<div className="text-sm text-blue-700">
|
||||
<p><strong>Contact Information:</strong></p>
|
||||
<p>Chorus Services<br />
|
||||
Email: legal@chorus.services<br />
|
||||
Website: https://chorus.services</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agreement Checkbox */}
|
||||
<div className="card">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
className="mt-1 mr-3 h-4 w-4 text-bzzz-primary border-gray-300 rounded focus:ring-bzzz-primary"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-gray-900">
|
||||
I have read and agree to the Terms and Conditions
|
||||
</span>
|
||||
<p className="text-gray-600 mt-1">
|
||||
By checking this box, you acknowledge that you have read, understood, and agree to be
|
||||
bound by the terms and conditions outlined above.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center text-red-600 text-sm">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 mr-1" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agreed && (
|
||||
<div className="flex items-center text-green-600 text-sm">
|
||||
<CheckCircleIcon className="h-4 w-4 mr-1" />
|
||||
Thank you for accepting the terms and conditions
|
||||
</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"
|
||||
disabled={!agreed}
|
||||
className={`${agreed ? 'btn-primary' : 'btn-disabled'}`}
|
||||
>
|
||||
{isCompleted ? 'Continue' : 'Next: License Validation'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,32 @@ export default function TestingValidation({
|
||||
})
|
||||
}
|
||||
|
||||
const getClusterDashboardUrl = () => {
|
||||
// Get the WebUI port from config, default to 9090
|
||||
const webuiPort = configData?.network?.ports?.webui || 9090
|
||||
return `http://localhost:${webuiPort}/dashboard`
|
||||
}
|
||||
|
||||
const handleGoToDashboard = () => {
|
||||
const dashboardUrl = getClusterDashboardUrl()
|
||||
|
||||
// Clear setup state since we're done
|
||||
localStorage.removeItem('bzzz-setup-state')
|
||||
|
||||
// Open cluster dashboard in new tab
|
||||
window.open(dashboardUrl, '_blank')
|
||||
|
||||
// Show completion message and suggest closing this tab
|
||||
const shouldClose = window.confirm(
|
||||
'Setup complete! The cluster dashboard has opened in a new tab.\n\n' +
|
||||
'You can now close this setup tab. Click OK to close automatically, or Cancel to keep it open.'
|
||||
)
|
||||
|
||||
if (shouldClose) {
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center py-12">
|
||||
@@ -65,14 +91,22 @@ export default function TestingValidation({
|
||||
🎉 Setup Complete!
|
||||
</h4>
|
||||
<p className="text-green-700 mb-4">
|
||||
Your BZZZ cluster has been successfully configured and validated.
|
||||
Your CHORUS:agents cluster has been successfully configured and deployed.
|
||||
</p>
|
||||
<div className="space-y-2 text-sm text-green-600">
|
||||
<div className="space-y-2 text-sm text-green-600 mb-4">
|
||||
<div>✓ System configuration validated</div>
|
||||
<div>✓ Network connectivity tested</div>
|
||||
<div>✓ AI services configured</div>
|
||||
<div>✓ Services deployed to all nodes</div>
|
||||
<div>✓ Cluster formation completed</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>Cluster Dashboard:</strong> <code>{getClusterDashboardUrl()}</code>
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
The setup process will be terminated and you'll be redirected to your operational cluster.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -87,9 +121,9 @@ export default function TestingValidation({
|
||||
</div>
|
||||
|
||||
{isCompleted && (
|
||||
<a href="/dashboard" className="btn-primary">
|
||||
Go to Dashboard
|
||||
</a>
|
||||
<button onClick={handleGoToDashboard} className="btn-primary">
|
||||
Go to Cluster Dashboard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,22 +2,42 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ChevronRightIcon, CheckCircleIcon } from '@heroicons/react/24/outline'
|
||||
import TermsAndConditions from './components/TermsAndConditions'
|
||||
import LicenseValidation from './components/LicenseValidation'
|
||||
import SystemDetection from './components/SystemDetection'
|
||||
import RepositoryConfiguration from './components/RepositoryConfiguration'
|
||||
import NetworkConfiguration from './components/NetworkConfiguration'
|
||||
import SecuritySetup from './components/SecuritySetup'
|
||||
import AIConfiguration from './components/AIConfiguration'
|
||||
import ResourceAllocation from './components/ResourceAllocation'
|
||||
import ServiceDeployment from './components/ServiceDeployment'
|
||||
import ClusterFormation from './components/ClusterFormation'
|
||||
import TestingValidation from './components/TestingValidation'
|
||||
|
||||
const SETUP_STEPS = [
|
||||
{
|
||||
id: 'terms',
|
||||
title: 'Terms & Conditions',
|
||||
description: 'Review and accept the software license agreement',
|
||||
component: TermsAndConditions,
|
||||
},
|
||||
{
|
||||
id: 'license',
|
||||
title: 'License Validation',
|
||||
description: 'Validate your BZZZ license key and email',
|
||||
component: LicenseValidation,
|
||||
},
|
||||
{
|
||||
id: 'detection',
|
||||
title: 'System Detection',
|
||||
description: 'Detect hardware and validate installation',
|
||||
component: SystemDetection,
|
||||
},
|
||||
{
|
||||
id: 'repository',
|
||||
title: 'Repository Setup',
|
||||
description: 'Configure Git repository for task management',
|
||||
component: RepositoryConfiguration,
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
title: 'Network Configuration',
|
||||
@@ -36,22 +56,16 @@ const SETUP_STEPS = [
|
||||
description: 'Configure OpenAI and Ollama/Parallama',
|
||||
component: AIConfiguration,
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
title: 'Resource Allocation',
|
||||
description: 'Allocate CPU, memory, and storage',
|
||||
component: ResourceAllocation,
|
||||
},
|
||||
{
|
||||
id: 'deployment',
|
||||
title: 'Service Deployment',
|
||||
description: 'Deploy and configure BZZZ services',
|
||||
description: 'Deploy and configure CHORUS:agents services',
|
||||
component: ServiceDeployment,
|
||||
},
|
||||
{
|
||||
id: 'cluster',
|
||||
title: 'Cluster Formation',
|
||||
description: 'Join or create BZZZ cluster',
|
||||
description: 'Join or create CHORUS:agents cluster',
|
||||
component: ClusterFormation,
|
||||
},
|
||||
{
|
||||
@@ -72,17 +86,63 @@ export default function SetupPage() {
|
||||
const [configData, setConfigData] = useState<ConfigData>({})
|
||||
const [systemInfo, setSystemInfo] = useState<any>(null)
|
||||
|
||||
// Load system information on mount
|
||||
// Load persisted data and system information on mount
|
||||
useEffect(() => {
|
||||
loadPersistedData()
|
||||
fetchSystemInfo()
|
||||
}, [])
|
||||
|
||||
// Save setup state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
saveSetupState()
|
||||
}, [currentStep, completedSteps, configData])
|
||||
|
||||
const loadPersistedData = () => {
|
||||
try {
|
||||
const savedState = localStorage.getItem('bzzz-setup-state')
|
||||
if (savedState) {
|
||||
const state = JSON.parse(savedState)
|
||||
setCurrentStep(state.currentStep || 0)
|
||||
setCompletedSteps(new Set(state.completedSteps || []))
|
||||
setConfigData(state.configData || {})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load persisted setup data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveSetupState = () => {
|
||||
try {
|
||||
const state = {
|
||||
currentStep,
|
||||
completedSteps: Array.from(completedSteps),
|
||||
configData,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
localStorage.setItem('bzzz-setup-state', JSON.stringify(state))
|
||||
} catch (error) {
|
||||
console.error('Failed to save setup state:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const clearPersistedData = () => {
|
||||
try {
|
||||
localStorage.removeItem('bzzz-setup-state')
|
||||
// Reset state to initial values
|
||||
setCurrentStep(0)
|
||||
setCompletedSteps(new Set<number>())
|
||||
setConfigData({})
|
||||
} catch (error) {
|
||||
console.error('Failed to clear persisted data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSystemInfo = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/system/info')
|
||||
const response = await fetch('/api/setup/system')
|
||||
if (response.ok) {
|
||||
const info = await response.json()
|
||||
setSystemInfo(info)
|
||||
const result = await response.json()
|
||||
setSystemInfo(result.system_info)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch system info:', error)
|
||||
@@ -90,12 +150,22 @@ export default function SetupPage() {
|
||||
}
|
||||
|
||||
const handleStepComplete = (stepIndex: number, data: any) => {
|
||||
console.log('Setup Page: Step complete', { stepIndex, data, currentConfigData: configData })
|
||||
setCompletedSteps(prev => new Set([...prev, stepIndex]))
|
||||
setConfigData(prev => ({ ...prev, ...data }))
|
||||
setConfigData(prev => {
|
||||
const newConfigData = { ...prev, ...data }
|
||||
console.log('Setup Page: Updated configData', { prev, data, newConfigData })
|
||||
return newConfigData
|
||||
})
|
||||
|
||||
// Auto-advance to next step
|
||||
if (stepIndex < SETUP_STEPS.length - 1) {
|
||||
setCurrentStep(stepIndex + 1)
|
||||
} else {
|
||||
// Setup is complete, clear persisted data after a delay
|
||||
setTimeout(() => {
|
||||
clearPersistedData()
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,17 +177,50 @@ export default function SetupPage() {
|
||||
|
||||
const CurrentStepComponent = SETUP_STEPS[currentStep].component
|
||||
|
||||
// Check if we're resuming from saved data
|
||||
const isResuming = currentStep > 0 || completedSteps.size > 0 || Object.keys(configData).length > 0
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Welcome to BZZZ Setup
|
||||
Welcome to CHORUS:agents Setup
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
Let's configure your distributed AI coordination cluster in {SETUP_STEPS.length} simple steps.
|
||||
Let's configure your distributed AI agent coordination cluster in {SETUP_STEPS.length} simple steps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Resume Setup Notification */}
|
||||
{isResuming && (
|
||||
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-blue-500 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">
|
||||
Setup Progress Restored
|
||||
</h3>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
Your previous setup progress has been restored. You're currently on step {currentStep + 1} of {SETUP_STEPS.length}.
|
||||
{completedSteps.size > 0 && ` You've completed ${completedSteps.size} step${completedSteps.size !== 1 ? 's' : ''}.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={clearPersistedData}
|
||||
className="text-blue-500 hover:text-blue-700 text-sm font-medium"
|
||||
>
|
||||
Start Over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
|
||||
{/* Progress Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
|
||||
Reference in New Issue
Block a user