blob: 437335979570d04b3f6b073ceb01f9148d12e169 [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
gio3ec94242025-05-16 12:46:57 +000056export function NodeGithubDetails({ id, data, disabled }: GithubNode & { disabled?: boolean }) {
giod0026612025-05-08 13:00:36 +000057 const store = useStateStore();
58 const projectId = useProjectId();
giod0026612025-05-08 13:00:36 +000059 const githubService = useGithubService();
gio7f98e772025-05-07 11:00:14 +000060
gioa71316d2025-05-24 09:41:36 +040061 const storeRepos = useGithubRepositories();
62 const isLoadingRepos = useGithubRepositoriesLoading();
63 const repoError = useGithubRepositoriesError();
64 const fetchStoreRepositories = useFetchGithubRepositories();
65
gioa71316d2025-05-24 09:41:36 +040066 const [isAnalyzing, setIsAnalyzing] = useState(false);
67 const [showModal, setShowModal] = useState(false);
68 const [discoveredServices, setDiscoveredServices] = useState<z.infer<typeof serviceAnalyzisSchema>[]>([]);
69 const [selectedServices, setSelectedServices] = useState<Record<string, boolean>>({});
70
giod0026612025-05-08 13:00:36 +000071 const form = useForm<z.infer<typeof schema>>({
72 resolver: zodResolver(schema),
73 mode: "onChange",
74 defaultValues: {
75 repositoryId: data.repository?.id,
76 },
77 });
gio7f98e772025-05-07 11:00:14 +000078
giod0026612025-05-08 13:00:36 +000079 useEffect(() => {
gioa71316d2025-05-24 09:41:36 +040080 form.reset({ repositoryId: data.repository?.id });
81 }, [data.repository?.id, form]);
82
83 useEffect(() => {
giod0026612025-05-08 13:00:36 +000084 const sub = form.watch(
85 (
86 value: DeepPartial<z.infer<typeof schema>>,
87 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
88 ) => {
89 if (type !== "change") {
90 return;
91 }
92 switch (name) {
93 case "repositoryId":
94 if (value.repositoryId) {
gio3d0bf032025-06-05 06:57:26 +000095 const repo = storeRepos.find((r) => r.id === value.repositoryId);
giod0026612025-05-08 13:00:36 +000096 if (repo) {
97 store.updateNodeData<"github">(id, {
98 repository: {
99 id: repo.id,
100 sshURL: repo.ssh_url,
gio818da4e2025-05-12 14:45:35 +0000101 fullName: repo.full_name,
giod0026612025-05-08 13:00:36 +0000102 },
103 });
104 }
105 }
106 break;
107 }
108 },
109 );
110 return () => sub.unsubscribe();
gio3d0bf032025-06-05 06:57:26 +0000111 }, [form, store, id, storeRepos]);
gio7f98e772025-05-07 11:00:14 +0000112
gioa71316d2025-05-24 09:41:36 +0400113 const analyze = useCallback(async () => {
114 if (!data.repository?.sshURL) return;
gio7f98e772025-05-07 11:00:14 +0000115
gioa71316d2025-05-24 09:41:36 +0400116 setIsAnalyzing(true);
117 try {
118 const resp = await fetch(`/api/project/${projectId}/analyze`, {
119 method: "POST",
120 body: JSON.stringify({
121 address: data.repository?.sshURL,
122 }),
123 headers: {
124 "Content-Type": "application/json",
125 },
126 });
127 const servicesResult = z.array(serviceAnalyzisSchema).safeParse(await resp.json());
128 if (!servicesResult.success) {
129 console.error(servicesResult.error);
130 setIsAnalyzing(false);
131 return;
giod0026612025-05-08 13:00:36 +0000132 }
gio7f98e772025-05-07 11:00:14 +0000133
gioa71316d2025-05-24 09:41:36 +0400134 setDiscoveredServices(servicesResult.data);
135 const initialSelectedServices: Record<string, boolean> = {};
136 servicesResult.data.forEach((service) => {
137 initialSelectedServices[service.name] = true;
138 });
139 setSelectedServices(initialSelectedServices);
140 setShowModal(true);
141 } catch (err) {
142 console.error("Analysis failed:", err);
143 } finally {
144 setIsAnalyzing(false);
145 }
146 }, [projectId, data.repository?.sshURL]);
147
148 const handleImportServices = () => {
149 discoveredServices.forEach((service) => {
150 if (selectedServices[service.name]) {
151 const newNodeData: Omit<ServiceData, "activeField" | "state"> = {
152 label: service.name,
153 repository: {
gio3d0bf032025-06-05 06:57:26 +0000154 id: data.repository!.id,
155 repoNodeId: id,
gioa71316d2025-05-24 09:41:36 +0400156 },
157 info: service,
158 type: "nodejs:24.0.2" as ServiceType,
159 env: [],
160 volume: [],
161 preBuildCommands: "",
162 isChoosingPortToConnect: false,
163 envVars: [],
164 ports: [],
165 };
166 const newNodeId = uuidv4();
167 store.addNode({
168 id: newNodeId,
169 type: "app",
170 data: newNodeData,
171 });
172 let edges = store.edges;
173 edges = edges.concat({
174 id: uuidv4(),
175 source: id,
176 sourceHandle: "repository",
177 target: newNodeId,
178 targetHandle: "repository",
179 });
180 store.setEdges(edges);
181 }
182 });
183 setShowModal(false);
184 setDiscoveredServices([]);
185 setSelectedServices({});
186 };
187
188 const handleCancelModal = () => {
189 setShowModal(false);
190 setDiscoveredServices([]);
191 setSelectedServices({});
192 };
gio7f98e772025-05-07 11:00:14 +0000193
giod0026612025-05-08 13:00:36 +0000194 return (
195 <>
196 <Form {...form}>
197 <form className="space-y-2">
198 <FormField
199 control={form.control}
200 name="repositoryId"
201 render={({ field }) => (
202 <FormItem>
gioa71316d2025-05-24 09:41:36 +0400203 <div className="flex items-center gap-2 w-full">
204 <div className="flex-grow">
205 <Select
206 onValueChange={(value) => field.onChange(Number(value))}
207 value={field.value?.toString()}
208 disabled={isLoadingRepos || !projectId || !githubService || disabled}
209 >
210 <FormControl>
211 <SelectTrigger>
212 <SelectValue
213 placeholder={
214 githubService
215 ? isLoadingRepos
216 ? "Loading..."
gio3d0bf032025-06-05 06:57:26 +0000217 : storeRepos.length === 0
gioa71316d2025-05-24 09:41:36 +0400218 ? "No repositories found"
219 : "Select a repository"
220 : "GitHub not configured"
221 }
222 />
223 </SelectTrigger>
224 </FormControl>
225 <SelectContent>
gio3d0bf032025-06-05 06:57:26 +0000226 {storeRepos.map((repo) => (
gioa71316d2025-05-24 09:41:36 +0400227 <SelectItem key={repo.id} value={repo.id.toString()}>
228 {repo.full_name}
229 {repo.description && ` - ${repo.description}`}
230 </SelectItem>
231 ))}
232 </SelectContent>
233 </Select>
234 </div>
235 {isLoadingRepos && (
236 <Button variant="ghost" size="icon" disabled>
237 <LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" />
238 </Button>
239 )}
240 {!isLoadingRepos && githubService && (
241 <Button
242 variant="ghost"
243 size="icon"
244 onClick={fetchStoreRepositories}
245 disabled={disabled}
246 aria-label="Refresh repositories"
247 >
248 <RefreshCw className="h-5 w-5 text-muted-foreground" />
249 </Button>
250 )}
251 </div>
giod0026612025-05-08 13:00:36 +0000252 <FormMessage />
gioa71316d2025-05-24 09:41:36 +0400253 {repoError && <p className="text-sm text-red-500 mt-1">{repoError}</p>}
giod0026612025-05-08 13:00:36 +0000254 {!githubService && (
255 <Alert variant="destructive" className="mt-2">
256 <AlertCircle className="h-4 w-4" />
257 <AlertDescription>
gio818da4e2025-05-12 14:45:35 +0000258 Please configure Github Personal Access Token in the Integrations tab.
giod0026612025-05-08 13:00:36 +0000259 </AlertDescription>
260 </Alert>
261 )}
262 </FormItem>
263 )}
264 />
265 </form>
266 </Form>
gioa71316d2025-05-24 09:41:36 +0400267 <Button disabled={!data.repository?.sshURL || isAnalyzing || !githubService || disabled} onClick={analyze}>
268 {isAnalyzing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
269 Scan for services
270 </Button>
271 {showModal && (
272 <Dialog open={showModal} onOpenChange={setShowModal}>
273 <DialogContent className="sm:max-w-[425px]">
274 <DialogHeader>
275 <DialogTitle>Discovered Services</DialogTitle>
276 <DialogDescription>Select the services you want to import.</DialogDescription>
277 </DialogHeader>
278 <div className="grid gap-4 py-4">
279 {discoveredServices.map((service) => (
280 <div key={service.name} className="flex flex-col space-y-2 p-2 border rounded-md">
281 <div className="flex items-center space-x-2">
282 <Switch
283 id={service.name}
284 checked={selectedServices[service.name]}
285 onCheckedChange={(checked: boolean) =>
286 setSelectedServices((prev) => ({
287 ...prev,
288 [service.name]: checked,
289 }))
290 }
291 />
292 <Label htmlFor={service.name} className="font-semibold">
293 {service.name}
294 </Label>
295 </div>
296 <div className="pl-6 text-sm text-gray-600">
297 <p>
298 <span className="font-medium">Location:</span> {service.location}
299 </p>
300 {service.configVars && service.configVars.length > 0 && (
301 <div className="mt-1">
302 <p className="font-medium">Environment Variables:</p>
303 <ul className="list-disc list-inside pl-4">
304 {service.configVars.map((envVar) => (
305 <li key={envVar.name}>{envVar.name}</li>
306 ))}
307 </ul>
308 </div>
309 )}
310 </div>
311 </div>
312 ))}
313 </div>
314 <DialogFooter>
315 <Button variant="outline" onClick={handleCancelModal}>
316 Cancel
317 </Button>
318 <Button onClick={handleImportServices}>Import</Button>
319 </DialogFooter>
320 </DialogContent>
321 </Dialog>
322 )}
giod0026612025-05-08 13:00:36 +0000323 </>
324 );
325}