From 7a3e8e0abd6574878f73505a4f3c763711681275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20R=C3=B6sel?= <320272+Traxmaxx@users.noreply.github.com> Date: Wed, 27 Sep 2023 18:01:45 +0200 Subject: [PATCH] feat: display dependency graph on the "Resource Explorer" page (#1005) * feat: add dependency graph backend (#883) * feat: add dependency graph frontend * feat: add filter state and share some of the inventory filter components with dependency graph --------- Co-authored-by: Avinesh Tripathi <73980067+AvineshTripathi@users.noreply.github.com> Co-authored-by: ShubhamPalriwala Co-authored-by: Mohamed Labouardy --- dashboard/.gitignore | 1 - dashboard/.tool-versions | 1 + .../components/explorer/DependencyGraph.tsx | 126 + .../explorer/DependencyGraphError.tsx | 67 + .../explorer/DependencyGraphLoader.tsx | 29 + .../explorer/DependencyGraphSkeleton.tsx | 18 + .../explorer/DependencyGraphWrapper.tsx | 107 + dashboard/components/explorer/config.ts | 118 + .../filter/DependencyGraphFilterDropdown.tsx | 128 + .../filter/DependencyGraphFilterField.tsx | 30 + .../filter/DependencyGraphFilterOptions.tsx | 170 + .../filter/DependencyGraphFilterSummary.tsx | 107 + .../filter/DependendencyGraphFilter.tsx | 95 + .../explorer/hooks/useDependencyGraph.tsx | 141 + dashboard/components/icons/ArrowDownIcon.tsx | 31 + .../filter/InventoryFilterField.tsx | 2 +- .../filter/InventoryFilterOperator.tsx | 28 + .../filter/InventoryFilterValue.tsx | 33 +- .../filter/hooks/useFilterWizard.tsx | 10 + dashboard/components/navbar/Navbar.tsx | 5 +- dashboard/next.config.js | 4 +- dashboard/package-lock.json | 6334 +++++++++++------ dashboard/package.json | 16 +- dashboard/pages/dashboard.tsx | 1 + dashboard/pages/explorer.tsx | 17 + .../assets/img/dependency-graph/aws-node.svg | 12 + dashboard/services/settingsService.ts | 14 + dashboard/styles/globals.css | 5 + dashboard/tailwind.config.js | 3 + dashboard/tsconfig.json | 10 +- handlers/resources_handler.go | 128 + handlers/views_handler.go | 2 +- internal/api/v1/endpoints.go | 1 + internal/internal.go | 2 +- .../20230619100000_add_new_relation_field.go | 24 + models/relation.go | 15 + models/resource.go | 5 +- providers/aws/aws.go | 4 +- providers/aws/ec2/autoscaling_groups.go | 18 + providers/aws/ec2/elastic_ips.go | 30 +- providers/aws/ec2/instances.go | 71 + providers/aws/ec2/keypair.go | 2 +- providers/aws/ec2/security_groups.go | 13 +- providers/aws/ec2/volumes.go | 33 +- providers/aws/ec2/vpcs.go | 52 + providers/aws/iam/roles.go | 38 +- providers/aws/lambda/functions.go | 41 +- 47 files changed, 5799 insertions(+), 2343 deletions(-) create mode 100644 dashboard/.tool-versions create mode 100644 dashboard/components/explorer/DependencyGraph.tsx create mode 100644 dashboard/components/explorer/DependencyGraphError.tsx create mode 100644 dashboard/components/explorer/DependencyGraphLoader.tsx create mode 100644 dashboard/components/explorer/DependencyGraphSkeleton.tsx create mode 100644 dashboard/components/explorer/DependencyGraphWrapper.tsx create mode 100644 dashboard/components/explorer/config.ts create mode 100644 dashboard/components/explorer/filter/DependencyGraphFilterDropdown.tsx create mode 100644 dashboard/components/explorer/filter/DependencyGraphFilterField.tsx create mode 100644 dashboard/components/explorer/filter/DependencyGraphFilterOptions.tsx create mode 100644 dashboard/components/explorer/filter/DependencyGraphFilterSummary.tsx create mode 100644 dashboard/components/explorer/filter/DependendencyGraphFilter.tsx create mode 100644 dashboard/components/explorer/hooks/useDependencyGraph.tsx create mode 100644 dashboard/components/icons/ArrowDownIcon.tsx create mode 100644 dashboard/pages/explorer.tsx create mode 100644 dashboard/public/assets/img/dependency-graph/aws-node.svg create mode 100644 migrations/20230619100000_add_new_relation_field.go create mode 100644 models/relation.go diff --git a/dashboard/.gitignore b/dashboard/.gitignore index ac1d86afd..c87c9b392 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -4,7 +4,6 @@ /node_modules /.pnp .pnp.js -/package-lock.json # testing /coverage diff --git a/dashboard/.tool-versions b/dashboard/.tool-versions new file mode 100644 index 000000000..70d0ba4ea --- /dev/null +++ b/dashboard/.tool-versions @@ -0,0 +1 @@ +nodejs 18.16.1 diff --git a/dashboard/components/explorer/DependencyGraph.tsx b/dashboard/components/explorer/DependencyGraph.tsx new file mode 100644 index 000000000..31ec6f64d --- /dev/null +++ b/dashboard/components/explorer/DependencyGraph.tsx @@ -0,0 +1,126 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { useState, memo } from 'react'; +import CytoscapeComponent from 'react-cytoscapejs'; +import Cytoscape, { EventObject } from 'cytoscape'; + +import nodeHtmlLabel, { + CytoscapeNodeHtmlParams + // @ts-ignore +} from 'cytoscape-node-html-label'; + +// @ts-ignore +import COSEBilkent from 'cytoscape-cose-bilkent'; + +import { ReactFlowData } from './hooks/useDependencyGraph'; +import { + edgeAnimationConfig, + edgeStyleConfig, + graphLayoutConfig, + leafStyleConfig, + maxZoom, + minZoom, + nodeHTMLLabelConfig, + nodeStyeConfig, + zoomLevelBreakpoint +} from './config'; + +export type DependencyGraphProps = { + data: ReactFlowData; +}; + +nodeHtmlLabel(Cytoscape.use(COSEBilkent)); +const DependencyGraph = ({ data }: DependencyGraphProps) => { + const [initDone, setInitDone] = useState(false); + + // Type technically is Cytoscape.EdgeCollection but that throws an unexpected error + const loopAnimation = (eles: any) => { + const ani = eles.animation(edgeAnimationConfig[0], edgeAnimationConfig[1]); + + ani + .reverse() + .play() + .promise('complete') + .then(() => loopAnimation(eles)); + }; + + const cyActionHandlers = (cy: Cytoscape.Core) => { + // make sure we did not init already, otherwise this will be bound more than once + if (!initDone) { + // Add HTML labels for better flexibility + // @ts-ignore + cy.nodeHtmlLabel([ + { + ...nodeHTMLLabelConfig, + tpl(templateData: Cytoscape.NodeDataDefinition) { + return `

${ + templateData.label || ' ' + }

+

${ + templateData.service || ' ' + }

`; + } + } + ]); + // Add class to leave nodes so we can make them smaller + cy.nodes().leaves().addClass('leaf'); + // same for root notes + cy.nodes().roots().addClass('root'); + // Animate edges + cy.edges().forEach(loopAnimation); + + // Hide labels when being zoomed out + cy.on('zoom', event => { + const opacity = cy.zoom() <= zoomLevelBreakpoint ? 0 : 1; + + Array.from( + document.querySelectorAll('.dependency-graph-node-label'), + e => { + // @ts-ignore + e.style.opacity = opacity; + return e; + } + ); + }); + // Make sure to tell we inited successfully and prevent another init + setInitDone(true); + } + }; + + return ( +
+ cyActionHandlers(cy)} + /> +
+ ); +}; + +export default memo(DependencyGraph); diff --git a/dashboard/components/explorer/DependencyGraphError.tsx b/dashboard/components/explorer/DependencyGraphError.tsx new file mode 100644 index 000000000..f996e2ec4 --- /dev/null +++ b/dashboard/components/explorer/DependencyGraphError.tsx @@ -0,0 +1,67 @@ +import Button from '@components/button/Button'; + +type DashboardDependencyGraphErrorProps = { + fetch: () => void; +}; + +function DependencyGraphError({ fetch }: DashboardDependencyGraphErrorProps) { + return ( + <> +
+
+
+

+ Dependency Graph +

+
+

+ Analyze account resource associations +

+
+
+
+
+
+ + + + +

+ Cannot fetch Relationships +

+
+ +
+
+
+
+ + ); +} + +export default DependencyGraphError; diff --git a/dashboard/components/explorer/DependencyGraphLoader.tsx b/dashboard/components/explorer/DependencyGraphLoader.tsx new file mode 100644 index 000000000..1e64707e7 --- /dev/null +++ b/dashboard/components/explorer/DependencyGraphLoader.tsx @@ -0,0 +1,29 @@ +import { memo } from 'react'; +import DependencyGraphError from './DependencyGraphError'; +import DependencyGraphSkeleton from './DependencyGraphSkeleton'; +import DependencyGraphView from './DependencyGraph'; +import { ReactFlowData } from './hooks/useDependencyGraph'; + +export type DependencyGraphLoaderProps = { + loading: boolean; + data: ReactFlowData | undefined; + error: boolean; + fetch: () => void; +}; + +function DependencyGraphLoader({ + loading, + data, + error, + fetch +}: DependencyGraphLoaderProps) { + if (loading) return ; + + if (error) return ; + + if (data && !loading) return ; + + return null; +} + +export default memo(DependencyGraphLoader); diff --git a/dashboard/components/explorer/DependencyGraphSkeleton.tsx b/dashboard/components/explorer/DependencyGraphSkeleton.tsx new file mode 100644 index 000000000..34af17184 --- /dev/null +++ b/dashboard/components/explorer/DependencyGraphSkeleton.tsx @@ -0,0 +1,18 @@ +function DependencyGraphSkeleton() { + return ( + <> +
+
+
+
+
+
+
+ + ); +} + +export default DependencyGraphSkeleton; diff --git a/dashboard/components/explorer/DependencyGraphWrapper.tsx b/dashboard/components/explorer/DependencyGraphWrapper.tsx new file mode 100644 index 000000000..254f1c37a --- /dev/null +++ b/dashboard/components/explorer/DependencyGraphWrapper.tsx @@ -0,0 +1,107 @@ +import { useRouter } from 'next/router'; +import cn from 'classnames'; + +import { useEffect, useState } from 'react'; +import parseURLParams from '@components/inventory/hooks/useInventory/helpers/parseURLParams'; +import { InventoryFilterData } from '@components/inventory/hooks/useInventory/types/useInventoryTypes'; +import ArrowDownIcon from '@components/icons/ArrowDownIcon'; +import DependencyGraphLoader from './DependencyGraphLoader'; +import DependendencyGraphFilter from './filter/DependendencyGraphFilter'; +import useDependencyGraph from './hooks/useDependencyGraph'; + +function DependencyGraphWrapper() { + const { + loading, + data, + error, + fetch, + filters, + displayedFilters, + setDisplayedFilters, + deleteFilter, + setFilters + } = useDependencyGraph(); + const router = useRouter(); + const [filterOpen, setFilterOpen] = useState(false); + + useEffect(() => { + const newFilters: InventoryFilterData[] = Object.keys(router.query).map( + param => parseURLParams(param as string, 'fetch') + ); + const newFiltersToDisplay: InventoryFilterData[] = Object.keys( + router.query + ).map(param => parseURLParams(param as string, 'display')); + + setFilters(newFilters); + setDisplayedFilters(newFiltersToDisplay); + }, [router.query]); + + useEffect(() => { + const newFilters: InventoryFilterData[] = Object.keys(router.query).map( + param => parseURLParams(param as string, 'fetch') + ); + const newFiltersToDisplay: InventoryFilterData[] = Object.keys( + router.query + ).map(param => parseURLParams(param as string, 'display')); + + setFilters(newFilters); + setDisplayedFilters(newFiltersToDisplay); + }, []); + + const hasFilters = + Object.keys(router.query).length > 0 && + displayedFilters && + displayedFilters.length > 0; + + return ( + <> +
+
+

Graph View

+
setFilterOpen(!filterOpen)} + > + {displayedFilters && displayedFilters?.length > 0 && ( + + {displayedFilters?.length} + + )} + Filters + +
+
+
+ +
+ +
+ + ); +} + +export default DependencyGraphWrapper; diff --git a/dashboard/components/explorer/config.ts b/dashboard/components/explorer/config.ts new file mode 100644 index 000000000..9b91f9f3e --- /dev/null +++ b/dashboard/components/explorer/config.ts @@ -0,0 +1,118 @@ +import Cytoscape from 'cytoscape'; + +export const zoomLevelBreakpoint = 1.5; +export const maxZoom = 4; +export const minZoom = 0.25; +export const graphLayoutConfig = { + name: 'cose-bilkent', + // 'draft', 'default' or 'proof" + // - 'draft' fast cooling rate + // - 'default' moderate cooling rate + // - "proof" slow cooling rate + quality: 'default', + // Whether to include labels in node dimensions. Useful for avoiding label overlap + nodeDimensionsIncludeLabels: true, + // number of ticks per frame; higher is faster but more jerky + refresh: 30, + // Whether to fit the network view after when done + fit: true, + // Padding on fit + padding: 10, + // Whether to enable incremental mode + randomize: true, + // Node repulsion (non overlapping) multiplier + nodeRepulsion: 10000, + // Ideal (intra-graph) edge length + idealEdgeLength: 100, + // Divisor to compute edge forces + edgeElasticity: 0.45, + // Nesting factor (multiplier) to compute ideal edge length for inter-graph edges + nestingFactor: 0.1, + // Gravity force (constant) + gravity: 0.25, + // Maximum number of iterations to perform + numIter: 2500, + // Whether to tile disconnected nodes + tile: true, + // Type of layout animation. The option set is {'during', 'end', false} + animate: 'end', + // Duration for animate:end + animationDuration: 500, + // Amount of vertical space to put between degree zero nodes during tiling (can also be a function) + tilingPaddingVertical: 10, + // Amount of horizontal space to put between degree zero nodes during tiling (can also be a function) + tilingPaddingHorizontal: 10, + // Gravity range (constant) for compounds + gravityRangeCompound: 1.5, + // Gravity force (constant) for compounds + gravityCompound: 1.0, + // Gravity range (constant) + gravityRange: 3.8, + // Initial cooling factor for incremental layout + initialEnergyOnIncremental: 0.5, + nodeSeparation: 20000 +}; + +export const nodeStyeConfig = { + width(node) { + return Math.max(2, Math.ceil(node.degree(false) / 2)) * 20; + }, + height(node) { + return Math.max(2, Math.ceil(node.degree(false) / 2)) * 20; + }, + shape: 'ellipse', + 'text-opacity': 1, + 'font-size': 17, + 'background-color': 'white', + 'background-image': node => + node.data('provider') === 'AWS' + ? '/assets/img/dependency-graph/aws-node.svg' + : '', + 'background-height': 20, + 'background-width': 20, + 'border-color': '#EDEBEE', + 'border-width': 1, + 'border-style': 'solid', + 'transition-property': 'opacity', + 'transition-duration': 0.2, + 'transition-timing-function': 'linear' +} as Cytoscape.Css.Node; + +export const edgeStyleConfig = { + width: 1, + 'line-fill': 'linear-gradient', + 'line-gradient-stop-colors': ['#008484', '#33CCCC'], + 'line-style': edge => (edge.data('relation') === 'USES' ? 'solid' : 'dashed'), + 'curve-style': 'unbundled-bezier', + 'control-point-distances': edge => edge.data('controlPointDistances'), + 'control-point-weights': [0.15, 0.85] +} as Cytoscape.Css.Edge; + +export const leafStyleConfig = { + width: 28, + height: 28, + opacity: 1 +} as Cytoscape.Css.Node; + +export const edgeAnimationConfig = [ + { + zoom: { level: 1 }, + easing: 'linear', + style: { + 'line-dash-offset': 24, + 'line-dash-pattern': [4, 4] + } + }, + { + duration: 4000 + } +]; + +export const nodeHTMLLabelConfig = { + query: 'node', // cytoscape query selector + halign: 'center', // title vertical position. Can be 'left',''center, 'right' + valign: 'bottom', // title vertical position. Can be 'top',''center, 'bottom' + halignBox: 'center', // title vertical position. Can be 'left',''center, 'right' + valignBox: 'bottom', // title relative box vertical position. Can be 'top',''center, 'bottom' + cssClass: 'dependency-graph-node-label' // any classes will be as attribute of
container for every title +}; diff --git a/dashboard/components/explorer/filter/DependencyGraphFilterDropdown.tsx b/dashboard/components/explorer/filter/DependencyGraphFilterDropdown.tsx new file mode 100644 index 000000000..c2c927f1d --- /dev/null +++ b/dashboard/components/explorer/filter/DependencyGraphFilterDropdown.tsx @@ -0,0 +1,128 @@ +import { useEffect } from 'react'; +import useInventory from '@components/inventory/hooks/useInventory/useInventory'; +import useFilterWizard from '@components/inventory/components/filter/hooks/useFilterWizard'; +import Button from '@components/button/Button'; +import InventoryFilterBreadcrumbs from '@components/inventory/components/filter/InventoryFilterBreadcrumbs'; +import InventoryFilterOperator from '@components/inventory/components/filter/InventoryFilterOperator'; +import InventoryFilterValue from '@components/inventory/components/filter/InventoryFilterValue'; +import DependencyGraphFilterSummary from './DependencyGraphFilterSummary'; +import DependencyGraphFilterField from './DependencyGraphFilterField'; + +type InventoryFilterDropdownProps = { + position: string; + closeDropdownAfterAdd: boolean; + toggle: () => void; +}; + +export default function InventoryFilterDropdown({ + position, + toggle, + closeDropdownAfterAdd +}: InventoryFilterDropdownProps) { + const { setSkippedSearch, router, setToast } = useInventory(); + + const { + // toggle, + step, + goTo, + handleField, + handleOperator, + handleTagKey, + handleValueCheck, + handleValueInput, + costBetween, + handleCostBetween, + inlineError, + data, + resetData, + cleanValues, + filter + } = useFilterWizard({ router, setSkippedSearch }); + + useEffect(() => { + cleanValues(); + }, []); + + return ( + <> + {/* Dropdown transparent backdrop */} +
+
+
+ {/* Filter breadcrumbs */} + +
+ + {/* Filter summary */} + {step !== 0 && data && data.field && ( + + )} +
+ + {/* Filter steps - 1/3 filter field */} + {step === 0 && ( + + )} + + {/* Filter steps - 2/3 filter operator */} + {step === 1 && ( + + )} + + {/* Filter steps - 3/3 filter value */} + {step === 2 && ( +
{ + e.preventDefault(); + filter(); + if (closeDropdownAfterAdd) toggle(); + }} + > +
+ +
+ {inlineError.hasError && ( +

+ {inlineError.message} +

+ )} +
+ +
+
+ )} +
+
+ + ); +} diff --git a/dashboard/components/explorer/filter/DependencyGraphFilterField.tsx b/dashboard/components/explorer/filter/DependencyGraphFilterField.tsx new file mode 100644 index 000000000..36f1b481f --- /dev/null +++ b/dashboard/components/explorer/filter/DependencyGraphFilterField.tsx @@ -0,0 +1,30 @@ +import Button from '@components/button/Button'; +import DependencyGraphFilterFieldOptions from './DependencyGraphFilterOptions'; + +type DependencyGraphFilterFieldProps = { + handleField: (field: string) => void; +}; + +function DependencyGraphFilterField({ + handleField +}: DependencyGraphFilterFieldProps) { + return ( + <> + {DependencyGraphFilterFieldOptions.map((option, idx) => ( + + ))} + + ); +} + +export default DependencyGraphFilterField; diff --git a/dashboard/components/explorer/filter/DependencyGraphFilterOptions.tsx b/dashboard/components/explorer/filter/DependencyGraphFilterOptions.tsx new file mode 100644 index 000000000..b1b817e6f --- /dev/null +++ b/dashboard/components/explorer/filter/DependencyGraphFilterOptions.tsx @@ -0,0 +1,170 @@ +import { ReactNode } from 'react'; + +export type DependencyGraphFilterFieldOptionsProps = { + label: string; + value: string; + icon: ReactNode; +}; + +const DependencyGraphFilterFieldOptions: DependencyGraphFilterFieldOptionsProps[] = + [ + { + label: 'Cloud provider', + value: 'provider', + icon: ( + + + + + ) + }, + { + label: 'Cloud region', + value: 'region', + icon: ( + + + + + + ) + }, + { + label: 'Cloud service', + value: 'service', + icon: ( + + + + ) + }, + { + label: 'Resource relation', + value: 'relations', + icon: ( + + + + + + + + + + + ) + } + ]; + +export default DependencyGraphFilterFieldOptions; diff --git a/dashboard/components/explorer/filter/DependencyGraphFilterSummary.tsx b/dashboard/components/explorer/filter/DependencyGraphFilterSummary.tsx new file mode 100644 index 000000000..c3692840f --- /dev/null +++ b/dashboard/components/explorer/filter/DependencyGraphFilterSummary.tsx @@ -0,0 +1,107 @@ +import { InventoryFilterData } from '@components/inventory/hooks/useInventory/types/useInventoryTypes'; +import Button from '@components/button/Button'; +import CloseIcon from '@components/icons/CloseIcon'; +import DependencyGraphFilterOptions from './DependencyGraphFilterOptions'; + +type DependencyGraphFilterSummaryProps = { + id?: number; + bg?: 'white'; + data: InventoryFilterData; + deleteFilter?: (idx: number) => void; + resetData?: () => void; +}; + +function DependencyGraphFilterSummary({ + id, + bg, + data, + deleteFilter, + resetData +}: DependencyGraphFilterSummaryProps) { + const index = DependencyGraphFilterOptions.findIndex( + option => option.value === data.field + ); + + function getField(param: 'icon' | 'label') { + if (param === 'icon') return DependencyGraphFilterOptions[index].icon; + if (param === 'label') return DependencyGraphFilterOptions[index].label; + return param; + } + + function getOperator(param: InventoryFilterData['operator']) { + if (param === 'IS') return 'is'; + if (param === 'IS_NOT') return 'is not'; + if (param === 'CONTAINS') return 'contains'; + if (param === 'NOT_CONTAINS') return 'does not contain'; + if (param === 'IS_EMPTY' && data.field !== 'tags') return 'is empty'; + if (param === 'IS_EMPTY' && data.field === 'tags') return 'which are empty'; + if (param === 'IS_NOT_EMPTY' && data.field !== 'tags') + return 'is not empty'; + if (param === 'IS_NOT_EMPTY' && data.field === 'tags') + return 'which are not empty'; + if (param === 'EQUAL') return 'is equal to'; + if (param === 'BETWEEN') return 'is between'; + if (param === 'GREATER_THAN') return 'is greater than'; + if (param === 'LESS_THAN') return 'is less than'; + if (param === 'EXISTS') return 'does exist'; + if (param === 'NOT_EXISTS') return 'does not exist'; + return param; + } + + return ( +
+
+
{getField('icon')}
+

{getField('label')}

+ {data.tagKey &&

: {data.tagKey}

} + {data.operator && ( + <> + : + {getOperator(data.operator)} + + )} + {data.values && + data.values.length > 0 && + data.values.map((value, idx) => ( +

+ {idx === 0 && :} + + {data.field === 'cost' && '$'} + {value} + + {data.values.length > 1 && idx < data.values.length - 1 && ( + + {data.field === 'cost' && data.operator === 'BETWEEN' + ? 'and' + : 'or'} + + )} +

+ ))} +
+ {(deleteFilter || resetData) && ( +
+ +
+ )} +
+ ); +} + +export default DependencyGraphFilterSummary; diff --git a/dashboard/components/explorer/filter/DependendencyGraphFilter.tsx b/dashboard/components/explorer/filter/DependendencyGraphFilter.tsx new file mode 100644 index 000000000..374346d4b --- /dev/null +++ b/dashboard/components/explorer/filter/DependendencyGraphFilter.tsx @@ -0,0 +1,95 @@ +import { ReactNode, useState } from 'react'; +import { NextRouter } from 'next/router'; +import useFilterWizard from '@components/inventory/components/filter/hooks/useFilterWizard'; +import useInventory from '@components/inventory/hooks/useInventory/useInventory'; +import { InventoryFilterData } from '@components/inventory/hooks/useInventory/types/useInventoryTypes'; +import PlusIcon from '@components/icons/PlusIcon'; +import Button from '@components/button/Button'; +import CloseIcon from '@components/icons/CloseIcon'; +import DependencyGraphFilterSummary from './DependencyGraphFilterSummary'; +import DependencyGraphFilterDropdown from './DependencyGraphFilterDropdown'; + +type DependendencyGraphFilterProps = { + hasFilters: boolean | undefined; + displayedFilters: InventoryFilterData[] | undefined; + deleteFilter: (idx: number) => void; + router: NextRouter; + children?: ReactNode; +}; + +function DependendencyGraphFilter({ + hasFilters, + displayedFilters, + deleteFilter, + router, + children +}: DependendencyGraphFilterProps) { + const [skippedSearch, setSkippedSearch] = useState(0); + const { toggle, isOpen } = useFilterWizard({ router, setSkippedSearch }); + + return ( +
+ {!hasFilters ? ( + <> +
+ + Filter +
+ {isOpen && ( + + )} + + ) : ( +
+
Filters
+ {displayedFilters && + displayedFilters.map((activeFilter, idx) => ( + + ))} + +
+
+
+ +
+ {isOpen && ( + + )} +
+ +
+
router.push(router.pathname)} + > + + Clear filters + + +
+
+
+ )} + + {children} +
+ ); +} + +export default DependendencyGraphFilter; diff --git a/dashboard/components/explorer/hooks/useDependencyGraph.tsx b/dashboard/components/explorer/hooks/useDependencyGraph.tsx new file mode 100644 index 000000000..72c27f07c --- /dev/null +++ b/dashboard/components/explorer/hooks/useDependencyGraph.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; + +import { InventoryFilterData } from '@components/inventory/hooks/useInventory/types/useInventoryTypes'; +import settingsService from '@services/settingsService'; + +export type ReactFlowData = { + nodes: any[]; + edges: any[]; +}; + +// converting the json object into data that reactflow needs +// TODO - based on selected library +function GetData(res: any) { + const d = { + nodes: [], + edges: [] + } as ReactFlowData; + res.forEach((ele: any) => { + // check if node exist already + if (d.nodes.findIndex(element => element.id === ele.resourceId) === -1) { + const a = { + data: { + label: ele.name, + service: ele.service, + provider: 'AWS', + id: ele.resourceId, + isRoot: true + } + }; + d.nodes.push(a); + } + + ele.relations.forEach((rel: any) => { + // check for other node exists + if (d.nodes.findIndex(element => element.id === rel.resourceId) === -1) { + const a = { + data: { + id: rel.resourceId, + label: rel.name, + service: ele.service, + type: rel.type, + provider: 'AWS', // when supporting new provider this could be made dynamic + isRoot: false + } + }; + d.nodes.push(a); + } + const edge = { + data: { + id: `${ele.resourceId}-${rel.resourceId}`, + source: ele.resourceId, + target: rel.resourceId, + relation: rel.relation, + label: rel.type, + controlPointDistances: [ + Math.floor(Math.random() * 20), + Math.floor(Math.random() * 21) - 20 + ] + } + }; + d.edges.push(edge); + }); + }); + return d; +} + +function useDependencyGraph() { + const [loading, setLoading] = useState(true); + const [data, setData] = useState(); + const [error, setError] = useState(false); + const [filters, setFilters] = useState([]); + const [displayedFilters, setDisplayedFilters] = + useState(); + + const router = useRouter(); + + function fetch() { + if (!loading) { + setLoading(true); + } + + if (error) { + setError(false); + } + + settingsService.getRelations(filters).then(res => { + if (res === Error) { + setLoading(false); + setError(true); + } else { + setLoading(false); + setData(GetData(res)); + } + }); + } + + function deleteFilter(idx: number) { + const updatedFilters: InventoryFilterData[] = [...filters!]; + updatedFilters.splice(idx, 1); + const url = updatedFilters + .map( + filter => + `${filter.field}${`:${filter.operator}`}${ + filter.values.length > 0 ? `:${filter.values}` : '' + }` + ) + .join('&'); + router.push(url ? `?${url}` : '', undefined, { shallow: true }); + } + + const loadingFilters = + Object.keys(router.query).length > 0 && !displayedFilters && !error; + + const hasFilters = + Object.keys(router.query).length > 0 && + displayedFilters && + displayedFilters.length > 0; + + useEffect(() => { + fetch(); + }, []); + + useEffect(() => { + fetch(); + }, [filters, displayedFilters]); + + return { + loading, + data, + error, + fetch, + filters, + displayedFilters, + setDisplayedFilters, + deleteFilter, + setFilters + }; +} + +export default useDependencyGraph; diff --git a/dashboard/components/icons/ArrowDownIcon.tsx b/dashboard/components/icons/ArrowDownIcon.tsx new file mode 100644 index 000000000..254be3ea0 --- /dev/null +++ b/dashboard/components/icons/ArrowDownIcon.tsx @@ -0,0 +1,31 @@ +import { SVGProps } from 'react'; + +const ArrowDownIcon = (props: SVGProps) => ( + + + + +); + +export default ArrowDownIcon; diff --git a/dashboard/components/inventory/components/filter/InventoryFilterField.tsx b/dashboard/components/inventory/components/filter/InventoryFilterField.tsx index cd275e077..d64ebeccf 100644 --- a/dashboard/components/inventory/components/filter/InventoryFilterField.tsx +++ b/dashboard/components/inventory/components/filter/InventoryFilterField.tsx @@ -1,4 +1,4 @@ -import Button from '../../../button/Button'; +import Button from '@components/button/Button'; import inventoryFilterFieldOptions from './InventoryFilterFieldOptions'; type InventoryFilterFieldProps = { diff --git a/dashboard/components/inventory/components/filter/InventoryFilterOperator.tsx b/dashboard/components/inventory/components/filter/InventoryFilterOperator.tsx index 42eb2e873..3f997d0dd 100644 --- a/dashboard/components/inventory/components/filter/InventoryFilterOperator.tsx +++ b/dashboard/components/inventory/components/filter/InventoryFilterOperator.tsx @@ -54,6 +54,21 @@ const inventoryFilterOperatorCostOptions: InventoryFilterOperatorOptionsProps[] } ]; +const inventoryFilterRelationsOptions: InventoryFilterOperatorOptionsProps[] = [ + { + label: 'is equal to', + value: 'EQUAL' + }, + { + label: 'is greater than', + value: 'GREATER_THAN' + }, + { + label: 'is less than', + value: 'LESS_THAN' + } +]; + function InventoryFilterOperator({ data, handleOperator, @@ -80,6 +95,7 @@ function InventoryFilterOperator({ {/* Operators list which are not tags or cost */} {data.field !== 'tags' && data.field !== 'cost' && + data.field !== 'relations' && inventoryFilterOperatorOptions.map((option, idx) => ( + ))}
); } diff --git a/dashboard/components/inventory/components/filter/InventoryFilterValue.tsx b/dashboard/components/inventory/components/filter/InventoryFilterValue.tsx index 313e03d28..0ed019754 100644 --- a/dashboard/components/inventory/components/filter/InventoryFilterValue.tsx +++ b/dashboard/components/inventory/components/filter/InventoryFilterValue.tsx @@ -121,6 +121,7 @@ function InventoryFilterValue({ {/* Display input for resource name and tag values */} {!options && data.field !== 'cost' && + data.field !== 'relations' && data.operator !== 'IS_EMPTY' && data.operator !== 'IS_NOT_EMPTY' && data.operator !== 'EXISTS' && @@ -140,21 +141,23 @@ function InventoryFilterValue({ )} {/* Display input for cost when is equal, greater or less than */} - {!options && data.field === 'cost' && data.operator !== 'BETWEEN' && ( -
- -
- )} + {!options && + (data.field === 'cost' || data.field === 'relations') && + data.operator !== 'BETWEEN' && ( +
+ +
+ )} {/* Display input for cost when is between */} {!options && data.field === 'cost' && data.operator === 'BETWEEN' && ( diff --git a/dashboard/components/inventory/components/filter/hooks/useFilterWizard.tsx b/dashboard/components/inventory/components/filter/hooks/useFilterWizard.tsx index f77ad9eaf..ff5327e8c 100644 --- a/dashboard/components/inventory/components/filter/hooks/useFilterWizard.tsx +++ b/dashboard/components/inventory/components/filter/hooks/useFilterWizard.tsx @@ -144,6 +144,16 @@ function useFilterWizard({ router, setSkippedSearch }: InventoryFilterProps) { undefined, { shallow: true } ); + } else if (router.asPath === '/explorer/') { + router.push( + `?${data.field === 'tag' ? `tag:${data.tagKey}` : data.field}:${ + data.operator + }${ + data.values.length > 0 ? `:${data.values.map(value => value)}` : '' + }`, + undefined, + { shallow: true } + ); } else { router.push( `${router.asPath}&${ diff --git a/dashboard/components/navbar/Navbar.tsx b/dashboard/components/navbar/Navbar.tsx index 8fc632bc7..0aa081c2c 100644 --- a/dashboard/components/navbar/Navbar.tsx +++ b/dashboard/components/navbar/Navbar.tsx @@ -19,14 +19,15 @@ function Navbar() { { label: 'Inventory', href: '/inventory' }, betaFlagOnboardingWizard ? { label: 'Cloud Accounts', href: '/cloud-accounts' } - : null + : null, + { label: 'Explorer', href: '/explorer' } ].filter(item => item !== null) as NavItem[]; return (