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>
</>
);
}
diff --git a/apps/canvas/front/src/components/node-gateway-https.tsx b/apps/canvas/front/src/components/node-gateway-https.tsx
index 0119305..3785d59 100644
--- a/apps/canvas/front/src/components/node-gateway-https.tsx
+++ b/apps/canvas/front/src/components/node-gateway-https.tsx
@@ -298,7 +298,11 @@
name="network"
render={({ field }) => (
<FormItem>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={data.readonly}
+ >
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Network" />
@@ -323,7 +327,7 @@
render={({ field }) => (
<FormItem>
<FormControl>
- <Input placeholder="subdomain" {...field} />
+ <Input placeholder="subdomain" {...field} disabled={data.readonly} />
</FormControl>
<FormMessage />
</FormItem>
@@ -338,7 +342,11 @@
name="id"
render={({ field }) => (
<FormItem>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={data.readonly}
+ >
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Service" />
@@ -361,7 +369,11 @@
name="portId"
render={({ field }) => (
<FormItem>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={data.readonly}
+ >
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Port" />
diff --git a/apps/canvas/front/src/components/node-gateway-tcp.tsx b/apps/canvas/front/src/components/node-gateway-tcp.tsx
index bb8b9be..86fa493 100644
--- a/apps/canvas/front/src/components/node-gateway-tcp.tsx
+++ b/apps/canvas/front/src/components/node-gateway-tcp.tsx
@@ -232,7 +232,11 @@
name="network"
render={({ field }) => (
<FormItem>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={data.readonly}
+ >
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Network" />
@@ -257,7 +261,7 @@
render={({ field }) => (
<FormItem>
<FormControl>
- <Input placeholder="subdomain" {...field} />
+ <Input placeholder="subdomain" {...field} disabled={data.readonly} />
</FormControl>
<FormMessage />
</FormItem>
@@ -280,7 +284,11 @@
name="serviceId"
render={({ field }) => (
<FormItem>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={data.readonly}
+ >
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Service" />
@@ -301,7 +309,11 @@
name="portId"
render={({ field }) => (
<FormItem>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={data.readonly}
+ >
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Port" />
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index a3784c1..60d9966 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -1,4 +1,4 @@
-import { AppNode, Env, GatewayHttpsNode, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
+import { AppNode, Env, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
export type AuthDisabled = {
enabled: false;
@@ -57,6 +57,11 @@
expose?: PortDomain[];
volume?: string[];
preBuildCommands?: { bin: string }[];
+ dev?: {
+ enabled: boolean;
+ ssh?: Domain;
+ codeServer?: Domain;
+ };
};
export type Volume = {
@@ -143,7 +148,7 @@
ingress: ingressNodes
.filter((i) => i.data.https!.serviceId === n.id)
.map(
- (i: GatewayHttpsNode): Ingress => ({
+ (i): Ingress => ({
network: networkMap.get(i.data.network!)!,
subdomain: i.data.subdomain!,
port: {
@@ -165,6 +170,23 @@
preBuildCommands: n.data.preBuildCommands
? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
: [],
+ dev: {
+ enabled: n.data.dev ? n.data.dev.enabled : false,
+ codeServer:
+ n.data.dev?.enabled && n.data.dev.expose != null
+ ? {
+ network: n.data.dev.expose.network,
+ subdomain: n.data.dev.expose.subdomain,
+ }
+ : undefined,
+ ssh:
+ n.data.dev?.enabled && n.data.dev.expose != null
+ ? {
+ network: n.data.dev.expose.network,
+ subdomain: n.data.dev.expose.subdomain,
+ }
+ : undefined,
+ },
};
}),
volume: nodes
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index b8db5b5..6e04e4c 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -41,6 +41,7 @@
};
export type GatewayHttpsData = NodeData & {
+ readonly?: boolean;
network?: string;
subdomain?: string;
https?: PortConnectedTo;
@@ -56,6 +57,7 @@
};
export type GatewayTCPData = NodeData & {
+ readonly?: boolean;
network?: string;
subdomain?: string;
exposed: PortConnectedTo[];
@@ -87,6 +89,11 @@
] as const;
export type ServiceType = (typeof ServiceTypes)[number];
+export type Domain = {
+ network: string;
+ subdomain: string;
+};
+
export type ServiceData = NodeData & {
type: ServiceType;
repository:
@@ -106,6 +113,17 @@
volume: string[];
preBuildCommands: string;
isChoosingPortToConnect: boolean;
+ dev?:
+ | {
+ enabled: false;
+ expose?: Domain;
+ }
+ | {
+ enabled: true;
+ expose?: Domain;
+ codeServerNodeId: string;
+ sshNodeId: string;
+ };
};
export type ServiceNode = Node<ServiceData> & {
@@ -168,35 +186,41 @@
| NANode;
export function nodeLabel(n: AppNode): string {
- switch (n.type) {
- case "network":
- return n.data.domain;
- case "app":
- return n.data.label || "Service";
- case "github":
- return n.data.repository?.fullName || "Github";
- case "gateway-https": {
- if (n.data && n.data.network && n.data.subdomain) {
- return `https://${n.data.subdomain}.${n.data.network}`;
- } else {
- return "HTTPS Gateway";
+ try {
+ switch (n.type) {
+ case "network":
+ return n.data.domain;
+ case "app":
+ return n.data.label || "Service";
+ case "github":
+ return n.data.repository?.fullName || "Github";
+ case "gateway-https": {
+ if (n.data && n.data.network && n.data.subdomain) {
+ return `https://${n.data.subdomain}.${n.data.network}`;
+ } else {
+ return "HTTPS Gateway";
+ }
}
- }
- case "gateway-tcp": {
- if (n.data && n.data.network && n.data.subdomain) {
- return `${n.data.subdomain}.${n.data.network}`;
- } else {
- return "TCP Gateway";
+ case "gateway-tcp": {
+ if (n.data && n.data.network && n.data.subdomain) {
+ return `${n.data.subdomain}.${n.data.network}`;
+ } else {
+ return "TCP Gateway";
+ }
}
+ case "mongodb":
+ return n.data.label || "MongoDB";
+ case "postgresql":
+ return n.data.label || "PostgreSQL";
+ case "volume":
+ return n.data.label || "Volume";
+ case undefined:
+ throw new Error("MUST NOT REACH!");
}
- case "mongodb":
- return n.data.label || "MongoDB";
- case "postgresql":
- return n.data.label || "PostgreSQL";
- case "volume":
- return n.data.label || "Volume";
- case undefined:
- throw new Error("MUST NOT REACH!");
+ } catch (e) {
+ console.error("opaa", e);
+ } finally {
+ console.log("done");
}
}
@@ -464,14 +488,36 @@
});
};
+ const injectNetworkNodes = () => {
+ const newNetworks = get().env.networks.filter(
+ (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
+ );
+ newNetworks.forEach((n) => {
+ get().addNode({
+ id: n.domain,
+ type: "network",
+ connectable: true,
+ data: {
+ domain: n.domain,
+ label: n.domain,
+ envVars: [],
+ ports: [],
+ state: "success", // TODO(gio): monitor network health
+ },
+ });
+ console.log("added network", n.domain);
+ });
+ };
+
const restoreSaved = async () => {
const { projectId } = get();
const resp = await fetch(`/api/project/${projectId}/saved/${get().mode === "deploy" ? "deploy" : "draft"}`, {
method: "GET",
});
const inst = await resp.json();
- setN(inst.nodes || []);
- set({ edges: inst.edges || [] });
+ setN(inst.nodes);
+ set({ edges: inst.edges });
+ injectNetworkNodes();
if (
get().zoom.x !== inst.viewport.x ||
get().zoom.y !== inst.viewport.y ||
@@ -780,23 +826,7 @@
} finally {
if (JSON.stringify(get().env) !== JSON.stringify(env)) {
set({ env });
- const newNetworks = env.networks.filter(
- (x) => !get().nodes.some((n) => n.type === "network" && n.data.domain === x.domain),
- );
- newNetworks.forEach((n) => {
- get().addNode({
- id: n.domain,
- type: "network",
- connectable: true,
- data: {
- domain: n.domain,
- label: n.domain,
- envVars: [],
- ports: [],
- state: "success", // TODO(gio): monitor network health
- },
- });
- });
+ injectNetworkNodes();
if (env.integrations.github) {
set({ githubService: new GitHubServiceImpl(projectId!) });