blob: 8d728d008375b6e5e4e1e5f9992240794804b729 [file] [log] [blame]
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;
state: string | null;
};
export type PortConnectedTo = {
serviceId: string;
portId: string;
}
export type NetworkData = NodeData & {
domain: string;
};
export type NetworkNode = Node<NetworkData> & {
type: "network";
};
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 = NetworkNode | GatewayHttpsNode | GatewayTCPNode | ServiceNode | VolumeNode | PostgreSQLNode | MongoDBNode | GithubNode | 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.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 "network":
return true;
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":
if (handle === "subdomain") {
return n.data.network === undefined;
}
return n.data === undefined || n.data.https === undefined;
case "gateway-tcp":
if (handle === "subdomain") {
return n.data.network === undefined;
}
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 | null;
} | {
id: string;
source: string | null;
name: string;
isEditting: boolean;
} | {
id: string;
source: string | null;
name: string;
alias: string;
isEditting: boolean;
} | {
id: string;
source: string | null;
portId: string;
name: string;
alias: string;
isEditting: boolean;
};
export type EnvVar = {
name: string;
value: string;
};
export function nodeEnvVarNamePort(n: AppNode, portName: string): string {
return `DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS_${portName.toUpperCase()}`;
}
export function nodeEnvVarNames(n: AppNode): string[] {
switch (n.type) {
case "app": return [
`DODO_SERVICE_${n.data.label.toUpperCase()}_ADDRESS`,
...(n.data.ports || []).map((p) => nodeEnvVarNamePort(n, p.name)),
];
case "github": return [];
case "gateway-https": return [];
case "gateway-tcp": return [];
case "mongodb": return [`DODO_MONGODB_${n.data.label.toUpperCase()}_URL`];
case "postgresql": return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_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 | undefined) => 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;
const fixedEnv: Env = {
"deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
"networks": [{
"name": "Public",
"domain": "v1.dodo.cloud",
}, {
"name": "Private",
"domain": "p.v1.dodo.cloud",
}],
};
export function useEnv(): Env {
return fixedEnv;
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 (tn.type === "network") {
if (sn.type === "gateway-https") {
updateNodeData<"gateway-https">(sn.id, {
network: tn.data.domain,
});
}else if (sn.type === "gateway-tcp") {
updateNodeData<"gateway-tcp">(sn.id, {
network: tn.data.domain,
});
}
}
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 === "ports" && c.targetHandle === "env_var") {
const sourcePorts = sn.data.ports || [];
const id = uuidv4();
if (sourcePorts.length === 1) {
updateNode(c.target, {
...tn,
data: {
...tn.data,
envVars: [
...(tn.data.envVars || []),
{
id: id,
source: c.source,
name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
portId: sourcePorts[0].id,
isEditting: false,
},
],
},
});
}
}
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 }),
};
});