Canvas: Overview tab
Change-Id: Ie40ed4e26991b7915ec005681b92eb39fdc354c9
diff --git a/apps/canvas/front/src/App.tsx b/apps/canvas/front/src/App.tsx
index 9b6d69a..794b433 100644
--- a/apps/canvas/front/src/App.tsx
+++ b/apps/canvas/front/src/App.tsx
@@ -7,6 +7,7 @@
import { Toaster } from "./components/ui/toaster";
import { ProjectSelect } from "./ProjectSelect";
import { Logs } from "./Monitoring";
+import { Overview } from "./Overview";
export default function App() {
return (
@@ -21,9 +22,10 @@
function AppImpl() {
return (
- <Tabs defaultValue="canvas" className="flex-1 flex flex-col min-h-0">
+ <Tabs defaultValue="overview" className="flex-1 flex flex-col min-h-0">
<div className="flex justify-between border-b">
<TabsList className="!rounded-none">
+ <TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="canvas">Canvas</TabsTrigger>
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
<TabsTrigger value="config">Config</TabsTrigger>
@@ -31,6 +33,9 @@
</TabsList>
<ProjectSelect className="w-fit min-w-[150px]" />
</div>
+ <TabsContent value="overview" className="!mt-0 flex-1 min-h-0">
+ <Overview />
+ </TabsContent>
<TabsContent value="canvas" className="!mt-0 flex-1 min-h-0">
<CanvasBuilder />
</TabsContent>
diff --git a/apps/canvas/front/src/Config.tsx b/apps/canvas/front/src/Config.tsx
index 137a467..bdad346 100644
--- a/apps/canvas/front/src/Config.tsx
+++ b/apps/canvas/front/src/Config.tsx
@@ -3,7 +3,7 @@
import JSONView from "@microlink/react-json-view";
import { useMemo } from "react";
-export function Config() {
+export function Config(): React.ReactNode {
const store = useStateStore();
const config = useMemo(
() => generateDodoConfig(store.projectId, store.nodes, store.env),
diff --git a/apps/canvas/front/src/Messages.tsx b/apps/canvas/front/src/Messages.tsx
index 0701afb..026d123 100644
--- a/apps/canvas/front/src/Messages.tsx
+++ b/apps/canvas/front/src/Messages.tsx
@@ -54,7 +54,9 @@
{[...grouped.entries()].map(([id, messages]) => (
<AccordionItem key={id} value={id}>
<AccordionTrigger className="flex flex-row-reverse !gap-1 !justify-end !h-fit !py-0">
- <Badge>{messages.length}</Badge>
+ <Badge className="h-5 min-w-5 rounded-full px-2 font-mono tabular-nums">
+ {messages.length}
+ </Badge>
<div>{id === "global" ? "Global" : nodeLabel(nodeMap.get(id)!)}</div>
</AccordionTrigger>
<AccordionContent className="flex flex-col !px-1">
diff --git a/apps/canvas/front/src/Overview.tsx b/apps/canvas/front/src/Overview.tsx
new file mode 100644
index 0000000..a2b35df
--- /dev/null
+++ b/apps/canvas/front/src/Overview.tsx
@@ -0,0 +1,267 @@
+import React, { useCallback, useMemo, useState } from "react";
+import {
+ useStateStore,
+ GithubNode,
+ ServiceNode,
+ GatewayHttpsNode,
+ nodeLabel,
+ Port,
+ nodeEnvVarNames,
+ AppNode,
+} from "@/lib/state";
+import { Button } from "./components/ui/button";
+import { Icon } from "./components/icon";
+import { PlusIcon } from "lucide-react";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "./components/ui/dialog";
+import { Input } from "./components/ui/input";
+import { Label } from "./components/ui/label";
+import { useToast } from "./hooks/use-toast";
+import { v4 as uuidv4 } from "uuid";
+
+export function Overview(): React.ReactNode {
+ const nodes = useStateStore((state) => state.nodes);
+ const edges = useStateStore((state) => state.edges);
+ const githubNodes = useMemo(() => nodes.filter((node): node is GithubNode => node.type === "github"), [nodes]);
+ const getServicesForRepo = useCallback(
+ (repoId: string): ServiceNode[] => {
+ return nodes.filter((node): node is ServiceNode => {
+ if (node.type !== "app") return false;
+ return edges.some(
+ (edge) =>
+ edge.source === repoId &&
+ edge.target === node.id &&
+ edge.sourceHandle === "repository" &&
+ edge.targetHandle === "repository",
+ );
+ });
+ },
+ [nodes, edges],
+ );
+ return (
+ <div className="h-full overflow-auto bg-muted p-4 flex flex-col gap-4">
+ {githubNodes.map((repoNode) => {
+ const services = getServicesForRepo(repoNode.id);
+ return (
+ <div key={repoNode.id}>
+ <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2">
+ <Icon type="github" /> {nodeLabel(repoNode)}
+ </h2>
+ {services.length > 0 ? (
+ <ul className="space-y-4">
+ {services.map((serviceNode) => (
+ <li key={serviceNode.id} className="pl-4 border-l-2 border-gray-200">
+ <Service service={serviceNode} />
+ </li>
+ ))}
+ </ul>
+ ) : (
+ <p className="text-sm text-gray-500 pl-4">No services imported from this repository.</p>
+ )}
+ </div>
+ );
+ })}
+ {nodes
+ .filter((n) => n.type === "volume")
+ .map((n) => {
+ return (
+ <div key={n.id}>
+ <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2">
+ <Icon type="volume" /> {nodeLabel(n)}
+ </h2>
+ <div className="pl-4 border-l-2 border-gray-200">
+ <Exports n={n} />
+ </div>
+ </div>
+ );
+ })}
+ {nodes
+ .filter((n) => n.type === "postgresql")
+ .map((n) => {
+ return (
+ <div key={n.id}>
+ <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2">
+ <Icon type="postgresql" /> {nodeLabel(n)}
+ </h2>
+ <div className="pl-4 border-l-2 border-gray-200">
+ <Exports n={n} />
+ </div>
+ </div>
+ );
+ })}
+ {nodes
+ .filter((n) => n.type === "mongodb")
+ .map((n) => {
+ return (
+ <div key={n.id}>
+ <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2">
+ <Icon type="mongodb" /> {nodeLabel(n)}
+ </h2>
+ <div className="pl-4 border-l-2 border-gray-200">
+ <Exports n={n} />
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ );
+}
+
+function Service({ service: serviceNode }: { service: ServiceNode }): React.ReactNode {
+ const { toast } = useToast();
+ const nodes = useStateStore((state) => state.nodes);
+ const updateNodeData = useStateStore((state) => state.updateNodeData);
+ const [isAddPortModalOpen, setIsAddPortModalOpen] = useState(false);
+ const [newPortName, setNewPortName] = useState("");
+ const [newPortValue, setNewPortValue] = useState("");
+
+ const httpsGateways = useMemo(
+ () => nodes.filter((node): node is GatewayHttpsNode => node.type === "gateway-https"),
+ [nodes],
+ );
+ const getGatewayForServicePort = useCallback(
+ (serviceId: string, port: Port): GatewayHttpsNode[] => {
+ return httpsGateways.filter(
+ (g) => g.data.https?.serviceId === serviceId && g.data.https?.portId === port.id,
+ );
+ },
+ [httpsGateways],
+ );
+ const getGatewayUrl = (g: GatewayHttpsNode): string => {
+ if (g.data.subdomain && g.data.network) {
+ return `https://${g.data.subdomain}.${g.data.network}`;
+ }
+ return "Gateway not fully configured";
+ };
+
+ const handleAddPort = () => {
+ if (!newPortName || !newPortValue) {
+ toast({
+ title: "Port name and value are required.",
+ variant: "destructive",
+ });
+ return;
+ }
+ const portValueNumber = parseInt(newPortValue, 10);
+ if (isNaN(portValueNumber) || portValueNumber <= 0 || portValueNumber > 65535) {
+ toast({
+ title: "Invalid port number.",
+ variant: "destructive",
+ });
+ return;
+ }
+ const newPort: Port = {
+ id: uuidv4(),
+ name: newPortName,
+ value: portValueNumber,
+ };
+ updateNodeData<"app">(serviceNode.id, {
+ ports: [...(serviceNode.data.ports || []), newPort],
+ } as Partial<ServiceNode["data"]>);
+ setNewPortName("");
+ setNewPortValue("");
+ setIsAddPortModalOpen(false);
+ };
+
+ return (
+ <>
+ <h3 className="text-lg font-medium text-gray-700 flex flex-row items-center gap-2">
+ <Icon type="app" /> {nodeLabel(serviceNode)}
+ </h3>
+ <div className="text-sm text-gray-500 pl-4 flex flex-row items-center gap-2">
+ <div>Branch: {serviceNode.data.repository?.branch ?? "master"}</div>
+ <div>Location: {serviceNode.data.repository?.rootDir ?? "/"}</div>
+ </div>
+ <div className="pl-4">
+ <h4 className="text-sm font-medium text-gray-500 flex flex-row items-center gap-2">
+ Ports
+ <Button variant="ghost" size="icon" onClick={() => setIsAddPortModalOpen(true)}>
+ <PlusIcon />
+ </Button>
+ </h4>
+ <ul className="pl-2">
+ {(serviceNode.data.ports || []).map((port) => {
+ const gateways = getGatewayForServicePort(serviceNode.id, port);
+ return (
+ <li key={port.id} className="text-sm text-gray-600">
+ <span className="font-medium">{port.name.toUpperCase()}:</span> {port.value}
+ {gateways.map((g) => (
+ <Button variant="link" asChild key={g.id} className="!h-fit !py-0">
+ <a href={getGatewayUrl(g)} target="_blank" rel="noopener noreferrer">
+ {getGatewayUrl(g)}
+ </a>
+ </Button>
+ ))}
+ </li>
+ );
+ })}
+ </ul>
+ </div>
+ <div className="pl-4">
+ <Exports n={serviceNode} />
+ </div>
+ <Dialog open={isAddPortModalOpen} onOpenChange={setIsAddPortModalOpen}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Add New Port to {nodeLabel(serviceNode)}</DialogTitle>
+ </DialogHeader>
+ <div>
+ <div>
+ <Label htmlFor="portName">Name</Label>
+ <Input
+ id="portName"
+ value={newPortName}
+ onChange={(e) => setNewPortName(e.target.value)}
+ placeholder="e.g., HTTP, Admin"
+ />
+ </div>
+ <div>
+ <Label htmlFor="portValue">Port Number</Label>
+ <Input
+ id="portValue"
+ type="number"
+ value={newPortValue}
+ onChange={(e) => setNewPortValue(e.target.value)}
+ placeholder="e.g., 80, 8080"
+ />
+ </div>
+ </div>
+ <DialogFooter>
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button onClick={handleAddPort}>Add Port</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ );
+}
+
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./components/ui/accordion";
+import { Badge } from "./components/ui/badge";
+
+function Exports({ n }: { n: AppNode }): React.ReactNode {
+ return (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="exports" className="!border-none">
+ <AccordionTrigger className="flex flex-row-reverse !gap-1 !justify-end !h-fit !py-1">
+ <Badge className="h-5 min-w-5 rounded-full px-2 font-mono tabular-nums">
+ {nodeEnvVarNames(n).length}
+ </Badge>{" "}
+ Exports
+ </AccordionTrigger>
+ <AccordionContent>
+ <ul className="pl-2 space-y-1">
+ {nodeEnvVarNames(n).map((name) => {
+ return (
+ <li key={name} className="text-xs font-mono">
+ {name}
+ </li>
+ );
+ })}
+ </ul>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ );
+}
diff --git a/apps/canvas/front/src/Tools.tsx b/apps/canvas/front/src/Tools.tsx
index 4c9b19e..f128ed0 100644
--- a/apps/canvas/front/src/Tools.tsx
+++ b/apps/canvas/front/src/Tools.tsx
@@ -12,11 +12,11 @@
<TabsList className="!justify-start !rounded-none">
<TabsTrigger value="messages" className="space-x-2">
<div>Messages</div>
- <Badge>{messages.length}</Badge>
+ <Badge className="h-5 min-w-5 rounded-full px-2 font-mono tabular-nums">{messages.length}</Badge>
</TabsTrigger>
<TabsTrigger value="gateways" className="space-x-2">
<div>Gateways</div>
- <Badge>{env.access.length}</Badge>
+ <Badge className="h-5 min-w-5 rounded-full px-2 font-mono tabular-nums">{env.access.length}</Badge>
</TabsTrigger>
<TabsTrigger value="deployKeys">Deploy keys</TabsTrigger>
</TabsList>