| 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> |
| ); |
| } |