Cavnas: Implement basic service discovery logic

Change-Id: I71b25076dba94d6491ad4db748b259870991c526
diff --git a/apps/canvas/front/src/components/node-github.tsx b/apps/canvas/front/src/components/node-github.tsx
index 4c065be..dd7c68a 100644
--- a/apps/canvas/front/src/components/node-github.tsx
+++ b/apps/canvas/front/src/components/node-github.tsx
@@ -1,6 +1,19 @@
 import { NodeRect } from "./node-rect";
-import { GithubNode, nodeIsConnectable, nodeLabel, useStateStore, useGithubService } from "@/lib/state";
-import { useEffect, useMemo, useState } from "react";
+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";
@@ -10,7 +23,12 @@
 import { GitHubRepository } from "../lib/github";
 import { useProjectId } from "@/lib/state";
 import { Alert, AlertDescription } from "./ui/alert";
-import { AlertCircle } from "lucide-react";
+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;
@@ -39,34 +57,42 @@
 export function NodeGithubDetails({ id, data, disabled }: GithubNode & { disabled?: boolean }) {
 	const store = useStateStore();
 	const projectId = useProjectId();
-	const [repos, setRepos] = useState<GitHubRepository[]>([]);
-	const [loading, setLoading] = useState(false);
-	const [error, setError] = useState<string | null>(null);
 	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) {
-			const { id, sshURL } = data.repository;
-			setRepos((prevRepos) => {
-				if (!prevRepos.some((r) => r.id === id)) {
-					return [
-						...prevRepos,
-						{
-							id,
-							name: sshURL.split("/").pop() || "",
-							full_name: sshURL.split("/").slice(-2).join("/"),
-							html_url: "",
-							ssh_url: sshURL,
-							description: null,
-							private: false,
-							default_branch: "main",
-						},
-					];
-				}
-				return prevRepos;
-			});
+			currentRepoInStore = storeRepos.some((r) => r.id === data.repository!.id);
 		}
-	}, [data.repository]);
+
+		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),
@@ -77,6 +103,10 @@
 	});
 
 	useEffect(() => {
+		form.reset({ repositoryId: data.repository?.id });
+	}, [data.repository?.id, form]);
+
+	useEffect(() => {
 		const sub = form.watch(
 			(
 				value: DeepPartial<z.infer<typeof schema>>,
@@ -88,7 +118,7 @@
 				switch (name) {
 					case "repositoryId":
 						if (value.repositoryId) {
-							const repo = repos.find((r) => r.id === value.repositoryId);
+							const repo = displayRepos.find((r) => r.id === value.repositoryId);
 							if (repo) {
 								store.updateNodeData<"github">(id, {
 									repository: {
@@ -104,26 +134,87 @@
 			},
 		);
 		return () => sub.unsubscribe();
-	}, [form, store, id, repos]);
+	}, [form, store, id, displayRepos]);
 
-	useEffect(() => {
-		const fetchRepositories = async () => {
-			if (!githubService) return;
+	const analyze = useCallback(async () => {
+		if (!data.repository?.sshURL) return;
 
-			setLoading(true);
-			setError(null);
-			try {
-				const repositories = await githubService.getRepositories();
-				setRepos(repositories);
-			} catch (err) {
-				setError(err instanceof Error ? err.message : "Failed to fetch repositories");
-			} finally {
-				setLoading(false);
+		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;
 			}
-		};
 
-		fetchRepositories();
-	}, [githubService]);
+			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 (
 		<>
@@ -134,32 +225,57 @@
 						name="repositoryId"
 						render={({ field }) => (
 							<FormItem>
-								<Select
-									onValueChange={(value) => field.onChange(Number(value))}
-									value={field.value?.toString()}
-									disabled={loading || !projectId || !githubService || disabled}
-								>
-									<FormControl>
-										<SelectTrigger>
-											<SelectValue
-												placeholder={
-													githubService ? "Select a repository" : "GitHub not configured"
-												}
-											/>
-										</SelectTrigger>
-									</FormControl>
-									<SelectContent>
-										{repos.map((repo) => (
-											<SelectItem key={repo.id} value={repo.id.toString()}>
-												{repo.full_name}
-												{repo.description && ` - ${repo.description}`}
-											</SelectItem>
-										))}
-									</SelectContent>
-								</Select>
+								<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 />
-								{error && <p className="text-sm text-red-500">{error}</p>}
-								{loading && <p className="text-sm text-gray-500">Loading repositories...</p>}
+								{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" />
@@ -173,6 +289,62 @@
 					/>
 				</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>
+			)}
 		</>
 	);
 }