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>