| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 1 | import { NodeRect } from "./node-rect"; |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 2 | import { |
| 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"; |
| 16 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 17 | import { z } from "zod"; |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 18 | import { DeepPartial, EventType, useForm } from "react-hook-form"; |
| 19 | import { zodResolver } from "@hookform/resolvers/zod"; |
| 20 | import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form"; |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 21 | import { Handle, Position } from "@xyflow/react"; |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 22 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 23 | import { useProjectId } from "@/lib/state"; |
| 24 | import { Alert, AlertDescription } from "./ui/alert"; |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 25 | import { AlertCircle, LoaderCircle, RefreshCw } from "lucide-react"; |
| 26 | import { Button } from "./ui/button"; |
| 27 | import { v4 as uuidv4 } from "uuid"; |
| 28 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; |
| 29 | import { Switch } from "./ui/switch"; |
| 30 | import { Label } from "./ui/label"; |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 31 | |
| 32 | export function NodeGithub(node: GithubNode) { |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 33 | 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 | ); |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 50 | } |
| 51 | |
| 52 | const schema = z.object({ |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 53 | repositoryId: z.number().optional(), |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 54 | }); |
| 55 | |
| gio | 3ec9424 | 2025-05-16 12:46:57 +0000 | [diff] [blame] | 56 | export function NodeGithubDetails({ id, data, disabled }: GithubNode & { disabled?: boolean }) { |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 57 | const store = useStateStore(); |
| 58 | const projectId = useProjectId(); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 59 | const githubService = useGithubService(); |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 60 | |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 61 | const storeRepos = useGithubRepositories(); |
| 62 | const isLoadingRepos = useGithubRepositoriesLoading(); |
| 63 | const repoError = useGithubRepositoriesError(); |
| 64 | const fetchStoreRepositories = useFetchGithubRepositories(); |
| 65 | |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 66 | 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 | |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 71 | const form = useForm<z.infer<typeof schema>>({ |
| 72 | resolver: zodResolver(schema), |
| 73 | mode: "onChange", |
| 74 | defaultValues: { |
| 75 | repositoryId: data.repository?.id, |
| 76 | }, |
| 77 | }); |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 78 | |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 79 | useEffect(() => { |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 80 | form.reset({ repositoryId: data.repository?.id }); |
| 81 | }, [data.repository?.id, form]); |
| 82 | |
| 83 | useEffect(() => { |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 84 | 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) { |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame^] | 95 | const repo = storeRepos.find((r) => r.id === value.repositoryId); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 96 | if (repo) { |
| 97 | store.updateNodeData<"github">(id, { |
| 98 | repository: { |
| 99 | id: repo.id, |
| 100 | sshURL: repo.ssh_url, |
| gio | 818da4e | 2025-05-12 14:45:35 +0000 | [diff] [blame] | 101 | fullName: repo.full_name, |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 102 | }, |
| 103 | }); |
| 104 | } |
| 105 | } |
| 106 | break; |
| 107 | } |
| 108 | }, |
| 109 | ); |
| 110 | return () => sub.unsubscribe(); |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame^] | 111 | }, [form, store, id, storeRepos]); |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 112 | |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 113 | const analyze = useCallback(async () => { |
| 114 | if (!data.repository?.sshURL) return; |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 115 | |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 116 | 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; |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 132 | } |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 133 | |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 134 | 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: { |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame^] | 154 | id: data.repository!.id, |
| 155 | repoNodeId: id, |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 156 | }, |
| 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 | }; |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 193 | |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 194 | 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> |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 203 | <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..." |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame^] | 217 | : storeRepos.length === 0 |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 218 | ? "No repositories found" |
| 219 | : "Select a repository" |
| 220 | : "GitHub not configured" |
| 221 | } |
| 222 | /> |
| 223 | </SelectTrigger> |
| 224 | </FormControl> |
| 225 | <SelectContent> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame^] | 226 | {storeRepos.map((repo) => ( |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 227 | <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> |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 252 | <FormMessage /> |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 253 | {repoError && <p className="text-sm text-red-500 mt-1">{repoError}</p>} |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 254 | {!githubService && ( |
| 255 | <Alert variant="destructive" className="mt-2"> |
| 256 | <AlertCircle className="h-4 w-4" /> |
| 257 | <AlertDescription> |
| gio | 818da4e | 2025-05-12 14:45:35 +0000 | [diff] [blame] | 258 | Please configure Github Personal Access Token in the Integrations tab. |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 259 | </AlertDescription> |
| 260 | </Alert> |
| 261 | )} |
| 262 | </FormItem> |
| 263 | )} |
| 264 | /> |
| 265 | </form> |
| 266 | </Form> |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame] | 267 | <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 | )} |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 323 | </> |
| 324 | ); |
| 325 | } |