blob: a2b35df9bcd1e1cf6e37439ee683c949499f7f9b [file] [log] [blame]
gioda120432025-06-02 09:42:26 +00001import React, { useCallback, useMemo, useState } from "react";
2import {
3 useStateStore,
4 GithubNode,
5 ServiceNode,
6 GatewayHttpsNode,
7 nodeLabel,
8 Port,
9 nodeEnvVarNames,
10 AppNode,
11} from "@/lib/state";
12import { Button } from "./components/ui/button";
13import { Icon } from "./components/icon";
14import { PlusIcon } from "lucide-react";
15import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "./components/ui/dialog";
16import { Input } from "./components/ui/input";
17import { Label } from "./components/ui/label";
18import { useToast } from "./hooks/use-toast";
19import { v4 as uuidv4 } from "uuid";
20
21export 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
109function 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
240import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./components/ui/accordion";
241import { Badge } from "./components/ui/badge";
242
243function 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}