blob: 9d74d5b2925014b8ae9a8b59a324f8007ee40742 [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, Viewport as ReactFlowViewport } from "@xyflow/react";
import {
addEdge,
applyEdgeChanges,
applyNodeChanges,
Connection,
EdgeChange,
useNodes,
XYPosition,
} 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 & {
readonly?: boolean;
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 & {
readonly?: boolean;
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 Domain = {
network: string;
subdomain: string;
};
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;
dev?:
| {
enabled: false;
expose?: Domain;
}
| {
enabled: true;
expose?: Domain;
codeServerNodeId: string;
sshNodeId: string;
};
};
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;
fullName: 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 {
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.subdomain) {
return `${n.data.subdomain}`;
} else {
return "HTTPS Gateway";
}
}
case "gateway-tcp": {
if (n.data && n.data.subdomain) {
return `${n.data.subdomain}`;
} 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!");
}
} catch (e) {
console.error("opaa", e);
} finally {
console.log("done");
}
}
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.nullable(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()),
user: z.object({
id: z.string(),
username: z.string(),
}),
});
export type Env = z.infer<typeof envSchema>;
const defaultEnv: Env = {
managerAddr: undefined,
deployKey: undefined,
networks: [],
integrations: {
github: false,
},
services: [],
user: {
id: "",
username: "",
},
};
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"]>;
type Viewport = {
transformX: number;
transformY: number;
transformZoom: number;
width: number;
height: number;
};
export type AppState = {
projectId: string | undefined;
mode: "edit" | "deploy";
projects: Project[];
nodes: AppNode[];
edges: Edge[];
zoom: ReactFlowViewport;
categories: Category[];
messages: Message[];
env: Env;
viewport: Viewport;
setViewport: (viewport: Viewport) => void;
githubService: GitHubService | null;
setHighlightCategory: (name: string, active: boolean) => void;
onNodesChange: OnNodesChange<AppNode>;
onEdgesChange: OnEdgesChange;
onConnect: OnConnect;
addNode: (node: Omit<AppNode, "position">) => void;
setNodes: (nodes: AppNode[]) => void;
setEdges: (edges: Edge[]) => void;
setProject: (projectId: string | undefined) => Promise<void>;
setMode: (mode: "edit" | "deploy") => 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;
const zoomSelector = (state: AppState) => state.zoom;
export function useZoom(): ReactFlowViewport {
return useStateStore(zoomSelector);
}
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);
}
export function useMode(): "edit" | "deploy" {
return useStateStore((state) => state.mode);
}
const v: Validator = CreateValidators();
function getRandomPosition({ width, height, transformX, transformY, transformZoom }: Viewport): XYPosition {
const zoomMultiplier = 1 / transformZoom;
const realWidth = width * zoomMultiplier;
const realHeight = height * zoomMultiplier;
const paddingMultiplier = 0.8;
const ret = {
x: -transformX * zoomMultiplier + Math.random() * realWidth * paddingMultiplier,
y: -transformY * zoomMultiplier + Math.random() * realHeight * paddingMultiplier,
};
return ret;
}
export const useStateStore = create<AppState>((set, get): AppState => {
const setN = (nodes: AppNode[]) => {
set({
nodes,
messages: v(nodes),
});
};
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 });
injectNetworkNodes();
if (
get().zoom.x !== inst.viewport.x ||
get().zoom.y !== inst.viewport.y ||
get().zoom.zoom !== inst.viewport.zoom
) {
set({ zoom: inst.viewport });
}
};
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,
mode: "edit",
projects: [],
nodes: [],
edges: [],
categories: defaultCategories,
messages: v([]),
env: defaultEnv,
viewport: {
transformX: 0,
transformY: 0,
transformZoom: 1,
width: 800,
height: 600,
},
zoom: {
x: 0,
y: 0,
zoom: 1,
},
githubService: null,
setViewport: (viewport) => {
const { viewport: vp } = get();
if (
viewport.transformX !== vp.transformX ||
viewport.transformY !== vp.transformY ||
viewport.transformZoom !== vp.transformZoom ||
viewport.width !== vp.width ||
viewport.height !== vp.height
) {
set({ viewport });
}
},
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),
});
},
addNode: (node) => {
const { viewport, nodes } = get();
setN(
nodes.concat({
...node,
position: getRandomPosition(viewport),
}),
);
},
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 {
if (JSON.stringify(get().env) !== JSON.stringify(env)) {
set({ env });
injectNetworkNodes();
if (env.integrations.github) {
set({ githubService: new GitHubServiceImpl(projectId!) });
} else {
set({ githubService: null });
}
}
}
},
setMode: (mode) => {
set({ mode });
},
setProject: async (projectId) => {
if (projectId === get().projectId) {
return;
}
set({
projectId,
});
if (projectId) {
await get().refreshEnv();
if (get().env.deployKey) {
set({ mode: "deploy" });
} else {
set({ mode: "edit" });
}
restoreSaved();
} else {
set({
nodes: [],
edges: [],
});
}
},
};
});