Skip to content

Commit

Permalink
feat: display dependency graph on the "Resource Explorer" page (#1005)
Browse files Browse the repository at this point in the history
* 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 <spalriwalau@gmail.com>
Co-authored-by: Mohamed Labouardy <mohamed@labouardy.com>
  • Loading branch information
4 people authored Sep 27, 2023
1 parent 5c7d07f commit 7a3e8e0
Show file tree
Hide file tree
Showing 47 changed files with 5,799 additions and 2,343 deletions.
1 change: 0 additions & 1 deletion dashboard/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
/node_modules
/.pnp
.pnp.js
/package-lock.json

# testing
/coverage
Expand Down
1 change: 1 addition & 0 deletions dashboard/.tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodejs 18.16.1
126 changes: 126 additions & 0 deletions dashboard/components/explorer/DependencyGraph.tsx
Original file line number Diff line number Diff line change
@@ -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 `<div><p style="font-size: 10px; text-shadow: 0 0 5px #F4F9F9,0 0 5px #F4F9F9,
0 0 5px #F4F9F9,0 0 5px #F4F9F9,
0 0 5px #F4F9F9,0 0 5px #F4F9F9,
0 0 5px #F4F9F9,0 0 5px #F4F9F9;" class="text-black-700 text-ellipsis max-w-[100px] overflow-hidden whitespace-nowrap text-center">${
templateData.label || '&nbsp;'
}</p>
<p style="font-size: 10px; text-shadow: 0 0 5px #F4F9F9,0 0 5px #F4F9F9,
0 0 5px #F4F9F9,0 0 5px #F4F9F9,
0 0 5px #F4F9F9,0 0 5px #F4F9F9,
0 0 5px #F4F9F9,0 0 5px #F4F9F9;" class="text-black-400 text-ellipsis max-w-[100px] overflow-hidden whitespace-nowrap text-center font-thin">${
templateData.service || '&nbsp;'
}</p></div>`;
}
}
]);
// 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 (
<div className="relative h-full flex-1 bg-dependency-graph bg-[length:40px_40px]">
<CytoscapeComponent
className="h-full w-full"
elements={CytoscapeComponent.normalizeElements({
nodes: data.nodes,
edges: data.edges
})}
maxZoom={maxZoom}
minZoom={minZoom}
layout={graphLayoutConfig}
stylesheet={[
{
selector: 'node',
style: nodeStyeConfig
},
{
selector: 'edge',
style: edgeStyleConfig
},
{
selector: '.leaf',
style: leafStyleConfig
}
]}
cy={(cy: Cytoscape.Core) => cyActionHandlers(cy)}
/>
</div>
);
};

export default memo(DependencyGraph);
67 changes: 67 additions & 0 deletions dashboard/components/explorer/DependencyGraphError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Button from '@components/button/Button';

type DashboardDependencyGraphErrorProps = {
fetch: () => void;
};

function DependencyGraphError({ fetch }: DashboardDependencyGraphErrorProps) {
return (
<>
<div className={`w-full rounded-lg bg-white px-6 py-4 pb-6`}>
<div className="-mx-6 flex items-center justify-between border-b border-black-200/40 px-6 pb-4">
<div>
<p className="text-sm font-semibold text-black-900">
Dependency Graph
</p>
<div className="mt-1"></div>
<p className="text-xs text-black-300">
Analyze account resource associations
</p>
</div>
<div className="flex h-[60px] items-center"></div>
</div>
<div className="mt-8"></div>
<div className="flex flex-col items-center justify-center">
<svg
className="h-20 w-20"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path stroke="none" d="M0 0h24v24H0z" />
<path d="M4 8V6a2 2 0 012-2h2M4 16v2a2 2 0 002 2h2M16 4h2a2 2 0 012 2v2M16 20h2a2 2 0 002-2v-2M9 10h.01M15 10h.01M9.5 15.05a3.5 3.5 0 015 0" />
</svg>
<p className="text-sm font-semibold text-black-900">
Cannot fetch Relationships
</p>
<div className="m-2 flex-shrink-0">
<Button style="secondary" size="sm" onClick={fetch}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
d="M22 12c0 5.52-4.48 10-10 10s-8.89-5.56-8.89-5.56m0 0h4.52m-4.52 0v5M2 12C2 6.48 6.44 2 12 2c6.67 0 10 5.56 10 5.56m0 0v-5m0 5h-4.44"
></path>
</svg>
Try again
</Button>
</div>
</div>
<div className="mt-12"></div>
</div>
</>
);
}

export default DependencyGraphError;
29 changes: 29 additions & 0 deletions dashboard/components/explorer/DependencyGraphLoader.tsx
Original file line number Diff line number Diff line change
@@ -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 <DependencyGraphSkeleton />;

if (error) return <DependencyGraphError fetch={fetch} />;

if (data && !loading) return <DependencyGraphView data={data} />;

return null;
}

export default memo(DependencyGraphLoader);
18 changes: 18 additions & 0 deletions dashboard/components/explorer/DependencyGraphSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
function DependencyGraphSkeleton() {
return (
<>
<div
data-testid="loading"
className="relative flex h-full items-center justify-center bg-dependency-graph bg-[length:40px_40px] align-middle"
>
<div>
<div className="h-3 w-24 rounded-lg bg-komiser-200/50"></div>
<div className="mt-2"></div>
<div className="h-3 w-48 rounded-lg bg-komiser-200/50"></div>
</div>
</div>
</>
);
}

export default DependencyGraphSkeleton;
107 changes: 107 additions & 0 deletions dashboard/components/explorer/DependencyGraphWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div className="flex h-[calc(100vh-145px)] w-full flex-col">
<div className="flex flex-row justify-between gap-2">
<p className="text-lg font-medium text-black-900">Graph View</p>
<div
className={cn(
'absolute -top-1 right-24 z-20 flex translate-y-0 cursor-pointer items-center justify-start gap-4 rounded-b-[4px] border-x border-b border-black-170 bg-white px-4 py-2 text-sm transition',
{ 'translate-y-[105px]': filterOpen }
)}
onClick={() => setFilterOpen(!filterOpen)}
>
{displayedFilters && displayedFilters?.length > 0 && (
<span className="bg-komiser-130 px-[6px] pb-[3px] pt-[2px] text-xs text-komiser-600">
{displayedFilters?.length}
</span>
)}
<span className="">Filters</span>
<ArrowDownIcon
height="16"
width="16"
className={cn('transition', {
'rotate-180': filterOpen
})}
/>
</div>
</div>
<div
className={cn(
'absolute left-0 top-0 z-10 m-0 h-[102px] w-full origin-top scale-y-0 border-b border-black-170 bg-white px-24 transition',
{ 'scale-y-100': filterOpen }
)}
>
<DependendencyGraphFilter
router={router}
hasFilters={hasFilters}
displayedFilters={displayedFilters}
deleteFilter={deleteFilter}
/>
</div>
<DependencyGraphLoader
loading={loading}
data={data}
error={error}
fetch={fetch}
/>
</div>
</>
);
}

export default DependencyGraphWrapper;
Loading

0 comments on commit 7a3e8e0

Please sign in to comment.