Canvas: build application infrastructure with drag and drop
Change-Id: I5cfd12e67794f3376c5c025af29470d52d77cf16
diff --git a/apps/canvas/src/lib/categories.ts b/apps/canvas/src/lib/categories.ts
new file mode 100644
index 0000000..e45c4fd
--- /dev/null
+++ b/apps/canvas/src/lib/categories.ts
@@ -0,0 +1,115 @@
+import { NodeType, InitData } from "@/lib/state";
+
+export interface CategoryItem<T extends NodeType = any> {
+ title: string;
+ init: InitData;
+ type: T;
+};
+
+export type Category = {
+ title: string;
+ items: CategoryItem[];
+ active?: boolean;
+};
+
+const defaultInit: Pick<InitData, "label" | "envVars" | "ports"> = {
+ label: "",
+ envVars: [],
+ ports: [],
+};
+
+export const defaultCategories: Category[] = [
+ {
+ title: "Repository",
+ items: [
+ {
+ title: "Github",
+ init: {
+ ...defaultInit,
+ },
+ type: "github",
+ },
+ {
+ title: "Gitlab",
+ init: {
+ ...defaultInit,
+ },
+ type: "github",
+ },
+ {
+ title: "Create new",
+ init: {
+ ...defaultInit,
+ },
+ type: "github",
+ }
+ ]
+ },
+ {
+ title: "Services",
+ items: [
+ {
+ title: "Service",
+ init: {
+ ...defaultInit,
+ },
+ type: "app",
+ }
+ ],
+ },
+ {
+ title: "Storage",
+ items: [
+ {
+ title: "Volume",
+ init: {
+ ...defaultInit,
+ },
+ type: "volume",
+ },
+ {
+ title: "PostgreSQL",
+ init: {
+ ...defaultInit,
+ ports: [{
+ id: "connection",
+ name: "connection",
+ value: 5432,
+ }],
+ },
+ type: "postgresql",
+ },
+ {
+ title: "MongoDB",
+ init: {
+ ...defaultInit,
+ ports: [{
+ id: "connection",
+ name: "connection",
+ value: 27017,
+ }],
+ },
+ type: "mongodb",
+ },
+ ],
+ },
+ {
+ title: "Gateways",
+ items: [
+ {
+ title: "HTTPS",
+ init: {
+ ...defaultInit,
+ },
+ type: "gateway-https",
+ },
+ {
+ title: "TCP",
+ init: {
+ ...defaultInit,
+ },
+ type: "gateway-tcp",
+ },
+ ],
+ },
+];
\ No newline at end of file
diff --git a/apps/canvas/src/lib/config.ts b/apps/canvas/src/lib/config.ts
new file mode 100644
index 0000000..f2a0784
--- /dev/null
+++ b/apps/canvas/src/lib/config.ts
@@ -0,0 +1,390 @@
+import { AppNode, Env, GatewayHttpsNode, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
+
+export type AuthDisabled = {
+ enabled: false;
+};
+
+export type AuthEnabled = {
+ enabled: true;
+ groups: string[];
+ noAuthPathPatterns: string[];
+};
+
+export type Auth = AuthDisabled | AuthEnabled;
+
+export type Ingress = {
+ network: string;
+ subdomain: string;
+ port: { name: string; } | { value: string; };
+ auth: Auth;
+};
+
+export type Domain = {
+ network: string;
+ subdomain: string;
+};
+
+export type PortValue = {
+ name: string;
+} | {
+ value: number;
+};
+
+export type PortDomain = Domain & {
+ port: PortValue;
+}
+
+export type Service = {
+ type: ServiceType;
+ name: string;
+ source: {
+ repository: string;
+ branch: string;
+ rootDir: string;
+ };
+ ports?: {
+ name: string;
+ value: number;
+ protocol: "TCP" | "UDP";
+ }[];
+ env?: {
+ name: string;
+ alias?: string;
+ }[]
+ ingress?: Ingress;
+ expose?: PortDomain[];
+ volume?: string[];
+};
+
+export type Volume = {
+ name: string;
+ accessMode: VolumeType;
+ size: string;
+};
+
+export type PostgreSQL = {
+ name: string;
+ size: string;
+ expose?: Domain[];
+};
+
+export type MongoDB = {
+ name: string;
+ size: string;
+ expose?: Domain[];
+};
+
+export type Config = {
+ service?: Service[];
+ volume?: Volume[];
+ postgresql?: PostgreSQL[];
+ mongodb?: MongoDB[];
+};
+
+export function generateDodoConfig(nodes: AppNode[], env: Env): Config | null {
+ try {
+ const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
+ const ingressNodes = nodes.filter((n) => n.type === "gateway-https").filter((n) => n.data.https !== undefined);
+ const tcpNodes = nodes.filter((n) => n.type === "gateway-tcp").filter((n) => n.data.exposed !== undefined);
+ const findExpose = (n: AppNode): PortDomain[] => {
+ return n.data.ports.map((p) => [n.id, p.id, p.name]).flatMap((sp) => {
+ return tcpNodes.flatMap((i) => (i.data.exposed || []).filter((t) => t.serviceId === sp[0] && t.portId === sp[1]).map(() => ({
+ network: networkMap.get(i.data.network!)!,
+ subdomain: i.data.subdomain!,
+ port: { name: sp[2] },
+ })));
+ });
+ };
+ return {
+ service: nodes.filter((n) => n.type === "app").map((n): Service => {
+ return {
+ type: n.data.type,
+ name: n.data.label,
+ source: {
+ repository: nodes.filter((i) => i.type === "github").find((i) => i.id === n.data.repository.id)!.data.address,
+ branch: n.data.repository.branch,
+ rootDir: n.data.repository.rootDir,
+ },
+ ports: (n.data.ports || []).map((p) => ({
+ name: p.name,
+ value: p.value,
+ protocol: "TCP", // TODO(gio)
+ })),
+ env: (n.data.envVars || []).filter((e) => "name" in e).map((e) => ({
+ name: e.name,
+ alias: "alias" in e ? e.alias : undefined,
+ })),
+ ingress: ((i: GatewayHttpsNode | undefined) => {
+ if (i === undefined) {
+ return undefined;
+ }
+ return {
+ network: networkMap.get(i.data.network!)!,
+ subdomain: i.data.subdomain!,
+ port: {
+ name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
+ },
+ auth: { enabled: false },
+ };
+ })(ingressNodes.find((i) => i.data.https!.serviceId === n.id)),
+ expose: findExpose(n),
+ };
+ }),
+ volume: nodes.filter((n) => n.type === "volume").map((n): Volume => ({
+ name: n.data.label,
+ accessMode: n.data.type,
+ size: n.data.size,
+ })),
+ postgresql: nodes.filter((n) => n.type === "postgresql").map((n): PostgreSQL => ({
+ name: n.data.label,
+ size: "1Gi", // TODO(gio)
+ expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
+ })),
+ mongodb: nodes.filter((n) => n.type === "mongodb").map((n): MongoDB => ({
+ name: n.data.label,
+ size: "1Gi", // TODO(gio)
+ expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
+ })),
+ };
+ } catch (e) {
+ console.log(e);
+ return null;
+ }
+}
+
+export interface Validator {
+ (nodes: AppNode[]): Message[];
+}
+
+function CombineValidators(...v: Validator[]): Validator {
+ return (n) => v.flatMap((v) => v(n));
+}
+
+function MessageTypeToNumber(t: MessageType) {
+ switch (t) {
+ case "FATAL": return 0;
+ case "WARNING": return 1;
+ case "INFO": return 2;
+ }
+}
+
+function NodeTypeToNumber(t?: NodeType) {
+ switch (t) {
+ case "github": return 0;
+ case "app": return 1;
+ case "volume": return 2;
+ case "postgresql": return 3;
+ case "mongodb": return 4;
+ case "gateway-https": return 5;
+ case undefined: return 100;
+ }
+}
+
+function SortingValidator(v: Validator): Validator {
+ return (n) => {
+ const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]))
+ return v(n).sort((a, b) => {
+ const at = MessageTypeToNumber(a.type);
+ const bt = MessageTypeToNumber(b.type);
+ if (a.nodeId === undefined && b.nodeId === undefined) {
+ if (at !== bt) {
+ return at - bt;
+ }
+ return a.id.localeCompare(b.id);
+ }
+ if (a.nodeId === undefined) {
+ return -1;
+ }
+ if (b.nodeId === undefined) {
+ return 1;
+ }
+ if (a.nodeId === b.nodeId) {
+ if (at !== bt) {
+ return at - bt;
+ }
+ return a.id.localeCompare(b.id);
+ }
+ const ant = nt.get(a.id)!;
+ const bnt = nt.get(b.id)!;
+ if (ant !== bnt) {
+ return ant - bnt;
+ }
+ return a.id.localeCompare(b.id);
+ });
+ };
+}
+
+export function CreateValidators(): Validator {
+ return SortingValidator(
+ CombineValidators(
+ EmptyValidator,
+ GitRepositoryValidator,
+ ServiceValidator,
+ GatewayHTTPSValidator,
+ GatewayTCPValidator,
+ )
+ );
+}
+
+function EmptyValidator(nodes: AppNode[]): Message[] {
+ if (nodes.length > 0) {
+ return [];
+ }
+ return [{
+ id: "no-nodes",
+ type: "FATAL",
+ message: "Start by importing application source code",
+ onHighlight: (store) => store.setHighlightCategory("repository", true),
+ onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
+ }];
+}
+
+function GitRepositoryValidator(nodes: AppNode[]): Message[] {
+ const git = nodes.filter((n) => n.type === "github");
+ const noAddress: Message[] = git.filter((n) => n.data == null || n.data.address == null || n.data.address === "").map((n) => ({
+ id: `${n.id}-no-address`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Configure repository address",
+ onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+ onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+ } satisfies Message));
+ const noApp = git.filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id)).map((n) => ({
+ id: `${n.id}-no-app`,
+ type: "WARNING",
+ nodeId: n.id,
+ message: "Connect to service",
+ onHighlight: (store) => store.setHighlightCategory("Services", true),
+ onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
+} satisfies Message));
+ return noAddress.concat(noApp);
+}
+
+function ServiceValidator(nodes: AppNode[]): Message[] {
+ const apps = nodes.filter((n) => n.type === "app");
+ const noName = apps.filter((n) => n.data == null || n.data.label == null || n.data.label === "").map((n): Message => ({
+ id: `${n.id}-no-name`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Name the service",
+ onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+ onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+ onClick: (store) => {
+ store.updateNode(n.id, { selected: true });
+ store.updateNodeData<"app">(n.id, {
+ activeField: "name" ,
+ });
+ },
+ }));
+ const noSource = apps.filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "").map((n): Message => ({
+ id: `${n.id}-no-repo`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Connect to source repository",
+ onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+ onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+ }));
+ const noRuntime = apps.filter((n) => n.data == null || n.data.type == null).map((n): Message => ({
+ id: `${n.id}-no-runtime`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Choose runtime",
+ onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+ onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+ onClick: (store) => {
+ store.updateNode(n.id, { selected: true });
+ store.updateNodeData<"app">(n.id, {
+ activeField: "type" ,
+ });
+ },
+ }));
+ const noPorts = apps.filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0).map((n): Message => ({
+ id: `${n.id}-no-ports`,
+ type: "INFO",
+ nodeId: n.id,
+ message: "Expose ports",
+ onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+ onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+ }));
+ const noIngress = apps.flatMap((n): Message[] => {
+ if (n.data == null) {
+ return [];
+ }
+ return (n.data.ports || []).filter((p) => !nodes.filter((i) => i.type === "gateway-https").some((i) => {
+ if (i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id) {
+ return true;
+ }
+ return false;
+ })).map((p): Message => ({
+ id: `${n.id}-${p.id}-no-ingress`,
+ type: "WARNING",
+ nodeId: n.id,
+ message: `Connect to ingress: ${p.name} - ${p.value}`,
+ onHighlight: (store) => {
+ store.updateNode(n.id, { selected: true });
+ store.setHighlightCategory("gateways", true);
+ },
+ onLooseHighlight: (store) => {
+ store.updateNode(n.id, { selected: false });
+ store.setHighlightCategory("gateways", false);
+ },
+ }));
+ });
+ const multipleIngress = apps.filter((n) => n.data != null && n.data.ports != null).flatMap((n) => n.data.ports.map((p): Message | undefined => {
+ const ing = nodes.filter((i) => i.type === "gateway-https").filter((i) => i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id);
+ if (ing.length < 2) {
+ return undefined;
+ }
+ return {
+ id: `${n.id}-${p.id}-multiple-ingress`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
+ onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+ onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+ };
+ })).filter((m) => m !== undefined);
+ return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
+}
+
+function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
+ const ing = nodes.filter((n) => n.type === "gateway-https");
+ const noNetwork: Message[] = ing.filter((n) => n.data == null || n.data.network == null || n.data.network == "" || n.data.subdomain == null || n.data.subdomain == "").map((n): Message => ({
+ id: `${n.id}-no-network`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Network and subdomain must be defined",
+ onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+ onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+ }));
+ const notConnected: Message[] = ing.filter((n) => n.data == null || n.data.https == null || n.data.https.serviceId == null || n.data.https.serviceId == "" || n.data.https.portId == null || n.data.https.portId == "").map((n) => ({
+ id: `${n.id}-not-connected`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Connect to a service port",
+ onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+ onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+ }));
+ return noNetwork.concat(notConnected);
+}
+
+function GatewayTCPValidator(nodes: AppNode[]): Message[] {
+ const ing = nodes.filter((n) => n.type === "gateway-tcp");
+ const noNetwork: Message[] = ing.filter((n) => n.data == null || n.data.network == null || n.data.network == "" || n.data.subdomain == null || n.data.subdomain == "").map((n): Message => ({
+ id: `${n.id}-no-network`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Network and subdomain must be defined",
+ onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+ onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+ }));
+ const notConnected: Message[] = ing.filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0).map((n) => ({
+ id: `${n.id}-not-connected`,
+ type: "FATAL",
+ nodeId: n.id,
+ message: "Connect to a service port",
+ onHighlight: (store) => store.updateNode(n.id, { selected: true }),
+ onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
+ }));
+ return noNetwork.concat(notConnected);
+}
\ No newline at end of file
diff --git a/apps/canvas/src/lib/state.ts b/apps/canvas/src/lib/state.ts
new file mode 100644
index 0000000..f718134
--- /dev/null
+++ b/apps/canvas/src/lib/state.ts
@@ -0,0 +1,567 @@
+import { v4 as uuidv4 } from "uuid";
+import { create } from 'zustand';
+import { addEdge, applyNodeChanges, applyEdgeChanges, Connection, EdgeChange, useNodes } from '@xyflow/react';
+import {
+ type Edge,
+ type Node,
+ type OnNodesChange,
+ type OnEdgesChange,
+ type OnConnect,
+} from '@xyflow/react';
+import { DeepPartial } from "react-hook-form";
+import { Category, defaultCategories } from "./categories";
+import { CreateValidators, Validator } from "./config";
+import { z } from "zod";
+
+export type InitData = {
+ label: string;
+ envVars: BoundEnvVar[];
+ ports: Port[];
+};
+
+export type NodeData = InitData & {
+ activeField?: string | undefined;
+};
+
+export type PortConnectedTo = {
+ serviceId: string;
+ portId: string;
+}
+
+export type GatewayHttpsData = NodeData & {
+ network?: string;
+ subdomain?: string;
+ https?: PortConnectedTo;
+};
+
+export type GatewayHttpsNode = Node<GatewayHttpsData> & {
+ type: "gateway-https";
+};
+
+export type GatewayTCPData = NodeData & {
+ network?: string;
+ subdomain?: string;
+ exposed: PortConnectedTo[];
+ selected?: {
+ serviceId?: string;
+ portId?: string;
+ };
+};
+
+export type GatewayTCPNode = Node<GatewayTCPData> & {
+ type: "gateway-tcp";
+};
+
+export type Port = {
+ id: string;
+ name: string;
+ value: number;
+};
+
+export const ServiceTypes = ["node-23.1.0", "nextjs:deno-2.0.0"] as const;
+export type ServiceType = typeof ServiceTypes[number];
+
+export type ServiceData = NodeData & {
+ type: ServiceType;
+ repository: {
+ id: string;
+ branch: string;
+ rootDir: string;
+ };
+ env: string[];
+ volume: string[];
+ isChoosingPortToConnect: boolean;
+};
+
+export type ServiceNode = Node<ServiceData> & {
+ type: "app";
+};
+
+export type VolumeType = "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod";
+
+export type VolumeData = NodeData & {
+ type: VolumeType;
+ size: string;
+ attachedTo: string[];
+};
+
+export type VolumeNode = Node<VolumeData> & {
+ type: "volume";
+};
+
+export type PostgreSQLData = NodeData & {
+ volumeId: string;
+};
+
+export type PostgreSQLNode = Node<PostgreSQLData> & {
+ type: "postgresql";
+};
+
+export type MongoDBData = NodeData & {
+ volumeId: string;
+};
+
+export type MongoDBNode = Node<MongoDBData> & {
+ type: "mongodb";
+};
+
+export type GithubData = NodeData & {
+ address: string;
+};
+
+export type GithubNode = Node<GithubData> & {
+ type: "github";
+};
+
+export type NANode = Node<NodeData> & {
+ type: undefined;
+};
+
+export type AppNode = GatewayHttpsNode | GatewayTCPNode | ServiceNode | VolumeNode | PostgreSQLNode | MongoDBNode | GithubNode | NANode;
+
+export function nodeLabel(n: AppNode): string {
+ switch (n.type) {
+ case "app": return n.data.label || "Service";
+ case "github": return n.data.address || "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 "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!");
+ }
+}
+
+export function nodeIsConnectable(n: AppNode, handle: string): boolean {
+ switch (n.type) {
+ case "app":
+ if (handle === "ports") {
+ return n.data !== undefined && n.data.ports !== undefined && n.data.ports.length > 0;
+ } else if (handle === "repository") {
+ if (!n.data || !n.data.repository || !n.data.repository.id) {
+ return true;
+ }
+ return false;
+ }
+ return false;
+ case "github":
+ if (n.data !== undefined && n.data.address) {
+ return true;
+ }
+ return false;
+ case "gateway-https":
+ return n.data === undefined || n.data.https === undefined;
+ case "gateway-tcp":
+ return true;
+ case "mongodb":
+ return true;
+ case "postgresql":
+ return true;
+ case "volume":
+ if (n.data === undefined || n.data.type === undefined) {
+ return false;
+ }
+ if (n.data.type === "ReadWriteOnce" || n.data.type === "ReadWriteOncePod") {
+ return n.data.attachedTo === undefined || n.data.attachedTo.length === 0;
+ }
+ return true;
+ case undefined: throw new Error("MUST NOT REACH!");
+ }
+}
+
+export type BoundEnvVar = {
+ id: string;
+ source: string;
+} | {
+ id: string;
+ source: string;
+ name: string;
+ isEditting: boolean;
+} | {
+ id: string;
+ source: string;
+ name: string;
+ alias: string;
+ isEditting: boolean;
+};
+
+export type EnvVar = {
+ name: string;
+ value: string;
+};
+
+export function nodeEnvVarNames(n: AppNode): string[] {
+ switch (n.type) {
+ case "app": return [
+ `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
+ ...(n.data.ports || []).map((p) => `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${p.name.toUpperCase()}`),
+ ];
+ case "github": return [];
+ case "gateway-https": return [];
+ case "gateway-tcp": return [];
+ case "mongodb": return [`DODO_MONGODB_${n.data.label.toUpperCase()}_CONNECTION_URL`];
+ case "postgresql": return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_CONNECTION_URL`];
+ case "volume": return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
+ case undefined: throw new Error("MUST NOT REACH");
+ }
+}
+
+export type NodeType = Exclude<Pick<AppNode, "type">["type"], undefined>;
+
+export type MessageType = "INFO" | "WARNING" | "FATAL";
+
+export type Message = {
+ id: string;
+ type: MessageType;
+ nodeId?: string;
+ message: string;
+ onHighlight?: (state: AppState) => void;
+ onLooseHighlight?: (state: AppState) => void;
+ onClick?: (state: AppState) => void;
+};
+
+export const envSchema = z.object({
+ deployKey: z.string(),
+ networks: z.array(z.object({
+ name: z.string(),
+ domain: z.string(),
+ })),
+});
+
+export type Env = z.infer<typeof envSchema>;
+
+export type Project = {
+ id: string;
+ name: string;
+}
+
+export type AppState = {
+ projectId: string | undefined;
+ projects: Project[];
+ nodes: AppNode[];
+ edges: Edge[];
+ categories: Category[];
+ messages: Message[];
+ env?: Env;
+ setHighlightCategory: (name: string, active: boolean) => void;
+ onNodesChange: OnNodesChange<AppNode>;
+ onEdgesChange: OnEdgesChange;
+ onConnect: OnConnect;
+ setNodes: (nodes: AppNode[]) => void;
+ setEdges: (edges: Edge[]) => void;
+ setProject: (projectId: string) => void;
+ setProjects: (projects: Project[]) => void;
+ updateNode: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>) => void;
+ updateNodeData: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>) => void;
+ replaceEdge: (c: Connection, id?: string) => void;
+ refreshEnv: () => Promise<Env | undefined>;
+};
+
+const projectIdSelector = (state: AppState) => state.projectId;
+const categoriesSelector = (state: AppState) => state.categories;
+const messagesSelector = (state: AppState) => state.messages;
+const envSelector = (state: AppState) => state.env;
+
+export function useProjectId(): string | undefined {
+ return useStateStore(projectIdSelector);
+}
+
+export function useCategories(): Category[] {
+ return useStateStore(categoriesSelector);
+}
+
+export function useMessages(): Message[] {
+ return useStateStore(messagesSelector);
+}
+
+export function useNodeMessages(id: string): Message[] {
+ return useMessages().filter((m) => m.nodeId === id);
+}
+
+export function useNodeLabel(id: string): string {
+ return nodeLabel(useNodes<AppNode>().find((n) => n.id === id)!);
+}
+
+export function useNodePortName(id: string, portId: string): string {
+ return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
+}
+
+let envRefresh: Promise<Env | undefined> | null = null;
+
+export function useEnv(): Env {
+ return {
+ "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
+ "networks": [{
+ "name": "Public",
+ "domain": "v1.dodo.cloud",
+ }, {
+ "name": "Private",
+ "domain": "p.v1.dodo.cloud",
+ }],
+ };
+ const store = useStateStore();
+ const env = envSelector(store);
+ console.log(env);
+ if (env != null) {
+ return env;
+ }
+ if (envRefresh == null) {
+ envRefresh = store.refreshEnv();
+ envRefresh.finally(() => envRefresh = null);
+ }
+ return {
+ deployKey: "",
+ networks: [],
+ };
+}
+
+const v: Validator = CreateValidators();
+
+export const useStateStore = create<AppState>((set, get): AppState => {
+ set({ env: {
+ "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
+ "networks": [{
+ "name": "Public",
+ "domain": "v1.dodo.cloud",
+ }, {
+ "name": "Private",
+ "domain": "p.v1.dodo.cloud",
+ }],
+ }});
+ console.log(get().env);
+ const setN = (nodes: AppNode[]) => {
+ set({
+ nodes: nodes,
+ messages: v(nodes),
+ })
+ };
+ function updateNodeData<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>): void {
+ setN(get().nodes.map((n) => {
+ if (n.id !== id) {
+ return n;
+ }
+ const nd = {
+ ...n,
+ data: {
+ ...n.data,
+ ...d,
+ },
+ };
+ return nd;
+ })
+ );
+ };
+ function updateNode<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>): void {
+ setN(
+ get().nodes.map((n) => {
+ if (n.id !== id) {
+ return n;
+ }
+ return {
+ ...n,
+ ...d,
+ };
+ })
+ );
+ };
+ function onConnect(c: Connection) {
+ const { nodes, edges } = get();
+ set({
+ edges: addEdge(c, edges),
+ });
+ const sn = nodes.filter((n) => n.id === c.source)[0]!;
+ const tn = nodes.filter((n) => n.id === c.target)[0]!;
+ if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
+ const sourceEnvVars = nodeEnvVarNames(sn);
+ if (sourceEnvVars.length === 0) {
+ throw new Error("MUST NOT REACH!");
+ }
+ const id = uuidv4();
+ if (sourceEnvVars.length === 1) {
+ updateNode(c.target, {
+ ...tn,
+ data: {
+ ...tn.data,
+ envVars: [
+ ...(tn.data.envVars || []),
+ {
+ id: id,
+ source: c.source,
+ name: sourceEnvVars[0],
+ isEditting: false,
+ },
+ ],
+ },
+ });
+ } else {
+ updateNode(c.target, {
+ ...tn,
+ data: {
+ ...tn.data,
+ envVars: [
+ ...(tn.data.envVars || []),
+ {
+ id: id,
+ source: c.source,
+ },
+ ],
+ },
+ });
+ }
+ }
+ if (c.sourceHandle === "volume") {
+ updateNodeData<"volume">(c.source, {
+ attachedTo: ((sn as VolumeNode).data.attachedTo || []).concat(c.source),
+ });
+ }
+ if (c.targetHandle === "volume") {
+ if (tn.type === "postgresql" || tn.type === "mongodb") {
+ updateNodeData(c.target, {
+ volumeId: c.source,
+ });
+ }
+ }
+ if (c.targetHandle === "https") {
+ if ((sn.data.ports || []).length === 1) {
+ updateNodeData<"gateway-https">(c.target, {
+ https: {
+ serviceId: c.source,
+ portId: sn.data.ports![0].id,
+ }
+ });
+ } else {
+ updateNodeData<"gateway-https">(c.target, {
+ https: {
+ serviceId: c.source,
+ portId: "", // TODO(gio)
+ }
+ });
+ }
+ }
+ if (c.targetHandle === "tcp") {
+ const td = tn.data as GatewayTCPData;
+ if ((sn.data.ports || []).length === 1) {
+ updateNodeData<"gateway-tcp">(c.target, {
+ exposed: (td.exposed || []).concat({
+ serviceId: c.source,
+ portId: sn.data.ports![0].id,
+ }),
+ });
+ } else {
+ updateNodeData<"gateway-tcp">(c.target, {
+ selected: {
+ serviceId: c.source,
+ portId: undefined,
+ },
+ });
+ }
+ }
+ if (sn.type === "app") {
+ if (c.sourceHandle === "ports") {
+ updateNodeData<"app">(sn.id, {
+ isChoosingPortToConnect: true,
+ });
+ }
+ }
+ if (tn.type === "app") {
+ if (c.targetHandle === "repository") {
+ updateNodeData<"app">(tn.id, {
+ repository: {
+ id: c.source,
+ branch: "master",
+ rootDir: "/",
+ }
+ });
+ }
+ }
+ }
+ return {
+ projectId: undefined,
+ projects: [],
+ nodes: [],
+ edges: [],
+ categories: defaultCategories,
+ messages: v([]),
+ setHighlightCategory: (name, active) => {
+ set({
+ categories: get().categories.map(
+ (c) => {
+ if (c.title.toLowerCase() !== name.toLowerCase()) {
+ return c;
+ } else {
+ return {
+ ...c,
+ active,
+ }
+ }
+ })
+ });
+ },
+ onNodesChange: (changes) => {
+ const nodes = applyNodeChanges(changes, get().nodes);
+ setN(nodes);
+ },
+ onEdgesChange: (changes) => {
+ set({
+ edges: applyEdgeChanges(changes, get().edges),
+ });
+ },
+ setNodes: (nodes) => {
+ setN(nodes);
+ },
+ setEdges: (edges) => {
+ set({ edges });
+ },
+ replaceEdge: (c, id) => {
+ let change: EdgeChange;
+ if (id === undefined) {
+ change = {
+ type: "add",
+ item: {
+ id: uuidv4(),
+ ...c,
+ }
+ };
+ onConnect(c);
+ } else {
+ change = {
+ type: "replace",
+ id,
+ item: {
+ id,
+ ...c,
+ }
+ };
+ }
+ set({
+ edges: applyEdgeChanges([change], get().edges),
+ })
+ },
+ updateNode,
+ updateNodeData,
+ onConnect,
+ refreshEnv: async () => {
+ return get().env;
+ const resp = await fetch("/env");
+ if (!resp.ok) {
+ throw new Error("failed to fetch env config");
+ }
+ set({ env: envSchema.parse(await resp.json()) });
+ return get().env;
+ },
+ setProject: (projectId) => set({ projectId }),
+ setProjects: (projects) => set({ projects }),
+ };
+});
diff --git a/apps/canvas/src/lib/utils.ts b/apps/canvas/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/apps/canvas/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}