Canvas: Service dev UI
Change-Id: I11968dbf5ec51c5fd234ad927d40b0b3983e71dd
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index b7aa7dc..3970558 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -12,6 +12,7 @@
GatewayHttpsNode,
AppNode,
GithubNode,
+ useEnv,
} from "@/lib/state";
import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
import { z } from "zod";
@@ -25,6 +26,8 @@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { Textarea } from "./ui/textarea";
import { Input } from "./ui/input";
+import { Checkbox } from "./ui/checkbox";
+import { Label } from "./ui/label";
export function NodeApp(node: ServiceNode) {
const { id, selected } = node;
@@ -79,9 +82,19 @@
rootDir: z.string(),
});
+const devSchema = z.object({
+ enabled: z.boolean(),
+});
+
+const exposeSchema = z.object({
+ network: z.string().min(1, "reqired"),
+ subdomain: z.string().min(1, "required"),
+});
+
export function NodeAppDetails({ id, data }: ServiceNode) {
const store = useStateStore();
const nodes = useNodes<AppNode>();
+ const env = useEnv();
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
mode: "onChange",
@@ -407,10 +420,214 @@
);
return () => sub.unsubscribe();
}, [id, data, sourceForm, store]);
-
+ const devForm = useForm<z.infer<typeof devSchema>>({
+ resolver: zodResolver(devSchema),
+ mode: "onChange",
+ defaultValues: {
+ enabled: data.dev ? data.dev.enabled : false,
+ },
+ });
+ useEffect(() => {
+ const sub = devForm.watch((value, { name }) => {
+ if (name === "enabled") {
+ if (value.enabled) {
+ const csGateway: Omit<GatewayHttpsNode, "position"> = {
+ id: uuidv4(),
+ type: "gateway-https",
+ data: {
+ readonly: true,
+ https: {
+ serviceId: id,
+ portId: `${id}-code-server`,
+ },
+ network: data.dev?.expose?.network,
+ subdomain: data.dev?.expose?.subdomain,
+ label: "",
+ envVars: [],
+ ports: [],
+ },
+ };
+ const sshGateway: Omit<GatewayTCPNode, "position"> = {
+ id: uuidv4(),
+ type: "gateway-tcp",
+ data: {
+ readonly: true,
+ exposed: [
+ {
+ serviceId: id,
+ portId: `${id}-ssh`,
+ },
+ ],
+ network: data.dev?.expose?.network,
+ subdomain: data.dev?.expose?.subdomain,
+ label: "",
+ envVars: [],
+ ports: [],
+ },
+ };
+ store.addNode(csGateway);
+ store.addNode(sshGateway);
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: true,
+ expose: data.dev?.expose,
+ codeServerNodeId: csGateway.id,
+ sshNodeId: sshGateway.id,
+ },
+ ports: (data.ports || []).concat(
+ {
+ id: `${id}-code-server`,
+ name: "code-server",
+ value: 9090,
+ },
+ {
+ id: `${id}-ssh`,
+ name: "ssh",
+ value: 22,
+ },
+ ),
+ });
+ let edges = store.edges.concat([
+ {
+ id: uuidv4(),
+ source: id,
+ sourceHandle: "ports",
+ target: csGateway.id,
+ targetHandle: "https",
+ },
+ {
+ id: uuidv4(),
+ source: id,
+ sourceHandle: "ports",
+ target: sshGateway.id,
+ targetHandle: "tcp",
+ },
+ ]);
+ if (data.dev?.expose?.network !== undefined) {
+ edges = edges.concat([
+ {
+ id: uuidv4(),
+ source: csGateway.id,
+ sourceHandle: "subdomain",
+ target: data.dev.expose.network,
+ targetHandle: "subdomain",
+ },
+ {
+ id: uuidv4(),
+ source: sshGateway.id,
+ sourceHandle: "subdomain",
+ target: data.dev.expose.network,
+ targetHandle: "subdomain",
+ },
+ ]);
+ }
+ store.setEdges(edges);
+ } else {
+ const { dev } = data;
+ if (dev?.enabled) {
+ store.setNodes(
+ store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
+ );
+ store.setEdges(
+ store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
+ );
+ }
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: false,
+ expose: dev?.expose,
+ },
+ ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
+ });
+ }
+ }
+ });
+ return () => sub.unsubscribe();
+ }, [id, data, devForm, store]);
+ const exposeForm = useForm<z.infer<typeof exposeSchema>>({
+ resolver: zodResolver(exposeSchema),
+ mode: "onChange",
+ defaultValues: {
+ network: data.dev?.expose?.network,
+ subdomain: data.dev?.expose?.subdomain,
+ },
+ });
+ useEffect(() => {
+ const sub = exposeForm.watch(
+ (
+ value: DeepPartial<z.infer<typeof exposeSchema>>,
+ { name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
+ ) => {
+ const { dev } = data;
+ if (!dev?.enabled) {
+ return;
+ }
+ if (name === "network") {
+ let edges = store.edges;
+ if (dev.enabled && dev.expose?.network !== undefined) {
+ edges = edges.filter((e) => {
+ if (
+ (e.source === dev.codeServerNodeId || e.source === dev.sshNodeId) &&
+ e.sourceHandle === "subdomain" &&
+ e.target === dev.expose?.network &&
+ e.targetHandle === "subdomain"
+ ) {
+ return false;
+ } else {
+ return true;
+ }
+ });
+ }
+ if (value.network !== undefined) {
+ edges = edges.concat(
+ {
+ id: uuidv4(),
+ source: dev.codeServerNodeId,
+ sourceHandle: "subdomain",
+ target: value.network,
+ targetHandle: "subdomain",
+ },
+ {
+ id: uuidv4(),
+ source: dev.sshNodeId,
+ sourceHandle: "subdomain",
+ target: value.network,
+ targetHandle: "subdomain",
+ },
+ );
+ }
+ store.setEdges(edges);
+ store.updateNodeData<"app">(id, {
+ dev: {
+ ...dev,
+ expose: {
+ network: value.network,
+ subdomain: dev.expose?.subdomain,
+ },
+ },
+ });
+ store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { network: value.network });
+ store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { network: value.network });
+ } else if (name === "subdomain") {
+ store.updateNodeData<"app">(id, {
+ dev: {
+ ...dev,
+ expose: {
+ network: dev.expose?.network,
+ subdomain: value.subdomain,
+ },
+ },
+ });
+ store.updateNodeData<"gateway-https">(dev.codeServerNodeId, { subdomain: value.subdomain });
+ store.updateNodeData<"gateway-tcp">(dev.sshNodeId, { subdomain: value.subdomain });
+ }
+ },
+ );
+ return () => sub.unsubscribe();
+ }, [id, data, exposeForm, store]);
return (
<>
- <Form {...form}>
+ <Form {...exposeForm}>
<form className="space-y-2">
<FormField
control={form.control}
@@ -602,6 +819,64 @@
value={data.preBuildCommands}
onChange={setPreBuildCommands}
/>
+ Dev
+ <Form {...devForm}>
+ <form className="space-y-2">
+ <FormField
+ control={devForm.control}
+ name="enabled"
+ render={({ field }) => (
+ <FormItem>
+ <div className="flex flex-row gap-1 items-center">
+ <Checkbox id="devEnabled" onCheckedChange={field.onChange} checked={field.value} />
+ <Label htmlFor="devEnabled">Enabled</Label>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ <Form {...exposeForm}>
+ <form className="space-y-2">
+ <FormField
+ control={exposeForm.control}
+ name="network"
+ render={({ field }) => (
+ <FormItem>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Network" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {env.networks.map((n) => (
+ <SelectItem
+ key={n.name}
+ value={n.domain}
+ >{`${n.name} - ${n.domain}`}</SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={exposeForm.control}
+ name="subdomain"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input placeholder="subdomain" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
</>
);
}