blob: 44f66d5c9c28cb33c178cdbb0a4d70679d200353 [file] [log] [blame]
gio8e74dc02025-06-13 10:19:26 +00001import { useCallback, useEffect, useState } from "react";
2import { z } from "zod";
3import { useForm, useWatch } from "react-hook-form";
4import { zodResolver } from "@hookform/resolvers/zod";
5import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
6import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
7import {
8 useProjectId,
9 useGithubService,
10 useGithubRepositories,
11 useGithubRepositoriesLoading,
12 useGithubRepositoriesError,
13 useFetchGithubRepositories,
gio8e74dc02025-06-13 10:19:26 +000014 useStateStore,
15} from "@/lib/state";
16import { Alert, AlertDescription } from "./ui/alert";
17import { AlertCircle, LoaderCircle, RefreshCw } from "lucide-react";
18import { Button } from "./ui/button";
19import { v4 as uuidv4 } from "uuid";
20import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
21import { Switch } from "./ui/switch";
22import { Label } from "./ui/label";
23import { useToast } from "@/hooks/use-toast";
gioc31bf142025-06-16 07:48:20 +000024import { serviceAnalyzisSchema, ServiceType, ServiceData } from "config";
gio8e74dc02025-06-13 10:19:26 +000025
26const schema = z.object({
27 repositoryId: z.number().optional(),
28});
29
30interface ImportModalProps {
31 open: boolean;
32 onOpenChange: (open: boolean) => void;
33 initialRepositoryId?: number;
34}
35
36export function ImportModal({ open, onOpenChange, initialRepositoryId }: ImportModalProps) {
37 const { toast } = useToast();
38 const store = useStateStore();
39 const projectId = useProjectId();
40 const githubService = useGithubService();
41 const storeRepos = useGithubRepositories();
42 const isLoadingRepos = useGithubRepositoriesLoading();
43 const repoError = useGithubRepositoriesError();
44 const fetchStoreRepositories = useFetchGithubRepositories();
45
46 const [isAnalyzing, setIsAnalyzing] = useState(false);
47 const [analysisAttempted, setAnalysisAttempted] = useState(false);
48 const [discoveredServices, setDiscoveredServices] = useState<z.infer<typeof serviceAnalyzisSchema>[]>([]);
49 const [selectedServices, setSelectedServices] = useState<Record<string, boolean>>({});
50
51 const form = useForm<z.infer<typeof schema>>({
52 resolver: zodResolver(schema),
53 mode: "onChange",
54 defaultValues: {
55 repositoryId: initialRepositoryId,
56 },
57 });
58
59 const selectedRepoId = useWatch({ control: form.control, name: "repositoryId" });
60
61 useEffect(() => {
62 form.reset({ repositoryId: initialRepositoryId });
63 setAnalysisAttempted(false);
64 }, [initialRepositoryId, form]);
65
66 // Clear analysis results when repository changes
67 useEffect(() => {
68 setDiscoveredServices([]);
69 setSelectedServices({});
70 setAnalysisAttempted(false);
71 }, [selectedRepoId]);
72
73 const analyze = useCallback(
74 async (sshURL: string) => {
75 if (!sshURL) return;
76
77 setIsAnalyzing(true);
78 try {
79 const resp = await fetch(`/api/project/${projectId}/analyze`, {
80 method: "POST",
81 body: JSON.stringify({
82 address: sshURL,
83 }),
84 headers: {
85 "Content-Type": "application/json",
86 },
87 });
88 const servicesResult = z.array(serviceAnalyzisSchema).safeParse(await resp.json());
89 if (!servicesResult.success) {
90 console.error(servicesResult.error);
91 toast({
92 variant: "destructive",
93 title: "Failed to analyze repository",
94 });
95 setIsAnalyzing(false);
96 return;
97 }
98
99 setDiscoveredServices(servicesResult.data);
100 const initialSelectedServices: Record<string, boolean> = {};
101 servicesResult.data.forEach((service) => {
102 initialSelectedServices[service.name] = true;
103 });
104 setSelectedServices(initialSelectedServices);
105 } catch (err) {
106 console.error("Analysis failed:", err);
107 toast({
108 variant: "destructive",
109 title: "Failed to analyze repository",
110 });
111 } finally {
112 setIsAnalyzing(false);
113 }
114 },
115 [projectId, toast],
116 );
117
118 // Auto-analyze when opened with initialRepositoryId
119 useEffect(() => {
120 if (open && initialRepositoryId && !isAnalyzing && !discoveredServices.length && !analysisAttempted) {
121 const repo = storeRepos.find((r) => r.id === initialRepositoryId);
122 if (repo?.ssh_url) {
123 setAnalysisAttempted(true);
124 analyze(repo.ssh_url);
125 }
126 }
127 }, [open, initialRepositoryId, isAnalyzing, discoveredServices.length, storeRepos, analyze, analysisAttempted]);
128
129 const handleImportServices = () => {
130 const repoId = form.getValues("repositoryId");
131 if (!repoId) return;
132
133 const repo = storeRepos.find((r) => r.id === repoId);
134 if (!repo) return;
135
136 // Check for existing GitHub node for this repository
137 const existingGithubNode = store.nodes.find((n) => n.type === "github" && n.data.repository?.id === repo.id);
138
139 const githubNodeId = existingGithubNode?.id || uuidv4();
140
141 // Only create a new GitHub node if one doesn't exist
142 if (!existingGithubNode) {
143 store.addNode({
144 id: githubNodeId,
145 type: "github",
146 data: {
147 label: repo.full_name,
148 repository: {
149 id: repo.id,
150 sshURL: repo.ssh_url,
151 fullName: repo.full_name,
152 },
153 envVars: [],
154 ports: [],
155 state: null,
156 },
157 });
158 }
159
160 discoveredServices.forEach((service) => {
161 if (selectedServices[service.name]) {
162 const newNodeData: Omit<ServiceData, "activeField" | "state"> = {
163 label: service.name,
164 repository: {
165 id: repo.id,
166 repoNodeId: githubNodeId,
167 },
168 info: service,
169 type: "nodejs:24.0.2" as ServiceType,
170 env: [],
171 volume: [],
172 preBuildCommands: "",
173 isChoosingPortToConnect: false,
174 envVars: [],
175 ports: [],
176 };
177 const newNodeId = uuidv4();
178 store.addNode({
179 id: newNodeId,
180 type: "app",
181 data: newNodeData,
182 });
183 let edges = store.edges;
184 edges = edges.concat({
185 id: uuidv4(),
186 source: githubNodeId,
187 sourceHandle: "repository",
188 target: newNodeId,
189 targetHandle: "repository",
190 });
191 store.setEdges(edges);
192 }
193 });
194
195 onOpenChange(false);
196 setDiscoveredServices([]);
197 setSelectedServices({});
198 form.reset();
199 };
200
201 return (
202 <Dialog open={open} onOpenChange={onOpenChange}>
203 <DialogContent className="sm:max-w-[425px]">
204 <DialogHeader>
205 <DialogTitle>Import Services</DialogTitle>
206 <DialogDescription>Select a repository and analyze it for services.</DialogDescription>
207 </DialogHeader>
208 <div className="grid gap-4 py-4">
209 <Form {...form}>
210 <form className="space-y-2">
211 <FormField
212 control={form.control}
213 name="repositoryId"
214 render={({ field }) => (
215 <FormItem>
216 <div className="flex items-center gap-2 w-full">
217 <div className="flex-grow">
218 <Select
219 onValueChange={(value) => field.onChange(Number(value))}
220 value={field.value?.toString()}
221 disabled={isLoadingRepos || !projectId || !githubService}
222 >
223 <FormControl>
224 <SelectTrigger>
225 <SelectValue
226 placeholder={
227 githubService
228 ? isLoadingRepos
229 ? "Loading..."
230 : storeRepos.length === 0
231 ? "No repositories found"
232 : "Select a repository"
233 : "GitHub not configured"
234 }
235 />
236 </SelectTrigger>
237 </FormControl>
238 <SelectContent>
239 {storeRepos.map((repo) => (
240 <SelectItem
241 key={repo.id}
242 value={repo.id.toString()}
243 className="cursor-pointer hover:bg-gray-100"
244 >
245 {repo.full_name}
246 {repo.description && ` - ${repo.description}`}
247 </SelectItem>
248 ))}
249 </SelectContent>
250 </Select>
251 </div>
252 {isLoadingRepos && (
253 <Button variant="ghost" size="icon" disabled>
254 <LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
255 </Button>
256 )}
257 {!isLoadingRepos && githubService && (
258 <Button
259 variant="ghost"
260 size="icon"
261 onClick={fetchStoreRepositories}
262 aria-label="Refresh repositories"
263 >
264 <RefreshCw className="h-5 w-5 text-muted-foreground" />
265 </Button>
266 )}
267 </div>
268 <FormMessage />
269 {repoError && <p className="text-sm text-red-500 mt-1">{repoError}</p>}
270 {!githubService && (
271 <Alert variant="destructive" className="mt-2">
272 <AlertCircle className="h-4 w-4" />
273 <AlertDescription>
274 Please configure Github Personal Access Token in the Integrations
275 tab.
276 </AlertDescription>
277 </Alert>
278 )}
279 </FormItem>
280 )}
281 />
282 </form>
283 </Form>
284 <Button
285 disabled={!form.getValues("repositoryId") || isAnalyzing || !githubService}
286 onClick={() => {
287 const repo = storeRepos.find((r) => r.id === form.getValues("repositoryId"));
288 if (repo?.ssh_url) {
289 analyze(repo.ssh_url);
290 }
291 }}
292 >
293 {isAnalyzing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
294 {isAnalyzing ? "Analyzing..." : "Scan for services"}
295 </Button>
296 {discoveredServices.length > 0 && (
297 <div className="grid gap-4">
298 <h4 className="font-medium">Discovered Services</h4>
299 {discoveredServices.map((service) => (
300 <div key={service.name} className="flex flex-col space-y-2 p-2 border rounded-md">
301 <div className="flex items-center space-x-2">
302 <Switch
303 id={service.name}
304 checked={selectedServices[service.name]}
305 onCheckedChange={(checked: boolean) =>
306 setSelectedServices((prev) => ({
307 ...prev,
308 [service.name]: checked,
309 }))
310 }
311 />
312 <Label htmlFor={service.name} className="font-semibold">
313 {service.name}
314 </Label>
315 </div>
316 <div className="pl-6 text-sm text-gray-600">
317 <p>
318 <span className="font-medium">Location:</span> {service.location}
319 </p>
320 {service.configVars && service.configVars.length > 0 && (
321 <div className="mt-1">
322 <p className="font-medium">Environment Variables:</p>
323 <ul className="list-disc list-inside pl-4">
324 {service.configVars.map((envVar) => (
325 <li key={envVar.name}>{envVar.name}</li>
326 ))}
327 </ul>
328 </div>
329 )}
330 </div>
331 </div>
332 ))}
333 </div>
334 )}
335 </div>
336 <DialogFooter>
337 <Button variant="outline" onClick={() => onOpenChange(false)}>
338 Cancel
339 </Button>
340 <Button
341 onClick={handleImportServices}
342 disabled={!discoveredServices.length || !Object.values(selectedServices).some(Boolean)}
343 >
344 Import Selected Services
345 </Button>
346 </DialogFooter>
347 </DialogContent>
348 </Dialog>
349 );
350}