From f9796636d94309620a022c4ccff22b06f1f453c7 Mon Sep 17 00:00:00 2001 From: David Moore Date: Tue, 17 Dec 2024 16:08:10 +1100 Subject: [PATCH 1/6] feat: add referenced services view to api explorer --- .../src/components/apis/APIExplorer.tsx | 25 +++++++++++++++++++ .../frontend/src/lib/utils/flatten-paths.ts | 6 +++++ pkg/dashboard/frontend/src/types.ts | 1 + 3 files changed, 32 insertions(+) diff --git a/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx b/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx index 16698e5de..22f550cad 100644 --- a/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx +++ b/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx @@ -505,6 +505,31 @@ const APIExplorer = () => { + {selectedApiEndpoint.requestingService ? ( +
+ Referenced by: +
+ + + + + +

Open in VSCode

+
+
+
+
+ ) : null} {selectedDoesNotExist && ( Endpoint not found. It might have been updated or removed. diff --git a/pkg/dashboard/frontend/src/lib/utils/flatten-paths.ts b/pkg/dashboard/frontend/src/lib/utils/flatten-paths.ts index 5299e2035..d8fc9d581 100644 --- a/pkg/dashboard/frontend/src/lib/utils/flatten-paths.ts +++ b/pkg/dashboard/frontend/src/lib/utils/flatten-paths.ts @@ -16,6 +16,11 @@ export function flattenPaths(doc: OpenAPIV3.Document): Endpoint[] { return } + // Get the service that is requesting this endpoint + const requestingService = (doc.paths[path] as any)?.[method]?.[ + 'x-nitric-target' + ]?.['name'] + method = method.toUpperCase() const key = `${doc.info.title}-${path}-${method}` const endpoint: Endpoint = { @@ -24,6 +29,7 @@ export function flattenPaths(doc: OpenAPIV3.Document): Endpoint[] { path, method: method as Method, doc, + requestingService, } uniquePaths[key] = endpoint diff --git a/pkg/dashboard/frontend/src/types.ts b/pkg/dashboard/frontend/src/types.ts index a180583e9..853be7571 100644 --- a/pkg/dashboard/frontend/src/types.ts +++ b/pkg/dashboard/frontend/src/types.ts @@ -152,6 +152,7 @@ export interface Endpoint { method: Method params?: Param[] doc: Api['spec'] + requestingService: string } export interface APIRequest { From 89778c73301c2511c6fdc8158dfc7bc54bd92154 Mon Sep 17 00:00:00 2001 From: David Moore Date: Wed, 18 Dec 2024 11:42:04 +1100 Subject: [PATCH 2/6] feat: add route information to api node and routes edge on arch diagram --- .../src/components/apis/APIRoutesList.tsx | 37 ++++++++++ .../components/architecture/Architecture.tsx | 27 ------- .../components/architecture/DetailsDrawer.tsx | 37 +++++++++- .../components/architecture/NitricEdge.tsx | 71 +++++++++++++++++-- .../components/architecture/nodes/APINode.tsx | 16 ++++- .../src/components/architecture/styles.css | 6 -- .../lib/utils/generate-architecture-data.ts | 21 +++++- 7 files changed, 171 insertions(+), 44 deletions(-) create mode 100644 pkg/dashboard/frontend/src/components/apis/APIRoutesList.tsx diff --git a/pkg/dashboard/frontend/src/components/apis/APIRoutesList.tsx b/pkg/dashboard/frontend/src/components/apis/APIRoutesList.tsx new file mode 100644 index 000000000..a65866f6a --- /dev/null +++ b/pkg/dashboard/frontend/src/components/apis/APIRoutesList.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { APIMethodBadge } from './APIMethodBadge' +import type { Endpoint } from '@/types' + +interface APIRoutesListProps { + endpoints: Endpoint[] + apiAddress: string +} + +const APIRoutesList: React.FC = ({ + endpoints, + apiAddress, +}) => { + return ( +
+ {endpoints.map((endpoint) => ( +
+
+ +
+ +
+ ))} +
+ ) +} + +export default APIRoutesList diff --git a/pkg/dashboard/frontend/src/components/architecture/Architecture.tsx b/pkg/dashboard/frontend/src/components/architecture/Architecture.tsx index 0a7efb800..501f5d7b2 100644 --- a/pkg/dashboard/frontend/src/components/architecture/Architecture.tsx +++ b/pkg/dashboard/frontend/src/components/architecture/Architecture.tsx @@ -11,10 +11,6 @@ import ReactFlow, { ReactFlowProvider, Position, Panel, - useOnSelectionChange, - getConnectedEdges, - applyEdgeChanges, - type EdgeSelectionChange, } from 'reactflow' import Dagre from '@dagrejs/dagre' import 'reactflow/dist/style.css' @@ -111,29 +107,6 @@ function ReactFlowLayout() { [setEdges], ) - useOnSelectionChange({ - onChange: ({ nodes: nodesChanged }) => { - const connectedEdges = getConnectedEdges(nodesChanged, edges) - - // select all connected edges if node is selected - if (connectedEdges.length) { - setEdges( - applyEdgeChanges( - connectedEdges.map( - (edge) => - ({ - id: edge.id, - type: 'select', - selected: true, - }) as EdgeSelectionChange, - ), - edges, - ), - ) - } - }, - }) - useEffect(() => { if (!data) return diff --git a/pkg/dashboard/frontend/src/components/architecture/DetailsDrawer.tsx b/pkg/dashboard/frontend/src/components/architecture/DetailsDrawer.tsx index f424b66f2..51a4c0456 100644 --- a/pkg/dashboard/frontend/src/components/architecture/DetailsDrawer.tsx +++ b/pkg/dashboard/frontend/src/components/architecture/DetailsDrawer.tsx @@ -8,7 +8,14 @@ import { } from '../ui/drawer' import { Button } from '../ui/button' import { useCallback, type PropsWithChildren } from 'react' -import { applyNodeChanges, useNodes, useNodeId, useReactFlow } from 'reactflow' +import { + applyNodeChanges, + useNodes, + useNodeId, + useReactFlow, + applyEdgeChanges, + useEdges, +} from 'reactflow' import type { NodeBaseData } from './nodes/NodeBase' import type { nodeTypes } from '@/lib/utils/generate-architecture-data' export interface DetailsDrawerProps extends PropsWithChildren { @@ -17,10 +24,14 @@ export interface DetailsDrawerProps extends PropsWithChildren { open: boolean testHref?: string footerChildren?: React.ReactNode + // children that are rendered after the services reference + trailingChildren?: React.ReactNode nodeType: keyof typeof nodeTypes icon: NodeBaseData['icon'] address?: string services?: string[] + type?: 'node' | 'edge' + edgeId?: string } export const DetailsDrawer = ({ @@ -28,16 +39,20 @@ export const DetailsDrawer = ({ description, children, footerChildren, + trailingChildren, open, testHref, icon: Icon, nodeType, address, services, + type = 'node', + edgeId, }: DetailsDrawerProps) => { const nodeId = useNodeId() - const { setNodes } = useReactFlow() + const { setNodes, setEdges } = useReactFlow() const nodes = useNodes() + const edges = useEdges() const selectServiceNode = useCallback( (serviceNodeId: string) => { @@ -63,6 +78,23 @@ export const DetailsDrawer = ({ ) const close = () => { + if (type === 'edge') { + setEdges( + applyEdgeChanges( + [ + { + id: edgeId || '', + type: 'select', + selected: false, + }, + ], + edges, + ), + ) + + return + } + setNodes( applyNodeChanges( [ @@ -130,6 +162,7 @@ export const DetailsDrawer = ({ ) : null} + {trailingChildren} {footerChildren} diff --git a/pkg/dashboard/frontend/src/components/architecture/NitricEdge.tsx b/pkg/dashboard/frontend/src/components/architecture/NitricEdge.tsx index 9449ae3db..709b7ffa7 100644 --- a/pkg/dashboard/frontend/src/components/architecture/NitricEdge.tsx +++ b/pkg/dashboard/frontend/src/components/architecture/NitricEdge.tsx @@ -9,6 +9,12 @@ import { useStore, type ReactFlowState, } from 'reactflow' +import { DetailsDrawer } from './DetailsDrawer' +import type { Endpoint } from '@/types' +import type { ApiNodeData } from './nodes/APINode' +import type { ServiceNodeData } from './nodes/ServiceNode' +import { Button } from '../ui/button' +import APIRoutesList from '../apis/APIRoutesList' export default function NitricEdge({ id, @@ -19,13 +25,17 @@ export default function NitricEdge({ targetX, targetY, label, - sourcePosition, - targetPosition, + // sourcePosition, + // targetPosition, style = {}, markerEnd, selected, data, -}: EdgeProps) { +}: EdgeProps<{ + type: string + endpoints: Endpoint[] + apiAddress: string +}>) { const allNodes = useNodes() const xEqual = sourceX === targetX @@ -58,9 +68,23 @@ export default function NitricEdge({ curvature: isBiDirectionEdge ? -0.05 : undefined, }) + const isAPIEdge = data?.type === 'api' + + const highlightEdge = selected || sourceNode?.selected || targetNode?.selected + + const Icon = (targetNode?.data as ServiceNodeData).icon + return ( <> - + {label && (
{label.toString().toLocaleLowerCase()} + {isAPIEdge && ( + + + + Open in VSCode + + + } + > +
+ + {(sourceNode?.data as ApiNodeData).title} + {' '} + has{' '} + + {data.endpoints.length}{' '} + {data.endpoints.length === 1 ? 'route' : 'routes'} + {' '} + referenced by{' '} + + {(targetNode?.data as ServiceNodeData).title} + +
+ +
+ )}
)} diff --git a/pkg/dashboard/frontend/src/components/architecture/nodes/APINode.tsx b/pkg/dashboard/frontend/src/components/architecture/nodes/APINode.tsx index a98a333ac..05324810d 100644 --- a/pkg/dashboard/frontend/src/components/architecture/nodes/APINode.tsx +++ b/pkg/dashboard/frontend/src/components/architecture/nodes/APINode.tsx @@ -1,10 +1,13 @@ import { type ComponentType } from 'react' -import type { Api } from '@/types' +import type { Api, Endpoint } from '@/types' import type { NodeProps } from 'reactflow' import NodeBase, { type NodeBaseData } from './NodeBase' +import APIRoutesList from '@/components/apis/APIRoutesList' -export type ApiNodeData = NodeBaseData +export interface ApiNodeData extends NodeBaseData { + endpoints: Endpoint[] +} export const APINode: ComponentType> = (props) => { const { data } = props @@ -20,6 +23,15 @@ export const APINode: ComponentType> = (props) => { testHref: `/`, // TODO add url param to switch to resource address: data.address, services: data.resource.requestingServices, + trailingChildren: data.address ? ( +
+ Routes: + +
+ ) : null, }} /> ) diff --git a/pkg/dashboard/frontend/src/components/architecture/styles.css b/pkg/dashboard/frontend/src/components/architecture/styles.css index e77bb7a6d..94738691a 100644 --- a/pkg/dashboard/frontend/src/components/architecture/styles.css +++ b/pkg/dashboard/frontend/src/components/architecture/styles.css @@ -49,12 +49,6 @@ @apply stroke-black/80; } -.react-flow__edge.selected { - .react-flow__edge-path { - @apply stroke-primary; - } -} - .react-flow__node-api { --nitric-node-from: #2563eb; /* Blue 600 */ --nitric-node-via: #60a5fa; /* Blue 400 */ diff --git a/pkg/dashboard/frontend/src/lib/utils/generate-architecture-data.ts b/pkg/dashboard/frontend/src/lib/utils/generate-architecture-data.ts index 65d4d784b..85eaeac4a 100644 --- a/pkg/dashboard/frontend/src/lib/utils/generate-architecture-data.ts +++ b/pkg/dashboard/frontend/src/lib/utils/generate-architecture-data.ts @@ -63,6 +63,7 @@ import { type BatchNodeData, } from '@/components/architecture/nodes/BatchNode' import { PERMISSION_TO_SDK_LABELS } from '../constants' +import { flattenPaths } from './flatten-paths' export const nodeTypes = { api: APINode, @@ -216,6 +217,8 @@ export function generateArchitectureData(data: WebSocketResponse): { const apiAddress = data.apiAddresses[api.name] const routes = (api.spec && Object.keys(api.spec.paths)) || [] + const allEndpoints = flattenPaths(api.spec) + const node = createNode(api, 'api', { title: api.name, resource: api, @@ -224,6 +227,7 @@ export function generateArchitectureData(data: WebSocketResponse): { description: `${routes.length} ${ routes.length === 1 ? 'Route' : 'Routes' }`, + endpoints: allEndpoints, }) const specEntries = (api.spec && api.spec.paths) || [] @@ -236,10 +240,16 @@ export function generateArchitectureData(data: WebSocketResponse): { return } + const target = method['x-nitric-target']['name'] + + const endpoints = allEndpoints.filter( + (endpoint) => endpoint.requestingService === target, + ) + edges.push({ - id: `e-${api.name}-${method.operationId}-${method['x-nitric-target']['name']}`, + id: `e-${api.name}-${method.operationId}-${target}`, source: node.id, - target: method['x-nitric-target']['name'], + target, animated: true, markerEnd: { type: MarkerType.ArrowClosed, @@ -248,7 +258,12 @@ export function generateArchitectureData(data: WebSocketResponse): { type: MarkerType.ArrowClosed, orient: 'auto-start-reverse', }, - label: 'routes', + label: `${endpoints.length} ${endpoints.length === 1 ? 'Route' : 'Routes'}`, + data: { + type: 'api', + endpoints, + apiAddress, + }, }) }) }) From 3e345b62e6d3b20a638dc2fcaacf753c2b2c7a3a Mon Sep 17 00:00:00 2001 From: David Moore Date: Wed, 18 Dec 2024 12:39:50 +1100 Subject: [PATCH 3/6] adjust spans for api route list --- .../frontend/src/components/apis/APIRoutesList.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/dashboard/frontend/src/components/apis/APIRoutesList.tsx b/pkg/dashboard/frontend/src/components/apis/APIRoutesList.tsx index a65866f6a..1637573c9 100644 --- a/pkg/dashboard/frontend/src/components/apis/APIRoutesList.tsx +++ b/pkg/dashboard/frontend/src/components/apis/APIRoutesList.tsx @@ -12,13 +12,13 @@ const APIRoutesList: React.FC = ({ apiAddress, }) => { return ( -
+
{endpoints.map((endpoint) => (
-
+
-
+
Date: Wed, 18 Dec 2024 12:40:19 +1100 Subject: [PATCH 4/6] add tests --- .../frontend/cypress/e2e/api-explorer.cy.ts | 80 +++++++++++++------ .../frontend/cypress/e2e/architecture.cy.ts | 26 ++++++ .../src/components/apis/APIExplorer.tsx | 1 + .../components/architecture/NitricEdge.tsx | 1 + 4 files changed, 83 insertions(+), 25 deletions(-) diff --git a/pkg/dashboard/frontend/cypress/e2e/api-explorer.cy.ts b/pkg/dashboard/frontend/cypress/e2e/api-explorer.cy.ts index dd943ef25..e5a3786e4 100644 --- a/pkg/dashboard/frontend/cypress/e2e/api-explorer.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/api-explorer.cy.ts @@ -1,3 +1,31 @@ +const expectedEndpoints = [ + 'first-api-/all-methods-DELETE', + 'first-api-/all-methods-GET', + 'first-api-/all-methods-OPTIONS', + 'first-api-/all-methods-PATCH', + 'first-api-/all-methods-POST', + 'first-api-/all-methods-PUT', + 'first-api-/header-test-GET', + 'first-api-/json-test-POST', + 'first-api-/path-test/{name}-GET', + 'first-api-/query-test-GET', + 'first-api-/schedule-count-GET', + 'first-api-/topic-count-GET', + 'second-api-/content-type-binary-GET', + 'second-api-/content-type-css-GET', + 'second-api-/content-type-html-GET', + 'second-api-/content-type-image-GET', + 'second-api-/content-type-xml-GET', + 'second-api-/image-from-bucket-DELETE', + 'second-api-/image-from-bucket-GET', + 'second-api-/image-from-bucket-PUT', + 'second-api-/very-nested-files-PUT', + 'my-db-api-/get-GET', + 'my-secret-api-/get-GET', + 'my-secret-api-/set-POST', + 'my-secret-api-/set-binary-POST', +] + describe('APIs spec', () => { beforeEach(() => { cy.viewport('macbook-16') @@ -6,38 +34,40 @@ describe('APIs spec', () => { }) it('should retrieve correct apis and endpoints', () => { + // open api routes for testing cy.get('[data-rct-item-id="second-api"]').click() - - const expectedEndpoints = [ - 'first-api', - 'first-api-/all-methods-DELETE', - 'first-api-/all-methods-GET', - 'first-api-/all-methods-OPTIONS', - 'first-api-/all-methods-PATCH', - 'first-api-/all-methods-POST', - 'first-api-/all-methods-PUT', - 'first-api-/header-test-GET', - 'first-api-/json-test-POST', - 'first-api-/path-test/{name}-GET', - 'first-api-/query-test-GET', - 'first-api-/schedule-count-GET', - 'first-api-/topic-count-GET', - 'second-api-/content-type-binary-GET', - 'second-api-/content-type-css-GET', - 'second-api-/content-type-html-GET', - 'second-api-/content-type-image-GET', - 'second-api-/content-type-xml-GET', - 'second-api-/image-from-bucket-DELETE', - 'second-api-/image-from-bucket-GET', - 'second-api-/image-from-bucket-PUT', - 'second-api-/very-nested-files-PUT', - ] + cy.get('[data-rct-item-id="my-db-api"]').click() + cy.get('[data-rct-item-id="my-secret-api"]').click() expectedEndpoints.forEach((id) => { cy.get(`[data-rct-item-id="${id}"]`).should('exist') }) }) + it('should have correct service reference', () => { + // open api routes for testing + cy.get('[data-rct-item-id="second-api"]').click() + cy.get('[data-rct-item-id="my-db-api"]').click() + cy.get('[data-rct-item-id="my-secret-api"]').click() + + expectedEndpoints.forEach((id) => { + cy.get(`[data-rct-item-id="${id}"]`).click() + + let expectedServiceFile = 'my-test-service.ts' + + if (id.includes('my-db-api')) { + expectedServiceFile = 'my-test-db.ts' + } else if (id.includes('my-secret-api')) { + expectedServiceFile = 'my-test-secret.ts' + } + + cy.getTestEl('requesting-service').should( + 'contain.text', + expectedServiceFile, + ) + }) + }) + it('should allow query params', () => { cy.intercept('/api/call/**').as('apiCall') diff --git a/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts b/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts index 75e690d27..f8bac20f1 100644 --- a/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts +++ b/pkg/dashboard/frontend/cypress/e2e/architecture.cy.ts @@ -35,4 +35,30 @@ describe('Architecture Spec', () => { expect(cy.contains('.react-flow__node', content)).to.exist }) }) + + it('should have correct routes drawer content', () => { + const expected = [ + [ + 'edge-label-e-first-api-allmethodsget-services/my-test-service.ts', + 'DELETE/all-methodsGET/all-methodsOPTIONS/all-methodsPATCH/all-methodsPOST/all-methodsPUT/all-methodsGET/header-testPOST/json-testGET/path-test/{name}GET/query-testGET/schedule-countGET/topic-count', + ], + [ + 'edge-label-e-second-api-imagefrombucketget-services/my-test-service.ts', + 'GET/content-type-binaryGET/content-type-cssGET/content-type-htmlGET/content-type-imageGET/content-type-xmlDELETE/image-from-bucketGET/image-from-bucketPUT/image-from-bucketPUT/very-nested-files', + ], + [ + 'edge-label-e-my-secret-api-setbinarypost-services/my-test-secret.ts', + 'GET/getPOST/setPOST/set-binary', + ], + ['edge-label-e-my-db-api-getget-services/my-test-db.ts', 'GET/get'], + ] + + expected.forEach(([edge, routes]) => { + cy.getTestEl(edge).click({ + force: true, + }) + + cy.getTestEl('api-routes-list').should('have.text', routes) + }) + }) }) diff --git a/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx b/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx index 22f550cad..9093e9486 100644 --- a/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx +++ b/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx @@ -514,6 +514,7 @@ const APIExplorer = () => { + {selectedApiEndpoint.requestingService} +

Open in VSCode