Canvas: Port expose form
Change-Id: I421c67230075778fad3359e11a5c573cd83882c9
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index 9eeda45..15e2fa3 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -22,6 +22,49 @@
import { Name } from "./node-name";
import { NodeDetailsProps } from "@/lib/types";
import { Gateway } from "@/Gateways";
+import { Port } from "config";
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
+
+const sourceSchema = z.object({
+ id: z.string().min(1, "required"),
+ branch: z.string(),
+ rootDir: z.string(),
+});
+
+const devSchema = z.object({
+ enabled: z.boolean(),
+});
+
+const exposeSchema = z.object({
+ network: z.string().min(1, "reqired"),
+ subdomain: z.string().min(1, "required"),
+});
+
+const agentSchema = z.object({
+ model: z.enum(["gemini", "claude"]),
+ apiKey: z.string().optional(),
+});
+
+const portExposeSchema = z
+ .object({
+ type: z.enum(["https", "tcp"]),
+ network: z.string().min(1, "Required"),
+ subdomain: z.string().optional(),
+ })
+ .refine(
+ (data) => {
+ if (data.type === "https" || data.type === "tcp") {
+ return !!data.subdomain && data.subdomain.length > 0;
+ }
+ return true;
+ },
+ {
+ message: "Subdomain is required",
+ path: ["subdomain"],
+ },
+ );
+
+type PortExposeFormValues = z.infer<typeof portExposeSchema>;
export function NodeApp(node: ServiceNode) {
const { id, selected } = node;
@@ -65,25 +108,217 @@
type: z.enum(ServiceTypes),
});
-const sourceSchema = z.object({
- id: z.string().min(1, "required"),
- branch: z.string(),
- rootDir: z.string(),
-});
+function ExposeForm({
+ node,
+ port,
+ onDone,
+ disabled,
+}: {
+ node: ServiceNode;
+ port: Port;
+ onDone: () => void;
+ disabled?: boolean;
+}) {
+ const store = useStateStore();
+ const nodes = useNodes<AppNode>();
+ const env = useEnv();
+ const form = useForm<PortExposeFormValues>({
+ resolver: zodResolver(portExposeSchema),
+ mode: "onChange",
+ defaultValues: {
+ type: "https",
+ },
+ });
-const devSchema = z.object({
- enabled: z.boolean(),
-});
+ const onSubmit = (data: PortExposeFormValues) => {
+ const networkNode = nodes.find((n) => n.type === "network" && n.data.domain === data.network);
+ if (!networkNode) {
+ // TODO: should show an error to the user
+ return;
+ }
+ if (data.type === "https") {
+ const newNode: Omit<GatewayHttpsNode, "position"> = {
+ id: uuidv4(),
+ type: "gateway-https",
+ data: {
+ https: {
+ serviceId: node.id,
+ portId: port.id,
+ },
+ network: data.network,
+ subdomain: data.subdomain!,
+ label: "",
+ envVars: [],
+ ports: [],
+ },
+ };
+ store.addNode(newNode);
+ store.setEdges(
+ store.edges.concat(
+ {
+ id: uuidv4(),
+ source: node.id,
+ sourceHandle: "ports",
+ target: newNode.id,
+ targetHandle: "https",
+ },
+ {
+ id: uuidv4(),
+ source: newNode.id,
+ sourceHandle: "subdomain",
+ target: networkNode.id,
+ targetHandle: "subdomain",
+ },
+ ),
+ );
+ } else if (data.type === "tcp") {
+ const existingGateway = nodes.find(
+ (n): n is GatewayTCPNode =>
+ n.type === "gateway-tcp" && n.data.network === data.network && n.data.subdomain === data.subdomain,
+ );
+ if (existingGateway) {
+ store.updateNodeData<"gateway-tcp">(existingGateway.id, {
+ exposed: [...existingGateway.data.exposed, { serviceId: node.id, portId: port.id }],
+ });
+ let edges = store.edges.concat({
+ id: uuidv4(),
+ source: node.id,
+ sourceHandle: "ports",
+ target: existingGateway.id,
+ targetHandle: "tcp",
+ });
+ if (
+ !edges.find(
+ (e) =>
+ e.source === existingGateway.id &&
+ e.target === networkNode.id &&
+ e.sourceHandle === "subdomain" &&
+ e.targetHandle === "subdomain",
+ )
+ ) {
+ edges = edges.concat({
+ id: uuidv4(),
+ source: existingGateway.id,
+ sourceHandle: "subdomain",
+ target: networkNode.id,
+ targetHandle: "subdomain",
+ });
+ }
+ store.setEdges(edges);
+ } else {
+ const newNode: Omit<GatewayTCPNode, "position"> = {
+ id: uuidv4(),
+ type: "gateway-tcp",
+ data: {
+ exposed: [{ serviceId: node.id, portId: port.id }],
+ network: data.network,
+ subdomain: data.subdomain,
+ label: "",
+ envVars: [],
+ ports: [],
+ },
+ };
+ store.addNode(newNode);
+ store.setEdges(
+ store.edges.concat(
+ {
+ id: uuidv4(),
+ source: node.id,
+ sourceHandle: "ports",
+ target: newNode.id,
+ targetHandle: "tcp",
+ },
+ {
+ id: uuidv4(),
+ source: newNode.id,
+ sourceHandle: "subdomain",
+ target: networkNode.id,
+ targetHandle: "subdomain",
+ },
+ ),
+ );
+ }
+ }
+ onDone();
+ };
-const exposeSchema = z.object({
- network: z.string().min(1, "reqired"),
- subdomain: z.string().min(1, "required"),
-});
+ const type = form.watch("type");
-const agentSchema = z.object({
- model: z.enum(["gemini", "claude"]),
- apiKey: z.string().optional(),
-});
+ return (
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2 border-t mt-2 pt-2">
+ <FormField
+ control={form.control}
+ name="type"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Gateway Type</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select a type" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="https">HTTPS</SelectItem>
+ <SelectItem value="tcp">TCP</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="network"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Network</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="Select a network" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {env.networks.map((n) => (
+ <SelectItem key={n.domain} value={n.domain}>
+ {n.name} - {n.domain}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {(type === "https" || type === "tcp") && (
+ <FormField
+ control={form.control}
+ name="subdomain"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Subdomain</FormLabel>
+ <FormControl>
+ <Input placeholder="subdomain" {...field} disabled={disabled} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+ <div className="flex justify-end gap-2">
+ <Button type="button" variant="ghost" onClick={onDone} disabled={disabled}>
+ Cancel
+ </Button>
+ <Button type="submit" disabled={disabled || !form.formState.isValid}>
+ Expose
+ </Button>
+ </div>
+ </form>
+ </Form>
+ );
+}
export function NodeAppDetails({ node, disabled, showName = true, isOverview = false }: NodeDetailsProps<ServiceNode>) {
const { data } = node;
@@ -175,7 +410,7 @@
<Runtime node={node} disabled={disabled} />
</TabsContent>
<TabsContent value="ports">
- <Ports node={node} disabled={disabled} />
+ <Ports node={node} disabled={disabled} isOverview={isOverview} />
</TabsContent>
<TabsContent value="vars">
<EnvVars node={node} disabled={disabled} />
@@ -386,11 +621,20 @@
);
}
-function Ports({ node, disabled }: { node: ServiceNode; disabled?: boolean }): React.ReactNode {
+function Ports({
+ node,
+ disabled,
+ isOverview,
+}: {
+ node: ServiceNode;
+ disabled?: boolean;
+ isOverview?: boolean;
+}): React.ReactNode {
const { id, data } = node;
const store = useStateStore();
const nodes = useNodes<AppNode>();
const [portIngresses, setPortIngresses] = useState<Record<string, string[]>>({});
+ const [exposingPortId, setExposingPortId] = useState<string | null>(null);
const httpsGateways = useMemo(
() => nodes.filter((n): n is GatewayHttpsNode => n.type === "gateway-https"),
@@ -545,11 +789,20 @@
{data &&
data.ports &&
data.ports.map((p) => (
- <>
+ <div key={p.id} className="contents">
<div className="contents">
<div className="flex items-center px-3">{p.name.toUpperCase()}</div>
<div className="flex items-center px-3">{p.value}</div>
- <div className="flex items-center">
+ <div className="flex items-center gap-1">
+ {isOverview && (
+ <Button
+ variant="outline"
+ onClick={() => setExposingPortId(p.id)}
+ disabled={disabled}
+ >
+ Expose
+ </Button>
+ )}
<Button
variant="destructive"
className="w-full"
@@ -563,11 +816,28 @@
{portIngresses[p.id]?.length > 0 && (
<div key={p.id} className="col-span-full pl-6">
{portIngresses[p.id].map((url) => (
- <Gateway g={{ type: "https", address: url, name: p.name }} />
+ <Gateway key={url} g={{ type: "https", address: url, name: p.name }} />
))}
</div>
)}
- </>
+ {exposingPortId === p.id && (
+ <Dialog open={true} onOpenChange={() => setExposingPortId(null)}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>
+ Expose Port {p.name}:{p.value}
+ </DialogTitle>
+ </DialogHeader>
+ <ExposeForm
+ node={node}
+ port={p}
+ onDone={() => setExposingPortId(null)}
+ disabled={disabled}
+ />
+ </DialogContent>
+ </Dialog>
+ )}
+ </div>
))}
<div>
<Input