| import { useCallback, useEffect, useState } from "react"; |
| import { z } from "zod"; |
| import { useForm, useWatch } from "react-hook-form"; |
| import { zodResolver } from "@hookform/resolvers/zod"; |
| import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form"; |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| import { |
| useProjectId, |
| useGithubService, |
| useGithubRepositories, |
| useGithubRepositoriesLoading, |
| useGithubRepositoriesError, |
| useFetchGithubRepositories, |
| serviceAnalyzisSchema, |
| ServiceType, |
| ServiceData, |
| useStateStore, |
| } from "@/lib/state"; |
| import { Alert, AlertDescription } from "./ui/alert"; |
| import { AlertCircle, LoaderCircle, RefreshCw } from "lucide-react"; |
| import { Button } from "./ui/button"; |
| import { v4 as uuidv4 } from "uuid"; |
| import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; |
| import { Switch } from "./ui/switch"; |
| import { Label } from "./ui/label"; |
| import { useToast } from "@/hooks/use-toast"; |
| |
| const schema = z.object({ |
| repositoryId: z.number().optional(), |
| }); |
| |
| interface ImportModalProps { |
| open: boolean; |
| onOpenChange: (open: boolean) => void; |
| initialRepositoryId?: number; |
| } |
| |
| export function ImportModal({ open, onOpenChange, initialRepositoryId }: ImportModalProps) { |
| const { toast } = useToast(); |
| const store = useStateStore(); |
| const projectId = useProjectId(); |
| const githubService = useGithubService(); |
| const storeRepos = useGithubRepositories(); |
| const isLoadingRepos = useGithubRepositoriesLoading(); |
| const repoError = useGithubRepositoriesError(); |
| const fetchStoreRepositories = useFetchGithubRepositories(); |
| |
| const [isAnalyzing, setIsAnalyzing] = useState(false); |
| const [analysisAttempted, setAnalysisAttempted] = useState(false); |
| const [discoveredServices, setDiscoveredServices] = useState<z.infer<typeof serviceAnalyzisSchema>[]>([]); |
| const [selectedServices, setSelectedServices] = useState<Record<string, boolean>>({}); |
| |
| const form = useForm<z.infer<typeof schema>>({ |
| resolver: zodResolver(schema), |
| mode: "onChange", |
| defaultValues: { |
| repositoryId: initialRepositoryId, |
| }, |
| }); |
| |
| const selectedRepoId = useWatch({ control: form.control, name: "repositoryId" }); |
| |
| useEffect(() => { |
| form.reset({ repositoryId: initialRepositoryId }); |
| setAnalysisAttempted(false); |
| }, [initialRepositoryId, form]); |
| |
| // Clear analysis results when repository changes |
| useEffect(() => { |
| setDiscoveredServices([]); |
| setSelectedServices({}); |
| setAnalysisAttempted(false); |
| }, [selectedRepoId]); |
| |
| const analyze = useCallback( |
| async (sshURL: string) => { |
| if (!sshURL) return; |
| |
| setIsAnalyzing(true); |
| try { |
| const resp = await fetch(`/api/project/${projectId}/analyze`, { |
| method: "POST", |
| body: JSON.stringify({ |
| address: sshURL, |
| }), |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| }); |
| const servicesResult = z.array(serviceAnalyzisSchema).safeParse(await resp.json()); |
| if (!servicesResult.success) { |
| console.error(servicesResult.error); |
| toast({ |
| variant: "destructive", |
| title: "Failed to analyze repository", |
| }); |
| setIsAnalyzing(false); |
| return; |
| } |
| |
| setDiscoveredServices(servicesResult.data); |
| const initialSelectedServices: Record<string, boolean> = {}; |
| servicesResult.data.forEach((service) => { |
| initialSelectedServices[service.name] = true; |
| }); |
| setSelectedServices(initialSelectedServices); |
| } catch (err) { |
| console.error("Analysis failed:", err); |
| toast({ |
| variant: "destructive", |
| title: "Failed to analyze repository", |
| }); |
| } finally { |
| setIsAnalyzing(false); |
| } |
| }, |
| [projectId, toast], |
| ); |
| |
| // Auto-analyze when opened with initialRepositoryId |
| useEffect(() => { |
| if (open && initialRepositoryId && !isAnalyzing && !discoveredServices.length && !analysisAttempted) { |
| const repo = storeRepos.find((r) => r.id === initialRepositoryId); |
| if (repo?.ssh_url) { |
| setAnalysisAttempted(true); |
| analyze(repo.ssh_url); |
| } |
| } |
| }, [open, initialRepositoryId, isAnalyzing, discoveredServices.length, storeRepos, analyze, analysisAttempted]); |
| |
| const handleImportServices = () => { |
| const repoId = form.getValues("repositoryId"); |
| if (!repoId) return; |
| |
| const repo = storeRepos.find((r) => r.id === repoId); |
| if (!repo) return; |
| |
| // Check for existing GitHub node for this repository |
| const existingGithubNode = store.nodes.find((n) => n.type === "github" && n.data.repository?.id === repo.id); |
| |
| const githubNodeId = existingGithubNode?.id || uuidv4(); |
| |
| // Only create a new GitHub node if one doesn't exist |
| if (!existingGithubNode) { |
| store.addNode({ |
| id: githubNodeId, |
| type: "github", |
| data: { |
| label: repo.full_name, |
| repository: { |
| id: repo.id, |
| sshURL: repo.ssh_url, |
| fullName: repo.full_name, |
| }, |
| envVars: [], |
| ports: [], |
| state: null, |
| }, |
| }); |
| } |
| |
| discoveredServices.forEach((service) => { |
| if (selectedServices[service.name]) { |
| const newNodeData: Omit<ServiceData, "activeField" | "state"> = { |
| label: service.name, |
| repository: { |
| id: repo.id, |
| repoNodeId: githubNodeId, |
| }, |
| info: service, |
| type: "nodejs:24.0.2" as ServiceType, |
| env: [], |
| volume: [], |
| preBuildCommands: "", |
| isChoosingPortToConnect: false, |
| envVars: [], |
| ports: [], |
| }; |
| const newNodeId = uuidv4(); |
| store.addNode({ |
| id: newNodeId, |
| type: "app", |
| data: newNodeData, |
| }); |
| let edges = store.edges; |
| edges = edges.concat({ |
| id: uuidv4(), |
| source: githubNodeId, |
| sourceHandle: "repository", |
| target: newNodeId, |
| targetHandle: "repository", |
| }); |
| store.setEdges(edges); |
| } |
| }); |
| |
| onOpenChange(false); |
| setDiscoveredServices([]); |
| setSelectedServices({}); |
| form.reset(); |
| }; |
| |
| return ( |
| <Dialog open={open} onOpenChange={onOpenChange}> |
| <DialogContent className="sm:max-w-[425px]"> |
| <DialogHeader> |
| <DialogTitle>Import Services</DialogTitle> |
| <DialogDescription>Select a repository and analyze it for services.</DialogDescription> |
| </DialogHeader> |
| <div className="grid gap-4 py-4"> |
| <Form {...form}> |
| <form className="space-y-2"> |
| <FormField |
| control={form.control} |
| name="repositoryId" |
| render={({ field }) => ( |
| <FormItem> |
| <div className="flex items-center gap-2 w-full"> |
| <div className="flex-grow"> |
| <Select |
| onValueChange={(value) => field.onChange(Number(value))} |
| value={field.value?.toString()} |
| disabled={isLoadingRepos || !projectId || !githubService} |
| > |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue |
| placeholder={ |
| githubService |
| ? isLoadingRepos |
| ? "Loading..." |
| : storeRepos.length === 0 |
| ? "No repositories found" |
| : "Select a repository" |
| : "GitHub not configured" |
| } |
| /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| {storeRepos.map((repo) => ( |
| <SelectItem |
| key={repo.id} |
| value={repo.id.toString()} |
| className="cursor-pointer hover:bg-gray-100" |
| > |
| {repo.full_name} |
| {repo.description && ` - ${repo.description}`} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| </div> |
| {isLoadingRepos && ( |
| <Button variant="ghost" size="icon" disabled> |
| <LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" /> |
| </Button> |
| )} |
| {!isLoadingRepos && githubService && ( |
| <Button |
| variant="ghost" |
| size="icon" |
| onClick={fetchStoreRepositories} |
| aria-label="Refresh repositories" |
| > |
| <RefreshCw className="h-5 w-5 text-muted-foreground" /> |
| </Button> |
| )} |
| </div> |
| <FormMessage /> |
| {repoError && <p className="text-sm text-red-500 mt-1">{repoError}</p>} |
| {!githubService && ( |
| <Alert variant="destructive" className="mt-2"> |
| <AlertCircle className="h-4 w-4" /> |
| <AlertDescription> |
| Please configure Github Personal Access Token in the Integrations |
| tab. |
| </AlertDescription> |
| </Alert> |
| )} |
| </FormItem> |
| )} |
| /> |
| </form> |
| </Form> |
| <Button |
| disabled={!form.getValues("repositoryId") || isAnalyzing || !githubService} |
| onClick={() => { |
| const repo = storeRepos.find((r) => r.id === form.getValues("repositoryId")); |
| if (repo?.ssh_url) { |
| analyze(repo.ssh_url); |
| } |
| }} |
| > |
| {isAnalyzing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />} |
| {isAnalyzing ? "Analyzing..." : "Scan for services"} |
| </Button> |
| {discoveredServices.length > 0 && ( |
| <div className="grid gap-4"> |
| <h4 className="font-medium">Discovered Services</h4> |
| {discoveredServices.map((service) => ( |
| <div key={service.name} className="flex flex-col space-y-2 p-2 border rounded-md"> |
| <div className="flex items-center space-x-2"> |
| <Switch |
| id={service.name} |
| checked={selectedServices[service.name]} |
| onCheckedChange={(checked: boolean) => |
| setSelectedServices((prev) => ({ |
| ...prev, |
| [service.name]: checked, |
| })) |
| } |
| /> |
| <Label htmlFor={service.name} className="font-semibold"> |
| {service.name} |
| </Label> |
| </div> |
| <div className="pl-6 text-sm text-gray-600"> |
| <p> |
| <span className="font-medium">Location:</span> {service.location} |
| </p> |
| {service.configVars && service.configVars.length > 0 && ( |
| <div className="mt-1"> |
| <p className="font-medium">Environment Variables:</p> |
| <ul className="list-disc list-inside pl-4"> |
| {service.configVars.map((envVar) => ( |
| <li key={envVar.name}>{envVar.name}</li> |
| ))} |
| </ul> |
| </div> |
| )} |
| </div> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| <DialogFooter> |
| <Button variant="outline" onClick={() => onOpenChange(false)}> |
| Cancel |
| </Button> |
| <Button |
| onClick={handleImportServices} |
| disabled={!discoveredServices.length || !Object.values(selectedServices).some(Boolean)} |
| > |
| Import Selected Services |
| </Button> |
| </DialogFooter> |
| </DialogContent> |
| </Dialog> |
| ); |
| } |