| import { v4 as uuidv4 } from "uuid"; |
| import { NodeRect } from "./node-rect"; |
| import { |
| useStateStore, |
| nodeLabel, |
| AppState, |
| nodeIsConnectable, |
| useEnv, |
| useGithubRepositories, |
| useMode, |
| } from "@/lib/state"; |
| import { |
| ServiceNode, |
| ServiceTypes, |
| GatewayHttpsNode, |
| GatewayTCPNode, |
| BoundEnvVar, |
| AppNode, |
| GithubNode, |
| Machines, |
| Machine, |
| MachinesSchema, |
| } from "config"; |
| import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState, useRef } 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 { Check, Code, Container, Copy, 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"; |
| import { Gateway } from "@/Gateways"; |
| import { Port } from "config"; |
| import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; |
| import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; |
| import { useToast } from "@/hooks/use-toast"; |
| import { LoaderCircle } from "lucide-react"; |
| |
| const sourceSchema = z.object({ |
| id: z.string().min(1, "required"), |
| branch: z.string(), |
| rootDir: z.string(), |
| }); |
| |
| const devSchema = z.object({ |
| enabled: z.boolean(), |
| mode: z.enum(["VM", "PROXY"]).optional(), |
| }); |
| |
| 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(), |
| }); |
| |
| const proxySchema = z.object({ |
| address: z.string().min(1, "required"), |
| }); |
| |
| const portExposeSchema = z |
| .object({ |
| type: z.enum(["https", "tcp"]), |
| network: z.string().min(1, "Required"), |
| subdomain: z.string().optional(), |
| }) |
| .refine( |
| (data) => { |
| if (data.type === "https" || data.type === "tcp") { |
| return !!data.subdomain && data.subdomain.length > 0; |
| } |
| return true; |
| }, |
| { |
| message: "Subdomain is required", |
| path: ["subdomain"], |
| }, |
| ); |
| |
| type PortExposeFormValues = z.infer<typeof portExposeSchema>; |
| |
| 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), |
| }); |
| |
| function ExposeForm({ |
| node, |
| port, |
| onDone, |
| disabled, |
| }: { |
| node: ServiceNode; |
| port: Port; |
| onDone: () => void; |
| disabled?: boolean; |
| }) { |
| const store = useStateStore(); |
| const nodes = useNodes<AppNode>(); |
| const env = useEnv(); |
| const form = useForm<PortExposeFormValues>({ |
| resolver: zodResolver(portExposeSchema), |
| mode: "onChange", |
| defaultValues: { |
| type: "https", |
| }, |
| }); |
| |
| const onSubmit = (data: PortExposeFormValues) => { |
| const networkNode = nodes.find((n) => n.type === "network" && n.data.domain === data.network); |
| if (!networkNode) { |
| // TODO: should show an error to the user |
| return; |
| } |
| if (data.type === "https") { |
| const newNode: Omit<GatewayHttpsNode, "position"> = { |
| id: uuidv4(), |
| type: "gateway-https", |
| data: { |
| https: { |
| serviceId: node.id, |
| portId: port.id, |
| }, |
| network: data.network, |
| subdomain: data.subdomain!, |
| label: "", |
| envVars: [], |
| ports: [], |
| }, |
| }; |
| store.addNode(newNode); |
| store.setEdges( |
| store.edges.concat( |
| { |
| id: uuidv4(), |
| source: node.id, |
| sourceHandle: "ports", |
| target: newNode.id, |
| targetHandle: "https", |
| }, |
| { |
| id: uuidv4(), |
| source: newNode.id, |
| sourceHandle: "subdomain", |
| target: networkNode.id, |
| targetHandle: "subdomain", |
| }, |
| ), |
| ); |
| } else if (data.type === "tcp") { |
| const existingGateway = nodes.find( |
| (n): n is GatewayTCPNode => |
| n.type === "gateway-tcp" && n.data.network === data.network && n.data.subdomain === data.subdomain, |
| ); |
| if (existingGateway) { |
| store.updateNodeData<"gateway-tcp">(existingGateway.id, { |
| exposed: [...existingGateway.data.exposed, { serviceId: node.id, portId: port.id }], |
| }); |
| let edges = store.edges.concat({ |
| id: uuidv4(), |
| source: node.id, |
| sourceHandle: "ports", |
| target: existingGateway.id, |
| targetHandle: "tcp", |
| }); |
| if ( |
| !edges.find( |
| (e) => |
| e.source === existingGateway.id && |
| e.target === networkNode.id && |
| e.sourceHandle === "subdomain" && |
| e.targetHandle === "subdomain", |
| ) |
| ) { |
| edges = edges.concat({ |
| id: uuidv4(), |
| source: existingGateway.id, |
| sourceHandle: "subdomain", |
| target: networkNode.id, |
| targetHandle: "subdomain", |
| }); |
| } |
| store.setEdges(edges); |
| } else { |
| const newNode: Omit<GatewayTCPNode, "position"> = { |
| id: uuidv4(), |
| type: "gateway-tcp", |
| data: { |
| exposed: [{ serviceId: node.id, portId: port.id }], |
| network: data.network, |
| subdomain: data.subdomain, |
| label: "", |
| envVars: [], |
| ports: [], |
| }, |
| }; |
| store.addNode(newNode); |
| store.setEdges( |
| store.edges.concat( |
| { |
| id: uuidv4(), |
| source: node.id, |
| sourceHandle: "ports", |
| target: newNode.id, |
| targetHandle: "tcp", |
| }, |
| { |
| id: uuidv4(), |
| source: newNode.id, |
| sourceHandle: "subdomain", |
| target: networkNode.id, |
| targetHandle: "subdomain", |
| }, |
| ), |
| ); |
| } |
| } |
| onDone(); |
| }; |
| |
| const type = form.watch("type"); |
| |
| return ( |
| <Form {...form}> |
| <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 border-t mt-2 pt-2"> |
| <FormField |
| control={form.control} |
| name="type" |
| render={({ field }) => ( |
| <FormItem> |
| <FormLabel>Gateway Type</FormLabel> |
| <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}> |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue placeholder="Select a type" /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| <SelectItem value="https">HTTPS</SelectItem> |
| <SelectItem value="tcp">TCP</SelectItem> |
| </SelectContent> |
| </Select> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| <FormField |
| control={form.control} |
| name="network" |
| render={({ field }) => ( |
| <FormItem> |
| <FormLabel>Network</FormLabel> |
| <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}> |
| <FormControl> |
| <SelectTrigger> |
| <SelectValue placeholder="Select a network" /> |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| {env.networks.map((n) => ( |
| <SelectItem key={n.domain} value={n.domain}> |
| {n.name} - {n.domain} |
| </SelectItem> |
| ))} |
| </SelectContent> |
| </Select> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| {(type === "https" || type === "tcp") && ( |
| <FormField |
| control={form.control} |
| name="subdomain" |
| render={({ field }) => ( |
| <FormItem> |
| <FormLabel>Subdomain</FormLabel> |
| <FormControl> |
| <Input placeholder="subdomain" {...field} disabled={disabled} /> |
| </FormControl> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| )} |
| <div className="flex justify-end gap-2"> |
| <Button type="button" variant="ghost" onClick={onDone} disabled={disabled}> |
| Cancel |
| </Button> |
| <Button type="submit" disabled={disabled || !form.formState.isValid}> |
| Expose |
| </Button> |
| </div> |
| </form> |
| </Form> |
| ); |
| } |
| |
| export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) { |
| const { data } = node; |
| const defaultTab = useMemo(() => { |
| if (data.dev?.enabled) { |
| return "dev"; |
| } |
| return "runtime"; |
| }, [data]); |
| return ( |
| <> |
| {showName ? <Name node={node} disabled={disabled} /> : null} |
| <Tabs defaultValue={defaultTab}> |
| <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} isOverview={isOverview} /> |
| </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, |
| isOverview, |
| }: { |
| node: ServiceNode; |
| disabled?: boolean; |
| isOverview?: boolean; |
| }): React.ReactNode { |
| const { id, data } = node; |
| const store = useStateStore(); |
| const nodes = useNodes<AppNode>(); |
| const [portIngresses, setPortIngresses] = useState<Record<string, string[]>>({}); |
| const [exposingPortId, setExposingPortId] = useState<string | null>(null); |
| |
| const httpsGateways = useMemo( |
| () => nodes.filter((n): n is GatewayHttpsNode => n.type === "gateway-https"), |
| [nodes], |
| ); |
| |
| useEffect(() => { |
| if (!data.ports) { |
| setPortIngresses({}); |
| return; |
| } |
| const newIngresses: Record<string, string[]> = {}; |
| for (const port of data.ports) { |
| newIngresses[port.id] = []; |
| } |
| for (const gateway of httpsGateways) { |
| const https = gateway.data.https; |
| if (https && https.serviceId === id && https.portId && gateway.data.network && gateway.data.subdomain) { |
| const url = `https://${gateway.data.subdomain}.${gateway.data.network}`; |
| if (newIngresses[https.portId]) { |
| newIngresses[https.portId].push(url); |
| } else { |
| newIngresses[https.portId] = [url]; |
| } |
| } |
| } |
| setPortIngresses(newIngresses); |
| console.log(newIngresses); |
| }, [id, data.ports, httpsGateways]); |
| |
| 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 key={p.id} className="contents"> |
| <div className="contents"> |
| <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 gap-1"> |
| {isOverview && ( |
| <Button |
| variant="outline" |
| onClick={() => setExposingPortId(p.id)} |
| disabled={disabled} |
| > |
| Expose |
| </Button> |
| )} |
| <Button |
| variant="destructive" |
| className="w-full" |
| onClick={() => removePort(p.id)} |
| disabled={disabled} |
| > |
| Remove |
| </Button> |
| </div> |
| </div> |
| {portIngresses[p.id]?.length > 0 && ( |
| <div key={p.id} className="col-span-full pl-6"> |
| {portIngresses[p.id].map((url) => ( |
| <Gateway key={url} g={{ type: "https", address: url, name: p.name }} /> |
| ))} |
| </div> |
| )} |
| {exposingPortId === p.id && ( |
| <Dialog open={true} onOpenChange={() => setExposingPortId(null)}> |
| <DialogContent> |
| <DialogHeader> |
| <DialogTitle> |
| Expose Port {p.name}:{p.value} |
| </DialogTitle> |
| </DialogHeader> |
| <ExposeForm |
| node={node} |
| port={p} |
| onDone={() => setExposingPortId(null)} |
| disabled={disabled} |
| /> |
| </DialogContent> |
| </Dialog> |
| )} |
| </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 mode = useMode(); |
| const env = useEnv(); |
| const store = useStateStore(); |
| const [name, setName] = useState(""); |
| const [value, setValue] = useState(""); |
| |
| const addEnvVar = useCallback(() => { |
| if (!name.trim() || !value.trim()) return; |
| store.updateNodeData<"app">(id, { |
| envVars: (data.envVars || []).concat({ |
| id: uuidv4(), |
| source: null, |
| name: name.toUpperCase(), |
| value: value, |
| }), |
| }); |
| setName(""); |
| setValue(""); |
| }, [id, data, store, name, value]); |
| |
| const removeEnvVar = useCallback( |
| (varId: string) => { |
| store.updateNodeData<"app">(id, { |
| envVars: (data.envVars || []).filter((v) => v.id !== varId), |
| }); |
| }, |
| [id, data, store], |
| ); |
| |
| const editValueEnvVar = useCallback( |
| (varId: string) => { |
| if (disabled) return; |
| store.updateNodeData<"app">(id, { |
| envVars: (data.envVars || []).map((v) => (v.id === varId ? { ...v, isEditting: true } : v)), |
| }); |
| }, |
| [id, data, store, disabled], |
| ); |
| |
| const saveValueEnvVar = useCallback( |
| (varId: string, newName: string, newValue: string) => { |
| store.updateNodeData<"app">(id, { |
| envVars: (data.envVars || []).map((v) => { |
| if (v.id === varId) { |
| return { ...v, name: newName.toUpperCase(), value: newValue, isEditting: false }; |
| } |
| return v; |
| }), |
| }); |
| }, |
| [id, data, store], |
| ); |
| |
| 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) { |
| if ("name" in o && value.toUpperCase() === o.name.toUpperCase()) { |
| return { |
| ...o, |
| isEditting: false, |
| alias: undefined, |
| }; |
| } else { |
| 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") { |
| saveAlias(e, event.currentTarget.value, store); |
| } else if (event.key === "Escape") { |
| store.updateNodeData(id, { |
| ...data, |
| envVars: data.envVars!.map((o) => (o.id === e.id ? { ...o, isEditting: false } : o)), |
| }); |
| } |
| }; |
| }, |
| [store, saveAlias, id, data], |
| ); |
| |
| const saveAliasOnBlur = useCallback( |
| (e: BoundEnvVar) => { |
| return (event: FocusEvent<HTMLInputElement>) => { |
| saveAlias(e, event.currentTarget.value, store); |
| }; |
| }, |
| [store, saveAlias], |
| ); |
| const envVars = useMemo(() => { |
| if (mode !== "deploy") { |
| return []; |
| } |
| return env.access |
| .filter((a) => a.name === data.label) |
| .filter((a) => a.type === "env_var") |
| .map((a) => ({ |
| name: a.var.split("=", 2)[0], |
| value: a.var.split("=", 2)[1], |
| })); |
| }, [mode, env.access, data.label]); |
| |
| const hiddenEnvVars = useMemo(() => { |
| return envVars |
| .map((v) => { |
| const { name, value } = v; |
| const match = value.match(/^(postgresql|mongodb):\/\/([^:]+):([^@]+)@([^:/]+)(?::(\d+))?\/(.+)$/); |
| if (match) { |
| const [_, protocol, username, _password, host, port, database] = match; |
| return { |
| name, |
| value, |
| hidden: `${protocol}://${username}:*****@${host}${port ? `:${port}` : ""}/${database}`, |
| }; |
| } |
| return { |
| name, |
| value, |
| hidden: value, |
| }; |
| }) |
| .map((v) => { |
| return { |
| ...v, |
| hidden: v.hidden.length > 50 ? v.hidden.slice(0, 50) + "..." : v.hidden, |
| }; |
| }); |
| }, [envVars]); |
| |
| const [copied, setCopied] = useState(false); |
| const [blip, setBlip] = useState(false); |
| |
| const handleCopy = () => { |
| navigator.clipboard.writeText(envVars.map((v) => `${v.name}=${v.value}`).join("\n")); |
| setCopied(true); |
| setBlip(true); |
| setTimeout(() => setCopied(false), 1000); |
| setTimeout(() => setBlip(false), 300); |
| }; |
| |
| if (hiddenEnvVars.length > 0) { |
| return ( |
| <div className="flex flex-col gap-1"> |
| <div className="grid grid-cols-[auto_minmax(0,1fr)] gap-1 flex-shrink"> |
| {hiddenEnvVars.map((v) => ( |
| <div key={v.name} className="contents"> |
| <div className="uppercase">{v.name}</div> |
| <div className="min-w-0 truncate">{v.hidden}</div> |
| </div> |
| ))} |
| </div> |
| <div className="flex justify-end"> |
| <Button onClick={handleCopy} className={blip ? "bg-green-100 transition-colors" : ""}> |
| {copied ? ( |
| <> |
| <Check className="w-4 h-4 text-green-600" /> Copy |
| </> |
| ) : ( |
| <> |
| <Copy className="w-4 h-4" /> Copy |
| </> |
| )} |
| </Button> |
| </div> |
| </div> |
| ); |
| } |
| |
| return ( |
| <div className="flex flex-col gap-1"> |
| <div className="grid grid-cols-[auto_1fr_1fr_auto] gap-1"> |
| {data?.envVars?.map((v) => { |
| if ("value" in v) { |
| if (v.isEditting) { |
| return ( |
| <div key={v.id} className="contents"> |
| <Input |
| className="uppercase col-start-2" |
| defaultValue={v.name} |
| onKeyUp={(e) => { |
| if (e.key === "Enter") { |
| const nameInput = e.currentTarget; |
| const valueInput = nameInput.parentElement?.querySelector( |
| 'input[placeholder="Value"]', |
| ) as HTMLInputElement; |
| if (valueInput) { |
| saveValueEnvVar(v.id, nameInput.value, valueInput.value); |
| } |
| } else if (e.key === "Escape") { |
| store.updateNodeData(id, { |
| ...data, |
| envVars: data.envVars!.map((o) => |
| o.id === v.id ? { ...o, isEditting: false } : o, |
| ), |
| }); |
| } |
| }} |
| autoFocus |
| disabled={disabled} |
| /> |
| <Input |
| placeholder="Value" |
| defaultValue={v.value} |
| onKeyUp={(e) => { |
| if (e.key === "Enter") { |
| const valueInput = e.currentTarget; |
| const nameInput = valueInput.parentElement?.querySelector( |
| 'input:not([placeholder="Value"])', |
| ) as HTMLInputElement; |
| if (nameInput) { |
| saveValueEnvVar(v.id, nameInput.value, valueInput.value); |
| } |
| } else if (e.key === "Escape") { |
| store.updateNodeData(id, { |
| ...data, |
| envVars: data.envVars!.map((o) => |
| o.id === v.id ? { ...o, isEditting: false } : o, |
| ), |
| }); |
| } |
| }} |
| disabled={disabled} |
| /> |
| <Button |
| variant="destructive" |
| size="sm" |
| onClick={() => removeEnvVar(v.id)} |
| disabled={disabled} |
| > |
| Remove |
| </Button> |
| </div> |
| ); |
| } |
| return ( |
| <div |
| key={v.id} |
| className={`contents ${disabled ? "" : "cursor-text"}`} |
| onClick={() => editValueEnvVar(v.id)} |
| > |
| <div>{!disabled && <Pencil className="w-4 h-4" />}</div> |
| <div className={`${disabled ? "col-span-2" : ""} col-start-2`}>{v.name}</div> |
| <div>{v.value}</div> |
| <Button |
| variant="destructive" |
| size="sm" |
| onClick={(e) => { |
| e.stopPropagation(); |
| removeEnvVar(v.id); |
| }} |
| disabled={disabled} |
| > |
| Remove |
| </Button> |
| </div> |
| ); |
| } |
| if ("name" in v) { |
| const value = "alias" in v ? v.alias : v.name; |
| if (v.isEditting) { |
| return ( |
| <Input |
| type="text" |
| className="uppercase col-start-2 col-span-3" |
| defaultValue={value} |
| onKeyUp={saveAliasOnEnter(v)} |
| onBlur={saveAliasOnBlur(v)} |
| autoFocus={true} |
| disabled={disabled} |
| /> |
| ); |
| } |
| return ( |
| <div |
| key={v.id} |
| onClick={editAlias(v)} |
| className={`contents ${disabled ? "" : "cursor-text"}`} |
| > |
| {!disabled && <Pencil className="w-4 h-4" />} |
| <div className="col-start-2 col-span-3"> |
| <TooltipProvider> |
| <Tooltip> |
| <TooltipTrigger className="uppercase">{value}</TooltipTrigger> |
| <TooltipContent>{v.name}</TooltipContent> |
| </Tooltip> |
| </TooltipProvider> |
| </div> |
| </div> |
| ); |
| } |
| return null; |
| })} |
| {!disabled && ( |
| <div className="contents"> |
| <Input |
| placeholder="Name" |
| className="uppercase col-start-2" |
| value={name} |
| onChange={(e) => setName(e.target.value)} |
| disabled={disabled} |
| /> |
| <Input |
| placeholder="Value" |
| value={value} |
| onChange={(e) => setValue(e.target.value)} |
| disabled={disabled} |
| /> |
| <Button onClick={addEnvVar} disabled={disabled || !name.trim() || !value.trim()}> |
| Add |
| </Button> |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
| |
| function usePrevious<T>(value: T) { |
| const ref = useRef<T>(); |
| useEffect(() => { |
| ref.current = value; |
| }, [value]); |
| return ref.current; |
| } |
| |
| function DevVM({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| const { id, data } = node; |
| const { dev } = data; |
| const prevDev = usePrevious(dev); |
| const env = useEnv(); |
| const store = useStateStore(); |
| useEffect(() => { |
| console.log("DDDEV", prevDev, dev); |
| if (!dev && !prevDev) { |
| return; |
| } |
| if ( |
| dev && |
| prevDev && |
| dev.enabled === prevDev.enabled && |
| "mode" in dev && |
| "mode" in prevDev && |
| dev.mode === prevDev.mode |
| ) { |
| return; |
| } |
| if (!dev?.enabled || dev.mode !== "VM") { |
| if (prevDev?.enabled && prevDev.mode === "VM") { |
| store.setNodes( |
| store.nodes.filter((n) => n.id !== prevDev.codeServerNodeId && n.id !== prevDev.sshNodeId), |
| ); |
| store.setEdges( |
| store.edges.filter((e) => e.target !== prevDev.codeServerNodeId && e.target !== prevDev.sshNodeId), |
| ); |
| if (dev?.enabled) { |
| store.updateNodeData<"app">(id, { |
| dev: { |
| enabled: dev.enabled, |
| mode: dev.mode, |
| }, |
| ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"), |
| }); |
| } else { |
| store.updateNodeData<"app">(id, { |
| dev: { |
| enabled: false, |
| }, |
| ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"), |
| }); |
| } |
| } |
| } else { |
| if (!prevDev?.enabled || prevDev.mode !== "VM") { |
| const csGateway: Omit<GatewayHttpsNode, "position"> = { |
| id: uuidv4(), |
| type: "gateway-https", |
| data: { |
| readonly: true, |
| https: { |
| serviceId: id, |
| portId: `${id}-code-server`, |
| }, |
| network: dev?.expose?.network, |
| subdomain: 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: dev?.expose?.network, |
| subdomain: dev?.expose?.subdomain, |
| label: "", |
| envVars: [], |
| ports: [], |
| }, |
| }; |
| store.addNode(csGateway); |
| store.addNode(sshGateway); |
| store.updateNodeData<"app">(id, { |
| dev: { |
| enabled: true, |
| mode: "VM", |
| expose: 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 (dev?.expose?.network !== undefined) { |
| edges = edges.concat([ |
| { |
| id: uuidv4(), |
| source: csGateway.id, |
| sourceHandle: "subdomain", |
| target: dev.expose.network, |
| targetHandle: "subdomain", |
| }, |
| { |
| id: uuidv4(), |
| source: sshGateway.id, |
| sourceHandle: "subdomain", |
| target: dev.expose.network, |
| targetHandle: "subdomain", |
| }, |
| ]); |
| } |
| store.setEdges(edges); |
| } |
| } |
| }, [id, data, dev, prevDev, store]); |
| const exposeForm = useForm<z.infer<typeof exposeSchema>>({ |
| resolver: zodResolver(exposeSchema), |
| mode: "onChange", |
| defaultValues: { |
| network: dev && "expose" in dev ? dev.expose?.network : undefined, |
| subdomain: dev && "expose" in dev ? dev.expose?.subdomain : undefined, |
| }, |
| }); |
| 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 || dev.mode !== "VM") { |
| 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, dev, prevDev, exposeForm, store]); |
| if (!dev?.enabled || dev.mode !== "VM") { |
| return null; |
| } |
| return ( |
| <div> |
| {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> |
| )} |
| </div> |
| ); |
| } |
| |
| function DevProxy({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| const { id, data } = node; |
| const store = useStateStore(); |
| const { toast } = useToast(); |
| const [machines, setMachines] = useState<Machines>([]); |
| const [loading, setLoading] = useState(false); |
| const [error, setError] = useState<string | null>(null); |
| |
| const fetchMachines = useCallback(async () => { |
| setLoading(true); |
| setError(null); |
| try { |
| const response = await fetch("/api/machines", { |
| method: "GET", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| }); |
| |
| if (!response.ok) { |
| throw new Error(`Failed to fetch machines: ${response.statusText}`); |
| } |
| |
| const machinesData = MachinesSchema.safeParse(await response.json()); |
| if (machinesData.success) { |
| setMachines( |
| machinesData.data |
| .filter((m) => !m.name.startsWith("proxy-dodo-app-")) |
| .filter( |
| (m) => m.last_seen && m.last_seen.seconds * 1000 >= Date.now() - 7 * 24 * 60 * 60 * 1000, |
| ), |
| ); |
| } else { |
| throw new Error("Invalid machines data"); |
| } |
| } catch (err) { |
| const errorMessage = err instanceof Error ? err.message : "Failed to fetch machines"; |
| setError(errorMessage); |
| toast({ |
| variant: "destructive", |
| title: "Error", |
| description: errorMessage, |
| }); |
| } finally { |
| setLoading(false); |
| } |
| }, [toast]); |
| |
| useEffect(() => { |
| if (data.dev?.enabled && "mode" in data.dev && data.dev.mode === "PROXY") { |
| fetchMachines(); |
| } |
| }, [data.dev, fetchMachines]); |
| |
| const proxyForm = useForm<z.infer<typeof proxySchema>>({ |
| resolver: zodResolver(proxySchema), |
| mode: "onChange", |
| defaultValues: { |
| address: data.dev && "address" in data.dev ? data.dev.address : undefined, |
| }, |
| }); |
| |
| useEffect(() => { |
| const sub = proxyForm.watch((value, { name }) => { |
| if (name === "address" && value.address) { |
| store.updateNodeData<"app">(id, { |
| dev: { |
| enabled: true, |
| mode: "PROXY", |
| address: value.address, |
| }, |
| }); |
| } |
| }); |
| return () => sub.unsubscribe(); |
| }, [id, proxyForm, store]); |
| |
| if (!data.dev?.enabled || data.dev.mode !== "PROXY") { |
| return null; |
| } |
| return ( |
| <div className="space-y-2"> |
| <Form {...proxyForm}> |
| <form className="space-y-2"> |
| <FormField |
| control={proxyForm.control} |
| name="address" |
| render={({ field }) => ( |
| <FormItem> |
| <Select |
| onValueChange={field.onChange} |
| value={field.value || ""} |
| disabled={disabled || loading} |
| > |
| <FormControl> |
| <SelectTrigger> |
| {loading ? ( |
| <div className="flex items-center gap-2"> |
| <LoaderCircle className="h-4 w-4 animate-spin" /> |
| <span>Loading machines...</span> |
| </div> |
| ) : ( |
| <SelectValue placeholder="Select a machine" /> |
| )} |
| </SelectTrigger> |
| </FormControl> |
| <SelectContent> |
| {loading ? ( |
| <div className="flex items-center justify-center p-4"> |
| <LoaderCircle className="h-4 w-4 animate-spin" /> |
| <span className="ml-2">Loading...</span> |
| </div> |
| ) : error ? ( |
| <div className="flex flex-col items-center justify-center p-4 text-destructive"> |
| <span className="text-sm">Failed to load machines</span> |
| <Button |
| variant="ghost" |
| size="sm" |
| className="mt-2" |
| onClick={fetchMachines} |
| > |
| Retry |
| </Button> |
| </div> |
| ) : machines.length === 0 ? ( |
| <div className="flex items-center justify-center p-4 text-muted-foreground"> |
| <span className="text-sm">No machines available</span> |
| </div> |
| ) : ( |
| machines.map((machine: Machine) => ( |
| <SelectItem key={machine.name} value={machine.name}> |
| {machine.name} |
| </SelectItem> |
| )) |
| )} |
| </SelectContent> |
| </Select> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| </form> |
| </Form> |
| </div> |
| ); |
| } |
| |
| function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| const { id, data } = node; |
| const store = useStateStore(); |
| const devForm = useForm<z.infer<typeof devSchema>>({ |
| resolver: zodResolver(devSchema), |
| mode: "onChange", |
| defaultValues: { |
| enabled: data.dev ? data.dev.enabled : false, |
| mode: data.dev?.enabled ? data.dev.mode : undefined, |
| }, |
| }); |
| useEffect(() => { |
| const sub = devForm.watch((value, { name }) => { |
| console.log("DDDEVV", name, value, data.dev); |
| if (name === "enabled") { |
| if (value.enabled) { |
| if (data.dev?.enabled && data.dev.mode === "VM") { |
| return; |
| } |
| store.updateNodeData<"app">(id, { |
| dev: { |
| enabled: true, |
| mode: "VM", |
| }, |
| }); |
| devForm.setValue("mode", "VM"); |
| } else { |
| store.updateNodeData<"app">(id, { |
| dev: { |
| enabled: false, |
| }, |
| }); |
| } |
| } else if (name === "mode") { |
| if (data.dev?.enabled && data.dev.mode === value.mode) { |
| return; |
| } |
| store.updateNodeData<"app">(id, { |
| dev: { |
| enabled: true, |
| mode: value.mode, |
| }, |
| }); |
| } |
| }); |
| return () => sub.unsubscribe(); |
| }, [id, data, devForm, 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">Development Mode</Label> |
| </div> |
| <FormMessage /> |
| </FormItem> |
| )} |
| /> |
| {data.dev?.enabled && ( |
| <FormField |
| control={devForm.control} |
| name="mode" |
| render={({ field }) => ( |
| <FormItem> |
| <div className="flex flex-row gap-1 items-center"> |
| <RadioGroup |
| onValueChange={field.onChange} |
| value={field.value} |
| disabled={disabled} |
| > |
| <div className="flex items-center space-x-2"> |
| <RadioGroupItem value="VM" id="vm" /> |
| <Label htmlFor="vm">Create a VM</Label> |
| </div> |
| <div className="flex items-center space-x-2"> |
| <RadioGroupItem value="PROXY" id="proxy" /> |
| <Label htmlFor="proxy">Proxy to existing machine</Label> |
| </div> |
| </RadioGroup> |
| </div> |
| </FormItem> |
| )} |
| /> |
| )} |
| </form> |
| </Form> |
| <DevVM node={node} disabled={disabled} /> |
| <DevProxy node={node} disabled={disabled} /> |
| </> |
| ); |
| } |
| |
| 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> |
| ); |
| } |