blob: dd7c68afe82b898194a5d68bfb9985f03c2c7342 [file] [log] [blame]
giod0026612025-05-08 13:00:36 +00001import { NodeRect } from "./node-rect";
gioa71316d2025-05-24 09:41:36 +04002import {
3 GithubNode,
4 nodeIsConnectable,
5 nodeLabel,
6 serviceAnalyzisSchema,
7 useStateStore,
8 useGithubService,
9 ServiceType,
10 ServiceData,
11 useGithubRepositories,
12 useGithubRepositoriesLoading,
13 useGithubRepositoriesError,
14 useFetchGithubRepositories,
15} from "@/lib/state";
16import { useCallback, useEffect, useMemo, useState } from "react";
gio5f2f1002025-03-20 18:38:48 +040017import { z } from "zod";
giod0026612025-05-08 13:00:36 +000018import { DeepPartial, EventType, useForm } from "react-hook-form";
19import { zodResolver } from "@hookform/resolvers/zod";
20import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
gio5f2f1002025-03-20 18:38:48 +040021import { Handle, Position } from "@xyflow/react";
giod0026612025-05-08 13:00:36 +000022import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
23import { GitHubRepository } from "../lib/github";
24import { useProjectId } from "@/lib/state";
25import { Alert, AlertDescription } from "./ui/alert";
gioa71316d2025-05-24 09:41:36 +040026import { AlertCircle, LoaderCircle, RefreshCw } from "lucide-react";
27import { Button } from "./ui/button";
28import { v4 as uuidv4 } from "uuid";
29import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
30import { Switch } from "./ui/switch";
31import { Label } from "./ui/label";
gio5f2f1002025-03-20 18:38:48 +040032
33export function NodeGithub(node: GithubNode) {
giod0026612025-05-08 13:00:36 +000034 const { id, selected } = node;
35 const isConnectable = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
36 return (
37 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
38 <div style={{ padding: "10px 20px" }}>
39 {nodeLabel(node)}
40 <Handle
41 id="repository"
42 type={"source"}
43 position={Position.Right}
44 isConnectableStart={isConnectable}
45 isConnectableEnd={isConnectable}
46 isConnectable={isConnectable}
47 />
48 </div>
49 </NodeRect>
50 );
gio5f2f1002025-03-20 18:38:48 +040051}
52
53const schema = z.object({
giod0026612025-05-08 13:00:36 +000054 repositoryId: z.number().optional(),
gio5f2f1002025-03-20 18:38:48 +040055});
56
gio3ec94242025-05-16 12:46:57 +000057export function NodeGithubDetails({ id, data, disabled }: GithubNode & { disabled?: boolean }) {
giod0026612025-05-08 13:00:36 +000058 const store = useStateStore();
59 const projectId = useProjectId();
giod0026612025-05-08 13:00:36 +000060 const githubService = useGithubService();
gio7f98e772025-05-07 11:00:14 +000061
gioa71316d2025-05-24 09:41:36 +040062 const storeRepos = useGithubRepositories();
63 const isLoadingRepos = useGithubRepositoriesLoading();
64 const repoError = useGithubRepositoriesError();
65 const fetchStoreRepositories = useFetchGithubRepositories();
66
67 const [displayRepos, setDisplayRepos] = useState<GitHubRepository[]>([]);
68
69 const [isAnalyzing, setIsAnalyzing] = useState(false);
70 const [showModal, setShowModal] = useState(false);
71 const [discoveredServices, setDiscoveredServices] = useState<z.infer<typeof serviceAnalyzisSchema>[]>([]);
72 const [selectedServices, setSelectedServices] = useState<Record<string, boolean>>({});
73
giod0026612025-05-08 13:00:36 +000074 useEffect(() => {
gioa71316d2025-05-24 09:41:36 +040075 let currentRepoInStore = false;
giod0026612025-05-08 13:00:36 +000076 if (data.repository) {
gioa71316d2025-05-24 09:41:36 +040077 currentRepoInStore = storeRepos.some((r) => r.id === data.repository!.id);
giod0026612025-05-08 13:00:36 +000078 }
gioa71316d2025-05-24 09:41:36 +040079
80 if (data.repository && !currentRepoInStore) {
81 const currentRepoForDisplay: GitHubRepository = {
82 id: data.repository.id,
83 name: data.repository.sshURL.split("/").pop() || "",
84 full_name: data.repository.fullName || data.repository.sshURL.split("/").slice(-2).join("/"),
85 html_url: "",
86 ssh_url: data.repository.sshURL,
87 description: null,
88 private: false,
89 default_branch: "main",
90 };
91 setDisplayRepos([currentRepoForDisplay, ...storeRepos.filter((r) => r.id !== data.repository!.id)]);
92 } else {
93 setDisplayRepos(storeRepos);
94 }
95 }, [data.repository, storeRepos]);
gio7f98e772025-05-07 11:00:14 +000096
giod0026612025-05-08 13:00:36 +000097 const form = useForm<z.infer<typeof schema>>({
98 resolver: zodResolver(schema),
99 mode: "onChange",
100 defaultValues: {
101 repositoryId: data.repository?.id,
102 },
103 });
gio7f98e772025-05-07 11:00:14 +0000104
giod0026612025-05-08 13:00:36 +0000105 useEffect(() => {
gioa71316d2025-05-24 09:41:36 +0400106 form.reset({ repositoryId: data.repository?.id });
107 }, [data.repository?.id, form]);
108
109 useEffect(() => {
giod0026612025-05-08 13:00:36 +0000110 const sub = form.watch(
111 (
112 value: DeepPartial<z.infer<typeof schema>>,
113 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
114 ) => {
115 if (type !== "change") {
116 return;
117 }
118 switch (name) {
119 case "repositoryId":
120 if (value.repositoryId) {
gioa71316d2025-05-24 09:41:36 +0400121 const repo = displayRepos.find((r) => r.id === value.repositoryId);
giod0026612025-05-08 13:00:36 +0000122 if (repo) {
123 store.updateNodeData<"github">(id, {
124 repository: {
125 id: repo.id,
126 sshURL: repo.ssh_url,
gio818da4e2025-05-12 14:45:35 +0000127 fullName: repo.full_name,
giod0026612025-05-08 13:00:36 +0000128 },
129 });
130 }
131 }
132 break;
133 }
134 },
135 );
136 return () => sub.unsubscribe();
gioa71316d2025-05-24 09:41:36 +0400137 }, [form, store, id, displayRepos]);
gio7f98e772025-05-07 11:00:14 +0000138
gioa71316d2025-05-24 09:41:36 +0400139 const analyze = useCallback(async () => {
140 if (!data.repository?.sshURL) return;
gio7f98e772025-05-07 11:00:14 +0000141
gioa71316d2025-05-24 09:41:36 +0400142 setIsAnalyzing(true);
143 try {
144 const resp = await fetch(`/api/project/${projectId}/analyze`, {
145 method: "POST",
146 body: JSON.stringify({
147 address: data.repository?.sshURL,
148 }),
149 headers: {
150 "Content-Type": "application/json",
151 },
152 });
153 const servicesResult = z.array(serviceAnalyzisSchema).safeParse(await resp.json());
154 if (!servicesResult.success) {
155 console.error(servicesResult.error);
156 setIsAnalyzing(false);
157 return;
giod0026612025-05-08 13:00:36 +0000158 }
gio7f98e772025-05-07 11:00:14 +0000159
gioa71316d2025-05-24 09:41:36 +0400160 setDiscoveredServices(servicesResult.data);
161 const initialSelectedServices: Record<string, boolean> = {};
162 servicesResult.data.forEach((service) => {
163 initialSelectedServices[service.name] = true;
164 });
165 setSelectedServices(initialSelectedServices);
166 setShowModal(true);
167 } catch (err) {
168 console.error("Analysis failed:", err);
169 } finally {
170 setIsAnalyzing(false);
171 }
172 }, [projectId, data.repository?.sshURL]);
173
174 const handleImportServices = () => {
175 discoveredServices.forEach((service) => {
176 if (selectedServices[service.name]) {
177 const newNodeData: Omit<ServiceData, "activeField" | "state"> = {
178 label: service.name,
179 repository: {
180 id: id,
181 },
182 info: service,
183 type: "nodejs:24.0.2" as ServiceType,
184 env: [],
185 volume: [],
186 preBuildCommands: "",
187 isChoosingPortToConnect: false,
188 envVars: [],
189 ports: [],
190 };
191 const newNodeId = uuidv4();
192 store.addNode({
193 id: newNodeId,
194 type: "app",
195 data: newNodeData,
196 });
197 let edges = store.edges;
198 edges = edges.concat({
199 id: uuidv4(),
200 source: id,
201 sourceHandle: "repository",
202 target: newNodeId,
203 targetHandle: "repository",
204 });
205 store.setEdges(edges);
206 }
207 });
208 setShowModal(false);
209 setDiscoveredServices([]);
210 setSelectedServices({});
211 };
212
213 const handleCancelModal = () => {
214 setShowModal(false);
215 setDiscoveredServices([]);
216 setSelectedServices({});
217 };
gio7f98e772025-05-07 11:00:14 +0000218
giod0026612025-05-08 13:00:36 +0000219 return (
220 <>
221 <Form {...form}>
222 <form className="space-y-2">
223 <FormField
224 control={form.control}
225 name="repositoryId"
226 render={({ field }) => (
227 <FormItem>
gioa71316d2025-05-24 09:41:36 +0400228 <div className="flex items-center gap-2 w-full">
229 <div className="flex-grow">
230 <Select
231 onValueChange={(value) => field.onChange(Number(value))}
232 value={field.value?.toString()}
233 disabled={isLoadingRepos || !projectId || !githubService || disabled}
234 >
235 <FormControl>
236 <SelectTrigger>
237 <SelectValue
238 placeholder={
239 githubService
240 ? isLoadingRepos
241 ? "Loading..."
242 : displayRepos.length === 0
243 ? "No repositories found"
244 : "Select a repository"
245 : "GitHub not configured"
246 }
247 />
248 </SelectTrigger>
249 </FormControl>
250 <SelectContent>
251 {displayRepos.map((repo) => (
252 <SelectItem key={repo.id} value={repo.id.toString()}>
253 {repo.full_name}
254 {repo.description && ` - ${repo.description}`}
255 </SelectItem>
256 ))}
257 </SelectContent>
258 </Select>
259 </div>
260 {isLoadingRepos && (
261 <Button variant="ghost" size="icon" disabled>
262 <LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
263 </Button>
264 )}
265 {!isLoadingRepos && githubService && (
266 <Button
267 variant="ghost"
268 size="icon"
269 onClick={fetchStoreRepositories}
270 disabled={disabled}
271 aria-label="Refresh repositories"
272 >
273 <RefreshCw className="h-5 w-5 text-muted-foreground" />
274 </Button>
275 )}
276 </div>
giod0026612025-05-08 13:00:36 +0000277 <FormMessage />
gioa71316d2025-05-24 09:41:36 +0400278 {repoError && <p className="text-sm text-red-500 mt-1">{repoError}</p>}
giod0026612025-05-08 13:00:36 +0000279 {!githubService && (
280 <Alert variant="destructive" className="mt-2">
281 <AlertCircle className="h-4 w-4" />
282 <AlertDescription>
gio818da4e2025-05-12 14:45:35 +0000283 Please configure Github Personal Access Token in the Integrations tab.
giod0026612025-05-08 13:00:36 +0000284 </AlertDescription>
285 </Alert>
286 )}
287 </FormItem>
288 )}
289 />
290 </form>
291 </Form>
gioa71316d2025-05-24 09:41:36 +0400292 <Button disabled={!data.repository?.sshURL || isAnalyzing || !githubService || disabled} onClick={analyze}>
293 {isAnalyzing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
294 Scan for services
295 </Button>
296 {showModal && (
297 <Dialog open={showModal} onOpenChange={setShowModal}>
298 <DialogContent className="sm:max-w-[425px]">
299 <DialogHeader>
300 <DialogTitle>Discovered Services</DialogTitle>
301 <DialogDescription>Select the services you want to import.</DialogDescription>
302 </DialogHeader>
303 <div className="grid gap-4 py-4">
304 {discoveredServices.map((service) => (
305 <div key={service.name} className="flex flex-col space-y-2 p-2 border rounded-md">
306 <div className="flex items-center space-x-2">
307 <Switch
308 id={service.name}
309 checked={selectedServices[service.name]}
310 onCheckedChange={(checked: boolean) =>
311 setSelectedServices((prev) => ({
312 ...prev,
313 [service.name]: checked,
314 }))
315 }
316 />
317 <Label htmlFor={service.name} className="font-semibold">
318 {service.name}
319 </Label>
320 </div>
321 <div className="pl-6 text-sm text-gray-600">
322 <p>
323 <span className="font-medium">Location:</span> {service.location}
324 </p>
325 {service.configVars && service.configVars.length > 0 && (
326 <div className="mt-1">
327 <p className="font-medium">Environment Variables:</p>
328 <ul className="list-disc list-inside pl-4">
329 {service.configVars.map((envVar) => (
330 <li key={envVar.name}>{envVar.name}</li>
331 ))}
332 </ul>
333 </div>
334 )}
335 </div>
336 </div>
337 ))}
338 </div>
339 <DialogFooter>
340 <Button variant="outline" onClick={handleCancelModal}>
341 Cancel
342 </Button>
343 <Button onClick={handleImportServices}>Import</Button>
344 </DialogFooter>
345 </DialogContent>
346 </Dialog>
347 )}
giod0026612025-05-08 13:00:36 +0000348 </>
349 );
350}