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:
anthonyrawlins
2025-08-19 00:19:00 +10:00
parent 6a6a49b7b1
commit c177363a19
16410 changed files with 1789161 additions and 230 deletions

View File

@@ -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>

View File

@@ -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>

View 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>
)
}

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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>

View 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>
)
}

View File

@@ -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>

View File

@@ -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">