blob: 55dced453cf295862f2c0e24f2c30b676e43f150 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { v4 as uuidv4 } from "uuid";
giod0026612025-05-08 13:00:36 +00002import { NodeRect } from "./node-rect";
gioc31bf142025-06-16 07:48:20 +00003import { useStateStore, nodeLabel, AppState, nodeIsConnectable, useEnv, useGithubRepositories } from "@/lib/state";
gio69148322025-06-19 23:16:12 +04004import { ServiceNode, ServiceTypes, GatewayHttpsNode, GatewayTCPNode, BoundEnvVar, AppNode, GithubNode } from "config";
giod0026612025-05-08 13:00:36 +00005import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
gio5f2f1002025-03-20 18:38:48 +04006import { z } from "zod";
gio3d0bf032025-06-05 06:57:26 +00007import { useForm, EventType, DeepPartial } from "react-hook-form";
giod0026612025-05-08 13:00:36 +00008import { zodResolver } from "@hookform/resolvers/zod";
gio69ff7592025-07-03 06:27:21 +00009import { Form, FormControl, FormField, FormItem, FormMessage, FormLabel } from "./ui/form";
giod0026612025-05-08 13:00:36 +000010import { Button } from "./ui/button";
gio33990c62025-05-06 07:51:24 +000011import { Handle, Position, useNodes } from "@xyflow/react";
gio5f2f1002025-03-20 18:38:48 +040012import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
gio5f2f1002025-03-20 18:38:48 +040013import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
gio91165612025-05-03 17:07:38 +000014import { Textarea } from "./ui/textarea";
giofcefd7c2025-05-13 08:01:07 +000015import { Input } from "./ui/input";
gio3d0bf032025-06-05 06:57:26 +000016import { Switch } from "./ui/switch";
gio48fde052025-05-14 09:48:08 +000017import { Label } from "./ui/label";
gio3d0bf032025-06-05 06:57:26 +000018import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
19import { Code, Container, Network, Pencil, Variable } from "lucide-react";
gio3d0bf032025-06-05 06:57:26 +000020import { Badge } from "./ui/badge";
21import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "./ui/accordion";
gio3fb133d2025-06-13 07:20:24 +000022import { Name } from "./node-name";
23import { NodeDetailsProps } from "@/lib/types";
gio5f2f1002025-03-20 18:38:48 +040024
25export function NodeApp(node: ServiceNode) {
giod0026612025-05-08 13:00:36 +000026 const { id, selected } = node;
27 const isConnectablePorts = useMemo(() => nodeIsConnectable(node, "ports"), [node]);
28 const isConnectableRepository = useMemo(() => nodeIsConnectable(node, "repository"), [node]);
29 return (
gio69148322025-06-19 23:16:12 +040030 <NodeRect id={id} selected={selected} node={node} state={node.data.state}>
giod0026612025-05-08 13:00:36 +000031 <div style={{ padding: "10px 20px" }}>
32 {nodeLabel(node)}
33 <Handle
34 id="repository"
35 type={"target"}
36 position={Position.Left}
37 isConnectableStart={isConnectableRepository}
38 isConnectableEnd={isConnectableRepository}
39 isConnectable={isConnectableRepository}
40 />
41 <Handle
42 id="ports"
43 type={"source"}
44 position={Position.Top}
45 isConnectableStart={isConnectablePorts}
46 isConnectableEnd={isConnectablePorts}
47 isConnectable={isConnectablePorts}
48 />
49 <Handle
50 id="env_var"
51 type={"target"}
52 position={Position.Bottom}
53 isConnectableStart={true}
54 isConnectableEnd={true}
55 isConnectable={true}
56 />
57 </div>
58 </NodeRect>
59 );
gio5f2f1002025-03-20 18:38:48 +040060}
61
62const schema = z.object({
giod0026612025-05-08 13:00:36 +000063 name: z.string().min(1, "requried"),
64 type: z.enum(ServiceTypes),
gio5f2f1002025-03-20 18:38:48 +040065});
66
gio33990c62025-05-06 07:51:24 +000067const sourceSchema = z.object({
giod0026612025-05-08 13:00:36 +000068 id: z.string().min(1, "required"),
69 branch: z.string(),
70 rootDir: z.string(),
gio33990c62025-05-06 07:51:24 +000071});
72
gio48fde052025-05-14 09:48:08 +000073const devSchema = z.object({
74 enabled: z.boolean(),
75});
76
77const exposeSchema = z.object({
78 network: z.string().min(1, "reqired"),
79 subdomain: z.string().min(1, "required"),
80});
81
gio69148322025-06-19 23:16:12 +040082const agentSchema = z.object({
gio69ff7592025-07-03 06:27:21 +000083 model: z.enum(["gemini", "claude"]),
84 apiKey: z.string().optional(),
gio69148322025-06-19 23:16:12 +040085});
86
gioe7734b22025-06-13 10:12:04 +000087export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
gio3d0bf032025-06-05 06:57:26 +000088 const { data } = node;
89 return (
90 <>
gio3fb133d2025-06-13 07:20:24 +000091 {showName ? <Name node={node} disabled={disabled} /> : null}
gio3d0bf032025-06-05 06:57:26 +000092 <Tabs defaultValue="runtime">
93 <TabsList className="w-full flex flex-row justify-between">
94 <TabsTrigger value="runtime">
gioe7734b22025-06-13 10:12:04 +000095 {isOverview ? (
96 <div className="flex flex-row gap-1 items-center">
97 <Container /> Runtime
98 </div>
99 ) : (
100 <TooltipProvider>
101 <Tooltip>
102 <TooltipTrigger>
103 <Container />
104 </TooltipTrigger>
105 <TooltipContent>Runtime</TooltipContent>
106 </Tooltip>
107 </TooltipProvider>
108 )}
gio3d0bf032025-06-05 06:57:26 +0000109 </TabsTrigger>
110 <TabsTrigger value="ports">
gioe7734b22025-06-13 10:12:04 +0000111 {isOverview ? (
112 <div className="flex flex-row gap-1 items-center">
113 <Network /> Ports
114 <Badge className="rounded-full">{data.ports?.length ?? 0}</Badge>
115 </div>
116 ) : (
117 <TooltipProvider>
118 <Tooltip>
119 <TooltipTrigger className="flex flex-row gap-1 items-center">
120 <Network />
121 </TooltipTrigger>
122 <TooltipContent>
123 Ports{" "}
124 <Badge variant="secondary" className="rounded-full">
125 {data.ports?.length ?? 0}
126 </Badge>
127 </TooltipContent>
128 </Tooltip>
129 </TooltipProvider>
130 )}
gio3d0bf032025-06-05 06:57:26 +0000131 </TabsTrigger>
132 <TabsTrigger value="vars">
gioe7734b22025-06-13 10:12:04 +0000133 {isOverview ? (
134 <div className="flex flex-row gap-1 items-center">
135 <Variable /> Variables
136 <Badge className="rounded-full">{data.envVars?.length ?? 0}</Badge>
137 </div>
138 ) : (
139 <TooltipProvider>
140 <Tooltip>
141 <TooltipTrigger className="flex flex-row gap-1 items-center">
142 <Variable />
143 </TooltipTrigger>
144 <TooltipContent>
145 Variables{" "}
146 <Badge variant="secondary" className="rounded-full">
147 {data.envVars?.length ?? 0}
148 </Badge>
149 </TooltipContent>
150 </Tooltip>
151 </TooltipProvider>
152 )}
gio3d0bf032025-06-05 06:57:26 +0000153 </TabsTrigger>
gio69148322025-06-19 23:16:12 +0400154 {node.data.type !== "sketch:latest" && (
155 <TabsTrigger value="dev">
156 {isOverview ? (
157 <div className="flex flex-row gap-1 items-center">
158 <Code /> Dev
159 </div>
160 ) : (
161 <TooltipProvider>
162 <Tooltip>
163 <TooltipTrigger className="flex flex-row gap-1 items-center">
164 <Code />
165 </TooltipTrigger>
166 <TooltipContent>Dev</TooltipContent>
167 </Tooltip>
168 </TooltipProvider>
169 )}
170 </TabsTrigger>
171 )}
gio3d0bf032025-06-05 06:57:26 +0000172 </TabsList>
173 <TabsContent value="runtime">
174 <Runtime node={node} disabled={disabled} />
175 </TabsContent>
176 <TabsContent value="ports">
177 <Ports node={node} disabled={disabled} />
178 </TabsContent>
179 <TabsContent value="vars">
180 <EnvVars node={node} disabled={disabled} />
181 </TabsContent>
gio69148322025-06-19 23:16:12 +0400182 {node.data.type !== "sketch:latest" && (
183 <TabsContent value="dev">
184 <Dev node={node} disabled={disabled} />
185 </TabsContent>
186 )}
gio3d0bf032025-06-05 06:57:26 +0000187 </Tabs>
188 </>
189 );
190}
191
gio3d0bf032025-06-05 06:57:26 +0000192function Runtime({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
193 const { id, data } = node;
194 const store = useStateStore();
giod0026612025-05-08 13:00:36 +0000195 const form = useForm<z.infer<typeof schema>>({
196 resolver: zodResolver(schema),
197 mode: "onChange",
198 defaultValues: {
199 name: data.label,
200 type: data.type,
201 },
202 });
giod0026612025-05-08 13:00:36 +0000203 useEffect(() => {
204 const sub = form.watch(
205 (
206 value: DeepPartial<z.infer<typeof schema>>,
207 { name, type }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
208 ) => {
giod0026612025-05-08 13:00:36 +0000209 if (type !== "change") {
210 return;
211 }
212 switch (name) {
213 case "name":
214 if (!value.name) {
215 break;
216 }
217 store.updateNodeData<"app">(id, {
218 label: value.name,
219 });
220 break;
221 case "type":
222 if (!value.type) {
223 break;
224 }
225 store.updateNodeData<"app">(id, {
226 type: value.type,
227 });
228 break;
229 }
230 },
231 );
232 return () => sub.unsubscribe();
233 }, [id, form, store]);
giod0026612025-05-08 13:00:36 +0000234 const [typeProps, setTypeProps] = useState({});
235 useEffect(() => {
236 if (data.activeField === "type") {
237 setTypeProps({
238 open: true,
239 onOpenChange: () => store.updateNodeData(id, { activeField: undefined }),
240 });
241 } else {
242 setTypeProps({});
243 }
244 }, [id, data, store, setTypeProps]);
gio3d0bf032025-06-05 06:57:26 +0000245 const setPreBuildCommands = useCallback(
246 (e: React.ChangeEvent<HTMLTextAreaElement>) => {
247 store.updateNodeData<"app">(id, {
248 preBuildCommands: e.currentTarget.value,
giod0026612025-05-08 13:00:36 +0000249 });
250 },
gio3d0bf032025-06-05 06:57:26 +0000251 [id, store],
giod0026612025-05-08 13:00:36 +0000252 );
gio69148322025-06-19 23:16:12 +0400253 const agentForm = useForm<z.infer<typeof agentSchema>>({
254 resolver: zodResolver(agentSchema),
255 mode: "onChange",
256 defaultValues: {
gio69ff7592025-07-03 06:27:21 +0000257 apiKey: data.model?.apiKey,
258 model: data.model?.name,
gio69148322025-06-19 23:16:12 +0400259 },
260 });
261 useEffect(() => {
gio69ff7592025-07-03 06:27:21 +0000262 const sub = agentForm.watch((value, { name }: { name?: keyof z.infer<typeof agentSchema> | undefined }) => {
263 switch (name) {
264 case "model":
265 agentForm.setValue("apiKey", "", { shouldDirty: true });
266 store.updateNodeData<"app">(id, {
267 model: {
268 name: value.model,
269 apiKey: undefined,
270 },
271 });
272 break;
273 case "apiKey":
274 store.updateNodeData<"app">(id, {
275 model: {
276 name: data.model?.name,
277 apiKey: value.apiKey,
278 },
279 });
280 break;
281 }
gio69148322025-06-19 23:16:12 +0400282 });
283 return () => sub.unsubscribe();
gio69ff7592025-07-03 06:27:21 +0000284 }, [id, agentForm, store, data]);
gio3d0bf032025-06-05 06:57:26 +0000285 return (
286 <>
287 <SourceRepo node={node} disabled={disabled} />
gio69148322025-06-19 23:16:12 +0400288 {node.data.type !== "sketch:latest" && (
289 <Form {...form}>
290 <form className="space-y-2">
291 <Label>Container Image</Label>
292 <FormField
293 control={form.control}
294 name="type"
295 render={({ field }) => (
296 <FormItem>
297 <Select
298 onValueChange={field.onChange}
299 value={field.value || ""}
300 {...typeProps}
301 disabled={disabled}
302 >
303 <FormControl>
304 <SelectTrigger>
305 <SelectValue />
306 </SelectTrigger>
307 </FormControl>
308 <SelectContent>
309 {ServiceTypes.filter((t) => t !== "sketch:latest").map((t) => (
310 <SelectItem key={t} value={t}>
311 {t}
312 </SelectItem>
313 ))}
314 </SelectContent>
315 </Select>
316 <FormMessage />
317 </FormItem>
318 )}
319 />
320 </form>
321 </Form>
322 )}
323 {node.data.type === "sketch:latest" && (
324 <Form {...agentForm}>
325 <form className="space-y-2">
gio69148322025-06-19 23:16:12 +0400326 <FormField
327 control={agentForm.control}
gio69ff7592025-07-03 06:27:21 +0000328 name="model"
329 render={({ field }) => (
330 <FormItem>
331 <FormLabel>AI Model</FormLabel>
332 <Select
333 onValueChange={field.onChange}
334 defaultValue={field.value}
335 disabled={disabled}
336 >
337 <FormControl>
338 <SelectTrigger>
339 <SelectValue placeholder="Select a model" />
340 </SelectTrigger>
341 </FormControl>
342 <SelectContent>
343 <SelectItem value="gemini">Gemini</SelectItem>
344 <SelectItem value="claude">Claude</SelectItem>
345 </SelectContent>
346 </Select>
347 <FormMessage />
348 </FormItem>
349 )}
350 />
351 <Label>API Key</Label>
352 <FormField
353 control={agentForm.control}
354 name="apiKey"
gio69148322025-06-19 23:16:12 +0400355 render={({ field }) => (
356 <FormItem>
gio3d0bf032025-06-05 06:57:26 +0000357 <FormControl>
gio69148322025-06-19 23:16:12 +0400358 <Input
359 type="password"
gio69ff7592025-07-03 06:27:21 +0000360 placeholder="Override AI Model API key"
gio69148322025-06-19 23:16:12 +0400361 {...field}
362 value={field.value || ""}
363 disabled={disabled}
364 />
gio3d0bf032025-06-05 06:57:26 +0000365 </FormControl>
gio69148322025-06-19 23:16:12 +0400366 <FormMessage />
367 </FormItem>
368 )}
369 />
370 </form>
371 </Form>
372 )}
373 {node.data.type !== "sketch:latest" && (
374 <>
375 <Label>Pre-Build Commands</Label>
376 <Textarea
377 placeholder="new line separated list of commands to run before running the service"
378 value={data.preBuildCommands}
379 onChange={setPreBuildCommands}
380 disabled={disabled}
gio3d0bf032025-06-05 06:57:26 +0000381 />
gio69148322025-06-19 23:16:12 +0400382 </>
383 )}
gio3d0bf032025-06-05 06:57:26 +0000384 </>
giod0026612025-05-08 13:00:36 +0000385 );
gio3d0bf032025-06-05 06:57:26 +0000386}
387
388function Ports({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
389 const { id, data } = node;
390 const store = useStateStore();
391 const [name, setName] = useState("");
392 const [value, setValue] = useState("");
393 const onSubmit = useCallback(() => {
394 const portId = uuidv4();
395 store.updateNodeData<"app">(id, {
396 ports: (data.ports || []).concat({
397 id: portId,
398 name: name.toUpperCase(),
399 value: Number(value),
400 }),
gio73ac16c2025-07-03 14:38:04 +0000401 envVars: (data.envVars || []).concat(
402 {
403 id: uuidv4(),
404 source: null,
405 portId,
406 name: `DODO_PORT_${name.toUpperCase()}`,
407 },
408 {
409 id: uuidv4(),
410 source: null,
411 portId,
412 name: `DODO_PORT_${name.toUpperCase()}`,
413 alias: name.toUpperCase(),
414 },
415 ),
gio3d0bf032025-06-05 06:57:26 +0000416 });
417 setName("");
418 setValue("");
419 }, [id, data, store, name, value, setName, setValue]);
giod0026612025-05-08 13:00:36 +0000420 const removePort = useCallback(
421 (portId: string) => {
422 // TODO(gio): this is ugly
423 const tcpRemoved = new Set<string>();
giod0026612025-05-08 13:00:36 +0000424 store.setEdges(
425 store.edges.filter((e) => {
426 if (e.source !== id || e.sourceHandle !== "ports") {
427 return true;
428 }
429 const tn = store.nodes.find((n) => n.id == e.target)!;
430 if (e.targetHandle === "https") {
431 const t = tn as GatewayHttpsNode;
432 if (t.data.https?.serviceId === id && t.data.https.portId === portId) {
433 return false;
434 }
435 }
436 if (e.targetHandle === "tcp") {
437 const t = tn as GatewayTCPNode;
438 if (tcpRemoved.has(t.id)) {
439 return true;
440 }
441 if (t.data.exposed.find((e) => e.serviceId === id && e.portId === portId)) {
442 tcpRemoved.add(t.id);
443 return false;
444 }
445 }
446 if (e.targetHandle === "env_var") {
447 if (
448 tn &&
449 (tn.data.envVars || []).find(
450 (ev) => ev.source === id && "portId" in ev && ev.portId === portId,
451 )
452 ) {
453 return false;
454 }
455 }
456 return true;
457 }),
458 );
459 store.nodes
460 .filter(
461 (n) =>
462 n.type === "gateway-https" &&
463 n.data.https &&
464 n.data.https.serviceId === id &&
465 n.data.https.portId === portId,
466 )
467 .forEach((n) => {
468 store.updateNodeData<"gateway-https">(n.id, {
469 https: undefined,
470 });
471 });
472 store.nodes
473 .filter((n) => n.type === "gateway-tcp")
474 .forEach((n) => {
475 const filtered = n.data.exposed.filter((e) => {
476 if (e.serviceId === id && e.portId === portId) {
477 return false;
478 } else {
479 return true;
480 }
481 });
482 if (filtered.length != n.data.exposed.length) {
483 store.updateNodeData<"gateway-tcp">(n.id, {
484 exposed: filtered,
485 });
486 }
487 });
488 store.nodes
489 .filter((n) => n.type === "app" && n.data.envVars)
490 .forEach((n) => {
491 store.updateNodeData<"app">(n.id, {
492 envVars: n.data.envVars.filter((ev) => {
493 if (ev.source === id && "portId" in ev && ev.portId === portId) {
494 return false;
495 }
496 return true;
497 }),
498 });
499 });
500 store.updateNodeData<"app">(id, {
501 ports: (data.ports || []).filter((p) => p.id !== portId),
502 envVars: (data.envVars || []).filter(
503 (ev) => !(ev.source === null && "portId" in ev && ev.portId === portId),
504 ),
505 });
506 },
507 [id, data, store],
508 );
gio3d0bf032025-06-05 06:57:26 +0000509 return (
510 <div className="flex flex-col gap-1">
511 <div className="grid grid-cols-[1fr_1fr_auto] gap-1">
512 {data &&
513 data.ports &&
514 data.ports.map((p) => (
515 <>
516 <div className="flex items-center px-3">{p.name.toUpperCase()}</div>
517 <div className="flex items-center px-3">{p.value}</div>
518 <div className="flex items-center">
519 <Button
520 variant="destructive"
521 className="w-full"
522 onClick={() => removePort(p.id)}
523 disabled={disabled}
524 >
525 Remove
526 </Button>
527 </div>
528 </>
529 ))}
530 <div>
531 <Input
532 placeholder="name"
533 className="uppercase w-0 min-w-full"
534 disabled={disabled}
535 value={name}
536 onChange={(e) => setName(e.target.value)}
537 />
538 </div>
539 <div>
540 <Input
541 placeholder="0"
542 className="w-0 min-w-full"
543 disabled={disabled}
544 value={value}
545 onChange={(e) => setValue(e.target.value)}
546 />
547 </div>
548 <div>
549 <Button type="submit" className="w-full" disabled={disabled} onClick={onSubmit}>
550 Add
551 </Button>
552 </div>
553 </div>
554 </div>
555 );
556}
557
558function EnvVars({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
559 const { id, data } = node;
560 const store = useStateStore();
561 const editAlias = useCallback(
562 (e: BoundEnvVar) => {
563 return () => {
gioff9b5522025-07-03 13:50:30 +0000564 if (disabled) {
565 return;
566 }
gio3d0bf032025-06-05 06:57:26 +0000567 store.updateNodeData(id, {
568 ...data,
569 envVars: data.envVars!.map((o) => {
570 if (o.id !== e.id) {
571 return o;
572 } else
573 return {
574 ...o,
575 isEditting: true,
576 };
577 }),
578 });
579 };
580 },
gioff9b5522025-07-03 13:50:30 +0000581 [id, data, store, disabled],
gio3d0bf032025-06-05 06:57:26 +0000582 );
583 const saveAlias = useCallback(
584 (e: BoundEnvVar, value: string, store: AppState) => {
585 store.updateNodeData(id, {
586 ...data,
587 envVars: data.envVars!.map((o) => {
588 if (o.id !== e.id) {
589 return o;
590 }
591 if (value) {
592 return {
593 ...o,
594 isEditting: false,
595 alias: value.toUpperCase(),
596 };
597 }
598 if ("alias" in o) {
599 const { alias: _, ...rest } = o;
600 return {
601 ...rest,
602 isEditting: false,
603 };
604 }
605 return {
606 ...o,
607 isEditting: false,
608 };
609 }),
giod0026612025-05-08 13:00:36 +0000610 });
611 },
gio3d0bf032025-06-05 06:57:26 +0000612 [id, data],
giod0026612025-05-08 13:00:36 +0000613 );
gio3d0bf032025-06-05 06:57:26 +0000614 const saveAliasOnEnter = useCallback(
615 (e: BoundEnvVar) => {
616 return (event: KeyboardEvent<HTMLInputElement>) => {
617 if (event.key === "Enter") {
618 event.preventDefault();
619 saveAlias(e, event.currentTarget.value, store);
giod0026612025-05-08 13:00:36 +0000620 }
gio3d0bf032025-06-05 06:57:26 +0000621 };
622 },
623 [store, saveAlias],
624 );
625 const saveAliasOnBlur = useCallback(
626 (e: BoundEnvVar) => {
627 return (event: FocusEvent<HTMLInputElement>) => {
628 saveAlias(e, event.currentTarget.value, store);
629 };
630 },
631 [store, saveAlias],
632 );
633 return (
634 <ul>
635 {data &&
636 data.envVars &&
637 data.envVars.map((v) => {
638 if ("name" in v) {
639 const value = "alias" in v ? v.alias : v.name;
640 if (v.isEditting) {
641 return (
642 <li key={v.id}>
643 <Input
644 type="text"
645 className="uppercase"
646 defaultValue={value}
647 onKeyUp={saveAliasOnEnter(v)}
648 onBlur={saveAliasOnBlur(v)}
649 autoFocus={true}
650 disabled={disabled}
651 />
652 </li>
653 );
654 }
655 return (
656 <li key={v.id} onClick={editAlias(v)}>
657 <TooltipProvider>
658 <Tooltip>
659 <TooltipTrigger className="w-full">
gioff9b5522025-07-03 13:50:30 +0000660 <div
661 className={`w-full flex flex-row items-center gap-1 ${disabled ? "" : "cursor-text"}`}
662 >
663 {!disabled && <Pencil className="w-4 h-4" />}
gio3d0bf032025-06-05 06:57:26 +0000664 <div className="uppercase">{value}</div>
665 </div>
666 </TooltipTrigger>
667 <TooltipContent>{v.name}</TooltipContent>
668 </Tooltip>
669 </TooltipProvider>
670 </li>
671 );
672 }
673 })}
674 </ul>
675 );
676}
677
678function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
679 const { id, data } = node;
680 const env = useEnv();
681 const store = useStateStore();
gio48fde052025-05-14 09:48:08 +0000682 const devForm = useForm<z.infer<typeof devSchema>>({
683 resolver: zodResolver(devSchema),
684 mode: "onChange",
685 defaultValues: {
686 enabled: data.dev ? data.dev.enabled : false,
687 },
688 });
689 useEffect(() => {
690 const sub = devForm.watch((value, { name }) => {
691 if (name === "enabled") {
692 if (value.enabled) {
693 const csGateway: Omit<GatewayHttpsNode, "position"> = {
694 id: uuidv4(),
695 type: "gateway-https",
696 data: {
697 readonly: true,
698 https: {
699 serviceId: id,
700 portId: `${id}-code-server`,
701 },
702 network: data.dev?.expose?.network,
703 subdomain: data.dev?.expose?.subdomain,
704 label: "",
705 envVars: [],
706 ports: [],
707 },
708 };
709 const sshGateway: Omit<GatewayTCPNode, "position"> = {
710 id: uuidv4(),
711 type: "gateway-tcp",
712 data: {
713 readonly: true,
714 exposed: [
715 {
716 serviceId: id,
717 portId: `${id}-ssh`,
718 },
719 ],
720 network: data.dev?.expose?.network,
721 subdomain: data.dev?.expose?.subdomain,
722 label: "",
723 envVars: [],
724 ports: [],
725 },
726 };
727 store.addNode(csGateway);
728 store.addNode(sshGateway);
729 store.updateNodeData<"app">(id, {
730 dev: {
731 enabled: true,
732 expose: data.dev?.expose,
733 codeServerNodeId: csGateway.id,
734 sshNodeId: sshGateway.id,
735 },
736 ports: (data.ports || []).concat(
737 {
738 id: `${id}-code-server`,
739 name: "code-server",
740 value: 9090,
741 },
742 {
743 id: `${id}-ssh`,
744 name: "ssh",
745 value: 22,
746 },
747 ),
748 });
749 let edges = store.edges.concat([
750 {
751 id: uuidv4(),
752 source: id,
753 sourceHandle: "ports",
754 target: csGateway.id,
755 targetHandle: "https",
756 },
757 {
758 id: uuidv4(),
759 source: id,
760 sourceHandle: "ports",
761 target: sshGateway.id,
762 targetHandle: "tcp",
763 },
764 ]);
765 if (data.dev?.expose?.network !== undefined) {
766 edges = edges.concat([
767 {
768 id: uuidv4(),
769 source: csGateway.id,
770 sourceHandle: "subdomain",
771 target: data.dev.expose.network,
772 targetHandle: "subdomain",
773 },
774 {
775 id: uuidv4(),
776 source: sshGateway.id,
777 sourceHandle: "subdomain",
778 target: data.dev.expose.network,
779 targetHandle: "subdomain",
780 },
781 ]);
782 }
783 store.setEdges(edges);
784 } else {
785 const { dev } = data;
786 if (dev?.enabled) {
787 store.setNodes(
788 store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
789 );
790 store.setEdges(
791 store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
792 );
793 }
794 store.updateNodeData<"app">(id, {
795 dev: {
796 enabled: false,
797 expose: dev?.expose,
798 },
799 ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
800 });
801 }
802 }
803 });
804 return () => sub.unsubscribe();
805 }, [id, data, devForm, store]);
806 const exposeForm = useForm<z.infer<typeof exposeSchema>>({
807 resolver: zodResolver(exposeSchema),
808 mode: "onChange",
809 defaultValues: {
810 network: data.dev?.expose?.network,
811 subdomain: data.dev?.expose?.subdomain,
812 },
813 });
814 useEffect(() => {
815 const sub = exposeForm.watch(
816 (
817 value: DeepPartial<z.infer<typeof exposeSchema>>,
818 { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
819 ) => {
820 const { dev } = data;
821 if (!dev?.enabled) {
822 return;
823 }
824 if (name === "network") {
825 let edges = store.edges;
826 if (dev.enabled && dev.expose?.network !== undefined) {
827 edges = edges.filter((e) => {
828 if (
829 (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
830 e.sourceHandle === "subdomain" &&
831 e.target === dev.expose?.network &&
832 e.targetHandle === "subdomain"
833 ) {
834 return false;
835 } else {
836 return true;
837 }
838 });
839 }
840 if (value.network !== undefined) {
841 edges = edges.concat(
842 {
843 id: uuidv4(),
844 source: dev.codeServerNodeId,
845 sourceHandle: "subdomain",
846 target: value.network,
847 targetHandle: "subdomain",
848 },
849 {
850 id: uuidv4(),
851 source: dev.sshNodeId,
852 sourceHandle: "subdomain",
853 target: value.network,
854 targetHandle: "subdomain",
855 },
856 );
857 }
858 store.setEdges(edges);
859 store.updateNodeData<"app">(id, {
860 dev: {
861 ...dev,
862 expose: {
863 network: value.network,
864 subdomain: dev.expose?.subdomain,
865 },
866 },
867 });
868 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
869 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
870 } else if (name === "subdomain") {
871 store.updateNodeData<"app">(id, {
872 dev: {
873 ...dev,
874 expose: {
875 network: dev.expose?.network,
876 subdomain: value.subdomain,
877 },
878 },
879 });
880 store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
881 store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
882 }
883 },
884 );
885 return () => sub.unsubscribe();
886 }, [id, data, exposeForm, store]);
giod0026612025-05-08 13:00:36 +0000887 return (
888 <>
gio48fde052025-05-14 09:48:08 +0000889 <Form {...devForm}>
890 <form className="space-y-2">
891 <FormField
892 control={devForm.control}
893 name="enabled"
894 render={({ field }) => (
895 <FormItem>
896 <div className="flex flex-row gap-1 items-center">
gio3d0bf032025-06-05 06:57:26 +0000897 <Switch
gio3ec94242025-05-16 12:46:57 +0000898 id="devEnabled"
899 onCheckedChange={field.onChange}
900 checked={field.value}
901 disabled={disabled}
902 />
gio3d0bf032025-06-05 06:57:26 +0000903 <Label htmlFor="devEnabled">Dev VM</Label>
gio48fde052025-05-14 09:48:08 +0000904 </div>
905 <FormMessage />
906 </FormItem>
907 )}
908 />
909 </form>
910 </Form>
gio29050d62025-05-16 04:49:26 +0000911 {data.dev && data.dev.enabled && (
912 <Form {...exposeForm}>
913 <form className="space-y-2">
gio3d0bf032025-06-05 06:57:26 +0000914 <Label>Network</Label>
gio29050d62025-05-16 04:49:26 +0000915 <FormField
916 control={exposeForm.control}
917 name="network"
918 render={({ field }) => (
919 <FormItem>
gio3ec94242025-05-16 12:46:57 +0000920 <Select
921 onValueChange={field.onChange}
gio3d0bf032025-06-05 06:57:26 +0000922 value={field.value || ""}
gio3ec94242025-05-16 12:46:57 +0000923 disabled={disabled}
924 >
gio29050d62025-05-16 04:49:26 +0000925 <FormControl>
926 <SelectTrigger>
gio3d0bf032025-06-05 06:57:26 +0000927 <SelectValue />
gio29050d62025-05-16 04:49:26 +0000928 </SelectTrigger>
929 </FormControl>
930 <SelectContent>
931 {env.networks.map((n) => (
932 <SelectItem
933 key={n.name}
934 value={n.domain}
935 >{`${n.name} - ${n.domain}`}</SelectItem>
936 ))}
937 </SelectContent>
938 </Select>
939 <FormMessage />
940 </FormItem>
941 )}
942 />
gio3d0bf032025-06-05 06:57:26 +0000943 <Label>Subdomain</Label>
gio29050d62025-05-16 04:49:26 +0000944 <FormField
945 control={exposeForm.control}
946 name="subdomain"
947 render={({ field }) => (
948 <FormItem>
gio48fde052025-05-14 09:48:08 +0000949 <FormControl>
gio3d0bf032025-06-05 06:57:26 +0000950 <Input {...field} disabled={disabled} />
gio48fde052025-05-14 09:48:08 +0000951 </FormControl>
gio29050d62025-05-16 04:49:26 +0000952 <FormMessage />
953 </FormItem>
954 )}
955 />
956 </form>
957 </Form>
958 )}
giod0026612025-05-08 13:00:36 +0000959 </>
960 );
961}
gio3d0bf032025-06-05 06:57:26 +0000962
963function SourceRepo({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
964 const { id, data } = node;
965 const store = useStateStore();
966 const nodes = useNodes<AppNode>();
967 const repo = useMemo(() => {
968 return nodes
969 .filter((n): n is GithubNode => n.type === "github")
970 .find((n) => n.id === data.repository?.repoNodeId);
971 }, [nodes, data.repository?.repoNodeId]);
972 const repos = useGithubRepositories();
973 const sourceForm = useForm<z.infer<typeof sourceSchema>>({
974 resolver: zodResolver(sourceSchema),
975 mode: "onChange",
976 defaultValues: {
977 id: data?.repository?.id?.toString(),
978 branch: data.repository && "branch" in data.repository ? data.repository.branch : undefined,
979 rootDir: data.repository && "rootDir" in data.repository ? data.repository.rootDir : undefined,
980 },
981 });
982 useEffect(() => {
983 const sub = sourceForm.watch(
984 (
985 value: DeepPartial<z.infer<typeof sourceSchema>>,
986 { name }: { name?: keyof z.infer<typeof sourceSchema> | undefined; type?: EventType | undefined },
987 ) => {
988 if (name === "id") {
989 const newRepoId = value.id ? parseInt(value.id, 10) : undefined;
990 if (!newRepoId) return;
991
992 const oldGithubNodeId = data.repository?.repoNodeId;
993 const selectedRepo = repos.find((r) => r.id === newRepoId);
994
995 if (!selectedRepo) return;
996
997 // If a node for the selected repo already exists, connect to it.
998 const existingNodeForSelectedRepo = nodes
999 .filter((n): n is GithubNode => n.type === "github")
1000 .find((n) => n.data.repository?.id === selectedRepo.id);
1001
1002 if (existingNodeForSelectedRepo) {
1003 let { nodes, edges } = store;
1004 if (oldGithubNodeId) {
1005 edges = edges.filter(
1006 (e) =>
1007 !(
1008 e.target === id &&
1009 e.source === oldGithubNodeId &&
1010 e.targetHandle === "repository"
1011 ),
1012 );
1013 }
1014 edges = edges.concat({
1015 id: uuidv4(),
1016 source: existingNodeForSelectedRepo.id,
1017 sourceHandle: "repository",
1018 target: id,
1019 targetHandle: "repository",
1020 });
1021 nodes = nodes.map((n) => {
1022 if (n.id !== id) {
1023 return n;
1024 } else {
1025 const sn = n as ServiceNode;
1026 return {
1027 ...sn,
1028 data: {
1029 ...sn.data,
1030 repository: {
1031 ...sn.data.repository,
1032 id: newRepoId,
1033 repoNodeId: existingNodeForSelectedRepo.id,
1034 },
1035 },
1036 };
1037 }
1038 });
1039 if (oldGithubNodeId && oldGithubNodeId !== existingNodeForSelectedRepo.id) {
1040 const isOldNodeStillUsed = edges.some(
1041 (e) => e.source === oldGithubNodeId && e.sourceHandle === "repository",
1042 );
1043 if (!isOldNodeStillUsed) {
1044 nodes = nodes.filter((n) => n.id !== oldGithubNodeId);
1045 }
1046 }
1047 store.setNodes(nodes);
1048 store.setEdges(edges);
1049 return;
1050 }
1051
1052 // No node for selected repo, decide whether to update old node or create a new one.
1053 if (oldGithubNodeId) {
1054 const isOldNodeShared =
1055 store.edges.filter(
1056 (e) =>
1057 e.source === oldGithubNodeId && e.target !== id && e.sourceHandle === "repository",
1058 ).length > 0;
1059
1060 if (!isOldNodeShared) {
1061 // Update old node
1062 store.updateNodeData<"github">(oldGithubNodeId, {
1063 repository: {
1064 id: selectedRepo.id,
1065 sshURL: selectedRepo.ssh_url,
1066 fullName: selectedRepo.full_name,
1067 },
1068 label: selectedRepo.full_name,
1069 });
1070 store.updateNodeData<"app">(id, {
1071 repository: {
1072 ...data.repository,
1073 id: newRepoId,
1074 },
1075 });
1076 } else {
1077 // Create new node because old one is shared
1078 const newGithubNodeId = uuidv4();
1079 store.addNode({
1080 id: newGithubNodeId,
1081 type: "github",
1082 data: {
1083 repository: {
1084 id: selectedRepo.id,
1085 sshURL: selectedRepo.ssh_url,
1086 fullName: selectedRepo.full_name,
1087 },
1088 label: selectedRepo.full_name,
1089 envVars: [],
1090 ports: [],
1091 },
1092 });
1093
1094 let edges = store.edges;
1095 // remove old edge
1096 edges = edges.filter(
1097 (e) =>
1098 !(
1099 e.target === id &&
1100 e.source === oldGithubNodeId &&
1101 e.targetHandle === "repository"
1102 ),
1103 );
1104 // add new edge
1105 edges = edges.concat({
1106 id: uuidv4(),
1107 source: newGithubNodeId,
1108 sourceHandle: "repository",
1109 target: id,
1110 targetHandle: "repository",
1111 });
1112 store.setEdges(edges);
1113 store.updateNodeData<"app">(id, {
1114 repository: {
1115 ...data.repository,
1116 id: newRepoId,
1117 repoNodeId: newGithubNodeId,
1118 },
1119 });
1120 }
1121 } else {
1122 // No old github node, so create a new one
1123 const newGithubNodeId = uuidv4();
1124 store.addNode({
1125 id: newGithubNodeId,
1126 type: "github",
1127 data: {
1128 repository: {
1129 id: selectedRepo.id,
1130 sshURL: selectedRepo.ssh_url,
1131 fullName: selectedRepo.full_name,
1132 },
1133 label: selectedRepo.full_name,
1134 envVars: [],
1135 ports: [],
1136 },
1137 });
1138 store.setEdges(
1139 store.edges.concat({
1140 id: uuidv4(),
1141 source: newGithubNodeId,
1142 sourceHandle: "repository",
1143 target: id,
1144 targetHandle: "repository",
1145 }),
1146 );
1147 store.updateNodeData<"app">(id, {
1148 repository: {
1149 ...data.repository,
1150 id: newRepoId,
1151 repoNodeId: newGithubNodeId,
1152 },
1153 });
1154 }
1155 } else if (name === "branch") {
1156 store.updateNodeData<"app">(id, {
1157 repository: {
1158 ...data?.repository,
1159 branch: value.branch,
1160 },
1161 });
1162 } else if (name === "rootDir") {
1163 store.updateNodeData<"app">(id, {
1164 repository: {
1165 ...data?.repository,
1166 rootDir: value.rootDir,
1167 },
1168 });
1169 }
1170 },
1171 );
1172 return () => sub.unsubscribe();
1173 }, [id, data, sourceForm, store, nodes, repos]);
1174 const [isExpanded, setIsExpanded] = useState(false);
1175 // useEffect(() => {
1176 // if (data.repository === undefined) {
1177 // setIsExpanded(true);
1178 // }
1179 // }, [data.repository, setIsExpanded]);
1180 console.log(data.repository, isExpanded, repo);
1181 return (
1182 <Accordion type="single" collapsible>
1183 <AccordionItem value="repository" className="border-none">
1184 <AccordionTrigger onClick={() => setIsExpanded(!isExpanded)}>
1185 Source {!isExpanded && repo !== undefined && repo.data.repository?.fullName}
1186 </AccordionTrigger>
1187 <AccordionContent className="px-1">
1188 <Form {...sourceForm}>
1189 <form className="space-y-2">
1190 <Label>Repository</Label>
1191 <FormField
1192 control={sourceForm.control}
1193 name="id"
1194 render={({ field }) => (
1195 <FormItem>
1196 <Select onValueChange={field.onChange} value={field.value} disabled={disabled}>
1197 <FormControl>
1198 <SelectTrigger>
1199 <SelectValue />
1200 </SelectTrigger>
1201 </FormControl>
1202 <SelectContent>
1203 {repos.map((r) => (
1204 <SelectItem
1205 key={r.id}
1206 value={r.id.toString()}
1207 >{`${r.full_name}`}</SelectItem>
1208 ))}
1209 </SelectContent>
1210 </Select>
1211 <FormMessage />
1212 </FormItem>
1213 )}
1214 />
1215 <Label>Branch</Label>
1216 <FormField
1217 control={sourceForm.control}
1218 name="branch"
1219 render={({ field }) => (
1220 <FormItem>
1221 <FormControl>
1222 <Input
1223 placeholder="master"
1224 className="lowercase"
1225 {...field}
1226 disabled={disabled}
1227 />
1228 </FormControl>
1229 <FormMessage />
1230 </FormItem>
1231 )}
1232 />
1233 <Label>Root Directory</Label>
1234 <FormField
1235 control={sourceForm.control}
1236 name="rootDir"
1237 render={({ field }) => (
1238 <FormItem>
1239 <FormControl>
1240 <Input placeholder="/" {...field} disabled={disabled} />
1241 </FormControl>
1242 <FormMessage />
1243 </FormItem>
1244 )}
1245 />
1246 </form>
1247 </Form>
1248 </AccordionContent>
1249 </AccordionItem>
1250 </Accordion>
1251 );
1252}