diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts index 5eb220748949..a2b2775518e8 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts @@ -49,11 +49,13 @@ import { runOnce, stopVersionControlRequest, terminateThreads, - updatePositions + updatePositions, + viewComponentConnections } from '../state/flow/flow.actions'; import { ComponentType } from '@nifi/shared'; import { ConfirmStopVersionControlRequest, + ConnectionDirection, MoveComponentRequest, OpenChangeVersionDialogRequest, OpenLocalChangesDialogRequest @@ -286,25 +288,23 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider { id: 'upstream-downstream', menuItems: [ { - condition: () => { - // TODO - hasUpstream - return false; + condition: (selection: d3.Selection) => { + return this.canvasUtils.hasUpstream(selection); }, - clazz: 'icon', + clazz: 'fa fa-long-arrow-up fa-rotate-45', text: 'Upstream', - action: () => { - // TODO - showUpstream + action: (selection: d3.Selection) => { + this.requestComponentConnections(selection, 'upstream'); } }, { - condition: () => { - // TODO - hasDownstream - return false; + condition: (selection: d3.Selection) => { + return this.canvasUtils.hasDownstream(selection); }, - clazz: 'icon', + clazz: 'fa fa-long-arrow-down fa-rotate-45', text: 'Downstream', - action: () => { - // TODO - showDownstream + action: (selection: d3.Selection) => { + this.requestComponentConnections(selection, 'downstream'); } } ] @@ -1434,4 +1434,48 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider { menuItem.action(selection, event); } } + + /** + * Requests the connections attached to the specified component in the specified direction. + * + * A component's connections are defined in the group that encloses it, which is the group currently + * on the canvas. The exception is a port whose connections cross that group's own boundary — the + * upstream side of an Input Port and the downstream side of an Output Port — which are defined one + * level up and are not rendered alongside the port at all. Those are the two cases that + * hasUpstream/hasDownstream gate on the presence of a parent group. + */ + private requestComponentConnections( + selection: d3.Selection, + direction: ConnectionDirection + ): void { + const crossesParentBoundary: boolean = + (direction === 'upstream' && this.canvasUtils.isInputPort(selection)) || + (direction === 'downstream' && this.canvasUtils.isOutputPort(selection)); + + let groupId: string | null = this.canvasUtils.getProcessGroupId(); + if (crossesParentBoundary) { + groupId = this.canvasUtils.getParentProcessGroupId(); + + // hasUpstream/hasDownstream do not offer these directions without a parent group + if (groupId === null) { + return; + } + } + + const selectionData = selection.datum(); + this.store.dispatch( + viewComponentConnections({ + request: { + id: selectionData.id, + // funnels have no name, and an unreadable component has no name to read + name: selectionData.permissions.canRead + ? (selectionData.component.name ?? selectionData.id) + : selectionData.id, + type: selectionData.type, + groupId, + direction + } + }) + ); + } } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts index 4c48a436d42d..25826b44da41 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts @@ -1010,4 +1010,73 @@ describe('CanvasUtils', () => { ); }); }); + + // These resolve a connection endpoint to the component as it is rendered in the group currently + // being viewed: a port inside a child group resolves to that group, since that is what the canvas + // draws and what the user can select. "View Connections" matches on the result, so every component + // type it offers relies on these. + describe('connection endpoint resolution', () => { + const GROUP_ID = 'group-being-viewed'; + const CHILD_GROUP_ID = 'child-group'; + const PARENT_GROUP_ID = 'parent-group'; + + function viewing(groupId: string): void { + const store = TestBed.inject(MockStore); + store.overrideSelector(selectCurrentProcessGroupId, groupId); + store.refreshState(); + } + + function connection( + sourceId: string, + sourceGroupId: string, + destinationId: string, + destinationGroupId: string + ): any { + return { id: 'conn', sourceId, sourceGroupId, destinationId, destinationGroupId }; + } + + it('resolves a component in the group being viewed to the component itself', () => { + viewing(GROUP_ID); + + // a processor wired to a funnel, both drawn in the group being viewed + const conn = connection('proc-a', GROUP_ID, 'funnel-a', GROUP_ID); + + expect(service.getConnectionSourceComponentId(conn)).toBe('proc-a'); + expect(service.getConnectionDestinationComponentId(conn)).toBe('funnel-a'); + }); + + it('resolves a port inside a child group to that child group', () => { + viewing(GROUP_ID); + + // a processor in the group being viewed feeding an Input Port of a child group, and an + // Output Port of that same child group feeding back in + const into = connection('proc-a', GROUP_ID, 'inner-input-port', CHILD_GROUP_ID); + const outOf = connection('inner-output-port', CHILD_GROUP_ID, 'proc-a', GROUP_ID); + + expect(service.getConnectionDestinationComponentId(into)).toBe(CHILD_GROUP_ID); + expect(service.getConnectionSourceComponentId(outOf)).toBe(CHILD_GROUP_ID); + }); + + it('resolves a port of the group being viewed to the port when the parent group is searched', () => { + // an Input Port's upstream connections are defined in the parent group, so that is the + // group whose flow gets searched — but the endpoint is still reported as belonging to the + // group being viewed, which is what makes it resolve to the port rather than to the group + viewing(GROUP_ID); + + const upstreamOfInputPort = connection('parent-proc', PARENT_GROUP_ID, 'input-port', GROUP_ID); + const downstreamOfOutputPort = connection('output-port', GROUP_ID, 'parent-proc', PARENT_GROUP_ID); + + expect(service.getConnectionDestinationComponentId(upstreamOfInputPort)).toBe('input-port'); + expect(service.getConnectionSourceComponentId(downstreamOfOutputPort)).toBe('output-port'); + }); + + it('resolves both ends of a self loop to the same component', () => { + viewing(GROUP_ID); + + const retry = connection('proc-a', GROUP_ID, 'proc-a', GROUP_ID); + + expect(service.getConnectionSourceComponentId(retry)).toBe('proc-a'); + expect(service.getConnectionDestinationComponentId(retry)).toBe('proc-a'); + }); + }); }); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts index 76587c6a0cc4..f8a86cf20a53 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts @@ -24,6 +24,7 @@ import { ClearBulletinsForGroupResponse, ComponentEntity, ConfirmStopVersionControlRequest, + ComponentConnectionsDialogRequest, CreateComponentRequest, CreateComponentResponse, CreateConnection, @@ -97,7 +98,8 @@ import { TerminateThreadsRequest, UpdatePositionsRequest, UploadProcessGroupRequest, - VersionControlInformationEntity + VersionControlInformationEntity, + ViewComponentConnectionsRequest } from './index'; import { StatusHistoryRequest } from '../../../../state/status-history'; import { @@ -628,6 +630,16 @@ export const replayLastProvenanceEvent = createAction( props<{ request: ReplayLastProvenanceEventRequest }>() ); +export const viewComponentConnections = createAction( + `${CANVAS_PREFIX} View Component Connections`, + props<{ request: ViewComponentConnectionsRequest }>() +); + +export const openComponentConnectionsDialog = createAction( + `${CANVAS_PREFIX} Open Component Connections Dialog`, + props<{ request: ComponentConnectionsDialogRequest }>() +); + export const enableComponent = createAction( `${CANVAS_PREFIX} Enable Component`, props<{ request: EnableComponentRequest | EnableProcessGroupRequest }>() diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts index a66564ab5bc6..485592545189 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts @@ -53,6 +53,7 @@ import { } from 'rxjs'; import { ComponentEntity, + ConnectionEntity, CreateConnectionDialogRequest, CreateProcessGroupDialogRequest, DeleteComponentResponse, @@ -161,6 +162,7 @@ import { ChangeVersionDialog } from '../../ui/canvas/items/flow/change-version-d import { ChangeVersionProgressDialog } from '../../ui/canvas/items/flow/change-version-progress-dialog/change-version-progress-dialog'; import { LocalChangesDialog } from '../../ui/canvas/items/flow/local-changes-dialog/local-changes-dialog'; import { ProcessorBacklogDialog } from '../../ui/canvas/items/processor/backlog-dialog/backlog-dialog.component'; +import { ComponentConnectionsDialog } from '../../ui/canvas/component-connections-dialog/component-connections-dialog.component'; import { ClusterConnectionService } from '../../../../service/cluster-connection.service'; import { ExtensionTypesService } from '../../../../service/extension-types.service'; import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog'; @@ -3159,6 +3161,67 @@ export class FlowEffects { { dispatch: false } ); + /** + * Loads the flow of the group that defines the requested component's connections and retains only + * the connections attached to that component in the requested direction. The group is the one on + * the canvas for most components, and the parent group for the two port cases whose connections + * cross the enclosing group's boundary. + * + * Matching goes through the canvas' own endpoint resolvers rather than comparing the raw ids. A + * connection drawn to a Process Group or Remote Process Group actually terminates at a port inside + * it, and getConnectionSourceComponentId/getConnectionDestinationComponentId are what collapse that + * port back to the group the user sees and selects. They compare against the group currently on the + * canvas, which is the right frame of reference for the parent-group searches too: a connection + * into an Input Port carries that port's own group as its destination group, so it resolves to the + * port rather than to the group.635 + * + * + * Both resolvers read the ids on the connection entity rather than on its component, so a + * connection the current user cannot read — the kind most worth reporting — is still matched. + */ + viewComponentConnections$ = createEffect(() => + this.actions$.pipe( + ofType(FlowActions.viewComponentConnections), + map((action) => action.request), + switchMap((request) => { + const attachedTo = (connection: ConnectionEntity): boolean => + request.direction === 'upstream' + ? this.canvasUtils.getConnectionDestinationComponentId(connection) === request.id + : this.canvasUtils.getConnectionSourceComponentId(connection) === request.id; + + return from(this.flowService.getFlow(request.groupId)).pipe( + map((flowEntity: ProcessGroupFlowEntity) => + FlowActions.openComponentConnectionsDialog({ + request: { + componentName: request.name, + componentType: request.type, + groupId: request.groupId, + direction: request.direction, + connections: flowEntity.processGroupFlow.flow.connections.filter(attachedTo) + } + }) + ), + catchError((errorResponse: HttpErrorResponse) => of(this.snackBarOrFullScreenError(errorResponse))) + ); + }) + ) + ); + + openComponentConnectionsDialog$ = createEffect( + () => + this.actions$.pipe( + ofType(FlowActions.openComponentConnectionsDialog), + map((action) => action.request), + tap((request) => { + this.dialog.open(ComponentConnectionsDialog, { + ...XL_DIALOG, + data: request + }); + }) + ), + { dispatch: false } + ); + showOkDialog$ = createEffect( () => this.actions$.pipe( @@ -4866,7 +4929,7 @@ export class FlowEffects { warnedIds: this.warnedPositionIds }) }); - const sanitizeConnection = (entity: ComponentEntity): ComponentEntity => ({ + const sanitizeConnection = (entity: ConnectionEntity): ConnectionEntity => ({ ...entity, position: sanitizePosition(entity.position, { componentId: entity.id, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts index f9d0122704c1..bcfcb1674801 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts @@ -712,23 +712,23 @@ function getComponentCollection(draftState: FlowState, componentType: ComponentT return collection; } -function processComponentCollection( - proposedComponents: ComponentEntity[], - currentComponents: ComponentEntity[], +function processComponentCollection( + proposedComponents: T[], + currentComponents: T[], addedCache: string[], removedCache: string[], overrideRevisionCheck: boolean -): ComponentEntity[] { +): T[] { // components in the proposed collection but not the current collection - const addedComponents: ComponentEntity[] = proposedComponents.filter((proposedComponent) => { + const addedComponents: T[] = proposedComponents.filter((proposedComponent) => { return !currentComponents.some((currentComponent) => currentComponent.id === proposedComponent.id); }); // components in the current collection that are no longer in the proposed collection - const removedComponents: ComponentEntity[] = currentComponents.filter((currentComponent) => { + const removedComponents: T[] = currentComponents.filter((currentComponent) => { return !proposedComponents.some((proposedComponent) => proposedComponent.id === currentComponent.id); }); // components that are in both the proposed collection and the current collection - const updatedComponents: ComponentEntity[] = currentComponents.filter((currentComponent) => { + const updatedComponents: T[] = currentComponents.filter((currentComponent) => { return proposedComponents.some((proposedComponents) => proposedComponents.id === currentComponent.id); }); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts index 27b84bddbb0a..3d4cb8c9ffff 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts @@ -19,6 +19,7 @@ import { flowFeatureKey, FlowState, SelectedComponent } from './index'; import { createSelector } from '@ngrx/store'; import { CanvasState, selectCanvasState } from '../index'; import { ComponentType, selectCurrentRoute } from '@nifi/shared'; +import { BreadcrumbEntity } from '../../../../state/shared'; import { detectOverlappingConnections, OverlappingConnectionGroup @@ -294,3 +295,35 @@ export const selectOverlappingConnections = createSelector( return detectOverlappingConnections(connections, processGroupId); } ); + +// maps the id of every group whose name is known in the current flow to that group's name: the current +// process group, its ancestors via the breadcrumb chain, and the child process groups and remote process +// groups it contains. Remote process groups are included alongside process groups because a connection to +// one reports the remote process group's own id as the source/destination group id of the remote port it +// terminates at, so the two kinds of id are looked up the same way. +export const selectProcessGroupIdToNameMap = createSelector( + selectBreadcrumbs, + selectProcessGroups, + selectRemoteProcessGroups, + (breadcrumbs: BreadcrumbEntity, processGroups: any[], remoteProcessGroups: any[]): Map => { + const idToName = new Map(); + + // the current process group and each of its ancestors + let breadcrumbEntity: BreadcrumbEntity | undefined = breadcrumbs; + while (breadcrumbEntity) { + if (breadcrumbEntity.permissions.canRead) { + idToName.set(breadcrumbEntity.id, breadcrumbEntity.breadcrumb.name); + } + breadcrumbEntity = breadcrumbEntity.parentBreadcrumb; + } + + // any child process groups or remote process groups of the current process group + [...(processGroups ?? []), ...(remoteProcessGroups ?? [])].forEach((group) => { + if (group.permissions.canRead) { + idToName.set(group.id, group.component.name); + } + }); + + return idToName; + } +); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts index c4e821d01524..063f37410aac 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts @@ -400,6 +400,31 @@ export interface ReplayLastProvenanceEventRequest { nodes: string; } +export type ConnectionDirection = 'upstream' | 'downstream'; + +export interface ViewComponentConnectionsRequest { + // the id of the component whose connections are being requested + id: string; + // the name of the component, or its id when the current user cannot read it + name: string; + // the type of the component + type: ComponentType; + // the id of the group that defines the connections. this is the current group for every component + // except an Input Port searched upstream or an Output Port searched downstream, whose connections + // cross the enclosing group's boundary and are defined in its parent + groupId: string; + direction: ConnectionDirection; +} + +export interface ComponentConnectionsDialogRequest { + componentName: string; + componentType: ComponentType; + // the group the connections belong to, used when navigating to one of them + groupId: string; + direction: ConnectionDirection; + connections: ConnectionEntity[]; +} + /* Snippets */ @@ -454,6 +479,21 @@ export interface ComponentEntityWithDimensions extends ComponentEntity { dimensions: Dimensions; } +/** + * A connection as returned by the flow endpoints. The source and destination are duplicated outside + * of the permission gated `component` so that a connection the current user cannot read can still be + * placed on the canvas. Prefer these fields over `component.source`/`component.destination` when the + * connection may be unauthorized. + */ +export interface ConnectionEntity extends ComponentEntity { + sourceId: string; + sourceGroupId: string; + sourceType: string; + destinationId: string; + destinationGroupId: string; + destinationType: string; +} + export interface Dimensions { width: number; height: number; @@ -465,7 +505,7 @@ export interface Flow { processors: ComponentEntity[]; inputPorts: ComponentEntity[]; outputPorts: ComponentEntity[]; - connections: ComponentEntity[]; + connections: ConnectionEntity[]; labels: ComponentEntity[]; funnels: ComponentEntity[]; } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.html new file mode 100644 index 000000000000..40386fa1f151 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.html @@ -0,0 +1,134 @@ + + +

{{ title }}

+ +
+
Selected Component
{{ componentName }}
+ @if (rows.length === 0) { +
{{ emptyMessage }}
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Source
Process Group
+ + + Source
Component
+ @if (row.source.name === null) { + + + Unauthorized + + } @else { + + } + Connection + + Destination
Process Group
+ + Destination
Component
+ @if (row.destination.name === null) { + + + Unauthorized + + } @else { + + } +
+
+ } +
+
+ + + diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.html.orig b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.html.orig new file mode 100644 index 000000000000..5b02bc6f0b11 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.html.orig @@ -0,0 +1,90 @@ + + +

{{ title }}

+ +
+
{{ componentName }}
+ @if (rows.length === 0) { +
{{ emptyMessage }}
+ } @else { +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Source Process Group + @if (row.source.name === null) { + Unauthorized + } @else { + {{ row.source.name }} + } + Source + @if (row.source.name === null) { + Unauthorized + } @else { + {{ row.source.name }} + } + Destination + @if (row.destination.name === null) { + Unauthorized + } @else { + {{ row.destination.name }} + } + Name + @if (row.name === null) { + Unnamed + } @else { + {{ row.name }} + } + + +
+
+ } +
+
+ + + diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.scss b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.scss new file mode 100644 index 000000000000..79b06663cedc --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.scss @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.component-connections-table { + width: 100%; + + table { + table-layout: fixed; + width: 100%; + } + + // flexible columns: truncate instead of pushing siblings out + .mat-column-source, + .mat-column-destination, + .mat-column-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 0; // works with table-layout: fixed to force shrinking + } + + // component type icons; sized relative to the cell text so they never grow the row height + .component-type-icon { + font-size: 1.15em; + line-height: 1; + vertical-align: baseline; + } + + //.link-cell, + //.cell-text { + .link-cell { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; + } +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.spec.ts new file mode 100644 index 000000000000..c58cccdacfaa --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.spec.ts @@ -0,0 +1,337 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { of } from 'rxjs'; +import { ComponentType } from '@nifi/shared'; + +import { ComponentConnectionsDialog } from './component-connections-dialog.component'; +import { ComponentConnectionsDialogRequest, ConnectionDirection, ConnectionEntity } from '../../../state/flow'; +import { navigateToComponent } from '../../../state/flow/flow.actions'; +import { CanvasUtils } from '../../../service/canvas-utils.service'; + +const COMPONENT_ID = 'a1b2c3d4-0000-0000-0000-000000000000'; +const GROUP_ID = 'e5f6a7b8-0000-0000-0000-000000000000'; +const CHILD_GROUP_ID = 'c9d0e1f2-0000-0000-0000-000000000000'; + +interface ConnectableStub { + id: string; + name: string; +} + +/** + * Builds a readable connection between the two supplied endpoints. The endpoints are reported as + * living in the group being viewed, which is the common case; {@link connectionIntoChildGroup} + * covers an endpoint inside a child group. + */ +function connection( + id: string, + source: ConnectableStub, + destination: ConnectableStub, + component: { name?: string; selectedRelationships?: string[] } = {} +): ConnectionEntity { + return { + id, + permissions: { canRead: true, canWrite: true }, + position: { x: 0, y: 0 }, + revision: { version: 0 }, + sourceId: source.id, + sourceGroupId: GROUP_ID, + sourceType: 'PROCESSOR', + destinationId: destination.id, + destinationGroupId: GROUP_ID, + destinationType: 'INPUT_PORT', + component: { + id, + source, + destination, + ...component + } + }; +} + +/** + * Builds a connection the current user cannot read. The API omits the component entirely in that case + * but still reports the endpoints at the top level of the entity. + */ +function unreadableConnection(id: string, sourceId: string, destinationId: string): ConnectionEntity { + return { + id, + permissions: { canRead: false, canWrite: false }, + position: { x: 0, y: 0 }, + revision: { version: 0 }, + sourceId, + sourceGroupId: GROUP_ID, + sourceType: 'PROCESSOR', + destinationId, + destinationGroupId: GROUP_ID, + destinationType: 'INPUT_PORT', + component: null + }; +} + +/** + * Builds a connection from a component in the viewed group to an Input Port inside a child group. + * The canvas draws this as terminating at the child group, but the entity names the port. + */ +function connectionIntoChildGroup( + id: string, + source: ConnectableStub, + innerPort: ConnectableStub, + name: string +): ConnectionEntity { + return { + id, + permissions: { canRead: true, canWrite: true }, + position: { x: 0, y: 0 }, + revision: { version: 0 }, + sourceId: source.id, + sourceGroupId: GROUP_ID, + sourceType: 'PROCESSOR', + destinationId: innerPort.id, + destinationGroupId: CHILD_GROUP_ID, + destinationType: 'INPUT_PORT', + component: { + id, + name, + source, + destination: innerPort + } + }; +} + +interface CreatedDialog { + component: ComponentConnectionsDialog; + fixture: ComponentFixture; + store: MockStore; + dialogRef: { close: ReturnType; keydownEvents: () => ReturnType }; +} + +function createDialog( + direction: ConnectionDirection, + connections: ConnectionEntity[], + componentName = 'In' +): CreatedDialog { + const dialogRequest: ComponentConnectionsDialogRequest = { + componentName, + groupId: GROUP_ID, + direction, + connections + }; + const dialogRef = { close: vi.fn(), keydownEvents: () => of() }; + + // only formatConnectionName is exercised; the real CanvasUtils subscribes to canvas state on + // construction, which this dialog has no need of + const canvasUtils = { + formatConnectionName: (component: any): string => { + if (component.name) { + return component.name; + } + if (component.selectedRelationships) { + return component.selectedRelationships.join(', '); + } + return ''; + } + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ComponentConnectionsDialog], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: dialogRequest }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: CanvasUtils, useValue: canvasUtils }, + provideMockStore({}) + ] + }); + + const fixture: ComponentFixture = TestBed.createComponent(ComponentConnectionsDialog); + fixture.detectChanges(); + return { + component: fixture.componentInstance, + fixture, + store: TestBed.inject(MockStore), + dialogRef + }; +} + +describe('ComponentConnectionsDialog', () => { + it('should create', () => { + const { component } = createDialog('upstream', []); + expect(component).toBeTruthy(); + }); + + describe('upstream', () => { + it('renders a row per connection showing both endpoints and the connection name', () => { + const { component, fixture } = createDialog('upstream', [ + connection( + 'c1', + { id: 's1', name: 'GenerateFlowFile' }, + { id: COMPONENT_ID, name: 'In' }, + { + selectedRelationships: ['success'] + } + ), + connection( + 'c2', + { id: 's2', name: 'UpdateAttribute' }, + { id: COMPONENT_ID, name: 'In' }, + { + name: 'to the port' + } + ) + ]); + + expect(component.title).toBe('Upstream Connections'); + expect(component.componentName).toBe('In'); + expect(component.rows).toEqual([ + { + id: 'c1', + name: 'success', + source: { name: 'GenerateFlowFile', id: 's1' }, + destination: { name: 'In', id: COMPONENT_ID } + }, + { + id: 'c2', + name: 'to the port', + source: { name: 'UpdateAttribute', id: 's2' }, + destination: { name: 'In', id: COMPONENT_ID } + } + ]); + + const text = fixture.nativeElement.textContent; + expect(text).toContain('GenerateFlowFile'); + expect(text).toContain('UpdateAttribute'); + expect(text).toContain('to the port'); + }); + + it('keeps a connection the user cannot read and marks both endpoints Unauthorized', () => { + const { component, fixture } = createDialog('upstream', [ + unreadableConnection('c1', 'hidden-source', COMPONENT_ID) + ]); + + expect(component.rows).toEqual([ + { + id: 'c1', + name: null, + source: { name: null, id: 'hidden-source' }, + destination: { name: null, id: COMPONENT_ID } + } + ]); + expect(fixture.nativeElement.textContent).toContain('Unauthorized'); + }); + + it('renders an Unnamed placeholder for a connection with neither a name nor relationships', () => { + const { component, fixture } = createDialog('upstream', [ + connection('c1', { id: 's1', name: 'Other Port' }, { id: COMPONENT_ID, name: 'In' }) + ]); + + expect(component.rows[0].name).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Unnamed'); + }); + }); + + describe('downstream', () => { + it('reports the destination alongside the source', () => { + const { component } = createDialog( + 'downstream', + [ + connection( + 'c1', + { id: COMPONENT_ID, name: 'Out' }, + { id: 'd1', name: 'LogAttribute' }, + { + name: 'from the port' + } + ) + ], + 'Out' + ); + + expect(component.title).toBe('Downstream Connections'); + expect(component.rows).toEqual([ + { + id: 'c1', + name: 'from the port', + source: { name: 'Out', id: COMPONENT_ID }, + destination: { name: 'LogAttribute', id: 'd1' } + } + ]); + }); + + it('names the port inside a child group rather than the group the canvas draws', () => { + const { component, fixture } = createDialog( + 'downstream', + [ + connectionIntoChildGroup( + 'c1', + { id: 's1', name: 'GenerateFlowFile' }, + { id: 'inner-port', name: 'Inner In' }, + 'into the group' + ) + ], + 'Child Group' + ); + + expect(component.rows).toEqual([ + { + id: 'c1', + name: 'into the group', + source: { name: 'GenerateFlowFile', id: 's1' }, + destination: { name: 'Inner In', id: 'inner-port' } + } + ]); + expect(fixture.nativeElement.textContent).toContain('Inner In'); + }); + }); + + describe('empty state', () => { + it('reports that no upstream connections were found', () => { + const { fixture } = createDialog('upstream', []); + expect(fixture.nativeElement.textContent).toContain('No upstream connections were found.'); + }); + + it('reports that no downstream connections were found', () => { + const { fixture } = createDialog('downstream', []); + expect(fixture.nativeElement.textContent).toContain('No downstream connections were found.'); + }); + }); + + describe('navigation', () => { + it('navigates to the connection in the group that defines it and closes the dialog', () => { + const { component, store, dialogRef } = createDialog('upstream', [ + connection('c1', { id: 's1', name: 'GenerateFlowFile' }, { id: COMPONENT_ID, name: 'In' }) + ]); + const dispatch = vi.spyOn(store, 'dispatch'); + + component.goTo(component.rows[0]); + + expect(dispatch).toHaveBeenCalledWith( + navigateToComponent({ + request: { + id: 'c1', + processGroupId: GROUP_ID, + type: ComponentType.Connection + } + }) + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.ts new file mode 100644 index 000000000000..4caf09dd6584 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.ts @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Store } from '@ngrx/store'; +import { CloseOnEscapeDialog, ComponentType } from '@nifi/shared'; +import { CanvasState } from '../../../state'; +import { ComponentConnectionsDialogRequest, ConnectionEntity } from '../../../state/flow'; +import { CanvasUtils } from '../../../service/canvas-utils.service'; +import { navigateToComponent } from '../../../state/flow/flow.actions'; +import { selectProcessGroupIdToNameMap } from '../../../state/flow/flow.selectors'; + +/** + * One end of a connection, with enough information to render a cell and navigate to it. + * - {@code id}: the component's own id. + * - {@code groupId}: the id of the process group that directly contains the component. + * - {@code type}: the component type, used to tell {@code navigateToComponent} what it's looking at. + * - {@code name}: the component name, or {@code null} when the current user cannot read the + * connection, in which case the cell renders an "Unauthorized" placeholder and is not clickable. + */ +export interface ConnectionEndpoint { + id: string; + groupId: string; + type: ComponentType; + name: string | null; +} + +/** + * Row in the connections table. + * - {@code id}: the connection id. + * - {@code name}: the connection name, or the relationships it carries when it has no name. + * {@code null} when it has neither, so the cell renders an "Unnamed" placeholder. + * + * Both ends are listed, along with each end's process group, rather than only the far end. When the + * selected component is a Process Group or Remote Process Group the connection actually terminates at + * a port inside it, and which group and port that is matters as much as the component on the other side. + */ +export interface ComponentConnectionRow { + id: string; + name: string | null; + source: ConnectionEndpoint; + destination: ConnectionEndpoint; +} + +/** + * Lists the connections attached to a component in one direction. For most components those + * connections are already drawn on the canvas, so this is a way to reach one whose other end sits + * somewhere else entirely. For an Input Port's upstream connections and an Output Port's downstream + * connections it is the only way, since those are defined in the parent process group and are not drawn + * alongside the port at all. Each of the 5 cells in a row is independently clickable and navigates + * to the process group, component, or connection it represents. + */ +@Component({ + selector: 'component-connections-dialog', + imports: [MatButtonModule, MatDialogModule, MatTableModule, MatTooltipModule], + templateUrl: './component-connections-dialog.component.html', + styleUrls: ['./component-connections-dialog.component.scss'] +}) +export class ComponentConnectionsDialog extends CloseOnEscapeDialog { + private dialogRequest = inject(MAT_DIALOG_DATA); + private componentConnectionsDialogRef = inject>(MatDialogRef); + private store = inject>(Store); + private canvasUtils = inject(CanvasUtils); + // Signal-based snapshot — reads current value synchronously, no manual subscribe/unsubscribe. + private groupIdToName = this.store.selectSignal(selectProcessGroupIdToNameMap); + + // Maps the string type returned by the NiFi API to the ComponentType enum used for navigation. + private static readonly TYPE_MAP: Record = { + PROCESSOR: ComponentType.Processor, + INPUT_PORT: ComponentType.InputPort, + OUTPUT_PORT: ComponentType.OutputPort, + REMOTE_INPUT_PORT: ComponentType.RemoteProcessGroup, + REMOTE_OUTPUT_PORT: ComponentType.RemoteProcessGroup, + FUNNEL: ComponentType.Funnel + }; + + readonly displayedColumns: string[] = [ + 'sourceProcessGroup', + 'sourceComponent', + 'connection', + 'destinationProcessGroup', + 'destinationComponent' + ]; + readonly componentName: string; + readonly componentType: ComponentType = this.dialogRequest.componentType; + readonly title: string; + readonly emptyMessage: string; + readonly rows: ComponentConnectionRow[]; + readonly dialogRequestGroupId: string = this.dialogRequest.groupId; + readonly processGroupType = ComponentType.ProcessGroup; + readonly connectionType = ComponentType.Connection; + + constructor() { + super(); + + const upstream = this.dialogRequest.direction === 'upstream'; + this.componentName = this.dialogRequest.componentName; + this.title = upstream ? 'Upstream Connections' : 'Downstream Connections'; + this.emptyMessage = upstream ? 'No upstream connections were found.' : 'No downstream connections were found.'; + this.rows = this.dialogRequest.connections.map((connection: ConnectionEntity) => this.buildRow(connection)); + } + + /** + * Navigates to and selects the given component, then closes the dialog. + * + * @param id the id of the component to navigate to + * @param processGroupId the id of the process group that should be entered to find the component + * @param type the type of component being navigated to + */ + navigateTo(id: string, processGroupId: string, type: ComponentType): void { + this.store.dispatch( + navigateToComponent({ + request: { + id, + processGroupId, + type + } + }) + ); + this.componentConnectionsDialogRef.close(); + } + + /** + * Maps a Process Group ID value to its name. + * + * @param groupId the uuid of the process group + * @returns string name of the process group + */ + resolveGroupName(groupId: string): string { + return this.groupIdToName().get(groupId) ?? groupId; // note the () — calling the signal + } + + /** + * Resolves the flowfont icon class that represents the given component type, matching the icons + * used for the same components on the canvas. + * + * @param type the type of the component + * @returns the icon class to render ahead of the component name + */ + componentIcon(type: ComponentType): string { + switch (type) { + case ComponentType.Processor: + return 'icon-processor'; + case ComponentType.InputPort: + return 'icon-port-in'; + case ComponentType.OutputPort: + return 'icon-port-out'; + case ComponentType.Funnel: + return 'icon-funnel'; + case ComponentType.ProcessGroup: + return 'icon-group'; + case ComponentType.RemoteProcessGroup: + return 'icon-group-remote'; + case ComponentType.Connection: + return 'icon-connect'; + default: + return 'icon-drop'; + } + } + + private buildRow(connection: ConnectionEntity): ComponentConnectionRow { + const name = connection.component ? this.canvasUtils.formatConnectionName(connection.component) : ''; + + return { + id: connection.id, + name: name === '' ? null : name, + source: { + id: connection.sourceId, + groupId: connection.sourceGroupId, + type: this.mapComponentType(connection.sourceType), + name: connection.component?.source?.name ?? null + }, + destination: { + id: connection.destinationId, + groupId: connection.destinationGroupId, + type: this.mapComponentType(connection.destinationType), + name: connection.component?.destination?.name ?? null + } + }; + } + + private mapComponentType(type: string): ComponentType { + return ComponentConnectionsDialog.TYPE_MAP[type] ?? ComponentType.Connector; + } +}