blob: fb461c2dd0fdfc981a0a9d62011b4df3a51fc845 [file] [log] [blame]
import { Category, defaultCategories } from "./categories";
import { CreateValidators, Validator } from "./config";
import { GitHubService, GitHubServiceImpl } from "./github";
import type { Edge, Node, OnConnect, OnEdgesChange, OnNodesChange } from "@xyflow/react";
import { addEdge, applyEdgeChanges, applyNodeChanges, Connection, EdgeChange, useNodes } from "@xyflow/react";
import type { DeepPartial } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { create } from "zustand";
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;
auth?: {
enabled: boolean;
groups: string[];
noAuthPathPatterns: string[];
};
};
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 = [
"deno:2.2.0",
"golang:1.20.0",
"golang:1.22.0",
"golang:1.24.0",
"hugo:latest",
"php:8.2-apache",
"nextjs:deno-2.0.0",
"node-23.1.0",
] as const;
export type ServiceType = (typeof ServiceTypes)[number];
export type ServiceData = NodeData & {
type: ServiceType;
repository:
| {
id: string;
}
| {
id: string;
branch: string;
}
| {
id: string;
branch: string;
rootDir: string;
};
env: string[];
volume: string[];
preBuildCommands: 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 & {
repository?: {
id: number;
sshURL: 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.repository?.sshURL || "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.repository?.id !== undefined) {
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");
default:
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({
managerAddr: z.optional(z.string().min(1)),
deployKey: z.optional(z.string().min(1)),
networks: z
.array(
z.object({
name: z.string().min(1),
domain: z.string().min(1),
}),
)
.default([]),
integrations: z.object({
github: z.boolean(),
}),
services: z.array(z.string()),
});
export type Env = z.infer<typeof envSchema>;
const defaultEnv: Env = {
managerAddr: undefined,
deployKey: undefined,
networks: [],
integrations: {
github: false,
},
services: [],
};
export type Project = {
id: string;
name: string;
};
export type IntegrationsConfig = {
github: boolean;
};
type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
export type AppState = {
projectId: string | undefined;
projects: Project[];
nodes: AppNode[];
edges: Edge[];
categories: Category[];
messages: Message[];
env: Env;
githubService: GitHubService | null;
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;
updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
replaceEdge: (c: Connection, id?: string) => void;
refreshEnv: () => Promise<void>;
};
const projectIdSelector = (state: AppState) => state.projectId;
const categoriesSelector = (state: AppState) => state.categories;
const messagesSelector = (state: AppState) => state.messages;
const githubServiceSelector = (state: AppState) => state.githubService;
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;
}
export function useEnv(): Env {
return useStateStore(envSelector);
}
export function useGithubService(): GitHubService | null {
return useStateStore(githubServiceSelector);
}
const v: Validator = CreateValidators();
export const useStateStore = create<AppState>((set, get): AppState => {
const setN = (nodes: AppNode[]) => {
set((state) => ({
...state,
nodes,
messages: v(nodes),
}));
};
function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
setN(
get().nodes.map((n) => {
if (n.id === id) {
return {
...n,
data: {
...n.data,
...data,
},
} as Extract<AppNode, { type: T }>;
}
return n;
}),
);
}
function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
setN(
get().nodes.map((n) => {
if (n.id === id) {
return {
...n,
...node,
} as Extract<AppNode, { type: T }>;
}
return n;
}),
);
}
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 (tn.type === "app") {
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<"app">(c.target, {
...tn,
data: {
...tn.data,
envVars: [
...(tn.data.envVars || []),
{
id: id,
source: c.source,
name: sourceEnvVars[0],
isEditting: false,
},
],
},
});
} else {
updateNode<"app">(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<"app">(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([]),
env: defaultEnv,
githubService: null,
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 () => {
const projectId = get().projectId;
let env: Env = defaultEnv;
try {
if (projectId) {
const response = await fetch(`/api/project/${projectId}/env`);
if (response.ok) {
const data = await response.json();
const result = envSchema.safeParse(data);
if (result.success) {
env = result.data;
} else {
console.error("Invalid env data:", result.error);
}
}
}
} catch (error) {
console.error("Failed to fetch integrations:", error);
} finally {
set({ env: env });
if (env.integrations.github) {
set({ githubService: new GitHubServiceImpl(projectId!) });
} else {
set({ githubService: null });
}
}
},
setProject: (projectId) => {
set({
projectId,
});
if (projectId) {
get().refreshEnv();
}
},
};
});