Canvas: Add VM/PROXY dev modes support
- Update ServiceSchema to discriminate between VM and PROXY dev modes
- Add DevDisabled, DevVM, DevProxy TypeScript types
- Update ServiceData type in graph.ts for new dev structure
- Update generateDodoConfig to handle both VM and PROXY modes
- Update configToGraph to properly convert dev configurations
- Maintain backward compatibility with existing dev configurations
- Update UI and introduce two new DevVM and DevProxy components
- Fetch user machine list from headscale API
Change-Id: I8f9df4ab9bd34c049fffadb748115335e8260a54
diff --git a/apps/canvas/back/.env.dev b/apps/canvas/back/.env.dev
index 2db1e80..e672e62 100644
--- a/apps/canvas/back/.env.dev
+++ b/apps/canvas/back/.env.dev
@@ -1,2 +1,4 @@
DATABASE_URL=file:/home/gio/dodo.db
-INTERNAL_API_ADDR=http://10.42.1.95:8081
+VPN_API_ADDR=http://headscale-api.hgrz-app-headscale.svc.cluster.local
+INTERNAL_API_ADDR=http://10.42.0.193:8081
+
diff --git a/apps/canvas/back/src/index.ts b/apps/canvas/back/src/index.ts
index 97df61f..f00549f 100644
--- a/apps/canvas/back/src/index.ts
+++ b/apps/canvas/back/src/index.ts
@@ -26,6 +26,7 @@
import { Instant, DateTimeFormatter, ZoneId } from "@js-joda/core";
import LogStore from "./log.js";
import { GraphOrConfigSchema, GraphSchema, GraphConfigOrDraft, AgentAccess } from "config/dist/graph.js";
+import { MachineManager } from "./machine_manager.js";
async function generateKey(root: string): Promise<[string, string]> {
const privKeyPath = path.join(root, "key");
@@ -41,6 +42,7 @@
const db = new PrismaClient();
const logStore = new LogStore(db);
const appManager = new AppManager();
+const machineManager = new MachineManager(env.VPN_API_ADDR!);
const projectMonitors = new Map<number, ProjectMonitor>();
@@ -791,6 +793,20 @@
};
};
+const handleMachines: express.Handler = async (req, resp) => {
+ try {
+ const machines = await machineManager.getUserMachines(resp.locals.username);
+ resp.status(200);
+ resp.write(JSON.stringify(machines));
+ } catch (error) {
+ console.error("Error getting machines:", error);
+ resp.status(500);
+ resp.write(JSON.stringify({ error: "Internal server error" }));
+ } finally {
+ resp.end();
+ }
+};
+
const handleEnv: express.Handler = async (req, resp) => {
const projectId = Number(req.params["projectId"]);
try {
@@ -1268,6 +1284,9 @@
// Public webhook route - no auth needed
app.post("/api/webhook/github/push", handleGithubPushWebhook);
+ // Public machines route - no auth needed
+ app.get("/api/machines", auth, handleMachines);
+
// Authenticated project routes
const projectRouter = express.Router();
projectRouter.use(auth);
diff --git a/apps/canvas/back/src/machine_manager.ts b/apps/canvas/back/src/machine_manager.ts
new file mode 100644
index 0000000..ab4d684
--- /dev/null
+++ b/apps/canvas/back/src/machine_manager.ts
@@ -0,0 +1,10 @@
+import { Machines, MachinesSchema } from "config";
+
+export class MachineManager {
+ constructor(private readonly apiAddr: string) {}
+
+ async getUserMachines(username: string): Promise<Machines> {
+ const response = await fetch(`${this.apiAddr}/user/${username}/node`);
+ return MachinesSchema.parse(await response.json());
+ }
+}
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
index f5957d4..7fd7066 100644
--- a/apps/canvas/config/src/config.ts
+++ b/apps/canvas/config/src/config.ts
@@ -112,25 +112,35 @@
? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
: [],
dev: n.data.dev?.enabled
- ? {
- enabled: true,
- mode: "VM",
- username: env.user.username,
- codeServer:
- n.data.dev.expose != null
- ? {
- network: networkMap.get(n.data.dev.expose.network)!,
- subdomain: n.data.dev.expose.subdomain,
- }
- : undefined,
- ssh:
- n.data.dev.expose != null
- ? {
- network: networkMap.get(n.data.dev.expose.network)!,
- subdomain: n.data.dev.expose.subdomain,
- }
- : undefined,
- }
+ ? n.data.dev.mode === "VM"
+ ? {
+ enabled: true,
+ mode: "VM",
+ username: env.user.username,
+ codeServer:
+ n.data.dev.expose != null
+ ? {
+ network: networkMap.get(n.data.dev.expose.network)!,
+ subdomain: n.data.dev.expose.subdomain,
+ }
+ : undefined,
+ ssh:
+ n.data.dev.expose != null
+ ? {
+ network: networkMap.get(n.data.dev.expose.network)!,
+ subdomain: n.data.dev.expose.subdomain,
+ }
+ : undefined,
+ }
+ : {
+ enabled: true,
+ mode: "PROXY",
+ address: n.data.dev.address,
+ vpn: {
+ enabled: true,
+ username: env.user.username,
+ },
+ }
: {
enabled: false,
},
@@ -311,7 +321,28 @@
? { name: "gemini", apiKey: s.model.geminiApiKey }
: { name: "claude", apiKey: s.model.anthropicApiKey },
}),
- // TODO(gio): dev
+ dev: s.dev?.enabled
+ ? s.dev.mode === "VM"
+ ? {
+ enabled: true,
+ mode: "VM",
+ expose: s.dev.ssh
+ ? {
+ network: s.dev.ssh.network,
+ subdomain: s.dev.ssh.subdomain,
+ }
+ : undefined,
+ codeServerNodeId: uuidv4(), // TODO: proper node tracking
+ sshNodeId: uuidv4(), // TODO: proper node tracking
+ }
+ : {
+ enabled: true,
+ mode: "PROXY",
+ address: s.dev.address,
+ }
+ : {
+ enabled: false,
+ },
isChoosingPortToConnect: false,
},
// TODO(gio): generate position
diff --git a/apps/canvas/config/src/graph.ts b/apps/canvas/config/src/graph.ts
index a4634cb..fc3258e 100644
--- a/apps/canvas/config/src/graph.ts
+++ b/apps/canvas/config/src/graph.ts
@@ -210,9 +210,15 @@
}
| {
enabled: true;
+ mode: "VM";
expose?: Domain;
codeServerNodeId: string;
sshNodeId: string;
+ }
+ | {
+ enabled: true;
+ mode: "PROXY";
+ address: string;
};
model?: {
name: "gemini" | "claude";
@@ -496,17 +502,23 @@
preBuildCommands: z.string().optional(),
isChoosingPortToConnect: z.boolean().optional(),
dev: z
- .discriminatedUnion("enabled", [
+ .union([
z.object({
enabled: z.literal(false),
expose: DomainSchema.optional(),
}),
z.object({
enabled: z.literal(true),
+ mode: z.literal("VM"),
expose: DomainSchema.optional(),
codeServerNodeId: z.string(),
sshNodeId: z.string(),
}),
+ z.object({
+ enabled: z.literal(true),
+ mode: z.literal("PROXY"),
+ address: z.string(),
+ }),
])
.optional(),
model: z
diff --git a/apps/canvas/config/src/index.ts b/apps/canvas/config/src/index.ts
index f6cc9a5..df1be47 100644
--- a/apps/canvas/config/src/index.ts
+++ b/apps/canvas/config/src/index.ts
@@ -7,6 +7,10 @@
ConfigWithInputSchema,
Domain,
Ingress,
+ Machine,
+ MachineSchema,
+ Machines,
+ MachinesSchema,
MongoDB,
PortDomain,
PortValue,
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
index a49c2d5..4146f75 100644
--- a/apps/canvas/config/src/types.ts
+++ b/apps/canvas/config/src/types.ts
@@ -98,15 +98,26 @@
expose: z.array(PortDomainSchema).optional(),
volume: z.array(z.string()).optional(),
preBuildCommands: z.array(z.object({ bin: z.string() })).optional(),
- dev: z.discriminatedUnion("enabled", [
+ dev: z.union([
z.object({ enabled: z.literal(false) }),
z.object({
enabled: z.literal(true),
- mode: z.string(),
+ mode: z.literal("VM"),
username: z.string().optional(),
ssh: DomainSchema.optional(),
codeServer: DomainSchema.optional(),
}),
+ z.object({
+ enabled: z.literal(true),
+ mode: z.literal("PROXY"),
+ address: z.string(),
+ vpn: z
+ .discriminatedUnion("enabled", [
+ z.object({ enabled: z.literal(false) }),
+ z.object({ enabled: z.literal(true), username: z.string() }),
+ ])
+ .optional(),
+ }),
]),
model: ModelSchema.optional(),
});
@@ -139,6 +150,12 @@
expose: z.array(DomainSchema).optional(),
});
+export const MachineSchema = z.object({
+ name: z.string(),
+});
+
+export const MachinesSchema = z.array(MachineSchema);
+
export const ConfigSchema = z.object({
service: z.array(ServiceSchema).optional(),
agent: z.array(AgentSchema).optional(),
@@ -180,5 +197,23 @@
export type MongoDB = z.infer<typeof MongoDBSchema>;
export type Config = z.infer<typeof ConfigSchema>;
export type ConfigWithInput = z.infer<typeof ConfigWithInputSchema>;
+export type Machine = z.infer<typeof MachineSchema>;
+export type Machines = z.infer<typeof MachinesSchema>;
+
+// Dev configuration types
+export type DevDisabled = { enabled: false };
+export type DevVM = {
+ enabled: true;
+ mode: "VM";
+ username?: string;
+ ssh?: Domain;
+ codeServer?: Domain;
+};
+export type DevProxy = {
+ enabled: true;
+ mode: "PROXY";
+ address: string;
+};
+export type Dev = DevDisabled | DevVM | DevProxy;
export const isAgent = (s: Service): s is Agent => s.type === "sketch:latest";
diff --git a/apps/canvas/front/package-lock.json b/apps/canvas/front/package-lock.json
index 247a513..2f44d68 100644
--- a/apps/canvas/front/package-lock.json
+++ b/apps/canvas/front/package-lock.json
@@ -18,6 +18,7 @@
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.2",
+ "@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
@@ -2762,6 +2763,311 @@
}
}
},
+ "node_modules/@radix-ui/react-radio-group": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz",
+ "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.10",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz",
+ "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-collection": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
+ "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-direction": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
+ "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz",
+ "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-roving-focus": {
+ "version": "1.1.10",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
+ "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-callback-ref": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
+ "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-previous": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+ "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-use-size": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+ "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz",
diff --git a/apps/canvas/front/package.json b/apps/canvas/front/package.json
index ea53fe0..43aa538 100644
--- a/apps/canvas/front/package.json
+++ b/apps/canvas/front/package.json
@@ -26,6 +26,7 @@
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.2",
+ "@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index 15e2fa3..317b7e0 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -1,8 +1,19 @@
import { v4 as uuidv4 } from "uuid";
import { NodeRect } from "./node-rect";
import { useStateStore, nodeLabel, AppState, nodeIsConnectable, useEnv, useGithubRepositories } from "@/lib/state";
-import { ServiceNode, ServiceTypes, GatewayHttpsNode, GatewayTCPNode, BoundEnvVar, AppNode, GithubNode } from "config";
-import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState } from "react";
+import {
+ ServiceNode,
+ ServiceTypes,
+ GatewayHttpsNode,
+ GatewayTCPNode,
+ BoundEnvVar,
+ AppNode,
+ GithubNode,
+ Machines,
+ Machine,
+ MachinesSchema,
+} from "config";
+import { KeyboardEvent, FocusEvent, useCallback, useEffect, useMemo, useState, useRef } from "react";
import { z } from "zod";
import { useForm, EventType, DeepPartial } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -24,6 +35,9 @@
import { Gateway } from "@/Gateways";
import { Port } from "config";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
+import { useToast } from "@/hooks/use-toast";
+import { LoaderCircle } from "lucide-react";
const sourceSchema = z.object({
id: z.string().min(1, "required"),
@@ -33,6 +47,7 @@
const devSchema = z.object({
enabled: z.boolean(),
+ mode: z.enum(["VM", "PROXY"]).optional(),
});
const exposeSchema = z.object({
@@ -45,6 +60,10 @@
apiKey: z.string().optional(),
});
+const proxySchema = z.object({
+ address: z.string().min(1, "required"),
+});
+
const portExposeSchema = z
.object({
type: z.enum(["https", "tcp"]),
@@ -322,10 +341,16 @@
export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
const { data } = node;
+ const defaultTab = useMemo(() => {
+ if (data.dev?.enabled) {
+ return "dev";
+ }
+ return "runtime";
+ }, [data]);
return (
<>
{showName ? <Name node={node} disabled={disabled} /> : null}
- <Tabs defaultValue="runtime">
+ <Tabs defaultValue={defaultTab}>
<TabsList className="w-full flex flex-row justify-between">
<TabsTrigger value="runtime">
{isOverview ? (
@@ -1157,140 +1182,163 @@
);
}
-function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+function usePrevious<T>(value: T) {
+ const ref = useRef<T>();
+ useEffect(() => {
+ ref.current = value;
+ }, [value]);
+ return ref.current;
+}
+
+function DevVM({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
const { id, data } = node;
+ const { dev } = data;
+ const prevDev = usePrevious(dev);
const env = useEnv();
const store = useStateStore();
- const devForm = useForm<z.infer<typeof devSchema>>({
- resolver: zodResolver(devSchema),
- mode: "onChange",
- defaultValues: {
- enabled: data.dev ? data.dev.enabled : false,
- },
- });
useEffect(() => {
- const sub = devForm.watch((value, { name }) => {
- if (name === "enabled") {
- if (value.enabled) {
- const csGateway: Omit<GatewayHttpsNode, "position"> = {
- id: uuidv4(),
- type: "gateway-https",
- data: {
- readonly: true,
- https: {
- serviceId: id,
- portId: `${id}-code-server`,
- },
- network: data.dev?.expose?.network,
- subdomain: data.dev?.expose?.subdomain,
- label: "",
- envVars: [],
- ports: [],
- },
- };
- const sshGateway: Omit<GatewayTCPNode, "position"> = {
- id: uuidv4(),
- type: "gateway-tcp",
- data: {
- readonly: true,
- exposed: [
- {
- serviceId: id,
- portId: `${id}-ssh`,
- },
- ],
- network: data.dev?.expose?.network,
- subdomain: data.dev?.expose?.subdomain,
- label: "",
- envVars: [],
- ports: [],
- },
- };
- store.addNode(csGateway);
- store.addNode(sshGateway);
+ console.log("DDDEV", prevDev, dev);
+ if (!dev && !prevDev) {
+ return;
+ }
+ if (
+ dev &&
+ prevDev &&
+ dev.enabled === prevDev.enabled &&
+ "mode" in dev &&
+ "mode" in prevDev &&
+ dev.mode === prevDev.mode
+ ) {
+ return;
+ }
+ if (!dev?.enabled || dev.mode !== "VM") {
+ if (prevDev?.enabled && prevDev.mode === "VM") {
+ store.setNodes(
+ store.nodes.filter((n) => n.id !== prevDev.codeServerNodeId && n.id !== prevDev.sshNodeId),
+ );
+ store.setEdges(
+ store.edges.filter((e) => e.target !== prevDev.codeServerNodeId && e.target !== prevDev.sshNodeId),
+ );
+ if (dev?.enabled) {
store.updateNodeData<"app">(id, {
dev: {
- enabled: true,
- expose: data.dev?.expose,
- codeServerNodeId: csGateway.id,
- sshNodeId: sshGateway.id,
+ enabled: dev.enabled,
+ mode: dev.mode,
},
- ports: (data.ports || []).concat(
- {
- id: `${id}-code-server`,
- name: "code-server",
- value: 9090,
- },
- {
- id: `${id}-ssh`,
- name: "ssh",
- value: 22,
- },
- ),
+ ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
});
- let edges = store.edges.concat([
- {
- id: uuidv4(),
- source: id,
- sourceHandle: "ports",
- target: csGateway.id,
- targetHandle: "https",
- },
- {
- id: uuidv4(),
- source: id,
- sourceHandle: "ports",
- target: sshGateway.id,
- targetHandle: "tcp",
- },
- ]);
- if (data.dev?.expose?.network !== undefined) {
- edges = edges.concat([
- {
- id: uuidv4(),
- source: csGateway.id,
- sourceHandle: "subdomain",
- target: data.dev.expose.network,
- targetHandle: "subdomain",
- },
- {
- id: uuidv4(),
- source: sshGateway.id,
- sourceHandle: "subdomain",
- target: data.dev.expose.network,
- targetHandle: "subdomain",
- },
- ]);
- }
- store.setEdges(edges);
} else {
- const { dev } = data;
- if (dev?.enabled) {
- store.setNodes(
- store.nodes.filter((n) => n.id !== dev.codeServerNodeId && n.id !== dev.sshNodeId),
- );
- store.setEdges(
- store.edges.filter((e) => e.target !== dev.codeServerNodeId && e.target !== dev.sshNodeId),
- );
- }
store.updateNodeData<"app">(id, {
dev: {
enabled: false,
- expose: dev?.expose,
},
ports: (data.ports || []).filter((p) => p.name !== "code-server" && p.name !== "ssh"),
});
}
}
- });
- return () => sub.unsubscribe();
- }, [id, data, devForm, store]);
+ } else {
+ if (!prevDev?.enabled || prevDev.mode !== "VM") {
+ const csGateway: Omit<GatewayHttpsNode, "position"> = {
+ id: uuidv4(),
+ type: "gateway-https",
+ data: {
+ readonly: true,
+ https: {
+ serviceId: id,
+ portId: `${id}-code-server`,
+ },
+ network: dev?.expose?.network,
+ subdomain: dev?.expose?.subdomain,
+ label: "",
+ envVars: [],
+ ports: [],
+ },
+ };
+ const sshGateway: Omit<GatewayTCPNode, "position"> = {
+ id: uuidv4(),
+ type: "gateway-tcp",
+ data: {
+ readonly: true,
+ exposed: [
+ {
+ serviceId: id,
+ portId: `${id}-ssh`,
+ },
+ ],
+ network: dev?.expose?.network,
+ subdomain: dev?.expose?.subdomain,
+ label: "",
+ envVars: [],
+ ports: [],
+ },
+ };
+ store.addNode(csGateway);
+ store.addNode(sshGateway);
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: true,
+ mode: "VM",
+ expose: dev?.expose,
+ codeServerNodeId: csGateway.id,
+ sshNodeId: sshGateway.id,
+ },
+ ports: (data.ports || []).concat(
+ {
+ id: `${id}-code-server`,
+ name: "code-server",
+ value: 9090,
+ },
+ {
+ id: `${id}-ssh`,
+ name: "ssh",
+ value: 22,
+ },
+ ),
+ });
+ let edges = store.edges.concat([
+ {
+ id: uuidv4(),
+ source: id,
+ sourceHandle: "ports",
+ target: csGateway.id,
+ targetHandle: "https",
+ },
+ {
+ id: uuidv4(),
+ source: id,
+ sourceHandle: "ports",
+ target: sshGateway.id,
+ targetHandle: "tcp",
+ },
+ ]);
+ if (dev?.expose?.network !== undefined) {
+ edges = edges.concat([
+ {
+ id: uuidv4(),
+ source: csGateway.id,
+ sourceHandle: "subdomain",
+ target: dev.expose.network,
+ targetHandle: "subdomain",
+ },
+ {
+ id: uuidv4(),
+ source: sshGateway.id,
+ sourceHandle: "subdomain",
+ target: dev.expose.network,
+ targetHandle: "subdomain",
+ },
+ ]);
+ }
+ store.setEdges(edges);
+ }
+ }
+ }, [id, data, dev, prevDev, store]);
const exposeForm = useForm<z.infer<typeof exposeSchema>>({
resolver: zodResolver(exposeSchema),
mode: "onChange",
defaultValues: {
- network: data.dev?.expose?.network,
- subdomain: data.dev?.expose?.subdomain,
+ network: dev && "expose" in dev ? dev.expose?.network : undefined,
+ subdomain: dev && "expose" in dev ? dev.expose?.subdomain : undefined,
},
});
useEffect(() => {
@@ -1300,7 +1348,7 @@
{ name }: { name?: keyof z.infer<typeof exposeSchema> | undefined; type?: EventType | undefined },
) => {
const { dev } = data;
- if (!dev?.enabled) {
+ if (!dev?.enabled || dev.mode !== "VM") {
return;
}
if (name === "network") {
@@ -1365,31 +1413,12 @@
},
);
return () => sub.unsubscribe();
- }, [id, data, exposeForm, store]);
+ }, [id, data, dev, prevDev, exposeForm, store]);
+ if (!dev?.enabled || dev.mode !== "VM") {
+ return null;
+ }
return (
- <>
- <Form {...devForm}>
- <form className="space-y-2">
- <FormField
- control={devForm.control}
- name="enabled"
- render={({ field }) => (
- <FormItem>
- <div className="flex flex-row gap-1 items-center">
- <Switch
- id="devEnabled"
- onCheckedChange={field.onChange}
- checked={field.value}
- disabled={disabled}
- />
- <Label htmlFor="devEnabled">Dev VM</Label>
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
- </form>
- </Form>
+ <div>
{data.dev && data.dev.enabled && (
<Form {...exposeForm}>
<form className="space-y-2">
@@ -1438,6 +1467,250 @@
</form>
</Form>
)}
+ </div>
+ );
+}
+
+function DevProxy({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+ const { id, data } = node;
+ const store = useStateStore();
+ const { toast } = useToast();
+ const [machines, setMachines] = useState<Machines>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const fetchMachines = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await fetch("/api/machines", {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch machines: ${response.statusText}`);
+ }
+
+ const machinesData = MachinesSchema.safeParse(await response.json());
+ if (machinesData.success) {
+ setMachines(machinesData.data);
+ } else {
+ throw new Error("Invalid machines data");
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : "Failed to fetch machines";
+ setError(errorMessage);
+ toast({
+ variant: "destructive",
+ title: "Error",
+ description: errorMessage,
+ });
+ } finally {
+ setLoading(false);
+ }
+ }, [toast]);
+
+ useEffect(() => {
+ if (data.dev?.enabled && "mode" in data.dev && data.dev.mode === "PROXY") {
+ fetchMachines();
+ }
+ }, [data.dev, fetchMachines]);
+
+ const proxyForm = useForm<z.infer<typeof proxySchema>>({
+ resolver: zodResolver(proxySchema),
+ mode: "onChange",
+ defaultValues: {
+ address: data.dev && "address" in data.dev ? data.dev.address : undefined,
+ },
+ });
+
+ useEffect(() => {
+ const sub = proxyForm.watch((value, { name }) => {
+ if (name === "address" && value.address) {
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: true,
+ mode: "PROXY",
+ address: value.address,
+ },
+ });
+ }
+ });
+ return () => sub.unsubscribe();
+ }, [id, proxyForm, store]);
+
+ if (!data.dev?.enabled || data.dev.mode !== "PROXY") {
+ return null;
+ }
+ return (
+ <div className="space-y-2">
+ <Form {...proxyForm}>
+ <form className="space-y-2">
+ <FormField
+ control={proxyForm.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <Select
+ onValueChange={field.onChange}
+ value={field.value || ""}
+ disabled={disabled || loading}
+ >
+ <FormControl>
+ <SelectTrigger>
+ {loading ? (
+ <div className="flex items-center gap-2">
+ <LoaderCircle className="h-4 w-4 animate-spin" />
+ <span>Loading machines...</span>
+ </div>
+ ) : (
+ <SelectValue placeholder="Select a machine" />
+ )}
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {loading ? (
+ <div className="flex items-center justify-center p-4">
+ <LoaderCircle className="h-4 w-4 animate-spin" />
+ <span className="ml-2">Loading...</span>
+ </div>
+ ) : error ? (
+ <div className="flex flex-col items-center justify-center p-4 text-destructive">
+ <span className="text-sm">Failed to load machines</span>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="mt-2"
+ onClick={fetchMachines}
+ >
+ Retry
+ </Button>
+ </div>
+ ) : machines.length === 0 ? (
+ <div className="flex items-center justify-center p-4 text-muted-foreground">
+ <span className="text-sm">No machines available</span>
+ </div>
+ ) : (
+ machines.map((machine: Machine) => (
+ <SelectItem key={machine.name} value={machine.name}>
+ {machine.name}
+ </SelectItem>
+ ))
+ )}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ </div>
+ );
+}
+
+function Dev({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+ const { id, data } = node;
+ const store = useStateStore();
+ const devForm = useForm<z.infer<typeof devSchema>>({
+ resolver: zodResolver(devSchema),
+ mode: "onChange",
+ defaultValues: {
+ enabled: data.dev ? data.dev.enabled : false,
+ mode: data.dev?.enabled ? data.dev.mode : undefined,
+ },
+ });
+ useEffect(() => {
+ const sub = devForm.watch((value, { name }) => {
+ console.log("DDDEVV", name, value, data.dev);
+ if (name === "enabled") {
+ if (value.enabled) {
+ if (data.dev?.enabled && data.dev.mode === "VM") {
+ return;
+ }
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: true,
+ mode: "VM",
+ },
+ });
+ devForm.setValue("mode", "VM");
+ } else {
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: false,
+ },
+ });
+ }
+ } else if (name === "mode") {
+ if (data.dev?.enabled && data.dev.mode === value.mode) {
+ return;
+ }
+ store.updateNodeData<"app">(id, {
+ dev: {
+ enabled: true,
+ mode: value.mode,
+ },
+ });
+ }
+ });
+ return () => sub.unsubscribe();
+ }, [id, data, devForm, store]);
+ return (
+ <>
+ <Form {...devForm}>
+ <form className="space-y-2">
+ <FormField
+ control={devForm.control}
+ name="enabled"
+ render={({ field }) => (
+ <FormItem>
+ <div className="flex flex-row gap-1 items-center">
+ <Switch
+ id="devEnabled"
+ onCheckedChange={field.onChange}
+ checked={field.value}
+ disabled={disabled}
+ />
+ <Label htmlFor="devEnabled">Development Mode</Label>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {data.dev?.enabled && (
+ <FormField
+ control={devForm.control}
+ name="mode"
+ render={({ field }) => (
+ <FormItem>
+ <div className="flex flex-row gap-1 items-center">
+ <RadioGroup
+ onValueChange={field.onChange}
+ value={field.value}
+ disabled={disabled}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="VM" id="vm" />
+ <Label htmlFor="vm">Create a VM</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="PROXY" id="proxy" />
+ <Label htmlFor="proxy">Proxy to existing machine</Label>
+ </div>
+ </RadioGroup>
+ </div>
+ </FormItem>
+ )}
+ />
+ )}
+ </form>
+ </Form>
+ <DevVM node={node} disabled={disabled} />
+ <DevProxy node={node} disabled={disabled} />
</>
);
}
diff --git a/apps/canvas/front/src/components/ui/radio-group.tsx b/apps/canvas/front/src/components/ui/radio-group.tsx
new file mode 100644
index 0000000..6e25b18
--- /dev/null
+++ b/apps/canvas/front/src/components/ui/radio-group.tsx
@@ -0,0 +1,35 @@
+import * as React from "react";
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
+import { cn } from "@/lib/utils";
+import { DotFilledIcon } from "@radix-ui/react-icons";
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef<typeof RadioGroupPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
+>(({ className, ...props }, ref) => {
+ return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
+});
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef<typeof RadioGroupPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
+>(({ className, ...props }, ref) => {
+ return (
+ <RadioGroupPrimitive.Item
+ ref={ref}
+ className={cn(
+ "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
+ <DotFilledIcon className="h-3.5 w-3.5 fill-primary" />
+ </RadioGroupPrimitive.Indicator>
+ </RadioGroupPrimitive.Item>
+ );
+});
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
+
+export { RadioGroup, RadioGroupItem };
diff --git a/core/headscale/client.go b/core/headscale/client.go
index f4d0f51..766ae52 100644
--- a/core/headscale/client.go
+++ b/core/headscale/client.go
@@ -96,14 +96,22 @@
IPAddresses []net.IP `json:"ip_addresses"`
}
-func (c *client) getNodeId(user, node string) (string, error) {
+func (c *client) getUserNodes(user string) ([]nodeInfo, error) {
cmd := exec.Command("headscale", c.config, "--user", user, "node", "list", "-o", "json")
out, err := cmd.Output()
if err != nil {
- return "", err
+ return nil, err
}
var nodes []nodeInfo
if err := json.NewDecoder(bytes.NewReader(out)).Decode(&nodes); err != nil {
+ return nil, err
+ }
+ return nodes, nil
+}
+
+func (c *client) getNodeId(user, node string) (string, error) {
+ nodes, err := c.getUserNodes(user)
+ if err != nil {
return "", err
}
for _, n := range nodes {
diff --git a/core/headscale/main.go b/core/headscale/main.go
index 8a3a9ec..fb20274 100644
--- a/core/headscale/main.go
+++ b/core/headscale/main.go
@@ -112,6 +112,7 @@
r.HandleFunc("/user/{user}/node/{node}/expire", s.expireUserNode).Methods(http.MethodPost)
r.HandleFunc("/user/{user}/node/{node}/ip", s.getNodeIP).Methods(http.MethodGet)
r.HandleFunc("/user/{user}/node/{node}", s.removeUserNode).Methods(http.MethodDelete)
+ r.HandleFunc("/user/{user}/node", s.getUserNodes).Methods(http.MethodGet)
r.HandleFunc("/user", s.createUser).Methods(http.MethodPost)
r.HandleFunc("/routes/{id}/enable", s.enableRoute).Methods(http.MethodPost)
go func() {
@@ -224,6 +225,24 @@
}
}
+func (s *server) getUserNodes(w http.ResponseWriter, r *http.Request) {
+ user, ok := mux.Vars(r)["user"]
+ if !ok {
+ http.Error(w, "no user", http.StatusBadRequest)
+ return
+ }
+ nodes, err := s.client.getUserNodes(user)
+ if err != nil {
+ if errors.Is(err, ErrorNotFound) {
+ http.Error(w, err.Error(), http.StatusNotFound)
+ } else {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ return
+ }
+ json.NewEncoder(w).Encode(nodes)
+}
+
func (s *server) handleSyncUsers(_ http.ResponseWriter, _ *http.Request) {
go s.syncUsers()
}