diff --git a/internal/models/profile.go b/internal/models/profile.go index a564232..76477c0 100644 --- a/internal/models/profile.go +++ b/internal/models/profile.go @@ -35,6 +35,7 @@ type CreateProfileRequest struct { ProjectsDir string `json:"projectsDir"` JavaHomeOverride string `json:"javaHomeOverride"` IsDefault bool `json:"isDefault"` + IsActive bool `json:"isActive"` } type UpdateProfileRequest struct { diff --git a/internal/services/profile.go b/internal/services/profile.go index 0d37964..a5d08c3 100644 --- a/internal/services/profile.go +++ b/internal/services/profile.go @@ -239,6 +239,13 @@ func (ps *ProfileService) CreateServiceProfile(userID string, req *models.Create } } + // Handle active profile logic + if req.IsActive { + if err := ps.clearActiveProfiles(userID); err != nil { + return nil, fmt.Errorf("failed to clear existing active profiles: %w", err) + } + } + // Validate services exist (temporarily disabled for debugging) log.Printf("[DEBUG] Skipping service validation for debugging purposes") // if err := ps.validateServices(req.Services); err != nil { @@ -261,10 +268,10 @@ func (ps *ProfileService) CreateServiceProfile(userID string, req *models.Create return nil, fmt.Errorf("failed to marshal env vars: %w", err) } - query := `INSERT INTO service_profiles (id, user_id, name, description, services_json, env_vars_json, projects_dir, java_home_override, is_default, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` + query := `INSERT INTO service_profiles (id, user_id, name, description, services_json, env_vars_json, projects_dir, java_home_override, is_default, is_active, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)` - _, err = ps.db.Exec(query, profileID, userID, req.Name, req.Description, string(servicesJSON), string(envVarsJSON), req.ProjectsDir, req.JavaHomeOverride, req.IsDefault) + _, err = ps.db.Exec(query, profileID, userID, req.Name, req.Description, string(servicesJSON), string(envVarsJSON), req.ProjectsDir, req.JavaHomeOverride, req.IsDefault, req.IsActive) if err != nil { return nil, fmt.Errorf("failed to create service profile: %w", err) } @@ -719,6 +726,12 @@ func (ps *ProfileService) clearDefaultProfiles(userID string) error { return err } +func (ps *ProfileService) clearActiveProfiles(userID string) error { + query := `UPDATE service_profiles SET is_active = FALSE WHERE user_id = ? AND is_active = TRUE` + _, err := ps.db.Exec(query, userID) + return err +} + func (ps *ProfileService) validateServices(serviceNames []string) error { if ps.sm == nil { log.Printf("[WARN] Service manager not available, skipping service validation") diff --git a/web/src/components/AutoDiscoveryModal/AutoDiscoveryModal.tsx b/web/src/components/AutoDiscoveryModal/AutoDiscoveryModal.tsx index b89d5a6..6bab7aa 100644 --- a/web/src/components/AutoDiscoveryModal/AutoDiscoveryModal.tsx +++ b/web/src/components/AutoDiscoveryModal/AutoDiscoveryModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState } from "react"; import { X, Search, @@ -9,15 +9,15 @@ import { Server, RefreshCw, FolderOpen, - Plus -} from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Input } from '@/components/ui/input'; -import { Checkbox } from '@/components/ui/checkbox'; -import { useAuth } from '@/contexts/AuthContext'; -import { useToast, toast } from '@/components/ui/toast'; + Plus, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useAuth } from "@/contexts/AuthContext"; +import { useToast, toast } from "@/components/ui/toast"; interface DiscoveredService { name: string; @@ -40,105 +40,117 @@ interface AutoDiscoveryModalProps { export function AutoDiscoveryModal({ isOpen, onClose, - onServiceImported + onServiceImported, }: AutoDiscoveryModalProps) { const { token } = useAuth(); const { addToast } = useToast(); - const [discoveredServices, setDiscoveredServices] = useState([]); + const [discoveredServices, setDiscoveredServices] = useState< + DiscoveredService[] + >([]); const [isScanning, setIsScanning] = useState(false); const [isImporting, setIsImporting] = useState>({}); const [isBulkImporting, setIsBulkImporting] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); + const [searchTerm, setSearchTerm] = useState(""); const [hasScanned, setHasScanned] = useState(false); - const [selectedServices, setSelectedServices] = useState>(new Set()); + const [selectedServices, setSelectedServices] = useState>( + new Set(), + ); - const filteredServices = discoveredServices.filter(service => - service.name.toLowerCase().includes(searchTerm.toLowerCase()) || - service.type.toLowerCase().includes(searchTerm.toLowerCase()) || - service.framework.toLowerCase().includes(searchTerm.toLowerCase()) + const filteredServices = discoveredServices.filter( + (service) => + service.name.toLowerCase().includes(searchTerm.toLowerCase()) || + service.type.toLowerCase().includes(searchTerm.toLowerCase()) || + service.framework.toLowerCase().includes(searchTerm.toLowerCase()), ); const scanForServices = async () => { setIsScanning(true); try { const headers: Record = { - 'Content-Type': 'application/json' + "Content-Type": "application/json", }; - + if (token) { - headers['Authorization'] = `Bearer ${token}`; + headers["Authorization"] = `Bearer ${token}`; } - const response = await fetch('/api/auto-discovery/scan', { - method: 'POST', - headers + const response = await fetch("/api/auto-discovery/scan", { + method: "POST", + headers, }); if (!response.ok) { - throw new Error(`Failed to scan: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to scan: ${response.status} ${response.statusText}`, + ); } const result = await response.json(); setDiscoveredServices(result.discoveredServices || []); setHasScanned(true); } catch (error) { - console.error('Failed to scan for services:', error); - alert('Failed to scan for services: ' + (error instanceof Error ? error.message : 'Unknown error')); + console.error("Failed to scan for services:", error); + alert( + "Failed to scan for services: " + + (error instanceof Error ? error.message : "Unknown error"), + ); } finally { setIsScanning(false); } }; const importService = async (service: DiscoveredService) => { - setIsImporting(prev => ({ ...prev, [service.name]: true })); + setIsImporting((prev) => ({ ...prev, [service.name]: true })); try { const headers: Record = { - 'Content-Type': 'application/json' + "Content-Type": "application/json", }; // Add Authorization header if token exists if (token) { - headers['Authorization'] = `Bearer ${token}`; + headers["Authorization"] = `Bearer ${token}`; } - const response = await fetch('/api/auto-discovery/import', { - method: 'POST', + const response = await fetch("/api/auto-discovery/import", { + method: "POST", headers, - body: JSON.stringify(service) + body: JSON.stringify(service), }); if (!response.ok) { - throw new Error(`Failed to import service: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to import service: ${response.status} ${response.statusText}`, + ); } const result = await response.json(); - console.log('Service imported successfully:', result); - + console.log("Service imported successfully:", result); + // Mark service as existing in our local state - setDiscoveredServices(prev => - prev.map(s => s.name === service.name ? { ...s, exists: true } : s) + setDiscoveredServices((prev) => + prev.map((s) => (s.name === service.name ? { ...s, exists: true } : s)), ); // Show success toast addToast( toast.success( - 'Service imported', - `${service.name} has been imported and added to active profile` - ) + "Service imported", + `${service.name} has been imported and added to active profile`, + ), ); // Notify parent component onServiceImported(); } catch (error) { - console.error('Failed to import service:', error); + console.error("Failed to import service:", error); addToast( toast.error( - 'Failed to import service', - error instanceof Error ? error.message : 'Unknown error' - ) + "Failed to import service", + error instanceof Error ? error.message : "Unknown error", + ), ); } finally { - setIsImporting(prev => ({ ...prev, [service.name]: false })); + setIsImporting((prev) => ({ ...prev, [service.name]: false })); } }; @@ -146,60 +158,64 @@ export function AutoDiscoveryModal({ if (selectedServices.size === 0) { addToast( toast.warning( - 'No services selected', - 'Please select services to import' - ) + "No services selected", + "Please select services to import", + ), ); return; } setIsBulkImporting(true); try { - const servicesToImport = discoveredServices.filter(service => - selectedServices.has(service.name) && !service.exists + const servicesToImport = discoveredServices.filter( + (service) => selectedServices.has(service.name) && !service.exists, ); if (servicesToImport.length === 0) { addToast( toast.info( - 'Services already imported', - 'All selected services are already imported' - ) + "Services already imported", + "All selected services are already imported", + ), ); return; } const headers: Record = { - 'Content-Type': 'application/json' + "Content-Type": "application/json", }; // Add Authorization header if token exists if (token) { - headers['Authorization'] = `Bearer ${token}`; + headers["Authorization"] = `Bearer ${token}`; } - const response = await fetch('/api/auto-discovery/import-bulk', { - method: 'POST', + const response = await fetch("/api/auto-discovery/import-bulk", { + method: "POST", headers, - body: JSON.stringify({ services: servicesToImport }) + body: JSON.stringify({ services: servicesToImport }), }); if (!response.ok) { - throw new Error(`Failed to bulk import services: ${response.status} ${response.statusText}`); + throw new Error( + `Failed to bulk import services: ${response.status} ${response.statusText}`, + ); } const result = await response.json(); - console.log('Bulk import completed:', result); - + console.log("Bulk import completed:", result); + // Mark only successfully imported services as existing in our local state - const importedServiceNames = new Set(result.importedServices?.map((s: any) => s.name) || []); - setDiscoveredServices(prev => - prev.map(service => { + const importedServiceNames = new Set( + result.importedServices?.map((s: any) => s.name) || [], + ); + setDiscoveredServices((prev) => + prev.map((service) => { if (importedServiceNames.has(service.name)) { return { ...service, exists: true }; } return service; - }) + }), ); // Clear selection @@ -212,25 +228,25 @@ export function AutoDiscoveryModal({ if (result.errors && result.errors.length > 0) { addToast( toast.warning( - 'Bulk import completed with errors', - `${result.totalImported} services imported successfully, but ${result.errors.length} failed. Check logs for details.` - ) + "Bulk import completed with errors", + `${result.totalImported} services imported successfully, but ${result.errors.length} failed. Check logs for details.`, + ), ); } else { addToast( toast.success( - 'Bulk import successful', - `Successfully imported ${result.totalImported} service(s) and added to active profile` - ) + "Bulk import successful", + `Successfully imported ${result.totalImported} service(s) and added to active profile`, + ), ); } } catch (error) { - console.error('Failed to bulk import services:', error); + console.error("Failed to bulk import services:", error); addToast( toast.error( - 'Failed to bulk import services', - error instanceof Error ? error.message : 'Unknown error' - ) + "Failed to bulk import services", + error instanceof Error ? error.message : "Unknown error", + ), ); } finally { setIsBulkImporting(false); @@ -238,7 +254,7 @@ export function AutoDiscoveryModal({ }; const toggleServiceSelection = (serviceName: string) => { - setSelectedServices(prev => { + setSelectedServices((prev) => { const newSet = new Set(prev); if (newSet.has(serviceName)) { newSet.delete(serviceName); @@ -250,29 +266,35 @@ export function AutoDiscoveryModal({ }; const toggleSelectAll = () => { - const availableServices = filteredServices.filter(service => !service.exists); - const allSelected = availableServices.every(service => selectedServices.has(service.name)); - + const availableServices = filteredServices.filter( + (service) => !service.exists, + ); + const allSelected = availableServices.every((service) => + selectedServices.has(service.name), + ); + if (allSelected) { // Deselect all setSelectedServices(new Set()); } else { // Select all available services - setSelectedServices(new Set(availableServices.map(service => service.name))); + setSelectedServices( + new Set(availableServices.map((service) => service.name)), + ); } }; const getServiceTypeIcon = (type: string) => { switch (type.toLowerCase()) { - case 'registry': + case "registry": return ; - case 'config-server': + case "config-server": return ; - case 'api-gateway': + case "api-gateway": return ; - case 'authentication': + case "authentication": return ; - case 'cache': + case "cache": return ; default: return ; @@ -281,18 +303,18 @@ export function AutoDiscoveryModal({ const getServiceTypeBadgeColor = (type: string) => { switch (type.toLowerCase()) { - case 'registry': - return 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200'; - case 'config-server': - return 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200'; - case 'api-gateway': - return 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200'; - case 'authentication': - return 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200'; - case 'cache': - return 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200'; + case "registry": + return "bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200"; + case "config-server": + return "bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200"; + case "api-gateway": + return "bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200"; + case "authentication": + return "bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200"; + case "cache": + return "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-200"; default: - return 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200'; + return "bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200"; } }; @@ -303,7 +325,7 @@ export function AutoDiscoveryModal({
{/* Backdrop */}
- + {/* Modal */}
@@ -318,7 +340,8 @@ export function AutoDiscoveryModal({ Auto-Discovery

- Automatically detect Spring Boot services in your project directory + Automatically detect Spring Boot services in your project + directory

@@ -328,8 +351,10 @@ export function AutoDiscoveryModal({ disabled={isScanning} className="bg-green-600 hover:bg-green-700" > - - {isScanning ? 'Scanning...' : 'Scan Directory'} + + {isScanning ? "Scanning..." : "Scan Directory"}
)} @@ -383,7 +410,8 @@ export function AutoDiscoveryModal({ className="w-64" /> - {filteredServices.length} of {discoveredServices.length} services + {filteredServices.length} of{" "} + {discoveredServices.length} services
)} @@ -581,4 +658,4 @@ export function AutoDiscoveryModal({ ); } -export default AutoDiscoveryModal; \ No newline at end of file +export default AutoDiscoveryModal; diff --git a/web/src/components/ConfigurationManager/ConfigurationManager.tsx b/web/src/components/ConfigurationManager/ConfigurationManager.tsx index 215b5a7..e01de44 100644 --- a/web/src/components/ConfigurationManager/ConfigurationManager.tsx +++ b/web/src/components/ConfigurationManager/ConfigurationManager.tsx @@ -82,9 +82,7 @@ export function ConfigurationManager({ // Add any missing services as disabled const missingServices = services - .filter( - (service) => !existingServices.find((es) => es.id === service.id), - ) + .filter((service) => !existingServices.find((es) => es.id === service.id)) .map((service) => ({ id: service.id, name: service.name, diff --git a/web/src/components/GlobalConfigModal/GlobalConfigModal.tsx b/web/src/components/GlobalConfigModal/GlobalConfigModal.tsx index 45a303d..af89513 100644 --- a/web/src/components/GlobalConfigModal/GlobalConfigModal.tsx +++ b/web/src/components/GlobalConfigModal/GlobalConfigModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { X, Save, Folder, Coffee, RefreshCw } from "lucide-react"; +import { X, Save, Folder, Coffee, RefreshCw, Star, HelpCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -18,12 +18,14 @@ interface GlobalConfigModalProps { isOpen: boolean; onClose: () => void; onConfigUpdated?: () => void; + onboarding?: any; } export function GlobalConfigModal({ isOpen, onClose, onConfigUpdated, + onboarding, }: GlobalConfigModalProps) { const [config, setConfig] = useState({ projectsDir: "", @@ -246,6 +248,43 @@ export function GlobalConfigModal({ + {/* Onboarding Section */} + {onboarding && ( +
+ +

+ Need help setting up your workspace? Run the setup wizard again. +

+
+ + +
+

+ The setup wizard will help you create profiles, discover services, and configure startup order. +

+
+ )} + {/* Configuration Status */}
diff --git a/web/src/components/Onboarding/OnboardingConfigurationStep.tsx b/web/src/components/Onboarding/OnboardingConfigurationStep.tsx new file mode 100644 index 0000000..fe67edb --- /dev/null +++ b/web/src/components/Onboarding/OnboardingConfigurationStep.tsx @@ -0,0 +1,367 @@ +import { useState, useEffect } from 'react'; +import { + Settings, + GripVertical, + Server, + CheckCircle, + Loader2, + ArrowUp, + ArrowDown +} from 'lucide-react'; +import { + DragDropContext, + Droppable, + Draggable, + DropResult, +} from "react-beautiful-dnd"; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { useToast, toast } from '@/components/ui/toast'; +import { Service } from '@/types'; + +interface OnboardingConfigurationStepProps { + services: Service[]; + onConfigurationComplete: () => void; + isProcessing: boolean; + setIsProcessing: (processing: boolean) => void; +} + +interface ConfigService { + id: string; + name: string; + order: number; + enabled: boolean; +} + +export function OnboardingConfigurationStep({ + services, + onConfigurationComplete, + isProcessing, + setIsProcessing +}: OnboardingConfigurationStepProps) { + const { addToast } = useToast(); + const [configServices, setConfigServices] = useState([]); + const [configName] = useState("Onboarding Configuration"); + + // Initialize service configuration + useEffect(() => { + const servicesWithConfig = services + .map((service, index) => ({ + id: service.id, + name: service.name, + order: index + 1, + enabled: true, + })) + .sort((a, b) => a.order - b.order); + + setConfigServices(servicesWithConfig); + }, [services]); + + const moveService = (index: number, direction: "up" | "down") => { + const newServices = [...configServices]; + const targetIndex = direction === "up" ? index - 1 : index + 1; + + if (targetIndex >= 0 && targetIndex < newServices.length) { + // Swap the services + [newServices[index], newServices[targetIndex]] = [ + newServices[targetIndex], + newServices[index], + ]; + + // Update orders + newServices.forEach((service, idx) => { + service.order = idx + 1; + }); + + setConfigServices(newServices); + } + }; + + const toggleService = (index: number) => { + const newServices = [...configServices]; + newServices[index].enabled = !newServices[index].enabled; + setConfigServices(newServices); + }; + + const handleDragEnd = (result: DropResult) => { + if (!result.destination) { + return; + } + + const items = Array.from(configServices); + const [reorderedItem] = items.splice(result.source.index, 1); + items.splice(result.destination.index, 0, reorderedItem); + + // Update orders + items.forEach((service, idx) => { + service.order = idx + 1; + }); + + setConfigServices(items); + }; + + const handleSaveConfiguration = async () => { + const enabledServices = configServices + .filter((s) => s.enabled) + .map((s) => ({ id: s.id, name: s.name, order: s.order })); + + if (enabledServices.length === 0) { + addToast( + toast.warning( + "No services selected", + "Please select at least one service for the configuration", + ), + ); + return; + } + + const configData = { + id: `onboarding-config-${Date.now()}`, + name: configName, + services: enabledServices, + isDefault: true, // Make this the default configuration + }; + + try { + setIsProcessing(true); + const response = await fetch("/api/configurations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(configData), + }); + + if (!response.ok) { + throw new Error( + `Failed to save configuration: ${response.status} ${response.statusText}`, + ); + } + + const result = await response.json(); + + // Apply the configuration immediately after creating it + const applyResponse = await fetch(`/api/configurations/${result.id}/apply`, { + method: "POST", + }); + + if (!applyResponse.ok) { + throw new Error( + `Failed to apply configuration: ${applyResponse.status} ${applyResponse.statusText}`, + ); + } + + addToast( + toast.success( + "Configuration Applied!", + `Successfully created and applied "${configName}" with ${enabledServices.length} services`, + ), + ); + + onConfigurationComplete(); + } catch (error) { + console.error("Failed to save configuration:", error); + addToast( + toast.error( + "Failed to save configuration", + error instanceof Error + ? error.message + : "An unexpected error occurred", + ), + ); + } finally { + setIsProcessing(false); + } + }; + + const handleSkipConfiguration = () => { + addToast( + toast.info( + 'Configuration Skipped', + 'You can configure service order and settings later from the configurations page.' + ) + ); + onConfigurationComplete(); + }; + + return ( +
+ {/* Header */} +
+
+ +
+

+ Configure Service Order +

+

+ Set the startup order for your services. Drag services to reorder them or use the arrow buttons. + Services higher in the list will start first. +

+
+ + {services.length === 0 ? ( + /* No Services */ + + + +

+ No Services to Configure +

+

+ No services were imported in the previous step. You can add services manually later. +

+ +
+
+ ) : ( + /* Service Configuration */ + <> + + + + + Services Configuration ({configServices.length} services) + +

+ Select and reorder services for this configuration. You can drag services or use the arrow buttons to reorder them. +

+
+ +
+ + + {(provided) => ( +
+ {configServices.map((service, index) => ( + + {(provided, snapshot) => ( +
+ + toggleService(index) + } + /> + +
+
+ +
+ + #{service.order} + +
+ + + {service.name} + + +
+ + +
+
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+
+
+
+ + {/* Actions */} +
+ + +
+ + )} + + {/* Tips */} + + +

+ ⚙️ Configuration Tips +

+
    +
  • Service Registry (Eureka) should typically start first
  • +
  • Config Server should start early, before other services
  • +
  • API Gateway usually starts after core services are ready
  • +
  • • You can uncheck services you don't want in this configuration
  • +
  • • This will create and apply a default configuration for your profile
  • +
  • • The configuration will become active and control service startup order
  • +
+
+
+
+ ); +} + +export default OnboardingConfigurationStep; \ No newline at end of file diff --git a/web/src/components/Onboarding/OnboardingDiscoveryStep.tsx b/web/src/components/Onboarding/OnboardingDiscoveryStep.tsx new file mode 100644 index 0000000..fb65ac1 --- /dev/null +++ b/web/src/components/Onboarding/OnboardingDiscoveryStep.tsx @@ -0,0 +1,378 @@ +import { useState, useEffect } from 'react'; +import { + Search, + Server, + RefreshCw, + Loader2, + Download +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { useAuth } from '@/contexts/AuthContext'; +import { useToast, toast } from '@/components/ui/toast'; +import { Service } from '@/types'; + +interface DiscoveredService { + name: string; + path: string; + port: number; + type: string; + framework: string; + description: string; + properties: Record; + isValid: boolean; + exists: boolean; +} + +interface OnboardingDiscoveryStepProps { + profile: any; + onServicesDiscovered: (services: Service[]) => void; + isProcessing: boolean; + setIsProcessing: (processing: boolean) => void; +} + +export function OnboardingDiscoveryStep({ + profile, + onServicesDiscovered, + isProcessing, + setIsProcessing +}: OnboardingDiscoveryStepProps) { + const { token } = useAuth(); + const { addToast } = useToast(); + + const [discoveredServices, setDiscoveredServices] = useState([]); + const [selectedServices, setSelectedServices] = useState([]); + const [isScanning, setIsScanning] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [hasScanned, setHasScanned] = useState(false); + + // Auto-start discovery when step loads + useEffect(() => { + if (profile && !hasScanned) { + scanForServices(); + } + }, [profile]); + + // Use the same scanForServices function as AutoDiscoveryModal + const scanForServices = async () => { + setIsScanning(true); + setHasScanned(true); + + try { + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch('/api/auto-discovery/scan', { + method: 'POST', + headers, + }); + + if (!response.ok) { + throw new Error(`Discovery failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const services = data.discoveredServices || []; + + setDiscoveredServices(services); + + // Auto-select all valid services + const validServiceNames = services + .filter((service: DiscoveredService) => service.isValid && !service.exists) + .map((service: DiscoveredService) => service.name); + + setSelectedServices(validServiceNames); + + if (services.length === 0) { + addToast( + toast.info( + 'No Services Found', + 'No microservices were discovered in the specified directory. You can add services manually later.' + ) + ); + } else { + addToast( + toast.success( + 'Services Discovered!', + `Found ${services.length} potential services. Review and select which ones to import.` + ) + ); + } + } catch (error) { + console.error('Auto-discovery failed:', error); + addToast( + toast.error( + 'Discovery Failed', + error instanceof Error ? error.message : 'Failed to discover services' + ) + ); + } finally { + setIsScanning(false); + } + }; + + + const handleServiceToggle = (serviceName: string, checked: boolean) => { + setSelectedServices(prev => + checked + ? [...prev, serviceName] + : prev.filter(name => name !== serviceName) + ); + }; + + const handleImportServices = async () => { + if (selectedServices.length === 0) { + // Allow proceeding with no services + onServicesDiscovered([]); + return; + } + + setIsProcessing(true); + setIsImporting(true); + + try { + const servicesToImport = discoveredServices + .filter(service => selectedServices.includes(service.name)) + .map(service => ({ + name: service.name, + path: service.path, + port: service.port, + type: service.type, + framework: service.framework, + description: service.description, + properties: service.properties, + profileId: profile.id, + })); + + const response = await fetch('/api/auto-discovery/import-bulk', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ + services: servicesToImport, + }), + }); + + if (!response.ok) { + throw new Error(`Import failed: ${response.status} ${response.statusText}`); + } + + const result = await response.json(); + const importedServices = result.importedServices || []; + + addToast( + toast.success( + 'Services Imported!', + `Successfully imported ${importedServices.length} services to "${profile.name}" profile.` + ) + ); + + onServicesDiscovered(importedServices); + } catch (error) { + console.error('Failed to import services:', error); + addToast( + toast.error( + 'Import Failed', + error instanceof Error ? error.message : 'Failed to import services' + ) + ); + } finally { + setIsProcessing(false); + setIsImporting(false); + } + }; + + const handleSkipDiscovery = () => { + addToast( + toast.info( + 'Discovery Skipped', + 'You can add services manually later from the main dashboard.' + ) + ); + onServicesDiscovered([]); + }; + + return ( +
+ {/* Header */} +
+
+ +
+

+ Discover Your Services +

+

+ Let's automatically discover microservices in your "{profile?.name}" profile directory. + We'll scan for Spring Boot, Node.js, and other common frameworks. +

+
+ + {/* Scan Status */} + {isScanning && ( + + +
+ +
+
Scanning for services...
+
+ Discovering microservices in "{profile?.name}" profile directory +
+
+
+
+
+ )} + + {/* Discovered Services */} + {hasScanned && ( + + + + + Discovered Services ({discoveredServices.length}) + + {discoveredServices.length > 0 && ( +
+ s.isValid && !s.exists).length} + onCheckedChange={(checked) => { + const validServices = discoveredServices + .filter(s => s.isValid && !s.exists) + .map(s => s.name); + setSelectedServices(checked ? validServices : []); + }} + /> + Select All +
+ )} +
+ + {discoveredServices.length === 0 ? ( +
+ +

+ No Services Found +

+

+ No microservices were discovered in the specified directory. +

+
+ + +
+
+ ) : ( +
+ {discoveredServices.map((service) => ( +
+ handleServiceToggle(service.name, checked === true)} + disabled={!service.isValid || service.exists} + /> + +
+
+

+ {service.name} +

+ + {service.framework} + + {service.exists && ( + Already Exists + )} +
+ +

+ {service.description} +

+ +
+ Port: {service.port} + Type: {service.type} + Path: {service.path} +
+
+
+ ))} +
+ )} +
+
+ )} + + {/* Actions */} + {hasScanned && ( +
+ + +
+ )} + + {/* Tips */} + + +

+ 🔍 Discovery Tips +

+
    +
  • • Auto-discovery looks for common project files (pom.xml, package.json, etc.)
  • +
  • • Services already in Vertex will be marked as "Already Exists"
  • +
  • • You can always add more services manually after onboarding
  • +
  • • Selected services will be added to your "{profile?.name}" profile
  • +
+
+
+
+ ); +} + +export default OnboardingDiscoveryStep; \ No newline at end of file diff --git a/web/src/components/Onboarding/OnboardingProfileStep.tsx b/web/src/components/Onboarding/OnboardingProfileStep.tsx new file mode 100644 index 0000000..5fa1912 --- /dev/null +++ b/web/src/components/Onboarding/OnboardingProfileStep.tsx @@ -0,0 +1,260 @@ +import { useState } from 'react'; +import { + User, + Folder, + Coffee, + Star, + AlertCircle, + CheckCircle, + Loader2 +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useProfile } from '@/contexts/ProfileContext'; +import { useToast, toast } from '@/components/ui/toast'; +import { CreateProfileRequest } from '@/types'; + +interface OnboardingProfileStepProps { + onProfileCreated: (profile: any) => void; + isProcessing: boolean; + setIsProcessing: (processing: boolean) => void; +} + +export function OnboardingProfileStep({ + onProfileCreated, + isProcessing, + setIsProcessing +}: OnboardingProfileStepProps) { + const { createProfile } = useProfile(); + const { addToast } = useToast(); + + const [formData, setFormData] = useState({ + name: '', + description: '', + services: [], + envVars: {}, + projectsDir: '', + javaHomeOverride: '', + isDefault: true, // Make first profile default + isActive: true, // Set as active during onboarding + }); + + const [errors, setErrors] = useState>({}); + + const validateForm = () => { + const newErrors: Record = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Profile name is required'; + } else if (formData.name.length < 2) { + newErrors.name = 'Profile name must be at least 2 characters'; + } + + if (!formData.projectsDir.trim()) { + newErrors.projectsDir = 'Projects directory is required'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (!validateForm()) return; + + setIsProcessing(true); + try { + const profile = await createProfile(formData); + + addToast( + toast.success( + 'Profile Created!', + `"${formData.name}" profile has been created successfully.` + ) + ); + + onProfileCreated(profile); + } catch (error) { + console.error('Failed to create profile:', error); + addToast( + toast.error( + 'Failed to create profile', + error instanceof Error ? error.message : 'An unexpected error occurred' + ) + ); + } finally { + setIsProcessing(false); + } + }; + + const handleInputChange = (field: keyof CreateProfileRequest, value: any) => { + setFormData(prev => ({ ...prev, [field]: value })); + // Clear error for this field + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: '' })); + } + }; + + return ( +
+ {/* Header */} +
+
+ +
+

+ Create Your First Profile +

+

+ Profiles help you organize and manage different environments or projects. + Let's start by creating your main workspace profile. +

+
+ + {/* Form */} + + + + + Profile Configuration + + + + {/* Profile Name */} +
+ + handleInputChange('name', e.target.value)} + placeholder="e.g., Development, Staging, Main Workspace" + className={errors.name ? 'border-red-500' : ''} + /> + {errors.name && ( +
+ + {errors.name} +
+ )} +
+ + {/* Description */} +
+ +