| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 1 | import { v4 as uuidv4 } from "uuid"; |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 2 | import { NodeRect } from "./node-rect"; |
| gio | 8323a05 | 2025-08-04 06:12:26 +0000 | [diff] [blame] | 3 | import { |
| 4 | useStateStore, |
| 5 | nodeLabel, |
| 6 | AppState, |
| 7 | nodeIsConnectable, |
| 8 | useEnv, |
| 9 | useGithubRepositories, |
| 10 | useMode, |
| 11 | } from "@/lib/state"; |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 12 | import { |
| 13 | ServiceNode, |
| 14 | ServiceTypes, |
| 15 | GatewayHttpsNode, |
| 16 | GatewayTCPNode, |
| 17 | BoundEnvVar, |
| 18 | AppNode, |
| 19 | GithubNode, |
| 20 | Machines, |
| 21 | Machine, |
| 22 | MachinesSchema, |
| 23 | } from "config"; |
| 24 | import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState, useRef } from "react"; |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 25 | import { z } from "zod"; |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 26 | import { useForm, EventType, DeepPartial } from "react-hook-form"; |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 27 | import { zodResolver } from "@hookform/resolvers/zod"; |
| gio | 69ff759 | 2025-07-03 06:27:21 +0000 | [diff] [blame] | 28 | import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from "./ui/form"; |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 29 | import { Button } from "./ui/button"; |
| gio | 33990c6 | 2025-05-06 07:51:24 +0000 | [diff] [blame] | 30 | import { Handle, Position, useNodes } from "@xyflow/react"; |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 31 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 32 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; |
| gio | 9116561 | 2025-05-03 17:07:38 +0000 | [diff] [blame] | 33 | import { Textarea } from "./ui/textarea"; |
| gio | fcefd7c | 2025-05-13 08:01:07 +0000 | [diff] [blame] | 34 | import { Input } from "./ui/input"; |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 35 | import { Switch } from "./ui/switch"; |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 36 | import { Label } from "./ui/label"; |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 37 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; |
| gio | 8323a05 | 2025-08-04 06:12:26 +0000 | [diff] [blame] | 38 | import { Check, Code, Container, Copy, Network, Pencil, Variable } from "lucide-react"; |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 39 | import { Badge } from "./ui/badge"; |
| 40 | import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ui/accordion"; |
| gio | 3fb133d | 2025-06-13 07:20:24 +0000 | [diff] [blame] | 41 | import { Name } from "./node-name"; |
| 42 | import { NodeDetailsProps } from "@/lib/types"; |
| gio | 9f3d4f5 | 2025-07-04 08:42:34 +0000 | [diff] [blame] | 43 | import { Gateway } from "@/Gateways"; |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 44 | import { Port } from "config"; |
| 45 | import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 46 | import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; |
| 47 | import { useToast } from "@/hooks/use-toast"; |
| 48 | import { LoaderCircle } from "lucide-react"; |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 49 | |
| 50 | const sourceSchema = z.object({ |
| 51 | id: z.string().min(1, "required"), |
| 52 | branch: z.string(), |
| 53 | rootDir: z.string(), |
| 54 | }); |
| 55 | |
| 56 | const devSchema = z.object({ |
| 57 | enabled: z.boolean(), |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 58 | mode: z.enum(["VM", "PROXY"]).optional(), |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 59 | }); |
| 60 | |
| 61 | const exposeSchema = z.object({ |
| 62 | network: z.string().min(1, "reqired"), |
| 63 | subdomain: z.string().min(1, "required"), |
| 64 | }); |
| 65 | |
| 66 | const agentSchema = z.object({ |
| 67 | model: z.enum(["gemini", "claude"]), |
| 68 | apiKey: z.string().optional(), |
| 69 | }); |
| 70 | |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 71 | const proxySchema = z.object({ |
| 72 | address: z.string().min(1, "required"), |
| 73 | }); |
| 74 | |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 75 | const portExposeSchema = z |
| 76 | .object({ |
| 77 | type: z.enum(["https", "tcp"]), |
| 78 | network: z.string().min(1, "Required"), |
| 79 | subdomain: z.string().optional(), |
| 80 | }) |
| 81 | .refine( |
| 82 | (data) => { |
| 83 | if (data.type === "https" || data.type === "tcp") { |
| 84 | return !!data.subdomain && data.subdomain.length > 0; |
| 85 | } |
| 86 | return true; |
| 87 | }, |
| 88 | { |
| 89 | message: "Subdomain is required", |
| 90 | path: ["subdomain"], |
| 91 | }, |
| 92 | ); |
| 93 | |
| 94 | type PortExposeFormValues = z.infer<typeof portExposeSchema>; |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 95 | |
| 96 | export function NodeApp(node: ServiceNode) { |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 97 | const { id, selected } = node; |
| 98 | const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]); |
| 99 | const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]); |
| 100 | return ( |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 101 | <NodeRect id={id} selected={selected} node={node} state={node.data.state}> |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 102 | <div style={{ padding: "10px 20px" }}> |
| 103 | {nodeLabel(node)} |
| 104 | <Handle |
| 105 | id="repository" |
| 106 | type={"target"} |
| 107 | position={Position.Left} |
| 108 | isConnectableStart={isConnectableRepository} |
| 109 | isConnectableEnd={isConnectableRepository} |
| 110 | isConnectable={isConnectableRepository} |
| 111 | /> |
| 112 | <Handle |
| 113 | id="ports" |
| 114 | type={"source"} |
| 115 | position={Position.Top} |
| 116 | isConnectableStart={isConnectablePorts} |
| 117 | isConnectableEnd={isConnectablePorts} |
| 118 | isConnectable={isConnectablePorts} |
| 119 | /> |
| 120 | <Handle |
| 121 | id="env_var" |
| 122 | type={"target"} |
| 123 | position={Position.Bottom} |
| 124 | isConnectableStart={true} |
| 125 | isConnectableEnd={true} |
| 126 | isConnectable={true} |
| 127 | /> |
| 128 | </div> |
| 129 | </NodeRect> |
| 130 | ); |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 131 | } |
| 132 | |
| 133 | const schema = z.object({ |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 134 | name: z.string().min(1, "requried"), |
| 135 | type: z.enum(ServiceTypes), |
| gio | 5f2f100 | 2025-03-20 18:38:48 +0400 | [diff] [blame] | 136 | }); |
| 137 | |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 138 | function ExposeForm({ |
| 139 | node, |
| 140 | port, |
| 141 | onDone, |
| 142 | disabled, |
| 143 | }: { |
| 144 | node: ServiceNode; |
| 145 | port: Port; |
| 146 | onDone: () => void; |
| 147 | disabled?: boolean; |
| 148 | }) { |
| 149 | const store = useStateStore(); |
| 150 | const nodes = useNodes<AppNode>(); |
| 151 | const env = useEnv(); |
| 152 | const form = useForm<PortExposeFormValues>({ |
| 153 | resolver: zodResolver(portExposeSchema), |
| 154 | mode: "onChange", |
| 155 | defaultValues: { |
| 156 | type: "https", |
| 157 | }, |
| 158 | }); |
| gio | 33990c6 | 2025-05-06 07:51:24 +0000 | [diff] [blame] | 159 | |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 160 | const onSubmit = (data: PortExposeFormValues) => { |
| 161 | const networkNode = nodes.find((n) => n.type === "network" && n.data.domain === data.network); |
| 162 | if (!networkNode) { |
| 163 | // TODO: should show an error to the user |
| 164 | return; |
| 165 | } |
| 166 | if (data.type === "https") { |
| 167 | const newNode: Omit<GatewayHttpsNode, "position"> = { |
| 168 | id: uuidv4(), |
| 169 | type: "gateway-https", |
| 170 | data: { |
| 171 | https: { |
| 172 | serviceId: node.id, |
| 173 | portId: port.id, |
| 174 | }, |
| 175 | network: data.network, |
| 176 | subdomain: data.subdomain!, |
| 177 | label: "", |
| 178 | envVars: [], |
| 179 | ports: [], |
| 180 | }, |
| 181 | }; |
| 182 | store.addNode(newNode); |
| 183 | store.setEdges( |
| 184 | store.edges.concat( |
| 185 | { |
| 186 | id: uuidv4(), |
| 187 | source: node.id, |
| 188 | sourceHandle: "ports", |
| 189 | target: newNode.id, |
| 190 | targetHandle: "https", |
| 191 | }, |
| 192 | { |
| 193 | id: uuidv4(), |
| 194 | source: newNode.id, |
| 195 | sourceHandle: "subdomain", |
| 196 | target: networkNode.id, |
| 197 | targetHandle: "subdomain", |
| 198 | }, |
| 199 | ), |
| 200 | ); |
| 201 | } else if (data.type === "tcp") { |
| 202 | const existingGateway = nodes.find( |
| 203 | (n): n is GatewayTCPNode => |
| 204 | n.type === "gateway-tcp" && n.data.network === data.network && n.data.subdomain === data.subdomain, |
| 205 | ); |
| 206 | if (existingGateway) { |
| 207 | store.updateNodeData<"gateway-tcp">(existingGateway.id, { |
| 208 | exposed: [...existingGateway.data.exposed, { serviceId: node.id, portId: port.id }], |
| 209 | }); |
| 210 | let edges = store.edges.concat({ |
| 211 | id: uuidv4(), |
| 212 | source: node.id, |
| 213 | sourceHandle: "ports", |
| 214 | target: existingGateway.id, |
| 215 | targetHandle: "tcp", |
| 216 | }); |
| 217 | if ( |
| 218 | !edges.find( |
| 219 | (e) => |
| 220 | e.source === existingGateway.id && |
| 221 | e.target === networkNode.id && |
| 222 | e.sourceHandle === "subdomain" && |
| 223 | e.targetHandle === "subdomain", |
| 224 | ) |
| 225 | ) { |
| 226 | edges = edges.concat({ |
| 227 | id: uuidv4(), |
| 228 | source: existingGateway.id, |
| 229 | sourceHandle: "subdomain", |
| 230 | target: networkNode.id, |
| 231 | targetHandle: "subdomain", |
| 232 | }); |
| 233 | } |
| 234 | store.setEdges(edges); |
| 235 | } else { |
| 236 | const newNode: Omit<GatewayTCPNode, "position"> = { |
| 237 | id: uuidv4(), |
| 238 | type: "gateway-tcp", |
| 239 | data: { |
| 240 | exposed: [{ serviceId: node.id, portId: port.id }], |
| 241 | network: data.network, |
| 242 | subdomain: data.subdomain, |
| 243 | label: "", |
| 244 | envVars: [], |
| 245 | ports: [], |
| 246 | }, |
| 247 | }; |
| 248 | store.addNode(newNode); |
| 249 | store.setEdges( |
| 250 | store.edges.concat( |
| 251 | { |
| 252 | id: uuidv4(), |
| 253 | source: node.id, |
| 254 | sourceHandle: "ports", |
| 255 | target: newNode.id, |
| 256 | targetHandle: "tcp", |
| 257 | }, |
| 258 | { |
| 259 | id: uuidv4(), |
| 260 | source: newNode.id, |
| 261 | sourceHandle: "subdomain", |
| 262 | target: networkNode.id, |
| 263 | targetHandle: "subdomain", |
| 264 | }, |
| 265 | ), |
| 266 | ); |
| 267 | } |
| 268 | } |
| 269 | onDone(); |
| 270 | }; |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 271 | |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 272 | const type = form.watch("type"); |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 273 | |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 274 | return ( |
| 275 | <Form {...form}> |
| 276 | <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 border-t mt-2 pt-2"> |
| 277 | <FormField |
| 278 | control={form.control} |
| 279 | name="type" |
| 280 | render={({ field }) => ( |
| 281 | <FormItem> |
| 282 | <FormLabel>Gateway Type</FormLabel> |
| 283 | <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}> |
| 284 | <FormControl> |
| 285 | <SelectTrigger> |
| 286 | <SelectValue placeholder="Select a type" /> |
| 287 | </SelectTrigger> |
| 288 | </FormControl> |
| 289 | <SelectContent> |
| 290 | <SelectItem value="https">HTTPS</SelectItem> |
| 291 | <SelectItem value="tcp">TCP</SelectItem> |
| 292 | </SelectContent> |
| 293 | </Select> |
| 294 | <FormMessage /> |
| 295 | </FormItem> |
| 296 | )} |
| 297 | /> |
| 298 | <FormField |
| 299 | control={form.control} |
| 300 | name="network" |
| 301 | render={({ field }) => ( |
| 302 | <FormItem> |
| 303 | <FormLabel>Network</FormLabel> |
| 304 | <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}> |
| 305 | <FormControl> |
| 306 | <SelectTrigger> |
| 307 | <SelectValue placeholder="Select a network" /> |
| 308 | </SelectTrigger> |
| 309 | </FormControl> |
| 310 | <SelectContent> |
| 311 | {env.networks.map((n) => ( |
| 312 | <SelectItem key={n.domain} value={n.domain}> |
| 313 | {n.name} - {n.domain} |
| 314 | </SelectItem> |
| 315 | ))} |
| 316 | </SelectContent> |
| 317 | </Select> |
| 318 | <FormMessage /> |
| 319 | </FormItem> |
| 320 | )} |
| 321 | /> |
| 322 | {(type === "https" || type === "tcp") && ( |
| 323 | <FormField |
| 324 | control={form.control} |
| 325 | name="subdomain" |
| 326 | render={({ field }) => ( |
| 327 | <FormItem> |
| 328 | <FormLabel>Subdomain</FormLabel> |
| 329 | <FormControl> |
| 330 | <Input placeholder="subdomain" {...field} disabled={disabled} /> |
| 331 | </FormControl> |
| 332 | <FormMessage /> |
| 333 | </FormItem> |
| 334 | )} |
| 335 | /> |
| 336 | )} |
| 337 | <div className="flex justify-end gap-2"> |
| 338 | <Button type="button" variant="ghost" onClick={onDone} disabled={disabled}> |
| 339 | Cancel |
| 340 | </Button> |
| 341 | <Button type="submit" disabled={disabled || !form.formState.isValid}> |
| 342 | Expose |
| 343 | </Button> |
| 344 | </div> |
| 345 | </form> |
| 346 | </Form> |
| 347 | ); |
| 348 | } |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 349 | |
| gio | e7734b2 | 2025-06-13 10:12:04 +0000 | [diff] [blame] | 350 | export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) { |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 351 | const { data } = node; |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 352 | const defaultTab = useMemo(() => { |
| 353 | if (data.dev?.enabled) { |
| 354 | return "dev"; |
| 355 | } |
| 356 | return "runtime"; |
| 357 | }, [data]); |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 358 | return ( |
| 359 | <> |
| gio | 3fb133d | 2025-06-13 07:20:24 +0000 | [diff] [blame] | 360 | {showName ? <Name node={node} disabled={disabled} /> : null} |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 361 | <Tabs defaultValue={defaultTab}> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 362 | <TabsList className="w-full flex flex-row justify-between"> |
| 363 | <TabsTrigger value="runtime"> |
| gio | e7734b2 | 2025-06-13 10:12:04 +0000 | [diff] [blame] | 364 | {isOverview ? ( |
| 365 | <div className="flex flex-row gap-1 items-center"> |
| 366 | <Container /> Runtime |
| 367 | </div> |
| 368 | ) : ( |
| 369 | <TooltipProvider> |
| 370 | <Tooltip> |
| 371 | <TooltipTrigger> |
| 372 | <Container /> |
| 373 | </TooltipTrigger> |
| 374 | <TooltipContent>Runtime</TooltipContent> |
| 375 | </Tooltip> |
| 376 | </TooltipProvider> |
| 377 | )} |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 378 | </TabsTrigger> |
| 379 | <TabsTrigger value="ports"> |
| gio | e7734b2 | 2025-06-13 10:12:04 +0000 | [diff] [blame] | 380 | {isOverview ? ( |
| 381 | <div className="flex flex-row gap-1 items-center"> |
| 382 | <Network /> Ports |
| 383 | <Badge className="rounded-full">{data.ports?.length ?? 0}</Badge> |
| 384 | </div> |
| 385 | ) : ( |
| 386 | <TooltipProvider> |
| 387 | <Tooltip> |
| 388 | <TooltipTrigger className="flex flex-row gap-1 items-center"> |
| 389 | <Network /> |
| 390 | </TooltipTrigger> |
| 391 | <TooltipContent> |
| 392 | Ports{" "} |
| 393 | <Badge variant="secondary" className="rounded-full"> |
| 394 | {data.ports?.length ?? 0} |
| 395 | </Badge> |
| 396 | </TooltipContent> |
| 397 | </Tooltip> |
| 398 | </TooltipProvider> |
| 399 | )} |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 400 | </TabsTrigger> |
| 401 | <TabsTrigger value="vars"> |
| gio | e7734b2 | 2025-06-13 10:12:04 +0000 | [diff] [blame] | 402 | {isOverview ? ( |
| 403 | <div className="flex flex-row gap-1 items-center"> |
| 404 | <Variable /> Variables |
| 405 | <Badge className="rounded-full">{data.envVars?.length ?? 0}</Badge> |
| 406 | </div> |
| 407 | ) : ( |
| 408 | <TooltipProvider> |
| 409 | <Tooltip> |
| 410 | <TooltipTrigger className="flex flex-row gap-1 items-center"> |
| 411 | <Variable /> |
| 412 | </TooltipTrigger> |
| 413 | <TooltipContent> |
| 414 | Variables{" "} |
| 415 | <Badge variant="secondary" className="rounded-full"> |
| 416 | {data.envVars?.length ?? 0} |
| 417 | </Badge> |
| 418 | </TooltipContent> |
| 419 | </Tooltip> |
| 420 | </TooltipProvider> |
| 421 | )} |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 422 | </TabsTrigger> |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 423 | {node.data.type !== "sketch:latest" && ( |
| 424 | <TabsTrigger value="dev"> |
| 425 | {isOverview ? ( |
| 426 | <div className="flex flex-row gap-1 items-center"> |
| 427 | <Code /> Dev |
| 428 | </div> |
| 429 | ) : ( |
| 430 | <TooltipProvider> |
| 431 | <Tooltip> |
| 432 | <TooltipTrigger className="flex flex-row gap-1 items-center"> |
| 433 | <Code /> |
| 434 | </TooltipTrigger> |
| 435 | <TooltipContent>Dev</TooltipContent> |
| 436 | </Tooltip> |
| 437 | </TooltipProvider> |
| 438 | )} |
| 439 | </TabsTrigger> |
| 440 | )} |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 441 | </TabsList> |
| 442 | <TabsContent value="runtime"> |
| 443 | <Runtime node={node} disabled={disabled} /> |
| 444 | </TabsContent> |
| 445 | <TabsContent value="ports"> |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 446 | <Ports node={node} disabled={disabled} isOverview={isOverview} /> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 447 | </TabsContent> |
| 448 | <TabsContent value="vars"> |
| 449 | <EnvVars node={node} disabled={disabled} /> |
| 450 | </TabsContent> |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 451 | {node.data.type !== "sketch:latest" && ( |
| 452 | <TabsContent value="dev"> |
| 453 | <Dev node={node} disabled={disabled} /> |
| 454 | </TabsContent> |
| 455 | )} |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 456 | </Tabs> |
| 457 | </> |
| 458 | ); |
| 459 | } |
| 460 | |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 461 | function Runtime({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| 462 | const { id, data } = node; |
| 463 | const store = useStateStore(); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 464 | const form = useForm<z.infer<typeof schema>>({ |
| 465 | resolver: zodResolver(schema), |
| 466 | mode: "onChange", |
| 467 | defaultValues: { |
| 468 | name: data.label, |
| 469 | type: data.type, |
| 470 | }, |
| 471 | }); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 472 | useEffect(() => { |
| 473 | const sub = form.watch( |
| 474 | ( |
| 475 | value: DeepPartial<z.infer<typeof schema>>, |
| 476 | { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined }, |
| 477 | ) => { |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 478 | if (type !== "change") { |
| 479 | return; |
| 480 | } |
| 481 | switch (name) { |
| 482 | case "name": |
| 483 | if (!value.name) { |
| 484 | break; |
| 485 | } |
| 486 | store.updateNodeData<"app">(id, { |
| 487 | label: value.name, |
| 488 | }); |
| 489 | break; |
| 490 | case "type": |
| 491 | if (!value.type) { |
| 492 | break; |
| 493 | } |
| 494 | store.updateNodeData<"app">(id, { |
| 495 | type: value.type, |
| 496 | }); |
| 497 | break; |
| 498 | } |
| 499 | }, |
| 500 | ); |
| 501 | return () => sub.unsubscribe(); |
| 502 | }, [id, form, store]); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 503 | const [typeProps, setTypeProps] = useState({}); |
| 504 | useEffect(() => { |
| 505 | if (data.activeField === "type") { |
| 506 | setTypeProps({ |
| 507 | open: true, |
| 508 | onOpenChange: () => store.updateNodeData(id, { activeField: undefined }), |
| 509 | }); |
| 510 | } else { |
| 511 | setTypeProps({}); |
| 512 | } |
| 513 | }, [id, data, store, setTypeProps]); |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 514 | const setPreBuildCommands = useCallback( |
| 515 | (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
| 516 | store.updateNodeData<"app">(id, { |
| 517 | preBuildCommands: e.currentTarget.value, |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 518 | }); |
| 519 | }, |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 520 | [id, store], |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 521 | ); |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 522 | const agentForm = useForm<z.infer<typeof agentSchema>>({ |
| 523 | resolver: zodResolver(agentSchema), |
| 524 | mode: "onChange", |
| 525 | defaultValues: { |
| gio | 69ff759 | 2025-07-03 06:27:21 +0000 | [diff] [blame] | 526 | apiKey: data.model?.apiKey, |
| 527 | model: data.model?.name, |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 528 | }, |
| 529 | }); |
| 530 | useEffect(() => { |
| gio | 69ff759 | 2025-07-03 06:27:21 +0000 | [diff] [blame] | 531 | const sub = agentForm.watch((value, { name }: { name?: keyof z.infer<typeof agentSchema> | undefined }) => { |
| 532 | switch (name) { |
| 533 | case "model": |
| 534 | agentForm.setValue("apiKey", "", { shouldDirty: true }); |
| 535 | store.updateNodeData<"app">(id, { |
| 536 | model: { |
| 537 | name: value.model, |
| 538 | apiKey: undefined, |
| 539 | }, |
| 540 | }); |
| 541 | break; |
| 542 | case "apiKey": |
| 543 | store.updateNodeData<"app">(id, { |
| 544 | model: { |
| 545 | name: data.model?.name, |
| 546 | apiKey: value.apiKey, |
| 547 | }, |
| 548 | }); |
| 549 | break; |
| 550 | } |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 551 | }); |
| 552 | return () => sub.unsubscribe(); |
| gio | 69ff759 | 2025-07-03 06:27:21 +0000 | [diff] [blame] | 553 | }, [id, agentForm, store, data]); |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 554 | return ( |
| 555 | <> |
| 556 | <SourceRepo node={node} disabled={disabled} /> |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 557 | {node.data.type !== "sketch:latest" && ( |
| 558 | <Form {...form}> |
| 559 | <form className="space-y-2"> |
| 560 | <Label>Container Image</Label> |
| 561 | <FormField |
| 562 | control={form.control} |
| 563 | name="type" |
| 564 | render={({ field }) => ( |
| 565 | <FormItem> |
| 566 | <Select |
| 567 | onValueChange={field.onChange} |
| 568 | value={field.value || ""} |
| 569 | {...typeProps} |
| 570 | disabled={disabled} |
| 571 | > |
| 572 | <FormControl> |
| 573 | <SelectTrigger> |
| 574 | <SelectValue /> |
| 575 | </SelectTrigger> |
| 576 | </FormControl> |
| 577 | <SelectContent> |
| 578 | {ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => ( |
| 579 | <SelectItem key={t} value={t}> |
| 580 | {t} |
| 581 | </SelectItem> |
| 582 | ))} |
| 583 | </SelectContent> |
| 584 | </Select> |
| 585 | <FormMessage /> |
| 586 | </FormItem> |
| 587 | )} |
| 588 | /> |
| 589 | </form> |
| 590 | </Form> |
| 591 | )} |
| 592 | {node.data.type === "sketch:latest" && ( |
| 593 | <Form {...agentForm}> |
| 594 | <form className="space-y-2"> |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 595 | <FormField |
| 596 | control={agentForm.control} |
| gio | 69ff759 | 2025-07-03 06:27:21 +0000 | [diff] [blame] | 597 | name="model" |
| 598 | render={({ field }) => ( |
| 599 | <FormItem> |
| 600 | <FormLabel>AI Model</FormLabel> |
| 601 | <Select |
| 602 | onValueChange={field.onChange} |
| 603 | defaultValue={field.value} |
| 604 | disabled={disabled} |
| 605 | > |
| 606 | <FormControl> |
| 607 | <SelectTrigger> |
| 608 | <SelectValue placeholder="Select a model" /> |
| 609 | </SelectTrigger> |
| 610 | </FormControl> |
| 611 | <SelectContent> |
| 612 | <SelectItem value="gemini">Gemini</SelectItem> |
| 613 | <SelectItem value="claude">Claude</SelectItem> |
| 614 | </SelectContent> |
| 615 | </Select> |
| 616 | <FormMessage /> |
| 617 | </FormItem> |
| 618 | )} |
| 619 | /> |
| 620 | <Label>API Key</Label> |
| 621 | <FormField |
| 622 | control={agentForm.control} |
| 623 | name="apiKey" |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 624 | render={({ field }) => ( |
| 625 | <FormItem> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 626 | <FormControl> |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 627 | <Input |
| 628 | type="password" |
| gio | 69ff759 | 2025-07-03 06:27:21 +0000 | [diff] [blame] | 629 | placeholder="Override AI Model API key" |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 630 | {...field} |
| 631 | value={field.value || ""} |
| 632 | disabled={disabled} |
| 633 | /> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 634 | </FormControl> |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 635 | <FormMessage /> |
| 636 | </FormItem> |
| 637 | )} |
| 638 | /> |
| 639 | </form> |
| 640 | </Form> |
| 641 | )} |
| 642 | {node.data.type !== "sketch:latest" && ( |
| 643 | <> |
| 644 | <Label>Pre-Build Commands</Label> |
| 645 | <Textarea |
| 646 | placeholder="new line separated list of commands to run before running the service" |
| 647 | value={data.preBuildCommands} |
| 648 | onChange={setPreBuildCommands} |
| 649 | disabled={disabled} |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 650 | /> |
| gio | 6914832 | 2025-06-19 23:16:12 +0400 | [diff] [blame] | 651 | </> |
| 652 | )} |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 653 | </> |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 654 | ); |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 655 | } |
| 656 | |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 657 | function Ports({ |
| 658 | node, |
| 659 | disabled, |
| 660 | isOverview, |
| 661 | }: { |
| 662 | node: ServiceNode; |
| 663 | disabled?: boolean; |
| 664 | isOverview?: boolean; |
| 665 | }): React.ReactNode { |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 666 | const { id, data } = node; |
| 667 | const store = useStateStore(); |
| gio | 9f3d4f5 | 2025-07-04 08:42:34 +0000 | [diff] [blame] | 668 | const nodes = useNodes<AppNode>(); |
| 669 | const [portIngresses, setPortIngresses] = useState<Record<string, string[]>>({}); |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 670 | const [exposingPortId, setExposingPortId] = useState<string | null>(null); |
| gio | 9f3d4f5 | 2025-07-04 08:42:34 +0000 | [diff] [blame] | 671 | |
| 672 | const httpsGateways = useMemo( |
| 673 | () => nodes.filter((n): n is GatewayHttpsNode => n.type === "gateway-https"), |
| 674 | [nodes], |
| 675 | ); |
| 676 | |
| 677 | useEffect(() => { |
| 678 | if (!data.ports) { |
| 679 | setPortIngresses({}); |
| 680 | return; |
| 681 | } |
| 682 | const newIngresses: Record<string, string[]> = {}; |
| 683 | for (const port of data.ports) { |
| 684 | newIngresses[port.id] = []; |
| 685 | } |
| 686 | for (const gateway of httpsGateways) { |
| 687 | const https = gateway.data.https; |
| 688 | if (https && https.serviceId === id && https.portId && gateway.data.network && gateway.data.subdomain) { |
| 689 | const url = `https://${gateway.data.subdomain}.${gateway.data.network}`; |
| 690 | if (newIngresses[https.portId]) { |
| 691 | newIngresses[https.portId].push(url); |
| 692 | } else { |
| 693 | newIngresses[https.portId] = [url]; |
| 694 | } |
| 695 | } |
| 696 | } |
| 697 | setPortIngresses(newIngresses); |
| 698 | console.log(newIngresses); |
| 699 | }, [id, data.ports, httpsGateways]); |
| 700 | |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 701 | const [name, setName] = useState(""); |
| 702 | const [value, setValue] = useState(""); |
| 703 | const onSubmit = useCallback(() => { |
| 704 | const portId = uuidv4(); |
| 705 | store.updateNodeData<"app">(id, { |
| 706 | ports: (data.ports || []).concat({ |
| 707 | id: portId, |
| 708 | name: name.toUpperCase(), |
| 709 | value: Number(value), |
| 710 | }), |
| gio | 73ac16c | 2025-07-03 14:38:04 +0000 | [diff] [blame] | 711 | envVars: (data.envVars || []).concat( |
| 712 | { |
| 713 | id: uuidv4(), |
| 714 | source: null, |
| 715 | portId, |
| 716 | name: `DODO_PORT_${name.toUpperCase()}`, |
| 717 | }, |
| 718 | { |
| 719 | id: uuidv4(), |
| 720 | source: null, |
| 721 | portId, |
| 722 | name: `DODO_PORT_${name.toUpperCase()}`, |
| 723 | alias: name.toUpperCase(), |
| 724 | }, |
| 725 | ), |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 726 | }); |
| 727 | setName(""); |
| 728 | setValue(""); |
| 729 | }, [id, data, store, name, value, setName, setValue]); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 730 | const removePort = useCallback( |
| 731 | (portId: string) => { |
| 732 | // TODO(gio): this is ugly |
| 733 | const tcpRemoved = new Set<string>(); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 734 | store.setEdges( |
| 735 | store.edges.filter((e) => { |
| 736 | if (e.source !== id || e.sourceHandle !== "ports") { |
| 737 | return true; |
| 738 | } |
| 739 | const tn = store.nodes.find((n) => n.id == e.target)!; |
| 740 | if (e.targetHandle === "https") { |
| 741 | const t = tn as GatewayHttpsNode; |
| 742 | if (t.data.https?.serviceId === id && t.data.https.portId === portId) { |
| 743 | return false; |
| 744 | } |
| 745 | } |
| 746 | if (e.targetHandle === "tcp") { |
| 747 | const t = tn as GatewayTCPNode; |
| 748 | if (tcpRemoved.has(t.id)) { |
| 749 | return true; |
| 750 | } |
| 751 | if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) { |
| 752 | tcpRemoved.add(t.id); |
| 753 | return false; |
| 754 | } |
| 755 | } |
| 756 | if (e.targetHandle === "env_var") { |
| 757 | if ( |
| 758 | tn && |
| 759 | (tn.data.envVars || []).find( |
| 760 | (ev) => ev.source === id && "portId" in ev && ev.portId === portId, |
| 761 | ) |
| 762 | ) { |
| 763 | return false; |
| 764 | } |
| 765 | } |
| 766 | return true; |
| 767 | }), |
| 768 | ); |
| 769 | store.nodes |
| 770 | .filter( |
| 771 | (n) => |
| 772 | n.type === "gateway-https" && |
| 773 | n.data.https && |
| 774 | n.data.https.serviceId === id && |
| 775 | n.data.https.portId === portId, |
| 776 | ) |
| 777 | .forEach((n) => { |
| 778 | store.updateNodeData<"gateway-https">(n.id, { |
| 779 | https: undefined, |
| 780 | }); |
| 781 | }); |
| 782 | store.nodes |
| 783 | .filter((n) => n.type === "gateway-tcp") |
| 784 | .forEach((n) => { |
| 785 | const filtered = n.data.exposed.filter((e) => { |
| 786 | if (e.serviceId === id && e.portId === portId) { |
| 787 | return false; |
| 788 | } else { |
| 789 | return true; |
| 790 | } |
| 791 | }); |
| 792 | if (filtered.length != n.data.exposed.length) { |
| 793 | store.updateNodeData<"gateway-tcp">(n.id, { |
| 794 | exposed: filtered, |
| 795 | }); |
| 796 | } |
| 797 | }); |
| 798 | store.nodes |
| 799 | .filter((n) => n.type === "app" && n.data.envVars) |
| 800 | .forEach((n) => { |
| 801 | store.updateNodeData<"app">(n.id, { |
| 802 | envVars: n.data.envVars.filter((ev) => { |
| 803 | if (ev.source === id && "portId" in ev && ev.portId === portId) { |
| 804 | return false; |
| 805 | } |
| 806 | return true; |
| 807 | }), |
| 808 | }); |
| 809 | }); |
| 810 | store.updateNodeData<"app">(id, { |
| 811 | ports: (data.ports || []).filter((p) => p.id !== portId), |
| 812 | envVars: (data.envVars || []).filter( |
| 813 | (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId), |
| 814 | ), |
| 815 | }); |
| 816 | }, |
| 817 | [id, data, store], |
| 818 | ); |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 819 | return ( |
| 820 | <div className="flex flex-col gap-1"> |
| 821 | <div className="grid grid-cols-[1fr_1fr_auto] gap-1"> |
| 822 | {data && |
| 823 | data.ports && |
| 824 | data.ports.map((p) => ( |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 825 | <div key={p.id} className="contents"> |
| gio | 9f3d4f5 | 2025-07-04 08:42:34 +0000 | [diff] [blame] | 826 | <div className="contents"> |
| 827 | <div className="flex items-center px-3">{p.name.toUpperCase()}</div> |
| 828 | <div className="flex items-center px-3">{p.value}</div> |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 829 | <div className="flex items-center gap-1"> |
| 830 | {isOverview && ( |
| 831 | <Button |
| 832 | variant="outline" |
| 833 | onClick={() => setExposingPortId(p.id)} |
| 834 | disabled={disabled} |
| 835 | > |
| 836 | Expose |
| 837 | </Button> |
| 838 | )} |
| gio | 9f3d4f5 | 2025-07-04 08:42:34 +0000 | [diff] [blame] | 839 | <Button |
| 840 | variant="destructive" |
| 841 | className="w-full" |
| 842 | onClick={() => removePort(p.id)} |
| 843 | disabled={disabled} |
| 844 | > |
| 845 | Remove |
| 846 | </Button> |
| 847 | </div> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 848 | </div> |
| gio | 9f3d4f5 | 2025-07-04 08:42:34 +0000 | [diff] [blame] | 849 | {portIngresses[p.id]?.length > 0 && ( |
| 850 | <div key={p.id} className="col-span-full pl-6"> |
| 851 | {portIngresses[p.id].map((url) => ( |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 852 | <Gateway key={url} g={{ type: "https", address: url, name: p.name }} /> |
| gio | 9f3d4f5 | 2025-07-04 08:42:34 +0000 | [diff] [blame] | 853 | ))} |
| 854 | </div> |
| 855 | )} |
| gio | 2e7d217 | 2025-07-04 09:24:53 +0000 | [diff] [blame] | 856 | {exposingPortId === p.id && ( |
| 857 | <Dialog open={true} onOpenChange={() => setExposingPortId(null)}> |
| 858 | <DialogContent> |
| 859 | <DialogHeader> |
| 860 | <DialogTitle> |
| 861 | Expose Port {p.name}:{p.value} |
| 862 | </DialogTitle> |
| 863 | </DialogHeader> |
| 864 | <ExposeForm |
| 865 | node={node} |
| 866 | port={p} |
| 867 | onDone={() => setExposingPortId(null)} |
| 868 | disabled={disabled} |
| 869 | /> |
| 870 | </DialogContent> |
| 871 | </Dialog> |
| 872 | )} |
| 873 | </div> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 874 | ))} |
| 875 | <div> |
| 876 | <Input |
| 877 | placeholder="name" |
| 878 | className="uppercase w-0 min-w-full" |
| 879 | disabled={disabled} |
| 880 | value={name} |
| 881 | onChange={(e) => setName(e.target.value)} |
| 882 | /> |
| 883 | </div> |
| 884 | <div> |
| 885 | <Input |
| 886 | placeholder="0" |
| 887 | className="w-0 min-w-full" |
| 888 | disabled={disabled} |
| 889 | value={value} |
| 890 | onChange={(e) => setValue(e.target.value)} |
| 891 | /> |
| 892 | </div> |
| 893 | <div> |
| 894 | <Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}> |
| 895 | Add |
| 896 | </Button> |
| 897 | </div> |
| 898 | </div> |
| 899 | </div> |
| 900 | ); |
| 901 | } |
| 902 | |
| 903 | function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| 904 | const { id, data } = node; |
| gio | 8323a05 | 2025-08-04 06:12:26 +0000 | [diff] [blame] | 905 | const mode = useMode(); |
| 906 | const env = useEnv(); |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 907 | const store = useStateStore(); |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 908 | const [name, setName] = useState(""); |
| 909 | const [value, setValue] = useState(""); |
| 910 | |
| 911 | const addEnvVar = useCallback(() => { |
| 912 | if (!name.trim() || !value.trim()) return; |
| 913 | store.updateNodeData<"app">(id, { |
| 914 | envVars: (data.envVars || []).concat({ |
| 915 | id: uuidv4(), |
| 916 | source: null, |
| 917 | name: name.toUpperCase(), |
| 918 | value: value, |
| 919 | }), |
| 920 | }); |
| 921 | setName(""); |
| 922 | setValue(""); |
| 923 | }, [id, data, store, name, value]); |
| 924 | |
| 925 | const removeEnvVar = useCallback( |
| 926 | (varId: string) => { |
| 927 | store.updateNodeData<"app">(id, { |
| 928 | envVars: (data.envVars || []).filter((v) => v.id !== varId), |
| 929 | }); |
| 930 | }, |
| 931 | [id, data, store], |
| 932 | ); |
| 933 | |
| 934 | const editValueEnvVar = useCallback( |
| 935 | (varId: string) => { |
| 936 | if (disabled) return; |
| 937 | store.updateNodeData<"app">(id, { |
| 938 | envVars: (data.envVars || []).map((v) => (v.id === varId ? { ...v, isEditting: true } : v)), |
| 939 | }); |
| 940 | }, |
| 941 | [id, data, store, disabled], |
| 942 | ); |
| 943 | |
| 944 | const saveValueEnvVar = useCallback( |
| 945 | (varId: string, newName: string, newValue: string) => { |
| 946 | store.updateNodeData<"app">(id, { |
| 947 | envVars: (data.envVars || []).map((v) => { |
| 948 | if (v.id === varId) { |
| 949 | return { ...v, name: newName.toUpperCase(), value: newValue, isEditting: false }; |
| 950 | } |
| 951 | return v; |
| 952 | }), |
| 953 | }); |
| 954 | }, |
| 955 | [id, data, store], |
| 956 | ); |
| 957 | |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 958 | const editAlias = useCallback( |
| 959 | (e: BoundEnvVar) => { |
| 960 | return () => { |
| gio | ff9b552 | 2025-07-03 13:50:30 +0000 | [diff] [blame] | 961 | if (disabled) { |
| 962 | return; |
| 963 | } |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 964 | store.updateNodeData(id, { |
| 965 | ...data, |
| 966 | envVars: data.envVars!.map((o) => { |
| 967 | if (o.id !== e.id) { |
| 968 | return o; |
| 969 | } else |
| 970 | return { |
| 971 | ...o, |
| 972 | isEditting: true, |
| 973 | }; |
| 974 | }), |
| 975 | }); |
| 976 | }; |
| 977 | }, |
| gio | ff9b552 | 2025-07-03 13:50:30 +0000 | [diff] [blame] | 978 | [id, data, store, disabled], |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 979 | ); |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 980 | |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 981 | const saveAlias = useCallback( |
| 982 | (e: BoundEnvVar, value: string, store: AppState) => { |
| 983 | store.updateNodeData(id, { |
| 984 | ...data, |
| 985 | envVars: data.envVars!.map((o) => { |
| 986 | if (o.id !== e.id) { |
| 987 | return o; |
| 988 | } |
| 989 | if (value) { |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 990 | if ("name" in o && value.toUpperCase() === o.name.toUpperCase()) { |
| 991 | return { |
| 992 | ...o, |
| 993 | isEditting: false, |
| 994 | alias: undefined, |
| 995 | }; |
| 996 | } else { |
| 997 | return { |
| 998 | ...o, |
| 999 | isEditting: false, |
| 1000 | alias: value.toUpperCase(), |
| 1001 | }; |
| 1002 | } |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1003 | } |
| 1004 | if ("alias" in o) { |
| 1005 | const { alias: _, ...rest } = o; |
| 1006 | return { |
| 1007 | ...rest, |
| 1008 | isEditting: false, |
| 1009 | }; |
| 1010 | } |
| 1011 | return { |
| 1012 | ...o, |
| 1013 | isEditting: false, |
| 1014 | }; |
| 1015 | }), |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 1016 | }); |
| 1017 | }, |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1018 | [id, data], |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 1019 | ); |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 1020 | |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1021 | const saveAliasOnEnter = useCallback( |
| 1022 | (e: BoundEnvVar) => { |
| 1023 | return (event: KeyboardEvent<HTMLInputElement>) => { |
| 1024 | if (event.key === "Enter") { |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1025 | saveAlias(e, event.currentTarget.value, store); |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 1026 | } else if (event.key === "Escape") { |
| 1027 | store.updateNodeData(id, { |
| 1028 | ...data, |
| 1029 | envVars: data.envVars!.map((o) => (o.id === e.id ? { ...o, isEditting: false } : o)), |
| 1030 | }); |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 1031 | } |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1032 | }; |
| 1033 | }, |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 1034 | [store, saveAlias, id, data], |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1035 | ); |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 1036 | |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1037 | const saveAliasOnBlur = useCallback( |
| 1038 | (e: BoundEnvVar) => { |
| 1039 | return (event: FocusEvent<HTMLInputElement>) => { |
| 1040 | saveAlias(e, event.currentTarget.value, store); |
| 1041 | }; |
| 1042 | }, |
| 1043 | [store, saveAlias], |
| 1044 | ); |
| gio | 8323a05 | 2025-08-04 06:12:26 +0000 | [diff] [blame] | 1045 | const envVars = useMemo(() => { |
| 1046 | if (mode !== "deploy") { |
| 1047 | return []; |
| 1048 | } |
| 1049 | return env.access |
| 1050 | .filter((a) => a.name === data.label) |
| 1051 | .filter((a) => a.type === "env_var") |
| 1052 | .map((a) => ({ |
| 1053 | name: a.var.split("=", 2)[0], |
| 1054 | value: a.var.split("=", 2)[1], |
| 1055 | })); |
| 1056 | }, [mode, env.access, data.label]); |
| 1057 | |
| 1058 | const hiddenEnvVars = useMemo(() => { |
| 1059 | return envVars |
| 1060 | .map((v) => { |
| 1061 | const { name, value } = v; |
| 1062 | const match = value.match(/^(postgresql|mongodb):\/\/([^:]+):([^@]+)@([^:/]+)(?::(\d+))?\/(.+)$/); |
| 1063 | if (match) { |
| 1064 | const [_, protocol, username, _password, host, port, database] = match; |
| 1065 | return { |
| 1066 | name, |
| 1067 | value, |
| 1068 | hidden: `${protocol}://${username}:*****@${host}${port ? `:${port}` : ""}/${database}`, |
| 1069 | }; |
| 1070 | } |
| 1071 | return { |
| 1072 | name, |
| 1073 | value, |
| 1074 | hidden: value, |
| 1075 | }; |
| 1076 | }) |
| 1077 | .map((v) => { |
| 1078 | return { |
| 1079 | ...v, |
| 1080 | hidden: v.hidden.length > 50 ? v.hidden.slice(0, 50) + "..." : v.hidden, |
| 1081 | }; |
| 1082 | }); |
| 1083 | }, [envVars]); |
| 1084 | |
| 1085 | const [copied, setCopied] = useState(false); |
| 1086 | const [blip, setBlip] = useState(false); |
| 1087 | |
| 1088 | const handleCopy = () => { |
| 1089 | navigator.clipboard.writeText(envVars.map((v) => `${v.name}=${v.value}`).join("\n")); |
| 1090 | setCopied(true); |
| 1091 | setBlip(true); |
| 1092 | setTimeout(() => setCopied(false), 1000); |
| 1093 | setTimeout(() => setBlip(false), 300); |
| 1094 | }; |
| 1095 | |
| 1096 | if (hiddenEnvVars.length > 0) { |
| 1097 | return ( |
| 1098 | <div className="flex flex-col gap-1"> |
| 1099 | <div className="grid grid-cols-[auto_minmax(0,1fr)] gap-1 flex-shrink"> |
| 1100 | {hiddenEnvVars.map((v) => ( |
| 1101 | <div key={v.name} className="contents"> |
| 1102 | <div className="uppercase">{v.name}</div> |
| 1103 | <div className="min-w-0 truncate">{v.hidden}</div> |
| 1104 | </div> |
| 1105 | ))} |
| 1106 | </div> |
| 1107 | <div className="flex justify-end"> |
| 1108 | <Button onClick={handleCopy} className={blip ? "bg-green-100 transition-colors" : ""}> |
| 1109 | {copied ? ( |
| 1110 | <> |
| 1111 | <Check className="w-4 h-4 text-green-600" /> Copy |
| 1112 | </> |
| 1113 | ) : ( |
| 1114 | <> |
| 1115 | <Copy className="w-4 h-4" /> Copy |
| 1116 | </> |
| 1117 | )} |
| 1118 | </Button> |
| 1119 | </div> |
| 1120 | </div> |
| 1121 | ); |
| 1122 | } |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 1123 | |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1124 | return ( |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 1125 | <div className="flex flex-col gap-1"> |
| 1126 | <div className="grid grid-cols-[auto_1fr_1fr_auto] gap-1"> |
| 1127 | {data?.envVars?.map((v) => { |
| 1128 | if ("value" in v) { |
| 1129 | if (v.isEditting) { |
| 1130 | return ( |
| 1131 | <div key={v.id} className="contents"> |
| 1132 | <Input |
| 1133 | className="uppercase col-start-2" |
| 1134 | defaultValue={v.name} |
| 1135 | onKeyUp={(e) => { |
| 1136 | if (e.key === "Enter") { |
| 1137 | const nameInput = e.currentTarget; |
| 1138 | const valueInput = nameInput.parentElement?.querySelector( |
| 1139 | 'input[placeholder="Value"]', |
| 1140 | ) as HTMLInputElement; |
| 1141 | if (valueInput) { |
| 1142 | saveValueEnvVar(v.id, nameInput.value, valueInput.value); |
| 1143 | } |
| 1144 | } else if (e.key === "Escape") { |
| 1145 | store.updateNodeData(id, { |
| 1146 | ...data, |
| 1147 | envVars: data.envVars!.map((o) => |
| 1148 | o.id === v.id ? { ...o, isEditting: false } : o, |
| 1149 | ), |
| 1150 | }); |
| 1151 | } |
| 1152 | }} |
| 1153 | autoFocus |
| 1154 | disabled={disabled} |
| 1155 | /> |
| 1156 | <Input |
| 1157 | placeholder="Value" |
| 1158 | defaultValue={v.value} |
| 1159 | onKeyUp={(e) => { |
| 1160 | if (e.key === "Enter") { |
| 1161 | const valueInput = e.currentTarget; |
| 1162 | const nameInput = valueInput.parentElement?.querySelector( |
| 1163 | 'input:not([placeholder="Value"])', |
| 1164 | ) as HTMLInputElement; |
| 1165 | if (nameInput) { |
| 1166 | saveValueEnvVar(v.id, nameInput.value, valueInput.value); |
| 1167 | } |
| 1168 | } else if (e.key === "Escape") { |
| 1169 | store.updateNodeData(id, { |
| 1170 | ...data, |
| 1171 | envVars: data.envVars!.map((o) => |
| 1172 | o.id === v.id ? { ...o, isEditting: false } : o, |
| 1173 | ), |
| 1174 | }); |
| 1175 | } |
| 1176 | }} |
| 1177 | disabled={disabled} |
| 1178 | /> |
| 1179 | <Button |
| 1180 | variant="destructive" |
| 1181 | size="sm" |
| 1182 | onClick={() => removeEnvVar(v.id)} |
| 1183 | disabled={disabled} |
| 1184 | > |
| 1185 | Remove |
| 1186 | </Button> |
| 1187 | </div> |
| 1188 | ); |
| 1189 | } |
| 1190 | return ( |
| 1191 | <div |
| 1192 | key={v.id} |
| 1193 | className={`contents ${disabled ? "" : "cursor-text"}`} |
| 1194 | onClick={() => editValueEnvVar(v.id)} |
| 1195 | > |
| 1196 | <div>{!disabled && <Pencil className="w-4 h-4" />}</div> |
| 1197 | <div className={`${disabled ? "col-span-2" : ""} col-start-2`}>{v.name}</div> |
| 1198 | <div>{v.value}</div> |
| 1199 | <Button |
| 1200 | variant="destructive" |
| 1201 | size="sm" |
| 1202 | onClick={(e) => { |
| 1203 | e.stopPropagation(); |
| 1204 | removeEnvVar(v.id); |
| 1205 | }} |
| 1206 | disabled={disabled} |
| 1207 | > |
| 1208 | Remove |
| 1209 | </Button> |
| 1210 | </div> |
| 1211 | ); |
| 1212 | } |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1213 | if ("name" in v) { |
| 1214 | const value = "alias" in v ? v.alias : v.name; |
| 1215 | if (v.isEditting) { |
| 1216 | return ( |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 1217 | <Input |
| 1218 | type="text" |
| 1219 | className="uppercase col-start-2 col-span-3" |
| 1220 | defaultValue={value} |
| 1221 | onKeyUp={saveAliasOnEnter(v)} |
| 1222 | onBlur={saveAliasOnBlur(v)} |
| 1223 | autoFocus={true} |
| 1224 | disabled={disabled} |
| 1225 | /> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1226 | ); |
| 1227 | } |
| 1228 | return ( |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 1229 | <div |
| 1230 | key={v.id} |
| 1231 | onClick={editAlias(v)} |
| 1232 | className={`contents ${disabled ? "" : "cursor-text"}`} |
| 1233 | > |
| 1234 | {!disabled && <Pencil className="w-4 h-4" />} |
| 1235 | <div className="col-start-2 col-span-3"> |
| 1236 | <TooltipProvider> |
| 1237 | <Tooltip> |
| 1238 | <TooltipTrigger className="uppercase">{value}</TooltipTrigger> |
| 1239 | <TooltipContent>{v.name}</TooltipContent> |
| 1240 | </Tooltip> |
| 1241 | </TooltipProvider> |
| 1242 | </div> |
| 1243 | </div> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1244 | ); |
| 1245 | } |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 1246 | return null; |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1247 | })} |
| gio | 1dacf1c | 2025-07-03 16:39:04 +0000 | [diff] [blame] | 1248 | {!disabled && ( |
| 1249 | <div className="contents"> |
| 1250 | <Input |
| 1251 | placeholder="Name" |
| 1252 | className="uppercase col-start-2" |
| 1253 | value={name} |
| 1254 | onChange={(e) => setName(e.target.value)} |
| 1255 | disabled={disabled} |
| 1256 | /> |
| 1257 | <Input |
| 1258 | placeholder="Value" |
| 1259 | value={value} |
| 1260 | onChange={(e) => setValue(e.target.value)} |
| 1261 | disabled={disabled} |
| 1262 | /> |
| 1263 | <Button onClick={addEnvVar} disabled={disabled || !name.trim() || !value.trim()}> |
| 1264 | Add |
| 1265 | </Button> |
| 1266 | </div> |
| 1267 | )} |
| 1268 | </div> |
| 1269 | </div> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1270 | ); |
| 1271 | } |
| 1272 | |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1273 | function usePrevious<T>(value: T) { |
| 1274 | const ref = useRef<T>(); |
| 1275 | useEffect(() => { |
| 1276 | ref.current = value; |
| 1277 | }, [value]); |
| 1278 | return ref.current; |
| 1279 | } |
| 1280 | |
| 1281 | function DevVM({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1282 | const { id, data } = node; |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1283 | const { dev } = data; |
| 1284 | const prevDev = usePrevious(dev); |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1285 | const env = useEnv(); |
| 1286 | const store = useStateStore(); |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1287 | useEffect(() => { |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1288 | console.log("DDDEV", prevDev, dev); |
| 1289 | if (!dev && !prevDev) { |
| 1290 | return; |
| 1291 | } |
| 1292 | if ( |
| 1293 | dev && |
| 1294 | prevDev && |
| 1295 | dev.enabled === prevDev.enabled && |
| 1296 | "mode" in dev && |
| 1297 | "mode" in prevDev && |
| 1298 | dev.mode === prevDev.mode |
| 1299 | ) { |
| 1300 | return; |
| 1301 | } |
| 1302 | if (!dev?.enabled || dev.mode !== "VM") { |
| 1303 | if (prevDev?.enabled && prevDev.mode === "VM") { |
| 1304 | store.setNodes( |
| 1305 | store.nodes.filter((n) => n.id !== prevDev.codeServerNodeId && n.id !== prevDev.sshNodeId), |
| 1306 | ); |
| 1307 | store.setEdges( |
| 1308 | store.edges.filter((e) => e.target !== prevDev.codeServerNodeId && e.target !== prevDev.sshNodeId), |
| 1309 | ); |
| 1310 | if (dev?.enabled) { |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1311 | store.updateNodeData<"app">(id, { |
| 1312 | dev: { |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1313 | enabled: dev.enabled, |
| 1314 | mode: dev.mode, |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1315 | }, |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1316 | ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"), |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1317 | }); |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1318 | } else { |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1319 | store.updateNodeData<"app">(id, { |
| 1320 | dev: { |
| 1321 | enabled: false, |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1322 | }, |
| 1323 | ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"), |
| 1324 | }); |
| 1325 | } |
| 1326 | } |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1327 | } else { |
| 1328 | if (!prevDev?.enabled || prevDev.mode !== "VM") { |
| 1329 | const csGateway: Omit<GatewayHttpsNode, "position"> = { |
| 1330 | id: uuidv4(), |
| 1331 | type: "gateway-https", |
| 1332 | data: { |
| 1333 | readonly: true, |
| 1334 | https: { |
| 1335 | serviceId: id, |
| 1336 | portId: `${id}-code-server`, |
| 1337 | }, |
| 1338 | network: dev?.expose?.network, |
| 1339 | subdomain: dev?.expose?.subdomain, |
| 1340 | label: "", |
| 1341 | envVars: [], |
| 1342 | ports: [], |
| 1343 | }, |
| 1344 | }; |
| 1345 | const sshGateway: Omit<GatewayTCPNode, "position"> = { |
| 1346 | id: uuidv4(), |
| 1347 | type: "gateway-tcp", |
| 1348 | data: { |
| 1349 | readonly: true, |
| 1350 | exposed: [ |
| 1351 | { |
| 1352 | serviceId: id, |
| 1353 | portId: `${id}-ssh`, |
| 1354 | }, |
| 1355 | ], |
| 1356 | network: dev?.expose?.network, |
| 1357 | subdomain: dev?.expose?.subdomain, |
| 1358 | label: "", |
| 1359 | envVars: [], |
| 1360 | ports: [], |
| 1361 | }, |
| 1362 | }; |
| 1363 | store.addNode(csGateway); |
| 1364 | store.addNode(sshGateway); |
| 1365 | store.updateNodeData<"app">(id, { |
| 1366 | dev: { |
| 1367 | enabled: true, |
| 1368 | mode: "VM", |
| 1369 | expose: dev?.expose, |
| 1370 | codeServerNodeId: csGateway.id, |
| 1371 | sshNodeId: sshGateway.id, |
| 1372 | }, |
| 1373 | ports: (data.ports || []).concat( |
| 1374 | { |
| 1375 | id: `${id}-code-server`, |
| 1376 | name: "code-server", |
| 1377 | value: 9090, |
| 1378 | }, |
| 1379 | { |
| 1380 | id: `${id}-ssh`, |
| 1381 | name: "ssh", |
| 1382 | value: 22, |
| 1383 | }, |
| 1384 | ), |
| 1385 | }); |
| 1386 | let edges = store.edges.concat([ |
| 1387 | { |
| 1388 | id: uuidv4(), |
| 1389 | source: id, |
| 1390 | sourceHandle: "ports", |
| 1391 | target: csGateway.id, |
| 1392 | targetHandle: "https", |
| 1393 | }, |
| 1394 | { |
| 1395 | id: uuidv4(), |
| 1396 | source: id, |
| 1397 | sourceHandle: "ports", |
| 1398 | target: sshGateway.id, |
| 1399 | targetHandle: "tcp", |
| 1400 | }, |
| 1401 | ]); |
| 1402 | if (dev?.expose?.network !== undefined) { |
| 1403 | edges = edges.concat([ |
| 1404 | { |
| 1405 | id: uuidv4(), |
| 1406 | source: csGateway.id, |
| 1407 | sourceHandle: "subdomain", |
| 1408 | target: dev.expose.network, |
| 1409 | targetHandle: "subdomain", |
| 1410 | }, |
| 1411 | { |
| 1412 | id: uuidv4(), |
| 1413 | source: sshGateway.id, |
| 1414 | sourceHandle: "subdomain", |
| 1415 | target: dev.expose.network, |
| 1416 | targetHandle: "subdomain", |
| 1417 | }, |
| 1418 | ]); |
| 1419 | } |
| 1420 | store.setEdges(edges); |
| 1421 | } |
| 1422 | } |
| 1423 | }, [id, data, dev, prevDev, store]); |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1424 | const exposeForm = useForm<z.infer<typeof exposeSchema>>({ |
| 1425 | resolver: zodResolver(exposeSchema), |
| 1426 | mode: "onChange", |
| 1427 | defaultValues: { |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1428 | network: dev && "expose" in dev ? dev.expose?.network : undefined, |
| 1429 | subdomain: dev && "expose" in dev ? dev.expose?.subdomain : undefined, |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1430 | }, |
| 1431 | }); |
| 1432 | useEffect(() => { |
| 1433 | const sub = exposeForm.watch( |
| 1434 | ( |
| 1435 | value: DeepPartial<z.infer<typeof exposeSchema>>, |
| 1436 | { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined }, |
| 1437 | ) => { |
| 1438 | const { dev } = data; |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1439 | if (!dev?.enabled || dev.mode !== "VM") { |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1440 | return; |
| 1441 | } |
| 1442 | if (name === "network") { |
| 1443 | let edges = store.edges; |
| 1444 | if (dev.enabled && dev.expose?.network !== undefined) { |
| 1445 | edges = edges.filter((e) => { |
| 1446 | if ( |
| 1447 | (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) && |
| 1448 | e.sourceHandle === "subdomain" && |
| 1449 | e.target === dev.expose?.network && |
| 1450 | e.targetHandle === "subdomain" |
| 1451 | ) { |
| 1452 | return false; |
| 1453 | } else { |
| 1454 | return true; |
| 1455 | } |
| 1456 | }); |
| 1457 | } |
| 1458 | if (value.network !== undefined) { |
| 1459 | edges = edges.concat( |
| 1460 | { |
| 1461 | id: uuidv4(), |
| 1462 | source: dev.codeServerNodeId, |
| 1463 | sourceHandle: "subdomain", |
| 1464 | target: value.network, |
| 1465 | targetHandle: "subdomain", |
| 1466 | }, |
| 1467 | { |
| 1468 | id: uuidv4(), |
| 1469 | source: dev.sshNodeId, |
| 1470 | sourceHandle: "subdomain", |
| 1471 | target: value.network, |
| 1472 | targetHandle: "subdomain", |
| 1473 | }, |
| 1474 | ); |
| 1475 | } |
| 1476 | store.setEdges(edges); |
| 1477 | store.updateNodeData<"app">(id, { |
| 1478 | dev: { |
| 1479 | ...dev, |
| 1480 | expose: { |
| 1481 | network: value.network, |
| 1482 | subdomain: dev.expose?.subdomain, |
| 1483 | }, |
| 1484 | }, |
| 1485 | }); |
| 1486 | store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network }); |
| 1487 | store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network }); |
| 1488 | } else if (name === "subdomain") { |
| 1489 | store.updateNodeData<"app">(id, { |
| 1490 | dev: { |
| 1491 | ...dev, |
| 1492 | expose: { |
| 1493 | network: dev.expose?.network, |
| 1494 | subdomain: value.subdomain, |
| 1495 | }, |
| 1496 | }, |
| 1497 | }); |
| 1498 | store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain }); |
| 1499 | store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain }); |
| 1500 | } |
| 1501 | }, |
| 1502 | ); |
| 1503 | return () => sub.unsubscribe(); |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1504 | }, [id, data, dev, prevDev, exposeForm, store]); |
| 1505 | if (!dev?.enabled || dev.mode !== "VM") { |
| 1506 | return null; |
| 1507 | } |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 1508 | return ( |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1509 | <div> |
| gio | 29050d6 | 2025-05-16 04:49:26 +0000 | [diff] [blame] | 1510 | {data.dev && data.dev.enabled && ( |
| 1511 | <Form {...exposeForm}> |
| 1512 | <form className="space-y-2"> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1513 | <Label>Network</Label> |
| gio | 29050d6 | 2025-05-16 04:49:26 +0000 | [diff] [blame] | 1514 | <FormField |
| 1515 | control={exposeForm.control} |
| 1516 | name="network" |
| 1517 | render={({ field }) => ( |
| 1518 | <FormItem> |
| gio | 3ec9424 | 2025-05-16 12:46:57 +0000 | [diff] [blame] | 1519 | <Select |
| 1520 | onValueChange={field.onChange} |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1521 | value={field.value || ""} |
| gio | 3ec9424 | 2025-05-16 12:46:57 +0000 | [diff] [blame] | 1522 | disabled={disabled} |
| 1523 | > |
| gio | 29050d6 | 2025-05-16 04:49:26 +0000 | [diff] [blame] | 1524 | <FormControl> |
| 1525 | <SelectTrigger> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1526 | <SelectValue /> |
| gio | 29050d6 | 2025-05-16 04:49:26 +0000 | [diff] [blame] | 1527 | </SelectTrigger> |
| 1528 | </FormControl> |
| 1529 | <SelectContent> |
| 1530 | {env.networks.map((n) => ( |
| 1531 | <SelectItem |
| 1532 | key={n.name} |
| 1533 | value={n.domain} |
| 1534 | >{`${n.name} - ${n.domain}`}</SelectItem> |
| 1535 | ))} |
| 1536 | </SelectContent> |
| 1537 | </Select> |
| 1538 | <FormMessage /> |
| 1539 | </FormItem> |
| 1540 | )} |
| 1541 | /> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1542 | <Label>Subdomain</Label> |
| gio | 29050d6 | 2025-05-16 04:49:26 +0000 | [diff] [blame] | 1543 | <FormField |
| 1544 | control={exposeForm.control} |
| 1545 | name="subdomain" |
| 1546 | render={({ field }) => ( |
| 1547 | <FormItem> |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1548 | <FormControl> |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1549 | <Input {...field} disabled={disabled} /> |
| gio | 48fde05 | 2025-05-14 09:48:08 +0000 | [diff] [blame] | 1550 | </FormControl> |
| gio | 29050d6 | 2025-05-16 04:49:26 +0000 | [diff] [blame] | 1551 | <FormMessage /> |
| 1552 | </FormItem> |
| 1553 | )} |
| 1554 | /> |
| 1555 | </form> |
| 1556 | </Form> |
| 1557 | )} |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1558 | </div> |
| 1559 | ); |
| 1560 | } |
| 1561 | |
| 1562 | function DevProxy({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| 1563 | const { id, data } = node; |
| 1564 | const store = useStateStore(); |
| 1565 | const { toast } = useToast(); |
| 1566 | const [machines, setMachines] = useState<Machines>([]); |
| 1567 | const [loading, setLoading] = useState(false); |
| 1568 | const [error, setError] = useState<string | null>(null); |
| 1569 | |
| 1570 | const fetchMachines = useCallback(async () => { |
| 1571 | setLoading(true); |
| 1572 | setError(null); |
| 1573 | try { |
| 1574 | const response = await fetch("/api/machines", { |
| 1575 | method: "GET", |
| 1576 | headers: { |
| 1577 | "Content-Type": "application/json", |
| 1578 | }, |
| 1579 | }); |
| 1580 | |
| 1581 | if (!response.ok) { |
| 1582 | throw new Error(`Failed to fetch machines: ${response.statusText}`); |
| 1583 | } |
| 1584 | |
| 1585 | const machinesData = MachinesSchema.safeParse(await response.json()); |
| 1586 | if (machinesData.success) { |
| gio | a4bf471 | 2025-08-03 02:21:28 +0000 | [diff] [blame] | 1587 | setMachines( |
| 1588 | machinesData.data |
| 1589 | .filter((m) => !m.name.startsWith("proxy-dodo-app-")) |
| 1590 | .filter( |
| 1591 | (m) => m.last_seen && m.last_seen.seconds * 1000 >= Date.now() - 7 * 24 * 60 * 60 * 1000, |
| 1592 | ), |
| 1593 | ); |
| gio | 43e0aad | 2025-08-01 16:17:27 +0400 | [diff] [blame] | 1594 | } else { |
| 1595 | throw new Error("Invalid machines data"); |
| 1596 | } |
| 1597 | } catch (err) { |
| 1598 | const errorMessage = err instanceof Error ? err.message : "Failed to fetch machines"; |
| 1599 | setError(errorMessage); |
| 1600 | toast({ |
| 1601 | variant: "destructive", |
| 1602 | title: "Error", |
| 1603 | description: errorMessage, |
| 1604 | }); |
| 1605 | } finally { |
| 1606 | setLoading(false); |
| 1607 | } |
| 1608 | }, [toast]); |
| 1609 | |
| 1610 | useEffect(() => { |
| 1611 | if (data.dev?.enabled && "mode" in data.dev && data.dev.mode === "PROXY") { |
| 1612 | fetchMachines(); |
| 1613 | } |
| 1614 | }, [data.dev, fetchMachines]); |
| 1615 | |
| 1616 | const proxyForm = useForm<z.infer<typeof proxySchema>>({ |
| 1617 | resolver: zodResolver(proxySchema), |
| 1618 | mode: "onChange", |
| 1619 | defaultValues: { |
| 1620 | address: data.dev && "address" in data.dev ? data.dev.address : undefined, |
| 1621 | }, |
| 1622 | }); |
| 1623 | |
| 1624 | useEffect(() => { |
| 1625 | const sub = proxyForm.watch((value, { name }) => { |
| 1626 | if (name === "address" && value.address) { |
| 1627 | store.updateNodeData<"app">(id, { |
| 1628 | dev: { |
| 1629 | enabled: true, |
| 1630 | mode: "PROXY", |
| 1631 | address: value.address, |
| 1632 | }, |
| 1633 | }); |
| 1634 | } |
| 1635 | }); |
| 1636 | return () => sub.unsubscribe(); |
| 1637 | }, [id, proxyForm, store]); |
| 1638 | |
| 1639 | if (!data.dev?.enabled || data.dev.mode !== "PROXY") { |
| 1640 | return null; |
| 1641 | } |
| 1642 | return ( |
| 1643 | <div className="space-y-2"> |
| 1644 | <Form {...proxyForm}> |
| 1645 | <form className="space-y-2"> |
| 1646 | <FormField |
| 1647 | control={proxyForm.control} |
| 1648 | name="address" |
| 1649 | render={({ field }) => ( |
| 1650 | <FormItem> |
| 1651 | <Select |
| 1652 | onValueChange={field.onChange} |
| 1653 | value={field.value || ""} |
| 1654 | disabled={disabled || loading} |
| 1655 | > |
| 1656 | <FormControl> |
| 1657 | <SelectTrigger> |
| 1658 | {loading ? ( |
| 1659 | <div className="flex items-center gap-2"> |
| 1660 | <LoaderCircle className="h-4 w-4 animate-spin" /> |
| 1661 | <span>Loading machines...</span> |
| 1662 | </div> |
| 1663 | ) : ( |
| 1664 | <SelectValue placeholder="Select a machine" /> |
| 1665 | )} |
| 1666 | </SelectTrigger> |
| 1667 | </FormControl> |
| 1668 | <SelectContent> |
| 1669 | {loading ? ( |
| 1670 | <div className="flex items-center justify-center p-4"> |
| 1671 | <LoaderCircle className="h-4 w-4 animate-spin" /> |
| 1672 | <span className="ml-2">Loading...</span> |
| 1673 | </div> |
| 1674 | ) : error ? ( |
| 1675 | <div className="flex flex-col items-center justify-center p-4 text-destructive"> |
| 1676 | <span className="text-sm">Failed to load machines</span> |
| 1677 | <Button |
| 1678 | variant="ghost" |
| 1679 | size="sm" |
| 1680 | className="mt-2" |
| 1681 | onClick={fetchMachines} |
| 1682 | > |
| 1683 | Retry |
| 1684 | </Button> |
| 1685 | </div> |
| 1686 | ) : machines.length === 0 ? ( |
| 1687 | <div className="flex items-center justify-center p-4 text-muted-foreground"> |
| 1688 | <span className="text-sm">No machines available</span> |
| 1689 | </div> |
| 1690 | ) : ( |
| 1691 | machines.map((machine: Machine) => ( |
| 1692 | <SelectItem key={machine.name} value={machine.name}> |
| 1693 | {machine.name} |
| 1694 | </SelectItem> |
| 1695 | )) |
| 1696 | )} |
| 1697 | </SelectContent> |
| 1698 | </Select> |
| 1699 | <FormMessage /> |
| 1700 | </FormItem> |
| 1701 | )} |
| 1702 | /> |
| 1703 | </form> |
| 1704 | </Form> |
| 1705 | </div> |
| 1706 | ); |
| 1707 | } |
| 1708 | |
| 1709 | function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| 1710 | const { id, data } = node; |
| 1711 | const store = useStateStore(); |
| 1712 | const devForm = useForm<z.infer<typeof devSchema>>({ |
| 1713 | resolver: zodResolver(devSchema), |
| 1714 | mode: "onChange", |
| 1715 | defaultValues: { |
| 1716 | enabled: data.dev ? data.dev.enabled : false, |
| 1717 | mode: data.dev?.enabled ? data.dev.mode : undefined, |
| 1718 | }, |
| 1719 | }); |
| 1720 | useEffect(() => { |
| 1721 | const sub = devForm.watch((value, { name }) => { |
| 1722 | console.log("DDDEVV", name, value, data.dev); |
| 1723 | if (name === "enabled") { |
| 1724 | if (value.enabled) { |
| 1725 | if (data.dev?.enabled && data.dev.mode === "VM") { |
| 1726 | return; |
| 1727 | } |
| 1728 | store.updateNodeData<"app">(id, { |
| 1729 | dev: { |
| 1730 | enabled: true, |
| 1731 | mode: "VM", |
| 1732 | }, |
| 1733 | }); |
| 1734 | devForm.setValue("mode", "VM"); |
| 1735 | } else { |
| 1736 | store.updateNodeData<"app">(id, { |
| 1737 | dev: { |
| 1738 | enabled: false, |
| 1739 | }, |
| 1740 | }); |
| 1741 | } |
| 1742 | } else if (name === "mode") { |
| 1743 | if (data.dev?.enabled && data.dev.mode === value.mode) { |
| 1744 | return; |
| 1745 | } |
| 1746 | store.updateNodeData<"app">(id, { |
| 1747 | dev: { |
| 1748 | enabled: true, |
| 1749 | mode: value.mode, |
| 1750 | }, |
| 1751 | }); |
| 1752 | } |
| 1753 | }); |
| 1754 | return () => sub.unsubscribe(); |
| 1755 | }, [id, data, devForm, store]); |
| 1756 | return ( |
| 1757 | <> |
| 1758 | <Form {...devForm}> |
| 1759 | <form className="space-y-2"> |
| 1760 | <FormField |
| 1761 | control={devForm.control} |
| 1762 | name="enabled" |
| 1763 | render={({ field }) => ( |
| 1764 | <FormItem> |
| 1765 | <div className="flex flex-row gap-1 items-center"> |
| 1766 | <Switch |
| 1767 | id="devEnabled" |
| 1768 | onCheckedChange={field.onChange} |
| 1769 | checked={field.value} |
| 1770 | disabled={disabled} |
| 1771 | /> |
| 1772 | <Label htmlFor="devEnabled">Development Mode</Label> |
| 1773 | </div> |
| 1774 | <FormMessage /> |
| 1775 | </FormItem> |
| 1776 | )} |
| 1777 | /> |
| 1778 | {data.dev?.enabled && ( |
| 1779 | <FormField |
| 1780 | control={devForm.control} |
| 1781 | name="mode" |
| 1782 | render={({ field }) => ( |
| 1783 | <FormItem> |
| 1784 | <div className="flex flex-row gap-1 items-center"> |
| 1785 | <RadioGroup |
| 1786 | onValueChange={field.onChange} |
| 1787 | value={field.value} |
| 1788 | disabled={disabled} |
| 1789 | > |
| 1790 | <div className="flex items-center space-x-2"> |
| 1791 | <RadioGroupItem value="VM" id="vm" /> |
| 1792 | <Label htmlFor="vm">Create a VM</Label> |
| 1793 | </div> |
| 1794 | <div className="flex items-center space-x-2"> |
| 1795 | <RadioGroupItem value="PROXY" id="proxy" /> |
| 1796 | <Label htmlFor="proxy">Proxy to existing machine</Label> |
| 1797 | </div> |
| 1798 | </RadioGroup> |
| 1799 | </div> |
| 1800 | </FormItem> |
| 1801 | )} |
| 1802 | /> |
| 1803 | )} |
| 1804 | </form> |
| 1805 | </Form> |
| 1806 | <DevVM node={node} disabled={disabled} /> |
| 1807 | <DevProxy node={node} disabled={disabled} /> |
| gio | d002661 | 2025-05-08 13:00:36 +0000 | [diff] [blame] | 1808 | </> |
| 1809 | ); |
| 1810 | } |
| gio | 3d0bf03 | 2025-06-05 06:57:26 +0000 | [diff] [blame] | 1811 | |
| 1812 | function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode { |
| 1813 | const { id, data } = node; |
| 1814 | const store = useStateStore(); |
| 1815 | const nodes = useNodes<AppNode>(); |
| 1816 | const repo = useMemo(() => { |
| 1817 | return nodes |
| 1818 | .filter((n): n is GithubNode => n.type === "github") |
| 1819 | .find((n) => n.id === data.repository?.repoNodeId); |
| 1820 | }, [nodes, data.repository?.repoNodeId]); |
| 1821 | const repos = useGithubRepositories(); |
| 1822 | const sourceForm = useForm<z.infer<typeof sourceSchema>>({ |
| 1823 | resolver: zodResolver(sourceSchema), |
| 1824 | mode: "onChange", |
| 1825 | defaultValues: { |
| 1826 | id: data?.repository?.id?.toString(), |
| 1827 | branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined, |
| 1828 | rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined, |
| 1829 | }, |
| 1830 | }); |
| 1831 | useEffect(() => { |
| 1832 | const sub = sourceForm.watch( |
| 1833 | ( |
| 1834 | value: DeepPartial<z.infer<typeof sourceSchema>>, |
| 1835 | { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined }, |
| 1836 | ) => { |
| 1837 | if (name === "id") { |
| 1838 | const newRepoId = value.id ? parseInt(value.id, 10) : undefined; |
| 1839 | if (!newRepoId) return; |
| 1840 | |
| 1841 | const oldGithubNodeId = data.repository?.repoNodeId; |
| 1842 | const selectedRepo = repos.find((r) => r.id === newRepoId); |
| 1843 | |
| 1844 | if (!selectedRepo) return; |
| 1845 | |
| 1846 | // If a node for the selected repo already exists, connect to it. |
| 1847 | const existingNodeForSelectedRepo = nodes |
| 1848 | .filter((n): n is GithubNode => n.type === "github") |
| 1849 | .find((n) => n.data.repository?.id === selectedRepo.id); |
| 1850 | |
| 1851 | if (existingNodeForSelectedRepo) { |
| 1852 | let { nodes, edges } = store; |
| 1853 | if (oldGithubNodeId) { |
| 1854 | edges = edges.filter( |
| 1855 | (e) => |
| 1856 | !( |
| 1857 | e.target === id && |
| 1858 | e.source === oldGithubNodeId && |
| 1859 | e.targetHandle === "repository" |
| 1860 | ), |
| 1861 | ); |
| 1862 | } |
| 1863 | edges = edges.concat({ |
| 1864 | id: uuidv4(), |
| 1865 | source: existingNodeForSelectedRepo.id, |
| 1866 | sourceHandle: "repository", |
| 1867 | target: id, |
| 1868 | targetHandle: "repository", |
| 1869 | }); |
| 1870 | nodes = nodes.map((n) => { |
| 1871 | if (n.id !== id) { |
| 1872 | return n; |
| 1873 | } else { |
| 1874 | const sn = n as ServiceNode; |
| 1875 | return { |
| 1876 | ...sn, |
| 1877 | data: { |
| 1878 | ...sn.data, |
| 1879 | repository: { |
| 1880 | ...sn.data.repository, |
| 1881 | id: newRepoId, |
| 1882 | repoNodeId: existingNodeForSelectedRepo.id, |
| 1883 | }, |
| 1884 | }, |
| 1885 | }; |
| 1886 | } |
| 1887 | }); |
| 1888 | if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) { |
| 1889 | const isOldNodeStillUsed = edges.some( |
| 1890 | (e) => e.source === oldGithubNodeId && e.sourceHandle === "repository", |
| 1891 | ); |
| 1892 | if (!isOldNodeStillUsed) { |
| 1893 | nodes = nodes.filter((n) => n.id !== oldGithubNodeId); |
| 1894 | } |
| 1895 | } |
| 1896 | store.setNodes(nodes); |
| 1897 | store.setEdges(edges); |
| 1898 | return; |
| 1899 | } |
| 1900 | |
| 1901 | // No node for selected repo, decide whether to update old node or create a new one. |
| 1902 | if (oldGithubNodeId) { |
| 1903 | const isOldNodeShared = |
| 1904 | store.edges.filter( |
| 1905 | (e) => |
| 1906 | e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository", |
| 1907 | ).length > 0; |
| 1908 | |
| 1909 | if (!isOldNodeShared) { |
| 1910 | // Update old node |
| 1911 | store.updateNodeData<"github">(oldGithubNodeId, { |
| 1912 | repository: { |
| 1913 | id: selectedRepo.id, |
| 1914 | sshURL: selectedRepo.ssh_url, |
| 1915 | fullName: selectedRepo.full_name, |
| 1916 | }, |
| 1917 | label: selectedRepo.full_name, |
| 1918 | }); |
| 1919 | store.updateNodeData<"app">(id, { |
| 1920 | repository: { |
| 1921 | ...data.repository, |
| 1922 | id: newRepoId, |
| 1923 | }, |
| 1924 | }); |
| 1925 | } else { |
| 1926 | // Create new node because old one is shared |
| 1927 | const newGithubNodeId = uuidv4(); |
| 1928 | store.addNode({ |
| 1929 | id: newGithubNodeId, |
| 1930 | type: "github", |
| 1931 | data: { |
| 1932 | repository: { |
| 1933 | id: selectedRepo.id, |
| 1934 | sshURL: selectedRepo.ssh_url, |
| 1935 | fullName: selectedRepo.full_name, |
| 1936 | }, |
| 1937 | label: selectedRepo.full_name, |
| 1938 | envVars: [], |
| 1939 | ports: [], |
| 1940 | }, |
| 1941 | }); |
| 1942 | |
| 1943 | let edges = store.edges; |
| 1944 | // remove old edge |
| 1945 | edges = edges.filter( |
| 1946 | (e) => |
| 1947 | !( |
| 1948 | e.target === id && |
| 1949 | e.source === oldGithubNodeId && |
| 1950 | e.targetHandle === "repository" |
| 1951 | ), |
| 1952 | ); |
| 1953 | // add new edge |
| 1954 | edges = edges.concat({ |
| 1955 | id: uuidv4(), |
| 1956 | source: newGithubNodeId, |
| 1957 | sourceHandle: "repository", |
| 1958 | target: id, |
| 1959 | targetHandle: "repository", |
| 1960 | }); |
| 1961 | store.setEdges(edges); |
| 1962 | store.updateNodeData<"app">(id, { |
| 1963 | repository: { |
| 1964 | ...data.repository, |
| 1965 | id: newRepoId, |
| 1966 | repoNodeId: newGithubNodeId, |
| 1967 | }, |
| 1968 | }); |
| 1969 | } |
| 1970 | } else { |
| 1971 | // No old github node, so create a new one |
| 1972 | const newGithubNodeId = uuidv4(); |
| 1973 | store.addNode({ |
| 1974 | id: newGithubNodeId, |
| 1975 | type: "github", |
| 1976 | data: { |
| 1977 | repository: { |
| 1978 | id: selectedRepo.id, |
| 1979 | sshURL: selectedRepo.ssh_url, |
| 1980 | fullName: selectedRepo.full_name, |
| 1981 | }, |
| 1982 | label: selectedRepo.full_name, |
| 1983 | envVars: [], |
| 1984 | ports: [], |
| 1985 | }, |
| 1986 | }); |
| 1987 | store.setEdges( |
| 1988 | store.edges.concat({ |
| 1989 | id: uuidv4(), |
| 1990 | source: newGithubNodeId, |
| 1991 | sourceHandle: "repository", |
| 1992 | target: id, |
| 1993 | targetHandle: "repository", |
| 1994 | }), |
| 1995 | ); |
| 1996 | store.updateNodeData<"app">(id, { |
| 1997 | repository: { |
| 1998 | ...data.repository, |
| 1999 | id: newRepoId, |
| 2000 | repoNodeId: newGithubNodeId, |
| 2001 | }, |
| 2002 | }); |
| 2003 | } |
| 2004 | } else if (name === "branch") { |
| 2005 | store.updateNodeData<"app">(id, { |
| 2006 | repository: { |
| 2007 | ...data?.repository, |
| 2008 | branch: value.branch, |
| 2009 | }, |
| 2010 | }); |
| 2011 | } else if (name === "rootDir") { |
| 2012 | store.updateNodeData<"app">(id, { |
| 2013 | repository: { |
| 2014 | ...data?.repository, |
| 2015 | rootDir: value.rootDir, |
| 2016 | }, |
| 2017 | }); |
| 2018 | } |
| 2019 | }, |
| 2020 | ); |
| 2021 | return () => sub.unsubscribe(); |
| 2022 | }, [id, data, sourceForm, store, nodes, repos]); |
| 2023 | const [isExpanded, setIsExpanded] = useState(false); |
| 2024 | // useEffect(() => { |
| 2025 | // if (data.repository === undefined) { |
| 2026 | // setIsExpanded(true); |
| 2027 | // } |
| 2028 | // }, [data.repository, setIsExpanded]); |
| 2029 | console.log(data.repository, isExpanded, repo); |
| 2030 | return ( |
| 2031 | <Accordion type="single" collapsible> |
| 2032 | <AccordionItem value="repository" className="border-none"> |
| 2033 | <AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}> |
| 2034 | Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName} |
| 2035 | </AccordionTrigger> |
| 2036 | <AccordionContent className="px-1"> |
| 2037 | <Form {...sourceForm}> |
| 2038 | <form className="space-y-2"> |
| 2039 | <Label>Repository</Label> |
| 2040 | <FormField |
| 2041 | control={sourceForm.control} |
| 2042 | name="id" |
| 2043 | render={({ field }) => ( |
| 2044 | <FormItem> |
| 2045 | <Select onValueChange={field.onChange} value={field.value} disabled={disabled}> |
| 2046 | <FormControl> |
| 2047 | <SelectTrigger> |
| 2048 | <SelectValue /> |
| 2049 | </SelectTrigger> |
| 2050 | </FormControl> |
| 2051 | <SelectContent> |
| 2052 | {repos.map((r) => ( |
| 2053 | <SelectItem |
| 2054 | key={r.id} |
| 2055 | value={r.id.toString()} |
| 2056 | >{`${r.full_name}`}</SelectItem> |
| 2057 | ))} |
| 2058 | </SelectContent> |
| 2059 | </Select> |
| 2060 | <FormMessage /> |
| 2061 | </FormItem> |
| 2062 | )} |
| 2063 | /> |
| 2064 | <Label>Branch</Label> |
| 2065 | <FormField |
| 2066 | control={sourceForm.control} |
| 2067 | name="branch" |
| 2068 | render={({ field }) => ( |
| 2069 | <FormItem> |
| 2070 | <FormControl> |
| 2071 | <Input |
| 2072 | placeholder="master" |
| 2073 | className="lowercase" |
| 2074 | {...field} |
| 2075 | disabled={disabled} |
| 2076 | /> |
| 2077 | </FormControl> |
| 2078 | <FormMessage /> |
| 2079 | </FormItem> |
| 2080 | )} |
| 2081 | /> |
| 2082 | <Label>Root Directory</Label> |
| 2083 | <FormField |
| 2084 | control={sourceForm.control} |
| 2085 | name="rootDir" |
| 2086 | render={({ field }) => ( |
| 2087 | <FormItem> |
| 2088 | <FormControl> |
| 2089 | <Input placeholder="/" {...field} disabled={disabled} /> |
| 2090 | </FormControl> |
| 2091 | <FormMessage /> |
| 2092 | </FormItem> |
| 2093 | )} |
| 2094 | /> |
| 2095 | </form> |
| 2096 | </Form> |
| 2097 | </AccordionContent> |
| 2098 | </AccordionItem> |
| 2099 | </Accordion> |
| 2100 | ); |
| 2101 | } |