blob: a2b35df9bcd1e1cf6e37439ee683c949499f7f9b [file] [log] [blame]
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>
);
}