| gio | da12043 | 2025-06-02 09:42:26 +0000 | [diff] [blame^] | 1 | import React, { useCallback, useMemo, useState } from "react"; |
| 2 | import { |
| 3 | useStateStore, |
| 4 | GithubNode, |
| 5 | ServiceNode, |
| 6 | GatewayHttpsNode, |
| 7 | nodeLabel, |
| 8 | Port, |
| 9 | nodeEnvVarNames, |
| 10 | AppNode, |
| 11 | } from "@/lib/state"; |
| 12 | import { Button } from "./components/ui/button"; |
| 13 | import { Icon } from "./components/icon"; |
| 14 | import { PlusIcon } from "lucide-react"; |
| 15 | import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "./components/ui/dialog"; |
| 16 | import { Input } from "./components/ui/input"; |
| 17 | import { Label } from "./components/ui/label"; |
| 18 | import { useToast } from "./hooks/use-toast"; |
| 19 | import { v4 as uuidv4 } from "uuid"; |
| 20 | |
| 21 | export function Overview(): React.ReactNode { |
| 22 | const nodes = useStateStore((state) => state.nodes); |
| 23 | const edges = useStateStore((state) => state.edges); |
| 24 | const githubNodes = useMemo(() => nodes.filter((node): node is GithubNode => node.type === "github"), [nodes]); |
| 25 | const getServicesForRepo = useCallback( |
| 26 | (repoId: string): ServiceNode[] => { |
| 27 | return nodes.filter((node): node is ServiceNode => { |
| 28 | if (node.type !== "app") return false; |
| 29 | return edges.some( |
| 30 | (edge) => |
| 31 | edge.source === repoId && |
| 32 | edge.target === node.id && |
| 33 | edge.sourceHandle === "repository" && |
| 34 | edge.targetHandle === "repository", |
| 35 | ); |
| 36 | }); |
| 37 | }, |
| 38 | [nodes, edges], |
| 39 | ); |
| 40 | return ( |
| 41 | <div className="h-full overflow-auto bg-muted p-4 flex flex-col gap-4"> |
| 42 | {githubNodes.map((repoNode) => { |
| 43 | const services = getServicesForRepo(repoNode.id); |
| 44 | return ( |
| 45 | <div key={repoNode.id}> |
| 46 | <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2"> |
| 47 | <Icon type="github" /> {nodeLabel(repoNode)} |
| 48 | </h2> |
| 49 | {services.length > 0 ? ( |
| 50 | <ul className="space-y-4"> |
| 51 | {services.map((serviceNode) => ( |
| 52 | <li key={serviceNode.id} className="pl-4 border-l-2 border-gray-200"> |
| 53 | <Service service={serviceNode} /> |
| 54 | </li> |
| 55 | ))} |
| 56 | </ul> |
| 57 | ) : ( |
| 58 | <p className="text-sm text-gray-500 pl-4">No services imported from this repository.</p> |
| 59 | )} |
| 60 | </div> |
| 61 | ); |
| 62 | })} |
| 63 | {nodes |
| 64 | .filter((n) => n.type === "volume") |
| 65 | .map((n) => { |
| 66 | return ( |
| 67 | <div key={n.id}> |
| 68 | <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2"> |
| 69 | <Icon type="volume" /> {nodeLabel(n)} |
| 70 | </h2> |
| 71 | <div className="pl-4 border-l-2 border-gray-200"> |
| 72 | <Exports n={n} /> |
| 73 | </div> |
| 74 | </div> |
| 75 | ); |
| 76 | })} |
| 77 | {nodes |
| 78 | .filter((n) => n.type === "postgresql") |
| 79 | .map((n) => { |
| 80 | return ( |
| 81 | <div key={n.id}> |
| 82 | <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2"> |
| 83 | <Icon type="postgresql" /> {nodeLabel(n)} |
| 84 | </h2> |
| 85 | <div className="pl-4 border-l-2 border-gray-200"> |
| 86 | <Exports n={n} /> |
| 87 | </div> |
| 88 | </div> |
| 89 | ); |
| 90 | })} |
| 91 | {nodes |
| 92 | .filter((n) => n.type === "mongodb") |
| 93 | .map((n) => { |
| 94 | return ( |
| 95 | <div key={n.id}> |
| 96 | <h2 className="text-xl font-medium mb-3 flex flex-row items-center gap-2"> |
| 97 | <Icon type="mongodb" /> {nodeLabel(n)} |
| 98 | </h2> |
| 99 | <div className="pl-4 border-l-2 border-gray-200"> |
| 100 | <Exports n={n} /> |
| 101 | </div> |
| 102 | </div> |
| 103 | ); |
| 104 | })} |
| 105 | </div> |
| 106 | ); |
| 107 | } |
| 108 | |
| 109 | function Service({ service: serviceNode }: { service: ServiceNode }): React.ReactNode { |
| 110 | const { toast } = useToast(); |
| 111 | const nodes = useStateStore((state) => state.nodes); |
| 112 | const updateNodeData = useStateStore((state) => state.updateNodeData); |
| 113 | const [isAddPortModalOpen, setIsAddPortModalOpen] = useState(false); |
| 114 | const [newPortName, setNewPortName] = useState(""); |
| 115 | const [newPortValue, setNewPortValue] = useState(""); |
| 116 | |
| 117 | const httpsGateways = useMemo( |
| 118 | () => nodes.filter((node): node is GatewayHttpsNode => node.type === "gateway-https"), |
| 119 | [nodes], |
| 120 | ); |
| 121 | const getGatewayForServicePort = useCallback( |
| 122 | (serviceId: string, port: Port): GatewayHttpsNode[] => { |
| 123 | return httpsGateways.filter( |
| 124 | (g) => g.data.https?.serviceId === serviceId && g.data.https?.portId === port.id, |
| 125 | ); |
| 126 | }, |
| 127 | [httpsGateways], |
| 128 | ); |
| 129 | const getGatewayUrl = (g: GatewayHttpsNode): string => { |
| 130 | if (g.data.subdomain && g.data.network) { |
| 131 | return `https://${g.data.subdomain}.${g.data.network}`; |
| 132 | } |
| 133 | return "Gateway not fully configured"; |
| 134 | }; |
| 135 | |
| 136 | const handleAddPort = () => { |
| 137 | if (!newPortName || !newPortValue) { |
| 138 | toast({ |
| 139 | title: "Port name and value are required.", |
| 140 | variant: "destructive", |
| 141 | }); |
| 142 | return; |
| 143 | } |
| 144 | const portValueNumber = parseInt(newPortValue, 10); |
| 145 | if (isNaN(portValueNumber) || portValueNumber <= 0 || portValueNumber > 65535) { |
| 146 | toast({ |
| 147 | title: "Invalid port number.", |
| 148 | variant: "destructive", |
| 149 | }); |
| 150 | return; |
| 151 | } |
| 152 | const newPort: Port = { |
| 153 | id: uuidv4(), |
| 154 | name: newPortName, |
| 155 | value: portValueNumber, |
| 156 | }; |
| 157 | updateNodeData<"app">(serviceNode.id, { |
| 158 | ports: [...(serviceNode.data.ports || []), newPort], |
| 159 | } as Partial<ServiceNode["data"]>); |
| 160 | setNewPortName(""); |
| 161 | setNewPortValue(""); |
| 162 | setIsAddPortModalOpen(false); |
| 163 | }; |
| 164 | |
| 165 | return ( |
| 166 | <> |
| 167 | <h3 className="text-lg font-medium text-gray-700 flex flex-row items-center gap-2"> |
| 168 | <Icon type="app" /> {nodeLabel(serviceNode)} |
| 169 | </h3> |
| 170 | <div className="text-sm text-gray-500 pl-4 flex flex-row items-center gap-2"> |
| 171 | <div>Branch: {serviceNode.data.repository?.branch ?? "master"}</div> |
| 172 | <div>Location: {serviceNode.data.repository?.rootDir ?? "/"}</div> |
| 173 | </div> |
| 174 | <div className="pl-4"> |
| 175 | <h4 className="text-sm font-medium text-gray-500 flex flex-row items-center gap-2"> |
| 176 | Ports |
| 177 | <Button variant="ghost" size="icon" onClick={() => setIsAddPortModalOpen(true)}> |
| 178 | <PlusIcon /> |
| 179 | </Button> |
| 180 | </h4> |
| 181 | <ul className="pl-2"> |
| 182 | {(serviceNode.data.ports || []).map((port) => { |
| 183 | const gateways = getGatewayForServicePort(serviceNode.id, port); |
| 184 | return ( |
| 185 | <li key={port.id} className="text-sm text-gray-600"> |
| 186 | <span className="font-medium">{port.name.toUpperCase()}:</span> {port.value} |
| 187 | {gateways.map((g) => ( |
| 188 | <Button variant="link" asChild key={g.id} className="!h-fit !py-0"> |
| 189 | <a href={getGatewayUrl(g)} target="_blank" rel="noopener noreferrer"> |
| 190 | {getGatewayUrl(g)} |
| 191 | </a> |
| 192 | </Button> |
| 193 | ))} |
| 194 | </li> |
| 195 | ); |
| 196 | })} |
| 197 | </ul> |
| 198 | </div> |
| 199 | <div className="pl-4"> |
| 200 | <Exports n={serviceNode} /> |
| 201 | </div> |
| 202 | <Dialog open={isAddPortModalOpen} onOpenChange={setIsAddPortModalOpen}> |
| 203 | <DialogContent> |
| 204 | <DialogHeader> |
| 205 | <DialogTitle>Add New Port to {nodeLabel(serviceNode)}</DialogTitle> |
| 206 | </DialogHeader> |
| 207 | <div> |
| 208 | <div> |
| 209 | <Label htmlFor="portName">Name</Label> |
| 210 | <Input |
| 211 | id="portName" |
| 212 | value={newPortName} |
| 213 | onChange={(e) => setNewPortName(e.target.value)} |
| 214 | placeholder="e.g., HTTP, Admin" |
| 215 | /> |
| 216 | </div> |
| 217 | <div> |
| 218 | <Label htmlFor="portValue">Port Number</Label> |
| 219 | <Input |
| 220 | id="portValue" |
| 221 | type="number" |
| 222 | value={newPortValue} |
| 223 | onChange={(e) => setNewPortValue(e.target.value)} |
| 224 | placeholder="e.g., 80, 8080" |
| 225 | /> |
| 226 | </div> |
| 227 | </div> |
| 228 | <DialogFooter> |
| 229 | <DialogClose asChild> |
| 230 | <Button variant="outline">Cancel</Button> |
| 231 | </DialogClose> |
| 232 | <Button onClick={handleAddPort}>Add Port</Button> |
| 233 | </DialogFooter> |
| 234 | </DialogContent> |
| 235 | </Dialog> |
| 236 | </> |
| 237 | ); |
| 238 | } |
| 239 | |
| 240 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./components/ui/accordion"; |
| 241 | import { Badge } from "./components/ui/badge"; |
| 242 | |
| 243 | function Exports({ n }: { n: AppNode }): React.ReactNode { |
| 244 | return ( |
| 245 | <Accordion type="single" collapsible className="w-full"> |
| 246 | <AccordionItem value="exports" className="!border-none"> |
| 247 | <AccordionTrigger className="flex flex-row-reverse !gap-1 !justify-end !h-fit !py-1"> |
| 248 | <Badge className="h-5 min-w-5 rounded-full px-2 font-mono tabular-nums"> |
| 249 | {nodeEnvVarNames(n).length} |
| 250 | </Badge>{" "} |
| 251 | Exports |
| 252 | </AccordionTrigger> |
| 253 | <AccordionContent> |
| 254 | <ul className="pl-2 space-y-1"> |
| 255 | {nodeEnvVarNames(n).map((name) => { |
| 256 | return ( |
| 257 | <li key={name} className="text-xs font-mono"> |
| 258 | {name} |
| 259 | </li> |
| 260 | ); |
| 261 | })} |
| 262 | </ul> |
| 263 | </AccordionContent> |
| 264 | </AccordionItem> |
| 265 | </Accordion> |
| 266 | ); |
| 267 | } |