This is an automated email from the ASF dual-hosted git repository.

rfellows pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new cdf1b214a3e NIFI-14777 Restore capability from NiFi 1.x to view 
upstream/downstre… (#11582)
cdf1b214a3e is described below

commit cdf1b214a3e48619b1d509de229780748c21683e
Author: markobean <[email protected]>
AuthorDate: Tue Sep 22 13:35:15 2026 -0400

    NIFI-14777 Restore capability from NiFi 1.x to view upstream/downstre… 
(#11582)
    
    * NIFI-14777 Restore capability from NiFi 1.x to view upstream/downstream 
connections. View connections > upstream/downstream is available in the context 
menu from a variety of components: input/output ports, processors, process 
groups, remote process groups and funnels.
    
    * NIFI-14777 update arrow direction on upstream/downstream context menu 
items
    
    * fix 1: remove <button> in favor of <a>
    
    * make current process group non-navigable
    
    * NIFI-14777 refactor PG ID/name selection to include possible peer PG in 
the parent group to support port-to-port connection in sibling PGs
    Update table formatting with fixed header and scrollable
    
    * NIFI-14777 refactored component-connections-dialog.copmonent.spec.ts to 
match current state of upstream/downstream dialog functionality; original was 
from a older implementation
    
    * NIFI-14777 updated functionality in dialog to remove the clickable 
feature of remote port components
    
    * NIFI-14777 fine tune presentation format of text in table
    
    * NIFI-14777 updated link for remote process group vs. process group
    
    * NIFI-14777 Added functionality for context referring to the current 
process group, e.g. right-click on empty space on canvas, including test specs
    
    * NIFI-14777 removed extra whitespace before link text
    
    * NIFI-14777 make dialog table columns sortable including test spec
    
    * NIFI-14777 implemented suggestion to re-use existing component-context 
widget
    
    * NIFI-14777 update to handle permissions properly
    
    * NIFI-14777 updated a couple UI display/formatting options
    
    This closes #11582
---
 .../service/canvas-context-menu.service.ts         |  102 +-
 .../service/canvas-utils.service.spec.ts           |   69 +
 .../flow-designer/service/canvas-utils.service.ts  |    8 +
 .../state/flow/component-connections.utils.spec.ts |  170 +++
 .../state/flow/component-connections.utils.ts      |   80 ++
 .../pages/flow-designer/state/flow/flow.actions.ts |   14 +-
 .../pages/flow-designer/state/flow/flow.effects.ts |  140 +-
 .../pages/flow-designer/state/flow/flow.reducer.ts |   14 +-
 .../app/pages/flow-designer/state/flow/index.ts    |   49 +-
 .../component-connections-dialog.component.html    |  146 ++
 .../component-connections-dialog.component.scss    |   49 +
 .../component-connections-dialog.component.spec.ts | 1430 ++++++++++++++++++++
 .../component-connections-dialog.component.ts      |  400 ++++++
 13 files changed, 2647 insertions(+), 24 deletions(-)

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 6267e71468f..99b213f9c4e 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
@@ -50,16 +50,18 @@ import {
     stopSources,
     stopVersionControlRequest,
     terminateThreads,
-    updatePositions
+    updatePositions,
+    viewComponentConnections
 } from '../state/flow/flow.actions';
 import { ComponentType } from '@nifi/shared';
 import {
     ConfirmStopVersionControlRequest,
+    ConnectionDirection,
     MoveComponentRequest,
     OpenChangeVersionDialogRequest,
     OpenLocalChangesDialogRequest
 } from '../state/flow';
-import { UpdateComponentRequest } from '../../../state/shared';
+import { BreadcrumbEntity, UpdateComponentRequest } from 
'../../../state/shared';
 import {
     ContextMenuDefinition,
     ContextMenuDefinitionProvider,
@@ -287,25 +289,31 @@ export class CanvasContextMenu implements 
ContextMenuDefinitionProvider {
         id: 'upstream-downstream',
         menuItems: [
             {
-                condition: () => {
-                    // TODO - hasUpstream
-                    return false;
+                condition: (selection: d3.Selection<any, any, any, any>) => {
+                    // an empty selection targets the current process group, 
whose connections are defined in its
+                    // parent, so there is nothing to report from the root 
process group since it has no parent
+                    return (
+                        this.canvasUtils.hasUpstream(selection) ||
+                        
this.canvasUtils.isNotRootGroupAndEmptySelection(selection)
+                    );
                 },
-                clazz: 'icon',
+                clazz: 'fa fa-long-arrow-up fa-rotate-45',
                 text: 'Upstream',
-                action: () => {
-                    // TODO - showUpstream
+                action: (selection: d3.Selection<any, any, any, any>) => {
+                    this.requestComponentConnections(selection, 'upstream');
                 }
             },
             {
-                condition: () => {
-                    // TODO - hasDownstream
-                    return false;
+                condition: (selection: d3.Selection<any, any, any, any>) => {
+                    return (
+                        this.canvasUtils.hasDownstream(selection) ||
+                        
this.canvasUtils.isNotRootGroupAndEmptySelection(selection)
+                    );
                 },
-                clazz: 'icon',
+                clazz: 'fa fa-long-arrow-down fa-rotate-45',
                 text: 'Downstream',
-                action: () => {
-                    // TODO - showDownstream
+                action: (selection: d3.Selection<any, any, any, any>) => {
+                    this.requestComponentConnections(selection, 'downstream');
                 }
             }
         ]
@@ -1465,4 +1473,70 @@ 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 component whose connections cross 
that group's own boundary and
+     * are defined one level up: the upstream side of an Input Port, the 
downstream side of an Output
+     * Port, and either side of the current group itself. Those are the cases 
that
+     * hasUpstream/hasDownstream gate on the presence of a parent group.
+     *
+     * An empty selection means the user did not select a component, which 
implicitly targets the current
+     * group.
+     */
+    private requestComponentConnections(
+        selection: d3.Selection<any, any, any, any>,
+        direction: ConnectionDirection
+    ): void {
+        const currentProcessGroupTargeted: boolean = 
this.canvasUtils.emptySelection(selection);
+
+        const crossesParentBoundary: boolean =
+            currentProcessGroupTargeted ||
+            (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 = currentProcessGroupTargeted ? 
this.currentProcessGroupDatum() : 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
+                }
+            })
+        );
+    }
+
+    /**
+     * Returns the current group shaped like the datum of a component rendered 
on the canvas, so that it
+     * can be reported the same way a selected component is. The current group 
draws the canvas itself
+     * rather than a node on it, so there is no selection to read this from.
+     */
+    private currentProcessGroupDatum(): any {
+        const breadcrumb: BreadcrumbEntity | null = 
this.canvasUtils.getCurrentProcessGroupBreadcrumb();
+
+        return {
+            id: this.canvasUtils.getProcessGroupId(),
+            type: ComponentType.ProcessGroup,
+            permissions: breadcrumb ? breadcrumb.permissions : { canRead: 
false, canWrite: false },
+            component: { name: breadcrumb?.breadcrumb.name }
+        };
+    }
 }
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 4c48a436d42..25826b44da4 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/service/canvas-utils.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
index 8da73babd8e..a7bb912d656 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
@@ -255,6 +255,14 @@ export class CanvasUtils {
         return this.parentProcessGroupId;
     }
 
+    /**
+     * Returns the breadcrumb of the current group, which carries its name and 
permissions. The current
+     * group is not rendered on its own canvas, so this is the only source for 
those details.
+     */
+    public getCurrentProcessGroupBreadcrumb(): BreadcrumbEntity | null {
+        return this.breadcrumbs;
+    }
+
     /**
      * Returns the current parameter context id or null if there is no bound 
parameter context.
      */
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.spec.ts
new file mode 100644
index 00000000000..a99d2e8dc11
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.spec.ts
@@ -0,0 +1,170 @@
+/*
+ * 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 { buildComponentIdToNameMap, collectEndpointGroupIds } from 
'./component-connections.utils';
+import { ComponentEntity, ConnectionEntity, ProcessGroupFlowEntity } from 
'./index';
+
+const DEFINING_GROUP_ID = 'defining-group-id';
+const CHILD_GROUP_ID = 'child-group-id';
+const OTHER_CHILD_GROUP_ID = 'other-child-group-id';
+const REMOTE_GROUP_ID = 'remote-process-group-id';
+
+interface EndpointOptions {
+    sourceGroupId?: string;
+    sourceType?: string;
+    destinationGroupId?: string;
+    destinationType?: string;
+}
+
+function connection(options: EndpointOptions = {}): ConnectionEntity {
+    return {
+        id: 'connection-id',
+        permissions: { canRead: false, canWrite: false },
+        position: { x: 0, y: 0 },
+        revision: { version: 0 },
+        sourceId: 'source-id',
+        sourceGroupId: options.sourceGroupId ?? DEFINING_GROUP_ID,
+        sourceType: options.sourceType ?? 'PROCESSOR',
+        destinationId: 'destination-id',
+        destinationGroupId: options.destinationGroupId ?? DEFINING_GROUP_ID,
+        destinationType: options.destinationType ?? 'PROCESSOR',
+        component: null
+    };
+}
+
+function component(id: string, name: string | undefined, canRead: boolean): 
ComponentEntity {
+    return {
+        id,
+        permissions: { canRead, canWrite: false },
+        position: { x: 0, y: 0 },
+        revision: { version: 0 },
+        component: { id, name }
+    };
+}
+
+function flowEntity(flow: 
Partial<ProcessGroupFlowEntity['processGroupFlow']['flow']>): 
ProcessGroupFlowEntity {
+    return {
+        permissions: { canRead: true, canWrite: true },
+        processGroupFlow: {
+            id: DEFINING_GROUP_ID,
+            uri: '',
+            parentGroupId: null,
+            breadcrumb: {
+                id: DEFINING_GROUP_ID,
+                permissions: { canRead: true, canWrite: true },
+                versionedFlowState: '',
+                breadcrumb: { id: DEFINING_GROUP_ID, name: 'Defining Process 
Group' }
+            },
+            flow: {
+                processGroups: [],
+                remoteProcessGroups: [],
+                processors: [],
+                inputPorts: [],
+                outputPorts: [],
+                connections: [],
+                labels: [],
+                funnels: [],
+                ...flow
+            }
+        }
+    } as unknown as ProcessGroupFlowEntity;
+}
+
+describe('collectEndpointGroupIds', () => {
+    it('collects nothing when both ends are in the group that defines the 
connections', () => {
+        expect(collectEndpointGroupIds([connection()], 
DEFINING_GROUP_ID)).toEqual([]);
+    });
+
+    it('collects the group behind a port an end reaches into', () => {
+        const connections = [
+            connection({ destinationGroupId: CHILD_GROUP_ID, destinationType: 
'INPUT_PORT' }),
+            connection({ sourceGroupId: OTHER_CHILD_GROUP_ID, sourceType: 
'OUTPUT_PORT' })
+        ];
+
+        expect(collectEndpointGroupIds(connections, 
DEFINING_GROUP_ID)).toEqual([CHILD_GROUP_ID, OTHER_CHILD_GROUP_ID]);
+    });
+
+    it('collects a group reached by several connections once', () => {
+        const connections = [
+            connection({ destinationGroupId: CHILD_GROUP_ID, destinationType: 
'INPUT_PORT' }),
+            connection({ destinationGroupId: CHILD_GROUP_ID, destinationType: 
'INPUT_PORT' }),
+            connection({ sourceGroupId: CHILD_GROUP_ID, sourceType: 
'OUTPUT_PORT' })
+        ];
+
+        expect(collectEndpointGroupIds(connections, 
DEFINING_GROUP_ID)).toEqual([CHILD_GROUP_ID]);
+    });
+
+    it('leaves out a Remote Process Group, which has no flow of its own to 
load', () => {
+        const connections = [
+            connection({ sourceGroupId: REMOTE_GROUP_ID, sourceType: 
'REMOTE_OUTPUT_PORT' }),
+            connection({ destinationGroupId: REMOTE_GROUP_ID, destinationType: 
'REMOTE_INPUT_PORT' })
+        ];
+
+        expect(collectEndpointGroupIds(connections, 
DEFINING_GROUP_ID)).toEqual([]);
+    });
+});
+
+describe('buildComponentIdToNameMap', () => {
+    it('names only the components the current user can read', () => {
+        const flow = flowEntity({
+            processors: [
+                component('readable-processor-id', 'Readable Processor', true),
+                component('hidden-processor-id', 'Hidden Processor', false)
+            ]
+        });
+
+        const idToName = buildComponentIdToNameMap([flow]);
+
+        expect(idToName.get('readable-processor-id')).toBe('Readable 
Processor');
+        expect(idToName.has('hidden-processor-id')).toBeFalsy();
+    });
+
+    it('names components of every group it is given, which is what separates 
the two ends', () => {
+        const definingFlow = flowEntity({
+            processors: [component('hidden-processor-id', 'Hidden Processor', 
false)]
+        });
+        const childFlow = flowEntity({
+            inputPorts: [component('input-port-id', 'Input Port A', true)]
+        });
+
+        const idToName = buildComponentIdToNameMap([definingFlow, childFlow]);
+
+        expect(idToName.has('hidden-processor-id')).toBeFalsy();
+        expect(idToName.get('input-port-id')).toBe('Input Port A');
+    });
+
+    it('names every kind of component a connection can be attached to', () => {
+        const flow = flowEntity({
+            processors: [component('processor-id', 'Processor', true)],
+            inputPorts: [component('input-port-id', 'Input Port', true)],
+            outputPorts: [component('output-port-id', 'Output Port', true)],
+            funnels: [component('funnel-id', undefined, true)]
+        });
+
+        const idToName = buildComponentIdToNameMap([flow]);
+
+        expect(idToName.get('processor-id')).toBe('Processor');
+        expect(idToName.get('input-port-id')).toBe('Input Port');
+        expect(idToName.get('output-port-id')).toBe('Output Port');
+        // a funnel has no name of its own, and is still reported as readable
+        expect(idToName.get('funnel-id')).toBe('');
+    });
+
+    it('builds an empty map when no flow could be loaded', () => {
+        expect(buildComponentIdToNameMap([]).size).toBe(0);
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.ts
new file mode 100644
index 00000000000..aa30186f629
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { ConnectionEntity, ProcessGroupFlowEntity } from './index';
+
+const REMOTE_PORT_TYPES: string[] = ['REMOTE_INPUT_PORT', 
'REMOTE_OUTPUT_PORT'];
+
+/**
+ * Collects the groups, other than the one that defines the given connections, 
that hold an end of one
+ * of them. Reporting a connection means reporting both of its ends, and a 
connection is readable only
+ * when the current user can read both, so each end has to be looked up in its 
own group to be named on
+ * its own permission.
+ *
+ * A Remote Process Group is left out: it is not a group whose flow can be 
loaded, and the ports inside
+ * it are reported through the Remote Process Group itself.
+ *
+ * @param connections the connections being reported
+ * @param definingGroupId the group that defines them, whose flow is already 
loaded
+ * @returns the id of each other group holding an end, without repeats
+ */
+export function collectEndpointGroupIds(connections: ConnectionEntity[], 
definingGroupId: string): string[] {
+    const groupIds = new Set<string>();
+
+    connections.forEach((connection) => {
+        if (connection.sourceGroupId !== definingGroupId && 
!REMOTE_PORT_TYPES.includes(connection.sourceType)) {
+            groupIds.add(connection.sourceGroupId);
+        }
+        if (
+            connection.destinationGroupId !== definingGroupId &&
+            !REMOTE_PORT_TYPES.includes(connection.destinationType)
+        ) {
+            groupIds.add(connection.destinationGroupId);
+        }
+    });
+
+    return [...groupIds];
+}
+
+/**
+ * Collects the name of every component the current user can read across the 
given flows. Each component
+ * is listed by its own group with its own permission, which is what lets one 
end of a connection be
+ * named while the other is reported as unauthorized.
+ *
+ * @param flowEntities the flow of each group holding an end of the 
connections being reported
+ * @returns the name of each readable component, by id
+ */
+export function buildComponentIdToNameMap(flowEntities: 
ProcessGroupFlowEntity[]): Map<string, string> {
+    const idToName = new Map<string, string>();
+
+    flowEntities.forEach((flowEntity) => {
+        const flow = flowEntity.processGroupFlow.flow;
+
+        [
+            ...(flow.processors ?? []),
+            ...(flow.inputPorts ?? []),
+            ...(flow.outputPorts ?? []),
+            ...(flow.funnels ?? [])
+        ].forEach((component) => {
+            if (component.permissions.canRead) {
+                idToName.set(component.id, component.component.name ?? '');
+            }
+        });
+    });
+
+    return idToName;
+}
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 441f5b5c5e8..ac76762e2be 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,
@@ -98,7 +99,8 @@ import {
     TerminateThreadsRequest,
     UpdatePositionsRequest,
     UploadProcessGroupRequest,
-    VersionControlInformationEntity
+    VersionControlInformationEntity,
+    ViewComponentConnectionsRequest
 } from './index';
 import { StatusHistoryRequest } from '../../../../state/status-history';
 import {
@@ -629,6 +631,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 5d9220f4699..bba9d67ab20 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
@@ -38,6 +38,7 @@ import {
     combineLatest,
     exhaustMap,
     filter,
+    forkJoin,
     from,
     interval,
     map,
@@ -53,6 +54,7 @@ import {
 } from 'rxjs';
 import {
     ComponentEntity,
+    ConnectionEntity,
     CreateConnectionDialogRequest,
     CreateProcessGroupDialogRequest,
     DeleteComponentResponse,
@@ -73,6 +75,7 @@ import {
     StopVersionControlResponse,
     VersionControlInformationEntity
 } from './index';
+import { buildComponentIdToNameMap, collectEndpointGroupIds } from 
'./component-connections.utils';
 import { Position } from '../shared';
 import { Action, Store } from '@ngrx/store';
 import {
@@ -101,6 +104,7 @@ import { CreatePort } from 
'../../ui/canvas/items/port/create-port/create-port.c
 import { EditPort } from 
'../../../../ui/common/component-dialogs/edit-port/edit-port.component';
 import {
     BranchEntity,
+    BreadcrumbEntity,
     BucketEntity,
     DisableComponentRequest,
     EnableComponentRequest,
@@ -162,6 +166,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';
@@ -3180,6 +3185,139 @@ 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.
+     *
+     *
+     * The group on the canvas is the one id the resolvers never collapse to, 
so when it is itself the
+     * requested component a connection into or out of it resolves to the port 
it is attached to instead.
+     * Matching the endpoint group id on the connection covers that case; for 
every other component the
+     * requested id is not a group id at that endpoint, so it adds nothing.
+     *
+     * 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.
+     *
+     * The flow of every other group an end of those connections lives in is 
loaded as well, since a
+     * connection is readable only when the current user can read both of its 
ends and carries no name
+     * for either one otherwise. Those flows are what report each end with its 
own permission.
+     */
+    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 
||
+                          connection.destinationGroupId === request.id
+                        : 
this.canvasUtils.getConnectionSourceComponentId(connection) === request.id ||
+                          connection.sourceGroupId === request.id;
+
+                return from(this.flowService.getFlow(request.groupId)).pipe(
+                    switchMap((flowEntity: ProcessGroupFlowEntity) => {
+                        const connections = 
flowEntity.processGroupFlow.flow.connections.filter(attachedTo);
+
+                        return this.loadEndpointGroupFlows(connections, 
request.groupId).pipe(
+                            map((endpointFlows: ProcessGroupFlowEntity[]) =>
+                                FlowActions.openComponentConnectionsDialog({
+                                    request: {
+                                        componentId: request.id,
+                                        componentName: request.name,
+                                        componentType: request.type,
+                                        groupId: request.groupId,
+                                        direction: request.direction,
+                                        connections,
+                                        groupIdToName: 
this.buildProcessGroupIdToNameMap(flowEntity),
+                                        componentIdToName: 
buildComponentIdToNameMap([flowEntity, ...endpointFlows])
+                                    }
+                                })
+                            )
+                        );
+                    }),
+                    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 }
+    );
+
+    private buildProcessGroupIdToNameMap(flowEntity: ProcessGroupFlowEntity): 
Map<string, string> {
+        const idToName = new Map<string, string>();
+        const processGroupFlow = flowEntity.processGroupFlow;
+
+        let breadcrumbEntity: BreadcrumbEntity | undefined = 
processGroupFlow.breadcrumb;
+        while (breadcrumbEntity) {
+            if (breadcrumbEntity.permissions.canRead) {
+                idToName.set(breadcrumbEntity.id, 
breadcrumbEntity.breadcrumb.name);
+            }
+            breadcrumbEntity = breadcrumbEntity.parentBreadcrumb;
+        }
+
+        [...(processGroupFlow.flow.processGroups ?? []), 
...(processGroupFlow.flow.remoteProcessGroups ?? [])].forEach(
+            (group) => {
+                if (group.permissions.canRead) {
+                    idToName.set(group.id, group.component.name);
+                }
+            }
+        );
+
+        return idToName;
+    }
+
+    /**
+     * Loads the flow of every other group holding an end of the given 
connections, so that each end can
+     * be reported on its own read permission. A group the current user cannot 
read answers with an
+     * error, which leaves that end reported as unauthorized rather than 
failing the dialog.
+     *
+     * @param connections the connections being reported
+     * @param definingGroupId the group that defines them, whose flow has 
already been loaded
+     * @returns the flow of each of the other groups the current user can read
+     */
+    private loadEndpointGroupFlows(
+        connections: ConnectionEntity[],
+        definingGroupId: string
+    ): Observable<ProcessGroupFlowEntity[]> {
+        const groupIds = collectEndpointGroupIds(connections, definingGroupId);
+
+        if (groupIds.length === 0) {
+            return of([]);
+        }
+
+        return forkJoin(
+            groupIds.map((groupId) =>
+                from(this.flowService.getFlow(groupId)).pipe(
+                    map((flowEntity) => flowEntity as ProcessGroupFlowEntity | 
null),
+                    catchError(() => of(null))
+                )
+            )
+        ).pipe(map((flows) => flows.filter((flowEntity) => flowEntity !== 
null)));
+    }
+
     showOkDialog$ = createEffect(
         () =>
             this.actions$.pipe(
@@ -4945,7 +5083,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 6e6f602ef42..d68881828e0 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
@@ -717,23 +717,23 @@ function getComponentCollection(draftState: FlowState, 
componentType: ComponentT
     return collection;
 }
 
-function processComponentCollection(
-    proposedComponents: ComponentEntity[],
-    currentComponents: ComponentEntity[],
+function processComponentCollection<T extends ComponentEntity>(
+    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/index.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
index 1b7b830ec22..80f4cf79f38 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,38 @@ 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 {
+    componentId: string;
+    componentName: string;
+    componentType: ComponentType;
+    // the group the connections belong to, used when navigating to one of them
+    groupId: string;
+    direction: ConnectionDirection;
+    connections: ConnectionEntity[];
+    // names resolved from the same flow entity that supplied the connections
+    groupIdToName: Map<string, string>;
+    // names of the components the current user can read, across every group 
these connections reach
+    // into. a connection is readable only when both of its ends are, so this 
is what lets each end be
+    // reported on its own permission rather than through the connection that 
joins them
+    componentIdToName: Map<string, string>;
+}
+
 /*
     Snippets
  */
@@ -454,6 +486,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 +512,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 00000000000..4bbc41201de
--- /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,146 @@
+<!--
+  ~ 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.
+  -->
+
+<h2 mat-dialog-title>{{ title }}</h2>
+<mat-dialog-content>
+    <div class="dialog-content flex flex-col h-full gap-y-2">
+        <component-context
+            [type]="componentType"
+            [name]="componentName"
+            [id]="componentId"></component-context>
+        @if (rows.length === 0) {
+            <div class="unset">{{ emptyMessage }}</div>
+        } @else {
+            <div class="listing-table component-connections-table flex-1 
relative">
+                <div class="absolute inset-0 overflow-y-auto 
overflow-x-hidden">
+                    <table
+                        mat-table
+                        [dataSource]="dataSource"
+                        matSort
+                        matSortDisableClear
+                        (matSortChange)="sortData($event)"
+                        [matSortActive]="initialSortColumn"
+                        [matSortDirection]="initialSortDirection">
+                        <ng-container matColumnDef="sourceProcessGroup">
+                            <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Source <br>Process Group</th>
+                            <td mat-cell *matCellDef="let row">
+                            @if (isNavigableProcessGroup(row.source.groupId)) {
+                                <a
+                                    class="component-connection-cell 
neutral-contrast"
+                                    
[matTooltip]="resolveGroupName(row.source.groupId)"
+                                    
(click)="navigateToProcessGroup(row.source)"><i
+                                    class="icon component-type-icon flex-none"
+                                    
[class]="componentIcon(processGroupTypeOf(row.source))">
+                                    </i>{{ 
resolveGroupName(row.source.groupId) }}
+                                </a>
+                            } @else {
+                                <span class="component-connection-cell 
neutral-contrast" [matTooltip]="resolveGroupName(row.source.groupId)">
+                                    <i class="icon component-type-icon" 
[class]="componentIcon(processGroupTypeOf(row.source))">
+                                    </i>{{ 
resolveGroupName(row.source.groupId) }}
+                                </span>
+                            }
+                            </td>
+                        </ng-container>
+
+                        <ng-container matColumnDef="sourceComponent">
+                            <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Source <br>Component</th>
+                            <td mat-cell *matCellDef="let row">
+                            @if (isRemoteProcessGroupPort(row.source)) {
+                                <span class="component-connection-cell 
neutral-contrast" [matTooltip]="componentTooltip(row.source)">
+                                    <i class="icon component-type-icon" 
[class]="componentIcon(row.source.type)">
+                                    </i>{{ formatComponentName(row.source) }}
+                                </span>
+                            } @else {
+                                <a
+                                    class="component-connection-cell 
neutral-contrast"
+                                    [matTooltip]="componentTooltip(row.source)"
+                                    (click)="navigateTo(row.source.id, 
row.source.groupId, row.source.type)"><i
+                                    class="icon component-type-icon" 
[class]="componentIcon(row.source.type)">
+                                    </i>{{ formatComponentName(row.source) }}
+                                </a>
+                            }
+                            </td>
+                        </ng-container>
+
+                        <ng-container matColumnDef="connection">
+                            <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Connection</th>
+                            <td mat-cell *matCellDef="let row">
+                                <a
+                                    class="component-connection-cell 
neutral-contrast"
+                                    [matTooltip]="row.name"
+                                    (click)="navigateTo(row.id, 
dialogRequestGroupId, connectionType)">
+                                    <i class="icon component-type-icon" 
[class]="componentIcon(connectionType)"></i>
+                                    @if (row.name === null) {
+                                        <span class="unset 
neutral-contrast">{{ formatConnectionName(row) }}</span>
+                                    } @else {
+                                        {{ row.name }}
+                                    }
+                                </a>
+                            </td>
+                        </ng-container>
+
+                        <ng-container matColumnDef="destinationProcessGroup">
+                            <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Destination <br>Process Group</th>
+                            <td mat-cell *matCellDef="let row">
+                            @if 
(isNavigableProcessGroup(row.destination.groupId)) {
+                                <a
+                                    class="component-connection-cell 
neutral-contrast"
+                                    
[matTooltip]="resolveGroupName(row.destination.groupId)"
+                                    
(click)="navigateToProcessGroup(row.destination)"><i
+                                    class="icon component-type-icon flex-none" 
[class]="componentIcon(processGroupTypeOf(row.destination))">
+                                    </i>{{ 
resolveGroupName(row.destination.groupId) }}
+                                </a>
+                            } @else {
+                                <span class="component-connection-cell 
neutral-contrast" [matTooltip]="resolveGroupName(row.destination.groupId)">
+                                    <i class="icon component-type-icon 
flex-none" [class]="componentIcon(processGroupTypeOf(row.destination))">
+                                    </i>{{ 
resolveGroupName(row.destination.groupId) }}
+                                </span>
+                            }
+                            </td>
+                        </ng-container>
+
+                        <ng-container matColumnDef="destinationComponent">
+                            <th mat-header-cell *matHeaderCellDef 
mat-sort-header>Destination <br>Component</th>
+                            <td mat-cell *matCellDef="let row">
+                            @if (isRemoteProcessGroupPort(row.destination)) {
+                                <span class="component-connection-cell 
neutral-contrast" [matTooltip]="componentTooltip(row.destination)">
+                                    <i class="icon component-type-icon" 
[class]="componentIcon(row.destination.type)">
+                                    </i>{{ 
formatComponentName(row.destination) }}
+                                </span>
+                            } @else {
+                                <a
+                                    class="component-connection-cell 
neutral-contrast"
+                                    
[matTooltip]="componentTooltip(row.destination)"
+                                    (click)="navigateTo(row.destination.id, 
row.destination.groupId, row.destination.type)"><i
+                                    class="icon component-type-icon" 
[class]="componentIcon(row.destination.type)">
+                                    </i>{{ 
formatComponentName(row.destination) }}
+                                </a>
+                            }
+                            </td>
+                        </ng-container>
+
+                        <tr mat-header-row *matHeaderRowDef="displayedColumns; 
sticky: true"></tr>
+                        <tr mat-row *matRowDef="let row; let even = even; 
columns: displayedColumns" [class.even]="even"></tr>
+                    </table>
+                </div>
+            </div>
+        }
+    </div>
+</mat-dialog-content>
+<mat-dialog-actions align="end">
+    <button type="button" mat-button mat-dialog-close 
cdkFocusInitial>OK</button>
+</mat-dialog-actions>
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 00000000000..d120342b362
--- /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,49 @@
+/*
+ * 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%;
+    }
+
+    .mat-column-sourceProcessGroup,
+    .mat-column-sourceComponent,
+    .mat-column-connection,
+    .mat-column-destinationProcessGroup,
+    .mat-column-destinationComponent {
+        max-width: 0;
+    }
+
+    .component-connection-cell {
+        display: block;
+        width: 100%;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+    }
+
+    .component-type-icon {
+        display: inline-block;
+        font-size: 1.15em;
+        line-height: 1;
+        vertical-align: baseline;
+        margin-right: 0.25rem;
+    }
+}
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 00000000000..e73dc720ae3
--- /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,1430 @@
+/*
+ * 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 { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { By } from '@angular/platform-browser';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { of } from 'rxjs';
+import { ComponentType } from '@nifi/shared';
+
+import { ComponentConnectionsDialog, ComponentConnectionRow } from 
'./component-connections-dialog.component';
+import { ComponentConnectionsDialogRequest, ConnectionDirection, 
ConnectionEntity } from '../../../state/flow';
+import { enterProcessGroup, navigateToComponent } from 
'../../../state/flow/flow.actions';
+import { CanvasUtils } from '../../../service/canvas-utils.service';
+
+const REQUEST_GROUP_ID = 'request-group-id';
+const SOURCE_GROUP_ID = 'source-group-id';
+const DESTINATION_GROUP_ID = 'destination-group-id';
+const UNKNOWN_GROUP_ID = 'unknown-group-id';
+const REMOTE_GROUP_ID = 'remote-process-group-id';
+
+// when the user selects nothing on the canvas the current group itself is 
reported, and its connections
+// are defined in the parent group, so the parent is the group the dialog 
request is built around
+const PARENT_GROUP_ID = 'parent-group-id';
+const CURRENT_GROUP_ID = 'current-group-id';
+const SIBLING_GROUP_ID = 'sibling-group-id';
+
+const SELECTED_COMPONENT_ID = 'selected-component-id';
+const SOURCE_ID = 'source-id';
+const DESTINATION_ID = 'destination-id';
+const CONNECTION_ID = 'connection-id';
+
+interface ConnectableStub {
+    id: string;
+    name: string;
+}
+
+interface ConnectionOptions {
+    id?: string;
+    source?: ConnectableStub;
+    destination?: ConnectableStub;
+    sourceGroupId?: string;
+    destinationGroupId?: string;
+    sourceType?: string;
+    destinationType?: string;
+    canRead?: boolean;
+    name?: string;
+    selectedRelationships?: string[];
+    component?: any | null;
+}
+
+interface CreatedDialog {
+    component: ComponentConnectionsDialog;
+    fixture: ComponentFixture<ComponentConnectionsDialog>;
+    store: MockStore;
+    dialogRef: {
+        close: ReturnType<typeof vi.fn>;
+        keydownEvents: () => ReturnType<typeof of>;
+    };
+}
+
+function readableConnection(options: ConnectionOptions = {}): ConnectionEntity 
{
+    const source = options.source ?? { id: SOURCE_ID, name: 'GenerateFlowFile' 
};
+    const destination = options.destination ?? { id: DESTINATION_ID, name: 
'LogAttribute' };
+
+    return {
+        id: options.id ?? CONNECTION_ID,
+        permissions: { canRead: options.canRead ?? true, canWrite: true },
+        position: { x: 0, y: 0 },
+        revision: { version: 0 },
+        sourceId: source.id,
+        sourceGroupId: options.sourceGroupId ?? SOURCE_GROUP_ID,
+        sourceType: options.sourceType ?? 'PROCESSOR',
+        destinationId: destination.id,
+        destinationGroupId: options.destinationGroupId ?? DESTINATION_GROUP_ID,
+        destinationType: options.destinationType ?? 'INPUT_PORT',
+        component:
+            options.component === undefined
+                ? {
+                      id: options.id ?? CONNECTION_ID,
+                      source,
+                      destination,
+                      name: options.name,
+                      selectedRelationships: options.selectedRelationships
+                  }
+                : options.component
+    };
+}
+
+function unreadableConnection(options: ConnectionOptions = {}): 
ConnectionEntity {
+    return {
+        id: options.id ?? CONNECTION_ID,
+        permissions: { canRead: false, canWrite: false },
+        position: { x: 0, y: 0 },
+        revision: { version: 0 },
+        sourceId: options.source?.id ?? SOURCE_ID,
+        sourceGroupId: options.sourceGroupId ?? SOURCE_GROUP_ID,
+        sourceType: options.sourceType ?? 'PROCESSOR',
+        destinationId: options.destination?.id ?? DESTINATION_ID,
+        destinationGroupId: options.destinationGroupId ?? DESTINATION_GROUP_ID,
+        destinationType: options.destinationType ?? 'INPUT_PORT',
+        component: null
+    };
+}
+
+/**
+ * Builds the dialog. The group on the canvas defaults to the group the 
connections belong to, which is
+ * where every component except a port searched across its own group's 
boundary is reported from.
+ */
+function createDialog(
+    direction: ConnectionDirection,
+    connections: ConnectionEntity[],
+    overrides: Partial<ComponentConnectionsDialogRequest> = {},
+    canvasGroupId?: string
+): CreatedDialog {
+    const dialogRequest: ComponentConnectionsDialogRequest = {
+        componentId: SELECTED_COMPONENT_ID,
+        componentName: 'Selected Component',
+        componentType: ComponentType.InputPort,
+        groupId: REQUEST_GROUP_ID,
+        direction,
+        connections,
+        groupIdToName: new Map([
+            [REQUEST_GROUP_ID, 'Current Process Group'],
+            [SOURCE_GROUP_ID, 'Source Process Group'],
+            [DESTINATION_GROUP_ID, 'Destination Process Group']
+        ]),
+        componentIdToName: new Map(),
+        ...overrides
+    };
+
+    const dialogRef = {
+        close: vi.fn(),
+        keydownEvents: () => of()
+    };
+
+    const canvasUtils = {
+        formatConnectionName: (component: any): string => {
+            if (component?.name) {
+                return component.name;
+            }
+            if (component?.selectedRelationships) {
+                return component.selectedRelationships.join(', ');
+            }
+            return '';
+        },
+        getProcessGroupId: (): string => canvasGroupId ?? dialogRequest.groupId
+    };
+
+    TestBed.resetTestingModule();
+    TestBed.configureTestingModule({
+        imports: [ComponentConnectionsDialog, NoopAnimationsModule],
+        providers: [
+            { provide: MAT_DIALOG_DATA, useValue: dialogRequest },
+            { provide: MatDialogRef, useValue: dialogRef },
+            { provide: CanvasUtils, useValue: canvasUtils },
+            provideMockStore({})
+        ]
+    });
+
+    const fixture = TestBed.createComponent(ComponentConnectionsDialog);
+    fixture.detectChanges();
+
+    return {
+        component: fixture.componentInstance,
+        fixture,
+        store: TestBed.inject(MockStore),
+        dialogRef
+    };
+}
+
+/**
+ * Builds the dialog as it is opened when the user selects nothing on the 
canvas: the current group is
+ * the reported component, and the group of the request is its parent, where 
its connections are defined.
+ */
+function createCurrentProcessGroupDialog(
+    direction: ConnectionDirection,
+    connections: ConnectionEntity[],
+    overrides: Partial<ComponentConnectionsDialogRequest> = {}
+): CreatedDialog {
+    return createDialog(
+        direction,
+        connections,
+        {
+            componentId: CURRENT_GROUP_ID,
+            componentName: 'Current Process Group',
+            componentType: ComponentType.ProcessGroup,
+            groupId: PARENT_GROUP_ID,
+            groupIdToName: new Map([
+                [PARENT_GROUP_ID, 'Parent Process Group'],
+                [CURRENT_GROUP_ID, 'Current Process Group'],
+                [SIBLING_GROUP_ID, 'Sibling Process Group']
+            ]),
+            ...overrides
+        },
+        CURRENT_GROUP_ID
+    );
+}
+
+function textContent(fixture: ComponentFixture<ComponentConnectionsDialog>): 
string {
+    return (fixture.nativeElement.textContent as string).replace(/\s+/g, ' 
').trim();
+}
+
+function getCells(fixture: ComponentFixture<ComponentConnectionsDialog>, 
columnClass: string): HTMLElement[] {
+    return 
fixture.debugElement.queryAll(By.css(`td.${columnClass}`)).map((debugElement) 
=> debugElement.nativeElement);
+}
+
+function clickCell(fixture: ComponentFixture<ComponentConnectionsDialog>, 
columnClass: string): void {
+    const link = getCells(fixture, columnClass)[0].querySelector('a') as 
HTMLAnchorElement;
+    link.click();
+}
+
+function clickHeader(fixture: ComponentFixture<ComponentConnectionsDialog>, 
columnClass: string): void {
+    const header = 
fixture.debugElement.query(By.css(`th.${columnClass}`)).nativeElement as 
HTMLElement;
+    const sortButton = header.querySelector('button');
+    (sortButton ?? header).click();
+    fixture.detectChanges();
+}
+
+function renderedIds(component: ComponentConnectionsDialog): string[] {
+    return component.dataSource.data.map((row) => row.id);
+}
+
+/**
+ * Rebuilds the route the navigateToComponent effect pushes for a dispatched 
navigation, so that a test
+ * can assert on the url the click produces rather than only on the component 
type carried by the action.
+ */
+function navigationUrl(dispatch: ReturnType<typeof vi.spyOn>): string {
+    const { request } = dispatch.mock.calls[0][0] as ReturnType<typeof 
navigateToComponent>;
+    return ['/process-groups', request.processGroupId, request.type, 
request.id].join('/');
+}
+
+describe('ComponentConnectionsDialog', () => {
+    it('creates the dialog', () => {
+        const { component } = createDialog('upstream', []);
+
+        expect(component).toBeTruthy();
+    });
+
+    describe('dialog metadata', () => {
+        it('sets upstream title and empty message', () => {
+            const { component, fixture } = createDialog('upstream', []);
+
+            expect(component.title).toBe('Upstream Connections');
+            expect(component.emptyMessage).toBe('No upstream connections were 
found.');
+            expect(textContent(fixture)).toContain('Upstream Connections');
+            expect(textContent(fixture)).toContain('No upstream connections 
were found.');
+        });
+
+        it('sets downstream title and empty message', () => {
+            const { component, fixture } = createDialog('downstream', []);
+
+            expect(component.title).toBe('Downstream Connections');
+            expect(component.emptyMessage).toBe('No downstream connections 
were found.');
+            expect(textContent(fixture)).toContain('Downstream Connections');
+            expect(textContent(fixture)).toContain('No downstream connections 
were found.');
+        });
+
+        it('reports the selected component through the shared component 
context', () => {
+            const { fixture } = createDialog('upstream', [], {
+                componentId: 'input-port-a-id',
+                componentName: 'Input Port A',
+                componentType: ComponentType.InputPort
+            });
+
+            const componentContext = 
fixture.debugElement.query(By.css('component-context'));
+            expect(componentContext).not.toBeNull();
+
+            const contextText = (componentContext.nativeElement.textContent as 
string).replace(/\s+/g, ' ').trim();
+            expect(contextText).toContain('Input Port A');
+            // the type label and copyable id the widget renders on top of the 
name
+            expect(contextText).toContain('Input Port');
+            expect(contextText).toContain('input-port-a-id');
+            
expect(componentContext.query(By.css('.icon-port-in'))).not.toBeNull();
+        });
+
+        it('reports an unreadable component by the id used in place of its 
name', () => {
+            const { fixture } = createDialog('upstream', [], {
+                componentId: 'unreadable-component-id',
+                componentName: 'unreadable-component-id',
+                componentType: ComponentType.Processor
+            });
+
+            const componentContext = 
fixture.debugElement.query(By.css('component-context'));
+            const contextText = (componentContext.nativeElement.textContent as 
string).replace(/\s+/g, ' ').trim();
+
+            expect(contextText).toContain('unreadable-component-id');
+            expect(contextText).toContain('Processor');
+        });
+
+        it('reports a remote process group with the remote group icon', () => {
+            const { fixture } = createDialog('downstream', [], {
+                componentId: REMOTE_GROUP_ID,
+                componentName: 'Remote Process Group A',
+                componentType: ComponentType.RemoteProcessGroup
+            });
+
+            const componentContext = 
fixture.debugElement.query(By.css('component-context'));
+
+            
expect(componentContext.query(By.css('.icon-group-remote'))).not.toBeNull();
+            
expect(componentContext.nativeElement.textContent).toContain('Remote Process 
Group A');
+        });
+    });
+
+    describe('row construction', () => {
+        it('builds a row for a readable connection with a connection name', () 
=> {
+            const connection = readableConnection({
+                id: 'named-connection-id',
+                name: 'Named Connection',
+                source: { id: 'processor-id', name: 'GenerateFlowFile' },
+                destination: { id: 'input-port-id', name: 'Input Port' }
+            });
+
+            const { component } = createDialog('upstream', [connection]);
+
+            expect(component.rows).toEqual<ComponentConnectionRow[]>([
+                {
+                    id: 'named-connection-id',
+                    name: 'Named Connection',
+                    source: {
+                        id: 'processor-id',
+                        groupId: SOURCE_GROUP_ID,
+                        type: ComponentType.Processor,
+                        name: 'GenerateFlowFile'
+                    },
+                    destination: {
+                        id: 'input-port-id',
+                        groupId: DESTINATION_GROUP_ID,
+                        type: ComponentType.InputPort,
+                        name: 'Input Port'
+                    }
+                }
+            ]);
+        });
+
+        it('uses selected relationships as the connection name when no 
explicit connection name is present', () => {
+            const connection = readableConnection({
+                selectedRelationships: ['success', 'retry']
+            });
+
+            const { component, fixture } = createDialog('upstream', 
[connection]);
+
+            expect(component.rows[0].name).toBe('success, retry');
+            expect(textContent(fixture)).toContain('success, retry');
+        });
+
+        it('uses a null connection name when the formatted name is empty', () 
=> {
+            const connection = readableConnection();
+
+            const { component } = createDialog('upstream', [connection]);
+
+            expect(component.rows[0].name).toBeNull();
+        });
+
+        it('keeps unreadable connections using top-level endpoint identifiers 
and null endpoint names', () => {
+            const connection = unreadableConnection({
+                id: 'unreadable-connection-id',
+                source: { id: 'hidden-source-id', name: 'Hidden Source' },
+                destination: { id: 'hidden-destination-id', name: 'Hidden 
Destination' }
+            });
+
+            const { component, fixture } = createDialog('upstream', 
[connection]);
+
+            expect(component.rows).toEqual<ComponentConnectionRow[]>([
+                {
+                    id: 'unreadable-connection-id',
+                    name: null,
+                    source: {
+                        id: 'hidden-source-id',
+                        groupId: SOURCE_GROUP_ID,
+                        type: ComponentType.Processor,
+                        name: null
+                    },
+                    destination: {
+                        id: 'hidden-destination-id',
+                        groupId: DESTINATION_GROUP_ID,
+                        type: ComponentType.InputPort,
+                        name: null
+                    }
+                }
+            ]);
+
+            expect(textContent(fixture)).toContain('Unauthorized');
+        });
+
+        it('maps remote input and output port endpoint types to 
RemoteProcessGroup', () => {
+            const remoteInputConnection = readableConnection({
+                id: 'remote-input-connection-id',
+                sourceType: 'REMOTE_INPUT_PORT',
+                destinationType: 'REMOTE_OUTPUT_PORT'
+            });
+
+            const { component } = createDialog('downstream', 
[remoteInputConnection]);
+
+            
expect(component.rows[0].source.type).toBe(ComponentType.RemoteProcessGroup);
+            
expect(component.rows[0].destination.type).toBe(ComponentType.RemoteProcessGroup);
+        });
+
+        it('maps unknown endpoint types to Connector', () => {
+            const unknownTypeConnection = readableConnection({
+                sourceType: 'UNKNOWN_SOURCE_TYPE',
+                destinationType: 'UNKNOWN_DESTINATION_TYPE'
+            });
+
+            const { component } = createDialog('downstream', 
[unknownTypeConnection]);
+
+            
expect(component.rows[0].source.type).toBe(ComponentType.Connector);
+            
expect(component.rows[0].destination.type).toBe(ComponentType.Connector);
+        });
+    });
+
+    describe('rendering', () => {
+        it('renders the expected table columns', () => {
+            const { component, fixture } = createDialog('upstream', 
[readableConnection()]);
+
+            expect(component.displayedColumns).toEqual([
+                'sourceProcessGroup',
+                'sourceComponent',
+                'connection',
+                'destinationProcessGroup',
+                'destinationComponent'
+            ]);
+
+            const renderedText = textContent(fixture);
+            expect(renderedText).toContain('Source Process Group');
+            expect(renderedText).toContain('Source Component');
+            expect(renderedText).toContain('Connection');
+            expect(renderedText).toContain('Destination Process Group');
+            expect(renderedText).toContain('Destination Component');
+        });
+
+        it('renders process group names resolved from the request map', () => {
+            const { fixture } = createDialog('upstream', 
[readableConnection()]);
+
+            expect(textContent(fixture)).toContain('Source Process Group');
+            expect(textContent(fixture)).toContain('Destination Process 
Group');
+        });
+
+        it('renders unknown process group ids when no name is available', () 
=> {
+            const connection = readableConnection({
+                sourceGroupId: UNKNOWN_GROUP_ID,
+                destinationGroupId: UNKNOWN_GROUP_ID
+            });
+
+            const { fixture } = createDialog('upstream', [connection]);
+
+            expect(textContent(fixture)).toContain(UNKNOWN_GROUP_ID);
+        });
+
+        it('renders component names and the formatted connection name', () => {
+            const connection = readableConnection({
+                name: 'Connection Name',
+                source: { id: 'source-component-id', name: 'Source Component 
Name' },
+                destination: { id: 'destination-component-id', name: 
'Destination Component Name' }
+            });
+
+            const { fixture } = createDialog('upstream', [connection]);
+
+            const renderedText = textContent(fixture);
+            expect(renderedText).toContain('Source Component Name');
+            expect(renderedText).toContain('Connection Name');
+            expect(renderedText).toContain('Destination Component Name');
+        });
+
+        it('renders "Connection" for an unnamed connection', () => {
+            const { component, fixture } = createDialog('upstream', 
[readableConnection()]);
+
+            expect(component.rows[0].name).toBeNull();
+            expect(textContent(fixture)).toContain('Connection');
+        });
+
+        it('marks the header as sticky and applies striped row classes', () => 
{
+            const { fixture } = createDialog('upstream', [
+                readableConnection({ id: 'connection-1' }),
+                readableConnection({ id: 'connection-2' })
+            ]);
+
+            
expect(fixture.debugElement.query(By.css('tr.mat-mdc-header-row'))).not.toBeNull();
+
+            const rows = 
fixture.debugElement.queryAll(By.css('tr.mat-mdc-row'));
+            expect(rows.length).toBe(2);
+            
expect(rows[0].nativeElement.classList.contains('even')).toBeTruthy();
+            
expect(rows[1].nativeElement.classList.contains('even')).toBeFalsy();
+        });
+
+        it('renders table cells using component-connection-cell wrappers for 
truncation styling', () => {
+            const { fixture } = createDialog('upstream', [readableConnection({ 
name: 'Named Connection' })]);
+
+            
expect(fixture.debugElement.queryAll(By.css('.component-connection-cell')).length).toBeGreaterThan(0);
+        });
+    });
+
+    describe('process group name resolution', () => {
+        it('resolves process group names from the dialog request map', () => {
+            const { component } = createDialog('upstream', []);
+
+            expect(component.resolveGroupName(REQUEST_GROUP_ID)).toBe('Current 
Process Group');
+            expect(component.resolveGroupName(SOURCE_GROUP_ID)).toBe('Source 
Process Group');
+            
expect(component.resolveGroupName(DESTINATION_GROUP_ID)).toBe('Destination 
Process Group');
+        });
+
+        it('falls back to the group id when no process group name is 
available', () => {
+            const { component } = createDialog('upstream', []);
+
+            
expect(component.resolveGroupName(UNKNOWN_GROUP_ID)).toBe(UNKNOWN_GROUP_ID);
+        });
+
+        it('offers nowhere to go for the group that both defines the 
connections and is on the canvas', () => {
+            const { component } = createDialog('upstream', []);
+
+            
expect(component.isNavigableProcessGroup(REQUEST_GROUP_ID)).toBeFalsy();
+            
expect(component.isNavigableProcessGroup(SOURCE_GROUP_ID)).toBeTruthy();
+        });
+
+        it('offers the group that defines the connections when it is not the 
group on the canvas', () => {
+            // a port searched across its own group's boundary reports 
connections defined in the parent
+            const { component } = createDialog('upstream', [], {}, 
SOURCE_GROUP_ID);
+
+            
expect(component.isNavigableProcessGroup(REQUEST_GROUP_ID)).toBeTruthy();
+            
expect(component.isNavigableProcessGroup(SOURCE_GROUP_ID)).toBeTruthy();
+        });
+    });
+
+    describe('navigation', () => {
+        it('dispatches navigation and closes the dialog when navigateTo is 
called', () => {
+            const { component, store, dialogRef } = createDialog('upstream', 
[]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            component.navigateTo('target-id', 'target-group-id', 
ComponentType.Processor);
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: 'target-id',
+                        processGroupId: 'target-group-id',
+                        type: ComponentType.Processor
+                    }
+                })
+            );
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('renders the current source process group as non-clickable', () => {
+            const connection = readableConnection({
+                sourceGroupId: REQUEST_GROUP_ID,
+                destinationGroupId: DESTINATION_GROUP_ID
+            });
+
+            const { fixture } = createDialog('upstream', [connection]);
+
+            const sourceProcessGroupCell = getCells(fixture, 
'mat-column-sourceProcessGroup')[0];
+            
expect(sourceProcessGroupCell.querySelector('span')).not.toBeNull();
+            expect(sourceProcessGroupCell.querySelector('a')).toBeNull();
+        });
+
+        it('renders a non-current source process group as clickable and 
navigates to it', () => {
+            const connection = readableConnection({
+                sourceGroupId: SOURCE_GROUP_ID
+            });
+
+            const { fixture, store, dialogRef } = createDialog('upstream', 
[connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const sourceProcessGroupCell = getCells(fixture, 
'mat-column-sourceProcessGroup')[0];
+            const link = sourceProcessGroupCell.querySelector('a') as 
HTMLAnchorElement;
+            link.click();
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: SOURCE_GROUP_ID,
+                        processGroupId: REQUEST_GROUP_ID,
+                        type: ComponentType.ProcessGroup
+                    }
+                })
+            );
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('renders the current destination process group as non-clickable', 
() => {
+            const connection = readableConnection({
+                sourceGroupId: SOURCE_GROUP_ID,
+                destinationGroupId: REQUEST_GROUP_ID
+            });
+
+            const { fixture } = createDialog('upstream', [connection]);
+
+            const destinationProcessGroupCell = getCells(fixture, 
'mat-column-destinationProcessGroup')[0];
+            
expect(destinationProcessGroupCell.querySelector('span')).not.toBeNull();
+            expect(destinationProcessGroupCell.querySelector('a')).toBeNull();
+        });
+
+        it('navigates to the readable source component using the source 
component group id', () => {
+            const connection = readableConnection({
+                source: { id: 'source-component-id', name: 'Source Component' 
},
+                sourceGroupId: SOURCE_GROUP_ID,
+                sourceType: 'PROCESSOR'
+            });
+
+            const { fixture, store, dialogRef } = createDialog('upstream', 
[connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const sourceComponentCell = getCells(fixture, 
'mat-column-sourceComponent')[0];
+            const link = sourceComponentCell.querySelector('a') as 
HTMLAnchorElement;
+            link.click();
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: 'source-component-id',
+                        processGroupId: SOURCE_GROUP_ID,
+                        type: ComponentType.Processor
+                    }
+                })
+            );
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('navigates to the readable destination component using the 
destination component group id', () => {
+            const connection = readableConnection({
+                destination: { id: 'destination-component-id', name: 
'Destination Component' },
+                destinationGroupId: DESTINATION_GROUP_ID,
+                destinationType: 'OUTPUT_PORT'
+            });
+
+            const { fixture, store, dialogRef } = createDialog('downstream', 
[connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const destinationComponentCell = getCells(fixture, 
'mat-column-destinationComponent')[0];
+            const link = destinationComponentCell.querySelector('a') as 
HTMLAnchorElement;
+            link.click();
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: 'destination-component-id',
+                        processGroupId: DESTINATION_GROUP_ID,
+                        type: ComponentType.OutputPort
+                    }
+                })
+            );
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('keeps an unreadable component clickable so it can be reached like 
it can on the canvas', () => {
+            const { fixture, store, dialogRef } = createDialog('upstream', 
[unreadableConnection()]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const sourceComponentCell = getCells(fixture, 
'mat-column-sourceComponent')[0];
+            const destinationComponentCell = getCells(fixture, 
'mat-column-destinationComponent')[0];
+
+            expect(sourceComponentCell.textContent).toContain('Unauthorized');
+            
expect(destinationComponentCell.textContent).toContain('Unauthorized');
+            expect(sourceComponentCell.querySelector('a')).not.toBeNull();
+            expect(destinationComponentCell.querySelector('a')).not.toBeNull();
+
+            clickCell(fixture, 'mat-column-sourceComponent');
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: SOURCE_ID,
+                        processGroupId: SOURCE_GROUP_ID,
+                        type: ComponentType.Processor
+                    }
+                })
+            );
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('identifies an unreadable component by its id, since the 
placeholder identifies nothing', () => {
+            const { component } = createDialog('upstream', [
+                readableConnection({
+                    source: { id: 'readable-source-id', name: 'Readable 
Source' },
+                    destination: { id: 'hidden-destination-id', name: 'Hidden 
Destination' }
+                }),
+                unreadableConnection({ destination: { id: 
'hidden-destination-id', name: 'Hidden Destination' } })
+            ]);
+
+            
expect(component.componentTooltip(component.rows[0].source)).toBe('Readable 
Source');
+            
expect(component.componentTooltip(component.rows[1].destination)).toBe('hidden-destination-id');
+        });
+
+        it('renders a remote input port source component as non-clickable', () 
=> {
+            const connection = readableConnection({
+                source: { id: 'remote-input-port-id', name: 'Remote Input 
Port' },
+                sourceGroupId: 'remote-process-group-id',
+                sourceType: 'REMOTE_INPUT_PORT',
+                destination: { id: 'processor-id', name: 'Processor' },
+                destinationType: 'PROCESSOR'
+            });
+
+            const { fixture, store, dialogRef } = createDialog('downstream', 
[connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const sourceComponentCell = getCells(fixture, 
'mat-column-sourceComponent')[0];
+
+            expect(sourceComponentCell.textContent).toContain('Remote Input 
Port');
+            expect(sourceComponentCell.querySelector('span')).not.toBeNull();
+            expect(sourceComponentCell.querySelector('a')).toBeNull();
+            expect(dispatch).not.toHaveBeenCalled();
+            expect(dialogRef.close).not.toHaveBeenCalled();
+        });
+
+        it('renders a remote output port source component as non-clickable', 
() => {
+            const connection = readableConnection({
+                source: { id: 'remote-output-port-id', name: 'Remote Output 
Port' },
+                sourceGroupId: 'remote-process-group-id',
+                sourceType: 'REMOTE_OUTPUT_PORT',
+                destination: { id: 'processor-id', name: 'Processor' },
+                destinationType: 'PROCESSOR'
+            });
+
+            const { fixture, store, dialogRef } = createDialog('downstream', 
[connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const sourceComponentCell = getCells(fixture, 
'mat-column-sourceComponent')[0];
+
+            expect(sourceComponentCell.textContent).toContain('Remote Output 
Port');
+            expect(sourceComponentCell.querySelector('span')).not.toBeNull();
+            expect(sourceComponentCell.querySelector('a')).toBeNull();
+            expect(dispatch).not.toHaveBeenCalled();
+            expect(dialogRef.close).not.toHaveBeenCalled();
+        });
+
+        it('renders a remote input port destination component as 
non-clickable', () => {
+            const connection = readableConnection({
+                source: { id: 'processor-id', name: 'Processor' },
+                sourceType: 'PROCESSOR',
+                destination: { id: 'remote-input-port-id', name: 'Remote Input 
Port' },
+                destinationGroupId: 'remote-process-group-id',
+                destinationType: 'REMOTE_INPUT_PORT'
+            });
+
+            const { fixture, store, dialogRef } = createDialog('upstream', 
[connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const destinationComponentCell = getCells(fixture, 
'mat-column-destinationComponent')[0];
+
+            expect(destinationComponentCell.textContent).toContain('Remote 
Input Port');
+            
expect(destinationComponentCell.querySelector('span')).not.toBeNull();
+            expect(destinationComponentCell.querySelector('a')).toBeNull();
+            expect(dispatch).not.toHaveBeenCalled();
+            expect(dialogRef.close).not.toHaveBeenCalled();
+        });
+
+        it('renders a remote output port destination component as 
non-clickable', () => {
+            const connection = readableConnection({
+                source: { id: 'processor-id', name: 'Processor' },
+                sourceType: 'PROCESSOR',
+                destination: { id: 'remote-output-port-id', name: 'Remote 
Output Port' },
+                destinationGroupId: 'remote-process-group-id',
+                destinationType: 'REMOTE_OUTPUT_PORT'
+            });
+
+            const { fixture, store, dialogRef } = createDialog('upstream', 
[connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const destinationComponentCell = getCells(fixture, 
'mat-column-destinationComponent')[0];
+
+            expect(destinationComponentCell.textContent).toContain('Remote 
Output Port');
+            
expect(destinationComponentCell.querySelector('span')).not.toBeNull();
+            expect(destinationComponentCell.querySelector('a')).toBeNull();
+            expect(dispatch).not.toHaveBeenCalled();
+            expect(dialogRef.close).not.toHaveBeenCalled();
+        });
+
+        it('continues rendering standard input and output port components as 
clickable', () => {
+            const connection = readableConnection({
+                source: { id: 'output-port-id', name: 'Output Port' },
+                sourceGroupId: SOURCE_GROUP_ID,
+                sourceType: 'OUTPUT_PORT',
+                destination: { id: 'input-port-id', name: 'Input Port' },
+                destinationGroupId: DESTINATION_GROUP_ID,
+                destinationType: 'INPUT_PORT'
+            });
+
+            const { fixture } = createDialog('downstream', [connection]);
+
+            const sourceComponentCell = getCells(fixture, 
'mat-column-sourceComponent')[0];
+            const destinationComponentCell = getCells(fixture, 
'mat-column-destinationComponent')[0];
+
+            expect(sourceComponentCell.querySelector('a')).not.toBeNull();
+            expect(sourceComponentCell.querySelector('span')).toBeNull();
+            expect(destinationComponentCell.querySelector('a')).not.toBeNull();
+            expect(destinationComponentCell.querySelector('span')).toBeNull();
+        });
+
+        it('navigates to the connection in the group that defines the dialog 
request', () => {
+            const connection = readableConnection({
+                id: 'connection-to-navigate-to',
+                name: 'Connection To Navigate To'
+            });
+
+            const { fixture, store, dialogRef } = createDialog('upstream', 
[connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const connectionCell = getCells(fixture, 
'mat-column-connection')[0];
+            const link = connectionCell.querySelector('a') as 
HTMLAnchorElement;
+            link.click();
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: 'connection-to-navigate-to',
+                        processGroupId: REQUEST_GROUP_ID,
+                        type: ComponentType.Connection
+                    }
+                })
+            );
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+    });
+
+    /**
+     * A connection can be read only when the current user can read both of 
its ends, so the names it
+     * carries vanish as soon as either end is unreadable. Each end is 
therefore reported from what is
+     * known about that component on its own.
+     */
+    describe('per-endpoint authorization', () => {
+        const CHILD_GROUP_ID = 'child-group-id';
+        const SELECTED_PORT_NAME = 'Input Port A';
+
+        // an Input Port searched upstream: the connections are defined in the 
parent, the port is not
+        // among the components of the parent, and the source processor there 
cannot be read
+        function unreadableSourceIntoSelectedPort(): ConnectionEntity {
+            return unreadableConnection({
+                id: 'unreadable-connection-id',
+                source: { id: 'hidden-processor-id', name: 'Hidden Processor' 
},
+                sourceGroupId: REQUEST_GROUP_ID,
+                sourceType: 'PROCESSOR',
+                destination: { id: SELECTED_COMPONENT_ID, name: 
SELECTED_PORT_NAME },
+                destinationGroupId: CHILD_GROUP_ID,
+                destinationType: 'INPUT_PORT'
+            });
+        }
+
+        /**
+         * The components of the child group holding the port are listed 
alongside those of the parent
+         * group that defines the connections, each with its own read 
permission, which is how the port
+         * is named while the processor at the other end is not.
+         */
+        function createPortDialog(
+            connections: ConnectionEntity[],
+            readableComponents: [string, string][] = [[SELECTED_COMPONENT_ID, 
SELECTED_PORT_NAME]]
+        ): CreatedDialog {
+            return createDialog(
+                'upstream',
+                connections,
+                {
+                    componentId: SELECTED_COMPONENT_ID,
+                    componentName: SELECTED_PORT_NAME,
+                    componentType: ComponentType.InputPort,
+                    componentIdToName: new Map(readableComponents)
+                },
+                CHILD_GROUP_ID
+            );
+        }
+
+        it('names the readable end of a connection the user cannot read', () 
=> {
+            const { component, fixture } = 
createPortDialog([unreadableSourceIntoSelectedPort()]);
+
+            expect(component.rows[0].source.name).toBeNull();
+            
expect(component.rows[0].destination.name).toBe(SELECTED_PORT_NAME);
+
+            const sourceComponentCell = getCells(fixture, 
'mat-column-sourceComponent')[0];
+            const destinationComponentCell = getCells(fixture, 
'mat-column-destinationComponent')[0];
+            expect(sourceComponentCell.textContent).toContain('Unauthorized');
+            
expect(destinationComponentCell.textContent).toContain(SELECTED_PORT_NAME);
+            
expect(destinationComponentCell.textContent).not.toContain('Unauthorized');
+        });
+
+        it('navigates to the readable end of a connection the user cannot 
read', () => {
+            const { fixture, store, dialogRef } = 
createPortDialog([unreadableSourceIntoSelectedPort()]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            clickCell(fixture, 'mat-column-destinationComponent');
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: SELECTED_COMPONENT_ID,
+                        processGroupId: CHILD_GROUP_ID,
+                        type: ComponentType.InputPort
+                    }
+                })
+            );
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('reports an end that no group reported as unauthorized, whichever 
end it is', () => {
+            // the component the connections were requested for is reported by 
its own group like any
+            // other component, and is unauthorized when that group did not 
report it
+            const { component } = 
createPortDialog([unreadableSourceIntoSelectedPort()], []);
+
+            expect(component.rows[0].source.name).toBeNull();
+            expect(component.rows[0].destination.name).toBeNull();
+            
expect(component.formatComponentName(component.rows[0].destination)).toBe('Unauthorized');
+        });
+
+        it('names both ends when each of their groups reported them', () => {
+            const { component } = createPortDialog(
+                [unreadableSourceIntoSelectedPort()],
+                [
+                    ['hidden-processor-id', 'No Longer Hidden Processor'],
+                    [SELECTED_COMPONENT_ID, SELECTED_PORT_NAME]
+                ]
+            );
+
+            expect(component.rows[0].source.name).toBe('No Longer Hidden 
Processor');
+            
expect(component.rows[0].destination.name).toBe(SELECTED_PORT_NAME);
+        });
+
+        it('names a funnel by its type, which is all the canvas shows for 
one', () => {
+            const funnelConnection = unreadableConnection({
+                source: { id: 'funnel-id', name: '' },
+                sourceGroupId: REQUEST_GROUP_ID,
+                sourceType: 'FUNNEL',
+                destination: { id: SELECTED_COMPONENT_ID, name: 
SELECTED_PORT_NAME },
+                destinationGroupId: CHILD_GROUP_ID,
+                destinationType: 'INPUT_PORT'
+            });
+
+            const { component } = createPortDialog([funnelConnection]);
+
+            expect(component.rows[0].source.name).toBe('Funnel');
+            
expect(component.formatComponentName(component.rows[0].source)).toBe('Funnel');
+        });
+
+        it('falls back to the name the connection carries for an end no group 
reports', () => {
+            // a port inside a Remote Process Group is listed by no flow of 
its own, and a readable
+            // connection - which means both of its ends are readable - is 
what names it
+            const connection = readableConnection({
+                source: { id: 'remote-output-port-id', name: 'Remote Output 
Port' },
+                sourceGroupId: REMOTE_GROUP_ID,
+                sourceType: 'REMOTE_OUTPUT_PORT',
+                destination: { id: SELECTED_COMPONENT_ID, name: 
SELECTED_PORT_NAME },
+                destinationGroupId: CHILD_GROUP_ID,
+                destinationType: 'INPUT_PORT'
+            });
+
+            const { component } = createPortDialog([connection], []);
+
+            expect(component.rows[0].source.name).toBe('Remote Output Port');
+            
expect(component.rows[0].destination.name).toBe(SELECTED_PORT_NAME);
+        });
+    });
+
+    /**
+     * A remote port's group is a Remote Process Group, which is navigated to 
as a component of the group
+     * on the canvas rather than as a group that can be entered. The type 
carried by the navigation is the
+     * ':type' segment of the resulting route, so a Process Group type here 
produces the wrong url.
+     */
+    describe('remote process group navigation', () => {
+        it('navigates to a remote source process group as a Remote Process 
Group', () => {
+            const connection = readableConnection({
+                source: { id: 'remote-output-port-id', name: 'Remote Output 
Port' },
+                sourceGroupId: REMOTE_GROUP_ID,
+                sourceType: 'REMOTE_OUTPUT_PORT',
+                destination: { id: 'processor-id', name: 'Processor' },
+                destinationGroupId: DESTINATION_GROUP_ID,
+                destinationType: 'PROCESSOR'
+            });
+
+            const { fixture, store, dialogRef } = createDialog('upstream', 
[connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const sourceProcessGroupCell = getCells(fixture, 
'mat-column-sourceProcessGroup')[0];
+            
expect(sourceProcessGroupCell.querySelector('i.icon-group-remote')).not.toBeNull();
+
+            clickCell(fixture, 'mat-column-sourceProcessGroup');
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: REMOTE_GROUP_ID,
+                        processGroupId: REQUEST_GROUP_ID,
+                        type: ComponentType.RemoteProcessGroup
+                    }
+                })
+            );
+            expect(navigationUrl(dispatch)).toBe(
+                
`/process-groups/${REQUEST_GROUP_ID}/${ComponentType.RemoteProcessGroup}/${REMOTE_GROUP_ID}`
+            );
+            
expect(navigationUrl(dispatch)).not.toContain(`/${ComponentType.ProcessGroup}/`);
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('navigates to a remote destination process group as a Remote 
Process Group', () => {
+            const connection = readableConnection({
+                source: { id: 'processor-id', name: 'Processor' },
+                sourceGroupId: SOURCE_GROUP_ID,
+                sourceType: 'PROCESSOR',
+                destination: { id: 'remote-input-port-id', name: 'Remote Input 
Port' },
+                destinationGroupId: REMOTE_GROUP_ID,
+                destinationType: 'REMOTE_INPUT_PORT'
+            });
+
+            const { fixture, store, dialogRef } = createDialog('downstream', 
[connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const destinationProcessGroupCell = getCells(fixture, 
'mat-column-destinationProcessGroup')[0];
+            
expect(destinationProcessGroupCell.querySelector('i.icon-group-remote')).not.toBeNull();
+
+            clickCell(fixture, 'mat-column-destinationProcessGroup');
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: REMOTE_GROUP_ID,
+                        processGroupId: REQUEST_GROUP_ID,
+                        type: ComponentType.RemoteProcessGroup
+                    }
+                })
+            );
+            expect(navigationUrl(dispatch)).toBe(
+                
`/process-groups/${REQUEST_GROUP_ID}/${ComponentType.RemoteProcessGroup}/${REMOTE_GROUP_ID}`
+            );
+            
expect(navigationUrl(dispatch)).not.toContain(`/${ComponentType.ProcessGroup}/`);
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('keeps navigating to a local process group as a Process Group', () 
=> {
+            const connection = readableConnection({
+                source: { id: 'output-port-id', name: 'Output Port' },
+                sourceGroupId: SOURCE_GROUP_ID,
+                sourceType: 'OUTPUT_PORT'
+            });
+
+            const { fixture, store } = createDialog('upstream', [connection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const sourceProcessGroupCell = getCells(fixture, 
'mat-column-sourceProcessGroup')[0];
+            
expect(sourceProcessGroupCell.querySelector('i.icon-group')).not.toBeNull();
+
+            clickCell(fixture, 'mat-column-sourceProcessGroup');
+
+            expect(navigationUrl(dispatch)).toBe(
+                
`/process-groups/${REQUEST_GROUP_ID}/${ComponentType.ProcessGroup}/${SOURCE_GROUP_ID}`
+            );
+        });
+    });
+
+    /**
+     * Right-clicking empty canvas selects no component, which implicitly 
reports the current process
+     * group. Its connections are defined one level up, so the dialog is built 
around the parent group:
+     * the parent is the group treated as current by the table, and the group 
the user is in is itself a
+     * navigable component within it.
+     */
+    describe('current process group selection', () => {
+        function upstreamIntoCurrentGroup(): ConnectionEntity {
+            return readableConnection({
+                source: { id: 'parent-processor-id', name: 'Parent Processor' 
},
+                sourceGroupId: PARENT_GROUP_ID,
+                sourceType: 'PROCESSOR',
+                destination: { id: 'input-port-id', name: 'Input Port' },
+                destinationGroupId: CURRENT_GROUP_ID,
+                destinationType: 'INPUT_PORT'
+            });
+        }
+
+        function downstreamOutOfCurrentGroup(): ConnectionEntity {
+            return readableConnection({
+                source: { id: 'output-port-id', name: 'Output Port' },
+                sourceGroupId: CURRENT_GROUP_ID,
+                sourceType: 'OUTPUT_PORT',
+                destination: { id: 'parent-processor-id', name: 'Parent 
Processor' },
+                destinationGroupId: PARENT_GROUP_ID,
+                destinationType: 'PROCESSOR'
+            });
+        }
+
+        it('reports the current process group as the selected component', () 
=> {
+            const { component, fixture } = 
createCurrentProcessGroupDialog('upstream', [upstreamIntoCurrentGroup()]);
+
+            expect(component.componentType).toBe(ComponentType.ProcessGroup);
+            expect(component.componentId).toBe(CURRENT_GROUP_ID);
+
+            const componentContext = 
fixture.debugElement.query(By.css('component-context'));
+            const contextText = (componentContext.nativeElement.textContent as 
string).replace(/\s+/g, ' ').trim();
+
+            expect(contextText).toContain('Current Process Group');
+            expect(contextText).toContain('Process Group');
+            expect(contextText).toContain(CURRENT_GROUP_ID);
+            
expect(componentContext.query(By.css('.icon-group'))).not.toBeNull();
+        });
+
+        it('offers both the parent group and the group on the canvas as places 
to go', () => {
+            const { component } = createCurrentProcessGroupDialog('upstream', 
[upstreamIntoCurrentGroup()]);
+
+            
expect(component.isNavigableProcessGroup(PARENT_GROUP_ID)).toBeTruthy();
+            
expect(component.isNavigableProcessGroup(CURRENT_GROUP_ID)).toBeTruthy();
+        });
+
+        it('enters the parent group from the cell of a component that sits in 
it', () => {
+            const { fixture, store, dialogRef } = 
createCurrentProcessGroupDialog('upstream', [
+                upstreamIntoCurrentGroup()
+            ]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const sourceProcessGroupCell = getCells(fixture, 
'mat-column-sourceProcessGroup')[0];
+            expect(sourceProcessGroupCell.textContent).toContain('Parent 
Process Group');
+
+            clickCell(fixture, 'mat-column-sourceProcessGroup');
+
+            // the parent holds no component of its own to select, so it is 
entered instead
+            expect(dispatch).toHaveBeenCalledWith(enterProcessGroup({ request: 
{ id: PARENT_GROUP_ID } }));
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('navigates into the current process group from the upstream 
destination group cell', () => {
+            const { fixture, store, dialogRef } = 
createCurrentProcessGroupDialog('upstream', [
+                upstreamIntoCurrentGroup()
+            ]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const destinationProcessGroupCell = getCells(fixture, 
'mat-column-destinationProcessGroup')[0];
+            expect(destinationProcessGroupCell.textContent).toContain('Current 
Process Group');
+
+            clickCell(fixture, 'mat-column-destinationProcessGroup');
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: CURRENT_GROUP_ID,
+                        processGroupId: PARENT_GROUP_ID,
+                        type: ComponentType.ProcessGroup
+                    }
+                })
+            );
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('navigates into the current process group from the downstream 
source group cell', () => {
+            const { fixture, store, dialogRef } = 
createCurrentProcessGroupDialog('downstream', [
+                downstreamOutOfCurrentGroup()
+            ]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            clickCell(fixture, 'mat-column-sourceProcessGroup');
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: CURRENT_GROUP_ID,
+                        processGroupId: PARENT_GROUP_ID,
+                        type: ComponentType.ProcessGroup
+                    }
+                })
+            );
+            expect(dialogRef.close).toHaveBeenCalled();
+        });
+
+        it('navigates to the port of a sibling group feeding the current 
process group', () => {
+            const siblingConnection = readableConnection({
+                source: { id: 'sibling-output-port-id', name: 'Sibling Output 
Port' },
+                sourceGroupId: SIBLING_GROUP_ID,
+                sourceType: 'OUTPUT_PORT',
+                destination: { id: 'input-port-id', name: 'Input Port' },
+                destinationGroupId: CURRENT_GROUP_ID,
+                destinationType: 'INPUT_PORT'
+            });
+
+            const { fixture, store } = 
createCurrentProcessGroupDialog('upstream', [siblingConnection]);
+            const dispatch = vi.spyOn(store, 'dispatch');
+
+            const sourceProcessGroupCell = getCells(fixture, 
'mat-column-sourceProcessGroup')[0];
+            expect(sourceProcessGroupCell.textContent).toContain('Sibling 
Process Group');
+
+            clickCell(fixture, 'mat-column-sourceComponent');
+
+            expect(dispatch).toHaveBeenCalledWith(
+                navigateToComponent({
+                    request: {
+                        id: 'sibling-output-port-id',
+                        processGroupId: SIBLING_GROUP_ID,
+                        type: ComponentType.OutputPort
+                    }
+                })
+            );
+        });
+
+        it('reports that the current process group has no connections in the 
requested direction', () => {
+            const { fixture } = createCurrentProcessGroupDialog('upstream', 
[]);
+
+            const renderedText = textContent(fixture);
+            expect(renderedText).toContain('Current Process Group');
+            expect(renderedText).toContain('No upstream connections were 
found.');
+        });
+    });
+
+    /**
+     * Every column sorts on the text it renders, so a row is ordered by what 
the user reads in that
+     * column rather than by the identifier behind it.
+     */
+    describe('sorting', () => {
+        // resolved labels, by column: source group / source component / 
connection / destination group /
+        // destination component
+        const CURRENT_ROW_ID = 'current-source-group-connection-id'; // 
Current / Zeta / Beta / Destination / Alpha
+        const SOURCE_ROW_ID = 'source-source-group-connection-id'; // Source / 
Alpha / Alpha / Current / Zeta
+        const DESTINATION_ROW_ID = 'destination-source-group-connection-id'; 
// Destination / Gamma / Gamma / Source / Gamma
+
+        function connectionsToSort(): ConnectionEntity[] {
+            return [
+                readableConnection({
+                    id: CURRENT_ROW_ID,
+                    name: 'Beta Connection',
+                    source: { id: 'zeta-processor-id', name: 'Zeta Processor' 
},
+                    sourceGroupId: REQUEST_GROUP_ID,
+                    destination: { id: 'alpha-port-id', name: 'Alpha Port' },
+                    destinationGroupId: DESTINATION_GROUP_ID
+                }),
+                readableConnection({
+                    id: SOURCE_ROW_ID,
+                    name: 'Alpha Connection',
+                    source: { id: 'alpha-processor-id', name: 'Alpha 
Processor' },
+                    sourceGroupId: SOURCE_GROUP_ID,
+                    destination: { id: 'zeta-port-id', name: 'Zeta Port' },
+                    destinationGroupId: REQUEST_GROUP_ID
+                }),
+                readableConnection({
+                    id: DESTINATION_ROW_ID,
+                    name: 'Gamma Connection',
+                    source: { id: 'gamma-processor-id', name: 'Gamma 
Processor' },
+                    sourceGroupId: DESTINATION_GROUP_ID,
+                    destination: { id: 'gamma-port-id', name: 'Gamma Port' },
+                    destinationGroupId: SOURCE_GROUP_ID
+                })
+            ];
+        }
+
+        it('sorts by the connection column ascending by default', () => {
+            const { component } = createDialog('upstream', 
connectionsToSort());
+
+            expect(component.initialSortColumn).toBe('connection');
+            expect(component.initialSortDirection).toBe('asc');
+            expect(component.activeSort).toEqual({ active: 'connection', 
direction: 'asc' });
+            expect(renderedIds(component)).toEqual([SOURCE_ROW_ID, 
CURRENT_ROW_ID, DESTINATION_ROW_ID]);
+        });
+
+        it('leaves the rows built from the request in their original order', 
() => {
+            const { component } = createDialog('upstream', 
connectionsToSort());
+
+            expect(component.rows.map((row) => 
row.id)).toEqual([CURRENT_ROW_ID, SOURCE_ROW_ID, DESTINATION_ROW_ID]);
+        });
+
+        it('renders every column as sortable', () => {
+            const { component, fixture } = createDialog('upstream', 
connectionsToSort());
+
+            const sortableHeaders = 
fixture.debugElement.queryAll(By.css('th.mat-sort-header'));
+            
expect(sortableHeaders.length).toBe(component.displayedColumns.length);
+        });
+
+        it('sorts by the source process group name', () => {
+            const { component } = createDialog('upstream', 
connectionsToSort());
+
+            component.sortData({ active: 'sourceProcessGroup', direction: 
'asc' });
+            expect(renderedIds(component)).toEqual([CURRENT_ROW_ID, 
DESTINATION_ROW_ID, SOURCE_ROW_ID]);
+
+            component.sortData({ active: 'sourceProcessGroup', direction: 
'desc' });
+            expect(renderedIds(component)).toEqual([SOURCE_ROW_ID, 
DESTINATION_ROW_ID, CURRENT_ROW_ID]);
+        });
+
+        it('sorts by the source component name', () => {
+            const { component } = createDialog('upstream', 
connectionsToSort());
+
+            component.sortData({ active: 'sourceComponent', direction: 'asc' 
});
+            expect(renderedIds(component)).toEqual([SOURCE_ROW_ID, 
DESTINATION_ROW_ID, CURRENT_ROW_ID]);
+
+            component.sortData({ active: 'sourceComponent', direction: 'desc' 
});
+            expect(renderedIds(component)).toEqual([CURRENT_ROW_ID, 
DESTINATION_ROW_ID, SOURCE_ROW_ID]);
+        });
+
+        it('sorts by the connection name', () => {
+            const { component } = createDialog('upstream', 
connectionsToSort());
+
+            component.sortData({ active: 'connection', direction: 'desc' });
+            expect(renderedIds(component)).toEqual([DESTINATION_ROW_ID, 
CURRENT_ROW_ID, SOURCE_ROW_ID]);
+
+            component.sortData({ active: 'connection', direction: 'asc' });
+            expect(renderedIds(component)).toEqual([SOURCE_ROW_ID, 
CURRENT_ROW_ID, DESTINATION_ROW_ID]);
+        });
+
+        it('sorts by the destination process group name', () => {
+            const { component } = createDialog('upstream', 
connectionsToSort());
+
+            component.sortData({ active: 'destinationProcessGroup', direction: 
'asc' });
+            expect(renderedIds(component)).toEqual([SOURCE_ROW_ID, 
CURRENT_ROW_ID, DESTINATION_ROW_ID]);
+
+            component.sortData({ active: 'destinationProcessGroup', direction: 
'desc' });
+            expect(renderedIds(component)).toEqual([DESTINATION_ROW_ID, 
CURRENT_ROW_ID, SOURCE_ROW_ID]);
+        });
+
+        it('sorts by the destination component name', () => {
+            const { component } = createDialog('upstream', 
connectionsToSort());
+
+            component.sortData({ active: 'destinationComponent', direction: 
'asc' });
+            expect(renderedIds(component)).toEqual([CURRENT_ROW_ID, 
DESTINATION_ROW_ID, SOURCE_ROW_ID]);
+
+            component.sortData({ active: 'destinationComponent', direction: 
'desc' });
+            expect(renderedIds(component)).toEqual([SOURCE_ROW_ID, 
DESTINATION_ROW_ID, CURRENT_ROW_ID]);
+        });
+
+        it('sorts a group with no resolved name by the id it renders', () => {
+            const connections = [
+                readableConnection({ id: 'named-group-connection-id', 
sourceGroupId: SOURCE_GROUP_ID }),
+                readableConnection({ id: 'unnamed-group-connection-id', 
sourceGroupId: UNKNOWN_GROUP_ID })
+            ];
+
+            const { component } = createDialog('upstream', connections);
+
+            // 'Source Process Group' sorts ahead of the raw 
'unknown-group-id' shown in place of a name
+            component.sortData({ active: 'sourceProcessGroup', direction: 
'asc' });
+            
expect(renderedIds(component)).toEqual(['named-group-connection-id', 
'unnamed-group-connection-id']);
+        });
+
+        it('sorts unreadable components under the placeholder rendered for 
them', () => {
+            const connections = [
+                readableConnection({
+                    id: 'zeta-connection-id',
+                    source: { id: 'zeta-processor-id', name: 'Zeta Processor' }
+                }),
+                unreadableConnection({ id: 'first-unreadable-connection-id' }),
+                readableConnection({
+                    id: 'alpha-connection-id',
+                    source: { id: 'alpha-processor-id', name: 'Alpha 
Processor' }
+                }),
+                unreadableConnection({ id: 'second-unreadable-connection-id' })
+            ];
+
+            const { component } = createDialog('upstream', connections);
+
+            // 'Alpha Processor' < 'Unauthorized' < 'Zeta Processor', and rows 
sharing the placeholder keep
+            // the order they were listed in
+            component.sortData({ active: 'sourceComponent', direction: 'asc' 
});
+            expect(renderedIds(component)).toEqual([
+                'alpha-connection-id',
+                'first-unreadable-connection-id',
+                'second-unreadable-connection-id',
+                'zeta-connection-id'
+            ]);
+        });
+
+        it('sorts unnamed connections under the placeholder rendered for 
them', () => {
+            const connections = [
+                readableConnection({ id: 'delta-connection-id', name: 'Delta 
Connection' }),
+                readableConnection({ id: 'unnamed-connection-id' }),
+                readableConnection({ id: 'alpha-connection-id', name: 'Alpha 
Connection' })
+            ];
+
+            const { component } = createDialog('upstream', connections);
+
+            // an unnamed connection renders 'Connection', which sorts between 
'Alpha' and 'Delta'
+            expect(component.rows[1].name).toBeNull();
+            component.sortData({ active: 'connection', direction: 'asc' });
+            expect(renderedIds(component)).toEqual([
+                'alpha-connection-id',
+                'unnamed-connection-id',
+                'delta-connection-id'
+            ]);
+        });
+
+        it('leaves the order unchanged for a column it does not sort on', () 
=> {
+            const { component } = createDialog('upstream', 
connectionsToSort());
+            const orderBeforeSort = renderedIds(component);
+
+            component.sortData({ active: 'unsortable-column', direction: 'asc' 
});
+
+            expect(renderedIds(component)).toEqual(orderBeforeSort);
+        });
+
+        it('re-sorts the table when a column header is clicked', () => {
+            const { component, fixture } = createDialog('upstream', 
connectionsToSort());
+
+            clickHeader(fixture, 'mat-column-sourceComponent');
+
+            expect(component.activeSort.active).toBe('sourceComponent');
+            expect(component.activeSort.direction).toBe('asc');
+            expect(renderedIds(component)).toEqual([SOURCE_ROW_ID, 
DESTINATION_ROW_ID, CURRENT_ROW_ID]);
+
+            const sourceComponentCells = getCells(fixture, 
'mat-column-sourceComponent');
+            expect(sourceComponentCells[0].textContent).toContain('Alpha 
Processor');
+            expect(sourceComponentCells[2].textContent).toContain('Zeta 
Processor');
+        });
+
+        it('reverses the order when the active column header is clicked 
again', () => {
+            const { component, fixture } = createDialog('upstream', 
connectionsToSort());
+
+            clickHeader(fixture, 'mat-column-connection');
+
+            expect(component.activeSort).toEqual({ active: 'connection', 
direction: 'desc' });
+            expect(renderedIds(component)).toEqual([DESTINATION_ROW_ID, 
CURRENT_ROW_ID, SOURCE_ROW_ID]);
+        });
+    });
+
+    describe('icons', () => {
+        it('returns the expected icon class for supported component types', () 
=> {
+            const { component } = createDialog('upstream', []);
+
+            
expect(component.componentIcon(ComponentType.Processor)).toBe('icon-processor');
+            
expect(component.componentIcon(ComponentType.InputPort)).toBe('icon-port-in');
+            
expect(component.componentIcon(ComponentType.OutputPort)).toBe('icon-port-out');
+            
expect(component.componentIcon(ComponentType.Funnel)).toBe('icon-funnel');
+            
expect(component.componentIcon(ComponentType.ProcessGroup)).toBe('icon-group');
+            
expect(component.componentIcon(ComponentType.RemoteProcessGroup)).toBe('icon-group-remote');
+            
expect(component.componentIcon(ComponentType.Connection)).toBe('icon-connect');
+        });
+
+        it('returns the drop icon for unsupported component types', () => {
+            const { component } = createDialog('upstream', []);
+
+            
expect(component.componentIcon(ComponentType.ControllerService)).toBe('icon-drop');
+        });
+    });
+});
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 00000000000..7107a302694
--- /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,400 @@
+/*
+ * 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 { MatSortModule, Sort } from '@angular/material/sort';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog, ComponentContext, ComponentType, NiFiCommon } 
from '@nifi/shared';
+import { CanvasState } from '../../../state';
+import { ComponentConnectionsDialogRequest, ConnectionEntity } from 
'../../../state/flow';
+import { CanvasUtils } from '../../../service/canvas-utils.service';
+import { enterProcessGroup, navigateToComponent } from 
'../../../state/flow/flow.actions';
+
+/**
+ * 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 this end, in
+ *   which case the cell renders an "Unauthorized" placeholder. The cell stays 
clickable either way,
+ *   the same as an unreadable component the user can select on the canvas.
+ */
+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: [ComponentContext, MatButtonModule, MatDialogModule, 
MatSortModule, MatTableModule, MatTooltipModule],
+    templateUrl: './component-connections-dialog.component.html',
+    styleUrls: ['./component-connections-dialog.component.scss']
+})
+export class ComponentConnectionsDialog extends CloseOnEscapeDialog {
+    private dialogRequest = 
inject<ComponentConnectionsDialogRequest>(MAT_DIALOG_DATA);
+    private componentConnectionsDialogRef = 
inject<MatDialogRef<ComponentConnectionsDialog>>(MatDialogRef);
+    private store = inject<Store<CanvasState>>(Store);
+    private canvasUtils = inject(CanvasUtils);
+    private nifiCommon = inject(NiFiCommon);
+
+    // Maps the string type returned by the NiFi API to the ComponentType enum 
used for navigation.
+    private static readonly TYPE_MAP: Record<string, ComponentType> = {
+        PROCESSOR: ComponentType.Processor,
+        INPUT_PORT: ComponentType.InputPort,
+        OUTPUT_PORT: ComponentType.OutputPort,
+        REMOTE_INPUT_PORT: ComponentType.RemoteProcessGroup,
+        REMOTE_OUTPUT_PORT: ComponentType.RemoteProcessGroup,
+        FUNNEL: ComponentType.Funnel
+    };
+
+    // rendered in place of a name the current user cannot read, or that the 
component does not have
+    private static readonly UNAUTHORIZED_LABEL = 'Unauthorized';
+    private static readonly UNNAMED_CONNECTION_LABEL = 'Connection';
+
+    readonly displayedColumns: string[] = [
+        'sourceProcessGroup',
+        'sourceComponent',
+        'connection',
+        'destinationProcessGroup',
+        'destinationComponent'
+    ];
+    readonly componentId: string = this.dialogRequest.componentId;
+    // the name the current user can read, or the component id when they 
cannot; component-context
+    // renders whatever it is given
+    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 remoteProcessGroupType = ComponentType.RemoteProcessGroup;
+    readonly connectionType = ComponentType.Connection;
+
+    readonly initialSortColumn = 'connection';
+    readonly initialSortDirection: 'asc' | 'desc' = 'asc';
+    activeSort: Sort = {
+        active: this.initialSortColumn,
+        direction: this.initialSortDirection
+    };
+    readonly dataSource: MatTableDataSource<ComponentConnectionRow> = new 
MatTableDataSource<ComponentConnectionRow>();
+
+    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));
+        this.dataSource.data = this.sortRows(this.rows, this.activeSort);
+    }
+
+    sortData(sort: Sort): void {
+        this.activeSort = sort;
+        this.dataSource.data = this.sortRows(this.dataSource.data, sort);
+    }
+
+    /**
+     * Orders the rows by the text each column actually renders, so that a row 
whose name is unreadable
+     * or absent sorts under the placeholder the user sees rather than under 
an empty key.
+     *
+     * @param data the rows to sort
+     * @param sort the active column and direction
+     * @returns the sorted rows
+     */
+    sortRows(data: ComponentConnectionRow[], sort: Sort): 
ComponentConnectionRow[] {
+        if (!data) {
+            return [];
+        }
+        return data.slice().sort((a, b) => {
+            const isAsc = sort.direction === 'asc';
+            let retVal: number;
+            switch (sort.active) {
+                case 'sourceProcessGroup':
+                    retVal = this.nifiCommon.compareString(
+                        this.resolveGroupName(a.source.groupId),
+                        this.resolveGroupName(b.source.groupId)
+                    );
+                    break;
+                case 'sourceComponent':
+                    retVal = this.nifiCommon.compareString(
+                        this.formatComponentName(a.source),
+                        this.formatComponentName(b.source)
+                    );
+                    break;
+                case 'connection':
+                    retVal = 
this.nifiCommon.compareString(this.formatConnectionName(a), 
this.formatConnectionName(b));
+                    break;
+                case 'destinationProcessGroup':
+                    retVal = this.nifiCommon.compareString(
+                        this.resolveGroupName(a.destination.groupId),
+                        this.resolveGroupName(b.destination.groupId)
+                    );
+                    break;
+                case 'destinationComponent':
+                    retVal = this.nifiCommon.compareString(
+                        this.formatComponentName(a.destination),
+                        this.formatComponentName(b.destination)
+                    );
+                    break;
+                default:
+                    return 0;
+            }
+            return retVal * (isAsc ? 1 : -1);
+        });
+    }
+
+    /**
+     * Returns the name rendered for an endpoint, which is a placeholder when 
the connection cannot be
+     * read and the endpoint has no name to show.
+     *
+     * @param endpoint the source or destination endpoint
+     * @returns the endpoint name to render and sort on
+     */
+    formatComponentName(endpoint: ConnectionEndpoint): string {
+        return endpoint.name ?? ComponentConnectionsDialog.UNAUTHORIZED_LABEL;
+    }
+
+    /**
+     * Returns the tooltip of an endpoint, which falls back to the id of a 
component the current user
+     * cannot read, since the placeholder rendered in its place identifies 
nothing on its own.
+     *
+     * @param endpoint the source or destination endpoint
+     * @returns the endpoint name, or its id when unreadable
+     */
+    componentTooltip(endpoint: ConnectionEndpoint): string {
+        return endpoint.name ?? endpoint.id;
+    }
+
+    /**
+     * Returns the name rendered for a connection, which is a placeholder when 
the connection has
+     * neither a name nor relationships to name it by.
+     *
+     * @param row the row of the connection
+     * @returns the connection name to render and sort on
+     */
+    formatConnectionName(row: ComponentConnectionRow): string {
+        return row.name ?? ComponentConnectionsDialog.UNNAMED_CONNECTION_LABEL;
+    }
+
+    /**
+     * 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();
+    }
+
+    /**
+     * Determines whether there is somewhere to go for the given process 
group. The only group there is
+     * not is the one that both defines these connections and is already open 
on the canvas: selecting
+     * it within itself is not a place the canvas can go, and the user is 
looking at it already.
+     *
+     * The group that defines the connections is the parent group when a 
port's connections cross its
+     * own group's boundary, and the parent is a group the user can still be 
taken to.
+     *
+     * @param groupId the process group id to check
+     * @returns whether the group can be navigated to
+     */
+    isNavigableProcessGroup(groupId: string): boolean {
+        return !(groupId === this.dialogRequestGroupId && groupId === 
this.canvasUtils.getProcessGroupId());
+    }
+
+    /**
+     * Navigates to the process group at one end of a connection, then closes 
the dialog. The group that
+     * defines the connections is entered, since it holds no component of its 
own to select, while any
+     * other group is a component of it and is selected there - as a Remote 
Process Group when the end is
+     * one of its remote ports.
+     *
+     * @param endpoint the source or destination endpoint whose group should 
be navigated to
+     */
+    navigateToProcessGroup(endpoint: ConnectionEndpoint): void {
+        if (endpoint.groupId === this.dialogRequestGroupId) {
+            this.store.dispatch(
+                enterProcessGroup({
+                    request: {
+                        id: endpoint.groupId
+                    }
+                })
+            );
+            this.componentConnectionsDialogRef.close();
+            return;
+        }
+
+        this.navigateTo(endpoint.groupId, this.dialogRequestGroupId, 
this.processGroupTypeOf(endpoint));
+    }
+
+    /**
+     * Returns the type the process group at one end of a connection is 
navigated to as.
+     *
+     * @param endpoint the source or destination endpoint
+     * @returns Remote Process Group when the end is a remote port, Process 
Group otherwise
+     */
+    processGroupTypeOf(endpoint: ConnectionEndpoint): ComponentType {
+        return this.isRemoteProcessGroupPort(endpoint) ? 
this.remoteProcessGroupType : this.processGroupType;
+    }
+
+    /**
+     * 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.dialogRequest.groupIdToName.get(groupId) ?? groupId;
+    }
+
+    /**
+     * Determines whether the endpoint is a port inside a Remote Process 
Group. Remote ports
+     * are not rendered as separate selectable elements on the current graph, 
so they should
+     * not be linked from the connections table.
+     *
+     * @param endpoint the source or destination endpoint to check
+     * @returns whether the endpoint is a remote port in a Remote Process Group
+     */
+    isRemoteProcessGroupPort(endpoint: ConnectionEndpoint): boolean {
+        return endpoint.type === ComponentType.RemoteProcessGroup;
+    }
+
+    /**
+     * 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: this.buildEndpoint(
+                connection.sourceId,
+                connection.sourceGroupId,
+                connection.sourceType,
+                connection.component?.source?.name
+            ),
+            destination: this.buildEndpoint(
+                connection.destinationId,
+                connection.destinationGroupId,
+                connection.destinationType,
+                connection.component?.destination?.name
+            )
+        };
+    }
+
+    private buildEndpoint(id: string, groupId: string, type: string, 
nameOnConnection?: string): ConnectionEndpoint {
+        return {
+            id,
+            groupId,
+            type: this.mapComponentType(type),
+            name:
+                this.mapComponentType(type) === ComponentType.Funnel
+                    ? 'Funnel'
+                    : this.resolveComponentName(id, nameOnConnection)
+        };
+    }
+
+    /**
+     * Resolves the name of one end of a connection from that component's own 
read permission, with no
+     * regard for the other end or for the connection between them. A 
connection is readable only when
+     * the current user can read both of its ends, so the names it carries 
disappear for both ends as
+     * soon as either one is unreadable; they are only a fallback here.
+     *
+     * The components of every group these connections reach into were listed 
with their own
+     * permissions, and each readable one is named there. An end that is not 
is either unreadable or a
+     * port inside a Remote Process Group, whose group lists nothing of its 
own - the name the
+     * connection carries covers the latter, and is only ever present when 
both ends are readable.
+     *
+     * @param id the id of the component at this end of the connection
+     * @param nameOnConnection the name the connection reports for this end, 
when it can be read
+     * @returns the name to render, or null when this end is unreadable
+     */
+    private resolveComponentName(id: string, nameOnConnection?: string): 
string | null {
+        return this.dialogRequest.componentIdToName.get(id) ?? 
nameOnConnection ?? null;
+    }
+
+    private mapComponentType(type: string): ComponentType {
+        return ComponentConnectionsDialog.TYPE_MAP[type] ?? 
ComponentType.Connector;
+    }
+}

Reply via email to