From 4eee6c04d3d7f64866548f1742cddeaffcce2026 Mon Sep 17 00:00:00 2001 From: Kirill Chernakov Date: Thu, 19 Sep 2024 20:47:18 +0400 Subject: [PATCH 01/13] feature: add topology applications UI (mocked API for now) --- keep-ui/app/topology/layout.tsx | 35 +- keep-ui/app/topology/models.tsx | 19 + .../app/topology/service-search-context.tsx | 12 +- keep-ui/app/topology/topology.tsx | 295 ++++----------- .../topology/ui/create-application-form.tsx | 88 +++++ .../ui/create-or-update-application.tsx | 174 +++++++++ .../topology/ui/manage-application-form.tsx | 114 ++++++ .../ui/topology-map/application-node.tsx | 15 + .../app/topology/ui/topology-map/index.tsx | 339 ++++++++++++++++++ .../topology-map/service-node.tsx} | 88 +++-- .../topology/{ => ui/topology-map}/styles.tsx | 0 .../{ => ui/topology-map}/topology.css | 0 keep-ui/components/ui/AutocompleteInput.tsx | 147 ++++++++ keep-ui/components/ui/Button.tsx | 28 ++ keep-ui/components/ui/TextInput.tsx | 24 ++ keep-ui/components/ui/Textarea.tsx | 11 + keep-ui/components/ui/index.ts | 5 + keep-ui/package-lock.json | 7 +- keep-ui/package.json | 1 + keep-ui/tailwind.config.js | 5 +- keep-ui/utils/helpers.ts | 6 + keep-ui/utils/hooks/useApplications.ts | 31 ++ keep-ui/utils/hooks/useTopology.ts | 75 +++- keep-ui/utils/type-utils.ts | 3 + 24 files changed, 1222 insertions(+), 300 deletions(-) create mode 100644 keep-ui/app/topology/ui/create-application-form.tsx create mode 100644 keep-ui/app/topology/ui/create-or-update-application.tsx create mode 100644 keep-ui/app/topology/ui/manage-application-form.tsx create mode 100644 keep-ui/app/topology/ui/topology-map/application-node.tsx create mode 100644 keep-ui/app/topology/ui/topology-map/index.tsx rename keep-ui/app/topology/{custom-node.tsx => ui/topology-map/service-node.tsx} (55%) rename keep-ui/app/topology/{ => ui/topology-map}/styles.tsx (100%) rename keep-ui/app/topology/{ => ui/topology-map}/topology.css (100%) create mode 100644 keep-ui/components/ui/AutocompleteInput.tsx create mode 100644 keep-ui/components/ui/Button.tsx create mode 100644 keep-ui/components/ui/TextInput.tsx create mode 100644 keep-ui/components/ui/Textarea.tsx create mode 100644 keep-ui/components/ui/index.ts create mode 100644 keep-ui/utils/hooks/useApplications.ts create mode 100644 keep-ui/utils/type-utils.ts diff --git a/keep-ui/app/topology/layout.tsx b/keep-ui/app/topology/layout.tsx index 0a4ade6ce..86f6b36d8 100644 --- a/keep-ui/app/topology/layout.tsx +++ b/keep-ui/app/topology/layout.tsx @@ -1,30 +1,23 @@ "use client"; -import { Subtitle, TextInput, Title } from "@tremor/react"; import { useState } from "react"; import { ServiceSearchContext } from "./service-search-context"; export default function Layout({ children }: { children: any }) { - const [serviceInput, setServiceInput] = useState(""); + const [serviceQuery, setServiceQuery] = useState(""); + const [selectedServiceId, setSelectedServiceId] = useState( + null + ); return ( -
-
-
- Service Topology - - Data describing the topology of components in your environment. - -
- -
- - {children} - -
+ + {children} + ); } diff --git a/keep-ui/app/topology/models.tsx b/keep-ui/app/topology/models.tsx index 4ee087158..71b7be716 100644 --- a/keep-ui/app/topology/models.tsx +++ b/keep-ui/app/topology/models.tsx @@ -1,3 +1,6 @@ +import { InterfaceToType } from "utils/type-utils"; +import type { Node } from "@xyflow/react"; + export interface TopologyServiceDependency { serviceId: string; serviceName: string; @@ -20,4 +23,20 @@ export interface TopologyService { ip_address?: string; mac_address?: string; manufacturer?: string; + applicationObject?: Application; } + +// We need to convert interface to type because only types are allowed in @xyflow/react +// https://github.com/xyflow/web/issues/486 +export type ServiceNodeType = Node, string>; + +export type Application = { + id: string; + name: string; + description: string; + services: { + id: string; + name: string; + }[]; + // TODO: Consider adding tags, cost of disruption, etc. +}; diff --git a/keep-ui/app/topology/service-search-context.tsx b/keep-ui/app/topology/service-search-context.tsx index 7d72545e9..384876296 100644 --- a/keep-ui/app/topology/service-search-context.tsx +++ b/keep-ui/app/topology/service-search-context.tsx @@ -1,3 +1,13 @@ import { createContext } from "react"; -export const ServiceSearchContext = createContext(""); +export const ServiceSearchContext = createContext<{ + serviceQuery: string; + setServiceQuery: (query: string) => void; + selectedServiceId: string | null; + setSelectedServiceId: (id: string | null) => void; +}>({ + serviceQuery: "", + setServiceQuery: (query: string) => {}, + selectedServiceId: "", + setSelectedServiceId: (id: string | null) => {}, +}); diff --git a/keep-ui/app/topology/topology.tsx b/keep-ui/app/topology/topology.tsx index 478d252f1..39e4cb7d4 100644 --- a/keep-ui/app/topology/topology.tsx +++ b/keep-ui/app/topology/topology.tsx @@ -1,246 +1,89 @@ "use client"; -import React, { useCallback, useContext, useEffect, useState } from "react"; -import { - Background, - BackgroundVariant, - Controls, - Edge, - Node, - ReactFlow, - ReactFlowInstance, - ReactFlowProvider, - applyNodeChanges, - applyEdgeChanges, - NodeChange, - EdgeChange, -} from "@xyflow/react"; -import dagre, { graphlib } from "@dagrejs/dagre"; -import "@xyflow/react/dist/style.css"; -import CustomNode from "./custom-node"; -import { Card } from "@tremor/react"; -import { - edgeLabelBgPaddingNoHover, - edgeLabelBgStyleNoHover, - edgeLabelBgBorderRadiusNoHover, - edgeMarkerEndNoHover, - edgeLabelBgStyleHover, - edgeMarkerEndHover, - nodeHeight, - nodeWidth, -} from "./styles"; -import "./topology.css"; -import { useTopology } from "utils/hooks/useTopology"; -import Loading from "app/loading"; -import { EmptyStateCard } from "@/components/ui/EmptyStateCard"; -import { useRouter } from "next/navigation"; +import { Subtitle, Title } from "@tremor/react"; +import { useContext, useMemo } from "react"; import { ServiceSearchContext } from "./service-search-context"; +import { AutocompleteInput } from "@/components/ui"; +import { TopologyMap } from "./ui/topology-map"; +import { useTopology } from "utils/hooks/useTopology"; +import { MagnifyingGlassIcon } from "@heroicons/react/24/solid"; -interface Props { +interface TopologyPageProps { providerId?: string; service?: string; environment?: string; } -// Function to create a Dagre layout -const dagreGraph = new graphlib.Graph(); -dagreGraph.setDefaultEdgeLabel(() => ({})); - -const getLayoutedElements = (nodes: any[], edges: any[]) => { - dagreGraph.setGraph({ rankdir: "LR", nodesep: 50, ranksep: 200 }); - - nodes.forEach((node) => { - dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight }); - }); - - edges.forEach((edge) => { - dagreGraph.setEdge(edge.source, edge.target); - }); - - dagre.layout(dagreGraph); - - nodes.forEach((node) => { - const nodeWithPosition = dagreGraph.node(node.id); - node.targetPosition = "left"; - node.sourcePosition = "right"; - - node.position = { - x: nodeWithPosition.x - nodeWidth / 2, - y: nodeWithPosition.y - nodeHeight / 2, - }; - - return node; - }); - - return { nodes, edges }; -}; - -const TopologyPage = ({ providerId, service, environment }: Props) => { - const router = useRouter(); - // State for nodes and edges - const [nodes, setNodes] = useState([]); - const [edges, setEdges] = useState([]); - const serviceInput = useContext(ServiceSearchContext); - const [reactFlowInstance, setReactFlowInstance] = - useState>(); - +export default function TopologyPage({ + providerId, + service, + environment, +}: TopologyPageProps) { const { topologyData, error, isLoading } = useTopology( providerId, service, environment ); - - const onNodesChange = useCallback( - (changes: NodeChange[]) => - setNodes((nds) => applyNodeChanges(changes, nds)), - [] - ); - const onEdgesChange = useCallback( - (changes: EdgeChange[]) => - setEdges((eds) => applyEdgeChanges(changes, eds)), - [] - ); - - const onEdgeHover = (eventType: "enter" | "leave", edge: Edge) => { - const newEdges = [...edges]; - const currentEdge = newEdges.find((e) => e.id === edge.id); - if (currentEdge) { - currentEdge.style = eventType === "enter" ? { stroke: "orange" } : {}; - currentEdge.labelBgStyle = - eventType === "enter" ? edgeLabelBgStyleHover : edgeLabelBgStyleNoHover; - currentEdge.markerEnd = - eventType === "enter" ? edgeMarkerEndHover : edgeMarkerEndNoHover; - currentEdge.labelStyle = eventType === "enter" ? { fill: "white" } : {}; - setEdges(newEdges); - } - }; - - const zoomToNode = useCallback( - (nodeId: string) => { - let node = reactFlowInstance?.getNode(nodeId); - if (!node) { - // Maybe its by display name? - node = reactFlowInstance - ?.getNodes() - .find((n) => n.data.display_name === nodeId); - } - if (node && reactFlowInstance) { - reactFlowInstance.setCenter(node.position.x, node.position.y); - } - }, - [reactFlowInstance] - ); - - useEffect(() => { - if (serviceInput) { - zoomToNode(serviceInput); - } - }, [serviceInput, zoomToNode]); - - useEffect(() => { - if (!topologyData) return; - - // Create nodes from service definitions - const newNodes = topologyData.map((service) => ({ - id: service.service.toString(), - type: "customNode", - data: service, - position: { x: 0, y: 0 }, // Dagre will handle the actual positioning - })); - - // Create edges from service dependencies - const edgeMap = new Map(); - - topologyData.forEach((service) => { - service.dependencies.forEach((dependency) => { - const dependencyService = topologyData.find( - (s) => s.service === dependency.serviceName - ); - const edgeId = `${service.service}_${dependency.protocol}_${ - dependencyService - ? dependencyService.service - : dependency.serviceId.toString() - }`; - if (!edgeMap.has(edgeId)) { - edgeMap.set(edgeId, { - id: edgeId, - source: service.service.toString(), - target: dependency.serviceName.toString(), - label: dependency.protocol === "unknown" ? "" : dependency.protocol, - animated: false, - labelBgPadding: edgeLabelBgPaddingNoHover, - labelBgStyle: edgeLabelBgStyleNoHover, - labelBgBorderRadius: edgeLabelBgBorderRadiusNoHover, - markerEnd: edgeMarkerEndNoHover, - }); - } + const searchOptions = useMemo(() => { + const createdApplications = new Set(); + const options: { label: string; value: string }[] = []; + topologyData?.forEach((service) => { + options.push({ + label: service.display_name, + value: service.service.toString(), }); + if ( + service.applicationObject && + !createdApplications.has(service.applicationObject.name) + ) { + createdApplications.add(service.applicationObject.name); + options.push({ + label: service.applicationObject.name, + value: service.applicationObject.name, + }); + } }); - - const newEdges = Array.from(edgeMap.values()); - const layoutedElements = getLayoutedElements(newNodes, newEdges); - setNodes(layoutedElements.nodes); - setEdges(layoutedElements.edges); + return options; }, [topologyData]); - - if (isLoading) return ; - if (error) - return ( -
- { - window.open("https://slack.keephq.dev/", "_blank"); + const { + serviceQuery, + setServiceQuery, + selectedServiceId, + setSelectedServiceId, + } = useContext(ServiceSearchContext); + return ( +
+
+
+ Service Topology + + Data describing the topology of components in your environment. + +
+ + icon={MagnifyingGlassIcon} + options={searchOptions} + placeholder="Search for a service" + onSelect={(option, clearInput) => { + setSelectedServiceId(option.value); + clearInput(); }} + className="w-64 mt-2" />
- ); - - return ( - - - onEdgeHover("enter", edge)} - onEdgeMouseLeave={(_event, edge) => onEdgeHover("leave", edge)} - nodeTypes={{ customNode: CustomNode }} - onInit={(instance) => { - setReactFlowInstance(instance); - }} - > - - - - - {!topologyData || - (topologyData?.length === 0 && ( - <> -
-
-
- router.push("/providers?labels=topology")} - /> -
-
- - ))} - + + + +
); -}; - -export default TopologyPage; +} diff --git a/keep-ui/app/topology/ui/create-application-form.tsx b/keep-ui/app/topology/ui/create-application-form.tsx new file mode 100644 index 000000000..008cd7313 --- /dev/null +++ b/keep-ui/app/topology/ui/create-application-form.tsx @@ -0,0 +1,88 @@ +import { useState, useCallback } from "react"; +import { OnSelectionChangeParams, useOnSelectionChange } from "@xyflow/react"; +import { Application } from "../types"; +import { Button } from "@tremor/react"; +import Modal from "@/components/ui/Modal"; +import { cn } from "utils/helpers"; +import { CreateOrUpdateApplicationForm } from "./create-or-update-application"; +import { useApplications } from "../../../utils/hooks/useApplications"; + +export function CreateApplicationForm({ + zoomToNode, + className, +}: { + zoomToNode: (nodeId: string) => void; + className?: string; +}) { + const { addApplication } = useApplications(); + const [selectedServices, setSelectedServices] = useState< + { + id: string; + name: string; + }[] + >([]); + const [isModalOpen, setIsModalOpen] = useState(false); + // the passed handler has to be memoized, otherwise the hook will not work correctly + const onChange = useCallback(({ nodes }: OnSelectionChangeParams) => { + if (isModalOpen) { + return; + } + setSelectedServices( + nodes + .filter((node) => node.type === "service") + .map((node) => ({ + id: node.id, + name: node.data.display_name as string, + })) + ); + }, []); + + useOnSelectionChange({ + onChange, + }); + + const createApplication = (application: Application) => { + addApplication(application); + setIsModalOpen(false); + setSelectedServices([]); + setTimeout(() => { + zoomToNode(application.id); + }, 100); + }; + + return ( +
+

+ {selectedServices.length > 0 && + `Selected: ${selectedServices.map((service) => service.name).join(", ")}`} +

+ + setIsModalOpen(false)} + > + setIsModalOpen(false)} + /> + +
+ ); +} diff --git a/keep-ui/app/topology/ui/create-or-update-application.tsx b/keep-ui/app/topology/ui/create-or-update-application.tsx new file mode 100644 index 000000000..30051aa58 --- /dev/null +++ b/keep-ui/app/topology/ui/create-or-update-application.tsx @@ -0,0 +1,174 @@ +import { Button } from "@tremor/react"; +import { TextInput, Textarea, AutocompleteInput } from "@/components/ui"; +import { useCallback, useState } from "react"; +import { useTopology } from "utils/hooks/useTopology"; +import { Application } from "../types"; +import { Icon } from "@tremor/react"; +import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/solid"; + +type FormErrors = { + name?: string; + services?: string; +}; + +export function CreateOrUpdateApplicationForm({ + action, + application, + onSubmit, + onCancel, +}: { + action: "create" | "edit"; + application: + | (Pick & Partial) + | Application; + onSubmit: (application: Omit & { id?: string }) => void; + onCancel: () => void; +}) { + const { topologyData } = useTopology(); + const [applicationName, setApplicationName] = useState( + application?.name || "" + ); + const [applicationDescription, setApplicationDescription] = useState( + application?.description || "" + ); + const [selectedServices, setSelectedServices] = useState< + { id: string; name: string }[] + >(application?.services || []); + const [errors, setErrors] = useState({}); + + const validateForm = (formValues: Omit): FormErrors => { + const newErrors: FormErrors = {}; + if (!formValues.name.trim()) { + newErrors.name = "Enter the application name"; + } + if (formValues.services.length === 0) { + newErrors.services = "Select at least one service"; + } + return newErrors; + }; + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const formValues = { + name: applicationName, + description: applicationDescription, + services: selectedServices, + }; + const validationErrors = validateForm(formValues); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + setErrors({}); + onSubmit({ ...formValues, id: application.id }); + }, + [ + applicationName, + applicationDescription, + selectedServices, + application.id, + onSubmit, + ] + ); + + return ( +
+

Group services into an application

+
+
+ Application name + {errors.name && ( +

{errors.name}

+ )} +
+ setApplicationName(e.target.value)} + required={true} + /> +
+
+
+ Description (optional) +
+