| import { v4 as uuidv4 } from "uuid"; |
| import { NodeRect } from "./node-rect"; |
| import { useStateStore, nodeLabel, AppState, nodeIsConnectable, useEnv, useGithubRepositories } from "@/lib/state"; |
| import { ServiceNode, ServiceTypes, GatewayHttpsNode, GatewayTCPNode, BoundEnvVar, AppNode, GithubNode } from "config"; |
| import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react"; |
| import { z } from "zod"; |
| import { useForm, EventType, DeepPartial } from "react-hook-form"; |
| import { zodResolver } from "@hookform/resolvers/zod"; |
| import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from "./ui/form"; |
| import { Button } from "./ui/button"; |
| import { Handle, Position, useNodes } from "@xyflow/react"; |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; |
| import { Textarea } from "./ui/textarea"; |
| import { Input } from "./ui/input"; |
| import { Switch } from "./ui/switch"; |
| import { Label } from "./ui/label"; |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; |
| import { Code, Container, Network, Pencil, Variable } from "lucide-react"; |
| import { Badge } from "./ui/badge"; |
| import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ui/accordion"; |
| import { Name } from "./node-name"; |
| import { NodeDetailsProps } from "@/lib/types"; |
| |
| export function NodeApp(node: ServiceNode) { |
| const { id, selected } = node; |
| const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]); |
| const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]); |
| return ( |
| <NodeRect id={id} selected={selected} node={node} state={node.data.state}> |
| <div style={{ padding: "10px 20px" }}> |
| {nodeLabel(node)} |
| <Handle |
| id="repository" |
| type={"target"} |
| position={Position.Left} |
| isConnectableStart={isConnectableRepository} |
| isConnectableEnd={isConnectableRepository} |
| isConnectable={isConnectableRepository} |
| /> |
| <Handle |
| id="ports" |
| type={"source"} |
| position={Position.Top} |
| isConnectableStart={isConnectablePorts} |
| isConnectableEnd={isConnectablePorts} |
| isConnectable={isConnectablePorts} |
| /> |
| <Handle |
| id="env_var" |
| type={"target"} |
| position={Position.Bottom} |
| isConnectableStart={true} |
| isConnectableEnd={true} |
| isConnectable={true} |
| /> |
| </div> |
| </NodeRect> |
| ); |
| } |
| |
| const schema = z.object({ |
| name: z.string().min(1, "requried"), |
| type: z.enum(ServiceTypes), |
| }); |
| |
| const sourceSchema = z.object({ |
| id: z.string().min(1, "required"), |
| branch: z.string(), |
| rootDir: z.string(), |
| }); |
| |
| const devSchema = z.object({ |
| enabled: z.boolean(), |
| }); |
| |
| const exposeSchema = z.object({ |
| network: z.string().min(1, "reqired"), |
| subdomain: z.string().min(1, "required"), |
| }); |
| |
| const agentSchema = z.object({ |
| model: z.enum(["gemini", "claude"]), |
| apiKey: z.string().optional(), |
| }); |
| |
| export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) { |
| const { data } = node; |
| return ( |
| <> |
| {showName ? <Name node={node} disabled={disabled} /> : null} |
| <Tabs defaultValue="runtime"> |
| <TabsList className="w-full flex flex-row justify-between"> |
| <TabsTrigger value="runtime"> |
| {isOverview ? ( |
| <div className="flex flex-row gap-1 items-center"> |
| <Container /> Runtime |
| </div> |
| ) : ( |
| <TooltipProvider> |
| <Tooltip> |
| <TooltipTrigger> |
| <Container /> |
| </TooltipTrigger> |
| <TooltipContent>Runtime</TooltipContent> |
| </Tooltip> |
| </TooltipProvider> |
| )} |
| </TabsTrigger> |
| <TabsTrigger value="ports"> |
| {isOverview ? ( |
| <div className="flex flex-row gap-1 items-center"> |
| <Network /> Ports |
| <Badge className="rounded-full">{data.ports?.length ?? 0}</Badge> |
| </div> |
| ) : ( |
| <TooltipProvider> |
| <Tooltip> |
| <TooltipTrigger className="flex flex-row gap-1 items-center"> |
| <Network /> |
| </TooltipTrigger> |
| <TooltipContent> |
| Ports{" "} |
| <Badge variant="secondary" className="rounded-full"> |
| {data.ports?.length ?? 0} |
| </Badge> |
| </TooltipContent> |
| </Tooltip> |
| </TooltipProvider> |
| )} |
| </TabsTrigger> |
| <TabsTrigger value="vars"> |
| {isOverview ? ( |
| <div className="flex flex-row gap-1 items-center"> |
| <Variable /> Variables |
| <Badge className="rounded-full">{data.envVars?.length ?? 0}</Badge> |
| </div> |
| ) : ( |
| <TooltipProvider> |
| <Tooltip> |
| <TooltipTrigger className="flex flex-row gap-1 items-center"> |
| <Variable /> |
| </TooltipTrigger> |
| <TooltipContent> |
| Variables{" "} |
| <Badge variant="secondary" className="rounded-full"> |
| {data.envVars?.length ?? 0} |
| </Badge> |
| </TooltipContent> |
| </Tooltip> |
| </TooltipProvider> |
| )} |
| </TabsTrigger> |
| {node.data.type !== "sketch:latest" && ( |
| <TabsTrigger value="dev"> |
| {isOverview ? ( |
| <div className="flex flex-row gap-1 items-center"> |
| <Code /> Dev |
| </div> |
| ) : ( |
| <TooltipProvider> |
| <Tooltip> |
| <TooltipTrigger className="flex flex-row gap-1 items-center"> |
| <Code /> |
| </TooltipTrigger> |
| <TooltipContent>Dev</TooltipContent> |
| </Tooltip> |
| </TooltipProvider> |
| )} |
| </TabsTrigger> |
| )} |
| </TabsList> |
| <TabsContent value="runtime"> |
| <Runtime node={node} disabled={disabled} /> |
| </TabsContent> |
| <TabsContent value="ports"> |
| <Ports node={node} disabled={disabled} /> |
| </TabsContent> |
| <TabsContent value="vars"> |
| <EnvVars node={node} disabled={disabled} /> |
| </TabsContent> |
| {node.data.type !== "sketch:latest" && ( |
| <TabsContent value="dev"> |
| <Dev node={node} disabled={disabled} /> |
| </TabsContent> |
| )} |
| </Tabs> |
| </> |
| ); |
| } |
| |
| function Runtime({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| const { id, data } = node; |
| const store = useStateStore(); |
| const form = useForm<z.infer<typeof schema>>({ |
| resolver: zodResolver(schema), |
| mode: "onChange", |
| defaultValues: { |
| name: data.label, |
| type: data.type, |
| }, |
| }); |
| 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 "name": |
| if (!value.name) { |
| break; |
| } |
| store.updateNodeData<"app">(id, { |
| label: value.name, |
| }); |
| break; |
| case "type": |
| if (!value.type) { |
| break; |
| } |
| store.updateNodeData<"app">(id, { |
| type: value.type, |
| }); |
| break; |
| } |
| }, |
| ); |
| return () => sub.unsubscribe(); |
| }, [id, form, store]); |
| const [typeProps, setTypeProps] = useState({}); |
| useEffect(() => { |
| if (data.activeField === "type") { |
| setTypeProps({ |
| open: true, |
| onOpenChange: () => store.updateNodeData(id, { activeField: undefined }), |
| }); |
| } else { |
| setTypeProps({}); |
| } |
| }, [id, data, store, setTypeProps]); |
| const setPreBuildCommands = useCallback( |
| (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
| store.updateNodeData<"app">(id, { |
| preBuildCommands: e.currentTarget.value, |
| }); |
| }, |
| [id, store], |
| ); |
| const agentForm = useForm<z.infer<typeof agentSchema>>({ |
| resolver: zodResolver(agentSchema), |
| mode: "onChange", |
| defaultValues: { |
| apiKey: data.model?.apiKey, |
| model: data.model?.name, |
| }, |
| }); |
| useEffect(() => { |
| const sub = agentForm.watch((value, { name }: { name?: keyof z.infer<typeof agentSchema> | undefined }) => { |
| switch (name) { |
| case "model": |
| agentForm.setValue("apiKey", "", { shouldDirty: true }); |
| store.updateNodeData<"app">(id, { |
| model: { |
| name: value.model, |
| apiKey: undefined, |
| }, |
| }); |
| break; |
| case "apiKey": |
| store.updateNodeData<"app">(id, { |
| model: { |
| name: data.model?.name, |
| apiKey: value.apiKey, |
| }, |
| }); |
| break; |
| } |
| }); |
| return () => sub.unsubscribe(); |
| }, [id, agentForm, store, data]); |
| return ( |
| <> |
| <SourceRepo node={node} disabled={disabled} /> |
| {node.data.type !== "sketch:latest" && ( |
| <Form {...form}> |
| <form className="space-y-2"> |
| <Label>Container Image</Label> |
| <FormField |
| control={form.control} |
| name="type" |
| render={({ field }) => ( |
| <FormItem> |
| <Select |
| onValueChange={field.onChange} |
| value={field.value || ""} |
| {...typeProps} |
| disabled={disabled} |
| > |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| {ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => ( |
| <SelectItem key={t} value={t}> |
| {t} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| </form> |
| </Form> |
| )} |
| {node.data.type === "sketch:latest" && ( |
| <Form {...agentForm}> |
| <form className="space-y-2"> |
| <FormField |
| control={agentForm.control} |
| name="model" |
| render={({ field }) => ( |
| <FormItem> |
| <FormLabel>AI Model</FormLabel> |
| <Select |
| onValueChange={field.onChange} |
| defaultValue={field.value} |
| disabled={disabled} |
| > |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue placeholder="Select a model" /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| <SelectItem value="gemini">Gemini</SelectItem> |
| <SelectItem value="claude">Claude</SelectItem> |
| </SelectContent> |
| </Select> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| <Label>API Key</Label> |
| <FormField |
| control={agentForm.control} |
| name="apiKey" |
| render={({ field }) => ( |
| <FormItem> |
| <FormControl> |
| <Input |
| type="password" |
| placeholder="Override AI Model API key" |
| {...field} |
| value={field.value || ""} |
| disabled={disabled} |
| /> |
| </FormControl> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| </form> |
| </Form> |
| )} |
| {node.data.type !== "sketch:latest" && ( |
| <> |
| <Label>Pre-Build Commands</Label> |
| <Textarea |
| placeholder="new line separated list of commands to run before running the service" |
| value={data.preBuildCommands} |
| onChange={setPreBuildCommands} |
| disabled={disabled} |
| /> |
| </> |
| )} |
| </> |
| ); |
| } |
| |
| function Ports({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| const { id, data } = node; |
| const store = useStateStore(); |
| const [name, setName] = useState(""); |
| const [value, setValue] = useState(""); |
| const onSubmit = useCallback(() => { |
| const portId = uuidv4(); |
| store.updateNodeData<"app">(id, { |
| ports: (data.ports || []).concat({ |
| id: portId, |
| name: name.toUpperCase(), |
| value: Number(value), |
| }), |
| envVars: (data.envVars || []).concat( |
| { |
| id: uuidv4(), |
| source: null, |
| portId, |
| name: `DODO_PORT_${name.toUpperCase()}`, |
| }, |
| { |
| id: uuidv4(), |
| source: null, |
| portId, |
| name: `DODO_PORT_${name.toUpperCase()}`, |
| alias: name.toUpperCase(), |
| }, |
| ), |
| }); |
| setName(""); |
| setValue(""); |
| }, [id, data, store, name, value, setName, setValue]); |
| const removePort = useCallback( |
| (portId: string) => { |
| // TODO(gio): this is ugly |
| const tcpRemoved = new Set<string>(); |
| store.setEdges( |
| store.edges.filter((e) => { |
| if (e.source !== id || e.sourceHandle !== "ports") { |
| return true; |
| } |
| const tn = store.nodes.find((n) => n.id == e.target)!; |
| if (e.targetHandle === "https") { |
| const t = tn as GatewayHttpsNode; |
| if (t.data.https?.serviceId === id && t.data.https.portId === portId) { |
| return false; |
| } |
| } |
| if (e.targetHandle === "tcp") { |
| const t = tn as GatewayTCPNode; |
| if (tcpRemoved.has(t.id)) { |
| return true; |
| } |
| if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) { |
| tcpRemoved.add(t.id); |
| return false; |
| } |
| } |
| if (e.targetHandle === "env_var") { |
| if ( |
| tn && |
| (tn.data.envVars || []).find( |
| (ev) => ev.source === id && "portId" in ev && ev.portId === portId, |
| ) |
| ) { |
| return false; |
| } |
| } |
| return true; |
| }), |
| ); |
| store.nodes |
| .filter( |
| (n) => |
| n.type === "gateway-https" && |
| n.data.https && |
| n.data.https.serviceId === id && |
| n.data.https.portId === portId, |
| ) |
| .forEach((n) => { |
| store.updateNodeData<"gateway-https">(n.id, { |
| https: undefined, |
| }); |
| }); |
| store.nodes |
| .filter((n) => n.type === "gateway-tcp") |
| .forEach((n) => { |
| const filtered = n.data.exposed.filter((e) => { |
| if (e.serviceId === id && e.portId === portId) { |
| return false; |
| } else { |
| return true; |
| } |
| }); |
| if (filtered.length != n.data.exposed.length) { |
| store.updateNodeData<"gateway-tcp">(n.id, { |
| exposed: filtered, |
| }); |
| } |
| }); |
| store.nodes |
| .filter((n) => n.type === "app" && n.data.envVars) |
| .forEach((n) => { |
| store.updateNodeData<"app">(n.id, { |
| envVars: n.data.envVars.filter((ev) => { |
| if (ev.source === id && "portId" in ev && ev.portId === portId) { |
| return false; |
| } |
| return true; |
| }), |
| }); |
| }); |
| store.updateNodeData<"app">(id, { |
| ports: (data.ports || []).filter((p) => p.id !== portId), |
| envVars: (data.envVars || []).filter( |
| (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId), |
| ), |
| }); |
| }, |
| [id, data, store], |
| ); |
| return ( |
| <div className="flex flex-col gap-1"> |
| <div className="grid grid-cols-[1fr_1fr_auto] gap-1"> |
| {data && |
| data.ports && |
| data.ports.map((p) => ( |
| <> |
| <div className="flex items-center px-3">{p.name.toUpperCase()}</div> |
| <div className="flex items-center px-3">{p.value}</div> |
| <div className="flex items-center"> |
| <Button |
| variant="destructive" |
| className="w-full" |
| onClick={() => removePort(p.id)} |
| disabled={disabled} |
| > |
| Remove |
| </Button> |
| </div> |
| </> |
| ))} |
| <div> |
| <Input |
| placeholder="name" |
| className="uppercase w-0 min-w-full" |
| disabled={disabled} |
| value={name} |
| onChange={(e) => setName(e.target.value)} |
| /> |
| </div> |
| <div> |
| <Input |
| placeholder="0" |
| className="w-0 min-w-full" |
| disabled={disabled} |
| value={value} |
| onChange={(e) => setValue(e.target.value)} |
| /> |
| </div> |
| <div> |
| <Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}> |
| Add |
| </Button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
| |
| function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| const { id, data } = node; |
| const store = useStateStore(); |
| const editAlias = useCallback( |
| (e: BoundEnvVar) => { |
| return () => { |
| if (disabled) { |
| return; |
| } |
| store.updateNodeData(id, { |
| ...data, |
| envVars: data.envVars!.map((o) => { |
| if (o.id !== e.id) { |
| return o; |
| } else |
| return { |
| ...o, |
| isEditting: true, |
| }; |
| }), |
| }); |
| }; |
| }, |
| [id, data, store, disabled], |
| ); |
| const saveAlias = useCallback( |
| (e: BoundEnvVar, value: string, store: AppState) => { |
| store.updateNodeData(id, { |
| ...data, |
| envVars: data.envVars!.map((o) => { |
| if (o.id !== e.id) { |
| return o; |
| } |
| if (value) { |
| return { |
| ...o, |
| isEditting: false, |
| alias: value.toUpperCase(), |
| }; |
| } |
| if ("alias" in o) { |
| const { alias: _, ...rest } = o; |
| return { |
| ...rest, |
| isEditting: false, |
| }; |
| } |
| return { |
| ...o, |
| isEditting: false, |
| }; |
| }), |
| }); |
| }, |
| [id, data], |
| ); |
| const saveAliasOnEnter = useCallback( |
| (e: BoundEnvVar) => { |
| return (event: KeyboardEvent<HTMLInputElement>) => { |
| if (event.key === "Enter") { |
| event.preventDefault(); |
| saveAlias(e, event.currentTarget.value, store); |
| } |
| }; |
| }, |
| [store, saveAlias], |
| ); |
| const saveAliasOnBlur = useCallback( |
| (e: BoundEnvVar) => { |
| return (event: FocusEvent<HTMLInputElement>) => { |
| saveAlias(e, event.currentTarget.value, store); |
| }; |
| }, |
| [store, saveAlias], |
| ); |
| return ( |
| <ul> |
| {data && |
| data.envVars && |
| data.envVars.map((v) => { |
| if ("name" in v) { |
| const value = "alias" in v ? v.alias : v.name; |
| if (v.isEditting) { |
| return ( |
| <li key={v.id}> |
| <Input |
| type="text" |
| className="uppercase" |
| defaultValue={value} |
| onKeyUp={saveAliasOnEnter(v)} |
| onBlur={saveAliasOnBlur(v)} |
| autoFocus={true} |
| disabled={disabled} |
| /> |
| </li> |
| ); |
| } |
| return ( |
| <li key={v.id} onClick={editAlias(v)}> |
| <TooltipProvider> |
| <Tooltip> |
| <TooltipTrigger className="w-full"> |
| <div |
| className={`w-full flex flex-row items-center gap-1 ${disabled ? "" : "cursor-text"}`} |
| > |
| {!disabled && <Pencil className="w-4 h-4" />} |
| <div className="uppercase">{value}</div> |
| </div> |
| </TooltipTrigger> |
| <TooltipContent>{v.name}</TooltipContent> |
| </Tooltip> |
| </TooltipProvider> |
| </li> |
| ); |
| } |
| })} |
| </ul> |
| ); |
| } |
| |
| function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| const { id, data } = node; |
| const env = useEnv(); |
| const store = useStateStore(); |
| const devForm = useForm<z.infer<typeof devSchema>>({ |
| resolver: zodResolver(devSchema), |
| mode: "onChange", |
| defaultValues: { |
| enabled: data.dev ? data.dev.enabled : false, |
| }, |
| }); |
| useEffect(() => { |
| const sub = devForm.watch((value, { name }) => { |
| if (name === "enabled") { |
| if (value.enabled) { |
| const csGateway: Omit<GatewayHttpsNode, "position"> = { |
| id: uuidv4(), |
| type: "gateway-https", |
| data: { |
| readonly: true, |
| https: { |
| serviceId: id, |
| portId: `${id}-code-server`, |
| }, |
| network: data.dev?.expose?.network, |
| subdomain: data.dev?.expose?.subdomain, |
| label: "", |
| envVars: [], |
| ports: [], |
| }, |
| }; |
| const sshGateway: Omit<GatewayTCPNode, "position"> = { |
| id: uuidv4(), |
| type: "gateway-tcp", |
| data: { |
| readonly: true, |
| exposed: [ |
| { |
| serviceId: id, |
| portId: `${id}-ssh`, |
| }, |
| ], |
| network: data.dev?.expose?.network, |
| subdomain: data.dev?.expose?.subdomain, |
| label: "", |
| envVars: [], |
| ports: [], |
| }, |
| }; |
| store.addNode(csGateway); |
| store.addNode(sshGateway); |
| store.updateNodeData<"app">(id, { |
| dev: { |
| enabled: true, |
| expose: data.dev?.expose, |
| codeServerNodeId: csGateway.id, |
| sshNodeId: sshGateway.id, |
| }, |
| ports: (data.ports || []).concat( |
| { |
| id: `${id}-code-server`, |
| name: "code-server", |
| value: 9090, |
| }, |
| { |
| id: `${id}-ssh`, |
| name: "ssh", |
| value: 22, |
| }, |
| ), |
| }); |
| let edges = store.edges.concat([ |
| { |
| id: uuidv4(), |
| source: id, |
| sourceHandle: "ports", |
| target: csGateway.id, |
| targetHandle: "https", |
| }, |
| { |
| id: uuidv4(), |
| source: id, |
| sourceHandle: "ports", |
| target: sshGateway.id, |
| targetHandle: "tcp", |
| }, |
| ]); |
| if (data.dev?.expose?.network !== undefined) { |
| edges = edges.concat([ |
| { |
| id: uuidv4(), |
| source: csGateway.id, |
| sourceHandle: "subdomain", |
| target: data.dev.expose.network, |
| targetHandle: "subdomain", |
| }, |
| { |
| id: uuidv4(), |
| source: sshGateway.id, |
| sourceHandle: "subdomain", |
| target: data.dev.expose.network, |
| targetHandle: "subdomain", |
| }, |
| ]); |
| } |
| store.setEdges(edges); |
| } else { |
| const { dev } = data; |
| if (dev?.enabled) { |
| store.setNodes( |
| store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId), |
| ); |
| store.setEdges( |
| store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId), |
| ); |
| } |
| store.updateNodeData<"app">(id, { |
| dev: { |
| enabled: false, |
| expose: dev?.expose, |
| }, |
| ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"), |
| }); |
| } |
| } |
| }); |
| return () => sub.unsubscribe(); |
| }, [id, data, devForm, store]); |
| const exposeForm = useForm<z.infer<typeof exposeSchema>>({ |
| resolver: zodResolver(exposeSchema), |
| mode: "onChange", |
| defaultValues: { |
| network: data.dev?.expose?.network, |
| subdomain: data.dev?.expose?.subdomain, |
| }, |
| }); |
| useEffect(() => { |
| const sub = exposeForm.watch( |
| ( |
| value: DeepPartial<z.infer<typeof exposeSchema>>, |
| { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined }, |
| ) => { |
| const { dev } = data; |
| if (!dev?.enabled) { |
| return; |
| } |
| if (name === "network") { |
| let edges = store.edges; |
| if (dev.enabled && dev.expose?.network !== undefined) { |
| edges = edges.filter((e) => { |
| if ( |
| (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) && |
| e.sourceHandle === "subdomain" && |
| e.target === dev.expose?.network && |
| e.targetHandle === "subdomain" |
| ) { |
| return false; |
| } else { |
| return true; |
| } |
| }); |
| } |
| if (value.network !== undefined) { |
| edges = edges.concat( |
| { |
| id: uuidv4(), |
| source: dev.codeServerNodeId, |
| sourceHandle: "subdomain", |
| target: value.network, |
| targetHandle: "subdomain", |
| }, |
| { |
| id: uuidv4(), |
| source: dev.sshNodeId, |
| sourceHandle: "subdomain", |
| target: value.network, |
| targetHandle: "subdomain", |
| }, |
| ); |
| } |
| store.setEdges(edges); |
| store.updateNodeData<"app">(id, { |
| dev: { |
| ...dev, |
| expose: { |
| network: value.network, |
| subdomain: dev.expose?.subdomain, |
| }, |
| }, |
| }); |
| store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network }); |
| store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network }); |
| } else if (name === "subdomain") { |
| store.updateNodeData<"app">(id, { |
| dev: { |
| ...dev, |
| expose: { |
| network: dev.expose?.network, |
| subdomain: value.subdomain, |
| }, |
| }, |
| }); |
| store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain }); |
| store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain }); |
| } |
| }, |
| ); |
| return () => sub.unsubscribe(); |
| }, [id, data, exposeForm, store]); |
| return ( |
| <> |
| <Form {...devForm}> |
| <form className="space-y-2"> |
| <FormField |
| control={devForm.control} |
| name="enabled" |
| render={({ field }) => ( |
| <FormItem> |
| <div className="flex flex-row gap-1 items-center"> |
| <Switch |
| id="devEnabled" |
| onCheckedChange={field.onChange} |
| checked={field.value} |
| disabled={disabled} |
| /> |
| <Label htmlFor="devEnabled">Dev VM</Label> |
| </div> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| </form> |
| </Form> |
| {data.dev && data.dev.enabled && ( |
| <Form {...exposeForm}> |
| <form className="space-y-2"> |
| <Label>Network</Label> |
| <FormField |
| control={exposeForm.control} |
| name="network" |
| render={({ field }) => ( |
| <FormItem> |
| <Select |
| onValueChange={field.onChange} |
| value={field.value || ""} |
| disabled={disabled} |
| > |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| {env.networks.map((n) => ( |
| <SelectItem |
| key={n.name} |
| value={n.domain} |
| >{`${n.name} - ${n.domain}`}</SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| <Label>Subdomain</Label> |
| <FormField |
| control={exposeForm.control} |
| name="subdomain" |
| render={({ field }) => ( |
| <FormItem> |
| <FormControl> |
| <Input {...field} disabled={disabled} /> |
| </FormControl> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| </form> |
| </Form> |
| )} |
| </> |
| ); |
| } |
| |
| function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| const { id, data } = node; |
| const store = useStateStore(); |
| const nodes = useNodes<AppNode>(); |
| const repo = useMemo(() => { |
| return nodes |
| .filter((n): n is GithubNode => n.type === "github") |
| .find((n) => n.id === data.repository?.repoNodeId); |
| }, [nodes, data.repository?.repoNodeId]); |
| const repos = useGithubRepositories(); |
| const sourceForm = useForm<z.infer<typeof sourceSchema>>({ |
| resolver: zodResolver(sourceSchema), |
| mode: "onChange", |
| defaultValues: { |
| id: data?.repository?.id?.toString(), |
| branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined, |
| rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined, |
| }, |
| }); |
| useEffect(() => { |
| const sub = sourceForm.watch( |
| ( |
| value: DeepPartial<z.infer<typeof sourceSchema>>, |
| { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined }, |
| ) => { |
| if (name === "id") { |
| const newRepoId = value.id ? parseInt(value.id, 10) : undefined; |
| if (!newRepoId) return; |
| |
| const oldGithubNodeId = data.repository?.repoNodeId; |
| const selectedRepo = repos.find((r) => r.id === newRepoId); |
| |
| if (!selectedRepo) return; |
| |
| // If a node for the selected repo already exists, connect to it. |
| const existingNodeForSelectedRepo = nodes |
| .filter((n): n is GithubNode => n.type === "github") |
| .find((n) => n.data.repository?.id === selectedRepo.id); |
| |
| if (existingNodeForSelectedRepo) { |
| let { nodes, edges } = store; |
| if (oldGithubNodeId) { |
| edges = edges.filter( |
| (e) => |
| !( |
| e.target === id && |
| e.source === oldGithubNodeId && |
| e.targetHandle === "repository" |
| ), |
| ); |
| } |
| edges = edges.concat({ |
| id: uuidv4(), |
| source: existingNodeForSelectedRepo.id, |
| sourceHandle: "repository", |
| target: id, |
| targetHandle: "repository", |
| }); |
| nodes = nodes.map((n) => { |
| if (n.id !== id) { |
| return n; |
| } else { |
| const sn = n as ServiceNode; |
| return { |
| ...sn, |
| data: { |
| ...sn.data, |
| repository: { |
| ...sn.data.repository, |
| id: newRepoId, |
| repoNodeId: existingNodeForSelectedRepo.id, |
| }, |
| }, |
| }; |
| } |
| }); |
| if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) { |
| const isOldNodeStillUsed = edges.some( |
| (e) => e.source === oldGithubNodeId && e.sourceHandle === "repository", |
| ); |
| if (!isOldNodeStillUsed) { |
| nodes = nodes.filter((n) => n.id !== oldGithubNodeId); |
| } |
| } |
| store.setNodes(nodes); |
| store.setEdges(edges); |
| return; |
| } |
| |
| // No node for selected repo, decide whether to update old node or create a new one. |
| if (oldGithubNodeId) { |
| const isOldNodeShared = |
| store.edges.filter( |
| (e) => |
| e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository", |
| ).length > 0; |
| |
| if (!isOldNodeShared) { |
| // Update old node |
| store.updateNodeData<"github">(oldGithubNodeId, { |
| repository: { |
| id: selectedRepo.id, |
| sshURL: selectedRepo.ssh_url, |
| fullName: selectedRepo.full_name, |
| }, |
| label: selectedRepo.full_name, |
| }); |
| store.updateNodeData<"app">(id, { |
| repository: { |
| ...data.repository, |
| id: newRepoId, |
| }, |
| }); |
| } else { |
| // Create new node because old one is shared |
| const newGithubNodeId = uuidv4(); |
| store.addNode({ |
| id: newGithubNodeId, |
| type: "github", |
| data: { |
| repository: { |
| id: selectedRepo.id, |
| sshURL: selectedRepo.ssh_url, |
| fullName: selectedRepo.full_name, |
| }, |
| label: selectedRepo.full_name, |
| envVars: [], |
| ports: [], |
| }, |
| }); |
| |
| let edges = store.edges; |
| // remove old edge |
| edges = edges.filter( |
| (e) => |
| !( |
| e.target === id && |
| e.source === oldGithubNodeId && |
| e.targetHandle === "repository" |
| ), |
| ); |
| // add new edge |
| edges = edges.concat({ |
| id: uuidv4(), |
| source: newGithubNodeId, |
| sourceHandle: "repository", |
| target: id, |
| targetHandle: "repository", |
| }); |
| store.setEdges(edges); |
| store.updateNodeData<"app">(id, { |
| repository: { |
| ...data.repository, |
| id: newRepoId, |
| repoNodeId: newGithubNodeId, |
| }, |
| }); |
| } |
| } else { |
| // No old github node, so create a new one |
| const newGithubNodeId = uuidv4(); |
| store.addNode({ |
| id: newGithubNodeId, |
| type: "github", |
| data: { |
| repository: { |
| id: selectedRepo.id, |
| sshURL: selectedRepo.ssh_url, |
| fullName: selectedRepo.full_name, |
| }, |
| label: selectedRepo.full_name, |
| envVars: [], |
| ports: [], |
| }, |
| }); |
| store.setEdges( |
| store.edges.concat({ |
| id: uuidv4(), |
| source: newGithubNodeId, |
| sourceHandle: "repository", |
| target: id, |
| targetHandle: "repository", |
| }), |
| ); |
| store.updateNodeData<"app">(id, { |
| repository: { |
| ...data.repository, |
| id: newRepoId, |
| repoNodeId: newGithubNodeId, |
| }, |
| }); |
| } |
| } else if (name === "branch") { |
| store.updateNodeData<"app">(id, { |
| repository: { |
| ...data?.repository, |
| branch: value.branch, |
| }, |
| }); |
| } else if (name === "rootDir") { |
| store.updateNodeData<"app">(id, { |
| repository: { |
| ...data?.repository, |
| rootDir: value.rootDir, |
| }, |
| }); |
| } |
| }, |
| ); |
| return () => sub.unsubscribe(); |
| }, [id, data, sourceForm, store, nodes, repos]); |
| const [isExpanded, setIsExpanded] = useState(false); |
| // useEffect(() => { |
| // if (data.repository === undefined) { |
| // setIsExpanded(true); |
| // } |
| // }, [data.repository, setIsExpanded]); |
| console.log(data.repository, isExpanded, repo); |
| return ( |
| <Accordion type="single" collapsible> |
| <AccordionItem value="repository" className="border-none"> |
| <AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}> |
| Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName} |
| </AccordionTrigger> |
| <AccordionContent className="px-1"> |
| <Form {...sourceForm}> |
| <form className="space-y-2"> |
| <Label>Repository</Label> |
| <FormField |
| control={sourceForm.control} |
| name="id" |
| render={({ field }) => ( |
| <FormItem> |
| <Select onValueChange={field.onChange} value={field.value} disabled={disabled}> |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| {repos.map((r) => ( |
| <SelectItem |
| key={r.id} |
| value={r.id.toString()} |
| >{`${r.full_name}`}</SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| <Label>Branch</Label> |
| <FormField |
| control={sourceForm.control} |
| name="branch" |
| render={({ field }) => ( |
| <FormItem> |
| <FormControl> |
| <Input |
| placeholder="master" |
| className="lowercase" |
| {...field} |
| disabled={disabled} |
| /> |
| </FormControl> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| <Label>Root Directory</Label> |
| <FormField |
| control={sourceForm.control} |
| name="rootDir" |
| render={({ field }) => ( |
| <FormItem> |
| <FormControl> |
| <Input placeholder="/" {...field} disabled={disabled} /> |
| </FormControl> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| </form> |
| </Form> |
| </AccordionContent> |
| </AccordionItem> |
| </Accordion> |
| ); |
| } |