Canvas: Implement worker to manager communication
Register workers on manager side.
Let user force reload service workers.
Change-Id: I2635a04167e7c853151d8a1f5c3511646181a063
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 65eab15..0a35dfe 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -3,9 +3,13 @@
import { env } from "node:process";
import axios from "axios";
import { GithubClient } from "./github";
+import { z } from "zod";
const db = new PrismaClient();
+// Map to store worker addresses by project ID
+const workers = new Map<number, string[]>();
+
const handleProjectCreate: express.Handler = async (req, resp) => {
try {
const { id } = await db.project.create({
@@ -346,6 +350,8 @@
resp.status(200);
resp.write(
JSON.stringify({
+ // TODO(gio): get from env or command line flags
+ managerAddr: "http://10.42.0.239:8080",
deployKey: project.deployKey,
integrations: {
github: !!project.githubToken,
@@ -371,6 +377,88 @@
}
};
+const WorkerSchema = z.object({
+ address: z.string().url(),
+});
+
+const handleRegisterWorker: express.Handler = async (req, resp) => {
+ try {
+ const projectId = Number(req.params["projectId"]);
+
+ const result = WorkerSchema.safeParse(req.body);
+ console.log(result);
+ if (!result.success) {
+ resp.status(400);
+ resp.write(
+ JSON.stringify({
+ error: "Invalid request data",
+ details: result.error.format(),
+ }),
+ );
+ return;
+ }
+
+ const { address } = result.data;
+
+ // Get existing workers or initialize empty array
+ const projectWorkers = workers.get(projectId) || [];
+
+ // Add new worker if not already present
+ if (!projectWorkers.includes(address)) {
+ projectWorkers.push(address);
+ }
+
+ workers.set(projectId, projectWorkers);
+
+ resp.status(200);
+ resp.write(
+ JSON.stringify({
+ success: true,
+ workers: projectWorkers,
+ }),
+ );
+ } catch (e) {
+ console.log(e);
+ resp.status(500);
+ resp.write(JSON.stringify({ error: "Failed to register worker" }));
+ } finally {
+ resp.end();
+ }
+};
+
+const handleReload: express.Handler = async (req, resp) => {
+ try {
+ const projectId = Number(req.params["projectId"]);
+ const projectWorkers = workers.get(projectId) || [];
+
+ if (projectWorkers.length === 0) {
+ resp.status(404);
+ resp.write(JSON.stringify({ error: "No workers registered for this project" }));
+ return;
+ }
+
+ await Promise.all(
+ projectWorkers.map(async (workerAddress) => {
+ try {
+ const updateEndpoint = `${workerAddress}/update`;
+ await axios.post(updateEndpoint);
+ } catch (error: any) {
+ console.log(`Failed to update worker ${workerAddress}: ${error.message || "Unknown error"}`);
+ }
+ }),
+ );
+
+ resp.status(200);
+ resp.write(JSON.stringify({ success: true }));
+ } catch (e) {
+ console.log(e);
+ resp.status(500);
+ resp.write(JSON.stringify({ error: "Failed to reload workers" }));
+ } finally {
+ resp.end();
+ }
+};
+
async function start() {
await db.$connect();
const app = express();
@@ -385,6 +473,8 @@
app.get("/api/project/:projectId/repos/github", handleGithubRepos);
app.post("/api/project/:projectId/github-token", handleUpdateGithubToken);
app.get("/api/project/:projectId/env", handleEnv);
+ app.post("/api/project/:projectId/workers", handleRegisterWorker);
+ app.post("/api/project/:projectId/reload", handleReload);
app.use("/", express.static("../front/dist"));
app.listen(env.DODO_PORT_WEB, () => {
console.log("started");
diff --git a/apps/canvas/front/src/Config.tsx b/apps/canvas/front/src/Config.tsx
index d2f1aed..7306456 100644
--- a/apps/canvas/front/src/Config.tsx
+++ b/apps/canvas/front/src/Config.tsx
@@ -1,10 +1,11 @@
import { useNodes } from "@xyflow/react";
-import { AppNode, useEnv } from "./lib/state";
+import { AppNode, useEnv, useProjectId } from "./lib/state";
import { generateDodoConfig } from "./lib/config";
import { useEffect, useMemo, useState } from "react";
export function Config() {
const env = useEnv();
+ const projectId = useProjectId();
const [nodes, setNodes] = useState<AppNode[]>([]);
const n = useNodes<AppNode>();
useEffect(() => {
@@ -13,7 +14,7 @@
setNodes(n);
}
}, [n, setNodes]);
- const config = useMemo(() => generateDodoConfig(nodes, env), [nodes, env]);
+ const config = useMemo(() => generateDodoConfig(projectId, nodes, env), [nodes, env]);
const configS = useMemo(() => JSON.stringify(config, undefined, 4), [config]);
return (
<div className="px-5">
diff --git a/apps/canvas/front/src/components/actions.tsx b/apps/canvas/front/src/components/actions.tsx
index eb89b8a..509b51c 100644
--- a/apps/canvas/front/src/components/actions.tsx
+++ b/apps/canvas/front/src/components/actions.tsx
@@ -25,6 +25,7 @@
const instance = useReactFlow();
const [ok, setOk] = useState(false);
const [loading, setLoading] = useState(false);
+ const [reloading, setReloading] = useState(false);
useEffect(() => {
setOk(!messages.some((m) => m.type === "FATAL"));
}, [messages, setOk]);
@@ -64,7 +65,7 @@
}
setLoading(true);
try {
- const config = generateDodoConfig(nodes, env);
+ const config = generateDodoConfig(projectId, nodes, env);
if (config == null) {
throw new Error("MUST NOT REACH!");
}
@@ -163,21 +164,64 @@
});
}
}, [store, clear, projectId, toast]);
- const [props, setProps] = useState({});
+ const reload = useCallback(async () => {
+ if (projectId == null) {
+ return;
+ }
+ setReloading(true);
+ try {
+ const resp = await fetch(`/api/project/${projectId}/reload`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+ if (resp.ok) {
+ toast({
+ title: "Reload triggered successfully",
+ });
+ } else {
+ toast({
+ variant: "destructive",
+ title: "Reload failed",
+ description: await resp.text(),
+ });
+ }
+ } catch (e) {
+ console.log(e);
+ toast({
+ variant: "destructive",
+ title: "Reload failed",
+ });
+ } finally {
+ setReloading(false);
+ }
+ }, [projectId, toast]);
+ const [deployProps, setDeployProps] = useState({});
+ const [reloadProps, setReloadProps] = useState({});
useEffect(() => {
if (loading) {
- setProps({ loading: true });
+ setDeployProps({ loading: true });
} else if (ok) {
- setProps({ disabled: false });
+ setDeployProps({ disabled: false });
} else {
- setProps({ disabled: true });
+ setDeployProps({ disabled: true });
}
- }, [ok, loading, setProps]);
+
+ if (reloading) {
+ setReloadProps({ loading: true });
+ } else {
+ setReloadProps({ disabled: projectId === undefined });
+ }
+ }, [ok, loading, reloading, projectId]);
return (
<>
- <Button onClick={deploy} {...props}>
+ <Button onClick={deploy} {...deployProps}>
Deploy
</Button>
+ <Button onClick={reload} {...reloadProps}>
+ Reload
+ </Button>
<Button onClick={save}>Save</Button>
<Button onClick={restoreSaved}>Restore</Button>
<Button onClick={clear} variant="destructive">
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index 187f51c..a3784c1 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -78,14 +78,21 @@
};
export type Config = {
+ input: {
+ appId: string;
+ managerAddr: string;
+ };
service?: Service[];
volume?: Volume[];
postgresql?: PostgreSQL[];
mongodb?: MongoDB[];
};
-export function generateDodoConfig(nodes: AppNode[], env: Env): Config | null {
+export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null {
try {
+ if (appId == null || env.managerAddr == null) {
+ return null;
+ }
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);
@@ -105,6 +112,10 @@
});
};
return {
+ input: {
+ appId: appId,
+ managerAddr: env.managerAddr,
+ },
service: nodes
.filter((n) => n.type === "app")
.map((n): Service => {
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index f9c1f56..0b3507d 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -314,6 +314,7 @@
};
export const envSchema = z.object({
+ managerAddr: z.optional(z.string().min(1)),
deployKey: z.optional(z.string().min(1)),
networks: z
.array(
@@ -331,6 +332,7 @@
export type Env = z.infer<typeof envSchema>;
const defaultEnv: Env = {
+ managerAddr: undefined,
deployKey: undefined,
networks: [],
integrations: {