blob: 44f66d5c9c28cb33c178cdbb0a4d70679d200353 [file] [log] [blame]
import { useCallback, useEffect, useState } from "react";
import { z } from "zod";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import {
useProjectId,
useGithubService,
useGithubRepositories,
useGithubRepositoriesLoading,
useGithubRepositoriesError,
useFetchGithubRepositories,
useStateStore,
} 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";
import { useToast } from "@/hooks/use-toast";
import { serviceAnalyzisSchema, ServiceType, ServiceData } from "config";
const schema = z.object({
repositoryId: z.number().optional(),
});
interface ImportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialRepositoryId?: number;
}
export function ImportModal({ open, onOpenChange, initialRepositoryId }: ImportModalProps) {
const { toast } = useToast();
const store = useStateStore();
const projectId = useProjectId();
const githubService = useGithubService();
const storeRepos = useGithubRepositories();
const isLoadingRepos = useGithubRepositoriesLoading();
const repoError = useGithubRepositoriesError();
const fetchStoreRepositories = useFetchGithubRepositories();
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisAttempted, setAnalysisAttempted] = useState(false);
const [discoveredServices, setDiscoveredServices] = useState<z.infer<typeof serviceAnalyzisSchema>[]>([]);
const [selectedServices, setSelectedServices] = useState<Record<string, boolean>>({});
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
mode: "onChange",
defaultValues: {
repositoryId: initialRepositoryId,
},
});
const selectedRepoId = useWatch({ control: form.control, name: "repositoryId" });
useEffect(() => {
form.reset({ repositoryId: initialRepositoryId });
setAnalysisAttempted(false);
}, [initialRepositoryId, form]);
// Clear analysis results when repository changes
useEffect(() => {
setDiscoveredServices([]);
setSelectedServices({});
setAnalysisAttempted(false);
}, [selectedRepoId]);
const analyze = useCallback(
async (sshURL: string) => {
if (!sshURL) return;
setIsAnalyzing(true);
try {
const resp = await fetch(`/api/project/${projectId}/analyze`, {
method: "POST",
body: JSON.stringify({
address: sshURL,
}),
headers: {
"Content-Type": "application/json",
},
});
const servicesResult = z.array(serviceAnalyzisSchema).safeParse(await resp.json());
if (!servicesResult.success) {
console.error(servicesResult.error);
toast({
variant: "destructive",
title: "Failed to analyze repository",
});
setIsAnalyzing(false);
return;
}
setDiscoveredServices(servicesResult.data);
const initialSelectedServices: Record<string, boolean> = {};
servicesResult.data.forEach((service) => {
initialSelectedServices[service.name] = true;
});
setSelectedServices(initialSelectedServices);
} catch (err) {
console.error("Analysis failed:", err);
toast({
variant: "destructive",
title: "Failed to analyze repository",
});
} finally {
setIsAnalyzing(false);
}
},
[projectId, toast],
);
// Auto-analyze when opened with initialRepositoryId
useEffect(() => {
if (open && initialRepositoryId && !isAnalyzing && !discoveredServices.length && !analysisAttempted) {
const repo = storeRepos.find((r) => r.id === initialRepositoryId);
if (repo?.ssh_url) {
setAnalysisAttempted(true);
analyze(repo.ssh_url);
}
}
}, [open, initialRepositoryId, isAnalyzing, discoveredServices.length, storeRepos, analyze, analysisAttempted]);
const handleImportServices = () => {
const repoId = form.getValues("repositoryId");
if (!repoId) return;
const repo = storeRepos.find((r) => r.id === repoId);
if (!repo) return;
// Check for existing GitHub node for this repository
const existingGithubNode = store.nodes.find((n) => n.type === "github" && n.data.repository?.id === repo.id);
const githubNodeId = existingGithubNode?.id || uuidv4();
// Only create a new GitHub node if one doesn't exist
if (!existingGithubNode) {
store.addNode({
id: githubNodeId,
type: "github",
data: {
label: repo.full_name,
repository: {
id: repo.id,
sshURL: repo.ssh_url,
fullName: repo.full_name,
},
envVars: [],
ports: [],
state: null,
},
});
}
discoveredServices.forEach((service) => {
if (selectedServices[service.name]) {
const newNodeData: Omit<ServiceData, "activeField" | "state"> = {
label: service.name,
repository: {
id: repo.id,
repoNodeId: githubNodeId,
},
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: githubNodeId,
sourceHandle: "repository",
target: newNodeId,
targetHandle: "repository",
});
store.setEdges(edges);
}
});
onOpenChange(false);
setDiscoveredServices([]);
setSelectedServices({});
form.reset();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Import Services</DialogTitle>
<DialogDescription>Select a repository and analyze it for services.</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<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}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
githubService
? isLoadingRepos
? "Loading..."
: storeRepos.length === 0
? "No repositories found"
: "Select a repository"
: "GitHub not configured"
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{storeRepos.map((repo) => (
<SelectItem
key={repo.id}
value={repo.id.toString()}
className="cursor-pointer hover:bg-gray-100"
>
{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}
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={!form.getValues("repositoryId") || isAnalyzing || !githubService}
onClick={() => {
const repo = storeRepos.find((r) => r.id === form.getValues("repositoryId"));
if (repo?.ssh_url) {
analyze(repo.ssh_url);
}
}}
>
{isAnalyzing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{isAnalyzing ? "Analyzing..." : "Scan for services"}
</Button>
{discoveredServices.length > 0 && (
<div className="grid gap-4">
<h4 className="font-medium">Discovered Services</h4>
{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>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleImportServices}
disabled={!discoveredServices.length || !Object.values(selectedServices).some(Boolean)}
>
Import Selected Services
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}