| 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"; |
| 23 | import { GitHubRepository } from "../lib/github"; |
| 24 | import { useProjectId } from "@/lib/state"; |
| 25 | import { Alert, AlertDescription } from "./ui/alert"; |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 26 | import { AlertCircle, LoaderCircle, RefreshCw } from "lucide-react"; |
| 27 | import { Button } from "./ui/button"; |
| 28 | import { v4 as uuidv4 } from "uuid"; |
| 29 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; |
| 30 | import { Switch } from "./ui/switch"; |
| 31 | import { Label } from "./ui/label"; |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 32 | |
| 33 | export function NodeGithub(node: GithubNode) { |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 34 | 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 | ); |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 51 | } |
| 52 | |
| 53 | const schema = z.object({ |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 54 | repositoryId: z.number().optional(), |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 55 | }); |
| 56 | |
| gio | 3ec9424 | 2025-05-16 12:46:57 +0000 | [diff] [blame] | 57 | export function NodeGithubDetails({ id, data, disabled }: GithubNode & { disabled?: boolean }) { |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 58 | const store = useStateStore(); |
| 59 | const projectId = useProjectId(); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 60 | const githubService = useGithubService(); |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 61 | |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 62 | 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 | |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 74 | useEffect(() => { |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 75 | let currentRepoInStore = false; |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 76 | if (data.repository) { |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 77 | currentRepoInStore = storeRepos.some((r) => r.id === data.repository!.id); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 78 | } |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 79 | |
| 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]); |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 96 | |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 97 | const form = useForm<z.infer<typeof schema>>({ |
| 98 | resolver: zodResolver(schema), |
| 99 | mode: "onChange", |
| 100 | defaultValues: { |
| 101 | repositoryId: data.repository?.id, |
| 102 | }, |
| 103 | }); |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 104 | |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 105 | useEffect(() => { |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 106 | form.reset({ repositoryId: data.repository?.id }); |
| 107 | }, [data.repository?.id, form]); |
| 108 | |
| 109 | useEffect(() => { |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 110 | 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) { |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 121 | const repo = displayRepos.find((r) => r.id === value.repositoryId); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 122 | if (repo) { |
| 123 | store.updateNodeData<"github">(id, { |
| 124 | repository: { |
| 125 | id: repo.id, |
| 126 | sshURL: repo.ssh_url, |
| gio | 818da4e | 2025-05-12 14:45:35 +0000 | [diff] [blame] | 127 | fullName: repo.full_name, |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 128 | }, |
| 129 | }); |
| 130 | } |
| 131 | } |
| 132 | break; |
| 133 | } |
| 134 | }, |
| 135 | ); |
| 136 | return () => sub.unsubscribe(); |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 137 | }, [form, store, id, displayRepos]); |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 138 | |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 139 | const analyze = useCallback(async () => { |
| 140 | if (!data.repository?.sshURL) return; |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 141 | |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 142 | 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; |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 158 | } |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 159 | |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 160 | 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 | }; |
| gio | 7f98e77 | 2025-05-07 11:00:14 +0000 | [diff] [blame] | 218 | |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 219 | 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> |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 228 | <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> |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 277 | <FormMessage /> |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 278 | {repoError && <p className="text-sm text-red-500 mt-1">{repoError}</p>} |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 279 | {!githubService && ( |
| 280 | <Alert variant="destructive" className="mt-2"> |
| 281 | <AlertCircle className="h-4 w-4" /> |
| 282 | <AlertDescription> |
| gio | 818da4e | 2025-05-12 14:45:35 +0000 | [diff] [blame] | 283 | Please configure Github Personal Access Token in the Integrations tab. |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 284 | </AlertDescription> |
| 285 | </Alert> |
| 286 | )} |
| 287 | </FormItem> |
| 288 | )} |
| 289 | /> |
| 290 | </form> |
| 291 | </Form> |
| gio | a71316d | 2025-05-24 09:41:36 +0400 | [diff] [blame^] | 292 | <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 | )} |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 348 | </> |
| 349 | ); |
| 350 | } |