blob: d16a191e4e75af5589c183fc0281ba8cef8160e1 [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";
giod0026612025-05-08 13:00:36 +000023import { useProjectId } from "@/lib/state";
24import { Alert, AlertDescription } from "./ui/alert";
gioa71316d2025-05-24 09:41:36 +040025import { AlertCircle, LoaderCircle, RefreshCw } from "lucide-react";
26import { Button } from "./ui/button";
27import { v4 as uuidv4 } from "uuid";
28import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
29import { Switch } from "./ui/switch";
30import { Label } from "./ui/label";
gio5f2f1002025-03-20 18:38:48 +040031
32export function NodeGithub(node: GithubNode) {
giod0026612025-05-08 13:00:36 +000033 const { id, selected } = node;
34 const isConnectable = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
35 return (
36 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
37 <div style={{ padding: "10px 20px" }}>
38 {nodeLabel(node)}
39 <Handle
40 id="repository"
41 type={"source"}
42 position={Position.Right}
43 isConnectableStart={isConnectable}
44 isConnectableEnd={isConnectable}
45 isConnectable={isConnectable}
46 />
47 </div>
48 </NodeRect>
49 );
gio5f2f1002025-03-20 18:38:48 +040050}
51
52const schema = z.object({
giod0026612025-05-08 13:00:36 +000053 repositoryId: z.number().optional(),
gio5f2f1002025-03-20 18:38:48 +040054});
55
gio08acd3a2025-06-12 12:15:30 +000056export function NodeGithubDetails({ node, disabled }: { node: GithubNode; disabled?: boolean }) {
57 const { id, data } = node;
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
gioa71316d2025-05-24 09:41:36 +040067 const [isAnalyzing, setIsAnalyzing] = useState(false);
68 const [showModal, setShowModal] = useState(false);
69 const [discoveredServices, setDiscoveredServices] = useState<z.infer<typeof serviceAnalyzisSchema>[]>([]);
70 const [selectedServices, setSelectedServices] = useState<Record<string, boolean>>({});
71
giod0026612025-05-08 13:00:36 +000072 const form = useForm<z.infer<typeof schema>>({
73 resolver: zodResolver(schema),
74 mode: "onChange",
75 defaultValues: {
76 repositoryId: data.repository?.id,
77 },
78 });
gio7f98e772025-05-07 11:00:14 +000079
giod0026612025-05-08 13:00:36 +000080 useEffect(() => {
gioa71316d2025-05-24 09:41:36 +040081 form.reset({ repositoryId: data.repository?.id });
82 }, [data.repository?.id, form]);
83
84 useEffect(() => {
giod0026612025-05-08 13:00:36 +000085 const sub = form.watch(
86 (
87 value: DeepPartial<z.infer<typeof schema>>,
88 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
89 ) => {
90 if (type !== "change") {
91 return;
92 }
93 switch (name) {
94 case "repositoryId":
95 if (value.repositoryId) {
gio3d0bf032025-06-05 06:57:26 +000096 const repo = storeRepos.find((r) => r.id === value.repositoryId);
giod0026612025-05-08 13:00:36 +000097 if (repo) {
98 store.updateNodeData<"github">(id, {
99 repository: {
100 id: repo.id,
101 sshURL: repo.ssh_url,
gio818da4e2025-05-12 14:45:35 +0000102 fullName: repo.full_name,
giod0026612025-05-08 13:00:36 +0000103 },
104 });
105 }
106 }
107 break;
108 }
109 },
110 );
111 return () => sub.unsubscribe();
gio3d0bf032025-06-05 06:57:26 +0000112 }, [form, store, id, storeRepos]);
gio7f98e772025-05-07 11:00:14 +0000113
gioa71316d2025-05-24 09:41:36 +0400114 const analyze = useCallback(async () => {
115 if (!data.repository?.sshURL) return;
gio7f98e772025-05-07 11:00:14 +0000116
gioa71316d2025-05-24 09:41:36 +0400117 setIsAnalyzing(true);
118 try {
119 const resp = await fetch(`/api/project/${projectId}/analyze`, {
120 method: "POST",
121 body: JSON.stringify({
122 address: data.repository?.sshURL,
123 }),
124 headers: {
125 "Content-Type": "application/json",
126 },
127 });
128 const servicesResult = z.array(serviceAnalyzisSchema).safeParse(await resp.json());
129 if (!servicesResult.success) {
130 console.error(servicesResult.error);
131 setIsAnalyzing(false);
132 return;
giod0026612025-05-08 13:00:36 +0000133 }
gio7f98e772025-05-07 11:00:14 +0000134
gioa71316d2025-05-24 09:41:36 +0400135 setDiscoveredServices(servicesResult.data);
136 const initialSelectedServices: Record<string, boolean> = {};
137 servicesResult.data.forEach((service) => {
138 initialSelectedServices[service.name] = true;
139 });
140 setSelectedServices(initialSelectedServices);
141 setShowModal(true);
142 } catch (err) {
143 console.error("Analysis failed:", err);
144 } finally {
145 setIsAnalyzing(false);
146 }
147 }, [projectId, data.repository?.sshURL]);
148
149 const handleImportServices = () => {
150 discoveredServices.forEach((service) => {
151 if (selectedServices[service.name]) {
152 const newNodeData: Omit<ServiceData, "activeField" | "state"> = {
153 label: service.name,
154 repository: {
gio3d0bf032025-06-05 06:57:26 +0000155 id: data.repository!.id,
156 repoNodeId: id,
gioa71316d2025-05-24 09:41:36 +0400157 },
158 info: service,
159 type: "nodejs:24.0.2" as ServiceType,
160 env: [],
161 volume: [],
162 preBuildCommands: "",
163 isChoosingPortToConnect: false,
164 envVars: [],
165 ports: [],
166 };
167 const newNodeId = uuidv4();
168 store.addNode({
169 id: newNodeId,
170 type: "app",
171 data: newNodeData,
172 });
173 let edges = store.edges;
174 edges = edges.concat({
175 id: uuidv4(),
176 source: id,
177 sourceHandle: "repository",
178 target: newNodeId,
179 targetHandle: "repository",
180 });
181 store.setEdges(edges);
182 }
183 });
184 setShowModal(false);
185 setDiscoveredServices([]);
186 setSelectedServices({});
187 };
188
189 const handleCancelModal = () => {
190 setShowModal(false);
191 setDiscoveredServices([]);
192 setSelectedServices({});
193 };
gio7f98e772025-05-07 11:00:14 +0000194
giod0026612025-05-08 13:00:36 +0000195 return (
196 <>
197 <Form {...form}>
198 <form className="space-y-2">
199 <FormField
200 control={form.control}
201 name="repositoryId"
202 render={({ field }) => (
203 <FormItem>
gioa71316d2025-05-24 09:41:36 +0400204 <div className="flex items-center gap-2 w-full">
205 <div className="flex-grow">
206 <Select
207 onValueChange={(value) => field.onChange(Number(value))}
208 value={field.value?.toString()}
209 disabled={isLoadingRepos || !projectId || !githubService || disabled}
210 >
211 <FormControl>
212 <SelectTrigger>
213 <SelectValue
214 placeholder={
215 githubService
216 ? isLoadingRepos
217 ? "Loading..."
gio3d0bf032025-06-05 06:57:26 +0000218 : storeRepos.length === 0
gioa71316d2025-05-24 09:41:36 +0400219 ? "No repositories found"
220 : "Select a repository"
221 : "GitHub not configured"
222 }
223 />
224 </SelectTrigger>
225 </FormControl>
226 <SelectContent>
gio3d0bf032025-06-05 06:57:26 +0000227 {storeRepos.map((repo) => (
gioa71316d2025-05-24 09:41:36 +0400228 <SelectItem key={repo.id} value={repo.id.toString()}>
229 {repo.full_name}
230 {repo.description && ` - ${repo.description}`}
231 </SelectItem>
232 ))}
233 </SelectContent>
234 </Select>
235 </div>
236 {isLoadingRepos && (
237 <Button variant="ghost" size="icon" disabled>
238 <LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
239 </Button>
240 )}
241 {!isLoadingRepos && githubService && (
242 <Button
243 variant="ghost"
244 size="icon"
245 onClick={fetchStoreRepositories}
246 disabled={disabled}
247 aria-label="Refresh repositories"
248 >
249 <RefreshCw className="h-5 w-5 text-muted-foreground" />
250 </Button>
251 )}
252 </div>
giod0026612025-05-08 13:00:36 +0000253 <FormMessage />
gioa71316d2025-05-24 09:41:36 +0400254 {repoError && <p className="text-sm text-red-500 mt-1">{repoError}</p>}
giod0026612025-05-08 13:00:36 +0000255 {!githubService && (
256 <Alert variant="destructive" className="mt-2">
257 <AlertCircle className="h-4 w-4" />
258 <AlertDescription>
gio818da4e2025-05-12 14:45:35 +0000259 Please configure Github Personal Access Token in the Integrations tab.
giod0026612025-05-08 13:00:36 +0000260 </AlertDescription>
261 </Alert>
262 )}
263 </FormItem>
264 )}
265 />
266 </form>
267 </Form>
gioa71316d2025-05-24 09:41:36 +0400268 <Button disabled={!data.repository?.sshURL || isAnalyzing || !githubService || disabled} onClick={analyze}>
269 {isAnalyzing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
270 Scan for services
271 </Button>
272 {showModal && (
273 <Dialog open={showModal} onOpenChange={setShowModal}>
274 <DialogContent className="sm:max-w-[425px]">
275 <DialogHeader>
276 <DialogTitle>Discovered Services</DialogTitle>
277 <DialogDescription>Select the services you want to import.</DialogDescription>
278 </DialogHeader>
279 <div className="grid gap-4 py-4">
280 {discoveredServices.map((service) => (
281 <div key={service.name} className="flex flex-col space-y-2 p-2 border rounded-md">
282 <div className="flex items-center space-x-2">
283 <Switch
284 id={service.name}
285 checked={selectedServices[service.name]}
286 onCheckedChange={(checked: boolean) =>
287 setSelectedServices((prev) => ({
288 ...prev,
289 [service.name]: checked,
290 }))
291 }
292 />
293 <Label htmlFor={service.name} className="font-semibold">
294 {service.name}
295 </Label>
296 </div>
297 <div className="pl-6 text-sm text-gray-600">
298 <p>
299 <span className="font-medium">Location:</span> {service.location}
300 </p>
301 {service.configVars && service.configVars.length > 0 && (
302 <div className="mt-1">
303 <p className="font-medium">Environment Variables:</p>
304 <ul className="list-disc list-inside pl-4">
305 {service.configVars.map((envVar) => (
306 <li key={envVar.name}>{envVar.name}</li>
307 ))}
308 </ul>
309 </div>
310 )}
311 </div>
312 </div>
313 ))}
314 </div>
315 <DialogFooter>
316 <Button variant="outline" onClick={handleCancelModal}>
317 Cancel
318 </Button>
319 <Button onClick={handleImportServices}>Import</Button>
320 </DialogFooter>
321 </DialogContent>
322 </Dialog>
323 )}
giod0026612025-05-08 13:00:36 +0000324 </>
325 );
326}