blob: b75f7b1fe56bfa2043b0f0d417da81cb070c012d [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";
gio3fb133d2025-06-13 07:20:24 +000031import { NodeDetailsProps } from "@/lib/types";
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
gio3fb133d2025-06-13 07:20:24 +000057export function NodeGithubDetails({ node, disabled }: NodeDetailsProps<GithubNode>) {
gio08acd3a2025-06-12 12:15:30 +000058 const { id, data } = node;
giod0026612025-05-08 13:00:36 +000059 const store = useStateStore();
60 const projectId = useProjectId();
giod0026612025-05-08 13:00:36 +000061 const githubService = useGithubService();
gio7f98e772025-05-07 11:00:14 +000062
gioa71316d2025-05-24 09:41:36 +040063 const storeRepos = useGithubRepositories();
64 const isLoadingRepos = useGithubRepositoriesLoading();
65 const repoError = useGithubRepositoriesError();
66 const fetchStoreRepositories = useFetchGithubRepositories();
67
gioa71316d2025-05-24 09:41:36 +040068 const [isAnalyzing, setIsAnalyzing] = useState(false);
69 const [showModal, setShowModal] = useState(false);
70 const [discoveredServices, setDiscoveredServices] = useState<z.infer<typeof serviceAnalyzisSchema>[]>([]);
71 const [selectedServices, setSelectedServices] = useState<Record<string, boolean>>({});
72
giod0026612025-05-08 13:00:36 +000073 const form = useForm<z.infer<typeof schema>>({
74 resolver: zodResolver(schema),
75 mode: "onChange",
76 defaultValues: {
77 repositoryId: data.repository?.id,
78 },
79 });
gio7f98e772025-05-07 11:00:14 +000080
giod0026612025-05-08 13:00:36 +000081 useEffect(() => {
gioa71316d2025-05-24 09:41:36 +040082 form.reset({ repositoryId: data.repository?.id });
83 }, [data.repository?.id, form]);
84
85 useEffect(() => {
giod0026612025-05-08 13:00:36 +000086 const sub = form.watch(
87 (
88 value: DeepPartial<z.infer<typeof schema>>,
89 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
90 ) => {
91 if (type !== "change") {
92 return;
93 }
94 switch (name) {
95 case "repositoryId":
96 if (value.repositoryId) {
gio3d0bf032025-06-05 06:57:26 +000097 const repo = storeRepos.find((r) => r.id === value.repositoryId);
giod0026612025-05-08 13:00:36 +000098 if (repo) {
99 store.updateNodeData<"github">(id, {
100 repository: {
101 id: repo.id,
102 sshURL: repo.ssh_url,
gio818da4e2025-05-12 14:45:35 +0000103 fullName: repo.full_name,
giod0026612025-05-08 13:00:36 +0000104 },
105 });
106 }
107 }
108 break;
109 }
110 },
111 );
112 return () => sub.unsubscribe();
gio3d0bf032025-06-05 06:57:26 +0000113 }, [form, store, id, storeRepos]);
gio7f98e772025-05-07 11:00:14 +0000114
gioa71316d2025-05-24 09:41:36 +0400115 const analyze = useCallback(async () => {
116 if (!data.repository?.sshURL) return;
gio7f98e772025-05-07 11:00:14 +0000117
gioa71316d2025-05-24 09:41:36 +0400118 setIsAnalyzing(true);
119 try {
120 const resp = await fetch(`/api/project/${projectId}/analyze`, {
121 method: "POST",
122 body: JSON.stringify({
123 address: data.repository?.sshURL,
124 }),
125 headers: {
126 "Content-Type": "application/json",
127 },
128 });
129 const servicesResult = z.array(serviceAnalyzisSchema).safeParse(await resp.json());
130 if (!servicesResult.success) {
131 console.error(servicesResult.error);
132 setIsAnalyzing(false);
133 return;
giod0026612025-05-08 13:00:36 +0000134 }
gio7f98e772025-05-07 11:00:14 +0000135
gioa71316d2025-05-24 09:41:36 +0400136 setDiscoveredServices(servicesResult.data);
137 const initialSelectedServices: Record<string, boolean> = {};
138 servicesResult.data.forEach((service) => {
139 initialSelectedServices[service.name] = true;
140 });
141 setSelectedServices(initialSelectedServices);
142 setShowModal(true);
143 } catch (err) {
144 console.error("Analysis failed:", err);
145 } finally {
146 setIsAnalyzing(false);
147 }
148 }, [projectId, data.repository?.sshURL]);
149
150 const handleImportServices = () => {
151 discoveredServices.forEach((service) => {
152 if (selectedServices[service.name]) {
153 const newNodeData: Omit<ServiceData, "activeField" | "state"> = {
154 label: service.name,
155 repository: {
gio3d0bf032025-06-05 06:57:26 +0000156 id: data.repository!.id,
157 repoNodeId: id,
gioa71316d2025-05-24 09:41:36 +0400158 },
159 info: service,
160 type: "nodejs:24.0.2" as ServiceType,
161 env: [],
162 volume: [],
163 preBuildCommands: "",
164 isChoosingPortToConnect: false,
165 envVars: [],
166 ports: [],
167 };
168 const newNodeId = uuidv4();
169 store.addNode({
170 id: newNodeId,
171 type: "app",
172 data: newNodeData,
173 });
174 let edges = store.edges;
175 edges = edges.concat({
176 id: uuidv4(),
177 source: id,
178 sourceHandle: "repository",
179 target: newNodeId,
180 targetHandle: "repository",
181 });
182 store.setEdges(edges);
183 }
184 });
185 setShowModal(false);
186 setDiscoveredServices([]);
187 setSelectedServices({});
188 };
189
190 const handleCancelModal = () => {
191 setShowModal(false);
192 setDiscoveredServices([]);
193 setSelectedServices({});
194 };
gio7f98e772025-05-07 11:00:14 +0000195
giod0026612025-05-08 13:00:36 +0000196 return (
197 <>
198 <Form {...form}>
199 <form className="space-y-2">
200 <FormField
201 control={form.control}
202 name="repositoryId"
203 render={({ field }) => (
204 <FormItem>
gioa71316d2025-05-24 09:41:36 +0400205 <div className="flex items-center gap-2 w-full">
206 <div className="flex-grow">
207 <Select
208 onValueChange={(value) => field.onChange(Number(value))}
209 value={field.value?.toString()}
210 disabled={isLoadingRepos || !projectId || !githubService || disabled}
211 >
212 <FormControl>
213 <SelectTrigger>
214 <SelectValue
215 placeholder={
216 githubService
217 ? isLoadingRepos
218 ? "Loading..."
gio3d0bf032025-06-05 06:57:26 +0000219 : storeRepos.length === 0
gioa71316d2025-05-24 09:41:36 +0400220 ? "No repositories found"
221 : "Select a repository"
222 : "GitHub not configured"
223 }
224 />
225 </SelectTrigger>
226 </FormControl>
227 <SelectContent>
gio3d0bf032025-06-05 06:57:26 +0000228 {storeRepos.map((repo) => (
gioa71316d2025-05-24 09:41:36 +0400229 <SelectItem key={repo.id} value={repo.id.toString()}>
230 {repo.full_name}
231 {repo.description && ` - ${repo.description}`}
232 </SelectItem>
233 ))}
234 </SelectContent>
235 </Select>
236 </div>
237 {isLoadingRepos && (
238 <Button variant="ghost" size="icon" disabled>
239 <LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
240 </Button>
241 )}
242 {!isLoadingRepos && githubService && (
243 <Button
244 variant="ghost"
245 size="icon"
246 onClick={fetchStoreRepositories}
247 disabled={disabled}
248 aria-label="Refresh repositories"
249 >
250 <RefreshCw className="h-5 w-5 text-muted-foreground" />
251 </Button>
252 )}
253 </div>
giod0026612025-05-08 13:00:36 +0000254 <FormMessage />
gioa71316d2025-05-24 09:41:36 +0400255 {repoError && <p className="text-sm text-red-500 mt-1">{repoError}</p>}
giod0026612025-05-08 13:00:36 +0000256 {!githubService && (
257 <Alert variant="destructive" className="mt-2">
258 <AlertCircle className="h-4 w-4" />
259 <AlertDescription>
gio818da4e2025-05-12 14:45:35 +0000260 Please configure Github Personal Access Token in the Integrations tab.
giod0026612025-05-08 13:00:36 +0000261 </AlertDescription>
262 </Alert>
263 )}
264 </FormItem>
265 )}
266 />
267 </form>
268 </Form>
gioa71316d2025-05-24 09:41:36 +0400269 <Button disabled={!data.repository?.sshURL || isAnalyzing || !githubService || disabled} onClick={analyze}>
270 {isAnalyzing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
271 Scan for services
272 </Button>
273 {showModal && (
274 <Dialog open={showModal} onOpenChange={setShowModal}>
275 <DialogContent className="sm:max-w-[425px]">
276 <DialogHeader>
277 <DialogTitle>Discovered Services</DialogTitle>
278 <DialogDescription>Select the services you want to import.</DialogDescription>
279 </DialogHeader>
280 <div className="grid gap-4 py-4">
281 {discoveredServices.map((service) => (
282 <div key={service.name} className="flex flex-col space-y-2 p-2 border rounded-md">
283 <div className="flex items-center space-x-2">
284 <Switch
285 id={service.name}
286 checked={selectedServices[service.name]}
287 onCheckedChange={(checked: boolean) =>
288 setSelectedServices((prev) => ({
289 ...prev,
290 [service.name]: checked,
291 }))
292 }
293 />
294 <Label htmlFor={service.name} className="font-semibold">
295 {service.name}
296 </Label>
297 </div>
298 <div className="pl-6 text-sm text-gray-600">
299 <p>
300 <span className="font-medium">Location:</span> {service.location}
301 </p>
302 {service.configVars && service.configVars.length > 0 && (
303 <div className="mt-1">
304 <p className="font-medium">Environment Variables:</p>
305 <ul className="list-disc list-inside pl-4">
306 {service.configVars.map((envVar) => (
307 <li key={envVar.name}>{envVar.name}</li>
308 ))}
309 </ul>
310 </div>
311 )}
312 </div>
313 </div>
314 ))}
315 </div>
316 <DialogFooter>
317 <Button variant="outline" onClick={handleCancelModal}>
318 Cancel
319 </Button>
320 <Button onClick={handleImportServices}>Import</Button>
321 </DialogFooter>
322 </DialogContent>
323 </Dialog>
324 )}
giod0026612025-05-08 13:00:36 +0000325 </>
326 );
327}