From 451b96e15e45a493295c60317a12483c695d1729 Mon Sep 17 00:00:00 2001 From: Sachin Chaurasiya Date: Wed, 27 Oct 2021 13:00:19 +0530 Subject: [PATCH] Add Design: Entity detail drawer for lineage tab. (#927) * Add Design: Entity detail drawer for lineage tab. * integrated apis for pipeline and dashboard to show data on drawer. * fixed mainentity should not be clickable. * added support for outside click * addressing review comments * minor tweaks --- .../EntityInfoDrawer.component.tsx | 245 ++++++++++++++++++ .../EntityInfoDrawer.interface.ts | 8 + .../EntityInfoDrawer.style.css | 29 +++ .../EntityLineage/EntityLineage.component.tsx | 168 +++++++++--- .../EntityLineage/EntityLineage.interface.ts | 5 + .../main/resources/ui/src/styles/tailwind.css | 5 + .../resources/ui/src/utils/EntityUtils.ts | 206 +++++++++++++++ .../resources/ui/src/utils/TableUtils.tsx | 9 + 8 files changed, 639 insertions(+), 36 deletions(-) create mode 100644 catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.component.tsx create mode 100644 catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.interface.ts create mode 100644 catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.style.css create mode 100644 catalog-rest-service/src/main/resources/ui/src/components/EntityLineage/EntityLineage.interface.ts create mode 100644 catalog-rest-service/src/main/resources/ui/src/utils/EntityUtils.ts diff --git a/catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.component.tsx b/catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.component.tsx new file mode 100644 index 00000000000..fe6ed6eee3c --- /dev/null +++ b/catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.component.tsx @@ -0,0 +1,245 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import classNames from 'classnames'; +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { getDatabase } from '../../axiosAPIs/databaseAPI'; +import { getPipelineByFqn } from '../../axiosAPIs/pipelineAPI'; +import { getServiceById } from '../../axiosAPIs/serviceAPI'; +import { getTableDetailsByFQN } from '../../axiosAPIs/tableAPI'; +import { EntityType } from '../../enums/entity.enum'; +import { Dashboard } from '../../generated/entity/data/dashboard'; +import { Pipeline } from '../../generated/entity/data/pipeline'; +import { Table } from '../../generated/entity/data/table'; +import { Topic } from '../../generated/entity/data/topic'; +import useToastContext from '../../hooks/useToastContext'; +import { getEntityOverview, getEntityTags } from '../../utils/EntityUtils'; +import { getEntityIcon, getEntityLink } from '../../utils/TableUtils'; +import { SelectedNode } from '../EntityLineage/EntityLineage.interface'; +import Loader from '../Loader/Loader'; +import Tags from '../tags/tags'; +import { LineageDrawerProps } from './EntityInfoDrawer.interface'; +import './EntityInfoDrawer.style.css'; + +const getHeaderLabel = ( + v = '', + type: string, + isMainNode: boolean, + separator = '.' +) => { + const length = v.split(separator).length; + + return ( + <> + {isMainNode ? ( + + {v.split(separator)[length - 1]} + + ) : ( + + + {v.split(separator)[length - 1]} + + + )} + + ); +}; + +const EntityInfoDrawer = ({ + show, + onCancel, + selectedNode, + isMainNode = false, +}: LineageDrawerProps) => { + const showToast = useToastContext(); + const [entityDetail, setEntityDetail] = useState< + Partial & Partial & Partial & Partial + >( + {} as Partial
& + Partial & + Partial & + Partial + ); + const [serviceType, setServiceType] = useState(''); + + const [isLoading, setIsLoading] = useState(false); + + const fetchEntityDetail = (selectedNode: SelectedNode) => { + switch (selectedNode.type) { + case EntityType.TABLE: { + setIsLoading(true); + getTableDetailsByFQN(selectedNode.name, [ + 'tags', + 'owner', + 'columns', + 'usageSummary', + 'tableProfile', + 'database', + ]) + .then((res: AxiosResponse) => { + getDatabase(res.data.database.id, 'service') + .then((resDB: AxiosResponse) => { + getServiceById('databaseServices', resDB.data.service?.id).then( + (resService: AxiosResponse) => { + setServiceType(resService.data.serviceType); + } + ); + }) + .catch((err: AxiosError) => { + const msg = err.message; + showToast({ + variant: 'error', + body: + msg ?? `Error while getting ${selectedNode.name} details`, + }); + }); + setEntityDetail(res.data); + setIsLoading(false); + }) + .catch((err: AxiosError) => { + const msg = err.message; + showToast({ + variant: 'error', + body: msg ?? `Error while getting ${selectedNode.name} details`, + }); + }); + + break; + } + case EntityType.PIPELINE: { + setIsLoading(true); + getPipelineByFqn(selectedNode.name, ['tags', 'owner', 'service']) + .then((res: AxiosResponse) => { + getServiceById('pipelineServices', res.data.service?.id) + .then((serviceRes: AxiosResponse) => { + setServiceType(serviceRes.data.serviceType); + }) + .catch((err: AxiosError) => { + const msg = err.message; + showToast({ + variant: 'error', + body: + msg ?? `Error while getting ${selectedNode.name} service`, + }); + }); + setEntityDetail(res.data); + setIsLoading(false); + }) + .catch((err: AxiosError) => { + const msg = err.message; + showToast({ + variant: 'error', + body: msg ?? `Error while getting ${selectedNode.name} details`, + }); + }); + + break; + } + + default: + break; + } + }; + + useEffect(() => { + fetchEntityDetail(selectedNode); + }, [selectedNode]); + + return ( +
+
+

+ {getEntityIcon(selectedNode.type)} + {getHeaderLabel(selectedNode.name, selectedNode.type, isMainNode)} +

+
+ onCancel(false)}> + + +
+
+
+ {isLoading ? ( + + ) : ( + <> +
+ Overview +
+ {getEntityOverview( + selectedNode.type, + entityDetail, + serviceType + ).map((d) => { + return ( +

+ {d.name && {d.name}:} + + {d.isLink ? ( + + {d.value} + + ) : ( + d.value + )} + +

+ ); + })} +
+
+
+
+ Tags +
+ {getEntityTags(selectedNode.type, entityDetail).length > 0 ? ( + getEntityTags(selectedNode.type, entityDetail).map((t) => { + return ; + }) + ) : ( +

No Tags added

+ )} +
+
+
+
+ Description +
+ {entityDetail.description ?? ( +

+ No description added +

+ )} +
+
+ + )} +
+ ); +}; + +export default EntityInfoDrawer; diff --git a/catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.interface.ts b/catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.interface.ts new file mode 100644 index 00000000000..92df9885631 --- /dev/null +++ b/catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.interface.ts @@ -0,0 +1,8 @@ +import { SelectedNode } from '../EntityLineage/EntityLineage.interface'; + +export interface LineageDrawerProps { + show: boolean; + onCancel: (value: boolean) => void; + selectedNode: SelectedNode; + isMainNode: boolean; +} diff --git a/catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.style.css b/catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.style.css new file mode 100644 index 00000000000..e5627b93fbc --- /dev/null +++ b/catalog-rest-service/src/main/resources/ui/src/components/EntityInfoDrawer/EntityInfoDrawer.style.css @@ -0,0 +1,29 @@ +.side-drawer { + height: 100%; + background: white; + position: absolute; + top: 0; + right: 0; + width: 325px; + z-index: 200; + margin-right: -16px; + overflow-y: auto; + padding: 16px; + transform: translateX(100%); + border-left: 1px solid #d9ceee; + transition: transform 0.3s ease-out; +} + +.backdrop { + position: absolute; + width: 100%; + height: 100%; + background-color: rgba(48, 46, 54, 0.6); + z-index: 100; + top: 0; + right: 0; +} + +.side-drawer.open { + transform: translateX(0); +} diff --git a/catalog-rest-service/src/main/resources/ui/src/components/EntityLineage/EntityLineage.component.tsx b/catalog-rest-service/src/main/resources/ui/src/components/EntityLineage/EntityLineage.component.tsx index 783089d3019..59f19715c55 100644 --- a/catalog-rest-service/src/main/resources/ui/src/components/EntityLineage/EntityLineage.component.tsx +++ b/catalog-rest-service/src/main/resources/ui/src/components/EntityLineage/EntityLineage.component.tsx @@ -23,7 +23,9 @@ import { EntityLineage, } from '../../generated/type/entityLineage'; import { EntityReference } from '../../generated/type/entityReference'; - +import { getEntityIcon } from '../../utils/TableUtils'; +import EntityInfoDrawer from '../EntityInfoDrawer/EntityInfoDrawer.component'; +import { SelectedNode } from './EntityLineage.interface'; const onLoad = (reactFlowInstance: OnLoadParams) => { reactFlowInstance.fitView(); reactFlowInstance.zoomTo(1); @@ -49,16 +51,21 @@ const getDataLabel = (v = '', separator = '.') => { const length = v.split(separator).length; return ( -

+ {v.split(separator)[length - 1]} -

+ ); }; const positionX = 150; const positionY = 60; -const getLineageData = (entityLineage: EntityLineage) => { +const getLineageData = ( + entityLineage: EntityLineage, + onSelect: (state: boolean, value: SelectedNode) => void +) => { const [x, y] = [0, 0]; const nodes = entityLineage['nodes']; let upstreamEdges: Array = @@ -96,7 +103,22 @@ const getLineageData = (entityLineage: EntityLineage) => { targetPosition: Position.Left, type: 'default', className: 'leaf-node', - data: { label: getDataLabel(node.name as string) }, + data: { + label: ( +

+ onSelect(true, { + name: node.name as string, + type: node.type, + id: `node-${node.id}-${depth}`, + }) + }> + {getEntityIcon(node.type)} + {getDataLabel(node.name as string)} +

+ ), + }, position: { x: -positionX * 2 * depth, y: y + positionY * upDepth, @@ -141,7 +163,22 @@ const getLineageData = (entityLineage: EntityLineage) => { targetPosition: Position.Left, type: 'default', className: 'leaf-node', - data: { label: getDataLabel(node.name as string) }, + data: { + label: ( + + onSelect(true, { + name: node.name as string, + type: node.type, + id: `node-${node.id}-${depth}`, + }) + }> + {getEntityIcon(node.type)} + {getDataLabel(node.name as string)} + + ), + }, position: { x: positionX * 2 * depth, y: y + positionY * downDepth, @@ -229,7 +266,21 @@ const getLineageData = (entityLineage: EntityLineage) => { : 'output' : 'input', className: 'leaf-node core', - data: { label: getDataLabel(mainNode.name as string) }, + data: { + label: ( +

+ onSelect(true, { + name: mainNode.name as string, + type: mainNode.type, + }) + }> + {getEntityIcon(mainNode.type)} + {getDataLabel(mainNode.name as string)} +

+ ), + }, position: { x: x, y: y }, }, ...UPStreamNodes.map((up) => { @@ -257,45 +308,90 @@ const Entitylineage: FunctionComponent<{ entityLineage: EntityLineage }> = ({ }: { entityLineage: EntityLineage; }) => { - const [elements, setElements] = useState( - getLineageData(entityLineage) as Elements + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [selectedNode, setSelectedNode] = useState( + {} as SelectedNode ); + + const selectNodeHandler = (state: boolean, value: SelectedNode) => { + setIsDrawerOpen(state); + setSelectedNode(value); + }; + const [elements, setElements] = useState( + getLineageData(entityLineage, selectNodeHandler) as Elements + ); + + const closeDrawer = (value: boolean) => { + setIsDrawerOpen(value); + + setElements((prevElements) => { + return prevElements.map((el) => { + if (el.id === selectedNode.id) { + return { ...el, className: 'leaf-node' }; + } else { + return el; + } + }); + }); + }; + const onElementsRemove = (elementsToRemove: Elements) => setElements((els) => removeElements(elementsToRemove, els)); const onConnect = (params: Edge | Connection) => setElements((els) => addEdge(params, els)); + const onElementClick = (el: FlowElement) => { + setElements((prevElements) => { + return prevElements.map((preEl) => { + if (preEl.id === el.id) { + return { ...preEl, className: `${preEl.className} selected-node` }; + } else { + return { ...preEl, className: 'leaf-node' }; + } + }); + }); + }; + useEffect(() => { - setElements(getLineageData(entityLineage) as Elements); + setElements(getLineageData(entityLineage, selectNodeHandler) as Elements); }, [entityLineage]); return ( -
- {(entityLineage?.downstreamEdges ?? []).length > 0 || - (entityLineage.upstreamEdges ?? []).length ? ( - - - - - - ) : ( -
- No Lineage data available -
- )} +
+
+ {(entityLineage?.downstreamEdges ?? []).length > 0 || + (entityLineage.upstreamEdges ?? []).length ? ( + + onElementClick(el)} + onElementsRemove={onElementsRemove} + onLoad={onLoad} + onNodeContextMenu={onNodeContextMenu} + onNodeMouseEnter={onNodeMouseEnter} + onNodeMouseLeave={onNodeMouseLeave} + onNodeMouseMove={onNodeMouseMove}> + + + + ) : ( +
+ No Lineage data available +
+ )} +
+
); }; diff --git a/catalog-rest-service/src/main/resources/ui/src/components/EntityLineage/EntityLineage.interface.ts b/catalog-rest-service/src/main/resources/ui/src/components/EntityLineage/EntityLineage.interface.ts new file mode 100644 index 00000000000..58f0eea16a5 --- /dev/null +++ b/catalog-rest-service/src/main/resources/ui/src/components/EntityLineage/EntityLineage.interface.ts @@ -0,0 +1,5 @@ +export interface SelectedNode { + name: string; + type: string; + id?: string; +} diff --git a/catalog-rest-service/src/main/resources/ui/src/styles/tailwind.css b/catalog-rest-service/src/main/resources/ui/src/styles/tailwind.css index 2194d57752d..0fe8d6ed926 100644 --- a/catalog-rest-service/src/main/resources/ui/src/styles/tailwind.css +++ b/catalog-rest-service/src/main/resources/ui/src/styles/tailwind.css @@ -308,6 +308,11 @@ } .leaf-node.selected, .leaf-node.selected:hover { + @apply tw-border-main; + box-shadow: 0 0 0 0.5px #e2dce4; + } + .leaf-node.selected-node, + .leaf-node.selected-node:hover { @apply tw-border-primary-active; box-shadow: 0 0 0 0.5px #7147e8; } diff --git a/catalog-rest-service/src/main/resources/ui/src/utils/EntityUtils.ts b/catalog-rest-service/src/main/resources/ui/src/utils/EntityUtils.ts new file mode 100644 index 00000000000..c14cb1f860c --- /dev/null +++ b/catalog-rest-service/src/main/resources/ui/src/utils/EntityUtils.ts @@ -0,0 +1,206 @@ +import { isNil } from 'lodash'; +import { + getDatabaseDetailsPath, + getServiceDetailsPath, + getTeamDetailsPath, +} from '../constants/constants'; +import { EntityType } from '../enums/entity.enum'; +import { Dashboard } from '../generated/entity/data/dashboard'; +import { Pipeline } from '../generated/entity/data/pipeline'; +import { Table } from '../generated/entity/data/table'; +import { Topic } from '../generated/entity/data/topic'; +import { TagLabel } from '../generated/type/tagLabel'; +import { getPartialNameFromFQN } from './CommonUtils'; +import { + getOwnerFromId, + getTierFromTableTags, + getUsagePercentile, +} from './TableUtils'; +import { getTableTags } from './TagsUtils'; +import { getRelativeDay } from './TimeUtils'; + +export const getEntityTags = ( + type: string, + entityDetail: Partial
& + Partial & + Partial & + Partial +): Array => { + switch (type) { + case EntityType.TABLE: { + const tableTags: Array = [ + ...getTableTags(entityDetail.columns || []), + ...(entityDetail.tags || []), + ]; + + return tableTags.map((t) => t.tagFQN); + } + case EntityType.PIPELINE: { + return entityDetail.tags?.map((t) => t.tagFQN) || []; + } + + default: + return []; + } +}; + +export const getEntityOverview = ( + type: string, + entityDetail: Partial
& + Partial & + Partial & + Partial, + serviceType: string +): Array<{ + name: string; + value: string; + isLink: boolean; + isExternal?: boolean; + url?: string; +}> => { + switch (type) { + case EntityType.TABLE: { + const { fullyQualifiedName, owner, tags, usageSummary, tableProfile } = + entityDetail; + const [service, database] = getPartialNameFromFQN( + fullyQualifiedName ?? '', + ['service', 'database'], + '.' + ).split('.'); + const ownerValue = getOwnerFromId(owner?.id); + const tier = getTierFromTableTags(tags || []); + const usage = !isNil(usageSummary?.weeklyStats?.percentileRank) + ? getUsagePercentile(usageSummary?.weeklyStats?.percentileRank || 0) + : '--'; + const queries = usageSummary?.weeklyStats?.count.toLocaleString() || '--'; + const getProfilerRowDiff = (tableProfile: Table['tableProfile']) => { + let retDiff; + if (tableProfile && tableProfile.length > 0) { + let rowDiff: string | number = tableProfile[0].rowCount || 0; + const dayDiff = getRelativeDay( + tableProfile[0].profileDate + ? new Date(tableProfile[0].profileDate).getTime() + : Date.now() + ); + if (tableProfile.length > 1) { + rowDiff = rowDiff - (tableProfile[1].rowCount || 0); + } + retDiff = `${(rowDiff >= 0 ? '+' : '') + rowDiff} rows ${dayDiff}`; + } + + return retDiff; + }; + + const profilerRowDiff = getProfilerRowDiff(tableProfile); + const overview = [ + { + name: 'Service', + value: service, + url: getServiceDetailsPath(service, serviceType), + isLink: true, + }, + { + name: 'Database', + value: database, + url: getDatabaseDetailsPath( + getPartialNameFromFQN( + fullyQualifiedName ?? '', + ['service', 'database'], + '.' + ) + ), + isLink: true, + }, + { + name: 'Owner', + value: ownerValue?.displayName || ownerValue?.name || '--', + url: getTeamDetailsPath(owner?.name || ''), + isLink: ownerValue + ? ownerValue.type === 'team' + ? true + : false + : false, + }, + { + name: 'Tier', + value: tier ? tier.split('.')[1] : '--', + isLink: false, + }, + { + name: 'Usage', + value: usage, + isLink: false, + }, + { + name: 'Queries', + value: `${queries} past week`, + isLink: false, + }, + { + name: 'Rows', + value: + tableProfile && tableProfile[0]?.rowCount + ? tableProfile[0].rowCount + : '--', + isLink: false, + }, + { + name: 'Columns', + value: + tableProfile && tableProfile[0]?.columnCount + ? tableProfile[0].columnCount + : '--', + isLink: false, + }, + ]; + if (!isNil(profilerRowDiff)) { + overview.push({ value: profilerRowDiff, name: '', isLink: false }); + } + + return overview; + } + + case EntityType.PIPELINE: { + const { owner, tags, pipelineUrl, service, fullyQualifiedName } = + entityDetail; + const ownerValue = getOwnerFromId(owner?.id); + const tier = getTierFromTableTags(tags || []); + + const overview = [ + { + name: 'Service', + value: service?.name as string, + url: getServiceDetailsPath(service?.name as string, serviceType), + isLink: true, + }, + { + name: 'Owner', + value: ownerValue?.displayName || ownerValue?.name || '--', + url: getTeamDetailsPath(owner?.name || ''), + isLink: ownerValue + ? ownerValue.type === 'team' + ? true + : false + : false, + }, + { + name: 'Tier', + value: tier ? tier.split('.')[1] : '--', + isLink: false, + }, + { + name: `${serviceType} url`, + value: fullyQualifiedName?.split('.')[1] as string, + url: pipelineUrl as string, + isLink: true, + isExternal: true, + }, + ]; + + return overview; + } + + default: + return []; + } +}; diff --git a/catalog-rest-service/src/main/resources/ui/src/utils/TableUtils.tsx b/catalog-rest-service/src/main/resources/ui/src/utils/TableUtils.tsx index 8680486553a..89d50b68f90 100644 --- a/catalog-rest-service/src/main/resources/ui/src/utils/TableUtils.tsx +++ b/catalog-rest-service/src/main/resources/ui/src/utils/TableUtils.tsx @@ -8,6 +8,7 @@ import { getPipelineDetailsPath, getTopicDetailsPath, } from '../constants/constants'; +import { EntityType } from '../enums/entity.enum'; import { SearchIndex } from '../enums/search.enum'; import { ConstraintTypes } from '../enums/table.enum'; import { Column, Table } from '../generated/entity/data/table'; @@ -164,15 +165,19 @@ export const getEntityLink = ( ) => { switch (indexType) { case SearchIndex.TOPIC: + case EntityType.TOPIC: return getTopicDetailsPath(fullyQualifiedName); case SearchIndex.DASHBOARD: + case EntityType.DASHBOARD: return getDashboardDetailsPath(fullyQualifiedName); case SearchIndex.PIPELINE: + case EntityType.PIPELINE: return getPipelineDetailsPath(fullyQualifiedName); case SearchIndex.TABLE: + case EntityType.TABLE: default: return getDatasetDetailsPath(fullyQualifiedName); } @@ -182,20 +187,24 @@ export const getEntityIcon = (indexType: string) => { let icon = ''; switch (indexType) { case SearchIndex.TOPIC: + case EntityType.TOPIC: icon = 'topic'; break; case SearchIndex.DASHBOARD: + case EntityType.DASHBOARD: icon = 'dashboard'; break; case SearchIndex.PIPELINE: + case EntityType.PIPELINE: icon = 'pipeline'; break; case SearchIndex.TABLE: + case EntityType.TABLE: default: icon = 'table';