| import { NodeRect } from "./node-rect"; |
| import { |
| GithubNode, |
| nodeIsConnectable, |
| nodeLabel, |
| serviceAnalyzisSchema, |
| useStateStore, |
| useGithubService, |
| ServiceType, |
| ServiceData, |
| useGithubRepositories, |
| useGithubRepositoriesLoading, |
| useGithubRepositoriesError, |
| useFetchGithubRepositories, |
| } from "@/lib/state"; |
| import { useCallback, useEffect, useMemo, useState } from "react"; |
| import { z } from "zod"; |
| import { DeepPartial, EventType, useForm } from "react-hook-form"; |
| import { zodResolver } from "@hookform/resolvers/zod"; |
| import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form"; |
| import { Handle, Position } from "@xyflow/react"; |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| import { GitHubRepository } from "../lib/github"; |
| import { useProjectId } from "@/lib/state"; |
| import { Alert, AlertDescription } from "./ui/alert"; |
| import { AlertCircle, LoaderCircle, RefreshCw } from "lucide-react"; |
| import { Button } from "./ui/button"; |
| import { v4 as uuidv4 } from "uuid"; |
| import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog"; |
| import { Switch } from "./ui/switch"; |
| import { Label } from "./ui/label"; |
| |
| export function NodeGithub(node: GithubNode) { |
| const { id, selected } = node; |
| const isConnectable = useMemo(() => nodeIsConnectable(node, "repository"), [node]); |
| return ( |
| <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}> |
| <div style={{ padding: "10px 20px" }}> |
| {nodeLabel(node)} |
| <Handle |
| id="repository" |
| type={"source"} |
| position={Position.Right} |
| isConnectableStart={isConnectable} |
| isConnectableEnd={isConnectable} |
| isConnectable={isConnectable} |
| /> |
| </div> |
| </NodeRect> |
| ); |
| } |
| |
| const schema = z.object({ |
| repositoryId: z.number().optional(), |
| }); |
| |
| export function NodeGithubDetails({ id, data, disabled }: GithubNode & { disabled?: boolean }) { |
| const store = useStateStore(); |
| const projectId = useProjectId(); |
| const githubService = useGithubService(); |
| |
| const storeRepos = useGithubRepositories(); |
| const isLoadingRepos = useGithubRepositoriesLoading(); |
| const repoError = useGithubRepositoriesError(); |
| const fetchStoreRepositories = useFetchGithubRepositories(); |
| |
| const [displayRepos, setDisplayRepos] = useState<GitHubRepository[]>([]); |
| |
| const [isAnalyzing, setIsAnalyzing] = useState(false); |
| const [showModal, setShowModal] = useState(false); |
| const [discoveredServices, setDiscoveredServices] = useState<z.infer<typeof serviceAnalyzisSchema>[]>([]); |
| const [selectedServices, setSelectedServices] = useState<Record<string, boolean>>({}); |
| |
| useEffect(() => { |
| let currentRepoInStore = false; |
| if (data.repository) { |
| currentRepoInStore = storeRepos.some((r) => r.id === data.repository!.id); |
| } |
| |
| if (data.repository && !currentRepoInStore) { |
| const currentRepoForDisplay: GitHubRepository = { |
| id: data.repository.id, |
| name: data.repository.sshURL.split("/").pop() || "", |
| full_name: data.repository.fullName || data.repository.sshURL.split("/").slice(-2).join("/"), |
| html_url: "", |
| ssh_url: data.repository.sshURL, |
| description: null, |
| private: false, |
| default_branch: "main", |
| }; |
| setDisplayRepos([currentRepoForDisplay, ...storeRepos.filter((r) => r.id !== data.repository!.id)]); |
| } else { |
| setDisplayRepos(storeRepos); |
| } |
| }, [data.repository, storeRepos]); |
| |
| const form = useForm<z.infer<typeof schema>>({ |
| resolver: zodResolver(schema), |
| mode: "onChange", |
| defaultValues: { |
| repositoryId: data.repository?.id, |
| }, |
| }); |
| |
| useEffect(() => { |
| form.reset({ repositoryId: data.repository?.id }); |
| }, [data.repository?.id, form]); |
| |
| useEffect(() => { |
| const sub = form.watch( |
| ( |
| value: DeepPartial<z.infer<typeof schema>>, |
| { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined }, |
| ) => { |
| if (type !== "change") { |
| return; |
| } |
| switch (name) { |
| case "repositoryId": |
| if (value.repositoryId) { |
| const repo = displayRepos.find((r) => r.id === value.repositoryId); |
| if (repo) { |
| store.updateNodeData<"github">(id, { |
| repository: { |
| id: repo.id, |
| sshURL: repo.ssh_url, |
| fullName: repo.full_name, |
| }, |
| }); |
| } |
| } |
| break; |
| } |
| }, |
| ); |
| return () => sub.unsubscribe(); |
| }, [form, store, id, displayRepos]); |
| |
| const analyze = useCallback(async () => { |
| if (!data.repository?.sshURL) return; |
| |
| setIsAnalyzing(true); |
| try { |
| const resp = await fetch(`/api/project/${projectId}/analyze`, { |
| method: "POST", |
| body: JSON.stringify({ |
| address: data.repository?.sshURL, |
| }), |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| }); |
| const servicesResult = z.array(serviceAnalyzisSchema).safeParse(await resp.json()); |
| if (!servicesResult.success) { |
| console.error(servicesResult.error); |
| setIsAnalyzing(false); |
| return; |
| } |
| |
| setDiscoveredServices(servicesResult.data); |
| const initialSelectedServices: Record<string, boolean> = {}; |
| servicesResult.data.forEach((service) => { |
| initialSelectedServices[service.name] = true; |
| }); |
| setSelectedServices(initialSelectedServices); |
| setShowModal(true); |
| } catch (err) { |
| console.error("Analysis failed:", err); |
| } finally { |
| setIsAnalyzing(false); |
| } |
| }, [projectId, data.repository?.sshURL]); |
| |
| const handleImportServices = () => { |
| discoveredServices.forEach((service) => { |
| if (selectedServices[service.name]) { |
| const newNodeData: Omit<ServiceData, "activeField" | "state"> = { |
| label: service.name, |
| repository: { |
| id: id, |
| }, |
| info: service, |
| type: "nodejs:24.0.2" as ServiceType, |
| env: [], |
| volume: [], |
| preBuildCommands: "", |
| isChoosingPortToConnect: false, |
| envVars: [], |
| ports: [], |
| }; |
| const newNodeId = uuidv4(); |
| store.addNode({ |
| id: newNodeId, |
| type: "app", |
| data: newNodeData, |
| }); |
| let edges = store.edges; |
| edges = edges.concat({ |
| id: uuidv4(), |
| source: id, |
| sourceHandle: "repository", |
| target: newNodeId, |
| targetHandle: "repository", |
| }); |
| store.setEdges(edges); |
| } |
| }); |
| setShowModal(false); |
| setDiscoveredServices([]); |
| setSelectedServices({}); |
| }; |
| |
| const handleCancelModal = () => { |
| setShowModal(false); |
| setDiscoveredServices([]); |
| setSelectedServices({}); |
| }; |
| |
| return ( |
| <> |
| <Form {...form}> |
| <form className="space-y-2"> |
| <FormField |
| control={form.control} |
| name="repositoryId" |
| render={({ field }) => ( |
| <FormItem> |
| <div className="flex items-center gap-2 w-full"> |
| <div className="flex-grow"> |
| <Select |
| onValueChange={(value) => field.onChange(Number(value))} |
| value={field.value?.toString()} |
| disabled={isLoadingRepos || !projectId || !githubService || disabled} |
| > |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue |
| placeholder={ |
| githubService |
| ? isLoadingRepos |
| ? "Loading..." |
| : displayRepos.length === 0 |
| ? "No repositories found" |
| : "Select a repository" |
| : "GitHub not configured" |
| } |
| /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| {displayRepos.map((repo) => ( |
| <SelectItem key={repo.id} value={repo.id.toString()}> |
| {repo.full_name} |
| {repo.description && ` - ${repo.description}`} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| </div> |
| {isLoadingRepos && ( |
| <Button variant="ghost" size="icon" disabled> |
| <LoaderCircle className="h-5 w-5 animate-spin text-muted-foreground" /> |
| </Button> |
| )} |
| {!isLoadingRepos && githubService && ( |
| <Button |
| variant="ghost" |
| size="icon" |
| onClick={fetchStoreRepositories} |
| disabled={disabled} |
| aria-label="Refresh repositories" |
| > |
| <RefreshCw className="h-5 w-5 text-muted-foreground" /> |
| </Button> |
| )} |
| </div> |
| <FormMessage /> |
| {repoError && <p className="text-sm text-red-500 mt-1">{repoError}</p>} |
| {!githubService && ( |
| <Alert variant="destructive" className="mt-2"> |
| <AlertCircle className="h-4 w-4" /> |
| <AlertDescription> |
| Please configure Github Personal Access Token in the Integrations tab. |
| </AlertDescription> |
| </Alert> |
| )} |
| </FormItem> |
| )} |
| /> |
| </form> |
| </Form> |
| <Button disabled={!data.repository?.sshURL || isAnalyzing || !githubService || disabled} onClick={analyze}> |
| {isAnalyzing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />} |
| Scan for services |
| </Button> |
| {showModal && ( |
| <Dialog open={showModal} onOpenChange={setShowModal}> |
| <DialogContent className="sm:max-w-[425px]"> |
| <DialogHeader> |
| <DialogTitle>Discovered Services</DialogTitle> |
| <DialogDescription>Select the services you want to import.</DialogDescription> |
| </DialogHeader> |
| <div className="grid gap-4 py-4"> |
| {discoveredServices.map((service) => ( |
| <div key={service.name} className="flex flex-col space-y-2 p-2 border rounded-md"> |
| <div className="flex items-center space-x-2"> |
| <Switch |
| id={service.name} |
| checked={selectedServices[service.name]} |
| onCheckedChange={(checked: boolean) => |
| setSelectedServices((prev) => ({ |
| ...prev, |
| [service.name]: checked, |
| })) |
| } |
| /> |
| <Label htmlFor={service.name} className="font-semibold"> |
| {service.name} |
| </Label> |
| </div> |
| <div className="pl-6 text-sm text-gray-600"> |
| <p> |
| <span className="font-medium">Location:</span> {service.location} |
| </p> |
| {service.configVars && service.configVars.length > 0 && ( |
| <div className="mt-1"> |
| <p className="font-medium">Environment Variables:</p> |
| <ul className="list-disc list-inside pl-4"> |
| {service.configVars.map((envVar) => ( |
| <li key={envVar.name}>{envVar.name}</li> |
| ))} |
| </ul> |
| </div> |
| )} |
| </div> |
| </div> |
| ))} |
| </div> |
| <DialogFooter> |
| <Button variant="outline" onClick={handleCancelModal}> |
| Cancel |
| </Button> |
| <Button onClick={handleImportServices}>Import</Button> |
| </DialogFooter> |
| </DialogContent> |
| </Dialog> |
| )} |
| </> |
| ); |
| } |