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..4ab7c63be 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-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-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-services/my-test-secret.ts',
+ 'GET/getPOST/setPOST/set-binary',
+ ],
+ ['edge-label-e-my-db-api-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 16698e5de..5883e318e 100644
--- a/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx
+++ b/pkg/dashboard/frontend/src/components/apis/APIExplorer.tsx
@@ -505,6 +505,27 @@ const APIExplorer = () => {
+ {selectedApiEndpoint.requestingService ? (
+
+ ) : null}
{selectedDoesNotExist && (
Endpoint not found. It might have been updated or removed.
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..1637573c9
--- /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..39d88ed06 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,12 +68,27 @@ 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 ? (
+
+ ) : 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/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/lib/utils/generate-architecture-data.ts b/pkg/dashboard/frontend/src/lib/utils/generate-architecture-data.ts
index 65d4d784b..e91a50b53 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,18 +217,23 @@ 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,
icon: GlobeAltIcon,
address: apiAddress,
- description: `${routes.length} ${
- routes.length === 1 ? 'Route' : 'Routes'
+ description: `${allEndpoints.length} ${
+ allEndpoints.length === 1 ? 'Route' : 'Routes'
}`,
+ endpoints: allEndpoints,
})
const specEntries = (api.spec && api.spec.paths) || []
+ const uniqueMap = new Map()
+
Object.entries(specEntries).forEach(([path, operations]) => {
AllHttpMethods.forEach((m) => {
const method = operations && (operations[m] as any)
@@ -236,10 +242,24 @@ export function generateArchitectureData(data: WebSocketResponse): {
return
}
+ const target = method['x-nitric-target']['name']
+
+ // we only need one api edge per service target
+ if (uniqueMap.has(target)) {
+ return
+ }
+
+ // mark the target as unique
+ uniqueMap.set(target, target)
+
+ 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}-${target}`,
source: node.id,
- target: method['x-nitric-target']['name'],
+ target,
animated: true,
markerEnd: {
type: MarkerType.ArrowClosed,
@@ -248,7 +268,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,
+ },
})
})
})
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 {