From f9c3c469c6e31081be47ebf8c2a82d1fc15d9c39 Mon Sep 17 00:00:00 2001 From: Dor Meiri <37194716+dormeiri@users.noreply.github.com> Date: Mon, 20 Mar 2023 00:35:08 +0200 Subject: [PATCH] Diff improvements (#49) --- packages/ui/src/App.tsx | 26 +- packages/ui/src/Details.tsx | 29 ++- ...urceEditModal.tsx => NewResourceModal.tsx} | 15 +- packages/ui/src/VisNetwork.tsx | 241 ++++++++++++------ packages/ui/src/scanOutputStore.ts | 14 +- packages/ui/src/types.ts | 2 +- packages/ui/tailwind.config.js | 1 + 7 files changed, 221 insertions(+), 107 deletions(-) rename packages/ui/src/{ResourceEditModal.tsx => NewResourceModal.tsx} (88%) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index e1840ee..31d1484 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -7,8 +7,8 @@ import './App.css'; import { Button } from './Button'; import { Details } from './Details'; import { Filter } from './Filter'; +import { NewResourceModal } from './NewResourceModal'; import { Pill } from './Pill'; -import { ResourceEditModal } from './ResourceEditModal'; import { Select } from './Select'; import { Toggle } from './Toggle'; import { VisNetwork } from './VisNetwork'; @@ -64,21 +64,35 @@ export function App() { } function handleNewResource(resource: Resource): void { - scanOutputStore.addResource(resource); + const extendedResource = scanOutputStore.addResource(resource); + VisNetwork.addResource(extendedResource); syncWithStore(); closeResourceEditModal(); } function handleRemoveResource(resourceId: string): void { - scanOutputStore.removeResource(resourceId); + const resource = scanOutputStore.removeResource(resourceId); + if (resource) { + VisNetwork.updateResource(resource); + for (const relationship of resource.relationships ?? []) { + if ((relationship.diff ?? resource.diff) === '+') { + resource.relationships = resource.relationships?.filter((r) => r.resourceId !== relationship.resourceId); + VisNetwork.removeRelationship(resourceId, relationship.resourceId); + } else { + VisNetwork.updateRelationship(resource, relationship); + } + } + } else VisNetwork.removeResource(resourceId); syncWithStore(); } function handleAddRelationship(resourceId: string, relationshipResourceId: string): void { const resource = scanOutputStore.scanOutput.resources.find((r) => r.id === resourceId); if (!resource) throw new Error('Invalid resource'); + const relationship = produceNewRelationship({ resourceId: relationshipResourceId }); resource.relationships ??= []; - resource.relationships.push(produceNewRelationship({ resourceId: relationshipResourceId })); + resource.relationships.push(relationship); + VisNetwork.addRelationship(resource, relationship); syncWithStore(); } @@ -90,8 +104,10 @@ export function App() { if (!relationship) throw new Error('Invalid relationship'); if ((relationship.diff ?? resource.diff) === '+') { resource.relationships = resource.relationships?.filter((r) => r.resourceId !== relationshipResourceId); + VisNetwork.removeRelationship(resourceId, relationshipResourceId); } else { relationship.diff = '-'; + VisNetwork.updateRelationship(resource, relationship); } syncWithStore(); } @@ -141,7 +157,7 @@ export function App() { /> )} - + ); } diff --git a/packages/ui/src/Details.tsx b/packages/ui/src/Details.tsx index b9f276b..7a03784 100644 --- a/packages/ui/src/Details.tsx +++ b/packages/ui/src/Details.tsx @@ -1,4 +1,4 @@ -import { TrashIcon } from '@heroicons/react/20/solid'; +import { MinusIcon, TrashIcon } from '@heroicons/react/20/solid'; import type { ReactElement } from 'react'; import React from 'react'; @@ -77,9 +77,13 @@ export function Details(props: DetailProps) { )} {relationship.url &&
{relationshipLink(relationship.url)}
} -
-
+ + {(props.resource.diff === '-' || relationship.diff === '-') && } + {props.resource.diff !== '-' && relationship.diff !== '-' && ( +
+
+ )} ); } @@ -89,9 +93,10 @@ export function Details(props: DetailProps) {
{props.resource.type && } -
{props.resource.name}
+
{props.resource.name ?? props.resource.id}
-
{props.resource.description &&
{props.resource.description}
} {detail('ID', props.resource.id)} @@ -101,15 +106,15 @@ export function Details(props: DetailProps) { 'Relationships', props.resource.relationships && (
- {props.resource.relationships - ?.filter((r) => r.diff !== '-') - .map((r, i) => ( -
{relationship(r)}
- ))} + {props.resource.relationships.map((r, i) => ( +
{relationship(r)}
+ ))}
) )} - props.addRelationship(props.resource.id, resourceId)} /> + )} {detail( 'Tags', !!props.resource.tags?.length && ( diff --git a/packages/ui/src/ResourceEditModal.tsx b/packages/ui/src/NewResourceModal.tsx similarity index 88% rename from packages/ui/src/ResourceEditModal.tsx rename to packages/ui/src/NewResourceModal.tsx index 8e38677..ba1f7dc 100644 --- a/packages/ui/src/ResourceEditModal.tsx +++ b/packages/ui/src/NewResourceModal.tsx @@ -9,8 +9,7 @@ import { scanOutputStore } from './scanOutputStore'; import type { FilterOption, ResourceExtended } from './types'; import { produceNewRelationship } from './utils'; -interface ResourceEditModalProps { - resource?: ResourceExtended; +interface NewResourceModalProps { isOpen: boolean; close: () => void; save: (resource: ResourceExtended) => void; @@ -21,8 +20,8 @@ function isValidResource(resource: Partial): resource is Resou return !!(resource.id && /[a-z0-9-]+/.test(resource.id)); } -export function ResourceEditModal(props: ResourceEditModalProps) { - const [resource, setResource] = useState>({ ...(props.resource ?? {}) }); +export function NewResourceModal(props: NewResourceModalProps) { + const [resource, setResource] = useState>({}); const resourceIdOptions: FilterOption[] = scanOutputStore.scanOutput.resources.map((r) => ({ key: r.id, @@ -32,8 +31,8 @@ export function ResourceEditModal(props: ResourceEditModalProps) { })); useEffect(() => { - setResource({ ...(props.resource ?? {}) }); - }, [props.resource]); + setResource({}); + }, [props.isOpen]); function handleSave() { if (isValidResource(resource)) props.save(resource); @@ -59,7 +58,7 @@ export function ResourceEditModal(props: ResourceEditModalProps) { } return ( - +
setResource({ ...resource, id: value })} /> @@ -77,7 +76,7 @@ export function ResourceEditModal(props: ResourceEditModalProps) {
-
diff --git a/packages/ui/src/VisNetwork.tsx b/packages/ui/src/VisNetwork.tsx index 9c14aef..f3c63c6 100644 --- a/packages/ui/src/VisNetwork.tsx +++ b/packages/ui/src/VisNetwork.tsx @@ -5,7 +5,7 @@ import { DataSet, DataView } from 'vis-data'; import { Network } from 'vis-network'; import { getTypeImagePath } from './constants'; -import type { ResourceExtended } from './types'; +import type { Diff, RelationshipExtended, ResourceExtended } from './types'; import { everyIncludes } from './utils'; const color = { @@ -18,46 +18,113 @@ const color = { }, }; +const edgeWidth = { + selected: 3, + default: 1, +}; + +interface Node { + id: string; + label: string; + group: string | undefined; + tags: any; + diff: ('+' | '-') | undefined; + font: any; + shape?: 'image'; + image?: string; +} + +interface Edge { + id: string; + from: string; + to: string; + arrowFrom: boolean; + arrowTo: boolean; + labels: Set; + diff: ('+' | '-') | undefined; + color: string | undefined; + label?: string; + arrows?: string; + width?: number; +} + +interface Group { + id: string; + shape: string; + image: string; +} + export interface VisNetworkProps { scanOutput: { resources: ResourceExtended[]; }; selectedTags: string[]; selectedResourceId: string | undefined; - resourceSelected: (nodeId: string) => void; + resourceSelected: (nodeId: string | undefined) => void; withDiff: boolean; } export class VisNetwork extends React.Component { private container = React.createRef(); - private edges: any; - private nodes: any; - private network?: Network; + + private static groups: Record; + private static edges: DataView; + private static nodes: DataView; + private static network?: Network; + + public static addResource(resource: ResourceExtended) { + const node = produceNode(resource); + if (node.group && !VisNetwork.groups[node.group]) { + node.shape = 'image'; + node.image = getTypeImagePath(node.group); + } + VisNetwork.nodes.getDataSet().add(node); + for (const relationship of resource.relationships ?? []) { + this.addRelationship(resource, relationship); + } + } + + public static updateResource(resource: ResourceExtended) { + VisNetwork.nodes.getDataSet().update(produceNode(resource)); + } + + public static removeResource(resourceId: string) { + VisNetwork.nodes.getDataSet().remove(resourceId); + } + + public static addRelationship(resource: ResourceExtended, relationship: RelationshipExtended) { + const edge = VisNetwork.edges.get(produceEdgeId(resource.id, relationship.resourceId)) ?? produceEdge(resource, relationship); + enrichEdgeForVis(edge); + if (VisNetwork.network?.getSelectedNodes().includes(resource.id)) { + edge.width = edgeWidth.selected; + } + VisNetwork.edges.getDataSet().add(edge); + } + + public static updateRelationship(resource: ResourceExtended, relationship: RelationshipExtended) { + if (resource.diff ?? relationship.diff) { + const existingEdge = VisNetwork.edges.get(produceEdgeId(resource.id, relationship.resourceId)); + VisNetwork.edges.getDataSet().update({ ...existingEdge, color: produceEdgeColor(resource.diff, relationship.diff) }); + } + } + + public static removeRelationship(resourceId: string, relationshipResourceId: string) { + VisNetwork.edges.getDataSet().remove(produceEdgeId(resourceId, relationshipResourceId)); + } public constructor(props: VisNetworkProps) { super(props); } private produceNetwork(): void { - this.nodes = this.extractNodes(); - this.edges = this.extractEdges(); - - let i = 0; - // eslint-disable-next-line node/no-unsupported-features/es-builtins - const groups = Object.fromEntries( - [...new Set(this.nodes.map((node: any) => node.group)).values()].map((group) => [ - group, - { - id: i++, - shape: 'image', - image: getTypeImagePath(group), - }, - ]) - ); + VisNetwork.nodes = this.extractNodes(); + VisNetwork.edges = this.extractEdges(); - this.network = new Network( + VisNetwork.groups = Object.fromEntries([...new Set(VisNetwork.nodes.map((node: any) => node.group)).values()].map((group) => [group, produceGroup(group)])); + + VisNetwork.network = new Network( this.container.current!, - { nodes: this.nodes, edges: this.edges }, + { nodes: VisNetwork.nodes, edges: VisNetwork.edges }, { physics: { solver: 'hierarchicalRepulsion', @@ -85,60 +152,28 @@ export class VisNetwork extends React.Component { background: color.background, }, }, - groups, + groups: VisNetwork.groups, } ); - this.network.on('click', (e) => this.props.resourceSelected(e.nodes[0])); + VisNetwork.network.on('click', (e) => this.props.resourceSelected(e.nodes[0])); } - private extractNodes() { - return new DataView( - new DataSet( - this.props.scanOutput.resources.map((resource: ResourceExtended) => ({ - id: resource.id, - label: resource.name ?? resource.id, - group: resource.type ?? resource.source, - tags: (resource.tags ?? []) as any, - diff: resource.diff, - font: { - background: resource.diff ? color.diff[resource.diff] : undefined, - } as any, - })) - ), - { - filter: (node) => (this.props.withDiff || node.diff == null) && everyIncludes(this.props.selectedTags, node.tags), - } - ); + private extractNodes(): DataView { + return new DataView(new DataSet(this.props.scanOutput.resources.map(produceNode)), { + filter: (node) => (this.props.withDiff || node.diff == null) && everyIncludes(this.props.selectedTags, node.tags), + }); } - private extractEdges() { - let i = 0; - + private extractEdges(): DataView { const edges: Record = {}; for (const resource of this.props.scanOutput.resources) { for (const relationship of resource.relationships ?? []) { - const key = [resource.id, relationship.resourceId].sort().join(','); - if (!edges[key]) { - edges[key] = { - id: i++, - from: resource.id, - to: relationship.resourceId, - arrowFrom: false, - arrowTo: false, - labels: new Set(), - diff: relationship.diff ?? resource.diff, - color: relationship.diff ?? resource.diff ? color.diff[relationship.diff ?? resource.diff!] : undefined, - }; - } - if (relationship.action) edges[key].labels.add(relationship.action); - edges[key].arrowFrom ||= relationship.from; - edges[key].arrowTo ||= relationship.to; + const id = produceEdgeId(resource.id, relationship.resourceId); + edges[id] ??= produceEdge(resource, relationship); + enrichEdgeWithRelationship(edges[id], relationship); } } - for (const edge of Object.values(edges)) { - edge.label = [...edge.labels].join('\n'); - edge.arrows = [edge.arrowFrom && 'from', edge.arrowTo && 'to'].filter(Boolean).join(', '); - } + Object.values(edges).forEach(enrichEdgeForVis); return new DataView(new DataSet(Object.values(edges), {}), { filter: (edge) => this.props.withDiff || edge.diff == null, @@ -150,34 +185,84 @@ export class VisNetwork extends React.Component { } public override componentDidUpdate(prevProps: VisNetworkProps): void { - if (!this.network) return; + if (!VisNetwork.network) return; - const isNewNetwork = prevProps.scanOutput !== this.props.scanOutput; - if (isNewNetwork) this.produceNetwork(); - - if (isNewNetwork || prevProps.selectedResourceId !== this.props.selectedResourceId) { - if (!isNewNetwork && prevProps.selectedResourceId != null && this.nodes.get(prevProps.selectedResourceId)) { - for (const edge of this.network.getConnectedEdges(prevProps.selectedResourceId)) { - this.network.updateEdge(edge, { width: 1 }); + if (prevProps.selectedResourceId !== this.props.selectedResourceId) { + if (prevProps.selectedResourceId != null && VisNetwork.nodes.get(prevProps.selectedResourceId)) { + for (const edge of VisNetwork.network.getConnectedEdges(prevProps.selectedResourceId)) { + VisNetwork.network.updateEdge(edge, { width: edgeWidth.default }); } } if (this.props.selectedResourceId != null) { - for (const edge of this.network.getConnectedEdges(this.props.selectedResourceId)) { - this.network.updateEdge(edge, { width: 3 }); + for (const edge of VisNetwork.network.getConnectedEdges(this.props.selectedResourceId)) { + VisNetwork.network.updateEdge(edge, { width: edgeWidth.selected }); } - this.network.focus(this.props.selectedResourceId, { + VisNetwork.network.focus(this.props.selectedResourceId, { animation: true, scale: 1, }); } } - this.nodes.refresh(); - this.edges.refresh(); + VisNetwork.nodes.refresh(); + VisNetwork.edges.refresh(); } public override render() { return
; } } + +function produceNode(resource: ResourceExtended): Node { + return { + id: resource.id, + label: resource.name ?? resource.id, + group: resource.type ?? resource.source, + tags: (resource.tags ?? []) as any, + diff: resource.diff, + font: { + background: resource.diff ? color.diff[resource.diff] : undefined, + } as any, + }; +} + +function produceEdge(resource: ResourceExtended, relationship: RelationshipExtended): Edge { + return { + id: produceEdgeId(resource.id, relationship.resourceId), + from: resource.id, + to: relationship.resourceId, + arrowFrom: !!relationship.from, + arrowTo: !!relationship.to, + labels: relationship.action ? new Set([relationship.action]) : new Set(), + diff: relationship.diff ?? resource.diff, + color: produceEdgeColor(resource.diff, relationship.diff), + }; +} + +function produceEdgeId(resourceId: string, relationshipResourceId: string) { + return [resourceId, relationshipResourceId].sort().join(','); +} + +function enrichEdgeWithRelationship(edge: Edge, relationship: RelationshipExtended) { + if (relationship.action) edge.labels.add(relationship.action); + edge.arrowFrom ||= !!relationship.from; + edge.arrowTo ||= !!relationship.to; +} + +function enrichEdgeForVis(edge: Edge) { + edge.label = [...edge.labels].join('\n'); + edge.arrows = [edge.arrowFrom && 'from', edge.arrowTo && 'to'].filter(Boolean).join(', '); +} + +function produceGroup(group: string): { id: string; shape: string; image: string } { + return { + id: group, + shape: 'image', + image: getTypeImagePath(group), + }; +} + +function produceEdgeColor(resourceDiff: Diff | undefined, relationshipDiff: Diff | undefined) { + return resourceDiff ?? relationshipDiff ? color.diff[resourceDiff ?? relationshipDiff!] : undefined; +} diff --git a/packages/ui/src/scanOutputStore.ts b/packages/ui/src/scanOutputStore.ts index 66f51cd..82ec33e 100644 --- a/packages/ui/src/scanOutputStore.ts +++ b/packages/ui/src/scanOutputStore.ts @@ -83,17 +83,25 @@ class ScanOutputStore { } } - public addResource(resource: Resource) { + public addResource(resource: Resource): ResourceExtended { if (this._scanOutput.resources.some((r) => r.id === resource.id)) throw new ResourceAlreadyExistError(); const newResource: ResourceExtended = { ...resource, source: 'ui', diff: '+' }; this.enrichResource(newResource, this._scanOutput); this._scanOutput.resources.push(newResource); + + return newResource; } - public removeResource(resourceId: string) { + public removeResource(resourceId: string): ResourceExtended | undefined { const resource = this._scanOutput.resources.find((r) => r.id === resourceId); - if (resource) resource.diff = '-'; + if (!resource) throw new Error('Resource does not exist'); + if (resource.diff === '+') { + this._scanOutput.resources.filter((r) => r.id !== resource.id); + return; + } + resource.diff = '-'; + return resource; } private setScanOutput(scanOutputNew: ScanResultExtended) { diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index d811271..f8dc909 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -1,6 +1,6 @@ import type { Relationship, Resource, ScanResult } from '@noodle-graph/types'; -type Diff = '+' | '-'; +export type Diff = '+' | '-'; export interface ResourceExtended extends Resource { relationships?: RelationshipExtended[]; diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js index 097e507..4f0e8d2 100644 --- a/packages/ui/tailwind.config.js +++ b/packages/ui/tailwind.config.js @@ -13,6 +13,7 @@ module.exports = { primary: '#f1f5f9', secondary: '#94a3b8', disabled: '#475569', + danger: '#b91c1c', }, backgroundColor: { darker: '#080d17',