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>
+ )}
</>
);
}