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