Canvas: Reuse node details component in overview
Make app details tabular.
Change-Id: I78a641e8e513eec44573bb8c8a391ef81a66e7fe
diff --git a/apps/canvas/front/src/Overview.tsx b/apps/canvas/front/src/Overview.tsx
index a2b35df..4e286ce 100644
--- a/apps/canvas/front/src/Overview.tsx
+++ b/apps/canvas/front/src/Overview.tsx
@@ -1,267 +1,30 @@
-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";
+import React, { useMemo } from "react";
+import { useStateStore, ServiceNode } from "@/lib/state";
+import { NodeDetails } from "./components/node-details";
+import { Actions } from "./components/actions";
+import { Canvas } from "./components/canvas";
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],
- );
+ const store = useStateStore();
+ const nodes = useMemo(() => store.nodes, [store.nodes]);
+ const isDeployMode = useMemo(() => store.mode === "deploy", [store.mode]);
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 className="h-full w-full overflow-auto bg-white p-2">
+ <div className="w-full flex flex-row justify-end">
+ <Actions />
+ <Canvas className="hidden" />
</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);
+ <div className="flex flex-wrap gap-4 pt-2">
+ {nodes
+ .filter((n): n is ServiceNode => n.type === "app")
+ .map((n) => {
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>
+ <div key={n.id} className="h-fit w-fit rounded-lg border-gray-200 border-2 p-2">
+ <NodeDetails disabled={isDeployMode} {...n} />
+ </div>
);
})}
- </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>
+ </div>
);
}