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 11c520e61b7 NIFI-16487 Add Connector process group export support 
(#11836)
11c520e61b7 is described below

commit 11c520e61b74c6ec2a5c8e9255cbca768cafc759
Author: Matt Gilman <[email protected]>
AuthorDate: Fri Oct 23 10:20:41 2026 -0400

    NIFI-16487 Add Connector process group export support (#11836)
    
    * NIFI-16487 Add Connector process group export support
    
    Add Connector-scoped flow definition downloads, including component state, 
cluster response merging, toolkit support, and Connector canvas actions.
    
    * NIFI-16487: Addressing review feedback.
    
    This closes #11836
---
 .../ExportProcessGroupEndpointMerger.java          |   5 +-
 .../ExportProcessGroupEndpointMergerTest.java      | 109 ++++++++++++++++
 .../org/apache/nifi/web/NiFiServiceFacade.java     |  13 ++
 .../apache/nifi/web/StandardNiFiServiceFacade.java |  21 +++-
 .../org/apache/nifi/web/api/ConnectorResource.java |  62 +++++++++
 .../nifi/web/StandardNiFiServiceFacadeTest.java    |  87 +++++++++++++
 .../apache/nifi/web/api/TestConnectorResource.java |  50 ++++++++
 .../connectors/service/connector.service.spec.ts   |  80 ++++++++++++
 .../pages/connectors/service/connector.service.ts  |  11 +-
 .../connector-canvas/connector-canvas.actions.ts   |  11 ++
 .../connector-canvas.effects.spec.ts               |  21 ++++
 .../connector-canvas/connector-canvas.effects.ts   |   9 ++
 .../connector-canvas.component.spec.ts             | 139 ++++++++++++++++++++-
 .../connector-canvas/connector-canvas.component.ts |  65 ++++++++++
 .../connectors/ConnectorTroubleshootingIT.java     |  31 +++++
 .../nifi/toolkit/client/ConnectorClient.java       |  29 +++++
 .../toolkit/client/impl/JerseyConnectorClient.java |  44 +++++++
 17 files changed, 778 insertions(+), 9 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportProcessGroupEndpointMerger.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportProcessGroupEndpointMerger.java
index 22fdd751fba..9a7eba7bfb0 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportProcessGroupEndpointMerger.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportProcessGroupEndpointMerger.java
@@ -36,7 +36,7 @@ import java.util.Set;
 import java.util.regex.Pattern;
 
 /**
- * Merges responses for {@code GET /nifi-api/process-groups/{uuid}/download}.
+ * Merges responses for Process Group flow definition download endpoints.
  * When component state is included in the export, each cluster node 
contributes its own LOCAL state
  * keyed by ordinal position. This merger combines the {@code localNodeStates} 
maps from every node's
  * snapshot into a single response.
@@ -44,7 +44,8 @@ import java.util.regex.Pattern;
 public class ExportProcessGroupEndpointMerger implements 
EndpointResponseMerger {
 
     public static final Pattern PROCESS_GROUP_DOWNLOAD_URI_PATTERN =
-            
Pattern.compile("/nifi-api/process-groups/(?:(?:root)|(?:[a-f0-9\\-]{36}))/download");
+            
Pattern.compile("/nifi-api/(?:process-groups/(?:(?:root)|(?:[a-f0-9\\-]{36}))"
+                    + 
"|connectors/[a-f0-9\\-]{36}/process-groups/[a-f0-9\\-]{36})/download");
 
     @Override
     public boolean canHandle(final URI uri, final String method) {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportProcessGroupEndpointMergerTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportProcessGroupEndpointMergerTest.java
new file mode 100644
index 00000000000..26a12f87bc8
--- /dev/null
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/ExportProcessGroupEndpointMergerTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ */
+package org.apache.nifi.cluster.coordination.http.endpoints;
+
+import jakarta.ws.rs.core.Response;
+import org.apache.nifi.cluster.manager.NodeResponse;
+import org.apache.nifi.cluster.protocol.NodeIdentifier;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedNodeState;
+import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.flow.VersionedProcessor;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+public class ExportProcessGroupEndpointMergerTest {
+
+    private static final String CONNECTOR_ID = 
"12345678-1234-1234-1234-123456789012";
+    private static final String PROCESS_GROUP_ID = 
"abcdef01-2345-6789-abcd-ef0123456789";
+    private static final String GLOBAL_URI = "/nifi-api/process-groups/" + 
PROCESS_GROUP_ID + "/download";
+    private static final String CONNECTOR_URI = "/nifi-api/connectors/" + 
CONNECTOR_ID + "/process-groups/" + PROCESS_GROUP_ID + "/download";
+
+    @Test
+    public void testCanHandle() {
+        final ExportProcessGroupEndpointMerger merger = new 
ExportProcessGroupEndpointMerger();
+
+        assertTrue(merger.canHandle(URI.create(GLOBAL_URI), "GET"));
+        
assertTrue(merger.canHandle(URI.create("/nifi-api/process-groups/root/download"),
 "GET"));
+        assertTrue(merger.canHandle(URI.create(CONNECTOR_URI), "GET"));
+        assertTrue(merger.canHandle(URI.create(CONNECTOR_URI + 
"?includeComponentState=true"), "get"));
+
+        assertFalse(merger.canHandle(URI.create(CONNECTOR_URI), "POST"));
+        
assertFalse(merger.canHandle(URI.create("/nifi-api/connectors/not-a-uuid/process-groups/"
 + PROCESS_GROUP_ID + "/download"), "GET"));
+        assertFalse(merger.canHandle(URI.create("/nifi-api/connectors/" + 
CONNECTOR_ID + "/process-groups/not-a-uuid/download"), "GET"));
+        assertFalse(merger.canHandle(URI.create(CONNECTOR_URI + "/status"), 
"GET"));
+    }
+
+    @Test
+    public void testMergeLocalNodeStates() {
+        final ExportProcessGroupEndpointMerger merger = new 
ExportProcessGroupEndpointMerger();
+        final NodeResponse firstNodeResponse = createNodeResponse(1, 
createSnapshot(0, "first"));
+        final NodeResponse secondNodeResponse = createNodeResponse(2, 
createSnapshot(1, "second"));
+
+        final NodeResponse mergedResponse = merger.merge(
+                URI.create(CONNECTOR_URI), "GET", Set.of(firstNodeResponse, 
secondNodeResponse), Collections.emptySet(), firstNodeResponse);
+
+        final ExportedFlowSnapshotEntity mergedEntity = 
(ExportedFlowSnapshotEntity) mergedResponse.getUpdatedEntity();
+        final List<VersionedNodeState> localNodeStates = 
mergedEntity.getSnapshot().getFlowContents()
+                
.getProcessors().iterator().next().getComponentState().getLocalNodeStates();
+        assertEquals(2, localNodeStates.size());
+        assertEquals("first", localNodeStates.get(0).getState().get("value"));
+        assertEquals("second", localNodeStates.get(1).getState().get("value"));
+    }
+
+    private NodeResponse createNodeResponse(final int nodeNumber, final 
RegisteredFlowSnapshot flowSnapshot) {
+        final Response response = mock(Response.class);
+        when(response.getStatus()).thenReturn(200);
+        
when(response.readEntity(RegisteredFlowSnapshot.class)).thenReturn(flowSnapshot);
+        final NodeIdentifier nodeIdentifier = new NodeIdentifier(
+                "node-" + nodeNumber, "localhost", 8080 + nodeNumber, 
"localhost", 9080 + nodeNumber, null, null, null, false);
+        return new NodeResponse(nodeIdentifier, "GET", 
URI.create(CONNECTOR_URI), response, 0L, "request");
+    }
+
+    private RegisteredFlowSnapshot createSnapshot(final int nodeOrdinal, final 
String value) {
+        final List<VersionedNodeState> localNodeStates = new ArrayList<>();
+        for (int i = 0; i < nodeOrdinal; i++) {
+            localNodeStates.add(null);
+        }
+        localNodeStates.add(new VersionedNodeState(Map.of("value", value)));
+
+        final VersionedComponentState componentState = new 
VersionedComponentState();
+        componentState.setLocalNodeStates(localNodeStates);
+        final VersionedProcessor processor = new VersionedProcessor();
+        processor.setIdentifier("processor");
+        processor.setComponentState(componentState);
+        final VersionedProcessGroup processGroup = new VersionedProcessGroup();
+        processGroup.setIdentifier(PROCESS_GROUP_ID);
+        processGroup.setProcessors(Set.of(processor));
+        final RegisteredFlowSnapshot flowSnapshot = new 
RegisteredFlowSnapshot();
+        flowSnapshot.setFlowContents(processGroup);
+        return flowSnapshot;
+    }
+}
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
index e2c9aed520b..f862c712cbc 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
@@ -2070,6 +2070,19 @@ public interface NiFiServiceFacade {
      */
     RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(String 
processGroupId, boolean includeReferencedServices, boolean 
includeComponentState, boolean mapAssetReferences);
 
+    /**
+     * Get the current state of a Process Group within a Connector's managed 
flow, converted to a Versioned Flow
+     * Snapshot for download.
+     *
+     * @param connectorId the ID of the Connector
+     * @param processGroupId the ID of the Process Group within the 
Connector's managed flow
+     * @param includeReferencedServices whether to include referenced 
controller services from parent groups
+     * @param includeComponentState whether to include component state in the 
export
+     * @return the current Process Group converted to a Versioned Flow 
Snapshot for download
+     */
+    RegisteredFlowSnapshot getConnectorCurrentFlowSnapshot(
+            String connectorId, String processGroupId, boolean 
includeReferencedServices, boolean includeComponentState);
+
     /**
      * Returns the name of the Flow Registry that is registered with the given 
ID. If no Flow Registry exists with the given ID, will return
      * the ID itself as the name
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
index 56a1d876793..cfe185bad5e 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
@@ -6434,12 +6434,23 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
     @Override
     public RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(final String 
processGroupId, final boolean includeReferencedServices, final boolean 
includeComponentState,
             final boolean mapAssetReferences) {
+        final ProcessGroup processGroup = 
processGroupDAO.getProcessGroup(processGroupId);
+        return getCurrentFlowSnapshot(processGroup, includeReferencedServices, 
includeComponentState, mapAssetReferences);
+    }
+
+    @Override
+    public RegisteredFlowSnapshot getConnectorCurrentFlowSnapshot(final String 
connectorId, final String processGroupId,
+            final boolean includeReferencedServices, final boolean 
includeComponentState) {
+        final ProcessGroup processGroup = 
locateConnectorProcessGroup(connectorId, processGroupId);
+        return getCurrentFlowSnapshot(processGroup, includeReferencedServices, 
includeComponentState, false);
+    }
+
+    private RegisteredFlowSnapshot getCurrentFlowSnapshot(final ProcessGroup 
processGroup, final boolean includeReferencedServices,
+            final boolean includeComponentState, final boolean 
mapAssetReferences) {
         if (!includeComponentState) {
-            return getCurrentFlowSnapshotByGroupId(processGroupId, 
includeReferencedServices);
+            return buildFlowSnapshot(processGroup, 
processGroup.getIdentifier(), includeReferencedServices, null);
         }
 
-        final ProcessGroup processGroup = 
processGroupDAO.getProcessGroup(processGroupId);
-
         // Validate all processors are stopped and all controller services are 
disabled
         final List<ProcessorNode> runningProcessors = 
processGroup.findAllProcessors().stream()
                 .filter(p -> p.getPhysicalScheduledState() != 
ScheduledState.STOPPED && p.getPhysicalScheduledState() != 
ScheduledState.DISABLED)
@@ -6474,7 +6485,7 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
                 
.localNodeOrdinal(clusterTopologyProvider.getLocalNodeOrdinal())
                 .build();
 
-        return buildFlowSnapshot(processGroup, processGroupId, 
includeReferencedServices, mappingOptions);
+        return buildFlowSnapshot(processGroup, processGroup.getIdentifier(), 
includeReferencedServices, mappingOptions);
     }
 
     private Set<String> getAllSubGroups(ProcessGroup processGroup) {
@@ -6487,7 +6498,7 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
 
     private RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(final 
String processGroupId, final boolean includeReferencedControllerServices) {
         final ProcessGroup processGroup = 
processGroupDAO.getProcessGroup(processGroupId);
-        return buildFlowSnapshot(processGroup, processGroupId, 
includeReferencedControllerServices, null);
+        return getCurrentFlowSnapshot(processGroup, 
includeReferencedControllerServices, false, false);
     }
 
     private RegisteredFlowSnapshot buildFlowSnapshot(final ProcessGroup 
processGroup, final String processGroupId,
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java
index 160a89c2607..fe386e57441 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java
@@ -2380,6 +2380,68 @@ public class ConnectorResource extends 
ApplicationResource {
         return generateOkResponse(entity).build();
     }
 
+    /**
+     * Gets a Process Group within a Connector as a flow definition download. 
Available regardless of whether the
+     * Connector is in Troubleshooting mode.
+     *
+     * @param connectorId the connector id
+     * @param processGroupId the process group id within the connector's 
managed flow
+     * @param includeReferencedServices whether referenced services from 
outside the target group should be included
+     * @param includeComponentState whether component state should be included
+     * @return the flow definition
+     */
+    @GET
+    @Consumes(MediaType.WILDCARD)
+    @Produces(MediaType.APPLICATION_JSON)
+    @Path("{id}/process-groups/{processGroupId}/download")
+    @Operation(
+            summary = "Gets a Process Group within a Connector for download",
+            responses = {
+                    @ApiResponse(responseCode = "200", content = 
@Content(schema = @Schema(implementation = String.class))),
+                    @ApiResponse(responseCode = "400", description = "NiFi was 
unable to complete the request because it was invalid. The request should not 
be retried without modification."),
+                    @ApiResponse(responseCode = "401", description = "Client 
could not be authenticated."),
+                    @ApiResponse(responseCode = "403", description = "Client 
is not authorized to make this request."),
+                    @ApiResponse(responseCode = "404", description = "The 
specified resource could not be found."),
+                    @ApiResponse(responseCode = "409", description = "The 
request was valid but NiFi was not in the appropriate state to process it.")
+            },
+            security = {
+                    @SecurityRequirement(name = "Read - /connectors/{uuid}"),
+                    @SecurityRequirement(name = "Write - /connectors/{uuid} - 
Only required when includeComponentState is true")
+            },
+            description = "Returns a flow definition for the specified Process 
Group within the Connector's managed flow. "
+                    + "Available regardless of whether the Connector is in 
Troubleshooting mode."
+    )
+    public Response exportConnectorProcessGroup(
+            @Parameter(description = "The connector id.", required = true)
+            @PathParam("id") final String connectorId,
+            @Parameter(description = "The process group id.", required = true)
+            @PathParam("processGroupId") final String processGroupId,
+            @Parameter(description = "Whether referenced services from outside 
the target group should be included")
+            @QueryParam("includeReferencedServices")
+            @DefaultValue("false") final boolean includeReferencedServices,
+            @Parameter(description = "Whether component state should be 
included. Requires all processors to be stopped and all controller services to 
be disabled.")
+            @QueryParam("includeComponentState")
+            @DefaultValue("false") final boolean includeComponentState) {
+
+        if (includeComponentState && isReplicateRequest()) {
+            return replicate(HttpMethod.GET);
+        }
+
+        final RequestAction requiredAction = includeComponentState ? 
RequestAction.WRITE : RequestAction.READ;
+        serviceFacade.authorizeAccess(lookup -> {
+            final Authorizable connector = lookup.getConnector(connectorId);
+            connector.authorize(authorizer, requiredAction, 
NiFiUserUtils.getNiFiUser());
+        });
+
+        final RegisteredFlowSnapshot flowSnapshot = 
serviceFacade.getConnectorCurrentFlowSnapshot(
+                connectorId, processGroupId, includeReferencedServices, 
includeComponentState);
+        final String filename = 
flowSnapshot.getFlowContents().getName().replaceAll("\\s", "_") + ".json";
+
+        return generateOkResponse(flowSnapshot)
+                .header(HttpHeaders.CONTENT_DISPOSITION, 
String.format("attachment; filename=\"%s\"", filename))
+                .build();
+    }
+
     /**
      * Gets the status history for a Process Group inside the Connector's 
managed flow. The status history is available
      * regardless of whether the Connector is in Troubleshooting mode.
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
index 1290d210b10..95f3c6a599b 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
@@ -72,6 +72,7 @@ import org.apache.nifi.controller.ScheduledState;
 import org.apache.nifi.controller.flow.FlowManager;
 import org.apache.nifi.controller.service.ControllerServiceNode;
 import org.apache.nifi.controller.service.ControllerServiceProvider;
+import org.apache.nifi.controller.service.ControllerServiceState;
 import org.apache.nifi.controller.status.ProcessGroupStatus;
 import org.apache.nifi.flow.ExecutionEngine;
 import org.apache.nifi.flow.ExternalControllerServiceReference;
@@ -797,6 +798,92 @@ public class StandardNiFiServiceFacadeTest {
         assertNull(versionedFlowSnapshot.getSnapshotMetadata());
     }
 
+    @Test
+    public void testGetConnectorCurrentFlowSnapshot() {
+        final String connectorId = UUID.randomUUID().toString();
+        final String groupId = UUID.randomUUID().toString();
+        final ConnectorDAO connectorDAO = mock(ConnectorDAO.class);
+        serviceFacade.setConnectorDAO(connectorDAO);
+
+        final ConnectorNode connectorNode = mock(ConnectorNode.class);
+        final FrameworkFlowContext flowContext = 
mock(FrameworkFlowContext.class);
+        final ProcessGroup managedGroup = mock(ProcessGroup.class);
+        final ProcessGroup processGroup = mock(ProcessGroup.class);
+        when(connectorDAO.getConnector(connectorId, 
ConnectorSyncMode.LOCAL_ONLY)).thenReturn(connectorNode);
+        when(connectorNode.getActiveFlowContext()).thenReturn(flowContext);
+        when(flowContext.getManagedProcessGroup()).thenReturn(managedGroup);
+        when(managedGroup.findProcessGroup(groupId)).thenReturn(processGroup);
+        when(processGroup.getIdentifier()).thenReturn(groupId);
+
+        final ExtensionManager extensionManager = mock(ExtensionManager.class);
+        
when(flowController.getExtensionManager()).thenReturn(extensionManager);
+        final ControllerServiceProvider controllerServiceProvider = 
mock(ControllerServiceProvider.class);
+        
when(flowController.getControllerServiceProvider()).thenReturn(controllerServiceProvider);
+
+        final StandardNiFiServiceFacade serviceFacadeSpy = spy(serviceFacade);
+        final VersionedComponentFlowMapper flowMapper = 
mock(VersionedComponentFlowMapper.class);
+        
when(serviceFacadeSpy.makeNiFiRegistryFlowMapper(extensionManager)).thenReturn(flowMapper);
+        final InstantiatedVersionedProcessGroup nonVersionedProcessGroup = 
mock(InstantiatedVersionedProcessGroup.class);
+        when(flowMapper.mapNonVersionedProcessGroup(processGroup, 
controllerServiceProvider)).thenReturn(nonVersionedProcessGroup);
+
+        final RegisteredFlowSnapshot flowSnapshot =
+                serviceFacadeSpy.getConnectorCurrentFlowSnapshot(connectorId, 
groupId, false, false);
+
+        assertEquals(nonVersionedProcessGroup, flowSnapshot.getFlowContents());
+        verify(connectorDAO).getConnector(connectorId, 
ConnectorSyncMode.LOCAL_ONLY);
+        verify(processGroupDAO, never()).getProcessGroup(anyString());
+    }
+
+    @Test
+    public void 
testGetConnectorCurrentFlowSnapshotRejectsProcessGroupOutsideConnector() {
+        final String connectorId = UUID.randomUUID().toString();
+        final String groupId = UUID.randomUUID().toString();
+        final ConnectorDAO connectorDAO = mock(ConnectorDAO.class);
+        serviceFacade.setConnectorDAO(connectorDAO);
+
+        final ConnectorNode connectorNode = mock(ConnectorNode.class);
+        final FrameworkFlowContext flowContext = 
mock(FrameworkFlowContext.class);
+        final ProcessGroup managedGroup = mock(ProcessGroup.class);
+        when(connectorDAO.getConnector(connectorId, 
ConnectorSyncMode.LOCAL_ONLY)).thenReturn(connectorNode);
+        when(connectorNode.getActiveFlowContext()).thenReturn(flowContext);
+        when(flowContext.getManagedProcessGroup()).thenReturn(managedGroup);
+
+        assertThrows(ResourceNotFoundException.class,
+                () -> 
serviceFacade.getConnectorCurrentFlowSnapshot(connectorId, groupId, false, 
false));
+        verify(processGroupDAO, never()).getProcessGroup(anyString());
+    }
+
+    @Test
+    public void testGetConnectorCurrentFlowSnapshotValidatesComponentState() {
+        final String connectorId = UUID.randomUUID().toString();
+        final String groupId = UUID.randomUUID().toString();
+        final ConnectorDAO connectorDAO = mock(ConnectorDAO.class);
+        serviceFacade.setConnectorDAO(connectorDAO);
+
+        final ConnectorNode connectorNode = mock(ConnectorNode.class);
+        final FrameworkFlowContext flowContext = 
mock(FrameworkFlowContext.class);
+        final ProcessGroup managedGroup = mock(ProcessGroup.class);
+        final ProcessGroup processGroup = mock(ProcessGroup.class);
+        when(connectorDAO.getConnector(connectorId, 
ConnectorSyncMode.LOCAL_ONLY)).thenReturn(connectorNode);
+        when(connectorNode.getActiveFlowContext()).thenReturn(flowContext);
+        when(flowContext.getManagedProcessGroup()).thenReturn(managedGroup);
+        when(managedGroup.findProcessGroup(groupId)).thenReturn(processGroup);
+
+        final ProcessorNode processor = mock(ProcessorNode.class);
+        
when(processor.getPhysicalScheduledState()).thenReturn(ScheduledState.RUNNING, 
ScheduledState.STOPPED);
+        when(processGroup.findAllProcessors()).thenReturn(List.of(processor));
+
+        assertThrows(IllegalStateException.class,
+                () -> 
serviceFacade.getConnectorCurrentFlowSnapshot(connectorId, groupId, false, 
true));
+
+        final ControllerServiceNode controllerService = 
mock(ControllerServiceNode.class);
+        
when(controllerService.getState()).thenReturn(ControllerServiceState.ENABLED);
+        
when(processGroup.findAllControllerServices()).thenReturn(Set.of(controllerService));
+
+        assertThrows(IllegalStateException.class,
+                () -> 
serviceFacade.getConnectorCurrentFlowSnapshot(connectorId, groupId, false, 
true));
+    }
+
     @Test
     public void 
testGetCurrentFlowSnapshotMapsAssetReferencesOnlyWhenRequested() {
         // Asset references are NiFi-internal identifiers that are meaningful 
only on this instance, so the general
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java
index c5aeb7fcd90..50ff6617900 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java
@@ -34,6 +34,8 @@ import org.apache.nifi.authorization.user.NiFiUserDetails;
 import org.apache.nifi.authorization.user.StandardNiFiUser;
 import org.apache.nifi.components.BacklogReportingException;
 import org.apache.nifi.controller.ScheduledState;
+import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
 import org.apache.nifi.util.NiFiProperties;
 import org.apache.nifi.web.NiFiServiceFacade;
 import org.apache.nifi.web.Revision;
@@ -1182,6 +1184,54 @@ public class TestConnectorResource {
         verify(serviceFacade, never()).getConnectorProcessGroup(anyString(), 
anyString());
     }
 
+    @Test
+    public void testExportConnectorProcessGroup() {
+        final AuthorizableLookup lookup = wireMigrationAuthorization();
+        final Authorizable connector = lookup.getConnector(CONNECTOR_ID);
+        final VersionedProcessGroup processGroup = new VersionedProcessGroup();
+        processGroup.setName("Test Process Group");
+        final RegisteredFlowSnapshot flowSnapshot = new 
RegisteredFlowSnapshot();
+        flowSnapshot.setFlowContents(processGroup);
+        when(serviceFacade.getConnectorCurrentFlowSnapshot(CONNECTOR_ID, 
PROCESS_GROUP_ID, true, false)).thenReturn(flowSnapshot);
+
+        try (Response response = 
connectorResource.exportConnectorProcessGroup(CONNECTOR_ID, PROCESS_GROUP_ID, 
true, false)) {
+            assertEquals(200, response.getStatus());
+            assertEquals(flowSnapshot, response.getEntity());
+            assertEquals("attachment; filename=\"Test_Process_Group.json\"", 
response.getHeaderString("Content-Disposition"));
+        }
+
+        verify(connector).authorize(eq(authorizer), eq(RequestAction.READ), 
any());
+        verify(serviceFacade).getConnectorCurrentFlowSnapshot(CONNECTOR_ID, 
PROCESS_GROUP_ID, true, false);
+    }
+
+    @Test
+    public void 
testExportConnectorProcessGroupWithComponentStateRequiresWrite() {
+        final AuthorizableLookup lookup = wireMigrationAuthorization();
+        final Authorizable connector = lookup.getConnector(CONNECTOR_ID);
+        final VersionedProcessGroup processGroup = new VersionedProcessGroup();
+        processGroup.setName("Test Process Group");
+        final RegisteredFlowSnapshot flowSnapshot = new 
RegisteredFlowSnapshot();
+        flowSnapshot.setFlowContents(processGroup);
+        when(serviceFacade.getConnectorCurrentFlowSnapshot(CONNECTOR_ID, 
PROCESS_GROUP_ID, false, true)).thenReturn(flowSnapshot);
+
+        try (Response response = 
connectorResource.exportConnectorProcessGroup(CONNECTOR_ID, PROCESS_GROUP_ID, 
false, true)) {
+            assertEquals(200, response.getStatus());
+        }
+
+        verify(connector).authorize(eq(authorizer), eq(RequestAction.WRITE), 
any());
+        verify(serviceFacade).getConnectorCurrentFlowSnapshot(CONNECTOR_ID, 
PROCESS_GROUP_ID, false, true);
+    }
+
+    @Test
+    public void testExportConnectorProcessGroupNotAuthorized() {
+        
doThrow(AccessDeniedException.class).when(serviceFacade).authorizeAccess(any(AuthorizeAccess.class));
+
+        assertThrows(AccessDeniedException.class, () ->
+                connectorResource.exportConnectorProcessGroup(CONNECTOR_ID, 
PROCESS_GROUP_ID, false, false));
+
+        verify(serviceFacade, 
never()).getConnectorCurrentFlowSnapshot(anyString(), anyString(), eq(false), 
eq(false));
+    }
+
     @Test
     public void testGetConnectorControllerServiceState() {
         final ComponentStateDTO stateDTO = new ComponentStateDTO();
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.spec.ts
new file mode 100644
index 00000000000..361eefc1958
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.spec.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 { provideHttpClient } from '@angular/common/http';
+import { TestBed } from '@angular/core/testing';
+import { UnsafeApiPathError } from '@nifi/shared';
+import { Client } from '../../../service/client.service';
+import { ClusterConnectionService } from 
'../../../service/cluster-connection.service';
+import { ConnectorService } from './connector.service';
+
+describe('ConnectorService', () => {
+    async function setup() {
+        await TestBed.configureTestingModule({
+            providers: [
+                ConnectorService,
+                provideHttpClient(),
+                { provide: Client, useValue: {} },
+                { provide: ClusterConnectionService, useValue: {} }
+            ]
+        }).compileComponents();
+
+        return {
+            service: TestBed.inject(ConnectorService),
+            open: vi.spyOn(window, 'open').mockImplementation(() => null)
+        };
+    }
+
+    afterEach(() => {
+        vi.restoreAllMocks();
+    });
+
+    it('should encode Connector and Process Group identifiers in the download 
URL', async () => {
+        const { service, open } = await setup();
+
+        service.downloadFlow({
+            connectorId: 'connector id',
+            processGroupId: 'process?group',
+            includeReferencedServices: true
+        });
+
+        expect(open).toHaveBeenCalledWith(
+            
'../nifi-api/connectors/connector%20id/process-groups/process%3Fgroup/download?includeReferencedServices=true',
+            '_blank',
+            'noreferrer'
+        );
+    });
+
+    it.each([
+        ['Connector', '..%2F..%2Fnifi-api%2Fcontroller', 'process-group'],
+        ['Process Group', 'connector', '../../controller']
+    ])(
+        'should reject a path-traversal %s identifier without opening a 
window',
+        async (_segment, connectorId, processGroupId) => {
+            const { service, open } = await setup();
+
+            expect(() =>
+                service.downloadFlow({
+                    connectorId,
+                    processGroupId,
+                    includeReferencedServices: false
+                })
+            ).toThrow(UnsafeApiPathError);
+            expect(open).not.toHaveBeenCalled();
+        }
+    );
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
index 3ffba043cd5..19f74d42c60 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
@@ -22,7 +22,7 @@ import { HttpClient } from '@angular/common/http';
 import { Client } from '../../../service/client.service';
 import { ClusterConnectionService } from 
'../../../service/cluster-connection.service';
 import { ChangeConnectorVersionRequest, ConnectorsResponse, 
CreateConnectorRequest } from '../state';
-import { ConnectorEntity, ProcessGroupEntity } from '@nifi/shared';
+import { ConnectorEntity, ProcessGroupEntity, safeApiPath } from 
'@nifi/shared';
 import {
     ControllerServiceEntity,
     ParameterContextEntity,
@@ -30,6 +30,7 @@ import {
     SearchResultsEntity
 } from '../../../state/shared';
 import { DropRequestEntity } from '../../../state/empty-queue';
+import type { DownloadConnectorFlowRequest } from 
'../state/connector-canvas/connector-canvas.actions';
 
 @Injectable({ providedIn: 'root' })
 export class ConnectorService {
@@ -138,6 +139,14 @@ export class ConnectorService {
         );
     }
 
+    downloadFlow(request: DownloadConnectorFlowRequest): void {
+        window.open(
+            
`${ConnectorService.API}/connectors/${safeApiPath(request.connectorId)}/process-groups/${safeApiPath(request.processGroupId)}/download?includeReferencedServices=${request.includeReferencedServices}`,
+            '_blank',
+            'noreferrer'
+        );
+    }
+
     getConnectorControllerServices(connectorId: string, processGroupId: 
string): Observable<any> {
         return this.httpClient.get(
             
`${ConnectorService.API}/connectors/${connectorId}/flow/process-groups/${processGroupId}/controller-services`
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
index 48f7f107d6e..20bb8331aac 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
@@ -58,6 +58,12 @@ export interface SelectComponentsRequest {
     components: SelectedComponent[];
 }
 
+export interface DownloadConnectorFlowRequest {
+    connectorId: string;
+    processGroupId: string;
+    includeReferencedServices: boolean;
+}
+
 export const loadConnectorFlow = createAction(
     '[Connector Canvas] Load Connector Flow',
     props<{ connectorId: string; processGroupId: string }>()
@@ -416,4 +422,9 @@ export const moveCanvasComponentsFailure = createAction(
     props<{ components: MoveCanvasComponentRef[]; error: string }>()
 );
 
+export const downloadFlow = createAction(
+    '[Connector Canvas] Download Flow',
+    props<{ request: DownloadConnectorFlowRequest }>()
+);
+
 export const resetConnectorCanvasState = createAction('[Connector Canvas] 
Reset State');
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
index 527a9df881d..64561fb26b7 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
@@ -59,6 +59,7 @@ import {
     createConnectionFailure,
     createConnectionRequested,
     createConnectionSuccess,
+    downloadFlow,
     enterProcessGroup,
     leaveProcessGroup,
     loadConnectorFlow,
@@ -146,6 +147,7 @@ describe('ConnectorCanvasEffects', () => {
 
         // Mock services
         const mockConnectorService = {
+            downloadFlow: vi.fn(),
             getConnectorFlow: vi.fn(),
             getProcessGroup: vi.fn(),
             getConnectorParameterContext: vi.fn(),
@@ -717,6 +719,25 @@ describe('ConnectorCanvasEffects', () => {
         });
     });
 
+    describe('downloadFlow$', () => {
+        it('should delegate the complete request to ConnectorService without 
dispatching another action', async () => {
+            const { effects, actions$, mockConnectorService } = await setup();
+            const action = downloadFlow({
+                request: {
+                    connectorId: 'connector-1',
+                    processGroupId: 'process-group-1',
+                    includeReferencedServices: true
+                }
+            });
+            actions$(of(action));
+
+            const result = await firstValueFrom(effects.downloadFlow$);
+
+            
expect(mockConnectorService.downloadFlow).toHaveBeenCalledWith(action.request);
+            expect(result).toBe(action);
+        });
+    });
+
     describe('enterProcessGroup$', () => {
         it('should navigate to the child process group canvas URL', async () 
=> {
             const { effects, actions$, mockRouter } = await setup({ 
connectorId: 'my-connector' });
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
index b1609dd5ce6..af00b696be2 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
@@ -239,6 +239,15 @@ export class ConnectorCanvasEffects {
         )
     );
 
+    downloadFlow$ = createEffect(
+        () =>
+            this.actions$.pipe(
+                ofType(ConnectorCanvasActions.downloadFlow),
+                tap(({ request }) => 
this.connectorService.downloadFlow(request))
+            ),
+        { dispatch: false }
+    );
+
     loadConnectorFlowComplete$ = createEffect(() =>
         this.actions$.pipe(
             ofType(ConnectorCanvasActions.loadConnectorFlowSuccess),
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
index 8502f4384a1..6e2ac199874 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
@@ -53,6 +53,7 @@ import {
 import { selectParentProcessGroupId } from 
'../../state/connector-canvas/connector-canvas.selectors';
 import {
     deselectAllComponents,
+    downloadFlow,
     enterProcessGroup,
     leaveProcessGroup,
     loadConnectorFlow,
@@ -290,6 +291,7 @@ interface SetupOptions {
     connectedToCluster?: boolean;
     contentViewerUrl?: string | null;
     connections?: ReturnType<typeof makeConnection>[];
+    connectorCanRead?: boolean;
 }
 
 const DEFAULT_CONNECTOR_ID = 'connector-1';
@@ -348,7 +350,29 @@ function buildMockSelectors(options: SetupOptions = {}) {
         { selector: selectUrl, value: url },
         { selector: selectRouteParams, value: routeParamsValue },
         { selector: selectCurrentUser, value: 
buildMockCurrentUser(canAccessProvenance) },
-        { selector: selectConnectorCanvasEntity, value: null },
+        {
+            selector: selectConnectorCanvasEntity,
+            value:
+                options.connectorCanRead === undefined
+                    ? null
+                    : {
+                          id: DEFAULT_CONNECTOR_ID,
+                          uri: `/connectors/${DEFAULT_CONNECTOR_ID}`,
+                          revision: { version: 1 },
+                          permissions: { canRead: options.connectorCanRead, 
canWrite: false },
+                          bulletins: [],
+                          status: {} as never,
+                          component: {
+                              id: DEFAULT_CONNECTOR_ID,
+                              name: 'Connector',
+                              type: 'type',
+                              state: 'STOPPED',
+                              bundle: { group: 'group', artifact: 'artifact', 
version: '1' },
+                              availableActions: [],
+                              managedProcessGroupId: DEFAULT_PROCESS_GROUP_ID
+                          }
+                      }
+        },
         { selector: selectConnectorCanvasEntitySaving, value: false },
         { selector: selectConnectorProvenanceEvents, value: 
options.provenanceEvents ?? [] },
         { selector: selectConnectorProvenanceStatus, value: 
options.provenanceStatus ?? 'pending' },
@@ -1086,6 +1110,17 @@ describe('ConnectorCanvasComponent', () => {
                 expect(result).toBeUndefined();
             });
 
+            it('should return download menu variants', () => {
+                const { component } = setup();
+                const menu = component.contextMenuProvider.getMenu('download');
+
+                expect(menu).toBeDefined();
+                expect(menu!.menuItems.map((item) => item.text)).toEqual([
+                    'Without External Services',
+                    'With External Services'
+                ]);
+            });
+
             it('should return canvas menu with Refresh, Leave Group, and Empty 
All Queues when targetType is canvas', fakeAsync(() => {
                 const { fixture, component } = setup({ parentProcessGroupId: 
'parent-pg' });
                 fixture.detectChanges();
@@ -1101,6 +1136,7 @@ describe('ConnectorCanvasComponent', () => {
                     'Refresh',
                     'Leave Group',
                     'Controller Services',
+                    'Download Flow Definition',
                     'Empty All Queues',
                     'Drain',
                     'Cancel Drain'
@@ -1127,6 +1163,7 @@ describe('ConnectorCanvasComponent', () => {
                     'Empty Queue',
                     'Empty All Queues',
                     'Controller Services',
+                    'Download Flow Definition',
                     'Center In View'
                 ]);
             }));
@@ -1139,6 +1176,106 @@ describe('ConnectorCanvasComponent', () => {
             });
         });
 
+        describe('download menu', () => {
+            it('should show Download Flow Definition on the canvas when the 
Connector is readable', fakeAsync(() => {
+                const { fixture, component } = setup({ connectorCanRead: true 
});
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(buildCanvasContext());
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const download = menu.menuItems.find((item) => item.text === 
'Download Flow Definition');
+
+                expect(download).toBeDefined();
+                expect(download!.condition!(null)).toBe(true);
+            }));
+
+            it('should hide Download Flow Definition when the Connector is not 
readable', fakeAsync(() => {
+                const { fixture, component } = setup({ connectorCanRead: false 
});
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(buildCanvasContext());
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const download = menu.menuItems.find((item) => item.text === 
'Download Flow Definition');
+
+                expect(download).toBeDefined();
+                expect(download!.condition!(null)).toBe(false);
+            }));
+
+            it('should show Download Flow Definition for a single readable 
Process Group', fakeAsync(() => {
+                const { fixture, component } = setup({ connectorCanRead: true 
});
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.ProcessGroup, 
'pg-child'));
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const download = menu.menuItems.find((item) => item.text === 
'Download Flow Definition');
+
+                expect(download).toBeDefined();
+                expect(download!.condition!(null)).toBe(true);
+            }));
+
+            it('should show Download Flow Definition for an unreadable Process 
Group when the Connector is readable', fakeAsync(() => {
+                const { fixture, component } = setup({ connectorCanRead: true 
});
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(
+                    buildComponentContext(ComponentType.ProcessGroup, 
'pg-child', 1, {
+                        permissions: { canRead: false, canWrite: false }
+                    })
+                );
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const download = menu.menuItems.find((item) => item.text === 
'Download Flow Definition');
+
+                expect(download).toBeDefined();
+                expect(download!.condition!(null)).toBe(true);
+            }));
+
+            it('should dispatch download for the current Process Group without 
external services', fakeAsync(() => {
+                const { fixture, component, dispatchSpy } = setup({ 
connectorCanRead: true });
+                fixture.detectChanges();
+                tick();
+                dispatchSpy.mockClear();
+
+                component.onContextMenuOpened(buildCanvasContext());
+                const menu = 
component.contextMenuProvider.getMenu('download')!;
+                menu.menuItems.find((item) => item.text === 'Without External 
Services')!.action!(null);
+
+                expect(dispatchSpy).toHaveBeenCalledWith(
+                    downloadFlow({
+                        request: {
+                            connectorId: DEFAULT_CONNECTOR_ID,
+                            processGroupId: DEFAULT_PROCESS_GROUP_ID,
+                            includeReferencedServices: false
+                        }
+                    })
+                );
+            }));
+
+            it('should dispatch download for the selected Process Group with 
external services', fakeAsync(() => {
+                const { fixture, component, dispatchSpy } = setup({ 
connectorCanRead: true });
+                fixture.detectChanges();
+                tick();
+                dispatchSpy.mockClear();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.ProcessGroup, 
'pg-child'));
+                const menu = 
component.contextMenuProvider.getMenu('download')!;
+                menu.menuItems.find((item) => item.text === 'With External 
Services')!.action!(null);
+
+                expect(dispatchSpy).toHaveBeenCalledWith(
+                    downloadFlow({
+                        request: {
+                            connectorId: DEFAULT_CONNECTOR_ID,
+                            processGroupId: 'pg-child',
+                            includeReferencedServices: true
+                        }
+                    })
+                );
+            }));
+        });
+
         describe('canvas menu conditions', () => {
             it('should always show Refresh', fakeAsync(() => {
                 const { fixture, component } = setup();
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
index ea9778d43c1..617f4962494 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
@@ -214,6 +214,24 @@ export class ConnectorCanvasComponent implements OnInit, 
OnDestroy {
 
     contextMenuProvider: ContextMenuDefinitionProvider = {
         getMenu: (menuId: string): ContextMenuDefinition | undefined => {
+            if (menuId === 'download') {
+                return {
+                    id: menuId,
+                    menuItems: [
+                        {
+                            text: 'Without External Services',
+                            clazz: 'fa',
+                            action: () => this.downloadFlowAction(false)
+                        },
+                        {
+                            text: 'With External Services',
+                            clazz: 'fa',
+                            action: () => this.downloadFlowAction(true)
+                        }
+                    ]
+                };
+            }
+
             if (menuId !== 'root') {
                 return undefined;
             }
@@ -244,6 +262,12 @@ export class ConnectorCanvasComponent implements OnInit, 
OnDestroy {
                         condition: () => !!this.currentProcessGroupId,
                         action: () => 
this.controllerServicesAction(this.currentProcessGroupId!)
                     },
+                    {
+                        text: 'Download Flow Definition',
+                        clazz: 'fa',
+                        subMenuId: 'download',
+                        condition: () => this.canDownloadFlow()
+                    },
                     { isSeparator: true },
                     {
                         text: 'Empty All Queues',
@@ -338,6 +362,12 @@ export class ConnectorCanvasComponent implements OnInit, 
OnDestroy {
                         condition: () => isProcessGroup && isSingleSelection,
                         action: () => 
this.controllerServicesAction(clicked!.entity.id)
                     },
+                    {
+                        text: 'Download Flow Definition',
+                        clazz: 'fa',
+                        subMenuId: 'download',
+                        condition: () => this.canDownloadFlow()
+                    },
                     { isSeparator: true },
                     this.getCenterInViewMenuItem()
                 ];
@@ -1039,6 +1069,41 @@ export class ConnectorCanvasComponent implements OnInit, 
OnDestroy {
         
this.store.dispatch(ConnectorCanvasActions.navigateToControllerServices({ 
processGroupId }));
     }
 
+    downloadFlowAction(includeReferencedServices: boolean): void {
+        const processGroupId = this.getDownloadProcessGroupId();
+        if (this.currentConnectorId && processGroupId) {
+            this.store.dispatch(
+                ConnectorCanvasActions.downloadFlow({
+                    request: {
+                        connectorId: this.currentConnectorId,
+                        processGroupId,
+                        includeReferencedServices
+                    }
+                })
+            );
+        }
+    }
+
+    private canDownloadFlow(): boolean {
+        return this.connectorEntity()?.permissions.canRead === true && 
this.getDownloadProcessGroupId() !== null;
+    }
+
+    private getDownloadProcessGroupId(): string | null {
+        const context = this.currentContextMenuContext;
+        if (context?.targetType === 'canvas') {
+            return this.currentProcessGroupId;
+        }
+
+        if (context?.targetType === 'component') {
+            const clicked = context.clickedComponent;
+            if (context.selectedComponents.length <= 1 && 
clicked?.ui.componentType === ComponentType.ProcessGroup) {
+                return clicked.entity.id;
+            }
+        }
+
+        return null;
+    }
+
     canDrain(): boolean {
         const entity = this.connectorEntity();
         if (!entity || this.entitySaving()) {
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorTroubleshootingIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorTroubleshootingIT.java
index 5d60a51feb2..cecc4266440 100644
--- 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorTroubleshootingIT.java
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorTroubleshootingIT.java
@@ -289,6 +289,37 @@ public class ConnectorTroubleshootingIT extends 
NiFiSystemIT {
         
getNifiClient().getProcessorClient().deleteProcessor(refreshedForDelete);
     }
 
+    /**
+     * Verifies Connector-scoped flow definition export outside 
Troubleshooting for the managed root and a child
+     * Process Group.
+     */
+    @Test
+    public void testExportProcessGroupOutsideTroubleshooting() throws 
NiFiClientException, IOException, InterruptedException {
+        final ConnectorEntity connector = 
getClientUtil().createConnector("ComponentLifecycleConnector");
+        final String connectorId = connector.getId();
+
+        getClientUtil().applyConnectorUpdate(connector);
+        getClientUtil().waitForValidConnector(connectorId);
+        assertConnectorState(connectorId, ConnectorState.STOPPED);
+
+        final String managedGroupId = 
getNifiClient().getConnectorClient().getConnector(connectorId).getComponent().getManagedProcessGroupId();
+        final String childGroupId = findFirstChildProcessGroupId(connectorId);
+        assertNotNull(childGroupId, "Managed flow should contain a child 
Process Group");
+
+        final File managedGroupExport = 
Files.createTempFile("connector-managed-group-", ".json").toFile();
+        final File childGroupExport = 
Files.createTempFile("connector-child-group-", ".json").toFile();
+        try {
+            
getNifiClient().getConnectorClient().exportProcessGroup(connectorId, 
managedGroupId, false, managedGroupExport);
+            
getNifiClient().getConnectorClient().exportProcessGroup(connectorId, 
childGroupId, true, childGroupExport);
+
+            assertTrue(managedGroupExport.length() > 0);
+            assertTrue(childGroupExport.length() > 0);
+        } finally {
+            Files.deleteIfExists(managedGroupExport.toPath());
+            Files.deleteIfExists(childGroupExport.toPath());
+        }
+    }
+
     /**
      * Stop a connector, enter Troubleshooting, start processors inside the 
managed flow, restart NiFi, and ensure the
      * processors are still running and the Connector remains in 
Troubleshooting.
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ConnectorClient.java
 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ConnectorClient.java
index bcf3019d4ee..7946c203de2 100644
--- 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ConnectorClient.java
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ConnectorClient.java
@@ -341,6 +341,35 @@ public interface ConnectorClient {
      */
     ProcessGroupEntity getProcessGroup(String connectorId, String 
processGroupId) throws NiFiClientException, IOException;
 
+    /**
+     * Exports a Process Group within a Connector's managed flow.
+     *
+     * @param connectorId the connector ID
+     * @param processGroupId the process group ID within the connector's 
managed flow
+     * @param includeReferencedServices whether referenced services should be 
included
+     * @param outputFile the file to write
+     * @return the output file
+     * @throws NiFiClientException if an error occurs during the request
+     * @throws IOException if an I/O error occurs
+     */
+    File exportProcessGroup(String connectorId, String processGroupId, boolean 
includeReferencedServices, File outputFile)
+            throws NiFiClientException, IOException;
+
+    /**
+     * Exports a Process Group within a Connector's managed flow.
+     *
+     * @param connectorId the connector ID
+     * @param processGroupId the process group ID within the connector's 
managed flow
+     * @param includeReferencedServices whether referenced services should be 
included
+     * @param includeComponentState whether component state should be included
+     * @param outputFile the file to write
+     * @return the output file
+     * @throws NiFiClientException if an error occurs during the request
+     * @throws IOException if an I/O error occurs
+     */
+    File exportProcessGroup(String connectorId, String processGroupId, boolean 
includeReferencedServices,
+            boolean includeComponentState, File outputFile) throws 
NiFiClientException, IOException;
+
     /**
      * Gets the status for the process group managed by a connector.
      *
diff --git 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
index db57054154e..fe63bac6b0c 100644
--- 
a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
+++ 
b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
@@ -16,6 +16,7 @@
  */
 package org.apache.nifi.toolkit.client.impl;
 
+import jakarta.ws.rs.WebApplicationException;
 import jakarta.ws.rs.client.Entity;
 import jakarta.ws.rs.client.WebTarget;
 import jakarta.ws.rs.core.MediaType;
@@ -608,6 +609,49 @@ public class JerseyConnectorClient extends 
AbstractJerseyClient implements Conne
         });
     }
 
+    @Override
+    public File exportProcessGroup(final String connectorId, final String 
processGroupId, final boolean includeReferencedServices,
+            final File outputFile) throws NiFiClientException, IOException {
+        return exportProcessGroup(connectorId, processGroupId, 
includeReferencedServices, false, outputFile);
+    }
+
+    @Override
+    public File exportProcessGroup(final String connectorId, final String 
processGroupId, final boolean includeReferencedServices,
+            final boolean includeComponentState, final File outputFile) throws 
NiFiClientException, IOException {
+        if (StringUtils.isBlank(connectorId)) {
+            throw new IllegalArgumentException("Connector id cannot be null or 
blank");
+        }
+        if (StringUtils.isBlank(processGroupId)) {
+            throw new IllegalArgumentException("Process group id cannot be 
null or blank");
+        }
+
+        return executeAction("Error exporting process group for Connector " + 
connectorId, () -> {
+            final WebTarget target = connectorTarget
+                    .path("/process-groups/{processGroupId}/download")
+                    .resolveTemplate("id", connectorId)
+                    .resolveTemplate("processGroupId", processGroupId)
+                    .queryParam("includeReferencedServices", 
includeReferencedServices)
+                    .queryParam("includeComponentState", 
includeComponentState);
+
+            final Response response = getRequestBuilder(target)
+                    .accept(MediaType.APPLICATION_JSON)
+                    .get();
+            if (response.getStatusInfo().getFamily() != 
Response.Status.Family.SUCCESSFUL) {
+                throw new WebApplicationException(response);
+            }
+
+            final String contentDispositionHeader = 
response.getHeaderString("Content-Disposition");
+            if (StringUtils.isBlank(contentDispositionHeader)) {
+                throw new IllegalStateException("Content-Disposition header 
was blank or missing");
+            }
+
+            try (final InputStream responseInputStream = 
response.readEntity(InputStream.class)) {
+                Files.copy(responseInputStream, outputFile.toPath(), 
StandardCopyOption.REPLACE_EXISTING);
+                return outputFile;
+            }
+        });
+    }
+
     @Override
     public ProcessGroupStatusEntity getStatus(final String connectorId, final 
boolean recursive) throws NiFiClientException, IOException {
         Objects.requireNonNull(connectorId, "Connector ID required");

Reply via email to