Canvas: Rework Deployment/Gateways tab
Change-Id: I938262b9a6ba2af060531e7dcdf91ddd66721385
diff --git a/apps/canvas/back/prisma/migrations/20250517144829_access/migration.sql b/apps/canvas/back/prisma/migrations/20250517144829_access/migration.sql
new file mode 100644
index 0000000..0127521
--- /dev/null
+++ b/apps/canvas/back/prisma/migrations/20250517144829_access/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Project" ADD COLUMN "access" TEXT;
diff --git a/apps/canvas/back/prisma/schema.prisma b/apps/canvas/back/prisma/schema.prisma
index 09ddc69..68ff022 100644
--- a/apps/canvas/back/prisma/schema.prisma
+++ b/apps/canvas/back/prisma/schema.prisma
@@ -22,4 +22,5 @@
instanceId String?
deployKey String?
githubToken String?
+ access String?
}
\ No newline at end of file
diff --git a/apps/canvas/back/src/app_manager.ts b/apps/canvas/back/src/app_manager.ts
index 2f62ddd..803192c 100644
--- a/apps/canvas/back/src/app_manager.ts
+++ b/apps/canvas/back/src/app_manager.ts
@@ -1,9 +1,54 @@
import axios from "axios";
import { z } from "zod";
+const accessSchema = z.discriminatedUnion("type", [
+ z.object({
+ type: z.literal("https"),
+ name: z.string(),
+ address: z.string(),
+ }),
+ z.object({
+ type: z.literal("ssh"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ }),
+ z.object({
+ type: z.literal("tcp"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ }),
+ z.object({
+ type: z.literal("udp"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ }),
+ z.object({
+ type: z.literal("postgresql"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ database: z.string(),
+ username: z.string(),
+ password: z.string(),
+ }),
+ z.object({
+ type: z.literal("mongodb"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ database: z.string(),
+ username: z.string(),
+ password: z.string(),
+ }),
+]);
+
export const DeployResponseSchema = z.object({
id: z.string(),
deployKey: z.string(),
+ access: z.array(accessSchema),
});
export type DeployResponse = z.infer<typeof DeployResponseSchema>;
@@ -24,6 +69,7 @@
if (response.status !== 200) {
throw new Error(`Failed to deploy application: ${response.statusText}`);
}
+ console.log(response.data);
const result = DeployResponseSchema.safeParse(response.data);
if (!result.success) {
throw new Error(`Invalid deploy response format: ${result.error.message}`);
@@ -31,13 +77,20 @@
return result.data;
}
- async update(instanceId: string, config: unknown): Promise<boolean> {
+ async update(instanceId: string, config: unknown): Promise<DeployResponse> {
const response = await axios.request({
url: `${this.baseUrl}/api/dodo-app/${instanceId}`,
method: "put",
data: { config },
});
- return response.status === 200;
+ if (response.status !== 200) {
+ throw new Error(`Failed to update application: ${response.statusText}`);
+ }
+ const result = DeployResponseSchema.safeParse(response.data);
+ if (!result.success) {
+ throw new Error(`Invalid update response format: ${result.error.message}`);
+ }
+ return result.data;
}
async getStatus(instanceId: string): Promise<unknown> {
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 6dede14..4dc942f 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -261,29 +261,25 @@
draft: null,
instanceId: deployResponse.id,
deployKey: deployResponse.deployKey,
+ access: JSON.stringify(deployResponse.access),
},
});
diff = { toAdd: extractGithubRepos(state) };
deployKey = deployResponse.deployKey;
} else {
- const success = await appManager.update(p.instanceId, req.body.config);
- if (success) {
- diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
- deployKey = p.deployKey;
- await db.project.update({
- where: {
- id: projectId,
- },
- data: {
- state,
- draft: null,
- },
- });
- } else {
- resp.status(500);
- resp.write(JSON.stringify({ error: "Failed to update deployment" }));
- return;
- }
+ const deployResponse = await appManager.update(p.instanceId, req.body.config);
+ diff = calculateRepoDiff(extractGithubRepos(p.state), extractGithubRepos(state));
+ deployKey = p.deployKey;
+ await db.project.update({
+ where: {
+ id: projectId,
+ },
+ data: {
+ state,
+ draft: null,
+ access: JSON.stringify(deployResponse.access),
+ },
+ });
}
if (diff && p.githubToken && deployKey) {
const github = new GithubClient(p.githubToken);
@@ -388,6 +384,7 @@
data: {
instanceId: null,
deployKey: null,
+ access: null,
state: null,
draft: p.draft ?? p.state,
},
@@ -465,6 +462,7 @@
select: {
deployKey: true,
githubToken: true,
+ access: true,
},
});
if (!project) {
@@ -480,6 +478,7 @@
// TODO(gio): get from env or command line flags
managerAddr: "http://10.42.0.95:8081",
deployKey: project.deployKey,
+ access: JSON.parse(project.access ?? "[]"),
integrations: {
github: !!project.githubToken,
},
diff --git a/apps/canvas/back/start.sh b/apps/canvas/back/start.sh
new file mode 100755
index 0000000..23bc786
--- /dev/null
+++ b/apps/canvas/back/start.sh
@@ -0,0 +1 @@
+DODO_PORT_WEB=8080 DODO_PORT_API=8081 npm run start
diff --git a/apps/canvas/front/package.json b/apps/canvas/front/package.json
index 55116cb..b25d17b 100644
--- a/apps/canvas/front/package.json
+++ b/apps/canvas/front/package.json
@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite --port=$DODO_PORT_WEB --host",
"build": "vite build",
- "format": "prettier --write src/**/*.{js,ts,jsx,tsx}",
+ "format": "prettier --write src/**/*.{js,ts,jsx,tsx} --list-different",
"format-check": "prettier --check src/**/*.{js,ts,jsx,tsx}",
"lint": "eslint .",
"preview": "vite preview --port=$DODO_PORT_WEB --host",
diff --git a/apps/canvas/front/src/Canvas.tsx b/apps/canvas/front/src/Canvas.tsx
index 3bdf00b..ae7e137 100644
--- a/apps/canvas/front/src/Canvas.tsx
+++ b/apps/canvas/front/src/Canvas.tsx
@@ -2,7 +2,7 @@
import { Canvas } from "@/components/canvas";
import { Details } from "@/components/details";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./components/ui/resizable";
-import { Tools } from "./Tootls";
+import { Tools } from "./Tools";
import { useStateStore } from "./lib/state";
export function CanvasBuilder() {
diff --git a/apps/canvas/front/src/Deployment.tsx b/apps/canvas/front/src/Deployment.tsx
deleted file mode 100644
index 62eccf6..0000000
--- a/apps/canvas/front/src/Deployment.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useNodes } from "@xyflow/react";
-import { AppNode, nodeLabel, ServiceNode } from "./lib/state";
-import { useMemo } from "react";
-import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "./components/ui/table";
-
-function ingress(nodes: AppNode[]) {
- const nm = new Map(nodes.map((n) => [n.id, n]));
- return nodes
- .filter((n) => n.type === "gateway-https")
- .map((i) => {
- console.log(i.data);
- if (!i.data || !i.data.network || !i.data.subdomain) {
- return null;
- }
- if (!i.data.https || !i.data.https.serviceId || !i.data.https.portId) {
- return null;
- }
- console.log("1231");
- const svc = nm.get(i.data.https.serviceId)! as ServiceNode;
- const port = svc.data.ports.find((p) => p.id === i.data.https!.portId)!;
- console.log({ svc, port });
- return {
- id: `${i.id} - ${port.id}`,
- service: svc,
- port: port,
- endpoint: `https://${i.data.subdomain}.${i.data.network}`,
- };
- })
- .filter((i) => i != null);
-}
-
-export function Deployment() {
- const nodes = useNodes<AppNode>();
- const ing = useMemo(() => ingress(nodes), [nodes]);
- return (
- <>
- <Table>
- <TableCaption>HTTPS Gateways</TableCaption>
- <TableHeader>
- <TableRow>
- <TableHead>Service</TableHead>
- <TableHead>Port</TableHead>
- <TableHead>Endpoint</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {ing.map((i) => (
- <TableRow>
- <TableCell>{nodeLabel(i.service)}</TableCell>
- <TableCell>{i.port.name}</TableCell>
- <TableCell>
- <a href={i.endpoint} target="_blank">
- {i.endpoint}
- </a>
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </>
- );
-}
diff --git a/apps/canvas/front/src/Gateways.tsx b/apps/canvas/front/src/Gateways.tsx
new file mode 100644
index 0000000..a1fce28
--- /dev/null
+++ b/apps/canvas/front/src/Gateways.tsx
@@ -0,0 +1,93 @@
+import { z } from "zod";
+import { accessSchema, useEnv } from "./lib/state";
+import { Copy, Globe, Terminal, Network, Database, Check } from "lucide-react";
+import { Button } from "./components/ui/button";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/ui/tooltip";
+import { useCallback, useState } from "react";
+
+export function Gateways() {
+ const env = useEnv();
+ const groupedAccess = env.access.reduce((acc, curr) => {
+ if (!acc.has(curr.name)) {
+ acc.set(curr.name, []);
+ }
+ acc.get(curr.name)!.push(curr);
+ return acc;
+ }, new Map<string, typeof env.access>());
+ return (
+ <ul>
+ {Array.from(groupedAccess.entries()).map(([name, access]) => (
+ <li key={name}>
+ {access.map((a) => (
+ <Gateway g={a} />
+ ))}
+ </li>
+ ))}
+ </ul>
+ );
+}
+
+function Gateway({ g }: { g: z.infer<typeof accessSchema> }) {
+ const [hidden, content] = (() => {
+ switch (g.type) {
+ case "https":
+ return [g.address, g.address];
+ case "ssh":
+ case "tcp":
+ case "udp":
+ return [`${g.host}:${g.port}`, `${g.host}:${g.port}`];
+ case "postgresql":
+ return [
+ `postgresql://${g.username}:*****@${g.host}:${g.port}/${g.database}`,
+ `postgresql://${g.username}:${g.password}@${g.host}:${g.port}/${g.database}`,
+ ];
+ case "mongodb":
+ return [
+ `mongodb://${g.username}:*****@${g.host}:${g.port}/${g.database}`,
+ `mongodb://${g.username}:${g.password}@${g.host}:${g.port}/${g.database}`,
+ ];
+ }
+ })();
+ const [clicked, setClicked] = useState(false);
+ const [open, setOpen] = useState(false);
+ const copy = useCallback(() => {
+ navigator.clipboard.writeText(content);
+ setClicked(true);
+ setOpen(true);
+ setTimeout(() => {
+ setClicked(false);
+ setOpen(false);
+ }, 1000);
+ }, [content, setClicked, setOpen]);
+ return (
+ <TooltipProvider>
+ <Tooltip delayDuration={100} open={open} onOpenChange={setOpen}>
+ <TooltipTrigger asChild>
+ <Button variant="ghost" onClick={copy}>
+ <AccessType type={g.type} className="w-4 h-4" />
+ <div className="hover:bg-gray-200 p-x-1">{hidden}</div>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent side="right" className="!bg-transparent cursor-pointer !p-0" sideOffset={1}>
+ {!clicked && <Copy className="w-4 h-4 !bg-transparent" color="black" />}
+ {clicked && <Check className="w-4 h-4 !bg-transparent" color="black" />}
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ );
+}
+
+function AccessType({ type, className }: { type: z.infer<typeof accessSchema>["type"]; className?: string }) {
+ switch (type) {
+ case "https":
+ return <Globe className={className} />;
+ case "ssh":
+ return <Terminal className={className} />;
+ case "tcp":
+ case "udp":
+ return <Network className={className} />;
+ case "postgresql":
+ case "mongodb":
+ return <Database className={className} />;
+ }
+}
diff --git a/apps/canvas/front/src/Tootls.tsx b/apps/canvas/front/src/Tools.tsx
similarity index 84%
rename from apps/canvas/front/src/Tootls.tsx
rename to apps/canvas/front/src/Tools.tsx
index b60e55c..c185d45 100644
--- a/apps/canvas/front/src/Tootls.tsx
+++ b/apps/canvas/front/src/Tools.tsx
@@ -1,6 +1,6 @@
import { Badge } from "./components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs";
-import { Deployment } from "./Deployment";
+import { Gateways } from "./Gateways";
import { useEnv, useMessages } from "./lib/state";
import { Messages } from "./Messages";
@@ -14,15 +14,15 @@
<div>Messages</div>
<Badge>{messages.length}</Badge>
</TabsTrigger>
- <TabsTrigger value="deployment">Deployment</TabsTrigger>
+ <TabsTrigger value="gateways">Gateways</TabsTrigger>
<TabsTrigger value="deployKeys">Deploy keys</TabsTrigger>
</TabsList>
<div className="!overflow-y-auto p-1">
<TabsContent value="messages">
<Messages />
</TabsContent>
- <TabsContent value="deployment">
- <Deployment />
+ <TabsContent value="gateways">
+ <Gateways />
</TabsContent>
<TabsContent value="deployKeys">{env && <>{env.deployKey}</>}</TabsContent>
</div>
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index aae7600..2f714f7 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -346,6 +346,50 @@
onClick?: (state: AppState) => void;
};
+export const accessSchema = z.discriminatedUnion("type", [
+ z.object({
+ type: z.literal("https"),
+ name: z.string(),
+ address: z.string(),
+ }),
+ z.object({
+ type: z.literal("ssh"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ }),
+ z.object({
+ type: z.literal("tcp"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ }),
+ z.object({
+ type: z.literal("udp"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ }),
+ z.object({
+ type: z.literal("postgresql"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ database: z.string(),
+ username: z.string(),
+ password: z.string(),
+ }),
+ z.object({
+ type: z.literal("mongodb"),
+ name: z.string(),
+ host: z.string(),
+ port: z.number(),
+ database: z.string(),
+ username: z.string(),
+ password: z.string(),
+ }),
+]);
+
export const envSchema = z.object({
managerAddr: z.optional(z.string().min(1)),
deployKey: z.optional(z.nullable(z.string().min(1))),
@@ -365,6 +409,7 @@
id: z.string(),
username: z.string(),
}),
+ access: z.array(accessSchema),
});
export type Env = z.infer<typeof envSchema>;
@@ -381,6 +426,7 @@
id: "",
username: "",
},
+ access: [],
};
export type Project = {