You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by ex...@apache.org on 2025/11/04 21:58:52 UTC
(nifi) branch main updated: NIFI-14996 Added support to Clear Component Bulletins (#10409)
This is an automated email from the ASF dual-hosted git repository.
exceptionfactory 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 2ee74101c5 NIFI-14996 Added support to Clear Component Bulletins (#10409)
2ee74101c5 is described below
commit 2ee74101c58e0831acbcef233cecef3ac718400a
Author: Matt Gilman <ma...@gmail.com>
AuthorDate: Tue Nov 4 16:58:47 2025 -0500
NIFI-14996 Added support to Clear Component Bulletins (#10409)
Signed-off-by: David Handermann <ex...@apache.org>
---
.../org/apache/nifi/web/api/dto/BulletinDTO.java | 18 +
.../nifi/web/api/dto/util/InstantAdapter.java | 2 -
.../apache/nifi/web/api/entity/BulletinEntity.java | 18 +
.../ClearBulletinsForGroupRequestEntity.java | 78 ++
.../ClearBulletinsForGroupResultsEntity.java} | 28 +-
.../api/entity/ClearBulletinsRequestEntity.java | 50 ++
.../web/api/entity/ClearBulletinsResultEntity.java | 69 ++
.../http/StandardHttpResponseMapper.java | 4 +
.../endpoints/ClearBulletinsEndpointMerger.java | 84 +++
.../ClearBulletinsForGroupEndpointMerger.java | 52 ++
.../ClearBulletinsEndpointMergerTest.java | 114 +++
.../ClearBulletinsForGroupEndpointMergerTest.java | 74 ++
.../nifi/events/VolatileBulletinRepository.java | 49 ++
.../events/VolatileBulletinRepositoryTest.java | 270 +++++++
.../org/apache/nifi/web/NiFiServiceFacade.java | 36 +
.../apache/nifi/web/StandardNiFiServiceFacade.java | 56 +-
.../apache/nifi/web/api/ControllerResource.java | 202 ++++-
.../nifi/web/api/ControllerServiceResource.java | 74 ++
.../java/org/apache/nifi/web/api/FlowResource.java | 126 +++-
.../org/apache/nifi/web/api/InputPortResource.java | 70 ++
.../apache/nifi/web/api/OutputPortResource.java | 70 ++
.../nifi/web/api/ParameterProviderResource.java | 74 ++
.../org/apache/nifi/web/api/ProcessorResource.java | 74 ++
.../nifi/web/api/RemoteProcessGroupResource.java | 75 ++
.../apache/nifi/web/api/ReportingTaskResource.java | 74 ++
.../org/apache/nifi/web/api/dto/DtoFactory.java | 1 +
.../org/apache/nifi/web/api/dto/EntityFactory.java | 1 +
.../nifi/web/StandardNiFiServiceFacadeTest.java | 215 ++++++
.../service/canvas-actions.service.spec.ts | 441 +++++++++++
.../service/canvas-actions.service.ts | 101 ++-
.../service/canvas-context-menu.service.ts | 6 +
.../service/canvas-utils.service.spec.ts | 258 ++++++-
.../flow-designer/service/canvas-utils.service.ts | 6 +
.../service/controller-service.service.ts | 7 +
.../pages/flow-designer/service/flow.service.ts | 27 +-
.../controller-services.actions.ts | 12 +
.../controller-services.effects.spec.ts | 451 +++++++++++-
.../controller-services.effects.ts | 26 +
.../controller-services.reducer.spec.ts | 434 +++++++++++
.../controller-services.reducer.ts | 13 +
.../pages/flow-designer/state/flow/flow.actions.ts | 34 +-
.../flow-designer/state/flow/flow.effects.spec.ts | 94 ++-
.../pages/flow-designer/state/flow/flow.effects.ts | 67 ++
.../flow-designer/state/flow/flow.reducer.spec.ts | 355 +++++++++
.../pages/flow-designer/state/flow/flow.reducer.ts | 30 +-
.../app/pages/flow-designer/state/flow/index.ts | 14 +
.../controller-services.component.html | 5 +-
.../controller-services.component.spec.ts | 276 ++++++-
.../controller-services.component.ts | 24 +-
.../settings/service/flow-analysis-rule.service.ts | 7 +
.../management-controller-service.service.ts | 7 +
.../settings/service/parameter-provider.service.ts | 7 +
.../settings/service/registry-client.service.ts | 7 +
.../settings/service/reporting-task.service.ts | 7 +
.../flow-analysis-rules.actions.ts | 12 +-
.../flow-analysis-rules.effects.spec.ts | 638 +++++++++++++---
.../flow-analysis-rules.effects.ts | 33 +
.../flow-analysis-rules.reducer.spec.ts | 180 +++++
.../flow-analysis-rules.reducer.ts | 13 +
.../management-controller-services.actions.ts | 12 +
.../management-controller-services.effects.spec.ts | 340 ++++++---
.../management-controller-services.effects.ts | 33 +
.../management-controller-services.reducer.spec.ts | 221 ++++++
.../management-controller-services.reducer.ts | 13 +
.../settings/state/parameter-providers/index.ts | 10 +-
.../parameter-providers.actions.ts | 11 +
.../parameter-providers.effects.spec.ts | 631 +++++++++++++---
.../parameter-providers.effects.ts | 33 +
.../parameter-providers.reducer.spec.ts | 194 +++++
.../parameter-providers.reducer.ts | 15 +-
.../registry-clients/registry-clients.actions.ts | 11 +
.../registry-clients.effects.spec.ts | 562 ++++++++++----
.../registry-clients/registry-clients.effects.ts | 33 +
.../registry-clients.reducer.spec.ts | 174 +++++
.../registry-clients/registry-clients.reducer.ts | 13 +
.../reporting-tasks/reporting-tasks.actions.ts | 12 +-
.../reporting-tasks.effects.spec.ts | 638 +++++++++++++---
.../reporting-tasks/reporting-tasks.effects.ts | 33 +
.../reporting-tasks.reducer.spec.ts | 201 +++++
.../reporting-tasks/reporting-tasks.reducer.ts | 13 +
.../flow-analysis-rule-table.component.html | 6 +
.../flow-analysis-rule-table.component.spec.ts | 809 ++++++++++++++++++++-
.../flow-analysis-rule-table.component.ts | 10 +
.../flow-analysis-rules.component.html | 5 +-
.../flow-analysis-rules.component.spec.ts | 375 +++++++++-
.../flow-analysis-rules.component.ts | 24 +-
.../management-controller-services.component.html | 5 +-
...anagement-controller-services.component.spec.ts | 274 ++++++-
.../management-controller-services.component.ts | 24 +-
.../parameter-providers-table.component.html | 16 +-
.../parameter-providers-table.component.scss | 2 +-
.../parameter-providers-table.component.spec.ts | 260 ++++++-
.../parameter-providers-table.component.ts | 26 +-
.../parameter-providers.component.html | 5 +-
.../parameter-providers.component.spec.ts | 294 +++++++-
.../parameter-providers.component.ts | 23 +-
.../registry-client-table.component.html | 12 +-
.../registry-client-table.component.spec.ts | 427 ++++++++++-
.../registry-client-table.component.ts | 10 +
.../registry-clients.component.html | 3 +-
.../registry-clients.component.spec.ts | 218 +++++-
.../registry-clients/registry-clients.component.ts | 25 +-
.../reporting-task-table.component.html | 6 +
.../reporting-task-table.component.spec.ts | 666 ++++++++++++++++-
.../reporting-task-table.component.ts | 9 +
.../reporting-tasks/reporting-tasks.component.html | 3 +-
.../reporting-tasks.component.spec.ts | 392 +++++++++-
.../reporting-tasks/reporting-tasks.component.ts | 24 +-
.../apps/nifi/src/app/state/shared/index.ts | 15 +
.../controller-service-table.component.html | 6 +
.../controller-service-table.component.spec.ts | 210 +++++-
.../controller-service-table.component.ts | 10 +
.../disable-controller-service.component.spec.ts | 7 +-
.../enable-controller-service.component.spec.ts | 4 +-
.../shared/src/services/nifi-common.service.ts | 32 +-
.../main/frontend/libs/shared/src/types/index.ts | 2 +
.../apache/nifi/util/MockBulletinRepository.java | 14 +
117 files changed, 12312 insertions(+), 794 deletions(-)
diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/BulletinDTO.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/BulletinDTO.java
index 51ee6b6388..da7136dadd 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/BulletinDTO.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/BulletinDTO.java
@@ -17,10 +17,12 @@
package org.apache.nifi.web.api.dto;
import io.swagger.v3.oas.annotations.media.Schema;
+import org.apache.nifi.web.api.dto.util.InstantAdapter;
import org.apache.nifi.web.api.dto.util.TimeAdapter;
import jakarta.xml.bind.annotation.XmlType;
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import java.time.Instant;
import java.util.Date;
/**
@@ -38,6 +40,7 @@ public class BulletinDTO {
private String level;
private String message;
private Date timestamp;
+ private Instant timestampIso;
private String sourceType;
private String stackTrace;
@@ -160,6 +163,21 @@ public class BulletinDTO {
this.timestamp = timestamp;
}
+ /**
+ * @return When this bulletin was generated in ISO format with full date and milliseconds.
+ */
+ @XmlJavaTypeAdapter(InstantAdapter.class)
+ @Schema(description = "When this bulletin was generated in ISO format with full date and milliseconds.",
+ type = "string"
+ )
+ public Instant getTimestampIso() {
+ return timestampIso;
+ }
+
+ public void setTimestampIso(Instant timestampIso) {
+ this.timestampIso = timestampIso;
+ }
+
@Schema(description = "The type of the source component"
)
public String getSourceType() {
diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/InstantAdapter.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/InstantAdapter.java
index 9bc7de3fc8..5692085579 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/InstantAdapter.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/InstantAdapter.java
@@ -24,8 +24,6 @@ import java.time.Instant;
*/
public class InstantAdapter extends XmlAdapter<String, Instant> {
- public static final String DEFAULT_DATE_TIME_FORMAT = "MM/dd/yyyy HH:mm:ss z";
-
@Override
public String marshal(Instant instant) throws Exception {
return instant.toString();
diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/BulletinEntity.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/BulletinEntity.java
index 435b5daefb..4eeb71c5db 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/BulletinEntity.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/BulletinEntity.java
@@ -19,10 +19,12 @@ package org.apache.nifi.web.api.entity;
import io.swagger.v3.oas.annotations.media.Schema;
import org.apache.nifi.web.api.dto.BulletinDTO;
import org.apache.nifi.web.api.dto.ReadablePermission;
+import org.apache.nifi.web.api.dto.util.InstantAdapter;
import org.apache.nifi.web.api.dto.util.TimeAdapter;
import jakarta.xml.bind.annotation.XmlRootElement;
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import java.time.Instant;
import java.util.Date;
/**
@@ -35,6 +37,7 @@ public class BulletinEntity extends Entity implements ReadablePermission {
private String groupId;
private String sourceId;
private Date timestamp;
+ private Instant timestampIso;
private String nodeAddress;
private Boolean canRead;
@@ -101,6 +104,21 @@ public class BulletinEntity extends Entity implements ReadablePermission {
this.timestamp = timestamp;
}
+ /**
+ * @return When this bulletin was generated in ISO format with full date and milliseconds.
+ */
+ @Schema(description = "When this bulletin was generated in ISO format with full date and milliseconds.",
+ type = "string"
+ )
+ @XmlJavaTypeAdapter(InstantAdapter.class)
+ public Instant getTimestampIso() {
+ return timestampIso;
+ }
+
+ public void setTimestampIso(Instant timestampIso) {
+ this.timestampIso = timestampIso;
+ }
+
/**
* @return If clustered, the address of the node from which the bulletin originated.
*/
diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsForGroupRequestEntity.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsForGroupRequestEntity.java
new file mode 100644
index 0000000000..80cc42af61
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsForGroupRequestEntity.java
@@ -0,0 +1,78 @@
+/*
+ * 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.web.api.entity;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.xml.bind.annotation.XmlRootElement;
+import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import org.apache.nifi.web.api.dto.util.InstantAdapter;
+
+import java.time.Instant;
+import java.util.Set;
+
+/**
+ * A serialized representation of this class can be placed in the entity body of a request to clear bulletins for multiple components.
+ */
+@XmlRootElement(name = "clearBulletinsForGroupRequestEntity")
+public class ClearBulletinsForGroupRequestEntity extends Entity {
+
+ private String id;
+ private Instant fromTimestamp;
+ private Set<String> components;
+
+ /**
+ * @return The id of the ProcessGroup
+ */
+ @Schema(description = "The id of the ProcessGroup"
+ )
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ /**
+ * @return the timestamp from which to clear bulletins (inclusive)
+ */
+ @XmlJavaTypeAdapter(InstantAdapter.class)
+ @Schema(description = "The timestamp from which to clear bulletins (inclusive). This field is required.",
+ type = "string",
+ required = true
+ )
+ public Instant getFromTimestamp() {
+ return fromTimestamp;
+ }
+
+ public void setFromTimestamp(Instant fromTimestamp) {
+ this.fromTimestamp = fromTimestamp;
+ }
+
+ /**
+ * @return The component IDs for which to clear bulletins. If not specified, all authorized descendant components will be used.
+ */
+ @Schema(description = "Optional component IDs for which to clear bulletins. If not specified, all authorized descendant components will be used."
+ )
+ public Set<String> getComponents() {
+ return components;
+ }
+
+ public void setComponents(Set<String> components) {
+ this.components = components;
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/InstantAdapter.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsForGroupResultsEntity.java
similarity index 51%
copy from nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/InstantAdapter.java
copy to nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsForGroupResultsEntity.java
index 9bc7de3fc8..880fc93a7d 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/util/InstantAdapter.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsForGroupResultsEntity.java
@@ -14,25 +14,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.nifi.web.api.dto.util;
+package org.apache.nifi.web.api.entity;
-import jakarta.xml.bind.annotation.adapters.XmlAdapter;
-import java.time.Instant;
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import jakarta.xml.bind.annotation.XmlRootElement;
/**
- * XmlAdapter for (un)marshalling an Instant
+ * A serialized representation of this class can be placed in the entity body of a response from clearing bulletins for multiple components.
*/
-public class InstantAdapter extends XmlAdapter<String, Instant> {
+@XmlRootElement(name = "clearBulletinsForGroupResultsEntity")
+public class ClearBulletinsForGroupResultsEntity extends Entity {
- public static final String DEFAULT_DATE_TIME_FORMAT = "MM/dd/yyyy HH:mm:ss z";
+ private int bulletinsCleared;
- @Override
- public String marshal(Instant instant) throws Exception {
- return instant.toString();
+ /**
+ * @return The total number of bulletins that were cleared
+ */
+ @Schema(description = "The total number of bulletins that were cleared.")
+ public int getBulletinsCleared() {
+ return bulletinsCleared;
}
- @Override
- public Instant unmarshal(String instant) throws Exception {
- return Instant.parse(instant);
+ public void setBulletinsCleared(int bulletinsCleared) {
+ this.bulletinsCleared = bulletinsCleared;
}
}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsRequestEntity.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsRequestEntity.java
new file mode 100644
index 0000000000..bf05132082
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsRequestEntity.java
@@ -0,0 +1,50 @@
+/*
+ * 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.web.api.entity;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import jakarta.xml.bind.annotation.XmlRootElement;
+import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import org.apache.nifi.web.api.dto.util.InstantAdapter;
+
+import java.time.Instant;
+
+/**
+ * A serialized representation of this class can be placed in the entity body of a request to clear bulletins for a component.
+ */
+@XmlRootElement(name = "clearBulletinsRequestEntity")
+public class ClearBulletinsRequestEntity extends Entity {
+
+ private Instant fromTimestamp;
+
+ /**
+ * @return the timestamp from which to clear bulletins (inclusive)
+ */
+ @XmlJavaTypeAdapter(InstantAdapter.class)
+ @Schema(description = "The timestamp from which to clear bulletins (inclusive). This field is required.",
+ type = "string",
+ required = true
+ )
+ public Instant getFromTimestamp() {
+ return fromTimestamp;
+ }
+
+ public void setFromTimestamp(Instant fromTimestamp) {
+ this.fromTimestamp = fromTimestamp;
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsResultEntity.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsResultEntity.java
new file mode 100644
index 0000000000..a64ce409b5
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ClearBulletinsResultEntity.java
@@ -0,0 +1,69 @@
+/*
+ * 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.web.api.entity;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import jakarta.xml.bind.annotation.XmlRootElement;
+import java.util.List;
+
+/**
+ * A serialized representation of this class can be placed in the entity body of a response from the REST API containing the result of clearing bulletins for a component.
+ */
+@XmlRootElement(name = "clearBulletinsResultEntity")
+public class ClearBulletinsResultEntity extends Entity {
+
+ private int bulletinsCleared;
+ private String componentId;
+ private List<BulletinEntity> bulletins;
+
+ /**
+ * @return the number of bulletins that were cleared
+ */
+ @Schema(description = "The number of bulletins that were cleared.")
+ public int getBulletinsCleared() {
+ return bulletinsCleared;
+ }
+
+ public void setBulletinsCleared(int bulletinsCleared) {
+ this.bulletinsCleared = bulletinsCleared;
+ }
+
+ /**
+ * @return the id of the component for which bulletins were cleared
+ */
+ @Schema(description = "The id of the component for which bulletins were cleared.")
+ public String getComponentId() {
+ return componentId;
+ }
+
+ public void setComponentId(String componentId) {
+ this.componentId = componentId;
+ }
+
+ /**
+ * @return the current bulletins for the component after clearing
+ */
+ @Schema(description = "The current bulletins for the component after clearing.")
+ public List<BulletinEntity> getBulletins() {
+ return bulletins;
+ }
+
+ public void setBulletins(List<BulletinEntity> bulletins) {
+ this.bulletins = bulletins;
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java
index 2331d470f8..f16d4eedc2 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java
@@ -20,6 +20,8 @@ import jakarta.ws.rs.core.StreamingOutput;
import org.apache.nifi.cluster.coordination.http.endpoints.AccessPolicyEndpointMerger;
import org.apache.nifi.cluster.coordination.http.endpoints.AssetsEndpointMerger;
import org.apache.nifi.cluster.coordination.http.endpoints.BulletinBoardEndpointMerger;
+import org.apache.nifi.cluster.coordination.http.endpoints.ClearBulletinsEndpointMerger;
+import org.apache.nifi.cluster.coordination.http.endpoints.ClearBulletinsForGroupEndpointMerger;
import org.apache.nifi.cluster.coordination.http.endpoints.ComponentStateEndpointMerger;
import org.apache.nifi.cluster.coordination.http.endpoints.ConnectionEndpointMerger;
import org.apache.nifi.cluster.coordination.http.endpoints.ConnectionStatusEndpointMerger;
@@ -205,6 +207,8 @@ public class StandardHttpResponseMapper implements HttpResponseMapper {
endpointMergers.add(new NarSummariesEndpointMerger());
endpointMergers.add(new NarDetailsEndpointMerger());
endpointMergers.add(new AssetsEndpointMerger());
+ endpointMergers.add(new ClearBulletinsEndpointMerger());
+ endpointMergers.add(new ClearBulletinsForGroupEndpointMerger());
}
@Override
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsEndpointMerger.java b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsEndpointMerger.java
new file mode 100644
index 0000000000..1a760edfb6
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsEndpointMerger.java
@@ -0,0 +1,84 @@
+/*
+ * 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 org.apache.nifi.cluster.manager.BulletinMerger;
+import org.apache.nifi.cluster.manager.NodeResponse;
+import org.apache.nifi.cluster.protocol.NodeIdentifier;
+import org.apache.nifi.web.api.entity.BulletinEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+public class ClearBulletinsEndpointMerger extends AbstractSingleEntityEndpoint<ClearBulletinsResultEntity> {
+ public static final Pattern CLEAR_BULLETINS_URI_PATTERN =
+ Pattern.compile("/nifi-api/(?:processors|controller-services|reporting-tasks|parameter-providers|registry-clients|flow-analysis-rules|remote-process-groups)/[a-f0-9\\-]{36}/bulletins");
+
+ @Override
+ public boolean canHandle(URI uri, String method) {
+ return "POST".equalsIgnoreCase(method) && CLEAR_BULLETINS_URI_PATTERN.matcher(uri.getPath()).matches();
+ }
+
+ @Override
+ protected Class<ClearBulletinsResultEntity> getEntityClass() {
+ return ClearBulletinsResultEntity.class;
+ }
+
+ @Override
+ protected void mergeResponses(ClearBulletinsResultEntity clientEntity, Map<NodeIdentifier, ClearBulletinsResultEntity> entityMap,
+ Set<NodeResponse> successfulResponses, Set<NodeResponse> problematicResponses) {
+
+ // Sum up the total bulletins cleared across all nodes
+ int totalBulletinsCleared = 0;
+ for (final ClearBulletinsResultEntity entity : entityMap.values()) {
+ totalBulletinsCleared += entity.getBulletinsCleared();
+ }
+ clientEntity.setBulletinsCleared(totalBulletinsCleared);
+
+ // Merge the remaining bulletins from all nodes
+ final Map<NodeIdentifier, List<BulletinEntity>> bulletinMap = new HashMap<>();
+ for (final Map.Entry<NodeIdentifier, ClearBulletinsResultEntity> entry : entityMap.entrySet()) {
+ final NodeIdentifier nodeIdentifier = entry.getKey();
+ final ClearBulletinsResultEntity entity = entry.getValue();
+ final String nodeAddress = nodeIdentifier.getApiAddress() + ":" + nodeIdentifier.getApiPort();
+
+ if (entity.getBulletins() != null) {
+ final List<BulletinEntity> nodeBulletins = new ArrayList<>();
+ for (final BulletinEntity bulletinEntity : entity.getBulletins()) {
+ if (bulletinEntity.getNodeAddress() == null) {
+ bulletinEntity.setNodeAddress(nodeAddress);
+ }
+ if (bulletinEntity.getCanRead() && bulletinEntity.getBulletin() != null && bulletinEntity.getBulletin().getNodeAddress() == null) {
+ bulletinEntity.getBulletin().setNodeAddress(nodeAddress);
+ }
+ nodeBulletins.add(bulletinEntity);
+ }
+ bulletinMap.put(nodeIdentifier, nodeBulletins);
+ }
+ }
+
+ // Use the BulletinMerger to merge the bulletins properly
+ final List<BulletinEntity> mergedBulletins = BulletinMerger.mergeBulletins(bulletinMap, entityMap.size());
+ clientEntity.setBulletins(mergedBulletins);
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsForGroupEndpointMerger.java b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsForGroupEndpointMerger.java
new file mode 100644
index 0000000000..c25b181876
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsForGroupEndpointMerger.java
@@ -0,0 +1,52 @@
+/*
+ * 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 org.apache.nifi.cluster.manager.NodeResponse;
+import org.apache.nifi.cluster.protocol.NodeIdentifier;
+import org.apache.nifi.web.api.entity.ClearBulletinsForGroupResultsEntity;
+
+import java.net.URI;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+public class ClearBulletinsForGroupEndpointMerger extends AbstractSingleEntityEndpoint<ClearBulletinsForGroupResultsEntity> {
+ public static final Pattern CLEAR_BULLETINS_FOR_GROUP_URI_PATTERN = Pattern.compile("/nifi-api/process-groups/[a-f0-9\\-]{36}/bulletins");
+
+ @Override
+ public boolean canHandle(URI uri, String method) {
+ return "POST".equalsIgnoreCase(method) && CLEAR_BULLETINS_FOR_GROUP_URI_PATTERN.matcher(uri.getPath()).matches();
+ }
+
+ @Override
+ protected Class<ClearBulletinsForGroupResultsEntity> getEntityClass() {
+ return ClearBulletinsForGroupResultsEntity.class;
+ }
+
+ @Override
+ protected void mergeResponses(ClearBulletinsForGroupResultsEntity clientEntity, Map<NodeIdentifier, ClearBulletinsForGroupResultsEntity> entityMap,
+ Set<NodeResponse> successfulResponses, Set<NodeResponse> problematicResponses) {
+
+ // Sum up the total bulletins cleared across all nodes
+ int totalBulletinsCleared = 0;
+ for (final ClearBulletinsForGroupResultsEntity entity : entityMap.values()) {
+ totalBulletinsCleared += entity.getBulletinsCleared();
+ }
+ clientEntity.setBulletinsCleared(totalBulletinsCleared);
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsEndpointMergerTest.java b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsEndpointMergerTest.java
new file mode 100644
index 0000000000..fb9929b05b
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsEndpointMergerTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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 org.apache.nifi.cluster.protocol.NodeIdentifier;
+import org.apache.nifi.web.api.entity.BulletinEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ClearBulletinsEndpointMergerTest {
+
+ @Test
+ public void testCanHandle() {
+ final ClearBulletinsEndpointMerger merger = new ClearBulletinsEndpointMerger();
+
+ // Test valid URIs
+ assertTrue(merger.canHandle(URI.create("/nifi-api/processors/12345678-1234-1234-1234-123456789012/bulletins"), "POST"));
+ assertTrue(merger.canHandle(URI.create("/nifi-api/controller-services/12345678-1234-1234-1234-123456789012/bulletins"), "POST"));
+ assertTrue(merger.canHandle(URI.create("/nifi-api/reporting-tasks/12345678-1234-1234-1234-123456789012/bulletins"), "POST"));
+ assertTrue(merger.canHandle(URI.create("/nifi-api/parameter-providers/12345678-1234-1234-1234-123456789012/bulletins"), "POST"));
+ assertTrue(merger.canHandle(URI.create("/nifi-api/registry-clients/12345678-1234-1234-1234-123456789012/bulletins"), "POST"));
+ assertTrue(merger.canHandle(URI.create("/nifi-api/flow-analysis-rules/12345678-1234-1234-1234-123456789012/bulletins"), "POST"));
+ assertTrue(merger.canHandle(URI.create("/nifi-api/remote-process-groups/12345678-1234-1234-1234-123456789012/bulletins"), "POST"));
+
+ // Test invalid URIs
+ assertFalse(merger.canHandle(URI.create("/nifi-api/processors/12345678-1234-1234-1234-123456789012/bulletins"), "GET"));
+ assertFalse(merger.canHandle(URI.create("/nifi-api/process-groups/12345678-1234-1234-1234-123456789012/bulletins"), "POST"));
+ assertFalse(merger.canHandle(URI.create("/nifi-api/processors/invalid-id/bulletins"), "POST"));
+ }
+
+ @Test
+ public void testMergeResponses() {
+ final ClearBulletinsEndpointMerger merger = new ClearBulletinsEndpointMerger();
+
+ // Create test entities
+ final ClearBulletinsResultEntity clientEntity = new ClearBulletinsResultEntity();
+ clientEntity.setComponentId("test-component");
+ clientEntity.setBulletinsCleared(5);
+ clientEntity.setBulletins(createTestBulletins(2));
+
+ final Map<NodeIdentifier, ClearBulletinsResultEntity> entityMap = new HashMap<>();
+
+ // Node 1
+ final NodeIdentifier node1 = new NodeIdentifier("node1", "localhost", 8080, "localhost", 8081, "localhost", 8082, 8083, false);
+ final ClearBulletinsResultEntity entity1 = new ClearBulletinsResultEntity();
+ entity1.setComponentId("test-component");
+ entity1.setBulletinsCleared(5);
+ entity1.setBulletins(createTestBulletins(2));
+ entityMap.put(node1, entity1);
+
+ // Node 2
+ final NodeIdentifier node2 = new NodeIdentifier("node2", "localhost", 8090, "localhost", 8091, "localhost", 8092, 8093, false);
+ final ClearBulletinsResultEntity entity2 = new ClearBulletinsResultEntity();
+ entity2.setComponentId("test-component");
+ entity2.setBulletinsCleared(3);
+ entity2.setBulletins(createTestBulletins(1));
+ entityMap.put(node2, entity2);
+
+ // Merge responses
+ merger.mergeResponses(clientEntity, entityMap, null, null);
+
+ // Verify merged results
+ assertEquals(8, clientEntity.getBulletinsCleared()); // 5 + 3
+ // Note: BulletinMerger may consolidate bulletins with same message, so we just verify we have bulletins
+ assertFalse(clientEntity.getBulletins().isEmpty());
+ }
+
+ private List<BulletinEntity> createTestBulletins(int count) {
+ final List<BulletinEntity> bulletins = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ final BulletinEntity bulletin = new BulletinEntity();
+ bulletin.setId((long) i);
+ bulletin.setCanRead(true);
+ bulletin.setTimestamp(new java.util.Date());
+ bulletin.setSourceId("test-source");
+ bulletin.setGroupId("test-group");
+
+ // Create the nested BulletinDTO
+ final org.apache.nifi.web.api.dto.BulletinDTO bulletinDto = new org.apache.nifi.web.api.dto.BulletinDTO();
+ bulletinDto.setMessage("Test bulletin " + i);
+ bulletinDto.setLevel("INFO");
+ bulletinDto.setSourceId("test-source");
+ bulletinDto.setTimestamp(new java.util.Date());
+ bulletin.setBulletin(bulletinDto);
+
+ bulletins.add(bulletin);
+ }
+ return bulletins;
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsForGroupEndpointMergerTest.java b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsForGroupEndpointMergerTest.java
new file mode 100644
index 0000000000..133f69c462
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/ClearBulletinsForGroupEndpointMergerTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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 org.apache.nifi.cluster.protocol.NodeIdentifier;
+import org.apache.nifi.web.api.entity.ClearBulletinsForGroupResultsEntity;
+import org.junit.jupiter.api.Test;
+
+import java.net.URI;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ClearBulletinsForGroupEndpointMergerTest {
+
+ @Test
+ public void testCanHandle() {
+ final ClearBulletinsForGroupEndpointMerger merger = new ClearBulletinsForGroupEndpointMerger();
+
+ // Test valid URIs
+ assertTrue(merger.canHandle(URI.create("/nifi-api/process-groups/12345678-1234-1234-1234-123456789012/bulletins"), "POST"));
+
+ // Test invalid URIs
+ assertFalse(merger.canHandle(URI.create("/nifi-api/process-groups/12345678-1234-1234-1234-123456789012/bulletins"), "GET"));
+ assertFalse(merger.canHandle(URI.create("/nifi-api/processors/12345678-1234-1234-1234-123456789012/bulletins"), "POST"));
+ assertFalse(merger.canHandle(URI.create("/nifi-api/process-groups/invalid-id/bulletins"), "POST"));
+ }
+
+ @Test
+ public void testMergeResponses() {
+ final ClearBulletinsForGroupEndpointMerger merger = new ClearBulletinsForGroupEndpointMerger();
+
+ // Create test entities
+ final ClearBulletinsForGroupResultsEntity clientEntity = new ClearBulletinsForGroupResultsEntity();
+ clientEntity.setBulletinsCleared(10);
+
+ final Map<NodeIdentifier, ClearBulletinsForGroupResultsEntity> entityMap = new HashMap<>();
+
+ // Node 1
+ final NodeIdentifier node1 = new NodeIdentifier("node1", "localhost", 8080, "localhost", 8081, "localhost", 8082, 8083, false);
+ final ClearBulletinsForGroupResultsEntity entity1 = new ClearBulletinsForGroupResultsEntity();
+ entity1.setBulletinsCleared(10);
+ entityMap.put(node1, entity1);
+
+ // Node 2
+ final NodeIdentifier node2 = new NodeIdentifier("node2", "localhost", 8090, "localhost", 8091, "localhost", 8092, 8093, false);
+ final ClearBulletinsForGroupResultsEntity entity2 = new ClearBulletinsForGroupResultsEntity();
+ entity2.setBulletinsCleared(7);
+ entityMap.put(node2, entity2);
+
+ // Merge responses
+ merger.mergeResponses(clientEntity, entityMap, null, null);
+
+ // Verify merged results
+ assertEquals(17, clientEntity.getBulletinsCleared()); // 10 + 7
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/events/VolatileBulletinRepository.java b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/events/VolatileBulletinRepository.java
index cd9e750b4c..7ea59751e8 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/events/VolatileBulletinRepository.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/events/VolatileBulletinRepository.java
@@ -23,6 +23,7 @@ import org.apache.nifi.reporting.ComponentType;
import org.apache.nifi.util.RingBuffer;
import org.apache.nifi.util.RingBuffer.Filter;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
@@ -310,4 +311,52 @@ public class VolatileBulletinRepository implements BulletinRepository {
}
}
}
+
+ @Override
+ public int clearBulletinsForComponent(final String sourceId, final Instant fromTimestamp) throws IllegalArgumentException {
+ if (sourceId == null) {
+ throw new IllegalArgumentException("Source ID cannot be null");
+ }
+
+ return clearBulletinsForComponents(Collections.singleton(sourceId), fromTimestamp);
+ }
+
+ @Override
+ public int clearBulletinsForComponents(Collection<String> sourceIds, Instant fromTimestamp) throws IllegalArgumentException {
+ if (sourceIds == null || sourceIds.isEmpty()) {
+ throw new IllegalArgumentException("Source IDs cannot be null or empty");
+ }
+
+ if (fromTimestamp == null) {
+ throw new IllegalArgumentException("From timestamp cannot be null");
+ }
+
+ int totalCleared = 0;
+
+ // Create filter to match bulletins for any of the given sourceIds and at or before fromTimestamp
+ final Filter<Bulletin> clearFilter = bulletin -> {
+ // Match any of the source IDs
+ if (!sourceIds.contains(bulletin.getSourceId())) {
+ return false;
+ }
+
+ // Clear bulletins that are older than or equal to the specified timestamp
+ // Convert bulletin timestamp (Date) to Instant for comparison
+ return bulletin.getTimestamp() != null && !bulletin.getTimestamp().toInstant().isAfter(fromTimestamp);
+ };
+
+ // Iterate through all bulletin stores to find and clear matching bulletins
+ for (final ConcurrentMap<String, RingBuffer<Bulletin>> componentMap : bulletinStoreMap.values()) {
+ for (final RingBuffer<Bulletin> ringBuffer : componentMap.values()) {
+ // Count how many bulletins would be cleared first
+ final int countToRemove = ringBuffer.countSelectedElements(clearFilter);
+ totalCleared += countToRemove;
+
+ // Remove the bulletins
+ ringBuffer.removeSelectedElements(clearFilter);
+ }
+ }
+
+ return totalCleared;
+ }
}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/events/VolatileBulletinRepositoryTest.java b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/events/VolatileBulletinRepositoryTest.java
new file mode 100644
index 0000000000..3925edccb6
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/events/VolatileBulletinRepositoryTest.java
@@ -0,0 +1,270 @@
+/*
+ * 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.events;
+
+import org.apache.nifi.reporting.Bulletin;
+import org.apache.nifi.reporting.BulletinQuery;
+import org.apache.nifi.reporting.ComponentType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class VolatileBulletinRepositoryTest {
+
+ private VolatileBulletinRepository repository;
+
+ @BeforeEach
+ public void setUp() {
+ repository = new VolatileBulletinRepository();
+ }
+
+ @Test
+ public void testClearBulletinsForSingleComponent() {
+ // Setup - add some bulletins
+ final String componentId = "test-component-1";
+ final Instant baseTime = Instant.now();
+ final Instant clearTime = baseTime.plusSeconds(5); // 5 seconds later
+
+ // Add bulletins before, at, and after clear time
+ addBulletin(componentId, "Before clear", Date.from(baseTime.plusSeconds(1)));
+ addBulletin(componentId, "At clear time", Date.from(clearTime));
+ addBulletin(componentId, "After clear", Date.from(baseTime.plusSeconds(10)));
+ addBulletin("other-component", "Other component", Date.from(baseTime.plusSeconds(6)));
+
+ // Initial count should be 4
+ assertEquals(4, getBulletinCount());
+
+ // Clear bulletins older than or equal to clearTime
+ int cleared = repository.clearBulletinsForComponent(componentId, clearTime);
+
+ // Should have cleared 2 bulletins (before clear time + at clear time)
+ assertEquals(2, cleared);
+
+ // Should have 2 bulletins remaining (after clear + other component)
+ assertEquals(2, getBulletinCount());
+ }
+
+ @Test
+ public void testClearBulletinsForMultipleComponents() {
+ // Setup - add bulletins for multiple components
+ final Instant baseTime = Instant.now();
+ final Instant clearTime = baseTime.plusSeconds(5);
+
+ addBulletin("component-1", "Component 1 before", Date.from(baseTime.plusSeconds(1)));
+ addBulletin("component-1", "Component 1 after", Date.from(baseTime.plusSeconds(6)));
+ addBulletin("component-2", "Component 2 before", Date.from(baseTime.plusSeconds(2)));
+ addBulletin("component-2", "Component 2 after", Date.from(baseTime.plusSeconds(7)));
+ addBulletin("component-3", "Component 3 after", Date.from(baseTime.plusSeconds(8)));
+
+ assertEquals(5, getBulletinCount());
+
+ // Clear bulletins for components 1 and 2 older than clearTime
+ int cleared = repository.clearBulletinsForComponents(
+ Arrays.asList("component-1", "component-2"), clearTime);
+
+ // Should have cleared 2 bulletins (component-1 before + component-2 before)
+ assertEquals(2, cleared);
+
+ // Should have 3 bulletins remaining (component-1 after + component-2 after + component-3 after)
+ assertEquals(3, getBulletinCount());
+ }
+
+ @Test
+ public void testClearBulletinsWithNullSourceId() {
+ assertThrows(IllegalArgumentException.class, () -> {
+ repository.clearBulletinsForComponent(null, Instant.now());
+ });
+ }
+
+ @Test
+ public void testClearBulletinsWithNullTimestamp() {
+ assertThrows(IllegalArgumentException.class, () -> {
+ repository.clearBulletinsForComponent("test-component", null);
+ });
+ }
+
+ @Test
+ public void testClearBulletinsForComponentsWithNullCollection() {
+ // Should throw exception when null collection is provided
+ assertThrows(IllegalArgumentException.class, () -> {
+ repository.clearBulletinsForComponents(null, Instant.now());
+ });
+ }
+
+ @Test
+ public void testClearBulletinsForComponentsWithEmptyCollection() {
+ // Should throw exception when empty collection is provided
+ assertThrows(IllegalArgumentException.class, () -> {
+ repository.clearBulletinsForComponents(Collections.emptyList(), Instant.now());
+ });
+ }
+
+ @Test
+ public void testClearBulletinsForComponentsWithNullTimestamp() {
+ assertThrows(IllegalArgumentException.class, () -> {
+ repository.clearBulletinsForComponents(Arrays.asList("component-1"), null);
+ });
+ }
+
+ @Test
+ public void testClearBulletinsExactTimestampMatch() {
+ final String componentId = "test-component";
+ final Instant exactTime = Instant.now();
+
+ // Add bulletin exactly at the clear time
+ addBulletin(componentId, "Exact time", Date.from(exactTime));
+ addBulletin(componentId, "Before", Date.from(exactTime.minusSeconds(1)));
+ addBulletin(componentId, "After", Date.from(exactTime.plusSeconds(1)));
+
+ assertEquals(3, getBulletinCount());
+
+ // Clear bulletins older than or equal to exact time
+ int cleared = repository.clearBulletinsForComponent(componentId, exactTime);
+
+ // Should clear bulletins at and before exact time
+ assertEquals(2, cleared);
+ assertEquals(1, getBulletinCount());
+ }
+
+ @Test
+ public void testClearBulletinsNonExistentComponent() {
+ // Add some bulletins
+ addBulletin("existing-component", "Test message", new Date());
+ assertEquals(1, getBulletinCount());
+
+ // Try to clear bulletins for non-existent component
+ int cleared = repository.clearBulletinsForComponent("non-existent", Instant.now());
+
+ // Should clear 0 bulletins
+ assertEquals(0, cleared);
+ assertEquals(1, getBulletinCount());
+ }
+
+ @Test
+ public void testClearBulletinsSingleComponentDelegatesToMultiple() {
+ // Verify that single component method delegates to multiple components method
+ final String componentId = "test-component";
+ final Instant clearTime = Instant.now();
+
+ addBulletin(componentId, "Test message", Date.from(clearTime.plusSeconds(1)));
+
+ // Clear using single component method
+ int clearedSingle = repository.clearBulletinsForComponent(componentId, clearTime);
+
+ // Add same bulletin again
+ addBulletin(componentId, "Test message", Date.from(clearTime.plusSeconds(1)));
+
+ // Clear using multiple components method with single component
+ int clearedMultiple = repository.clearBulletinsForComponents(
+ Collections.singletonList(componentId), clearTime);
+
+ // Results should be the same
+ assertEquals(clearedSingle, clearedMultiple);
+ }
+
+ private void addBulletin(String sourceId, String message, Date timestamp) {
+ TestBulletin bulletin = new TestBulletin(sourceId, message, timestamp);
+ repository.addBulletin(bulletin);
+ }
+
+ private static class TestBulletin extends Bulletin {
+ private final String sourceId;
+ private final String message;
+ private final Date timestamp;
+
+ public TestBulletin(String sourceId, String message, Date timestamp) {
+ super(System.nanoTime());
+ this.sourceId = sourceId;
+ this.message = message;
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public String getSourceId() {
+ return sourceId;
+ }
+
+ @Override
+ public String getMessage() {
+ return message;
+ }
+
+ @Override
+ public Date getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public String getCategory() {
+ return "Test";
+ }
+
+ @Override
+ public String getLevel() {
+ return "INFO";
+ }
+
+ @Override
+ public ComponentType getSourceType() {
+ return ComponentType.PROCESSOR;
+ }
+
+ @Override
+ public String getSourceName() {
+ return "Test Source";
+ }
+
+ @Override
+ public String getGroupId() {
+ return "test-group";
+ }
+
+ @Override
+ public String getGroupName() {
+ return "Test Group";
+ }
+
+ @Override
+ public String getGroupPath() {
+ return "/";
+ }
+
+ @Override
+ public String getNodeAddress() {
+ return "localhost";
+ }
+
+ @Override
+ public String getFlowFileUuid() {
+ return null;
+ }
+ }
+
+ private int getBulletinCount() {
+ BulletinQuery query = new BulletinQuery.Builder().build();
+ List<Bulletin> bulletins = repository.findBulletins(query);
+ return bulletins.size();
+ }
+}
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 ff78ad29f3..0cddedbfec 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
@@ -101,6 +101,8 @@ import org.apache.nifi.web.api.entity.ActivateControllerServicesEntity;
import org.apache.nifi.web.api.entity.AffectedComponentEntity;
import org.apache.nifi.web.api.entity.AssetEntity;
import org.apache.nifi.web.api.entity.BulletinEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsForGroupResultsEntity;
import org.apache.nifi.web.api.entity.ComponentValidationResultEntity;
import org.apache.nifi.web.api.entity.ConfigurationAnalysisEntity;
import org.apache.nifi.web.api.entity.ConnectionEntity;
@@ -161,6 +163,7 @@ import org.apache.nifi.web.api.request.FlowMetricsRegistry;
import java.io.IOException;
import java.io.InputStream;
+import java.time.Instant;
import java.util.Collection;
import java.util.Date;
import java.util.List;
@@ -3090,4 +3093,37 @@ public interface NiFiServiceFacade {
*/
AssetEntity deleteAsset(String parameterContextId, String assetId);
+ // -----------------------------------------
+ // Bulletin methods
+ // -----------------------------------------
+
+ /**
+ * Clears bulletins for the specified component.
+ *
+ * @param componentId the component id
+ * @param fromTimestamp the timestamp from which to clear bulletins (inclusive), must not be null
+ * @return the clear bulletin result entity
+ */
+ ClearBulletinsResultEntity clearBulletinsForComponent(String componentId, Instant fromTimestamp);
+
+
+ /**
+ * Clears bulletins for the specified components.
+ *
+ * @param processGroupId the process group id
+ * @param fromTimestamp the timestamp from which to clear bulletins (inclusive), must not be null
+ * @param componentIds the component IDs for which to clear bulletins
+ * @return the results of clearing bulletins for each component
+ */
+ ClearBulletinsForGroupResultsEntity clearBulletinsForComponents(String processGroupId, Instant fromTimestamp, Set<String> componentIds);
+
+ /**
+ * Filters components within the specified process group using the provided function.
+ *
+ * @param groupId the id of the process group
+ * @param getComponents function that takes a ProcessGroup and returns a set of component IDs
+ * @return set of component IDs returned by the function
+ */
+ Set<String> filterComponents(String groupId, Function<ProcessGroup, Set<String>> getComponents);
+
}
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 319f795985..97b43df0f8 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
@@ -18,7 +18,6 @@ package org.apache.nifi.web;
import io.prometheus.client.CollectorRegistry;
import jakarta.ws.rs.WebApplicationException;
-import jakarta.ws.rs.core.Response;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.Strings;
import org.apache.nifi.action.Action;
@@ -327,6 +326,8 @@ import org.apache.nifi.web.api.entity.ActivateControllerServicesEntity;
import org.apache.nifi.web.api.entity.AffectedComponentEntity;
import org.apache.nifi.web.api.entity.AssetEntity;
import org.apache.nifi.web.api.entity.BulletinEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsForGroupResultsEntity;
import org.apache.nifi.web.api.entity.ComponentReferenceEntity;
import org.apache.nifi.web.api.entity.ComponentValidationResultEntity;
import org.apache.nifi.web.api.entity.ConfigurationAnalysisEntity;
@@ -430,6 +431,7 @@ import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.InputStream;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -4099,6 +4101,7 @@ public class StandardNiFiServiceFacade implements NiFiServiceFacade {
case PROCESSOR -> authorizableLookup.getProcessor(sourceId).getAuthorizable();
case REPORTING_TASK -> authorizableLookup.getReportingTask(sourceId).getAuthorizable();
case FLOW_ANALYSIS_RULE -> authorizableLookup.getFlowAnalysisRule(sourceId).getAuthorizable();
+ case FLOW_REGISTRY_CLIENT -> authorizableLookup.getFlowRegistryClient(sourceId).getAuthorizable();
case PARAMETER_PROVIDER -> authorizableLookup.getParameterProvider(sourceId).getAuthorizable();
case CONTROLLER_SERVICE -> authorizableLookup.getControllerService(sourceId).getAuthorizable();
case FLOW_CONTROLLER -> controllerFacade;
@@ -4106,8 +4109,6 @@ public class StandardNiFiServiceFacade implements NiFiServiceFacade {
case OUTPUT_PORT -> authorizableLookup.getOutputPort(sourceId);
case REMOTE_PROCESS_GROUP -> authorizableLookup.getRemoteProcessGroup(sourceId);
case PROCESS_GROUP -> authorizableLookup.getProcessGroup(sourceId).getAuthorizable();
- default ->
- throw new WebApplicationException(Response.serverError().entity("An unexpected type of component is the source of this bulletin.").build());
};
} catch (final ResourceNotFoundException e) {
// if the underlying component is gone, disallow
@@ -7424,4 +7425,53 @@ public class StandardNiFiServiceFacade implements NiFiServiceFacade {
public void setAssetManager(final AssetManager assetManager) {
this.assetManager = assetManager;
}
+
+ @Override
+ public ClearBulletinsResultEntity clearBulletinsForComponent(final String componentId, final Instant fromTimestamp) {
+ if (fromTimestamp == null) {
+ throw new IllegalArgumentException("From timestamp must be specified");
+ }
+
+ final int bulletinsCleared = bulletinRepository.clearBulletinsForComponent(componentId, fromTimestamp);
+
+ // Get the current bulletins for the component after clearing
+ final List<Bulletin> currentBulletins = bulletinRepository.findBulletinsForSource(componentId);
+ final List<BulletinEntity> bulletinEntities = currentBulletins.stream()
+ .map(bulletin -> entityFactory.createBulletinEntity(dtoFactory.createBulletinDto(bulletin, false), authorizeBulletin(bulletin)))
+ .collect(Collectors.toList());
+
+ // Create the response entity
+ final ClearBulletinsResultEntity entity = new ClearBulletinsResultEntity();
+ entity.setComponentId(componentId);
+ entity.setBulletinsCleared(bulletinsCleared);
+ entity.setBulletins(bulletinEntities);
+
+ return entity;
+ }
+
+
+ @Override
+ public ClearBulletinsForGroupResultsEntity clearBulletinsForComponents(final String processGroupId, final Instant fromTimestamp, final Set<String> componentIds) {
+ if (fromTimestamp == null) {
+ throw new IllegalArgumentException("From timestamp must be specified");
+ }
+ if (componentIds == null || componentIds.isEmpty()) {
+ throw new IllegalArgumentException("Component IDs must be specified");
+ }
+
+ // Clear bulletins for all components specified
+ final int totalBulletinsCleared = bulletinRepository.clearBulletinsForComponents(componentIds, fromTimestamp);
+
+ // Create the response entity
+ final ClearBulletinsForGroupResultsEntity entity = new ClearBulletinsForGroupResultsEntity();
+ entity.setBulletinsCleared(totalBulletinsCleared);
+
+ return entity;
+ }
+
+ @Override
+ public Set<String> filterComponents(final String groupId, final Function<ProcessGroup, Set<String>> getComponents) {
+ final ProcessGroup processGroup = processGroupDAO.getProcessGroup(groupId);
+ return getComponents.apply(processGroup);
+ }
}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ControllerResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ControllerResource.java
index 0010826316..90d98197a3 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ControllerResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ControllerResource.java
@@ -84,6 +84,11 @@ import org.apache.nifi.web.api.dto.PropertyDescriptorDTO;
import org.apache.nifi.web.api.dto.ReportingTaskDTO;
import org.apache.nifi.web.api.dto.VerifyConfigRequestDTO;
import org.apache.nifi.web.api.entity.BulletinEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsRequestEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
+import org.apache.nifi.web.api.entity.ParameterProviderEntity;
+import org.apache.nifi.web.api.entity.FlowAnalysisRuleEntity;
+import org.apache.nifi.web.api.entity.FlowRegistryClientEntity;
import org.apache.nifi.web.api.entity.ClusterEntity;
import org.apache.nifi.web.api.entity.ComponentHistoryEntity;
import org.apache.nifi.web.api.entity.ComponentStateEntity;
@@ -91,10 +96,8 @@ import org.apache.nifi.web.api.entity.ConfigurationAnalysisEntity;
import org.apache.nifi.web.api.entity.ControllerConfigurationEntity;
import org.apache.nifi.web.api.entity.ControllerServiceEntity;
import org.apache.nifi.web.api.entity.Entity;
-import org.apache.nifi.web.api.entity.FlowAnalysisRuleEntity;
import org.apache.nifi.web.api.entity.FlowAnalysisRuleRunStatusEntity;
import org.apache.nifi.web.api.entity.FlowAnalysisRulesEntity;
-import org.apache.nifi.web.api.entity.FlowRegistryClientEntity;
import org.apache.nifi.web.api.entity.FlowRegistryClientTypesEntity;
import org.apache.nifi.web.api.entity.FlowRegistryClientsEntity;
import org.apache.nifi.web.api.entity.HistoryEntity;
@@ -102,7 +105,6 @@ import org.apache.nifi.web.api.entity.NarDetailsEntity;
import org.apache.nifi.web.api.entity.NarSummariesEntity;
import org.apache.nifi.web.api.entity.NarSummaryEntity;
import org.apache.nifi.web.api.entity.NodeEntity;
-import org.apache.nifi.web.api.entity.ParameterProviderEntity;
import org.apache.nifi.web.api.entity.PropertyDescriptorEntity;
import org.apache.nifi.web.api.entity.ReportingTaskEntity;
import org.apache.nifi.web.api.entity.VerifyConfigRequestEntity;
@@ -121,6 +123,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.Collections;
+import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Set;
@@ -354,6 +357,70 @@ public class ControllerResource extends ApplicationResource {
);
}
+ /**
+ * Clears the bulletins for the specified parameter provider.
+ *
+ * @param id The id of the parameter provider
+ * @param requestClearBulletinEntity A clearBulletinsRequestEntity
+ * @return A clearBulletinsResultEntity
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/parameter-providers/{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for a parameter provider",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsResultEntity.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 = "Write - /parameter-providers/{uuid}")
+ }
+ )
+ public Response clearParameterProviderBulletins(
+ @Parameter(description = "The parameter provider id.", required = true)
+ @PathParam("id") final String id,
+ @Parameter(description = "The request to clear bulletins.", required = true)
+ final ClearBulletinsRequestEntity requestClearBulletinEntity) {
+
+ // Verify the request
+ if (requestClearBulletinEntity == null) {
+ throw new IllegalArgumentException("Clear bulletin request must be specified.");
+ }
+
+ final Instant fromTimestamp = requestClearBulletinEntity.getFromTimestamp();
+ if (fromTimestamp == null) {
+ throw new IllegalArgumentException("From timestamp must be specified.");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, requestClearBulletinEntity);
+ }
+
+ final ParameterProviderEntity requestParameterProviderEntity = new ParameterProviderEntity();
+ requestParameterProviderEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestParameterProviderEntity,
+ lookup -> {
+ final Authorizable authorizable = lookup.getParameterProvider(id).getAuthorizable();
+ authorizable.authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ },
+ () -> { },
+ (parameterProviderEntity) -> {
+ final Instant execFromTimestamp = requestClearBulletinEntity.getFromTimestamp();
+ final ClearBulletinsResultEntity entity = serviceFacade.clearBulletinsForComponent(parameterProviderEntity.getId(), execFromTimestamp);
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
+
// ---------------
// reporting tasks
// ---------------
@@ -1423,7 +1490,70 @@ public class ControllerResource extends ApplicationResource {
entity.setRequest(dto);
return entity;
}
- //--
+
+ /**
+ * Clears the bulletins for the specified flow analysis rule.
+ *
+ * @param id The id of the flow analysis rule
+ * @param requestClearBulletinEntity A clearBulletinsRequestEntity
+ * @return A clearBulletinsResultEntity
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/flow-analysis-rules/{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for a flow analysis rule",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsResultEntity.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 = "Write - /flow-analysis-rules/{uuid}")
+ }
+ )
+ public Response clearFlowAnalysisRuleBulletins(
+ @Parameter(description = "The flow analysis rule id.", required = true)
+ @PathParam("id") final String id,
+ @Parameter(description = "The request to clear bulletins.", required = true)
+ final ClearBulletinsRequestEntity requestClearBulletinEntity) {
+
+ // Verify the request
+ if (requestClearBulletinEntity == null) {
+ throw new IllegalArgumentException("Clear bulletin request must be specified.");
+ }
+
+ final Instant fromTimestamp = requestClearBulletinEntity.getFromTimestamp();
+ if (fromTimestamp == null) {
+ throw new IllegalArgumentException("From timestamp must be specified.");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, requestClearBulletinEntity);
+ }
+
+ final FlowAnalysisRuleEntity requestFlowAnalysisRuleEntity = new FlowAnalysisRuleEntity();
+ requestFlowAnalysisRuleEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestFlowAnalysisRuleEntity,
+ lookup -> {
+ final ComponentAuthorizable authorizable = lookup.getFlowAnalysisRule(id);
+ authorizable.getAuthorizable().authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ },
+ () -> { },
+ (flowAnalysisRuleEntity) -> {
+ final Instant execFromTimestamp = requestClearBulletinEntity.getFromTimestamp();
+ final ClearBulletinsResultEntity entity = serviceFacade.clearBulletinsForComponent(flowAnalysisRuleEntity.getId(), execFromTimestamp);
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
// ----------
// registries
@@ -2109,6 +2239,70 @@ public class ControllerResource extends ApplicationResource {
return generateOkResponse(entity).build();
}
+ /**
+ * Clears the bulletins for the specified registry client.
+ *
+ * @param id The id of the registry client
+ * @param requestClearBulletinEntity A clearBulletinsRequestEntity
+ * @return A clearBulletinsResultEntity
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/registry-clients/{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for a registry client",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsResultEntity.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 = "Write - /controller/registry-clients/{uuid}")
+ }
+ )
+ public Response clearRegistryClientBulletins(
+ @Parameter(description = "The registry client id.", required = true)
+ @PathParam("id") final String id,
+ @Parameter(description = "The request to clear bulletins.", required = true)
+ final ClearBulletinsRequestEntity requestClearBulletinEntity) {
+
+ // Verify the request
+ if (requestClearBulletinEntity == null) {
+ throw new IllegalArgumentException("Clear bulletin request must be specified.");
+ }
+
+ final Instant fromTimestamp = requestClearBulletinEntity.getFromTimestamp();
+ if (fromTimestamp == null) {
+ throw new IllegalArgumentException("From timestamp must be specified.");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, requestClearBulletinEntity);
+ }
+
+ final FlowRegistryClientEntity requestFlowRegistryClientEntity = new FlowRegistryClientEntity();
+ requestFlowRegistryClientEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestFlowRegistryClientEntity,
+ lookup -> {
+ final Authorizable authorizable = lookup.getFlowRegistryClient(id).getAuthorizable();
+ authorizable.authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ },
+ () -> { },
+ (flowRegistryClientEntity) -> {
+ final Instant execFromTimestamp = requestClearBulletinEntity.getFromTimestamp();
+ final ClearBulletinsResultEntity entity = serviceFacade.clearBulletinsForComponent(flowRegistryClientEntity.getId(), execFromTimestamp);
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
+
/**
* Retrieves the types of flow registry clients that this NiFi supports.
*
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ControllerServiceResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ControllerServiceResource.java
index 3e6fd20347..6dde123c46 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ControllerServiceResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ControllerServiceResource.java
@@ -70,6 +70,8 @@ import org.apache.nifi.web.api.dto.ControllerServiceDTO;
import org.apache.nifi.web.api.dto.PropertyDescriptorDTO;
import org.apache.nifi.web.api.dto.RevisionDTO;
import org.apache.nifi.web.api.dto.VerifyConfigRequestDTO;
+import org.apache.nifi.web.api.entity.ClearBulletinsRequestEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
import org.apache.nifi.web.api.entity.ComponentStateEntity;
import org.apache.nifi.web.api.entity.ConfigurationAnalysisEntity;
import org.apache.nifi.web.api.entity.ControllerServiceEntity;
@@ -86,6 +88,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import java.util.Collections;
+import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -404,6 +407,77 @@ public class ControllerServiceResource extends ApplicationResource {
);
}
+ /**
+ * Clears bulletins for a controller service.
+ *
+ * @param id The id of the controller service
+ * @param clearBulletinsRequestEntity The clear bulletin request
+ * @return a clearBulletinsResultEntity
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for a controller service",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsResultEntity.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 = "Write - /controller-services/{uuid}")
+ }
+ )
+ public Response clearBulletins(
+ @Parameter(
+ description = "The controller service id.",
+ required = true
+ )
+ @PathParam("id") final String id,
+ @Parameter(
+ description = "The clear bulletin request specifying the timestamp from which to clear bulletins.",
+ required = true
+ ) final ClearBulletinsRequestEntity clearBulletinsRequestEntity) {
+
+ if (clearBulletinsRequestEntity == null) {
+ throw new IllegalArgumentException("Clear bulletin request must be specified.");
+ }
+
+ // Validate the request
+ if (clearBulletinsRequestEntity.getFromTimestamp() == null) {
+ throw new IllegalArgumentException("From timestamp must be specified in the clear bulletin request.");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, clearBulletinsRequestEntity);
+ }
+
+ final ControllerServiceEntity requestControllerServiceEntity = new ControllerServiceEntity();
+ requestControllerServiceEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestControllerServiceEntity,
+ lookup -> {
+ final Authorizable controllerService = lookup.getControllerService(id).getAuthorizable();
+ controllerService.authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ },
+ () -> { },
+ (controllerServiceEntity) -> {
+ final Instant fromTimestamp = clearBulletinsRequestEntity.getFromTimestamp();
+
+ // Clear bulletins for the controller service
+ final ClearBulletinsResultEntity entity = serviceFacade.clearBulletinsForComponent(controllerServiceEntity.getId(), fromTimestamp);
+
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
+
/**
* Retrieves the references of the specified controller service.
*
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
index 124687dd3d..e80ad7f118 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java
@@ -32,6 +32,7 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
@@ -105,6 +106,8 @@ import org.apache.nifi.web.api.entity.ActivateControllerServicesEntity;
import org.apache.nifi.web.api.entity.AdditionalDetailsEntity;
import org.apache.nifi.web.api.entity.BannerEntity;
import org.apache.nifi.web.api.entity.BulletinBoardEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsForGroupRequestEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsForGroupResultsEntity;
import org.apache.nifi.web.api.entity.ClusterSearchResultsEntity;
import org.apache.nifi.web.api.entity.ClusterSummaryEntity;
import org.apache.nifi.web.api.entity.ComponentHistoryEntity;
@@ -172,6 +175,7 @@ import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.text.Collator;
+import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -629,7 +633,7 @@ public class FlowResource extends ApplicationResource {
@SecurityRequirement(name = "Read - /flow")
},
description = "If the uiOnly query parameter is provided with a value of true, the returned entity may only contain fields that are necessary for rendering the NiFi User Interface. As " +
- "such, " +
+ "such, " +
"the selected fields may change at any time, even during incremental releases, without warning. As a result, this parameter should not be provided by any client other than the UI."
)
public Response getControllerServicesFromController(@QueryParam("uiOnly") @DefaultValue("false") final boolean uiOnly,
@@ -681,7 +685,7 @@ public class FlowResource extends ApplicationResource {
@SecurityRequirement(name = "Read - /flow")
},
description = "If the uiOnly query parameter is provided with a value of true, the returned entity may only contain fields that are necessary for rendering the NiFi User Interface. As " +
- "such, " +
+ "such, " +
"the selected fields may change at any time, even during incremental releases, without warning. As a result, this parameter should not be provided by any client other than the UI."
)
public Response getControllerServicesFromGroup(
@@ -1230,6 +1234,122 @@ public class FlowResource extends ApplicationResource {
);
}
+ /**
+ * Clears bulletins for components in the specified Process Group.
+ *
+ * @param id The id of the process group.
+ * @param clearBulletinsForGroupRequestEntity A clearBulletinsRequestEntity.
+ * @return A clearBulletinsResultsEntity.
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("process-groups/{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for components in the specified Process Group.",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsForGroupResultsEntity.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 - /flow"),
+ @SecurityRequirement(name = "Write - /process-groups/{uuid} - For the process group"),
+ @SecurityRequirement(name = "Write - /{component-type}/{uuid} - For every component having bulletins cleared")
+ }
+ )
+ public Response clearBulletins(
+ @Parameter(
+ description = "The process group id.",
+ required = true
+ )
+ @PathParam("id") String id,
+ @Parameter(
+ description = "The request to clear bulletins. If the components in the request are not specified, all authorized components will be considered.",
+ required = true
+ ) final ClearBulletinsForGroupRequestEntity clearBulletinsForGroupRequestEntity) {
+
+ if (clearBulletinsForGroupRequestEntity == null) {
+ throw new IllegalArgumentException("Clear bulletins request must be specified.");
+ }
+
+ // ensure the same id is being used
+ if (!id.equals(clearBulletinsForGroupRequestEntity.getId())) {
+ throw new IllegalArgumentException(String.format("The process group id (%s) in the request body does "
+ + "not equal the process group id of the requested resource (%s).", clearBulletinsForGroupRequestEntity.getId(), id));
+ }
+
+ final Instant fromTimestamp = clearBulletinsForGroupRequestEntity.getFromTimestamp();
+ if (fromTimestamp == null) {
+ throw new IllegalArgumentException("The from timestamp must be specified.");
+ }
+
+ // if the components are not specified, gather all authorized components
+ if (clearBulletinsForGroupRequestEntity.getComponents() == null) {
+ // get component IDs that the user has write access to
+ final Set<String> writableComponentIds = serviceFacade.filterComponents(id, group -> {
+ final Set<String> componentIds = new HashSet<>();
+
+ // find all processors with write permissions
+ group.findAllProcessors().stream()
+ .filter(processor -> processor.isAuthorized(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser()))
+ .forEach(processor -> componentIds.add(processor.getIdentifier()));
+
+ // find all input ports with write permissions
+ group.findAllInputPorts().stream()
+ .filter(inputPort -> inputPort.isAuthorized(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser()))
+ .forEach(inputPort -> componentIds.add(inputPort.getIdentifier()));
+
+ // find all output ports with write permissions
+ group.findAllOutputPorts().stream()
+ .filter(outputPort -> outputPort.isAuthorized(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser()))
+ .forEach(outputPort -> componentIds.add(outputPort.getIdentifier()));
+
+ // find all rpgs with write permissions
+ group.findAllRemoteProcessGroups().stream()
+ .filter(remoteProcessGroup -> remoteProcessGroup.isAuthorized(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser()))
+ .forEach(remoteProcessGroup -> componentIds.add(remoteProcessGroup.getIdentifier()));
+
+ return componentIds;
+ });
+
+ // set the components (no revisions needed)
+ clearBulletinsForGroupRequestEntity.setComponents(writableComponentIds);
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, clearBulletinsForGroupRequestEntity);
+ }
+
+ final ProcessGroupEntity requestProcessGroupEntity = new ProcessGroupEntity();
+ requestProcessGroupEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestProcessGroupEntity,
+ lookup -> {
+ authorizeFlow();
+
+ // ensure access to every component being cleared
+ final Set<String> requestComponentsToClear = clearBulletinsForGroupRequestEntity.getComponents();
+ requestComponentsToClear.forEach(componentId -> {
+ final Authorizable connectable = lookup.getLocalConnectable(componentId);
+ connectable.authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ });
+ },
+ () -> { },
+ (processGroupEntity) -> {
+ // clear the bulletins
+ final ClearBulletinsForGroupResultsEntity entity = serviceFacade.clearBulletinsForComponents(
+ processGroupEntity.getId(), fromTimestamp, clearBulletinsForGroupRequestEntity.getComponents());
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
+
// ------
// search
// ------
@@ -2571,7 +2691,7 @@ public class FlowResource extends ApplicationResource {
authorizeFlow();
FlowVersionLocation baseVersionLocation = new FlowVersionLocation(branchIdA, bucketIdA, flowIdA, versionA);
FlowVersionLocation comparedVersionLocation = new FlowVersionLocation(branchIdB, bucketIdB, flowIdB, versionB);
- final FlowComparisonEntity versionDifference = serviceFacade.getVersionDifference(registryId, baseVersionLocation, comparedVersionLocation);
+ final FlowComparisonEntity versionDifference = serviceFacade.getVersionDifference(registryId, baseVersionLocation, comparedVersionLocation);
// Note: with the current implementation, this is deterministic. However, the internal data structure used in comparison is set, thus
// later changes might cause discrepancies. Practical use of the endpoint usually remains within one "page" though.
return generateOkResponse(limitDifferences(versionDifference, offset, limit))
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/InputPortResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/InputPortResource.java
index d56d77a2e4..c53c950c9f 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/InputPortResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/InputPortResource.java
@@ -28,6 +28,7 @@ import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
@@ -46,6 +47,8 @@ import org.apache.nifi.web.NiFiServiceFacade;
import org.apache.nifi.web.Revision;
import org.apache.nifi.web.api.dto.PortDTO;
import org.apache.nifi.web.api.dto.PositionDTO;
+import org.apache.nifi.web.api.entity.ClearBulletinsRequestEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
import org.apache.nifi.web.api.entity.PortEntity;
import org.apache.nifi.web.api.entity.PortRunStatusEntity;
import org.apache.nifi.web.api.entity.ProcessorEntity;
@@ -54,6 +57,7 @@ import org.apache.nifi.web.api.request.LongParameter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
+import java.time.Instant;
import java.util.Set;
/**
@@ -387,6 +391,72 @@ public class InputPortResource extends ApplicationResource {
return dto;
}
+ /**
+ * Clears the bulletins for the specified input port.
+ *
+ * @param id The id of the input port
+ * @param requestClearBulletinEntity A clearBulletinsRequestEntity
+ * @return A clearBulletinsResultEntity
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for an input port",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsResultEntity.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 = "Write - /input-ports/{uuid}")
+ }
+ )
+ public Response clearBulletins(
+ @Parameter(description = "The input port id.", required = true)
+ @PathParam("id") final String id,
+ @Parameter(description = "The request to clear bulletins.", required = true)
+ final ClearBulletinsRequestEntity requestClearBulletinEntity) {
+
+ // Verify the request
+ if (requestClearBulletinEntity == null) {
+ throw new IllegalArgumentException("Clear bulletin request must be specified.");
+ }
+
+ final Instant fromTimestamp = requestClearBulletinEntity.getFromTimestamp();
+ if (fromTimestamp == null) {
+ throw new IllegalArgumentException("From timestamp must be specified.");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, requestClearBulletinEntity);
+ }
+
+ final PortEntity requestPortEntity = new PortEntity();
+ requestPortEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestPortEntity,
+ lookup -> {
+ final Authorizable inputPort = lookup.getInputPort(id);
+ inputPort.authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ },
+ () -> { },
+ (portEntity) -> {
+ // clear the bulletins
+ final ClearBulletinsResultEntity entity = serviceFacade.clearBulletinsForComponent(portEntity.getId(), fromTimestamp);
+
+ // generate the response
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
+
@Autowired
public void setServiceFacade(NiFiServiceFacade serviceFacade) {
this.serviceFacade = serviceFacade;
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OutputPortResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OutputPortResource.java
index 703ea13bcc..92c27f6e40 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OutputPortResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/OutputPortResource.java
@@ -28,6 +28,7 @@ import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
@@ -46,6 +47,8 @@ import org.apache.nifi.web.NiFiServiceFacade;
import org.apache.nifi.web.Revision;
import org.apache.nifi.web.api.dto.PortDTO;
import org.apache.nifi.web.api.dto.PositionDTO;
+import org.apache.nifi.web.api.entity.ClearBulletinsRequestEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
import org.apache.nifi.web.api.entity.PortEntity;
import org.apache.nifi.web.api.entity.PortRunStatusEntity;
import org.apache.nifi.web.api.entity.ProcessorEntity;
@@ -54,6 +57,7 @@ import org.apache.nifi.web.api.request.LongParameter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
+import java.time.Instant;
import java.util.Set;
/**
@@ -388,6 +392,72 @@ public class OutputPortResource extends ApplicationResource {
return dto;
}
+ /**
+ * Clears the bulletins for the specified output port.
+ *
+ * @param id The id of the output port
+ * @param requestClearBulletinEntity A clearBulletinsRequestEntity
+ * @return A clearBulletinsResultEntity
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for an output port",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsResultEntity.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 = "Write - /output-ports/{uuid}")
+ }
+ )
+ public Response clearBulletins(
+ @Parameter(description = "The output port id.", required = true)
+ @PathParam("id") final String id,
+ @Parameter(description = "The request to clear bulletins.", required = true)
+ final ClearBulletinsRequestEntity requestClearBulletinEntity) {
+
+ // Verify the request
+ if (requestClearBulletinEntity == null) {
+ throw new IllegalArgumentException("Clear bulletin request must be specified.");
+ }
+
+ final Instant fromTimestamp = requestClearBulletinEntity.getFromTimestamp();
+ if (fromTimestamp == null) {
+ throw new IllegalArgumentException("From timestamp must be specified.");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, requestClearBulletinEntity);
+ }
+
+ final PortEntity requestPortEntity = new PortEntity();
+ requestPortEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestPortEntity,
+ lookup -> {
+ final Authorizable outputPort = lookup.getOutputPort(id);
+ outputPort.authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ },
+ () -> { },
+ (portEntity) -> {
+ // clear the bulletins
+ final ClearBulletinsResultEntity entity = serviceFacade.clearBulletinsForComponent(portEntity.getId(), fromTimestamp);
+
+ // generate the response
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
+
@Autowired
public void setServiceFacade(NiFiServiceFacade serviceFacade) {
this.serviceFacade = serviceFacade;
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ParameterProviderResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ParameterProviderResource.java
index da78992a9b..f5b6d8109f 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ParameterProviderResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ParameterProviderResource.java
@@ -102,6 +102,8 @@ import org.apache.nifi.web.api.entity.PropertyDescriptorEntity;
import org.apache.nifi.web.api.entity.VerifyConfigRequestEntity;
import org.apache.nifi.web.api.request.ClientIdParameter;
import org.apache.nifi.web.api.request.LongParameter;
+import org.apache.nifi.web.api.entity.ClearBulletinsRequestEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
import org.apache.nifi.web.util.ComponentLifecycle;
import org.apache.nifi.web.util.LifecycleManagementException;
import org.apache.nifi.web.util.ParameterUpdateManager;
@@ -113,6 +115,7 @@ import org.springframework.stereotype.Controller;
import java.net.URI;
import java.net.URISyntaxException;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -498,6 +501,77 @@ public class ParameterProviderResource extends AbstractParameterResource {
);
}
+ /**
+ * Clears bulletins for a parameter provider.
+ *
+ * @param id The id of the parameter provider
+ * @param clearBulletinsRequestEntity The clear bulletin request
+ * @return a clearBulletinsResultEntity
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for a parameter provider",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsResultEntity.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 = "Write - /parameter-providers/{uuid}")
+ }
+ )
+ public Response clearBulletins(
+ @Parameter(
+ description = "The parameter provider id.",
+ required = true
+ )
+ @PathParam("id") final String id,
+ @Parameter(
+ description = "The clear bulletin request specifying the timestamp from which to clear bulletins.",
+ required = true
+ ) final ClearBulletinsRequestEntity clearBulletinsRequestEntity) {
+
+ if (clearBulletinsRequestEntity == null) {
+ throw new IllegalArgumentException("Clear bulletin request must be specified.");
+ }
+
+ // Validate the request
+ if (clearBulletinsRequestEntity.getFromTimestamp() == null) {
+ throw new IllegalArgumentException("From timestamp must be specified in the clear bulletin request.");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, clearBulletinsRequestEntity);
+ }
+
+ final ParameterProviderEntity requestParameterProviderEntity = new ParameterProviderEntity();
+ requestParameterProviderEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestParameterProviderEntity,
+ lookup -> {
+ final Authorizable parameterProvider = lookup.getParameterProvider(id).getAuthorizable();
+ parameterProvider.authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ },
+ () -> { },
+ (parameterProviderEntity) -> {
+ final Instant fromTimestamp = clearBulletinsRequestEntity.getFromTimestamp();
+
+ // Clear bulletins for the parameter provider
+ final ClearBulletinsResultEntity entity = serviceFacade.clearBulletinsForComponent(parameterProviderEntity.getId(), fromTimestamp);
+
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
+
/**
* Updates the specified a Parameter Provider.
*
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProcessorResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProcessorResource.java
index 07d8103fe0..f26f0390fe 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProcessorResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ProcessorResource.java
@@ -70,6 +70,8 @@ import org.apache.nifi.web.api.dto.ProcessorConfigDTO;
import org.apache.nifi.web.api.dto.ProcessorDTO;
import org.apache.nifi.web.api.dto.PropertyDescriptorDTO;
import org.apache.nifi.web.api.dto.VerifyConfigRequestDTO;
+import org.apache.nifi.web.api.entity.ClearBulletinsRequestEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
import org.apache.nifi.web.api.entity.ComponentStateEntity;
import org.apache.nifi.web.api.entity.ConfigurationAnalysisEntity;
import org.apache.nifi.web.api.entity.ProcessorDiagnosticsEntity;
@@ -87,6 +89,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import java.util.Collections;
+import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -547,6 +550,77 @@ public class ProcessorResource extends ApplicationResource {
);
}
+ /**
+ * Clears bulletins for a processor.
+ *
+ * @param id The id of the processor
+ * @param clearBulletinsRequestEntity The clear bulletin request
+ * @return a clearBulletinsResultEntity
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for a processor",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsResultEntity.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 = "Write - /processors/{uuid}")
+ }
+ )
+ public Response clearBulletins(
+ @Parameter(
+ description = "The processor id.",
+ required = true
+ )
+ @PathParam("id") final String id,
+ @Parameter(
+ description = "The clear bulletin request specifying the timestamp from which to clear bulletins.",
+ required = true
+ ) final ClearBulletinsRequestEntity clearBulletinsRequestEntity) {
+
+ if (clearBulletinsRequestEntity == null) {
+ throw new IllegalArgumentException("Clear bulletin request must be specified.");
+ }
+
+ // Validate the request
+ if (clearBulletinsRequestEntity.getFromTimestamp() == null) {
+ throw new IllegalArgumentException("From timestamp must be specified in the clear bulletin request.");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, clearBulletinsRequestEntity);
+ }
+
+ final ProcessorEntity requestProcessorEntity = new ProcessorEntity();
+ requestProcessorEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestProcessorEntity,
+ lookup -> {
+ final Authorizable processor = lookup.getProcessor(id).getAuthorizable();
+ processor.authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ },
+ () -> { },
+ (processorEntity) -> {
+ final Instant fromTimestamp = clearBulletinsRequestEntity.getFromTimestamp();
+
+ // Clear bulletins for the processor
+ final ClearBulletinsResultEntity entity = serviceFacade.clearBulletinsForComponent(processorEntity.getId(), fromTimestamp);
+
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
+
@POST
@Consumes(MediaType.APPLICATION_JSON)
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/RemoteProcessGroupResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/RemoteProcessGroupResource.java
index 3aac0f11da..0ce031200d 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/RemoteProcessGroupResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/RemoteProcessGroupResource.java
@@ -28,6 +28,7 @@ import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HttpMethod;
+import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
@@ -50,6 +51,8 @@ import org.apache.nifi.web.api.dto.PositionDTO;
import org.apache.nifi.web.api.dto.RemoteProcessGroupDTO;
import org.apache.nifi.web.api.dto.RemoteProcessGroupPortDTO;
import org.apache.nifi.web.api.dto.RevisionDTO;
+import org.apache.nifi.web.api.entity.ClearBulletinsRequestEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
import org.apache.nifi.web.api.entity.ComponentStateEntity;
import org.apache.nifi.web.api.entity.RemotePortRunStatusEntity;
import org.apache.nifi.web.api.entity.RemoteProcessGroupEntity;
@@ -61,6 +64,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import java.net.URI;
+import java.time.Instant;
import java.util.Set;
import java.util.stream.Collectors;
@@ -225,6 +229,77 @@ public class RemoteProcessGroupResource extends ApplicationResource {
);
}
+ /**
+ * Clears bulletins for a remote process group.
+ *
+ * @param id The id of the remote process group
+ * @param clearBulletinsRequestEntity The clear bulletin request
+ * @return a clearBulletinsResultEntity
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for a remote process group",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsResultEntity.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 = "Write - /remote-process-groups/{uuid}")
+ }
+ )
+ public Response clearBulletins(
+ @Parameter(
+ description = "The remote process group id.",
+ required = true
+ )
+ @PathParam("id") final String id,
+ @Parameter(
+ description = "The clear bulletin request.",
+ required = true
+ ) final ClearBulletinsRequestEntity clearBulletinsRequestEntity) {
+
+ if (clearBulletinsRequestEntity == null) {
+ throw new IllegalArgumentException("Clear bulletin request must be specified.");
+ }
+
+ // Validate the request
+ if (clearBulletinsRequestEntity.getFromTimestamp() == null) {
+ throw new IllegalArgumentException("From timestamp must be specified in the clear bulletin request.");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, clearBulletinsRequestEntity);
+ }
+
+ final RemoteProcessGroupEntity requestRemoteProcessGroupEntity = new RemoteProcessGroupEntity();
+ requestRemoteProcessGroupEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestRemoteProcessGroupEntity,
+ lookup -> {
+ final Authorizable remoteProcessGroup = lookup.getRemoteProcessGroup(id);
+ remoteProcessGroup.authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ },
+ () -> { },
+ (remoteProcessGroupEntity) -> {
+ final Instant fromTimestamp = clearBulletinsRequestEntity.getFromTimestamp();
+
+ // Clear bulletins for the remote process group
+ final ClearBulletinsResultEntity entity = serviceFacade.clearBulletinsForComponent(remoteProcessGroupEntity.getId(), fromTimestamp);
+
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
+
/**
* Updates the specified remote process group input port.
*
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ReportingTaskResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ReportingTaskResource.java
index 7b98fa8a36..c139ded985 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ReportingTaskResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ReportingTaskResource.java
@@ -66,6 +66,8 @@ import org.apache.nifi.web.api.dto.ConfigurationAnalysisDTO;
import org.apache.nifi.web.api.dto.PropertyDescriptorDTO;
import org.apache.nifi.web.api.dto.ReportingTaskDTO;
import org.apache.nifi.web.api.dto.VerifyConfigRequestDTO;
+import org.apache.nifi.web.api.entity.ClearBulletinsRequestEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
import org.apache.nifi.web.api.entity.ComponentStateEntity;
import org.apache.nifi.web.api.entity.ConfigurationAnalysisEntity;
import org.apache.nifi.web.api.entity.PropertyDescriptorEntity;
@@ -80,6 +82,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import java.util.Collections;
+import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -389,6 +392,77 @@ public class ReportingTaskResource extends ApplicationResource {
);
}
+ /**
+ * Clears bulletins for a reporting task.
+ *
+ * @param id The id of the reporting task
+ * @param clearBulletinsRequestEntity The clear bulletin request
+ * @return a clearBulletinsResultEntity
+ */
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("{id}/bulletins/clear-requests")
+ @Operation(
+ summary = "Clears bulletins for a reporting task",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ClearBulletinsResultEntity.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 = "Write - /reporting-tasks/{uuid}")
+ }
+ )
+ public Response clearBulletins(
+ @Parameter(
+ description = "The reporting task id.",
+ required = true
+ )
+ @PathParam("id") final String id,
+ @Parameter(
+ description = "The clear bulletin request specifying the timestamp from which to clear bulletins.",
+ required = true
+ ) final ClearBulletinsRequestEntity clearBulletinsRequestEntity) {
+
+ if (clearBulletinsRequestEntity == null) {
+ throw new IllegalArgumentException("Clear bulletin request must be specified.");
+ }
+
+ // Validate the request
+ if (clearBulletinsRequestEntity.getFromTimestamp() == null) {
+ throw new IllegalArgumentException("From timestamp must be specified in the clear bulletin request.");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, clearBulletinsRequestEntity);
+ }
+
+ final ReportingTaskEntity requestReportingTaskEntity = new ReportingTaskEntity();
+ requestReportingTaskEntity.setId(id);
+
+ return withWriteLock(
+ serviceFacade,
+ requestReportingTaskEntity,
+ lookup -> {
+ final Authorizable reportingTask = lookup.getReportingTask(id).getAuthorizable();
+ reportingTask.authorize(authorizer, RequestAction.WRITE, NiFiUserUtils.getNiFiUser());
+ },
+ () -> { },
+ (reportingTaskEntity) -> {
+ final Instant fromTimestamp = clearBulletinsRequestEntity.getFromTimestamp();
+
+ // Clear bulletins for the reporting task
+ final ClearBulletinsResultEntity entity = serviceFacade.clearBulletinsForComponent(reportingTaskEntity.getId(), fromTimestamp);
+
+ return generateOkResponse(entity).build();
+ }
+ );
+ }
+
/**
* Updates the specified a Reporting Task.
*
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
index 684435065f..104d39f850 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
@@ -3519,6 +3519,7 @@ public final class DtoFactory {
dto.setId(bulletin.getId());
dto.setNodeAddress(bulletin.getNodeAddress());
dto.setTimestamp(bulletin.getTimestamp());
+ dto.setTimestampIso(bulletin.getTimestamp() != null ? bulletin.getTimestamp().toInstant() : null);
dto.setGroupId(bulletin.getGroupId());
dto.setSourceId(bulletin.getSourceId());
dto.setSourceName(bulletin.getSourceName());
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/EntityFactory.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/EntityFactory.java
index d176ae3d5a..39176ca8b1 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/EntityFactory.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/EntityFactory.java
@@ -794,6 +794,7 @@ public final class EntityFactory {
entity.setSourceId(dto.getSourceId());
entity.setGroupId(dto.getGroupId());
entity.setTimestamp(dto.getTimestamp());
+ entity.setTimestampIso(dto.getTimestampIso());
entity.setNodeAddress(dto.getNodeAddress());
entity.setCanRead(canRead);
if (canRead) {
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 49941a7233..f068301db4 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
@@ -98,6 +98,8 @@ import org.apache.nifi.web.api.dto.action.HistoryQueryDTO;
import org.apache.nifi.web.api.dto.status.StatusHistoryDTO;
import org.apache.nifi.web.api.entity.ActionEntity;
import org.apache.nifi.web.api.entity.AffectedComponentEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsResultEntity;
+import org.apache.nifi.web.api.entity.ClearBulletinsForGroupResultsEntity;
import org.apache.nifi.web.api.entity.CopyRequestEntity;
import org.apache.nifi.web.api.entity.CopyResponseEntity;
import org.apache.nifi.web.api.entity.ProcessGroupEntity;
@@ -123,11 +125,13 @@ import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import java.nio.charset.StandardCharsets;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
+import java.util.Iterator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -1098,6 +1102,16 @@ public class StandardNiFiServiceFacadeTest {
bulletinList.add(bulletin);
}
+ @Override
+ public List<Bulletin> findBulletinsForSource(String sourceId) {
+ List<Bulletin> ans = new ArrayList<>();
+ for (Bulletin b : bulletinList) {
+ if (sourceId.equals(b.getSourceId()))
+ ans.add(b);
+ }
+ return ans;
+ }
+
@Override
public List<Bulletin> findBulletinsForGroupBySource(String groupId) {
List<Bulletin> ans = new ArrayList<>();
@@ -1108,6 +1122,120 @@ public class StandardNiFiServiceFacadeTest {
return ans;
}
+ @Override
+ public int clearBulletinsForComponent(String sourceId, Instant fromTimestamp) {
+ int cleared = 0;
+ final Iterator<Bulletin> iterator = bulletinList.iterator();
+ while (iterator.hasNext()) {
+ final Bulletin bulletin = iterator.next();
+ if (sourceId.equals(bulletin.getSourceId())
+ && bulletin.getTimestamp() != null
+ && !bulletin.getTimestamp().toInstant().isAfter(fromTimestamp)) {
+ iterator.remove();
+ cleared++;
+ }
+ }
+ return cleared;
+ }
+
+ @Override
+ public int clearBulletinsForComponents(Collection<String> sourceIds, Instant fromTimestamp) {
+ if (sourceIds == null || sourceIds.isEmpty()) {
+ throw new IllegalArgumentException("Source ID cannot be null or empty");
+ }
+
+ int cleared = 0;
+ final Iterator<Bulletin> iterator = bulletinList.iterator();
+ while (iterator.hasNext()) {
+ final Bulletin bulletin = iterator.next();
+ if (sourceIds.contains(bulletin.getSourceId())
+ && bulletin.getTimestamp() != null
+ && !bulletin.getTimestamp().toInstant().isAfter(fromTimestamp)) {
+ iterator.remove();
+ cleared++;
+ }
+ }
+ return cleared;
+ }
+
+ public void addTestBulletin(String sourceId, String message, Instant timestamp) {
+ TestBulletin bulletin = new TestBulletin(sourceId, message, Date.from(timestamp));
+ bulletinList.add(bulletin);
+ }
+
+ }
+
+ private static class TestBulletin extends Bulletin {
+ private final String sourceId;
+ private final String message;
+ private final Date timestamp;
+
+ public TestBulletin(String sourceId, String message, Date timestamp) {
+ super(System.nanoTime());
+ this.sourceId = sourceId;
+ this.message = message;
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public String getSourceId() {
+ return sourceId;
+ }
+
+ @Override
+ public String getMessage() {
+ return message;
+ }
+
+ @Override
+ public Date getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public String getCategory() {
+ return "Test";
+ }
+
+ @Override
+ public String getLevel() {
+ return "INFO";
+ }
+
+ @Override
+ public ComponentType getSourceType() {
+ return ComponentType.PROCESSOR;
+ }
+
+ @Override
+ public String getSourceName() {
+ return "Test Component";
+ }
+
+ @Override
+ public String getGroupId() {
+ return "test-group";
+ }
+
+ @Override
+ public String getGroupName() {
+ return "Test Group";
+ }
+
+ @Override
+ public String getGroupPath() {
+ return "/";
+ }
+
+ @Override
+ public String getNodeAddress() {
+ return null;
+ }
+
+ @Override
+ public String getFlowFileUuid() {
+ return null;
+ }
}
@Test
@@ -1449,4 +1577,91 @@ public class StandardNiFiServiceFacadeTest {
verify(controllerFacade, times(1)).resetAllCounters();
verify(dtoFactory, times(1)).createCountersDto(any());
}
+
+ @Test
+ public void testClearBulletinsForComponent() {
+ final String componentId = "test-component-123";
+ final Instant fromTimestamp = Instant.now();
+
+ MockTestBulletinRepository bulletinRepository = new MockTestBulletinRepository();
+
+ // Add some test bulletins with different timestamps
+ Instant beforeTime = fromTimestamp.minusSeconds(10);
+ Instant afterTime = fromTimestamp.plusSeconds(10);
+
+ bulletinRepository.addTestBulletin(componentId, "Before message", beforeTime);
+ bulletinRepository.addTestBulletin(componentId, "At time message", fromTimestamp);
+ bulletinRepository.addTestBulletin(componentId, "After message", afterTime);
+ bulletinRepository.addTestBulletin("other-component", "Other component message", afterTime);
+
+ serviceFacade.setBulletinRepository(bulletinRepository);
+
+ ClearBulletinsResultEntity result = serviceFacade.clearBulletinsForComponent(componentId, fromTimestamp);
+
+ assertNotNull(result);
+ assertEquals(componentId, result.getComponentId());
+ assertEquals(2, result.getBulletinsCleared()); // Should clear bulletins older than or equal to fromTimestamp
+ assertEquals(2, bulletinRepository.bulletinList.size()); // 2 bulletins should remain (after time + other component)
+ }
+
+ @Test
+ public void testClearBulletinsForComponents() {
+ final String processGroupId = "test-process-group-123";
+ final Instant fromTimestamp = Instant.now();
+ final Set<String> componentIds = Set.of("component-1", "component-2");
+
+ MockTestBulletinRepository bulletinRepository = new MockTestBulletinRepository();
+
+ // Add test bulletins for multiple components
+ Instant beforeTime = fromTimestamp.minusSeconds(10);
+ Instant afterTime = fromTimestamp.plusSeconds(10);
+
+ bulletinRepository.addTestBulletin("component-1", "Component 1 before", beforeTime);
+ bulletinRepository.addTestBulletin("component-1", "Component 1 after", afterTime);
+ bulletinRepository.addTestBulletin("component-2", "Component 2 after", afterTime);
+ bulletinRepository.addTestBulletin("component-3", "Component 3 after", afterTime);
+
+ serviceFacade.setBulletinRepository(bulletinRepository);
+
+ ClearBulletinsForGroupResultsEntity result = serviceFacade.clearBulletinsForComponents(
+ processGroupId, fromTimestamp, componentIds);
+
+ assertNotNull(result);
+ assertEquals(1, result.getBulletinsCleared()); // Should clear 1 bulletin (component-1 before only)
+ assertEquals(3, bulletinRepository.bulletinList.size()); // 3 bulletins should remain (all "after" bulletins)
+ }
+
+ @Test
+ public void testClearBulletinsForComponentWithZeroResult() {
+ final String componentId = "non-existent-component";
+ final Instant fromTimestamp = Instant.now();
+
+ MockTestBulletinRepository bulletinRepository = new MockTestBulletinRepository();
+
+ // Add a bulletin for a different component
+ bulletinRepository.addTestBulletin("other-component", "Other message", Instant.now());
+
+ serviceFacade.setBulletinRepository(bulletinRepository);
+
+ ClearBulletinsResultEntity result = serviceFacade.clearBulletinsForComponent(componentId, fromTimestamp);
+
+ assertNotNull(result);
+ assertEquals(componentId, result.getComponentId());
+ assertEquals(0, result.getBulletinsCleared());
+ assertEquals(1, bulletinRepository.bulletinList.size()); // Original bulletin should remain
+ }
+
+ @Test
+ public void testClearBulletinsForComponentsWithEmptySet() {
+ final String processGroupId = "test-process-group-123";
+ final Instant fromTimestamp = Instant.now();
+ final Set<String> emptyComponentIds = Collections.emptySet();
+
+ MockTestBulletinRepository bulletinRepository = new MockTestBulletinRepository();
+ serviceFacade.setBulletinRepository(bulletinRepository);
+
+ assertThrows(IllegalArgumentException.class, () -> {
+ serviceFacade.clearBulletinsForComponents(processGroupId, fromTimestamp, emptyComponentIds);
+ });
+ }
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.spec.ts
new file mode 100644
index 0000000000..bfd5f7d264
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.spec.ts
@@ -0,0 +1,441 @@
+/*
+ * 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 { TestBed } from '@angular/core/testing';
+import { provideMockStore, MockStore } from '@ngrx/store/testing';
+import { Router } from '@angular/router';
+import { MatDialog } from '@angular/material/dialog';
+import * as d3 from 'd3';
+
+import { CanvasActionsService } from './canvas-actions.service';
+import { CanvasUtils } from './canvas-utils.service';
+import { CanvasView } from './canvas-view.service';
+import { CopyPasteService } from './copy-paste.service';
+import { Client } from '../../../service/client.service';
+import { CanvasState } from '../state';
+import { flowFeatureKey } from '../state/flow';
+import * as fromFlow from '../state/flow/flow.reducer';
+import { transformFeatureKey } from '../state/transform';
+import * as fromTransform from '../state/transform/transform.reducer';
+import { controllerServicesFeatureKey } from '../state/controller-services';
+import * as fromControllerServices from '../state/controller-services/controller-services.reducer';
+import { parameterFeatureKey } from '../state/parameter';
+import * as fromParameter from '../state/parameter/parameter.reducer';
+import { queueFeatureKey } from '../../queue/state';
+import * as fromQueue from '../state/queue/queue.reducer';
+import { flowAnalysisFeatureKey } from '../state/flow-analysis';
+import * as fromFlowAnalysis from '../state/flow-analysis/flow-analysis.reducer';
+import * as FlowActions from '../state/flow/flow.actions';
+import { ComponentType, NiFiCommon } from '@nifi/shared';
+
+// Mock d3 module
+jest.mock('d3', () => ({
+ selectAll: jest.fn()
+}));
+
+describe('CanvasActionsService', () => {
+ let service: CanvasActionsService;
+ let store: MockStore;
+ let mockNiFiCommon: jest.Mocked<NiFiCommon>;
+
+ const initialState: CanvasState = {
+ [flowFeatureKey]: fromFlow.initialState,
+ [transformFeatureKey]: fromTransform.initialState,
+ [controllerServicesFeatureKey]: fromControllerServices.initialState,
+ [parameterFeatureKey]: fromParameter.initialState,
+ [queueFeatureKey]: fromQueue.initialState,
+ [flowAnalysisFeatureKey]: fromFlowAnalysis.initialState
+ };
+
+ beforeEach(() => {
+ // Create mock NiFiCommon - only mock the methods we actually use in tests
+ mockNiFiCommon = {
+ getMostRecentBulletinTimestamp: jest.fn()
+ } as any;
+
+ TestBed.configureTestingModule({
+ providers: [
+ CanvasActionsService,
+ provideMockStore({ initialState }),
+ {
+ provide: NiFiCommon,
+ useValue: mockNiFiCommon
+ },
+ {
+ provide: Router,
+ useValue: {
+ navigate: jest.fn()
+ }
+ },
+ {
+ provide: CanvasUtils,
+ useValue: {
+ isProcessor: jest.fn().mockReturnValue(false),
+ isRemoteProcessGroup: jest.fn().mockReturnValue(false),
+ isLabel: jest.fn().mockReturnValue(false),
+ isConnection: jest.fn().mockReturnValue(false),
+ canRead: jest.fn().mockReturnValue(true),
+ canWrite: jest.fn().mockReturnValue(true),
+ canCopy: jest.fn().mockReturnValue(true),
+ canModify: jest.fn().mockReturnValue(true),
+ canDelete: jest.fn().mockReturnValue(true),
+ getComponentByType: jest.fn(),
+ moveToFront: jest.fn(),
+ moveToBack: jest.fn(),
+ getSelectionByComponentType: jest.fn()
+ }
+ },
+ {
+ provide: CanvasView,
+ useValue: {
+ updateCanvasVisibility: jest.fn(),
+ centerBoundingBox: jest.fn(),
+ isCanvasInitialized: jest.fn().mockReturnValue(false)
+ }
+ },
+ {
+ provide: MatDialog,
+ useValue: {
+ open: jest.fn()
+ }
+ },
+ {
+ provide: Client,
+ useValue: {
+ isSecure: jest.fn().mockReturnValue(false)
+ }
+ },
+ {
+ provide: CopyPasteService,
+ useValue: {
+ isCopiedContentInView: jest.fn(),
+ toOffsetPasteRequest: jest.fn(),
+ toCenteredPasteRequest: jest.fn(),
+ paste: jest.fn()
+ }
+ }
+ ]
+ });
+ service = TestBed.inject(CanvasActionsService);
+ store = TestBed.inject(MockStore);
+
+ jest.spyOn(store, 'dispatch');
+ });
+
+ describe('clearBulletins action', () => {
+ let mockProcessGroupId: string;
+ let clearBulletinsAction: any;
+
+ beforeEach(() => {
+ mockProcessGroupId = 'test-process-group-id';
+ jest.spyOn(service, 'currentProcessGroupId').mockReturnValue(mockProcessGroupId);
+ clearBulletinsAction = service.getAction('clearBulletins');
+ });
+
+ describe('condition', () => {
+ let clearBulletinsCondition: any;
+ let mockD3SelectAll: jest.MockedFunction<typeof d3.selectAll>;
+
+ beforeEach(() => {
+ clearBulletinsCondition = service.getConditionFunction('clearBulletins');
+ mockD3SelectAll = d3.selectAll as jest.MockedFunction<typeof d3.selectAll>;
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return true when selection is empty and components with bulletins exist', () => {
+ // Create a mock empty selection
+ const mockEmptySelection = {
+ empty: jest.fn().mockReturnValue(true)
+ };
+
+ // Mock d3.selectAll to return non-empty selection for components with bulletins
+ mockD3SelectAll.mockReturnValue({
+ empty: jest.fn().mockReturnValue(false)
+ } as any);
+
+ const result = clearBulletinsCondition(mockEmptySelection);
+
+ expect(result).toBe(true);
+ expect(mockD3SelectAll).toHaveBeenCalledWith('g.component.has-bulletins');
+ });
+
+ it('should return false when selection is empty and no components with bulletins exist', () => {
+ const mockEmptySelection = {
+ empty: jest.fn().mockReturnValue(true)
+ };
+
+ // Mock d3.selectAll to return empty selection (no components with bulletins)
+ mockD3SelectAll.mockReturnValue({
+ empty: jest.fn().mockReturnValue(true)
+ } as any);
+
+ const result = clearBulletinsCondition(mockEmptySelection);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true for single component with bulletins and write permissions', () => {
+ const mockSelection = {
+ empty: jest.fn().mockReturnValue(false),
+ size: jest.fn().mockReturnValue(1),
+ datum: jest.fn().mockReturnValue({
+ type: ComponentType.Processor,
+ permissions: { canWrite: true }
+ }),
+ classed: jest.fn().mockReturnValue(true)
+ };
+
+ const result = clearBulletinsCondition(mockSelection);
+
+ expect(result).toBe(true);
+ expect(mockSelection.classed).toHaveBeenCalledWith('has-bulletins');
+ });
+
+ it('should return false for single component without write permissions', () => {
+ const mockSelection = {
+ empty: jest.fn().mockReturnValue(false),
+ size: jest.fn().mockReturnValue(1),
+ datum: jest.fn().mockReturnValue({
+ type: ComponentType.Processor,
+ permissions: { canWrite: false }
+ }),
+ classed: jest.fn().mockReturnValue(true)
+ };
+
+ const result = clearBulletinsCondition(mockSelection);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true for process group with bulletins (permissions not required)', () => {
+ const mockSelection = {
+ empty: jest.fn().mockReturnValue(false),
+ size: jest.fn().mockReturnValue(1),
+ datum: jest.fn().mockReturnValue({
+ type: ComponentType.ProcessGroup
+ }),
+ classed: jest.fn().mockReturnValue(true)
+ };
+
+ const result = clearBulletinsCondition(mockSelection);
+
+ expect(result).toBe(true);
+ expect(mockSelection.classed).toHaveBeenCalledWith('has-bulletins');
+ });
+
+ it('should return false for single component without bulletins', () => {
+ const mockSelection = {
+ empty: jest.fn().mockReturnValue(false),
+ size: jest.fn().mockReturnValue(1),
+ datum: jest.fn().mockReturnValue({
+ type: ComponentType.Processor,
+ permissions: { canWrite: true }
+ }),
+ classed: jest.fn().mockReturnValue(false)
+ };
+
+ const result = clearBulletinsCondition(mockSelection);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false for multiple component selection', () => {
+ const mockSelection = {
+ empty: jest.fn().mockReturnValue(false),
+ size: jest.fn().mockReturnValue(2)
+ };
+
+ const result = clearBulletinsCondition(mockSelection);
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('action', () => {
+ let mockD3SelectAll: jest.SpyInstance;
+
+ beforeEach(() => {
+ mockD3SelectAll = jest.spyOn(d3, 'selectAll');
+ });
+
+ it('should dispatch clearBulletinsForProcessGroup for empty selection when bulletins exist', () => {
+ const mockTimestamp = '2023-10-07T12:00:00.000Z';
+ const mockEmptySelection = {
+ empty: jest.fn().mockReturnValue(true)
+ };
+
+ // Mock d3.selectAll to return components with bulletins
+ const mockEach = jest.fn((callback: (d: any) => void) => {
+ // Simulate calling the callback with mock data that has bulletins
+ callback({
+ bulletins: [
+ { timestampIso: '2023-10-07T12:00:00.000Z', bulletin: { timestamp: '12:00:00 UTC' } }
+ ]
+ });
+ });
+
+ mockD3SelectAll.mockReturnValue({
+ each: mockEach
+ } as any);
+
+ // Mock getMostRecentBulletinTimestamp to return a timestamp
+ mockNiFiCommon.getMostRecentBulletinTimestamp.mockReturnValue(mockTimestamp);
+
+ clearBulletinsAction.action(mockEmptySelection);
+
+ expect(mockD3SelectAll).toHaveBeenCalledWith('g.component.has-bulletins');
+ expect(mockNiFiCommon.getMostRecentBulletinTimestamp).toHaveBeenCalled();
+ expect(store.dispatch).toHaveBeenCalledWith(
+ FlowActions.clearBulletinsForProcessGroup({
+ request: {
+ processGroupId: mockProcessGroupId,
+ fromTimestamp: mockTimestamp
+ }
+ })
+ );
+ });
+
+ it('should not dispatch for empty selection if no bulletins are found', () => {
+ const mockEmptySelection = {
+ empty: jest.fn().mockReturnValue(true)
+ };
+
+ // Mock d3.selectAll to return components without bulletins
+ const mockEach = jest.fn((callback: (d: any) => void) => {
+ // Simulate calling the callback with mock data that has no bulletins
+ callback({ bulletins: null });
+ });
+
+ mockD3SelectAll.mockReturnValue({
+ each: mockEach
+ } as any);
+
+ // Mock getMostRecentBulletinTimestamp to return null (no bulletins)
+ mockNiFiCommon.getMostRecentBulletinTimestamp.mockReturnValue(null);
+
+ clearBulletinsAction.action(mockEmptySelection);
+
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
+
+ it('should dispatch clearBulletinsForProcessGroup for process group selection using getMostRecentBulletinTimestamp', () => {
+ const mockTimestamp = '2023-10-07T12:00:00.000Z';
+ const mockBulletins = [
+ { timestampIso: '2023-10-07T12:00:00.000Z', bulletin: { timestamp: '12:00:00 UTC' } }
+ ];
+ const mockProcessGroupData = {
+ type: ComponentType.ProcessGroup,
+ id: 'selected-process-group-id',
+ bulletins: mockBulletins
+ };
+
+ const mockSelection = {
+ empty: jest.fn().mockReturnValue(false),
+ size: jest.fn().mockReturnValue(1),
+ datum: jest.fn().mockReturnValue(mockProcessGroupData)
+ };
+
+ mockNiFiCommon.getMostRecentBulletinTimestamp.mockReturnValue(mockTimestamp);
+
+ clearBulletinsAction.action(mockSelection);
+
+ expect(mockNiFiCommon.getMostRecentBulletinTimestamp).toHaveBeenCalledWith(mockBulletins);
+ expect(store.dispatch).toHaveBeenCalledWith(
+ FlowActions.clearBulletinsForProcessGroup({
+ request: {
+ processGroupId: mockProcessGroupData.id,
+ fromTimestamp: mockTimestamp
+ }
+ })
+ );
+ });
+
+ it('should dispatch clearBulletinForComponent for processor selection using getMostRecentBulletinTimestamp', () => {
+ const mockTimestamp = '2023-10-07T12:00:00.000Z';
+ const mockBulletins = [
+ { timestampIso: '2023-10-07T12:00:00.000Z', bulletin: { timestamp: '12:00:00 UTC' } }
+ ];
+ const mockProcessorData = {
+ type: ComponentType.Processor,
+ id: 'processor-id',
+ uri: 'processor-uri',
+ bulletins: mockBulletins
+ };
+
+ const mockSelection = {
+ empty: jest.fn().mockReturnValue(false),
+ size: jest.fn().mockReturnValue(1),
+ datum: jest.fn().mockReturnValue(mockProcessorData)
+ };
+
+ mockNiFiCommon.getMostRecentBulletinTimestamp.mockReturnValue(mockTimestamp);
+
+ clearBulletinsAction.action(mockSelection);
+
+ expect(mockNiFiCommon.getMostRecentBulletinTimestamp).toHaveBeenCalledWith(mockBulletins);
+ expect(store.dispatch).toHaveBeenCalledWith(
+ FlowActions.clearBulletinsForComponent({
+ request: {
+ uri: mockProcessorData.uri,
+ fromTimestamp: mockTimestamp,
+ componentId: mockProcessorData.id,
+ componentType: mockProcessorData.type
+ }
+ })
+ );
+ });
+
+ it('should dispatch clearBulletinForComponent for input port selection using getMostRecentBulletinTimestamp', () => {
+ const mockTimestamp = '2023-10-07T12:00:00.000Z';
+ const mockBulletins = [
+ { timestampIso: '2023-10-07T12:00:00.000Z', bulletin: { timestamp: '12:00:00 UTC' } }
+ ];
+ const mockInputPortData = {
+ type: ComponentType.InputPort,
+ id: 'input-port-id',
+ uri: 'input-port-uri',
+ bulletins: mockBulletins
+ };
+
+ const mockSelection = {
+ empty: jest.fn().mockReturnValue(false),
+ size: jest.fn().mockReturnValue(1),
+ datum: jest.fn().mockReturnValue(mockInputPortData)
+ };
+
+ mockNiFiCommon.getMostRecentBulletinTimestamp.mockReturnValue(mockTimestamp);
+
+ clearBulletinsAction.action(mockSelection);
+
+ expect(mockNiFiCommon.getMostRecentBulletinTimestamp).toHaveBeenCalledWith(mockBulletins);
+ expect(store.dispatch).toHaveBeenCalledWith(
+ FlowActions.clearBulletinsForComponent({
+ request: {
+ uri: mockInputPortData.uri,
+ fromTimestamp: mockTimestamp,
+ componentId: mockInputPortData.id,
+ componentType: mockInputPortData.type
+ }
+ })
+ );
+ });
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts
index 370a387b0f..b335dccb55 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts
@@ -18,6 +18,8 @@
import { Injectable, inject } from '@angular/core';
import { CanvasUtils } from './canvas-utils.service';
import {
+ clearBulletinsForComponent,
+ clearBulletinsForProcessGroup,
copySuccess,
deleteComponents,
disableComponents,
@@ -52,7 +54,7 @@ import { CanvasState } from '../state';
import * as d3 from 'd3';
import { MatDialog } from '@angular/material/dialog';
import { CanvasView } from './canvas-view.service';
-import { ComponentType } from '@nifi/shared';
+import { ComponentType, NiFiCommon } from '@nifi/shared';
import { Client } from '../../../service/client.service';
import { CopyRequestContext, CopyRequestEntity, CopyResponseEntity } from '../../../state/copy';
import { CopyPasteService } from './copy-paste.service';
@@ -83,6 +85,7 @@ export class CanvasActionsService {
private dialog = inject(MatDialog);
private client = inject(Client);
private copyService = inject(CopyPasteService);
+ private nifiCommon = inject(NiFiCommon);
private _actions: CanvasActions = {
delete: {
@@ -535,6 +538,102 @@ export class CanvasActionsService {
})
);
}
+ },
+ clearBulletins: {
+ id: 'clearBulletins',
+ condition: (selection: d3.Selection<any, any, any, any>) => {
+ // Empty selection means user is opening context menu for current process group
+ if (selection.empty()) {
+ // Use d3 to select components with has-bulletins class
+ // If this selection is non-empty, clear bulletins action should be available
+ const componentsWithBulletins = d3.selectAll('g.component.has-bulletins');
+ return !componentsWithBulletins.empty();
+ }
+
+ // Show clear bulletins option only when there are bulletins to clear
+ if (selection.size() === 1) {
+ const d = selection.datum();
+
+ // For individual components (not process groups), check write permissions
+ if (d.type !== ComponentType.ProcessGroup) {
+ if (!d.permissions.canWrite) {
+ return false;
+ }
+ }
+
+ // Check if the component has the has-bulletins class
+ return selection.classed('has-bulletins');
+ }
+ return false;
+ },
+ action: (selection: d3.Selection<any, any, any, any>) => {
+ if (selection.empty()) {
+ // Clear bulletins for current process group when no selection
+ // Get all components with bulletins and find the most recent timestamp
+ const componentsWithBulletins = d3.selectAll('g.component.has-bulletins');
+
+ // Find the most recent timestamp across all components
+ let mostRecentTimestamp: string | null = null;
+ let mostRecentTime: number | null = null;
+ componentsWithBulletins.each((d: any) => {
+ if (d?.bulletins) {
+ const componentTimestamp = this.nifiCommon.getMostRecentBulletinTimestamp(d.bulletins);
+ if (componentTimestamp !== null) {
+ const componentTime = new Date(componentTimestamp).getTime();
+ if (mostRecentTime === null || componentTime > mostRecentTime) {
+ mostRecentTime = componentTime;
+ mostRecentTimestamp = componentTimestamp;
+ }
+ }
+ }
+ });
+
+ if (mostRecentTimestamp === null || mostRecentTimestamp === undefined) {
+ return; // no bulletins to clear
+ }
+
+ this.store.dispatch(
+ clearBulletinsForProcessGroup({
+ request: {
+ processGroupId: this.currentProcessGroupId(),
+ fromTimestamp: mostRecentTimestamp
+ }
+ })
+ );
+ } else if (selection.size() === 1) {
+ const d = selection.datum();
+
+ // Get the most recent bulletin timestamp from the selected component
+ const fromTimestamp = this.nifiCommon.getMostRecentBulletinTimestamp(d.bulletins);
+ if (fromTimestamp === null) {
+ return; // no bulletins to clear
+ }
+
+ if (d.type === ComponentType.ProcessGroup) {
+ // For process groups, clear bulletins for all child components
+ this.store.dispatch(
+ clearBulletinsForProcessGroup({
+ request: {
+ processGroupId: d.id,
+ fromTimestamp
+ }
+ })
+ );
+ } else {
+ // Clear bulletins for this component
+ this.store.dispatch(
+ clearBulletinsForComponent({
+ request: {
+ uri: d.uri,
+ fromTimestamp,
+ componentId: d.id,
+ componentType: d.type
+ }
+ })
+ );
+ }
+ }
+ }
}
};
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 80bfeaaa7a..8fa55e42f5 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
@@ -1208,6 +1208,12 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
text: 'Change Color',
action: this.canvasActionsService.getActionFunction('changeColor')
},
+ {
+ condition: this.canvasActionsService.getConditionFunction('clearBulletins'),
+ clazz: 'fa fa-eraser',
+ text: 'Clear Bulletins',
+ action: this.canvasActionsService.getActionFunction('clearBulletins')
+ },
{
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.canRead(selection) && this.canvasUtils.isRemoteProcessGroup(selection);
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 958abfe4b4..7ed80573fc 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
@@ -37,7 +37,7 @@ import { queueFeatureKey } from '../../queue/state';
import * as fromQueue from '../state/queue/queue.reducer';
import { flowAnalysisFeatureKey } from '../state/flow-analysis';
import * as fromFlowAnalysis from '../state/flow-analysis/flow-analysis.reducer';
-import { ComponentType } from '@nifi/shared';
+import { ComponentType, BulletinEntity } from '@nifi/shared';
import * as d3 from 'd3';
describe('CanvasUtils', () => {
@@ -139,9 +139,9 @@ describe('CanvasUtils', () => {
});
it('should return false for multiple selections', () => {
- const div1 = document.createElement('g');
- const div2 = document.createElement('g');
- const multiSelection = d3.selectAll([div1, div2]);
+ const g1 = document.createElement('g');
+ const g2 = document.createElement('g');
+ const multiSelection = d3.selectAll([g1, g2]);
expect(service.isStoppable(multiSelection)).toBe(false);
});
@@ -409,4 +409,254 @@ describe('CanvasUtils', () => {
});
});
});
+
+ describe('bulletins', () => {
+ let mockSelection: any;
+
+ beforeEach(() => {
+ // Create a mock DOM element and selection
+ const g = document.createElement('g');
+ g.classList.add('component');
+
+ // Create mock bulletin background and icon elements
+ const bulletinBackground = document.createElement('rect');
+ bulletinBackground.classList.add('bulletin-background');
+ const bulletinIcon = document.createElement('text');
+ bulletinIcon.classList.add('bulletin-icon');
+
+ g.appendChild(bulletinBackground);
+ g.appendChild(bulletinIcon);
+
+ document.body.appendChild(g);
+
+ mockSelection = d3.select(g);
+ });
+
+ afterEach(() => {
+ // Clean up DOM
+ const elements = document.querySelectorAll('g.component');
+ elements.forEach((el) => el.remove());
+ });
+
+ it('should add has-bulletins class when bulletins are present', () => {
+ const bulletins: BulletinEntity[] = [
+ {
+ id: 1,
+ canRead: true,
+ timestampIso: new Date().toISOString(),
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: '12:00:00 UTC',
+ bulletin: {
+ id: 1,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'ERROR',
+ message: 'Test error message',
+ timestamp: '12:00:00 UTC',
+ timestampIso: new Date().toISOString()
+ }
+ }
+ ];
+
+ service.bulletins(mockSelection, bulletins);
+
+ expect(mockSelection.classed('has-bulletins')).toBe(true);
+ });
+
+ it('should remove has-bulletins class when no bulletins are present', () => {
+ // First add the class
+ mockSelection.classed('has-bulletins', true);
+ expect(mockSelection.classed('has-bulletins')).toBe(true);
+
+ // Call bulletins with empty array
+ service.bulletins(mockSelection, []);
+
+ expect(mockSelection.classed('has-bulletins')).toBe(false);
+ });
+
+ it('should remove has-bulletins class when bulletins array is null', () => {
+ // First add the class
+ mockSelection.classed('has-bulletins', true);
+ expect(mockSelection.classed('has-bulletins')).toBe(true);
+
+ // Call bulletins with null
+ service.bulletins(mockSelection, null as any);
+
+ expect(mockSelection.classed('has-bulletins')).toBe(false);
+ });
+
+ it('should remove has-bulletins class when bulletins array is undefined', () => {
+ // First add the class
+ mockSelection.classed('has-bulletins', true);
+ expect(mockSelection.classed('has-bulletins')).toBe(true);
+
+ // Call bulletins with undefined
+ service.bulletins(mockSelection, undefined as any);
+
+ expect(mockSelection.classed('has-bulletins')).toBe(false);
+ });
+
+ it('should filter out bulletins with canRead=false and only show readable bulletins', () => {
+ const bulletins: BulletinEntity[] = [
+ {
+ id: 1,
+ canRead: false, // Not readable
+ timestampIso: new Date().toISOString(),
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: '12:00:00 UTC',
+ bulletin: {
+ id: 1,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'ERROR',
+ message: 'Hidden error message',
+ timestamp: '12:00:00 UTC',
+ timestampIso: new Date().toISOString()
+ }
+ },
+ {
+ id: 2,
+ canRead: true, // Readable
+ timestampIso: new Date().toISOString(),
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: '12:01:00 UTC',
+ bulletin: {
+ id: 2,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'WARN',
+ message: 'Visible warning message',
+ timestamp: '12:00:00 UTC',
+ timestampIso: new Date().toISOString()
+ }
+ }
+ ];
+
+ service.bulletins(mockSelection, bulletins);
+
+ // Should add has-bulletins class because there is one readable bulletin
+ expect(mockSelection.classed('has-bulletins')).toBe(true);
+ });
+
+ it('should remove has-bulletins class when all bulletins have canRead=false', () => {
+ const bulletins: BulletinEntity[] = [
+ {
+ id: 1,
+ canRead: false, // Not readable
+ timestampIso: new Date().toISOString(),
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: '12:00:00 UTC',
+ bulletin: {
+ id: 1,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'ERROR',
+ message: 'Hidden error message',
+ timestamp: '12:00:00 UTC',
+ timestampIso: new Date().toISOString()
+ }
+ }
+ ];
+
+ service.bulletins(mockSelection, bulletins);
+
+ // Should not add has-bulletins class because there are no readable bulletins
+ expect(mockSelection.classed('has-bulletins')).toBe(false);
+ });
+
+ it('should remove has-bulletins class when bulletins have missing bulletin property', () => {
+ const bulletins: BulletinEntity[] = [
+ {
+ id: 1,
+ canRead: true,
+ timestampIso: new Date().toISOString(),
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: '12:00:00 UTC',
+ bulletin: null as any // Missing bulletin data
+ }
+ ];
+
+ service.bulletins(mockSelection, bulletins);
+
+ // Should not add has-bulletins class because bulletin data is missing
+ expect(mockSelection.classed('has-bulletins')).toBe(false);
+ });
+
+ it('should set appropriate level class based on most severe bulletin', () => {
+ const bulletins: BulletinEntity[] = [
+ {
+ id: 1,
+ canRead: true,
+ timestampIso: new Date().toISOString(),
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: '12:00:00 UTC',
+ bulletin: {
+ id: 1,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'INFO',
+ message: 'Info message',
+ timestamp: '12:00:00 UTC',
+ timestampIso: new Date().toISOString()
+ }
+ },
+ {
+ id: 2,
+ canRead: true,
+ timestampIso: new Date().toISOString(),
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: '12:01:00 UTC',
+ bulletin: {
+ id: 2,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'ERROR', // Most severe
+ message: 'Error message',
+ timestamp: '12:01:00 UTC',
+ timestampIso: new Date().toISOString()
+ }
+ }
+ ];
+
+ service.bulletins(mockSelection, bulletins);
+
+ expect(mockSelection.classed('has-bulletins')).toBe(true);
+
+ // Check that the error level class is applied to the bulletin icon
+ const bulletinIcon = mockSelection.select('text.bulletin-icon');
+ expect(bulletinIcon.classed('error')).toBe(true);
+ expect(bulletinIcon.classed('info')).toBe(false);
+ });
+ });
});
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 47aa26ccf6..9131bbd113 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
@@ -1284,6 +1284,9 @@ export class CanvasUtils {
selection.select('text.bulletin-icon').style('visibility', 'hidden');
selection.select('rect.bulletin-background').style('visibility', 'hidden');
+ // remove the has-bulletins class
+ selection.classed('has-bulletins', false);
+
// reset the canvas tooltip
this.resetCanvasTooltip(selection);
}
@@ -1316,6 +1319,9 @@ export class CanvasUtils {
.select('rect.bulletin-background')
.style('visibility', 'visible');
+ // add the has-bulletins class to indicate this component has bulletins
+ selection.classed('has-bulletins', true);
+
// reset any level-specifying classes that might have been there before
bulletinIcon
.classed('trace', false)
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/controller-service.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/controller-service.service.ts
index c5188a69e0..4e477aa948 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/controller-service.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/controller-service.service.ts
@@ -94,6 +94,13 @@ export class ControllerServiceService implements ControllerServiceCreator, Prope
);
}
+ clearBulletins(request: { uri: string; fromTimestamp: string }): Observable<any> {
+ const payload = {
+ fromTimestamp: request.fromTimestamp
+ };
+ return this.httpClient.post(`${this.nifiCommon.stripProtocol(request.uri)}/bulletins/clear-requests`, payload);
+ }
+
deleteControllerService(deleteControllerService: DeleteControllerServiceRequest): Observable<any> {
const entity: ControllerServiceEntity = deleteControllerService.controllerService;
const params = new HttpParams({
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts
index 8a98836064..fae04753d0 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts
@@ -19,6 +19,7 @@ import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';
import {
+ ClearBulletinsForGroupRequest,
ComponentRunStatusRequest,
ControllerServiceStateRequest,
CreateComponentRequest,
@@ -55,7 +56,7 @@ import {
import { Client } from '../../../service/client.service';
import { ComponentType, NiFiCommon } from '@nifi/shared';
import { ClusterConnectionService } from '../../../service/cluster-connection.service';
-import { PropertyDescriptorRetriever } from '../../../state/shared';
+import { ClearBulletinsRequest, PropertyDescriptorRetriever } from '../../../state/shared';
@Injectable({ providedIn: 'root' })
export class FlowService implements PropertyDescriptorRetriever {
@@ -475,4 +476,28 @@ export class FlowService implements PropertyDescriptorRetriever {
'noreferrer'
);
}
+
+ /*
+ Clear Bulletins
+ */
+
+ clearBulletinForComponent(request: ClearBulletinsRequest): Observable<any> {
+ const payload = {
+ fromTimestamp: request.fromTimestamp
+ };
+
+ return this.httpClient.post(`${this.nifiCommon.stripProtocol(request.uri)}/bulletins/clear-requests`, payload);
+ }
+
+ clearBulletinsForProcessGroup(request: ClearBulletinsForGroupRequest): Observable<any> {
+ const payload: any = {
+ id: request.processGroupId,
+ fromTimestamp: request.fromTimestamp
+ };
+
+ return this.httpClient.post(
+ `${FlowService.API}/flow/process-groups/${request.processGroupId}/bulletins/clear-requests`,
+ payload
+ );
+ }
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.actions.ts
index a966e1c571..1654b601c9 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.actions.ts
@@ -27,6 +27,8 @@ import {
SelectControllerServiceRequest
} from './index';
import {
+ ClearBulletinsRequest,
+ ClearBulletinsResponse,
CreateControllerServiceRequest,
DisableControllerServiceDialogRequest,
EditControllerServiceDialogRequest,
@@ -142,3 +144,13 @@ export const openChangeControllerServiceVersionDialog = createAction(
`[Controller Services] Open Change Controller Service Version Dialog`,
props<{ request: FetchComponentVersionsRequest }>()
);
+
+export const clearControllerServiceBulletins = createAction(
+ '[Flow Designer Controller Services] Clear Controller Service Bulletins',
+ props<{ request: ClearBulletinsRequest }>()
+);
+
+export const clearControllerServiceBulletinsSuccess = createAction(
+ '[Flow Designer Controller Services] Clear Controller Service Bulletins Success',
+ props<{ response: ClearBulletinsResponse }>()
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts
index f251b7c21e..1cb3f1fa2a 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts
@@ -6,7 +6,7 @@
* (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
+ * 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,
@@ -23,6 +23,15 @@ import { ReplaySubject, of, take, throwError } from 'rxjs';
import { HttpErrorResponse } from '@angular/common/http';
import * as ControllerServicesActions from './controller-services.actions';
+import {
+ clearControllerServiceBulletins,
+ clearControllerServiceBulletinsSuccess,
+ createControllerService,
+ createControllerServiceSuccess,
+ deleteControllerService,
+ deleteControllerServiceSuccess,
+ loadControllerServices
+} from './controller-services.actions';
import { ControllerServicesEffects } from './controller-services.effects';
import { ControllerServiceService } from '../../service/controller-service.service';
import { ErrorHelper } from '../../../../service/error-helper.service';
@@ -32,9 +41,14 @@ import { PropertyTableHelperService } from '../../../../service/property-table-h
import { ParameterHelperService } from '../../service/parameter-helper.service';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
import { Client } from '../../../../service/client.service';
-import { Storage } from '@nifi/shared';
+import { ComponentType, Storage } from '@nifi/shared';
import { ParameterContextService } from '../../../parameter-contexts/service/parameter-contexts.service';
+import { controllerServicesFeatureKey } from './index';
import { initialState } from './controller-services.reducer';
+import { ClearBulletinsRequest, ClearBulletinsResponse } from '../../../../state/shared';
+import * as ErrorActions from '../../../../state/error/error.actions';
+import { canvasFeatureKey } from '../index';
+import { flowFeatureKey } from '../flow';
describe('ControllerServicesEffects', () => {
interface SetupOptions {
@@ -54,36 +68,103 @@ describe('ControllerServicesEffects', () => {
} as any;
async function setup({ controllerServicesState = initialState }: SetupOptions = {}) {
+ const controllerServiceServiceSpy = {
+ getControllerServices: jest.fn(),
+ getControllerService: jest.fn(),
+ getFlow: jest.fn(),
+ createControllerService: jest.fn(),
+ updateControllerService: jest.fn(),
+ deleteControllerService: jest.fn(),
+ clearBulletins: jest.fn()
+ } as unknown as jest.Mocked<ControllerServiceService>;
+
await TestBed.configureTestingModule({
providers: [
ControllerServicesEffects,
provideMockActions(() => action$),
provideMockStore({
initialState: {
- canvas: {
- controllerServiceListing: controllerServicesState
+ [canvasFeatureKey]: {
+ [controllerServicesFeatureKey]: controllerServicesState,
+ [flowFeatureKey]: {
+ id: 'root'
+ }
}
}
}),
{
provide: ControllerServiceService,
- useValue: { getControllerServices: jest.fn(), getFlow: jest.fn() }
+ useValue: controllerServiceServiceSpy
+ },
+ {
+ provide: ErrorHelper,
+ useValue: {
+ handleLoadingError: jest.fn(),
+ getErrorString: jest.fn(),
+ showErrorInContext: jest.fn(),
+ fullScreenError: jest.fn()
+ }
+ },
+ {
+ provide: MatDialog,
+ useValue: {
+ open: jest.fn(),
+ closeAll: jest.fn()
+ }
+ },
+ {
+ provide: Router,
+ useValue: {
+ navigate: jest.fn()
+ }
+ },
+ {
+ provide: PropertyTableHelperService,
+ useValue: {
+ getComponentHistory: jest.fn(),
+ createNewProperty: jest.fn(),
+ createNewService: jest.fn()
+ }
+ },
+ {
+ provide: ParameterHelperService,
+ useValue: {
+ convertToParameter: jest.fn()
+ }
+ },
+ {
+ provide: ExtensionTypesService,
+ useValue: {
+ getControllerServiceVersionsForType: jest.fn()
+ }
},
- { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn(), getErrorString: jest.fn() } },
- { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
- { provide: Router, useValue: { navigate: jest.fn() } },
- { provide: PropertyTableHelperService, useValue: {} },
- { provide: ParameterHelperService, useValue: {} },
- { provide: ExtensionTypesService, useValue: {} },
- { provide: Client, useValue: {} },
- { provide: Storage, useValue: { setItem: jest.fn(), getItem: jest.fn() } },
- { provide: ParameterContextService, useValue: { getParameterContext: jest.fn() } }
+ {
+ provide: Client,
+ useValue: {
+ getClientId: jest.fn().mockReturnValue('test-client-id')
+ }
+ },
+ {
+ provide: Storage,
+ useValue: {
+ setItem: jest.fn(),
+ getItem: jest.fn()
+ }
+ },
+ {
+ provide: ParameterContextService,
+ useValue: {
+ getParameterContext: jest.fn()
+ }
+ }
]
}).compileComponents();
const store = TestBed.inject(MockStore);
const effects = TestBed.inject(ControllerServicesEffects);
- const controllerServiceService = TestBed.inject(ControllerServiceService);
+ const controllerServiceService = TestBed.inject(
+ ControllerServiceService
+ ) as jest.Mocked<ControllerServiceService>;
const errorHelper = TestBed.inject(ErrorHelper);
return { effects, store, controllerServiceService, errorHelper };
@@ -221,4 +302,344 @@ describe('ControllerServicesEffects', () => {
expect(result).toEqual(errorAction);
});
});
+
+ describe('clearControllerServiceBulletins$', () => {
+ it('should dispatch clearControllerServiceBulletinsSuccess action when bulletins are cleared successfully', async () => {
+ const { effects, controllerServiceService } = await setup();
+
+ const request: ClearBulletinsRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-controller-service-id',
+ componentType: ComponentType.ControllerService
+ };
+
+ const serviceResponse = {
+ bulletinsCleared: 5,
+ bulletins: []
+ };
+
+ const expectedResponse: ClearBulletinsResponse = {
+ componentId: request.componentId,
+ bulletinsCleared: 5,
+ bulletins: [],
+ componentType: request.componentType
+ };
+
+ jest.spyOn(controllerServiceService, 'clearBulletins').mockReturnValue(of(serviceResponse));
+ action$.next(clearControllerServiceBulletins({ request }));
+
+ const result = await new Promise((resolve) =>
+ effects.clearControllerServiceBulletins$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(controllerServiceService.clearBulletins).toHaveBeenCalledWith({
+ uri: request.uri,
+ fromTimestamp: request.fromTimestamp
+ });
+ expect(result).toEqual(clearControllerServiceBulletinsSuccess({ response: expectedResponse }));
+ });
+
+ it('should handle bulletinsCleared being undefined and default to 0', async () => {
+ const { effects, controllerServiceService } = await setup();
+
+ const request: ClearBulletinsRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-controller-service-id',
+ componentType: ComponentType.ControllerService
+ };
+
+ const serviceResponse = {
+ // bulletinsCleared is undefined
+ bulletins: []
+ };
+
+ const expectedResponse: ClearBulletinsResponse = {
+ componentId: request.componentId,
+ bulletinsCleared: 0, // Should default to 0
+ bulletins: [],
+ componentType: request.componentType
+ };
+
+ jest.spyOn(controllerServiceService, 'clearBulletins').mockReturnValue(of(serviceResponse));
+ action$.next(clearControllerServiceBulletins({ request }));
+
+ const result = await new Promise((resolve) =>
+ effects.clearControllerServiceBulletins$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(clearControllerServiceBulletinsSuccess({ response: expectedResponse }));
+ });
+
+ it('should preserve request fields in the success response', async () => {
+ const { effects, controllerServiceService } = await setup();
+
+ const request: ClearBulletinsRequest = {
+ uri: 'different-test-uri',
+ fromTimestamp: '2023-06-15T08:30:00.000Z',
+ componentId: 'different-controller-service-id',
+ componentType: ComponentType.ControllerService
+ };
+
+ const serviceResponse = {
+ bulletinsCleared: 3,
+ bulletins: []
+ };
+
+ jest.spyOn(controllerServiceService, 'clearBulletins').mockReturnValue(of(serviceResponse));
+ action$.next(clearControllerServiceBulletins({ request }));
+
+ const result = await new Promise<any>((resolve) =>
+ effects.clearControllerServiceBulletins$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result.response.componentId).toBe(request.componentId);
+ expect(result.response.componentType).toBe(request.componentType);
+ expect(result.response.bulletinsCleared).toBe(3);
+ expect(result.response.bulletins).toEqual([]);
+ });
+
+ it('should call service with correct parameters', async () => {
+ const { effects, controllerServiceService } = await setup();
+
+ const request: ClearBulletinsRequest = {
+ uri: 'test-controller-service-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-controller-service-id',
+ componentType: ComponentType.ControllerService
+ };
+
+ const serviceResponse = {
+ bulletinsCleared: 2,
+ bulletins: []
+ };
+
+ jest.spyOn(controllerServiceService, 'clearBulletins').mockReturnValue(of(serviceResponse));
+ action$.next(clearControllerServiceBulletins({ request }));
+
+ await new Promise<void>((resolve) =>
+ effects.clearControllerServiceBulletins$.pipe(take(1)).subscribe(() => {
+ expect(controllerServiceService.clearBulletins).toHaveBeenCalledWith({
+ uri: request.uri,
+ fromTimestamp: request.fromTimestamp
+ });
+ expect(controllerServiceService.clearBulletins).toHaveBeenCalledTimes(1);
+ resolve();
+ })
+ );
+ });
+
+ it('should handle zero bulletins cleared', async () => {
+ const { effects, controllerServiceService } = await setup();
+
+ const request: ClearBulletinsRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-controller-service-id',
+ componentType: ComponentType.ControllerService
+ };
+
+ const serviceResponse = {
+ bulletinsCleared: 0,
+ bulletins: []
+ };
+
+ const expectedResponse: ClearBulletinsResponse = {
+ componentId: request.componentId,
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: request.componentType
+ };
+
+ jest.spyOn(controllerServiceService, 'clearBulletins').mockReturnValue(of(serviceResponse));
+ action$.next(clearControllerServiceBulletins({ request }));
+
+ const result = await new Promise((resolve) =>
+ effects.clearControllerServiceBulletins$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(clearControllerServiceBulletinsSuccess({ response: expectedResponse }));
+ });
+
+ it('should handle large number of bulletins cleared', async () => {
+ const { effects, controllerServiceService } = await setup();
+
+ const request: ClearBulletinsRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-controller-service-id',
+ componentType: ComponentType.ControllerService
+ };
+
+ const serviceResponse = {
+ bulletinsCleared: 1000,
+ bulletins: []
+ };
+
+ const expectedResponse: ClearBulletinsResponse = {
+ componentId: request.componentId,
+ bulletinsCleared: 1000,
+ bulletins: [],
+ componentType: request.componentType
+ };
+
+ jest.spyOn(controllerServiceService, 'clearBulletins').mockReturnValue(of(serviceResponse));
+ action$.next(clearControllerServiceBulletins({ request }));
+
+ const result = await new Promise((resolve) =>
+ effects.clearControllerServiceBulletins$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(clearControllerServiceBulletinsSuccess({ response: expectedResponse }));
+ });
+ });
+
+ describe('createControllerService$', () => {
+ it('should dispatch createControllerServiceSuccess when service is created successfully', async () => {
+ const { effects, controllerServiceService } = await setup();
+
+ const request = {
+ revision: { clientId: 'test-client', version: 0 },
+ processGroupId: 'test-process-group-id',
+ controllerServiceType: 'org.apache.nifi.TestService',
+ controllerServiceBundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' }
+ };
+ const serviceResponse = {
+ id: 'new-service-id',
+ component: { name: 'New Test Service' }
+ } as any;
+
+ jest.spyOn(controllerServiceService, 'createControllerService').mockReturnValue(of(serviceResponse));
+ action$.next(createControllerService({ request }));
+
+ const result = await new Promise((resolve) =>
+ effects.createControllerService$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ createControllerServiceSuccess({
+ response: { controllerService: serviceResponse }
+ })
+ );
+ expect(controllerServiceService.createControllerService).toHaveBeenCalledWith(request);
+ });
+
+ it('should dispatch snackBarError and close dialogs when creation fails', async () => {
+ const { effects, controllerServiceService, errorHelper } = await setup();
+
+ const request = {
+ revision: { clientId: 'test-client', version: 0 },
+ processGroupId: 'test-process-group-id',
+ controllerServiceType: 'org.apache.nifi.TestService',
+ controllerServiceBundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' }
+ };
+ const errorResponse = new HttpErrorResponse({
+ error: 'Creation failed',
+ status: 500,
+ statusText: 'Internal Server Error'
+ });
+
+ const mockDialog = TestBed.inject(MatDialog);
+ jest.spyOn(mockDialog, 'closeAll');
+ jest.spyOn(errorHelper as any, 'getErrorString').mockReturnValue('Creation failed');
+ jest.spyOn(controllerServiceService, 'createControllerService').mockReturnValue(
+ throwError(() => errorResponse)
+ );
+
+ action$.next(createControllerService({ request }));
+
+ const result = await new Promise((resolve) =>
+ effects.createControllerService$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(ErrorActions.snackBarError({ error: 'Creation failed' }));
+ expect(mockDialog.closeAll).toHaveBeenCalled();
+ });
+ });
+
+ describe('deleteControllerService$', () => {
+ it('should dispatch deleteControllerServiceSuccess when service is deleted successfully', async () => {
+ const { effects, controllerServiceService } = await setup();
+
+ const request = {
+ controllerService: {
+ id: 'service-to-delete',
+ component: { name: 'Service to Delete' }
+ } as any
+ };
+ const serviceResponse = {
+ id: 'service-to-delete',
+ component: { name: 'Service to Delete' }
+ } as any;
+
+ jest.spyOn(controllerServiceService, 'deleteControllerService').mockReturnValue(of(serviceResponse));
+ action$.next(deleteControllerService({ request }));
+
+ const result = await new Promise((resolve) =>
+ effects.deleteControllerService$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ deleteControllerServiceSuccess({
+ response: { controllerService: serviceResponse }
+ })
+ );
+ expect(controllerServiceService.deleteControllerService).toHaveBeenCalledWith(request);
+ });
+
+ it('should dispatch snackBarError when deletion fails', async () => {
+ const { effects, controllerServiceService, errorHelper } = await setup();
+
+ const request = {
+ controllerService: {
+ id: 'service-to-delete',
+ component: { name: 'Service to Delete' }
+ } as any
+ };
+ const errorResponse = new HttpErrorResponse({
+ error: 'Deletion failed',
+ status: 500,
+ statusText: 'Internal Server Error'
+ });
+
+ jest.spyOn(errorHelper as any, 'getErrorString').mockReturnValue('Deletion failed');
+ jest.spyOn(controllerServiceService, 'deleteControllerService').mockReturnValue(
+ throwError(() => errorResponse)
+ );
+
+ action$.next(deleteControllerService({ request }));
+
+ const result = await new Promise((resolve) =>
+ effects.deleteControllerService$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(ErrorActions.snackBarError({ error: 'Deletion failed' }));
+ });
+ });
+
+ describe('deleteControllerServiceSuccess$', () => {
+ it('should dispatch loadControllerServices after successful deletion', async () => {
+ const { effects } = await setup();
+
+ const response = {
+ controllerService: {
+ id: 'deleted-service',
+ component: { name: 'Deleted Service' }
+ } as any
+ };
+
+ action$.next(deleteControllerServiceSuccess({ response }));
+
+ const result = await new Promise((resolve) =>
+ effects.deleteControllerServiceSuccess$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ loadControllerServices({
+ request: { processGroupId: 'root' }
+ })
+ );
+ });
+ });
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts
index c8ca4aff37..08c075c205 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts
@@ -871,4 +871,30 @@ export class ControllerServicesEffects {
return ['/settings', 'registry-clients', reference.id];
}
}
+
+ clearControllerServiceBulletins$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ControllerServicesActions.clearControllerServiceBulletins),
+ map((action) => action.request),
+ switchMap((request) =>
+ from(
+ this.controllerServiceService.clearBulletins({
+ uri: request.uri,
+ fromTimestamp: request.fromTimestamp
+ })
+ ).pipe(
+ map((response) =>
+ ControllerServicesActions.clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: request.componentId,
+ bulletinsCleared: response.bulletinsCleared || 0,
+ bulletins: response.bulletins || [],
+ componentType: request.componentType
+ }
+ })
+ )
+ )
+ )
+ )
+ );
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.spec.ts
new file mode 100644
index 0000000000..f0d5fcaccd
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.spec.ts
@@ -0,0 +1,434 @@
+/*
+ * 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 { controllerServicesReducer, initialState } from './controller-services.reducer';
+import { clearControllerServiceBulletinsSuccess } from './controller-services.actions';
+import { ControllerServicesState } from './index';
+import { ComponentType, BulletinEntity } from '@nifi/shared';
+
+describe('Controller Services Reducer', () => {
+ describe('clearControllerServiceBulletinsSuccess', () => {
+ let testState: ControllerServicesState;
+ const testTimestamp = new Date('2023-01-01T12:00:00.000Z').getTime();
+ const olderTimestamp = new Date('2023-01-01T11:00:00.000Z').getTime();
+ const newerTimestamp = new Date('2023-01-01T13:00:00.000Z').getTime();
+
+ const createTestBulletin = (id: number, timestamp: number): BulletinEntity => {
+ const isoString = new Date(timestamp).toISOString();
+ const timeOnly = isoString.substring(11, 19) + ' UTC'; // Extract HH:MM:SS and add UTC
+ return {
+ id,
+ canRead: true,
+ sourceId: 'controller-service-1',
+ groupId: 'group1',
+ timestamp: timeOnly,
+ timestampIso: isoString,
+ bulletin: {
+ id,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'controller-service-1',
+ sourceName: 'Test Controller Service',
+ sourceType: 'COMPONENT',
+ level: 'ERROR',
+ message: `Test message ${id}`,
+ timestamp: timeOnly,
+ timestampIso: isoString
+ }
+ };
+ };
+
+ beforeEach(() => {
+ testState = {
+ ...initialState,
+ controllerServices: [
+ {
+ id: 'controller-service-1',
+ uri: 'test-uri-1',
+ bulletins: [
+ createTestBulletin(1, olderTimestamp), // Should be removed
+ createTestBulletin(2, newerTimestamp) // Should be kept
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'controller-service-1',
+ name: 'Test Controller Service 1',
+ type: 'org.apache.nifi.TestControllerService',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ state: 'ENABLED',
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ referencingComponents: [],
+ comments: '',
+ deprecated: false,
+ extensionMissing: false,
+ multipleVersionsAvailable: false,
+ persistsState: false,
+ restricted: false,
+ validationStatus: 'VALID'
+ },
+ status: {
+ runStatus: 'ENABLED',
+ validationStatus: 'VALID'
+ }
+ } as any,
+ {
+ id: 'controller-service-2',
+ uri: 'test-uri-2',
+ bulletins: [
+ createTestBulletin(3, olderTimestamp), // Should be removed
+ createTestBulletin(4, testTimestamp), // Should be kept (equal to timestamp)
+ createTestBulletin(5, newerTimestamp) // Should be kept
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'controller-service-2',
+ name: 'Test Controller Service 2',
+ type: 'org.apache.nifi.TestControllerService',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ state: 'ENABLED',
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ referencingComponents: [],
+ comments: '',
+ deprecated: false,
+ extensionMissing: false,
+ multipleVersionsAvailable: false,
+ persistsState: false,
+ restricted: false,
+ validationStatus: 'VALID'
+ },
+ status: {
+ runStatus: 'ENABLED',
+ validationStatus: 'VALID'
+ }
+ } as any,
+ {
+ id: 'controller-service-3',
+ uri: 'test-uri-3',
+ bulletins: [
+ createTestBulletin(6, olderTimestamp) // Should be removed
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'controller-service-3',
+ name: 'Test Controller Service 3',
+ type: 'org.apache.nifi.TestControllerService',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ state: 'ENABLED',
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ referencingComponents: [],
+ comments: '',
+ deprecated: false,
+ extensionMissing: false,
+ multipleVersionsAvailable: false,
+ persistsState: false,
+ restricted: false,
+ validationStatus: 'VALID'
+ },
+ status: {
+ runStatus: 'ENABLED',
+ validationStatus: 'VALID'
+ }
+ } as any
+ ]
+ };
+ });
+
+ it('should replace bulletins for controller service', () => {
+ // Server returns bulletins that remain after clearing (newer bulletins)
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, newerTimestamp)],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = controllerServicesReducer(testState, action);
+
+ const service = newState.controllerServices.find((s) => s.id === 'controller-service-1') as any;
+ expect(service.bulletins).toHaveLength(1);
+ expect(service.bulletins[0].id).toBe(2); // Only newer bulletin remains
+ });
+
+ it('should replace bulletins keeping equal and newer timestamps', () => {
+ // Server returns bulletins that remain after clearing (equal and newer bulletins)
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-2',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(4, testTimestamp), createTestBulletin(5, newerTimestamp)],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = controllerServicesReducer(testState, action);
+
+ const service = newState.controllerServices.find((s) => s.id === 'controller-service-2') as any;
+ expect(service.bulletins).toHaveLength(2);
+ expect(service.bulletins.map((b: any) => b.id)).toEqual([4, 5]); // Equal and newer bulletins remain
+ });
+
+ it('should remove all older bulletins when all are older than timestamp', () => {
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-3',
+ bulletinsCleared: 1,
+ bulletins: [],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = controllerServicesReducer(testState, action);
+
+ const service = newState.controllerServices.find((s) => s.id === 'controller-service-3') as any;
+ expect(service.bulletins).toHaveLength(0); // All bulletins removed
+ });
+
+ it('should not affect other controller services when updating specific service', () => {
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, newerTimestamp)],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = controllerServicesReducer(testState, action);
+
+ // Other services should remain unchanged
+ const service2 = newState.controllerServices.find((s) => s.id === 'controller-service-2') as any;
+ expect(service2.bulletins).toHaveLength(3); // Original count unchanged
+
+ const service3 = newState.controllerServices.find((s) => s.id === 'controller-service-3') as any;
+ expect(service3.bulletins).toHaveLength(1); // Original count unchanged
+ });
+
+ it('should handle controller service with no bulletins', () => {
+ testState.controllerServices.push({
+ id: 'controller-service-no-bulletins',
+ uri: 'test-uri-no-bulletins',
+ bulletins: undefined,
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'controller-service-no-bulletins',
+ name: 'Test Controller Service No Bulletins',
+ type: 'org.apache.nifi.TestControllerService',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ state: 'ENABLED',
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ referencingComponents: [],
+ comments: '',
+ deprecated: false,
+ extensionMissing: false,
+ multipleVersionsAvailable: false,
+ persistsState: false,
+ restricted: false,
+ validationStatus: 'VALID'
+ },
+ status: {
+ runStatus: 'ENABLED',
+ validationStatus: 'VALID'
+ }
+ } as any);
+
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-no-bulletins',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = controllerServicesReducer(testState, action);
+
+ const service = newState.controllerServices.find((s) => s.id === 'controller-service-no-bulletins') as any;
+ expect(service).toBeDefined();
+ expect(service.bulletins).toEqual([]);
+ });
+
+ it('should handle controller service with empty bulletins array', () => {
+ testState.controllerServices.push({
+ id: 'controller-service-empty-bulletins',
+ uri: 'test-uri-empty-bulletins',
+ bulletins: [],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'controller-service-empty-bulletins',
+ name: 'Test Controller Service Empty Bulletins',
+ type: 'org.apache.nifi.TestControllerService',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ state: 'ENABLED',
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ referencingComponents: [],
+ comments: '',
+ deprecated: false,
+ extensionMissing: false,
+ multipleVersionsAvailable: false,
+ persistsState: false,
+ restricted: false,
+ validationStatus: 'VALID'
+ },
+ status: {
+ runStatus: 'ENABLED',
+ validationStatus: 'VALID'
+ }
+ } as any);
+
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-empty-bulletins',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = controllerServicesReducer(testState, action);
+
+ const service = newState.controllerServices.find(
+ (s) => s.id === 'controller-service-empty-bulletins'
+ ) as any;
+ expect(service).toBeDefined();
+ expect(service.bulletins).toEqual([]);
+ });
+
+ it('should handle non-existent controller service gracefully', () => {
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'non-existent-service',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = controllerServicesReducer(testState, action);
+
+ // State should remain unchanged
+ expect(newState).toEqual(testState);
+ });
+
+ it('should handle empty controller services array gracefully', () => {
+ const emptyState: ControllerServicesState = {
+ ...initialState,
+ controllerServices: []
+ };
+
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'any-service',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = controllerServicesReducer(emptyState, action);
+
+ // State should remain unchanged
+ expect(newState).toEqual(emptyState);
+ });
+
+ it('should preserve all other controller service properties when filtering bulletins', () => {
+ const originalService = testState.controllerServices[0];
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, newerTimestamp)],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = controllerServicesReducer(testState, action);
+
+ const updatedService = newState.controllerServices.find((s) => s.id === 'controller-service-1');
+ expect(updatedService).toBeDefined();
+
+ // All properties except bulletins should remain the same
+ expect(updatedService!.id).toBe(originalService.id);
+ expect(updatedService!.uri).toBe(originalService.uri);
+ expect(updatedService!.revision).toEqual(originalService.revision);
+ expect(updatedService!.permissions).toEqual(originalService.permissions);
+ expect(updatedService!.component).toEqual(originalService.component);
+ expect(updatedService!.status).toEqual(originalService.status);
+
+ // Only bulletins should be filtered
+ expect((updatedService as any).bulletins).toHaveLength(1);
+ expect((updatedService as any).bulletins[0].id).toBe(2);
+ });
+
+ it('should handle multiple bulletin clearing operations correctly', () => {
+ // First operation - clear service 1
+ const action1 = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, newerTimestamp)],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const intermediateState = controllerServicesReducer(testState, action1);
+
+ // Second operation - clear service 2
+ const action2 = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-2',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(4, testTimestamp), createTestBulletin(5, newerTimestamp)],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const finalState = controllerServicesReducer(intermediateState, action2);
+
+ // Both services should be updated correctly
+ const service1 = finalState.controllerServices.find((s) => s.id === 'controller-service-1') as any;
+ expect(service1.bulletins).toHaveLength(1);
+ expect(service1.bulletins[0].id).toBe(2);
+
+ const service2 = finalState.controllerServices.find((s) => s.id === 'controller-service-2') as any;
+ expect(service2.bulletins).toHaveLength(2);
+ expect(service2.bulletins.map((b: any) => b.id)).toEqual([4, 5]);
+
+ // Service 3 should remain unchanged
+ const service3 = finalState.controllerServices.find((s) => s.id === 'controller-service-3') as any;
+ expect(service3.bulletins).toHaveLength(1);
+ expect(service3.bulletins[0].id).toBe(6);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.ts
index a88f8ec9fe..f04861fbc7 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.reducer.ts
@@ -17,6 +17,7 @@
import { createReducer, on } from '@ngrx/store';
import {
+ clearControllerServiceBulletinsSuccess,
configureControllerService,
configureControllerServiceSuccess,
controllerServicesBannerApiError,
@@ -114,5 +115,17 @@ export const controllerServicesReducer = createReducer(
}
draftState.saving = false;
});
+ }),
+ on(clearControllerServiceBulletinsSuccess, (state, { response }) => {
+ return produce(state, (draftState) => {
+ const componentIndex: number = draftState.controllerServices.findIndex(
+ (service: any) => service.id === response.componentId
+ );
+ if (componentIndex > -1) {
+ const service = draftState.controllerServices[componentIndex];
+ // Replace bulletins with the current bulletins from the server
+ service.bulletins = response.bulletins || [];
+ }
+ });
})
);
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 232f4ab422..9ea7f5769e 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
@@ -20,6 +20,8 @@ import {
CenterComponentRequest,
ChangeColorRequest,
ChangeVersionDialogRequest,
+ ClearBulletinsForGroupRequest,
+ ClearBulletinsForGroupResponse,
ComponentEntity,
ConfirmStopVersionControlRequest,
CreateComponentRequest,
@@ -110,9 +112,15 @@ import {
VersionControlInformationEntity
} from './index';
import { StatusHistoryRequest } from '../../../../state/status-history';
-import { FetchComponentVersionsRequest, RegistryClientEntity } from '../../../../state/shared';
+import {
+ ClearBulletinsRequest,
+ ClearBulletinsResponse,
+ FetchComponentVersionsRequest,
+ RegistryClientEntity
+} from '../../../../state/shared';
import { ErrorContext } from '../../../../state/error';
import { CopyResponseContext, CopyResponseEntity } from '../../../../state/copy';
+import { BulletinEntity } from '@nifi/shared';
const CANVAS_PREFIX = '[Canvas]';
@@ -657,6 +665,30 @@ export const runOnce = createAction(`${CANVAS_PREFIX} Run Once`, props<{ request
export const runOnceSuccess = createAction(`${CANVAS_PREFIX} Run Once Success`, props<{ response: RunOnceResponse }>());
+/*
+ Clear Bulletins
+*/
+
+export const clearBulletinsForComponent = createAction(
+ `${CANVAS_PREFIX} Clear Bulletins For Component`,
+ props<{ request: ClearBulletinsRequest }>()
+);
+
+export const clearBulletinsForComponentSuccess = createAction(
+ `${CANVAS_PREFIX} Clear Bulletins For Component Success`,
+ props<{ response: ClearBulletinsResponse }>()
+);
+
+export const clearBulletinsForProcessGroup = createAction(
+ `${CANVAS_PREFIX} Clear Bulletins For Process Group`,
+ props<{ request: ClearBulletinsForGroupRequest }>()
+);
+
+export const clearBulletinsForProcessGroupSuccess = createAction(
+ `${CANVAS_PREFIX} Clear Bulletins For Process Group Success`,
+ props<{ response: ClearBulletinsForGroupResponse }>()
+);
+
export const startComponent = createAction(
`${CANVAS_PREFIX} Start Component`,
props<{ request: StartComponentRequest | StartProcessGroupRequest }>()
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
index 2bddcbed21..8c4a2d9a1d 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
@@ -14,11 +14,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
import { FlowService } from '../../service/flow.service';
import * as FlowActions from './flow.actions';
import { of, ReplaySubject, take, throwError } from 'rxjs';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
-import { ComponentHistoryEntity } from '../../../../state/shared';
+import { ClearBulletinsResponse, ComponentHistoryEntity } from '../../../../state/shared';
import { EditProcessor } from '../../ui/canvas/items/processor/edit-processor/edit-processor.component';
import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
import { FlowEffects } from './flow.effects';
@@ -801,7 +802,8 @@ describe('FlowEffects', () => {
getProcessor: jest.fn(),
updateComponent: jest.fn(),
createConnection: jest.fn(),
- createLabel: jest.fn()
+ createLabel: jest.fn(),
+ clearBulletinsForProcessGroup: jest.fn()
}
},
{
@@ -1092,4 +1094,92 @@ describe('FlowEffects', () => {
});
});
});
+
+ describe('clearBulletinsForProcessGroup$', () => {
+ beforeEach(() => {
+ effects = TestBed.inject(FlowEffects);
+ });
+
+ it('should call flowService.clearBulletinsForProcessGroup and dispatch success action', async () => {
+ const request = {
+ processGroupId: 'test-group-id',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ components: ['component-1', 'component-2']
+ };
+
+ const mockResponse = {
+ bulletinsCleared: 10
+ };
+
+ jest.spyOn(flowService, 'clearBulletinsForProcessGroup').mockReturnValue(of(mockResponse));
+
+ const action = FlowActions.clearBulletinsForProcessGroup({ request });
+ action$.next(action);
+
+ const result = await new Promise((resolve) =>
+ effects.clearBulletinsForProcessGroup$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ FlowActions.clearBulletinsForProcessGroupSuccess({
+ response: {
+ processGroupId: 'test-group-id',
+ bulletinsCleared: 10
+ }
+ })
+ );
+ expect(flowService.clearBulletinsForProcessGroup).toHaveBeenCalledWith(request);
+ });
+ });
+
+ describe('clearBulletinsForProcessGroupSuccess$', () => {
+ beforeEach(() => {
+ effects = TestBed.inject(FlowEffects);
+ });
+
+ it('should dispatch reloadFlow when clearing bulletins for the currently viewed process group', async () => {
+ const currentGroupId = 'current-group-id';
+ store.overrideSelector(selectCurrentProcessGroupId, currentGroupId);
+ store.refreshState();
+
+ const response = {
+ processGroupId: currentGroupId,
+ bulletinsCleared: 5
+ };
+
+ const action = FlowActions.clearBulletinsForProcessGroupSuccess({ response });
+ action$.next(action);
+
+ const result = await new Promise((resolve) =>
+ effects.clearBulletinsForProcessGroupSuccess$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(FlowActions.reloadFlow());
+ });
+
+ it('should dispatch loadChildProcessGroup when clearing bulletins for a child process group', async () => {
+ const currentGroupId = 'current-group-id';
+ const childGroupId = 'child-group-id';
+ store.overrideSelector(selectCurrentProcessGroupId, currentGroupId);
+ store.refreshState();
+
+ const response = {
+ processGroupId: childGroupId,
+ bulletinsCleared: 3
+ };
+
+ const action = FlowActions.clearBulletinsForProcessGroupSuccess({ response });
+ action$.next(action);
+
+ const result = await new Promise((resolve) =>
+ effects.clearBulletinsForProcessGroupSuccess$.pipe(take(1)).subscribe(resolve)
+ );
+
+ expect(result).toEqual(
+ FlowActions.loadChildProcessGroup({
+ request: { id: childGroupId }
+ })
+ );
+ });
+ });
});
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 912e0c4019..c1e169c8c4 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
@@ -4618,4 +4618,71 @@ export class FlowEffects {
),
{ dispatch: false }
);
+
+ /*
+ Clear Bulletins Effects
+ */
+
+ clearBulletinsForComponent$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(FlowActions.clearBulletinsForComponent),
+ map((action) => action.request),
+ switchMap((request) =>
+ from(this.flowService.clearBulletinForComponent(request)).pipe(
+ map((response) =>
+ FlowActions.clearBulletinsForComponentSuccess({
+ response: {
+ componentId: response.componentId,
+ bulletinsCleared: response.bulletinsCleared,
+ bulletins: response.bulletins || [],
+ componentType: request.componentType
+ }
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) => of(this.snackBarOrFullScreenError(errorResponse)))
+ )
+ )
+ )
+ );
+
+ clearBulletinsForProcessGroup$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(FlowActions.clearBulletinsForProcessGroup),
+ map((action) => action.request),
+ switchMap((request) =>
+ from(this.flowService.clearBulletinsForProcessGroup(request)).pipe(
+ map((response) =>
+ FlowActions.clearBulletinsForProcessGroupSuccess({
+ response: {
+ processGroupId: request.processGroupId,
+ bulletinsCleared: response.bulletinsCleared
+ }
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) => of(this.snackBarOrFullScreenError(errorResponse)))
+ )
+ )
+ )
+ );
+
+ clearBulletinsForProcessGroupSuccess$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(FlowActions.clearBulletinsForProcessGroupSuccess),
+ map((action) => action.response),
+ concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)),
+ switchMap(([response, currentProcessGroupId]) => {
+ // If we cleared bulletins for the currently viewed process group, reload the entire flow
+ if (response.processGroupId === currentProcessGroupId) {
+ return of(FlowActions.reloadFlow());
+ } else {
+ // If it's a child process group visible on the canvas, reload just that child
+ return of(
+ FlowActions.loadChildProcessGroup({
+ request: { id: response.processGroupId }
+ })
+ );
+ }
+ })
+ )
+ );
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.spec.ts
new file mode 100644
index 0000000000..5a0454fe52
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.spec.ts
@@ -0,0 +1,355 @@
+/*
+ * 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 { flowReducer, initialState } from './flow.reducer';
+import { clearBulletinsForComponentSuccess } from './flow.actions';
+import { FlowState } from './index';
+import { BulletinEntity, ComponentType } from '@nifi/shared';
+
+describe('Flow Reducer', () => {
+ describe('clearBulletinsForComponentSuccess', () => {
+ let testState: FlowState;
+ const testTimestamp = new Date('2023-01-01T12:00:00.000Z').getTime();
+ const olderTimestamp = new Date('2023-01-01T11:00:00.000Z').getTime();
+ const newerTimestamp = new Date('2023-01-01T13:00:00.000Z').getTime();
+
+ const createTestBulletin = (id: number, timestamp: number): BulletinEntity => {
+ const isoString = new Date(timestamp).toISOString();
+ const timeOnly = isoString.substring(11, 19) + ' UTC'; // Extract HH:MM:SS and add UTC
+ return {
+ id,
+ canRead: true,
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: timeOnly,
+ timestampIso: isoString,
+ bulletin: {
+ id,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'ERROR',
+ message: `Test message ${id}`,
+ timestamp: timeOnly,
+ timestampIso: isoString
+ }
+ };
+ };
+
+ beforeEach(() => {
+ testState = {
+ ...initialState,
+ flow: {
+ ...initialState.flow,
+ processGroupFlow: {
+ ...initialState.flow.processGroupFlow,
+ flow: {
+ ...initialState.flow.processGroupFlow.flow,
+ processors: [
+ {
+ id: 'processor-1',
+ bulletins: [
+ createTestBulletin(1, olderTimestamp), // Should be removed
+ createTestBulletin(2, newerTimestamp) // Should be kept
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ position: { x: 0, y: 0 },
+ component: { id: 'processor-1', type: 'Processor' }
+ } as any
+ ],
+ inputPorts: [
+ {
+ id: 'input-port-1',
+ bulletins: [
+ createTestBulletin(3, olderTimestamp), // Should be removed
+ createTestBulletin(4, testTimestamp), // Should be kept (equal to timestamp)
+ createTestBulletin(5, newerTimestamp) // Should be kept
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ position: { x: 0, y: 0 },
+ component: { id: 'input-port-1', type: 'InputPort' }
+ } as any
+ ],
+ outputPorts: [
+ {
+ id: 'output-port-1',
+ bulletins: [
+ createTestBulletin(6, olderTimestamp) // Should be removed
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ position: { x: 0, y: 0 },
+ component: { id: 'output-port-1', type: 'OutputPort' }
+ } as any
+ ],
+ remoteProcessGroups: [
+ {
+ id: 'remote-pg-1',
+ bulletins: [
+ createTestBulletin(7, newerTimestamp) // Should be kept
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ position: { x: 0, y: 0 },
+ component: { id: 'remote-pg-1', type: 'RemoteProcessGroup' }
+ } as any
+ ]
+ }
+ }
+ }
+ };
+ });
+
+ it('should replace bulletins for processors', () => {
+ // Provide the bulletins that should remain after clearing
+ const action = clearBulletinsForComponentSuccess({
+ response: {
+ componentId: 'processor-1',
+ bulletins: [createTestBulletin(2, newerTimestamp)],
+ bulletinsCleared: 1,
+ componentType: ComponentType.Processor
+ }
+ });
+
+ const newState = flowReducer(testState, action);
+
+ const processor = newState.flow.processGroupFlow.flow.processors![0] as any;
+ expect(processor.bulletins).toHaveLength(1);
+ expect(processor.bulletins![0].id).toBe(2); // Only newer bulletin remains
+ });
+
+ it('should replace bulletins for input ports', () => {
+ const action = clearBulletinsForComponentSuccess({
+ response: {
+ componentId: 'input-port-1',
+ bulletins: [createTestBulletin(4, testTimestamp), createTestBulletin(5, newerTimestamp)],
+ bulletinsCleared: 1,
+ componentType: ComponentType.InputPort
+ }
+ });
+
+ const newState = flowReducer(testState, action);
+
+ const inputPort = newState.flow.processGroupFlow.flow.inputPorts![0] as any;
+ expect(inputPort.bulletins).toHaveLength(2);
+ expect(inputPort.bulletins!.map((b: any) => b.id)).toEqual([4, 5]); // Equal and newer bulletins remain
+ });
+
+ it('should filter older bulletins from output ports', () => {
+ const action = clearBulletinsForComponentSuccess({
+ response: {
+ componentId: 'output-port-1',
+ bulletins: [],
+ bulletinsCleared: 1,
+ componentType: ComponentType.OutputPort
+ }
+ });
+
+ const newState = flowReducer(testState, action);
+
+ const outputPort = newState.flow.processGroupFlow.flow.outputPorts![0] as any;
+ expect(outputPort.bulletins).toHaveLength(0); // All bulletins removed
+ });
+
+ it('should replace bulletins for remote process groups', () => {
+ const action = clearBulletinsForComponentSuccess({
+ response: {
+ componentId: 'remote-pg-1',
+ bulletins: [createTestBulletin(7, newerTimestamp)],
+ bulletinsCleared: 0,
+ componentType: ComponentType.RemoteProcessGroup
+ }
+ });
+
+ const newState = flowReducer(testState, action);
+
+ const remotePg = newState.flow.processGroupFlow.flow.remoteProcessGroups![0] as any;
+ expect(remotePg.bulletins).toHaveLength(1);
+ expect(remotePg.bulletins![0].id).toBe(7); // Newer bulletin remains
+ });
+
+ it('should not affect other components when updating specific component', () => {
+ const action = clearBulletinsForComponentSuccess({
+ response: {
+ componentId: 'processor-1',
+ bulletins: [createTestBulletin(2, newerTimestamp)],
+ bulletinsCleared: 1,
+ componentType: ComponentType.Processor
+ }
+ });
+
+ const newState = flowReducer(testState, action);
+
+ // Other components should remain unchanged
+ const inputPort = newState.flow.processGroupFlow.flow.inputPorts![0] as any;
+ expect(inputPort.bulletins).toHaveLength(3); // Original count unchanged
+
+ const outputPort = newState.flow.processGroupFlow.flow.outputPorts![0] as any;
+ expect(outputPort.bulletins).toHaveLength(1); // Original count unchanged
+
+ const remotePg = newState.flow.processGroupFlow.flow.remoteProcessGroups![0] as any;
+ expect(remotePg.bulletins).toHaveLength(1); // Original count unchanged
+ });
+
+ it('should handle component with no bulletins', () => {
+ // Add a component with no bulletins
+ testState.flow.processGroupFlow.flow.processors!.push({
+ id: 'processor-no-bulletins',
+ bulletins: undefined,
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ position: { x: 0, y: 0 },
+ component: { id: 'processor-no-bulletins', type: 'Processor' }
+ } as any);
+
+ const action = clearBulletinsForComponentSuccess({
+ response: {
+ componentId: 'processor-no-bulletins',
+ bulletins: [],
+ bulletinsCleared: 0,
+ componentType: ComponentType.Processor
+ }
+ });
+
+ const newState = flowReducer(testState, action);
+
+ const processor = newState.flow.processGroupFlow.flow.processors!.find(
+ (p) => p.id === 'processor-no-bulletins'
+ ) as any;
+ expect(processor).toBeDefined();
+ expect(processor!.bulletins).toEqual([]);
+ });
+
+ it('should handle component with empty bulletins array', () => {
+ // Add a component with empty bulletins array
+ testState.flow.processGroupFlow.flow.processors!.push({
+ id: 'processor-empty-bulletins',
+ bulletins: [],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ position: { x: 0, y: 0 },
+ component: { id: 'processor-empty-bulletins', type: 'Processor' }
+ } as any);
+
+ const action = clearBulletinsForComponentSuccess({
+ response: {
+ componentId: 'processor-empty-bulletins',
+ bulletins: [],
+ bulletinsCleared: 0,
+ componentType: ComponentType.Processor
+ }
+ });
+
+ const newState = flowReducer(testState, action);
+
+ const processor = newState.flow.processGroupFlow.flow.processors!.find(
+ (p) => p.id === 'processor-empty-bulletins'
+ ) as any;
+ expect(processor).toBeDefined();
+ expect(processor!.bulletins).toEqual([]);
+ });
+
+ it('should handle non-existent component gracefully', () => {
+ const action = clearBulletinsForComponentSuccess({
+ response: {
+ componentId: 'non-existent-component',
+ bulletins: [],
+ bulletinsCleared: 0,
+ componentType: ComponentType.Processor
+ }
+ });
+
+ const newState = flowReducer(testState, action);
+
+ // State should remain unchanged
+ expect(newState).toEqual(testState);
+ });
+
+ it('should handle missing collections gracefully', () => {
+ // Create state with missing collections
+ const stateWithMissingCollections: FlowState = {
+ ...initialState,
+ flow: {
+ ...initialState.flow,
+ processGroupFlow: {
+ ...initialState.flow.processGroupFlow,
+ flow: {
+ ...initialState.flow.processGroupFlow.flow,
+ processors: null as any,
+ inputPorts: null as any,
+ outputPorts: null as any,
+ remoteProcessGroups: null as any
+ }
+ }
+ }
+ };
+
+ const action = clearBulletinsForComponentSuccess({
+ response: {
+ componentId: 'any-component',
+ bulletins: [],
+ bulletinsCleared: 0,
+ componentType: ComponentType.Processor
+ }
+ });
+
+ const newState = flowReducer(stateWithMissingCollections, action);
+
+ // State should remain unchanged
+ expect(newState).toEqual(stateWithMissingCollections);
+ });
+
+ it('should break after finding and updating the first matching component', () => {
+ // Add duplicate component IDs across different collections to test break logic
+ testState.flow.processGroupFlow.flow.inputPorts!.push({
+ id: 'processor-1', // Same ID as processor
+ bulletins: [createTestBulletin(99, olderTimestamp)],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ position: { x: 0, y: 0 },
+ component: { id: 'processor-1', type: 'InputPort' }
+ } as any);
+
+ const action = clearBulletinsForComponentSuccess({
+ response: {
+ componentId: 'processor-1',
+ bulletins: [createTestBulletin(2, newerTimestamp)],
+ bulletinsCleared: 1,
+ componentType: ComponentType.Processor
+ }
+ });
+
+ const newState = flowReducer(testState, action);
+
+ // Only processor should be updated (first match), input port should remain unchanged
+ const processor = newState.flow.processGroupFlow.flow.processors![0] as any;
+ expect(processor.bulletins).toHaveLength(1);
+ expect(processor.bulletins![0].id).toBe(2);
+
+ const inputPortWithSameId = newState.flow.processGroupFlow.flow.inputPorts!.find(
+ (p) => p.id === 'processor-1'
+ ) as any;
+ expect(inputPortWithSameId!.bulletins).toHaveLength(1);
+ expect(inputPortWithSameId!.bulletins![0].id).toBe(99); // Unchanged
+ });
+ });
+});
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 2cd7b24ff8..4b1a01c160 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
@@ -19,6 +19,7 @@ import { createReducer, on } from '@ngrx/store';
import {
changeVersionComplete,
changeVersionSuccess,
+ clearBulletinsForComponentSuccess,
createComponentComplete,
createComponentSuccess,
createConnection,
@@ -90,7 +91,7 @@ import {
uploadProcessGroup
} from './flow.actions';
import { ComponentEntity, FlowState } from './index';
-import { ComponentType } from '@nifi/shared';
+import { ComponentType, BulletinEntity } from '@nifi/shared';
import { produce } from 'immer';
export const initialState: FlowState = {
@@ -652,7 +653,32 @@ export const flowReducer = createReducer(
on(changeVersionComplete, revertChangesComplete, (state) => ({
...state,
changeVersionRequest: null
- }))
+ })),
+ on(clearBulletinsForComponentSuccess, (state, { response }) => {
+ return produce(state, (draftState) => {
+ // Find and update the component in all possible collections
+ const collections = [
+ { name: 'processors', array: draftState.flow.processGroupFlow.flow.processors },
+ { name: 'inputPorts', array: draftState.flow.processGroupFlow.flow.inputPorts },
+ { name: 'outputPorts', array: draftState.flow.processGroupFlow.flow.outputPorts },
+ { name: 'remoteProcessGroups', array: draftState.flow.processGroupFlow.flow.remoteProcessGroups }
+ ];
+
+ for (const collection of collections) {
+ if (collection.array) {
+ const componentIndex = collection.array.findIndex(
+ (component: any) => component.id === response.componentId
+ );
+ if (componentIndex > -1) {
+ const component = collection.array[componentIndex] as any;
+ // Replace bulletins with the current bulletins from the server
+ component.bulletins = response.bulletins;
+ break; // Component found and updated, no need to continue
+ }
+ }
+ }
+ });
+ })
);
function getComponentCollection(draftState: FlowState, componentType: ComponentType): any[] | null {
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 5fb3956586..d0ae7391bc 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
@@ -836,6 +836,20 @@ export interface LoadChildProcessGroupRequest {
id: string;
}
+/*
+ Clear Bulletins
+*/
+
+export interface ClearBulletinsForGroupRequest {
+ processGroupId: string;
+ fromTimestamp: string;
+}
+
+export interface ClearBulletinsForGroupResponse {
+ processGroupId: string;
+ bulletinsCleared: number;
+}
+
export interface FlowUpdateRequest {
requestId: string;
processGroupId: string;
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.html
index fad36f3488..eb8763390d 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.html
@@ -85,7 +85,10 @@
(viewStateControllerService)="viewStateControllerService($event)"
(changeControllerServiceVersion)="changeControllerServiceVersion($event)"
(goToControllerService)="navigateToControllerService($event)"
- (deleteControllerService)="deleteControllerService($event)"></controller-service-table>
+ (deleteControllerService)="deleteControllerService($event)"
+ (clearBulletinsControllerService)="
+ clearBulletinsControllerService($event)
+ "></controller-service-table>
</div>
}
<div class="flex justify-between">
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.spec.ts
index 849390d6e7..1613caf03d 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.spec.ts
@@ -17,9 +17,9 @@
import { TestBed } from '@angular/core/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
-
import { ControllerServices } from './controller-services.component';
-import { provideMockStore, MockStore } from '@ngrx/store/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { initialState } from '../../state/controller-services/controller-services.reducer';
import { RouterTestingModule } from '@angular/router/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { MockComponent } from 'ng-mocks';
@@ -28,6 +28,11 @@ import { canvasFeatureKey } from '../../state';
import { controllerServicesFeatureKey } from '../../state/controller-services';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { ControllerServiceEntity } from '../../../../state/shared';
+import { ComponentType } from '@nifi/shared';
+import {
+ clearControllerServiceBulletins,
+ loadControllerServices
+} from '../../state/controller-services/controller-services.actions';
import { BreadcrumbEntity } from '../../state/shared';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -37,6 +42,69 @@ import { ControllerServiceTable } from '../../../../ui/common/controller-service
describe('ControllerServices', () => {
// Mock data factories
+ function createMockControllerServiceEntity(): ControllerServiceEntity {
+ return {
+ id: 'test-controller-service-id',
+ uri: 'test-uri',
+ bulletins: [
+ {
+ id: 1,
+ sourceId: 'test-controller-service-id',
+ groupId: 'test-group-id',
+ timestamp: '12:00:00 UTC',
+ timestampIso: '2023-10-08T12:00:00.000Z',
+ canRead: true,
+ bulletin: {
+ id: 1,
+ category: 'INFO',
+ level: 'INFO',
+ message: 'Test bulletin',
+ timestamp: '12:00:00 UTC',
+ sourceId: 'test-controller-service-id',
+ groupId: 'test-group-id',
+ sourceType: 'CONTROLLER_SERVICE',
+ sourceName: 'Test Controller Service'
+ }
+ }
+ ],
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ revision: {
+ version: 1,
+ clientId: 'test-client'
+ },
+ component: {
+ id: 'test-controller-service-id',
+ name: 'Test Controller Service',
+ type: 'org.apache.nifi.TestControllerService',
+ bundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-bundle',
+ version: '1.0.0'
+ },
+ state: 'ENABLED',
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ referencingComponents: [],
+ comments: '',
+ deprecated: false,
+ extensionMissing: false,
+ multipleVersionsAvailable: false,
+ persistsState: false,
+ restricted: false,
+ validationStatus: 'VALID'
+ },
+ status: {
+ runStatus: 'ENABLED',
+ validationStatus: 'VALID'
+ },
+ parentGroupId: 'test-process-group-id'
+ } as ControllerServiceEntity;
+ }
+
function createMockBreadcrumb(
options: {
id?: string;
@@ -169,7 +237,7 @@ describe('ControllerServices', () => {
async function setup(options: SetupOptions = {}) {
const { controllerServicesState = createMockState(), mockStore = {} } = options;
- const initialState = {
+ const testInitialState = {
[canvasFeatureKey]: {
[controllerServicesFeatureKey]: controllerServicesState
},
@@ -209,14 +277,13 @@ describe('ControllerServices', () => {
MockComponent(Breadcrumbs),
MockComponent(ControllerServiceTable)
],
- providers: [provideMockStore({ initialState })],
+ providers: [provideMockStore({ initialState: testInitialState })],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
const fixture = TestBed.createComponent(ControllerServices);
const component = fixture.componentInstance;
const store = TestBed.inject(MockStore);
-
fixture.detectChanges();
return { component, fixture, store };
@@ -231,6 +298,205 @@ describe('ControllerServices', () => {
const { component } = await setup();
expect(component).toBeTruthy();
});
+
+ it('should return true for isInitialLoading when timestamp matches initial state', async () => {
+ const { component } = await setup();
+ const state = { ...initialState };
+
+ expect(component.isInitialLoading(state)).toBe(true);
+ });
+
+ it('should return false for isInitialLoading when timestamp differs from initial state', async () => {
+ const { component } = await setup();
+ const state = {
+ ...initialState,
+ loadedTimestamp: '2023-01-01 12:00:00 EST'
+ };
+
+ expect(component.isInitialLoading(state)).toBe(false);
+ });
+ });
+
+ describe('Action dispatching', () => {
+ it('should dispatch loadControllerServices action when refreshControllerServiceListing is called', async () => {
+ const { component, store } = await setup();
+ jest.spyOn(store, 'dispatch');
+
+ // Set the current process group ID
+ (component as any).currentProcessGroupId = 'test-process-group-id';
+
+ component.refreshControllerServiceListing();
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ loadControllerServices({
+ request: {
+ processGroupId: 'test-process-group-id'
+ }
+ })
+ );
+ });
+
+ it('should dispatch clearControllerServiceBulletins action when clearBulletinsControllerService is called', async () => {
+ const { component, store } = await setup();
+ const mockControllerServiceEntity = createMockControllerServiceEntity();
+ jest.spyOn(store, 'dispatch');
+
+ component.clearBulletinsControllerService(mockControllerServiceEntity);
+
+ expect(store.dispatch).toHaveBeenCalledWith(
+ clearControllerServiceBulletins({
+ request: {
+ uri: mockControllerServiceEntity.uri,
+ fromTimestamp: '2023-10-08T12:00:00.000Z',
+ componentId: mockControllerServiceEntity.id,
+ componentType: ComponentType.ControllerService
+ }
+ })
+ );
+ });
+ });
+
+ describe('Breadcrumb and scope functions', () => {
+ it('should format scope correctly for controller service entity', async () => {
+ const { component } = await setup();
+ const mockControllerServiceEntity = createMockControllerServiceEntity();
+ const mockBreadcrumb = {
+ id: 'test-process-group-id',
+ breadcrumb: { id: 'test-process-group-id', name: 'Test Process Group' },
+ permissions: { canRead: true, canWrite: true },
+ parentBreadcrumb: undefined,
+ versionedFlowState: 'UNVERSIONED'
+ };
+
+ const formatScopeFn = component.formatScope(mockBreadcrumb);
+ const result = formatScopeFn(mockControllerServiceEntity);
+
+ expect(result).toBe('Test Process Group');
+ });
+
+ it('should return entity ID when breadcrumb cannot be read', async () => {
+ const { component } = await setup();
+ const mockControllerServiceEntity = createMockControllerServiceEntity();
+ const mockBreadcrumb = {
+ id: 'test-process-group-id',
+ breadcrumb: { id: 'test-process-group-id', name: 'Test Process Group' },
+ permissions: { canRead: false, canWrite: false },
+ parentBreadcrumb: undefined,
+ versionedFlowState: 'UNVERSIONED'
+ };
+
+ const formatScopeFn = component.formatScope(mockBreadcrumb);
+ const result = formatScopeFn(mockControllerServiceEntity);
+
+ expect(result).toBe('test-process-group-id');
+ });
+
+ it('should return empty string when breadcrumb is not found', async () => {
+ const { component } = await setup();
+ const mockControllerServiceEntity = createMockControllerServiceEntity();
+ mockControllerServiceEntity.parentGroupId = 'different-group-id';
+ const mockBreadcrumb = {
+ id: 'test-process-group-id',
+ breadcrumb: { id: 'test-process-group-id', name: 'Test Process Group' },
+ permissions: { canRead: true, canWrite: true },
+ parentBreadcrumb: undefined,
+ versionedFlowState: 'UNVERSIONED'
+ };
+
+ const formatScopeFn = component.formatScope(mockBreadcrumb);
+ const result = formatScopeFn(mockControllerServiceEntity);
+
+ expect(result).toBe('');
+ });
+
+ it('should return true when entity is defined by current group', async () => {
+ const { component } = await setup();
+ const mockControllerServiceEntity = createMockControllerServiceEntity();
+ const mockBreadcrumb = {
+ id: 'test-process-group-id',
+ breadcrumb: { id: 'test-process-group-id', name: 'Test Process Group' },
+ permissions: { canRead: true, canWrite: true },
+ parentBreadcrumb: undefined,
+ versionedFlowState: 'UNVERSIONED'
+ };
+
+ const definedByCurrentGroupFn = component.definedByCurrentGroup(mockBreadcrumb);
+ const result = definedByCurrentGroupFn(mockControllerServiceEntity);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when entity is not defined by current group', async () => {
+ const { component } = await setup();
+ const mockControllerServiceEntity = createMockControllerServiceEntity();
+ mockControllerServiceEntity.parentGroupId = 'different-group-id';
+ const mockBreadcrumb = {
+ id: 'test-process-group-id',
+ breadcrumb: { id: 'test-process-group-id', name: 'Test Process Group' },
+ permissions: { canRead: true, canWrite: true },
+ parentBreadcrumb: undefined,
+ versionedFlowState: 'UNVERSIONED'
+ };
+
+ const definedByCurrentGroupFn = component.definedByCurrentGroup(mockBreadcrumb);
+ const result = definedByCurrentGroupFn(mockControllerServiceEntity);
+
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('Permission functions', () => {
+ it('should return true when user can modify parent group', async () => {
+ const { component } = await setup();
+ const mockControllerServiceEntity = createMockControllerServiceEntity();
+ const mockBreadcrumb = {
+ id: 'test-process-group-id',
+ breadcrumb: { id: 'test-process-group-id', name: 'Test Process Group' },
+ permissions: { canRead: true, canWrite: true },
+ parentBreadcrumb: undefined,
+ versionedFlowState: 'UNVERSIONED'
+ };
+
+ const canModifyParentFn = component.canModifyParent(mockBreadcrumb);
+ const result = canModifyParentFn(mockControllerServiceEntity);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when user cannot modify parent group', async () => {
+ const { component } = await setup();
+ const mockControllerServiceEntity = createMockControllerServiceEntity();
+ const mockBreadcrumb = {
+ id: 'test-process-group-id',
+ breadcrumb: { id: 'test-process-group-id', name: 'Test Process Group' },
+ permissions: { canRead: true, canWrite: false },
+ parentBreadcrumb: undefined,
+ versionedFlowState: 'UNVERSIONED'
+ };
+
+ const canModifyParentFn = component.canModifyParent(mockBreadcrumb);
+ const result = canModifyParentFn(mockControllerServiceEntity);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when breadcrumb is not found for permission check', async () => {
+ const { component } = await setup();
+ const mockControllerServiceEntity = createMockControllerServiceEntity();
+ mockControllerServiceEntity.parentGroupId = 'different-group-id';
+ const mockBreadcrumb = {
+ id: 'test-process-group-id',
+ breadcrumb: { id: 'test-process-group-id', name: 'Test Process Group' },
+ permissions: { canRead: true, canWrite: true },
+ parentBreadcrumb: undefined,
+ versionedFlowState: 'UNVERSIONED'
+ };
+
+ const canModifyParentFn = component.canModifyParent(mockBreadcrumb);
+ const result = canModifyParentFn(mockControllerServiceEntity);
+
+ expect(result).toBe(false);
+ });
});
describe('Filtering functionality', () => {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.ts
index ba9e6a2388..b501dceed4 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.ts
@@ -28,6 +28,7 @@ import {
} from '../../state/controller-services/controller-services.selectors';
import { ControllerServicesState } from '../../state/controller-services';
import {
+ clearControllerServiceBulletins,
loadControllerServices,
navigateToAdvancedServiceUi,
navigateToEditService,
@@ -43,7 +44,7 @@ import {
selectControllerService
} from '../../state/controller-services/controller-services.actions';
import { initialState } from '../../state/controller-services/controller-services.reducer';
-import { ComponentType, isDefinedAndNotNull, TextTip } from '@nifi/shared';
+import { ComponentType, isDefinedAndNotNull, NiFiCommon, TextTip } from '@nifi/shared';
import { ControllerServiceEntity } from '../../../../state/shared';
import { BreadcrumbEntity } from '../../state/shared';
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
@@ -62,6 +63,7 @@ import { DocumentationRequest } from '../../../../state/documentation';
})
export class ControllerServices implements OnDestroy {
private store = inject<Store<NiFiState>>(Store);
+ private nifiCommon = inject(NiFiCommon);
// Convert observables to signals
serviceState = this.store.selectSignal(selectControllerServicesState);
@@ -361,6 +363,26 @@ export class ControllerServices implements OnDestroy {
);
}
+ clearBulletinsControllerService(entity: ControllerServiceEntity): void {
+ // Get the most recent bulletin timestamp from the entity's bulletins
+ // This will be reconstructed from the time-only string to a full timestamp
+ const fromTimestamp = this.nifiCommon.getMostRecentBulletinTimestamp(entity.bulletins || []);
+ if (fromTimestamp === null) {
+ return; // no bulletins to clear
+ }
+
+ this.store.dispatch(
+ clearControllerServiceBulletins({
+ request: {
+ uri: entity.uri,
+ fromTimestamp,
+ componentId: entity.id,
+ componentType: ComponentType.ControllerService
+ }
+ })
+ );
+ }
+
// Filter methods
toggleFilter(): void {
this.showCurrentScopeOnly.set(!this.showCurrentScopeOnly());
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/flow-analysis-rule.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/flow-analysis-rule.service.ts
index 9b03e51fb5..d8cbaaf358 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/flow-analysis-rule.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/flow-analysis-rule.service.ts
@@ -82,6 +82,13 @@ export class FlowAnalysisRuleService implements PropertyDescriptorRetriever {
);
}
+ clearBulletins(request: { uri: string; fromTimestamp: string }): Observable<any> {
+ const payload = {
+ fromTimestamp: request.fromTimestamp
+ };
+ return this.httpClient.post(`${this.nifiCommon.stripProtocol(request.uri)}/bulletins/clear-requests`, payload);
+ }
+
setEnable(flowAnalysisRule: EnableFlowAnalysisRuleRequest, enabled: boolean): Observable<any> {
const entity: FlowAnalysisRuleEntity = flowAnalysisRule.flowAnalysisRule;
return this.httpClient.put(`${this.nifiCommon.stripProtocol(entity.uri)}/run-status`, {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/management-controller-service.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/management-controller-service.service.ts
index 3722999123..01b9efe37c 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/management-controller-service.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/management-controller-service.service.ts
@@ -93,4 +93,11 @@ export class ManagementControllerServiceService implements ControllerServiceCrea
});
return this.httpClient.delete(this.nifiCommon.stripProtocol(entity.uri), { params });
}
+
+ clearBulletins(request: { uri: string; fromTimestamp: string }): Observable<any> {
+ const payload = {
+ fromTimestamp: request.fromTimestamp
+ };
+ return this.httpClient.post(`${this.nifiCommon.stripProtocol(request.uri)}/bulletins/clear-requests`, payload);
+ }
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/parameter-provider.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/parameter-provider.service.ts
index ac532a9cef..7eec4045f6 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/parameter-provider.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/parameter-provider.service.ts
@@ -71,6 +71,13 @@ export class ParameterProviderService implements PropertyDescriptorRetriever {
return this.httpClient.delete(this.nifiCommon.stripProtocol(entity.uri), { params });
}
+ clearBulletins(request: { uri: string; fromTimestamp: string }): Observable<any> {
+ const payload = {
+ fromTimestamp: request.fromTimestamp
+ };
+ return this.httpClient.post(`${this.nifiCommon.stripProtocol(request.uri)}/bulletins/clear-requests`, payload);
+ }
+
getPropertyDescriptor(id: string, propertyName: string, sensitive: boolean): Observable<any> {
const params: any = {
propertyName,
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/registry-client.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/registry-client.service.ts
index bc3631d3dd..50b3749f60 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/registry-client.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/registry-client.service.ts
@@ -69,4 +69,11 @@ export class RegistryClientService implements PropertyDescriptorRetriever {
});
return this.httpClient.delete(this.nifiCommon.stripProtocol(entity.uri), { params });
}
+
+ clearBulletins(request: { uri: string; fromTimestamp: string }): Observable<any> {
+ const payload = {
+ fromTimestamp: request.fromTimestamp
+ };
+ return this.httpClient.post(`${this.nifiCommon.stripProtocol(request.uri)}/bulletins/clear-requests`, payload);
+ }
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/reporting-task.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/reporting-task.service.ts
index 80aceb0c08..5b0894c62b 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/reporting-task.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/service/reporting-task.service.ts
@@ -108,4 +108,11 @@ export class ReportingTaskService implements PropertyDescriptorRetriever {
configureReportingTask.payload
);
}
+
+ clearBulletins(request: { uri: string; fromTimestamp: string }): Observable<any> {
+ const payload = {
+ fromTimestamp: request.fromTimestamp
+ };
+ return this.httpClient.post(`${this.nifiCommon.stripProtocol(request.uri)}/bulletins/clear-requests`, payload);
+ }
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.actions.ts
index 6046cdeb94..89d3489ba6 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.actions.ts
@@ -31,7 +31,7 @@ import {
LoadFlowAnalysisRulesResponse,
SelectFlowAnalysisRuleRequest
} from './index';
-import { FetchComponentVersionsRequest } from '../../../../state/shared';
+import { ClearBulletinsRequest, ClearBulletinsResponse, FetchComponentVersionsRequest } from '../../../../state/shared';
export const resetFlowAnalysisRulesState = createAction('[Flow Analysis Rules] Reset Flow Analysis Rules State');
@@ -137,3 +137,13 @@ export const openChangeFlowAnalysisRuleVersionDialog = createAction(
`[Flow Analysis Rules] Open Change Flow Analysis Rule Version Dialog`,
props<{ request: FetchComponentVersionsRequest }>()
);
+
+export const clearFlowAnalysisRuleBulletins = createAction(
+ '[Flow Analysis Rules] Clear Flow Analysis Rule Bulletins',
+ props<{ request: ClearBulletinsRequest }>()
+);
+
+export const clearFlowAnalysisRuleBulletinsSuccess = createAction(
+ '[Flow Analysis Rules] Clear Flow Analysis Rule Bulletins Success',
+ props<{ response: ClearBulletinsResponse }>()
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.spec.ts
index f57c5d79c7..32227c0ee9 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.spec.ts
@@ -6,7 +6,7 @@
* (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
+ * 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,
@@ -17,186 +17,616 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
-import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { provideMockStore } from '@ngrx/store/testing';
import { Action } from '@ngrx/store';
-import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { ReplaySubject, of, throwError, firstValueFrom } from 'rxjs';
+import { Router } from '@angular/router';
+import { MatDialog } from '@angular/material/dialog';
import { HttpErrorResponse } from '@angular/common/http';
-import * as FlowAnalysisRuleActions from './flow-analysis-rules.actions';
import { FlowAnalysisRulesEffects } from './flow-analysis-rules.effects';
+import * as FlowAnalysisRulesActions from './flow-analysis-rules.actions';
import { FlowAnalysisRuleService } from '../../service/flow-analysis-rule.service';
import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
import { ErrorHelper } from '../../../../service/error-helper.service';
-import { MatDialog } from '@angular/material/dialog';
-import { Router } from '@angular/router';
import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { ComponentType } from '@nifi/shared';
+import { FlowAnalysisRuleEntity, flowAnalysisRulesFeatureKey } from './index';
import { initialState } from './flow-analysis-rules.reducer';
import { settingsFeatureKey } from '../index';
-import { flowAnalysisRulesFeatureKey } from './index';
+import * as ErrorActions from '../../../../state/error/error.actions';
describe('FlowAnalysisRulesEffects', () => {
- interface SetupOptions {
- flowAnalysisRulesState?: any;
- }
+ let actions$: ReplaySubject<Action>;
+ let effects: FlowAnalysisRulesEffects;
+ let mockFlowAnalysisRuleService: jest.Mocked<FlowAnalysisRuleService>;
+ let mockErrorHelper: jest.Mocked<ErrorHelper>;
+ let mockDialog: jest.Mocked<MatDialog>;
+
+ beforeEach(() => {
+ const flowAnalysisRuleServiceSpy = {
+ getFlowAnalysisRule: jest.fn(),
+ createFlowAnalysisRule: jest.fn(),
+ updateFlowAnalysisRule: jest.fn(),
+ deleteFlowAnalysisRule: jest.fn(),
+ setEnable: jest.fn(),
+ clearBulletins: jest.fn()
+ } as unknown as jest.Mocked<FlowAnalysisRuleService>;
+
+ const errorHelperSpy = {
+ handleLoadingError: jest.fn().mockReturnValue({ type: '[Error] Loading Error' }),
+ getErrorString: jest.fn().mockReturnValue('Test error message'),
+ showErrorInContext: jest.fn(),
+ fullScreenError: jest.fn().mockReturnValue({ type: 'FULL_SCREEN_ERROR' })
+ } as unknown as jest.Mocked<ErrorHelper>;
+
+ const dialogSpy = {
+ open: jest.fn(),
+ closeAll: jest.fn()
+ } as unknown as jest.Mocked<MatDialog>;
+
+ const routerSpy = {
+ navigate: jest.fn()
+ } as unknown as jest.Mocked<Router>;
+
+ const propertyTableHelperServiceSpy = {
+ getComponentHistory: jest.fn(),
+ createNewProperty: jest.fn(),
+ createNewService: jest.fn()
+ } as unknown as jest.Mocked<PropertyTableHelperService>;
- const mockResponse = {
- flowAnalysisRules: [],
- currentTime: '2023-01-01 12:00:00 EST'
- };
+ const extensionTypesServiceSpy = {
+ getFlowAnalysisRuleVersionsForType: jest.fn()
+ } as unknown as jest.Mocked<ExtensionTypesService>;
- async function setup({ flowAnalysisRulesState = initialState }: SetupOptions = {}) {
- await TestBed.configureTestingModule({
+ TestBed.configureTestingModule({
providers: [
FlowAnalysisRulesEffects,
- provideMockActions(() => action$),
+ provideMockActions(() => actions$),
provideMockStore({
initialState: {
[settingsFeatureKey]: {
- [flowAnalysisRulesFeatureKey]: flowAnalysisRulesState
+ [flowAnalysisRulesFeatureKey]: initialState
}
}
}),
- { provide: FlowAnalysisRuleService, useValue: { getFlowAnalysisRule: jest.fn() } },
+ { provide: FlowAnalysisRuleService, useValue: flowAnalysisRuleServiceSpy },
{ provide: ManagementControllerServiceService, useValue: {} },
- { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn() } },
- { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
- { provide: Router, useValue: { navigate: jest.fn() } },
- { provide: PropertyTableHelperService, useValue: {} },
- { provide: ExtensionTypesService, useValue: {} }
+ { provide: ErrorHelper, useValue: errorHelperSpy },
+ { provide: Router, useValue: routerSpy },
+ { provide: MatDialog, useValue: dialogSpy },
+ { provide: PropertyTableHelperService, useValue: propertyTableHelperServiceSpy },
+ { provide: ExtensionTypesService, useValue: extensionTypesServiceSpy }
]
- }).compileComponents();
+ });
- const store = TestBed.inject(MockStore);
- const effects = TestBed.inject(FlowAnalysisRulesEffects);
- const flowAnalysisRuleService = TestBed.inject(FlowAnalysisRuleService);
- const errorHelper = TestBed.inject(ErrorHelper);
+ effects = TestBed.inject(FlowAnalysisRulesEffects);
+ mockFlowAnalysisRuleService = TestBed.inject(FlowAnalysisRuleService) as jest.Mocked<FlowAnalysisRuleService>;
+ mockErrorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ mockDialog = TestBed.inject(MatDialog) as jest.Mocked<MatDialog>;
+ actions$ = new ReplaySubject(1);
+ });
- return { effects, store, flowAnalysisRuleService, errorHelper };
- }
+ describe('loadFlowAnalysisRule$', () => {
+ it('should dispatch loadFlowAnalysisRulesSuccess action when flow analysis rules are loaded successfully', async () => {
+ const mockResponse = {
+ flowAnalysisRules: [createMockFlowAnalysisRuleEntity()],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
+ mockFlowAnalysisRuleService.getFlowAnalysisRule.mockReturnValue(of(mockResponse));
- let action$: ReplaySubject<Action>;
- beforeEach(() => {
- action$ = new ReplaySubject<Action>();
- });
+ actions$.next(FlowAnalysisRulesActions.loadFlowAnalysisRules());
+
+ const result = await firstValueFrom(effects.loadFlowAnalysisRule$);
+
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.loadFlowAnalysisRulesSuccess({
+ response: {
+ flowAnalysisRules: mockResponse.flowAnalysisRules,
+ loadedTimestamp: mockResponse.currentTime
+ }
+ })
+ );
+ expect(mockFlowAnalysisRuleService.getFlowAnalysisRule).toHaveBeenCalled();
+ });
+
+ it('should dispatch loadFlowAnalysisRulesError with status pending on initial load failure', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ mockFlowAnalysisRuleService.getFlowAnalysisRule.mockReturnValue(throwError(() => errorResponse));
+
+ actions$.next(FlowAnalysisRulesActions.loadFlowAnalysisRules());
- it('should create', async () => {
- const { effects } = await setup();
- expect(effects).toBeTruthy();
+ const result = await firstValueFrom(effects.loadFlowAnalysisRule$);
+
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.loadFlowAnalysisRulesError({
+ errorResponse,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
});
- describe('Load Flow Analysis Rules', () => {
- it('should load flow analysis rules successfully', async () => {
- const { effects, flowAnalysisRuleService } = await setup();
+ describe('loadFlowAnalysisRulesError$', () => {
+ it('should handle error on initial load (hasExistingData=false)', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500 });
+ const errorAction = FlowAnalysisRulesActions.flowAnalysisRuleBannerApiError({ error: 'Error loading' });
- action$.next(FlowAnalysisRuleActions.loadFlowAnalysisRules());
- jest.spyOn(flowAnalysisRuleService, 'getFlowAnalysisRule').mockReturnValueOnce(of(mockResponse) as never);
+ mockErrorHelper.handleLoadingError.mockReturnValue(errorAction);
- const result = await new Promise((resolve) =>
- effects.loadFlowAnalysisRule$.pipe(take(1)).subscribe(resolve)
+ actions$.next(
+ FlowAnalysisRulesActions.loadFlowAnalysisRulesError({
+ errorResponse,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
);
+ const result = await firstValueFrom(effects.loadFlowAnalysisRulesError$);
+
+ expect(mockErrorHelper.handleLoadingError).toHaveBeenCalledWith(false, errorResponse);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle error on refresh (hasExistingData=true)', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500 });
+ const errorAction = FlowAnalysisRulesActions.flowAnalysisRuleBannerApiError({ error: 'Error loading' });
+
+ mockErrorHelper.handleLoadingError.mockReturnValue(errorAction);
+
+ actions$.next(
+ FlowAnalysisRulesActions.loadFlowAnalysisRulesError({
+ errorResponse,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+
+ const result = await firstValueFrom(effects.loadFlowAnalysisRulesError$);
+
+ expect(mockErrorHelper.handleLoadingError).toHaveBeenCalledWith(true, errorResponse);
+ expect(result).toEqual(errorAction);
+ });
+ });
+
+ describe('createFlowAnalysisRule$', () => {
+ it('should dispatch createFlowAnalysisRuleSuccess action when flow analysis rule is created successfully', async () => {
+ const mockRequest = {
+ flowAnalysisRuleType: 'org.apache.nifi.TestFlowAnalysisRule',
+ flowAnalysisRuleBundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-bundle',
+ version: '1.0.0'
+ },
+ revision: { version: 0, clientId: 'test-client' }
+ };
+ const mockResponse = createMockFlowAnalysisRuleEntity();
+ mockFlowAnalysisRuleService.createFlowAnalysisRule.mockReturnValue(of(mockResponse));
+
+ actions$.next(FlowAnalysisRulesActions.createFlowAnalysisRule({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.createFlowAnalysisRule$);
+
expect(result).toEqual(
- FlowAnalysisRuleActions.loadFlowAnalysisRulesSuccess({
+ FlowAnalysisRulesActions.createFlowAnalysisRuleSuccess({
response: {
- flowAnalysisRules: mockResponse.flowAnalysisRules,
- loadedTimestamp: mockResponse.currentTime
+ flowAnalysisRule: mockResponse
}
})
);
+ expect(mockFlowAnalysisRuleService.createFlowAnalysisRule).toHaveBeenCalledWith(mockRequest);
});
- it('should fail to load flow analysis rules on initial load with hasExistingData=false', async () => {
- const { effects, flowAnalysisRuleService } = await setup();
+ it('should handle error and close dialog when creating flow analysis rule fails', async () => {
+ const mockRequest = {
+ flowAnalysisRuleType: 'org.apache.nifi.TestFlowAnalysisRule',
+ flowAnalysisRuleBundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-bundle',
+ version: '1.0.0'
+ },
+ revision: { version: 0, clientId: 'test-client' }
+ };
+ const errorResponse = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' });
+ mockFlowAnalysisRuleService.createFlowAnalysisRule.mockReturnValue(throwError(() => errorResponse));
+
+ actions$.next(FlowAnalysisRulesActions.createFlowAnalysisRule({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.createFlowAnalysisRule$);
- action$.next(FlowAnalysisRuleActions.loadFlowAnalysisRules());
- const error = new HttpErrorResponse({ status: 500 });
- jest.spyOn(flowAnalysisRuleService, 'getFlowAnalysisRule').mockImplementationOnce(() =>
- throwError(() => error)
+ expect(mockDialog.closeAll).toHaveBeenCalled();
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(errorResponse);
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.flowAnalysisRuleSnackbarApiError({
+ error: 'Test error message'
+ })
);
+ });
+ });
+
+ describe('deleteFlowAnalysisRule$', () => {
+ it('should dispatch deleteFlowAnalysisRuleSuccess action when flow analysis rule is deleted successfully', async () => {
+ const mockRequest = {
+ flowAnalysisRule: createMockFlowAnalysisRuleEntity()
+ };
+ const mockResponse = createMockFlowAnalysisRuleEntity();
+ mockFlowAnalysisRuleService.deleteFlowAnalysisRule.mockReturnValue(of(mockResponse));
- const result = await new Promise((resolve) =>
- effects.loadFlowAnalysisRule$.pipe(take(1)).subscribe(resolve)
+ actions$.next(FlowAnalysisRulesActions.deleteFlowAnalysisRule({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.deleteFlowAnalysisRule$);
+
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.deleteFlowAnalysisRuleSuccess({
+ response: {
+ flowAnalysisRule: mockResponse
+ }
+ })
);
+ expect(mockFlowAnalysisRuleService.deleteFlowAnalysisRule).toHaveBeenCalledWith(mockRequest);
+ });
+
+ it('should handle error when deleting flow analysis rule fails', async () => {
+ const mockRequest = {
+ flowAnalysisRule: createMockFlowAnalysisRuleEntity()
+ };
+ const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ mockFlowAnalysisRuleService.deleteFlowAnalysisRule.mockReturnValue(throwError(() => errorResponse));
+
+ actions$.next(FlowAnalysisRulesActions.deleteFlowAnalysisRule({ request: mockRequest }));
+ const result = await firstValueFrom(effects.deleteFlowAnalysisRule$);
+
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(errorResponse);
expect(result).toEqual(
- FlowAnalysisRuleActions.loadFlowAnalysisRulesError({
- errorResponse: error,
- loadedTimestamp: initialState.loadedTimestamp,
- status: 'pending'
+ FlowAnalysisRulesActions.flowAnalysisRuleSnackbarApiError({
+ error: 'Test error message'
})
);
});
+ });
- it('should fail to load flow analysis rules on refresh with hasExistingData=true', async () => {
- const stateWithData = { ...initialState, loadedTimestamp: '2023-01-01 11:00:00 EST' };
- const { effects, flowAnalysisRuleService } = await setup({
- flowAnalysisRulesState: stateWithData
- });
+ describe('enableFlowAnalysisRule$', () => {
+ it('should dispatch enableFlowAnalysisRuleSuccess action when flow analysis rule is enabled successfully', async () => {
+ const mockRequest = {
+ id: 'test-id',
+ flowAnalysisRule: createMockFlowAnalysisRuleEntity()
+ };
+ const mockResponse = createMockFlowAnalysisRuleEntity();
+ mockFlowAnalysisRuleService.setEnable.mockReturnValue(of(mockResponse));
+
+ actions$.next(FlowAnalysisRulesActions.enableFlowAnalysisRule({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.enableFlowAnalysisRule$);
- action$.next(FlowAnalysisRuleActions.loadFlowAnalysisRules());
- const error = new HttpErrorResponse({ status: 500 });
- jest.spyOn(flowAnalysisRuleService, 'getFlowAnalysisRule').mockImplementationOnce(() =>
- throwError(() => error)
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.enableFlowAnalysisRuleSuccess({
+ response: {
+ id: mockRequest.id,
+ flowAnalysisRule: mockResponse
+ }
+ })
);
+ expect(mockFlowAnalysisRuleService.setEnable).toHaveBeenCalledWith(mockRequest, true);
+ });
- const result = await new Promise((resolve) =>
- effects.loadFlowAnalysisRule$.pipe(take(1)).subscribe(resolve)
+ it('should handle error when enabling flow analysis rule fails', async () => {
+ const mockRequest = {
+ id: 'test-id',
+ flowAnalysisRule: createMockFlowAnalysisRuleEntity()
+ };
+ const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ mockFlowAnalysisRuleService.setEnable.mockReturnValue(throwError(() => errorResponse));
+
+ actions$.next(FlowAnalysisRulesActions.enableFlowAnalysisRule({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.enableFlowAnalysisRule$);
+
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(errorResponse);
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.flowAnalysisRuleSnackbarApiError({
+ error: 'Test error message'
+ })
);
+ });
+ });
+
+ describe('disableFlowAnalysisRule$', () => {
+ it('should dispatch disableFlowAnalysisRuleSuccess action when flow analysis rule is disabled successfully', async () => {
+ const mockRequest = {
+ id: 'test-id',
+ flowAnalysisRule: createMockFlowAnalysisRuleEntity()
+ };
+ const mockResponse = createMockFlowAnalysisRuleEntity();
+ mockFlowAnalysisRuleService.setEnable.mockReturnValue(of(mockResponse));
+
+ actions$.next(FlowAnalysisRulesActions.disableFlowAnalysisRule({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.disableFlowAnalysisRule$);
expect(result).toEqual(
- FlowAnalysisRuleActions.loadFlowAnalysisRulesError({
- errorResponse: error,
- loadedTimestamp: stateWithData.loadedTimestamp,
- status: 'success'
+ FlowAnalysisRulesActions.disableFlowAnalysisRuleSuccess({
+ response: {
+ id: mockRequest.id,
+ flowAnalysisRule: mockResponse
+ }
+ })
+ );
+ expect(mockFlowAnalysisRuleService.setEnable).toHaveBeenCalledWith(mockRequest, false);
+ });
+
+ it('should handle error when disabling flow analysis rule fails', async () => {
+ const mockRequest = {
+ id: 'test-id',
+ flowAnalysisRule: createMockFlowAnalysisRuleEntity()
+ };
+ const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ mockFlowAnalysisRuleService.setEnable.mockReturnValue(throwError(() => errorResponse));
+
+ actions$.next(FlowAnalysisRulesActions.disableFlowAnalysisRule({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.disableFlowAnalysisRule$);
+
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(errorResponse);
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.flowAnalysisRuleSnackbarApiError({
+ error: 'Test error message'
})
);
});
});
- describe('Load Flow Analysis Rules Error', () => {
- it('should handle flow analysis rules error for initial load', async () => {
- const { effects, errorHelper } = await setup();
+ describe('clearFlowAnalysisRuleBulletins$', () => {
+ it('should dispatch clearFlowAnalysisRuleBulletinsSuccess action when bulletins are cleared successfully', async () => {
+ const mockRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-component-id',
+ componentType: ComponentType.FlowAnalysisRule
+ };
+ const mockResponse = {
+ bulletinsCleared: 5,
+ bulletins: []
+ };
+ mockFlowAnalysisRuleService.clearBulletins.mockReturnValue(of(mockResponse));
- const error = new HttpErrorResponse({ status: 500 });
- const errorAction = FlowAnalysisRuleActions.flowAnalysisRuleBannerApiError({ error: 'Error loading' });
- action$.next(
- FlowAnalysisRuleActions.loadFlowAnalysisRulesError({
- errorResponse: error,
- loadedTimestamp: initialState.loadedTimestamp,
- status: 'pending'
+ actions$.next(FlowAnalysisRulesActions.clearFlowAnalysisRuleBulletins({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.clearFlowAnalysisRuleBulletins$);
+
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.clearFlowAnalysisRuleBulletinsSuccess({
+ response: {
+ componentId: mockRequest.componentId,
+ bulletinsCleared: 5,
+ bulletins: mockResponse.bulletins || [],
+ componentType: mockRequest.componentType
+ }
})
);
- jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+ expect(mockFlowAnalysisRuleService.clearBulletins).toHaveBeenCalledWith({
+ uri: mockRequest.uri,
+ fromTimestamp: mockRequest.fromTimestamp
+ });
+ });
+
+ it('should handle undefined bulletinsCleared response', async () => {
+ const mockRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-component-id',
+ componentType: ComponentType.FlowAnalysisRule
+ };
+ const mockResponse = {
+ bulletins: []
+ }; // No bulletinsCleared property
+ mockFlowAnalysisRuleService.clearBulletins.mockReturnValue(of(mockResponse));
+
+ actions$.next(FlowAnalysisRulesActions.clearFlowAnalysisRuleBulletins({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.clearFlowAnalysisRuleBulletins$);
- const result = await new Promise((resolve) =>
- effects.loadFlowAnalysisRulesError$.pipe(take(1)).subscribe(resolve)
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.clearFlowAnalysisRuleBulletinsSuccess({
+ response: {
+ componentId: mockRequest.componentId,
+ bulletinsCleared: 0, // Should default to 0
+ bulletins: mockResponse.bulletins || [],
+ componentType: mockRequest.componentType
+ }
+ })
);
+ });
- expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
- expect(result).toEqual(errorAction);
+ it('should pass through all required fields in the success response', async () => {
+ const mockRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-component-id',
+ componentType: ComponentType.FlowAnalysisRule
+ };
+ const mockResponse = {
+ bulletinsCleared: 3,
+ bulletins: []
+ };
+ mockFlowAnalysisRuleService.clearBulletins.mockReturnValue(of(mockResponse));
+
+ actions$.next(FlowAnalysisRulesActions.clearFlowAnalysisRuleBulletins({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.clearFlowAnalysisRuleBulletins$);
+
+ const successAction = result as any;
+ expect(successAction.response.componentId).toBe(mockRequest.componentId);
+ expect(successAction.response.componentType).toBe(mockRequest.componentType);
+ expect(successAction.response.bulletinsCleared).toBe(3);
+ expect(successAction.response.bulletins).toEqual([]);
+ });
+ });
+
+ describe('configureFlowAnalysisRule$', () => {
+ it('should dispatch configureFlowAnalysisRuleSuccess action when flow analysis rule is configured successfully', async () => {
+ const mockRequest = {
+ id: 'test-id',
+ uri: 'test-uri',
+ payload: {
+ revision: { version: 1, clientId: 'test-client' },
+ component: {
+ id: 'test-id',
+ name: 'Updated Flow Analysis Rule'
+ }
+ },
+ postUpdateNavigation: undefined,
+ postUpdateNavigationBoundary: undefined
+ };
+ const mockResponse = createMockFlowAnalysisRuleEntity();
+ mockFlowAnalysisRuleService.updateFlowAnalysisRule.mockReturnValue(of(mockResponse));
+
+ actions$.next(FlowAnalysisRulesActions.configureFlowAnalysisRule({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.configureFlowAnalysisRule$);
+
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.configureFlowAnalysisRuleSuccess({
+ response: {
+ id: mockRequest.id,
+ flowAnalysisRule: mockResponse,
+ postUpdateNavigation: mockRequest.postUpdateNavigation,
+ postUpdateNavigationBoundary: mockRequest.postUpdateNavigationBoundary
+ }
+ })
+ );
+ expect(mockFlowAnalysisRuleService.updateFlowAnalysisRule).toHaveBeenCalledWith(mockRequest);
});
- it('should handle flow analysis rules error for refresh', async () => {
- const { effects, errorHelper } = await setup();
+ it('should handle error with banner when configuring flow analysis rule fails', async () => {
+ const mockRequest = {
+ id: 'test-id',
+ uri: 'test-uri',
+ payload: {
+ revision: { version: 1, clientId: 'test-client' },
+ component: {
+ id: 'test-id',
+ name: 'Updated Flow Analysis Rule'
+ }
+ },
+ postUpdateNavigation: undefined,
+ postUpdateNavigationBoundary: undefined
+ };
+ const errorResponse = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' });
+ mockFlowAnalysisRuleService.updateFlowAnalysisRule.mockReturnValue(throwError(() => errorResponse));
- const error = new HttpErrorResponse({ status: 500 });
- const errorAction = FlowAnalysisRuleActions.flowAnalysisRuleBannerApiError({ error: 'Error loading' });
- action$.next(
- FlowAnalysisRuleActions.loadFlowAnalysisRulesError({
- errorResponse: error,
- loadedTimestamp: '2023-01-01 11:00:00 EST',
- status: 'success'
+ actions$.next(FlowAnalysisRulesActions.configureFlowAnalysisRule({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.configureFlowAnalysisRule$);
+
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(errorResponse);
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.flowAnalysisRuleBannerApiError({
+ error: 'Test error message'
})
);
- jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+ });
+ });
+
+ describe('createFlowAnalysisRuleSuccess$', () => {
+ it('should close dialog and dispatch selectFlowAnalysisRule action', async () => {
+ const mockResponse = {
+ flowAnalysisRule: createMockFlowAnalysisRuleEntity()
+ };
+
+ actions$.next(FlowAnalysisRulesActions.createFlowAnalysisRuleSuccess({ response: mockResponse }));
+
+ const result = await firstValueFrom(effects.createFlowAnalysisRuleSuccess$);
- const result = await new Promise((resolve) =>
- effects.loadFlowAnalysisRulesError$.pipe(take(1)).subscribe(resolve)
+ expect(mockDialog.closeAll).toHaveBeenCalled();
+ expect(result).toEqual(
+ FlowAnalysisRulesActions.selectFlowAnalysisRule({
+ request: {
+ id: mockResponse.flowAnalysisRule.id
+ }
+ })
);
+ });
+ });
- expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
- expect(result).toEqual(errorAction);
+ describe('flowAnalysisRuleBannerApiError$', () => {
+ it('should dispatch addBannerError action', async () => {
+ const errorMessage = 'Test error message';
+
+ actions$.next(FlowAnalysisRulesActions.flowAnalysisRuleBannerApiError({ error: errorMessage }));
+
+ const result = await firstValueFrom(effects.flowAnalysisRuleBannerApiError$);
+
+ expect(result).toEqual(
+ ErrorActions.addBannerError({
+ errorContext: {
+ errors: [errorMessage],
+ context: 'flow-analysis-rules' as any
+ }
+ })
+ );
+ });
+ });
+
+ describe('flowAnalysisRuleSnackbarApiError$', () => {
+ it('should dispatch snackBarError action', async () => {
+ const errorMessage = 'Test error message';
+
+ actions$.next(FlowAnalysisRulesActions.flowAnalysisRuleSnackbarApiError({ error: errorMessage }));
+
+ const result = await firstValueFrom(effects.flowAnalysisRuleSnackbarApiError$);
+
+ expect(result).toEqual(ErrorActions.snackBarError({ error: errorMessage }));
});
});
+
+ // Mock data factory
+ function createMockFlowAnalysisRuleEntity(): FlowAnalysisRuleEntity {
+ return {
+ id: 'test-flow-analysis-rule-id',
+ uri: 'test-uri',
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ operatePermissions: {
+ canRead: true,
+ canWrite: true
+ },
+ revision: {
+ version: 1,
+ clientId: 'test-client'
+ },
+ component: {
+ id: 'test-flow-analysis-rule-id',
+ name: 'Test Flow Analysis Rule',
+ type: 'org.apache.nifi.TestFlowAnalysisRule',
+ bundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-bundle',
+ version: '1.0.0'
+ },
+ state: 'DISABLED',
+ comments: 'Test comments',
+ persistsState: true,
+ restricted: false,
+ deprecated: false,
+ multipleVersionsAvailable: false,
+ supportsSensitiveDynamicProperties: false,
+ properties: {},
+ descriptors: {},
+ customUiUrl: '',
+ annotationData: '',
+ validationErrors: [],
+ validationStatus: 'VALID'
+ },
+ status: {
+ runStatus: 'DISABLED',
+ validationStatus: 'VALID'
+ },
+ bulletins: []
+ };
+ }
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.ts
index 04e317778a..fd7558daef 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.effects.ts
@@ -575,4 +575,37 @@ export class FlowAnalysisRulesEffects {
),
{ dispatch: false }
);
+
+ clearFlowAnalysisRuleBulletins$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(FlowAnalysisRuleActions.clearFlowAnalysisRuleBulletins),
+ map((action) => action.request),
+ switchMap((request) =>
+ from(
+ this.flowAnalysisRuleService.clearBulletins({
+ uri: request.uri,
+ fromTimestamp: request.fromTimestamp
+ })
+ ).pipe(
+ map((response) =>
+ FlowAnalysisRuleActions.clearFlowAnalysisRuleBulletinsSuccess({
+ response: {
+ componentId: request.componentId,
+ bulletinsCleared: response.bulletinsCleared || 0,
+ bulletins: response.bulletins || [],
+ componentType: request.componentType
+ }
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ FlowAnalysisRuleActions.flowAnalysisRuleSnackbarApiError({
+ error: this.errorHelper.getErrorString(errorResponse)
+ })
+ )
+ )
+ )
+ )
+ )
+ );
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.spec.ts
new file mode 100644
index 0000000000..59862cdd79
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.spec.ts
@@ -0,0 +1,180 @@
+/*
+ * 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 { flowAnalysisRulesReducer, initialState } from './flow-analysis-rules.reducer';
+import { clearFlowAnalysisRuleBulletinsSuccess } from './flow-analysis-rules.actions';
+import { FlowAnalysisRulesState } from './index';
+import { BulletinEntity, ComponentType } from '@nifi/shared';
+
+describe('Flow Analysis Rules Reducer', () => {
+ describe('clearFlowAnalysisRuleBulletinsSuccess', () => {
+ let testState: FlowAnalysisRulesState;
+ const testTimestamp = new Date('2023-01-01T12:00:00.000Z').getTime();
+ const olderTimestamp = new Date('2023-01-01T11:00:00.000Z').getTime();
+ const newerTimestamp = new Date('2023-01-01T13:00:00.000Z').getTime();
+
+ const createTestBulletin = (id: number, timestamp: number): BulletinEntity => {
+ const isoString = new Date(timestamp).toISOString();
+ const timeOnly = isoString.substring(11, 19) + ' UTC'; // Extract HH:MM:SS and add UTC
+ return {
+ id,
+ canRead: true,
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: timeOnly,
+ timestampIso: isoString,
+ bulletin: {
+ id,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'TRACE',
+ message: `Test message ${id}`,
+ timestamp: timeOnly,
+ timestampIso: isoString
+ }
+ };
+ };
+
+ beforeEach(() => {
+ testState = {
+ ...initialState,
+ flowAnalysisRules: [
+ {
+ id: 'flow-analysis-rule-1',
+ uri: 'test-uri-1',
+ status: { runStatus: 'ENABLED', validationStatus: 'VALID' },
+ bulletins: [
+ createTestBulletin(1, olderTimestamp), // Should be removed
+ createTestBulletin(2, testTimestamp), // Should be kept (equal)
+ createTestBulletin(3, newerTimestamp) // Should be kept
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'flow-analysis-rule-1',
+ name: 'Test Flow Analysis Rule',
+ type: 'org.apache.nifi.TestFlowAnalysisRule',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ state: 'ENABLED',
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ enforcementPolicy: 'WARN'
+ }
+ },
+ {
+ id: 'flow-analysis-rule-2',
+ uri: 'test-uri-2',
+ status: { runStatus: 'DISABLED', validationStatus: 'VALID' },
+ bulletins: [
+ createTestBulletin(4, olderTimestamp) // Should be removed
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'flow-analysis-rule-2',
+ name: 'Another Flow Analysis Rule',
+ type: 'org.apache.nifi.AnotherFlowAnalysisRule',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ state: 'DISABLED',
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ enforcementPolicy: 'ENFORCE'
+ }
+ }
+ ]
+ };
+ });
+
+ it('should replace bulletins for the specified flow analysis rule', () => {
+ // Server returns bulletins that remain after clearing (equal and newer bulletins)
+ const action = clearFlowAnalysisRuleBulletinsSuccess({
+ response: {
+ componentId: 'flow-analysis-rule-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, testTimestamp), createTestBulletin(3, newerTimestamp)],
+ componentType: ComponentType.FlowAnalysisRule
+ }
+ });
+
+ const newState = flowAnalysisRulesReducer(testState, action);
+
+ const flowAnalysisRule = newState.flowAnalysisRules.find((far) => far.id === 'flow-analysis-rule-1');
+ expect(flowAnalysisRule).toBeDefined();
+ expect(flowAnalysisRule!.bulletins).toHaveLength(2);
+ expect(flowAnalysisRule!.bulletins!.map((b) => b.id)).toEqual([2, 3]); // Equal and newer bulletins remain
+ });
+
+ it('should not affect other flow analysis rules', () => {
+ const action = clearFlowAnalysisRuleBulletinsSuccess({
+ response: {
+ componentId: 'flow-analysis-rule-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, testTimestamp), createTestBulletin(3, newerTimestamp)],
+ componentType: ComponentType.FlowAnalysisRule
+ }
+ });
+
+ const newState = flowAnalysisRulesReducer(testState, action);
+
+ const otherFlowAnalysisRule = newState.flowAnalysisRules.find((far) => far.id === 'flow-analysis-rule-2');
+ expect(otherFlowAnalysisRule).toBeDefined();
+ expect(otherFlowAnalysisRule!.bulletins).toHaveLength(1); // Original count unchanged
+ expect(otherFlowAnalysisRule!.bulletins![0].id).toBe(4);
+ });
+
+ it('should handle flow analysis rule with no bulletins', () => {
+ testState.flowAnalysisRules[0].bulletins = [] as any;
+
+ const action = clearFlowAnalysisRuleBulletinsSuccess({
+ response: {
+ componentId: 'flow-analysis-rule-1',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.FlowAnalysisRule
+ }
+ });
+
+ const newState = flowAnalysisRulesReducer(testState, action);
+
+ const flowAnalysisRule = newState.flowAnalysisRules.find((far) => far.id === 'flow-analysis-rule-1');
+ expect(flowAnalysisRule).toBeDefined();
+ expect(flowAnalysisRule!.bulletins).toEqual([]);
+ });
+
+ it('should handle non-existent flow analysis rule gracefully', () => {
+ const action = clearFlowAnalysisRuleBulletinsSuccess({
+ response: {
+ componentId: 'non-existent-rule',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.FlowAnalysisRule
+ }
+ });
+
+ const newState = flowAnalysisRulesReducer(testState, action);
+
+ // State should remain unchanged
+ expect(newState).toEqual(testState);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.ts
index 0ef9563918..e761815759 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/flow-analysis-rules/flow-analysis-rules.reducer.ts
@@ -18,6 +18,7 @@
import { createReducer, on } from '@ngrx/store';
import { FlowAnalysisRulesState } from './index';
import {
+ clearFlowAnalysisRuleBulletinsSuccess,
configureFlowAnalysisRule,
configureFlowAnalysisRuleSuccess,
createFlowAnalysisRule,
@@ -121,5 +122,17 @@ export const flowAnalysisRulesReducer = createReducer(
}
draftState.saving = false;
});
+ }),
+ on(clearFlowAnalysisRuleBulletinsSuccess, (state, { response }) => {
+ return produce(state, (draftState) => {
+ const componentIndex: number = draftState.flowAnalysisRules.findIndex(
+ (rule: any) => rule.id === response.componentId
+ );
+ if (componentIndex > -1) {
+ const rule = draftState.flowAnalysisRules[componentIndex];
+ // Replace bulletins with the current bulletins from the server
+ rule.bulletins = response.bulletins || [];
+ }
+ });
})
);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.actions.ts
index f9a5e76528..4b4c7e6735 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.actions.ts
@@ -26,6 +26,8 @@ import {
SelectControllerServiceRequest
} from './index';
import {
+ ClearBulletinsRequest,
+ ClearBulletinsResponse,
CreateControllerServiceRequest,
DisableControllerServiceDialogRequest,
EditControllerServiceDialogRequest,
@@ -144,3 +146,13 @@ export const openChangeMgtControllerServiceVersionDialog = createAction(
`[Management Controller Services] Open Change Management Controller Service Version Dialog`,
props<{ request: FetchComponentVersionsRequest }>()
);
+
+export const clearControllerServiceBulletins = createAction(
+ '[Management Controller Services] Clear Controller Service Bulletins',
+ props<{ request: ClearBulletinsRequest }>()
+);
+
+export const clearControllerServiceBulletinsSuccess = createAction(
+ '[Management Controller Services] Clear Controller Service Bulletins Success',
+ props<{ response: ClearBulletinsResponse }>()
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.spec.ts
index e052cdfca7..54d59e488c 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.spec.ts
@@ -6,7 +6,7 @@
* (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
+ * 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,
@@ -17,84 +17,124 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
-import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { provideMockStore } from '@ngrx/store/testing';
import { Action } from '@ngrx/store';
-import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { ReplaySubject, of, throwError, firstValueFrom } from 'rxjs';
+import { take } from 'rxjs/operators';
+import { Router } from '@angular/router';
+import { MatDialog } from '@angular/material/dialog';
import { HttpErrorResponse } from '@angular/common/http';
-import * as ManagementControllerServicesActions from './management-controller-services.actions';
import { ManagementControllerServicesEffects } from './management-controller-services.effects';
+import * as ManagementControllerServicesActions from './management-controller-services.actions';
import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
+import { Client } from '../../../../service/client.service';
import { ErrorHelper } from '../../../../service/error-helper.service';
-import { MatDialog } from '@angular/material/dialog';
-import { Router } from '@angular/router';
import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
-import { Client } from '../../../../service/client.service';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { ComponentType } from '@nifi/shared';
+import { managementControllerServicesFeatureKey } from './index';
import { initialState } from './management-controller-services.reducer';
+import { settingsFeatureKey } from '../index';
describe('ManagementControllerServicesEffects', () => {
- interface SetupOptions {
- managementControllerServicesState?: any;
- }
+ let actions$: ReplaySubject<Action>;
+ let effects: ManagementControllerServicesEffects;
+ let mockManagementControllerServiceService: jest.Mocked<ManagementControllerServiceService>;
+ let mockErrorHelper: jest.Mocked<ErrorHelper>;
+
+ beforeEach(() => {
+ const managementControllerServiceServiceSpy = {
+ getControllerServices: jest.fn(),
+ createControllerService: jest.fn(),
+ getControllerService: jest.fn(),
+ getPropertyDescriptor: jest.fn(),
+ updateControllerService: jest.fn(),
+ deleteControllerService: jest.fn(),
+ clearBulletins: jest.fn()
+ } as unknown as jest.Mocked<ManagementControllerServiceService>;
- const mockResponse = {
- controllerServices: [],
- currentTime: '2023-01-01 12:00:00 EST'
- };
+ const errorHelperSpy = {
+ handleLoadingError: jest.fn().mockReturnValue({ type: '[Error] Loading Error' }),
+ getErrorString: jest.fn(),
+ showErrorInContext: jest.fn(),
+ fullScreenError: jest.fn()
+ } as unknown as jest.Mocked<ErrorHelper>;
- async function setup({ managementControllerServicesState = initialState }: SetupOptions = {}) {
- await TestBed.configureTestingModule({
+ TestBed.configureTestingModule({
providers: [
ManagementControllerServicesEffects,
- provideMockActions(() => action$),
+ provideMockActions(() => actions$),
provideMockStore({
initialState: {
- settings: {
- managementControllerServices: managementControllerServicesState
+ [settingsFeatureKey]: {
+ [managementControllerServicesFeatureKey]: initialState
}
}
}),
- { provide: ManagementControllerServiceService, useValue: { getControllerServices: jest.fn() } },
- { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn() } },
- { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
- { provide: Router, useValue: { navigate: jest.fn() } },
- { provide: PropertyTableHelperService, useValue: {} },
- { provide: Client, useValue: {} },
- { provide: ExtensionTypesService, useValue: {} }
+ {
+ provide: ManagementControllerServiceService,
+ useValue: managementControllerServiceServiceSpy
+ },
+ {
+ provide: Client,
+ useValue: {
+ getClientId: jest.fn().mockReturnValue('test-client-id')
+ }
+ },
+ {
+ provide: ErrorHelper,
+ useValue: errorHelperSpy
+ },
+ {
+ provide: Router,
+ useValue: {
+ navigate: jest.fn()
+ }
+ },
+ {
+ provide: MatDialog,
+ useValue: {
+ open: jest.fn(),
+ closeAll: jest.fn()
+ }
+ },
+ {
+ provide: PropertyTableHelperService,
+ useValue: {
+ getComponentHistory: jest.fn(),
+ createNewProperty: jest.fn(),
+ createNewService: jest.fn()
+ }
+ },
+ {
+ provide: ExtensionTypesService,
+ useValue: {
+ getControllerServiceVersionsForType: jest.fn()
+ }
+ }
]
- }).compileComponents();
-
- const store = TestBed.inject(MockStore);
- const effects = TestBed.inject(ManagementControllerServicesEffects);
- const managementControllerServiceService = TestBed.inject(ManagementControllerServiceService);
- const errorHelper = TestBed.inject(ErrorHelper);
-
- return { effects, store, managementControllerServiceService, errorHelper };
- }
-
- let action$: ReplaySubject<Action>;
- beforeEach(() => {
- action$ = new ReplaySubject<Action>();
- });
+ });
- it('should create', async () => {
- const { effects } = await setup();
- expect(effects).toBeTruthy();
+ effects = TestBed.inject(ManagementControllerServicesEffects);
+ mockManagementControllerServiceService = TestBed.inject(
+ ManagementControllerServiceService
+ ) as jest.Mocked<ManagementControllerServiceService>;
+ mockErrorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ actions$ = new ReplaySubject(1);
});
- describe('Load Management Controller Services', () => {
- it('should load management controller services successfully', async () => {
- const { effects, managementControllerServiceService } = await setup();
+ describe('loadManagementControllerServices$', () => {
+ it('should dispatch loadManagementControllerServicesSuccess when services are loaded successfully', async () => {
+ const mockResponse = {
+ controllerServices: [],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
+ mockManagementControllerServiceService.getControllerServices.mockReturnValue(of(mockResponse));
- action$.next(ManagementControllerServicesActions.loadManagementControllerServices());
- jest.spyOn(managementControllerServiceService, 'getControllerServices').mockReturnValueOnce(
- of(mockResponse) as never
- );
+ actions$.next(ManagementControllerServicesActions.loadManagementControllerServices());
- const result = await new Promise((resolve) =>
- effects.loadManagementControllerServices$.pipe(take(1)).subscribe(resolve)
- );
+ const result = await firstValueFrom(effects.loadManagementControllerServices$);
expect(result).toEqual(
ManagementControllerServicesActions.loadManagementControllerServicesSuccess({
@@ -104,103 +144,175 @@ describe('ManagementControllerServicesEffects', () => {
}
})
);
+ expect(mockManagementControllerServiceService.getControllerServices).toHaveBeenCalled();
});
- it('should fail to load management controller services on initial load with hasExistingData=false', async () => {
- const { effects, managementControllerServiceService } = await setup();
-
- action$.next(ManagementControllerServicesActions.loadManagementControllerServices());
- const error = new HttpErrorResponse({ status: 500 });
- jest.spyOn(managementControllerServiceService, 'getControllerServices').mockImplementationOnce(() =>
- throwError(() => error)
+ it('should dispatch loadManagementControllerServicesError with status pending on initial load failure', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ mockManagementControllerServiceService.getControllerServices.mockReturnValue(
+ throwError(() => errorResponse)
);
- const result = await new Promise((resolve) =>
- effects.loadManagementControllerServices$.pipe(take(1)).subscribe(resolve)
- );
+ actions$.next(ManagementControllerServicesActions.loadManagementControllerServices());
+
+ const result = await firstValueFrom(effects.loadManagementControllerServices$);
expect(result).toEqual(
ManagementControllerServicesActions.loadManagementControllerServicesError({
- errorResponse: error,
+ errorResponse,
loadedTimestamp: initialState.loadedTimestamp,
status: 'pending'
})
);
});
-
- it('should fail to load management controller services on refresh with hasExistingData=true', async () => {
- const stateWithData = { ...initialState, loadedTimestamp: '2023-01-01 11:00:00 EST' };
- const { effects, managementControllerServiceService } = await setup({
- managementControllerServicesState: stateWithData
- });
-
- action$.next(ManagementControllerServicesActions.loadManagementControllerServices());
- const error = new HttpErrorResponse({ status: 500 });
- jest.spyOn(managementControllerServiceService, 'getControllerServices').mockImplementationOnce(() =>
- throwError(() => error)
- );
-
- const result = await new Promise((resolve) =>
- effects.loadManagementControllerServices$.pipe(take(1)).subscribe(resolve)
- );
-
- expect(result).toEqual(
- ManagementControllerServicesActions.loadManagementControllerServicesError({
- errorResponse: error,
- loadedTimestamp: stateWithData.loadedTimestamp,
- status: 'success'
- })
- );
- });
});
- describe('Load Management Controller Services Error', () => {
- it('should handle management controller services error for initial load', async () => {
- const { effects, errorHelper } = await setup();
-
- const error = new HttpErrorResponse({ status: 500 });
+ describe('loadManagementControllerServicesError$', () => {
+ it('should handle error on initial load (hasExistingData=false)', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500 });
const errorAction = ManagementControllerServicesActions.managementControllerServicesBannerApiError({
- error: 'Error message'
+ error: 'Error loading'
});
- action$.next(
+
+ mockErrorHelper.handleLoadingError.mockReturnValue(errorAction);
+
+ actions$.next(
ManagementControllerServicesActions.loadManagementControllerServicesError({
- errorResponse: error,
+ errorResponse,
loadedTimestamp: initialState.loadedTimestamp,
status: 'pending'
})
);
- jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
- const result = await new Promise((resolve) =>
- effects.loadManagementControllerServicesError$.pipe(take(1)).subscribe(resolve)
- );
+ const result = await firstValueFrom(effects.loadManagementControllerServicesError$);
- expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
+ expect(mockErrorHelper.handleLoadingError).toHaveBeenCalledWith(false, errorResponse);
expect(result).toEqual(errorAction);
});
- it('should handle management controller services error for refresh', async () => {
- const { effects, errorHelper } = await setup();
-
- const error = new HttpErrorResponse({ status: 500 });
+ it('should handle error on refresh (hasExistingData=true)', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500 });
const errorAction = ManagementControllerServicesActions.managementControllerServicesBannerApiError({
- error: 'Error message'
+ error: 'Error loading'
});
- action$.next(
+
+ mockErrorHelper.handleLoadingError.mockReturnValue(errorAction);
+
+ actions$.next(
ManagementControllerServicesActions.loadManagementControllerServicesError({
- errorResponse: error,
+ errorResponse,
loadedTimestamp: '2023-01-01 11:00:00 EST',
status: 'success'
})
);
- jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
- const result = await new Promise((resolve) =>
- effects.loadManagementControllerServicesError$.pipe(take(1)).subscribe(resolve)
- );
+ const result = await firstValueFrom(effects.loadManagementControllerServicesError$);
- expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
+ expect(mockErrorHelper.handleLoadingError).toHaveBeenCalledWith(true, errorResponse);
expect(result).toEqual(errorAction);
});
});
+
+ describe('clearControllerServiceBulletins$', () => {
+ it('should dispatch clearControllerServiceBulletinsSuccess action when bulletins are cleared successfully', () => {
+ const request = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'controller-service-1',
+ componentType: ComponentType.ControllerService
+ };
+
+ const mockResponse = {
+ bulletinsCleared: 3,
+ bulletins: []
+ };
+
+ mockManagementControllerServiceService.clearBulletins.mockReturnValue(of(mockResponse));
+
+ const action = ManagementControllerServicesActions.clearControllerServiceBulletins({ request });
+ actions$.next(action);
+
+ effects.clearControllerServiceBulletins$.pipe(take(1)).subscribe((result) => {
+ expect(result).toEqual(
+ ManagementControllerServicesActions.clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: request.componentId,
+ bulletinsCleared: mockResponse.bulletinsCleared,
+ bulletins: mockResponse.bulletins || [],
+ componentType: request.componentType
+ }
+ })
+ );
+ });
+
+ expect(mockManagementControllerServiceService.clearBulletins).toHaveBeenCalledWith({
+ uri: request.uri,
+ fromTimestamp: request.fromTimestamp
+ });
+ });
+
+ it('should handle bulletinsCleared being undefined and default to 0', () => {
+ const request = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'controller-service-2',
+ componentType: ComponentType.ControllerService
+ };
+
+ const mockResponse = {
+ // bulletinsCleared is undefined
+ bulletins: []
+ };
+
+ mockManagementControllerServiceService.clearBulletins.mockReturnValue(of(mockResponse));
+
+ const action = ManagementControllerServicesActions.clearControllerServiceBulletins({ request });
+ actions$.next(action);
+
+ effects.clearControllerServiceBulletins$.pipe(take(1)).subscribe((result) => {
+ expect(result).toEqual(
+ ManagementControllerServicesActions.clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: request.componentId,
+ bulletinsCleared: 0, // Should default to 0
+ bulletins: mockResponse.bulletins || [],
+ componentType: request.componentType
+ }
+ })
+ );
+ });
+ });
+
+ it('should preserve request fields in the success response', () => {
+ const request = {
+ uri: 'different-uri',
+ fromTimestamp: '2023-06-15T08:30:00.000Z',
+ componentId: 'another-controller-service',
+ componentType: ComponentType.ControllerService
+ };
+
+ const mockResponse = {
+ bulletinsCleared: 7,
+ bulletins: []
+ };
+
+ mockManagementControllerServiceService.clearBulletins.mockReturnValue(of(mockResponse));
+
+ const action = ManagementControllerServicesActions.clearControllerServiceBulletins({ request });
+ actions$.next(action);
+
+ effects.clearControllerServiceBulletins$.pipe(take(1)).subscribe((result) => {
+ expect(result).toEqual(
+ ManagementControllerServicesActions.clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: request.componentId, // From request
+ bulletinsCleared: mockResponse.bulletinsCleared, // From response
+ bulletins: mockResponse.bulletins || [], // From response
+ componentType: request.componentType // From request
+ }
+ })
+ );
+ });
+ });
+ });
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.ts
index a141aec27c..b2598f43f5 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.effects.ts
@@ -700,6 +700,39 @@ export class ManagementControllerServicesEffects {
{ dispatch: false }
);
+ clearControllerServiceBulletins$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ManagementControllerServicesActions.clearControllerServiceBulletins),
+ map((action) => action.request),
+ switchMap((request) =>
+ from(
+ this.managementControllerServiceService.clearBulletins({
+ uri: request.uri,
+ fromTimestamp: request.fromTimestamp
+ })
+ ).pipe(
+ map((response) =>
+ ManagementControllerServicesActions.clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: request.componentId,
+ bulletinsCleared: response.bulletinsCleared || 0,
+ bulletins: response.bulletins || [],
+ componentType: request.componentType
+ }
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ ManagementControllerServicesActions.managementControllerServicesSnackbarApiError({
+ error: this.errorHelper.getErrorString(errorResponse)
+ })
+ )
+ )
+ )
+ )
+ )
+ );
+
private getRouteForReference(reference: ControllerServiceReferencingComponent): string[] {
if (reference.referenceType == 'ControllerService') {
if (reference.groupId == null) {
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.spec.ts
new file mode 100644
index 0000000000..ad1b974028
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.spec.ts
@@ -0,0 +1,221 @@
+/*
+ * 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 { managementControllerServicesReducer, initialState } from './management-controller-services.reducer';
+import { clearControllerServiceBulletinsSuccess } from './management-controller-services.actions';
+import { ManagementControllerServicesState } from './index';
+import { BulletinEntity, ComponentType } from '@nifi/shared';
+
+describe('Management Controller Services Reducer', () => {
+ describe('clearControllerServiceBulletinsSuccess', () => {
+ let testState: ManagementControllerServicesState;
+ const testTimestamp = new Date('2023-01-01T12:00:00.000Z').getTime();
+ const olderTimestamp = new Date('2023-01-01T11:00:00.000Z').getTime();
+ const newerTimestamp = new Date('2023-01-01T13:00:00.000Z').getTime();
+
+ const createTestBulletin = (id: number, timestamp: number): BulletinEntity => {
+ const isoString = new Date(timestamp).toISOString();
+ const timeOnly = isoString.substring(11, 19) + ' UTC'; // Extract HH:MM:SS and add UTC
+ return {
+ id,
+ canRead: true,
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: timeOnly,
+ timestampIso: isoString,
+ bulletin: {
+ id,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'ERROR',
+ message: `Test message ${id}`,
+ timestamp: timeOnly,
+ timestampIso: isoString
+ }
+ };
+ };
+
+ beforeEach(() => {
+ testState = {
+ ...initialState,
+ controllerServices: [
+ {
+ id: 'controller-service-1',
+ uri: 'test-uri-1',
+ status: { runStatus: 'ENABLED', validationStatus: 'VALID' },
+ bulletins: [
+ createTestBulletin(1, olderTimestamp), // Should be removed
+ createTestBulletin(2, testTimestamp), // Should be kept (equal)
+ createTestBulletin(3, newerTimestamp) // Should be kept
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'controller-service-1',
+ name: 'Test Controller Service',
+ type: 'org.apache.nifi.TestControllerService',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ state: 'ENABLED',
+ properties: {},
+ descriptors: {},
+ validationErrors: []
+ }
+ },
+ {
+ id: 'controller-service-2',
+ uri: 'test-uri-2',
+ status: { runStatus: 'DISABLED', validationStatus: 'VALID' },
+ bulletins: [
+ createTestBulletin(4, olderTimestamp) // Should be removed
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'controller-service-2',
+ name: 'Another Controller Service',
+ type: 'org.apache.nifi.AnotherControllerService',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ state: 'DISABLED',
+ properties: {},
+ descriptors: {},
+ validationErrors: []
+ }
+ }
+ ]
+ };
+ });
+
+ it('should replace bulletins for the specified controller service', () => {
+ // Server returns bulletins that remain after clearing (equal and newer bulletins)
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, testTimestamp), createTestBulletin(3, newerTimestamp)],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = managementControllerServicesReducer(testState, action);
+
+ const controllerService = newState.controllerServices.find((cs) => cs.id === 'controller-service-1');
+ expect(controllerService).toBeDefined();
+ expect(controllerService!.bulletins).toHaveLength(2);
+ expect(controllerService!.bulletins!.map((b) => b.id)).toEqual([2, 3]); // Equal and newer bulletins remain
+ });
+
+ it('should not affect other controller services', () => {
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, testTimestamp), createTestBulletin(3, newerTimestamp)],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = managementControllerServicesReducer(testState, action);
+
+ const otherControllerService = newState.controllerServices.find((cs) => cs.id === 'controller-service-2');
+ expect(otherControllerService).toBeDefined();
+ expect(otherControllerService!.bulletins).toHaveLength(1); // Original count unchanged
+ expect(otherControllerService!.bulletins![0].id).toBe(4);
+ });
+
+ it('should handle controller service with no bulletins', () => {
+ testState.controllerServices[0].bulletins = [] as any;
+
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-1',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = managementControllerServicesReducer(testState, action);
+
+ const controllerService = newState.controllerServices.find((cs) => cs.id === 'controller-service-1');
+ expect(controllerService).toBeDefined();
+ expect(controllerService!.bulletins).toEqual([]);
+ });
+
+ it('should handle controller service with empty bulletins array', () => {
+ testState.controllerServices[0].bulletins = [];
+
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-1',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = managementControllerServicesReducer(testState, action);
+
+ const controllerService = newState.controllerServices.find((cs) => cs.id === 'controller-service-1');
+ expect(controllerService).toBeDefined();
+ expect(controllerService!.bulletins).toEqual([]);
+ });
+
+ it('should handle non-existent controller service gracefully', () => {
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'non-existent-service',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = managementControllerServicesReducer(testState, action);
+
+ // State should remain unchanged
+ expect(newState).toEqual(testState);
+ });
+
+ it('should remove all bulletins older than the specified timestamp', () => {
+ testState.controllerServices[1].bulletins = [
+ createTestBulletin(5, olderTimestamp),
+ createTestBulletin(6, new Date('2023-01-01T10:00:00.000Z').getTime()), // Even older
+ createTestBulletin(7, newerTimestamp)
+ ];
+
+ const action = clearControllerServiceBulletinsSuccess({
+ response: {
+ componentId: 'controller-service-2',
+ bulletinsCleared: 2,
+ bulletins: [createTestBulletin(7, newerTimestamp)], // Only newer bulletin remains
+ componentType: ComponentType.ControllerService
+ }
+ });
+
+ const newState = managementControllerServicesReducer(testState, action);
+
+ const controllerService = newState.controllerServices.find((cs) => cs.id === 'controller-service-2');
+ expect(controllerService).toBeDefined();
+ expect(controllerService!.bulletins).toHaveLength(1);
+ expect(controllerService!.bulletins![0].id).toBe(7); // Only newer bulletin remains
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.ts
index 4915195286..aba59f3320 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/management-controller-services/management-controller-services.reducer.ts
@@ -18,6 +18,7 @@
import { createReducer, on } from '@ngrx/store';
import { ManagementControllerServicesState } from './index';
import {
+ clearControllerServiceBulletinsSuccess,
configureControllerService,
configureControllerServiceSuccess,
createControllerService,
@@ -98,5 +99,17 @@ export const managementControllerServicesReducer = createReducer(
}
draftState.saving = false;
});
+ }),
+ on(clearControllerServiceBulletinsSuccess, (state, { response }) => {
+ return produce(state, (draftState) => {
+ const componentIndex: number = draftState.controllerServices.findIndex(
+ (service: any) => service.id === response.componentId
+ );
+ if (componentIndex > -1) {
+ const service = draftState.controllerServices[componentIndex];
+ // Replace bulletins with the current bulletins from the server
+ service.bulletins = response.bulletins || [];
+ }
+ });
})
);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/index.ts
index 3e51c2a08b..6b6679d583 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/index.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/index.ts
@@ -16,7 +16,13 @@
*/
import { Bundle, ComponentHistory, ParameterEntity, PropertyDescriptor } from '../../../../state/shared';
-import { AffectedComponentEntity, ParameterContextReferenceEntity, Permissions, Revision } from '@nifi/shared';
+import {
+ AffectedComponentEntity,
+ BulletinEntity,
+ ParameterContextReferenceEntity,
+ Permissions,
+ Revision
+} from '@nifi/shared';
export const parameterProvidersFeatureKey = 'parameterProviders';
@@ -67,7 +73,7 @@ export interface ParameterProvider {
export interface ParameterProviderEntity {
id: string;
- bulletins: [];
+ bulletins: BulletinEntity[];
component: ParameterProvider;
permissions: Permissions;
revision: Revision;
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.actions.ts
index 5652317803..24c17e2fb3 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.actions.ts
@@ -31,6 +31,7 @@ import {
PollParameterProviderParametersUpdateSuccess,
SelectParameterProviderRequest
} from './index';
+import { ClearBulletinsRequest, ClearBulletinsResponse } from '../../../../state/shared';
const PARAMETER_PROVIDERS_PREFIX = '[Parameter Providers]';
@@ -176,3 +177,13 @@ export const deleteParameterProviderParametersUpdateRequest = createAction(
export const submitParameterProviderParametersUpdateComplete = createAction(
`${PARAMETER_PROVIDERS_PREFIX} Submit Parameter Provider Parameters Update Complete`
);
+
+export const clearParameterProviderBulletins = createAction(
+ `${PARAMETER_PROVIDERS_PREFIX} Clear Parameter Provider Bulletins`,
+ props<{ request: ClearBulletinsRequest }>()
+);
+
+export const clearParameterProviderBulletinsSuccess = createAction(
+ `${PARAMETER_PROVIDERS_PREFIX} Clear Parameter Provider Bulletins Success`,
+ props<{ response: ClearBulletinsResponse }>()
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.spec.ts
index ea95c21a85..c75dfd7862 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.spec.ts
@@ -6,7 +6,7 @@
* (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
+ * 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,
@@ -17,88 +17,180 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
-import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { provideMockStore } from '@ngrx/store/testing';
import { Action } from '@ngrx/store';
-import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { ReplaySubject, of, throwError, firstValueFrom } from 'rxjs';
+import { Router } from '@angular/router';
+import { MatDialog } from '@angular/material/dialog';
import { HttpErrorResponse } from '@angular/common/http';
-import * as ParameterProviderActions from './parameter-providers.actions';
import { ParameterProvidersEffects } from './parameter-providers.effects';
+import * as ParameterProviderActions from './parameter-providers.actions';
import { ParameterProviderService } from '../../service/parameter-provider.service';
-import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
+import { Client } from '../../../../service/client.service';
import { ErrorHelper } from '../../../../service/error-helper.service';
-import { MatDialog } from '@angular/material/dialog';
-import { Router } from '@angular/router';
import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
-import { Client } from '../../../../service/client.service';
-import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
+import { ComponentType } from '@nifi/shared';
+import { ParameterProviderEntity, parameterProvidersFeatureKey } from './index';
import { initialParameterProvidersState } from './parameter-providers.reducer';
+import { settingsFeatureKey } from '../index';
describe('ParameterProvidersEffects', () => {
- interface SetupOptions {
- parameterProvidersState?: any;
+ let actions$: ReplaySubject<Action>;
+ let effects: ParameterProvidersEffects;
+ let mockParameterProviderService: jest.Mocked<ParameterProviderService>;
+ let mockErrorHelper: jest.Mocked<ErrorHelper>;
+ let mockDialog: jest.Mocked<MatDialog>;
+
+ // Mock data factories
+ function createMockParameterProviderEntity(): ParameterProviderEntity {
+ return {
+ id: 'test-parameter-provider-id',
+ uri: 'test-uri',
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ revision: {
+ version: 1,
+ clientId: 'test-client'
+ },
+ component: {
+ id: 'test-parameter-provider-id',
+ name: 'Test Parameter Provider',
+ type: 'org.apache.nifi.TestParameterProvider',
+ bundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-bundle',
+ version: '1.0.0'
+ },
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ affectedComponents: [],
+ parameterGroupConfigurations: [],
+ comments: '',
+ deprecated: false,
+ extensionMissing: false,
+ multipleVersionsAvailable: false,
+ persistsState: false,
+ referencingParameterContexts: [],
+ restricted: false,
+ validationStatus: 'VALID'
+ },
+ bulletins: []
+ } as ParameterProviderEntity;
}
- const mockResponse = {
- parameterProviders: [],
- currentTime: '2023-01-01 12:00:00 EST'
- };
+ beforeEach(() => {
+ const parameterProviderServiceSpy = {
+ getParameterProviders: jest.fn(),
+ createParameterProvider: jest.fn(),
+ updateParameterProvider: jest.fn(),
+ deleteParameterProvider: jest.fn(),
+ fetchParameters: jest.fn(),
+ applyParameters: jest.fn(),
+ pollParameterProviderParametersUpdateRequest: jest.fn(),
+ deleteParameterProviderParametersUpdateRequest: jest.fn(),
+ clearBulletins: jest.fn()
+ } as unknown as jest.Mocked<ParameterProviderService>;
+
+ const clientSpy = {
+ getClientId: jest.fn().mockReturnValue('test-client-id')
+ } as unknown as jest.Mocked<Client>;
+
+ const errorHelperSpy = {
+ handleLoadingError: jest.fn().mockReturnValue({ type: '[Error] Loading Error' }),
+ getErrorString: jest.fn().mockReturnValue('Test error message'),
+ showErrorInContext: jest.fn().mockReturnValue(true),
+ fullScreenError: jest.fn()
+ } as unknown as jest.Mocked<ErrorHelper>;
+
+ const routerSpy = {
+ navigate: jest.fn()
+ } as unknown as jest.Mocked<Router>;
- async function setup({ parameterProvidersState = initialParameterProvidersState }: SetupOptions = {}) {
- await TestBed.configureTestingModule({
+ const dialogSpy = {
+ open: jest.fn(),
+ closeAll: jest.fn()
+ } as unknown as jest.Mocked<MatDialog>;
+
+ TestBed.configureTestingModule({
providers: [
ParameterProvidersEffects,
- provideMockActions(() => action$),
+ provideMockActions(() => actions$),
provideMockStore({
initialState: {
- settings: {
- parameterProviders: parameterProvidersState
+ [settingsFeatureKey]: {
+ [parameterProvidersFeatureKey]: initialParameterProvidersState
}
}
}),
- { provide: ParameterProviderService, useValue: { getParameterProviders: jest.fn() } },
- { provide: ManagementControllerServiceService, useValue: {} },
- { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn() } },
- { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
- { provide: Router, useValue: { navigate: jest.fn() } },
- { provide: PropertyTableHelperService, useValue: {} },
- { provide: Client, useValue: {} },
- { provide: ExtensionTypesService, useValue: {} }
+ {
+ provide: ParameterProviderService,
+ useValue: parameterProviderServiceSpy
+ },
+ {
+ provide: Client,
+ useValue: clientSpy
+ },
+ {
+ provide: ErrorHelper,
+ useValue: errorHelperSpy
+ },
+ {
+ provide: Router,
+ useValue: routerSpy
+ },
+ {
+ provide: MatDialog,
+ useValue: dialogSpy
+ },
+ {
+ provide: PropertyTableHelperService,
+ useValue: {
+ getComponentHistory: jest.fn(),
+ createNewProperty: jest.fn(),
+ createNewService: jest.fn()
+ }
+ },
+ {
+ provide: ManagementControllerServiceService,
+ useValue: {
+ getControllerServices: jest.fn(),
+ createControllerService: jest.fn(),
+ getControllerService: jest.fn(),
+ updateControllerService: jest.fn(),
+ deleteControllerService: jest.fn()
+ }
+ }
]
- }).compileComponents();
-
- const store = TestBed.inject(MockStore);
- const effects = TestBed.inject(ParameterProvidersEffects);
- const parameterProviderService = TestBed.inject(ParameterProviderService);
- const errorHelper = TestBed.inject(ErrorHelper);
-
- return { effects, store, parameterProviderService, errorHelper };
- }
+ });
- let action$: ReplaySubject<Action>;
- beforeEach(() => {
- action$ = new ReplaySubject<Action>();
+ effects = TestBed.inject(ParameterProvidersEffects);
+ mockParameterProviderService = TestBed.inject(
+ ParameterProviderService
+ ) as jest.Mocked<ParameterProviderService>;
+ mockErrorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ mockDialog = TestBed.inject(MatDialog) as jest.Mocked<MatDialog>;
+ actions$ = new ReplaySubject(1);
});
- it('should create', async () => {
- const { effects } = await setup();
- expect(effects).toBeTruthy();
- });
+ describe('loadParameterProviders$', () => {
+ it('should dispatch loadParameterProvidersSuccess action when parameter providers are loaded successfully', async () => {
+ const mockResponse = {
+ parameterProviders: [createMockParameterProviderEntity()],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
- describe('Load Parameter Providers', () => {
- it('should load parameter providers successfully', async () => {
- const { effects, parameterProviderService } = await setup();
+ mockParameterProviderService.getParameterProviders.mockReturnValue(of(mockResponse));
- action$.next(ParameterProviderActions.loadParameterProviders());
- jest.spyOn(parameterProviderService, 'getParameterProviders').mockReturnValueOnce(
- of(mockResponse) as never
- );
+ actions$.next(ParameterProviderActions.loadParameterProviders());
- const result = await new Promise((resolve) =>
- effects.loadParameterProviders$.pipe(take(1)).subscribe(resolve)
- );
+ const action = await firstValueFrom(effects.loadParameterProviders$);
- expect(result).toEqual(
+ expect(action).toEqual(
ParameterProviderActions.loadParameterProvidersSuccess({
response: {
parameterProviders: mockResponse.parameterProviders,
@@ -106,99 +198,416 @@ describe('ParameterProvidersEffects', () => {
}
})
);
+ expect(mockParameterProviderService.getParameterProviders).toHaveBeenCalled();
});
- it('should fail to load parameter providers on initial load with hasExistingData=false', async () => {
- const { effects, parameterProviderService } = await setup();
+ it('should dispatch loadParameterProvidersError with status pending on initial load failure', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ mockParameterProviderService.getParameterProviders.mockReturnValue(throwError(() => errorResponse));
- action$.next(ParameterProviderActions.loadParameterProviders());
- const error = new HttpErrorResponse({ status: 500 });
- jest.spyOn(parameterProviderService, 'getParameterProviders').mockImplementationOnce(() =>
- throwError(() => error)
- );
+ actions$.next(ParameterProviderActions.loadParameterProviders());
- const result = await new Promise((resolve) =>
- effects.loadParameterProviders$.pipe(take(1)).subscribe(resolve)
- );
+ const action = await firstValueFrom(effects.loadParameterProviders$);
- expect(result).toEqual(
+ expect(action).toEqual(
ParameterProviderActions.loadParameterProvidersError({
- errorResponse: error,
+ errorResponse,
loadedTimestamp: initialParameterProvidersState.loadedTimestamp,
status: 'pending'
})
);
});
+ });
- it('should fail to load parameter providers on refresh with hasExistingData=true', async () => {
- const stateWithData = { ...initialParameterProvidersState, loadedTimestamp: '2023-01-01 11:00:00 EST' };
- const { effects, parameterProviderService } = await setup({
- parameterProvidersState: stateWithData
- });
+ describe('loadParameterProvidersError$', () => {
+ it('should handle error on initial load (hasExistingData=false)', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500 });
+ const errorAction = ParameterProviderActions.parameterProvidersBannerApiError({ error: 'Error loading' });
- action$.next(ParameterProviderActions.loadParameterProviders());
- const error = new HttpErrorResponse({ status: 500 });
- jest.spyOn(parameterProviderService, 'getParameterProviders').mockImplementationOnce(() =>
- throwError(() => error)
- );
+ mockErrorHelper.handleLoadingError.mockReturnValue(errorAction);
- const result = await new Promise((resolve) =>
- effects.loadParameterProviders$.pipe(take(1)).subscribe(resolve)
+ actions$.next(
+ ParameterProviderActions.loadParameterProvidersError({
+ errorResponse,
+ loadedTimestamp: initialParameterProvidersState.loadedTimestamp,
+ status: 'pending'
+ })
);
- expect(result).toEqual(
+ const action = await firstValueFrom(effects.loadParameterProvidersError$);
+
+ expect(mockErrorHelper.handleLoadingError).toHaveBeenCalledWith(false, errorResponse);
+ expect(action).toEqual(errorAction);
+ });
+
+ it('should handle error on refresh (hasExistingData=true)', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500 });
+ const errorAction = ParameterProviderActions.parameterProvidersBannerApiError({ error: 'Error loading' });
+
+ mockErrorHelper.handleLoadingError.mockReturnValue(errorAction);
+
+ actions$.next(
ParameterProviderActions.loadParameterProvidersError({
- errorResponse: error,
- loadedTimestamp: stateWithData.loadedTimestamp,
+ errorResponse,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
status: 'success'
})
);
+
+ const action = await firstValueFrom(effects.loadParameterProvidersError$);
+
+ expect(mockErrorHelper.handleLoadingError).toHaveBeenCalledWith(true, errorResponse);
+ expect(action).toEqual(errorAction);
});
});
- describe('loadParameterProvidersError$', () => {
- it('should handle parameter providers error for initial load', async () => {
- const { effects, errorHelper } = await setup();
+ describe('createParameterProvider$', () => {
+ it('should dispatch createParameterProviderSuccess action when parameter provider is created successfully', async () => {
+ const mockRequest = {
+ parameterProviderType: 'org.apache.nifi.TestParameterProvider',
+ parameterProviderBundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-bundle',
+ version: '1.0.0'
+ },
+ revision: {
+ clientId: 'test-client-id',
+ version: 0
+ }
+ };
+ const mockResponse = createMockParameterProviderEntity();
- const error = new HttpErrorResponse({ status: 500 });
- const errorAction = ParameterProviderActions.parameterProvidersBannerApiError({ error: 'Error message' });
- action$.next(
- ParameterProviderActions.loadParameterProvidersError({
- errorResponse: error,
- loadedTimestamp: initialParameterProvidersState.loadedTimestamp,
- status: 'pending'
+ mockParameterProviderService.createParameterProvider.mockReturnValue(of(mockResponse));
+
+ actions$.next(ParameterProviderActions.createParameterProvider({ request: mockRequest }));
+
+ const action = await firstValueFrom(effects.createParameterProvider$);
+
+ expect(action).toEqual(
+ ParameterProviderActions.createParameterProviderSuccess({
+ response: {
+ parameterProvider: mockResponse
+ }
})
);
- jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+ expect(mockParameterProviderService.createParameterProvider).toHaveBeenCalledWith(mockRequest);
+ });
+
+ it('should handle error and close dialog when creating parameter provider fails', async () => {
+ const mockRequest = {
+ parameterProviderType: 'org.apache.nifi.TestParameterProvider',
+ parameterProviderBundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-bundle',
+ version: '1.0.0'
+ },
+ revision: {
+ clientId: 'test-client-id',
+ version: 0
+ }
+ };
+ const mockError = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' });
- const result = await new Promise((resolve) =>
- effects.loadParameterProvidersError$.pipe(take(1)).subscribe(resolve)
+ mockParameterProviderService.createParameterProvider.mockReturnValue(throwError(() => mockError));
+
+ actions$.next(ParameterProviderActions.createParameterProvider({ request: mockRequest }));
+
+ const action = await firstValueFrom(effects.createParameterProvider$);
+
+ expect(mockDialog.closeAll).toHaveBeenCalled();
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(mockError);
+ expect(action.type).toContain('Snackbar Error');
+ });
+ });
+
+ describe('deleteParameterProvider$', () => {
+ it('should dispatch deleteParameterProviderSuccess action when parameter provider is deleted successfully', async () => {
+ const mockParameterProvider = createMockParameterProviderEntity();
+ const mockRequest = { parameterProvider: mockParameterProvider };
+
+ mockParameterProviderService.deleteParameterProvider.mockReturnValue(of(mockParameterProvider));
+
+ actions$.next(ParameterProviderActions.deleteParameterProvider({ request: mockRequest }));
+
+ const action = await firstValueFrom(effects.deleteParameterProvider);
+
+ expect(action).toEqual(
+ ParameterProviderActions.deleteParameterProviderSuccess({
+ response: {
+ parameterProvider: mockParameterProvider
+ }
+ })
);
+ expect(mockParameterProviderService.deleteParameterProvider).toHaveBeenCalledWith(mockRequest);
+ });
+
+ it('should handle error when deleting parameter provider fails', async () => {
+ const mockParameterProvider = createMockParameterProviderEntity();
+ const mockRequest = { parameterProvider: mockParameterProvider };
+ const mockError = new HttpErrorResponse({ status: 404, statusText: 'Not Found' });
+
+ mockParameterProviderService.deleteParameterProvider.mockReturnValue(throwError(() => mockError));
+
+ actions$.next(ParameterProviderActions.deleteParameterProvider({ request: mockRequest }));
+
+ const action = await firstValueFrom(effects.deleteParameterProvider);
- expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, error);
- expect(result).toEqual(errorAction);
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(mockError);
+ expect(action.type).toContain('Snackbar Error');
});
+ });
- it('should handle parameter providers error for refresh', async () => {
- const { effects, errorHelper } = await setup();
+ describe('clearParameterProviderBulletins$', () => {
+ it('should dispatch clearParameterProviderBulletinsSuccess action when bulletins are cleared successfully', async () => {
+ const mockRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-parameter-provider-id',
+ componentType: ComponentType.ParameterProvider
+ };
+ const mockResponse = {
+ bulletinsCleared: 5,
+ bulletins: []
+ };
- const error = new HttpErrorResponse({ status: 500 });
- const errorAction = ParameterProviderActions.parameterProvidersBannerApiError({ error: 'Error message' });
- action$.next(
- ParameterProviderActions.loadParameterProvidersError({
- errorResponse: error,
- loadedTimestamp: '2023-01-01 11:00:00 EST',
- status: 'success'
+ mockParameterProviderService.clearBulletins.mockReturnValue(of(mockResponse));
+
+ actions$.next(ParameterProviderActions.clearParameterProviderBulletins({ request: mockRequest }));
+
+ const action = await firstValueFrom(effects.clearParameterProviderBulletins$);
+
+ expect(action).toEqual(
+ ParameterProviderActions.clearParameterProviderBulletinsSuccess({
+ response: {
+ componentId: mockRequest.componentId,
+ bulletinsCleared: mockResponse.bulletinsCleared,
+ bulletins: mockResponse.bulletins || [],
+ componentType: mockRequest.componentType
+ }
+ })
+ );
+ expect(mockParameterProviderService.clearBulletins).toHaveBeenCalledWith({
+ uri: mockRequest.uri,
+ fromTimestamp: mockRequest.fromTimestamp
+ });
+ });
+
+ it('should handle undefined bulletinsCleared response', async () => {
+ const mockRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-parameter-provider-id',
+ componentType: ComponentType.ParameterProvider
+ };
+ const mockResponse = {
+ bulletins: []
+ }; // No bulletinsCleared property
+
+ mockParameterProviderService.clearBulletins.mockReturnValue(of(mockResponse));
+
+ actions$.next(ParameterProviderActions.clearParameterProviderBulletins({ request: mockRequest }));
+
+ const action = await firstValueFrom(effects.clearParameterProviderBulletins$);
+
+ expect(action).toEqual(
+ ParameterProviderActions.clearParameterProviderBulletinsSuccess({
+ response: {
+ componentId: mockRequest.componentId,
+ bulletinsCleared: 0, // Should default to 0
+ bulletins: mockResponse.bulletins || [],
+ componentType: mockRequest.componentType
+ }
+ })
+ );
+ });
+
+ it('should pass through all required fields in the success response', async () => {
+ const mockRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-parameter-provider-id',
+ componentType: ComponentType.ParameterProvider
+ };
+ const mockResponse = {
+ bulletinsCleared: 3,
+ bulletins: []
+ };
+
+ mockParameterProviderService.clearBulletins.mockReturnValue(of(mockResponse));
+
+ actions$.next(ParameterProviderActions.clearParameterProviderBulletins({ request: mockRequest }));
+
+ const action = await firstValueFrom(effects.clearParameterProviderBulletins$);
+
+ const response = (action as any).response;
+ expect(response.componentId).toBe(mockRequest.componentId);
+ expect(response.bulletinsCleared).toBe(mockResponse.bulletinsCleared);
+ expect(response.componentType).toBe(mockRequest.componentType);
+ expect(response.bulletins).toEqual([]);
+ });
+ });
+
+ describe('fetchParameterProviderParametersAndOpenDialog$', () => {
+ it('should dispatch fetchParameterProviderParametersSuccess action when parameters are fetched successfully', async () => {
+ const mockRequest = {
+ id: 'test-parameter-provider-id',
+ revision: {
+ version: 1,
+ clientId: 'test-client-id'
+ }
+ };
+ const mockResponse = createMockParameterProviderEntity();
+
+ mockParameterProviderService.fetchParameters.mockReturnValue(of(mockResponse));
+
+ actions$.next(
+ ParameterProviderActions.fetchParameterProviderParametersAndOpenDialog({ request: mockRequest })
+ );
+
+ const action = await firstValueFrom(effects.fetchParameterProviderParametersAndOpenDialog$);
+
+ expect(action).toEqual(
+ ParameterProviderActions.fetchParameterProviderParametersSuccess({
+ response: { parameterProvider: mockResponse }
})
);
- jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+ expect(mockParameterProviderService.fetchParameters).toHaveBeenCalledWith(mockRequest);
+ });
+
+ it('should handle error when fetching parameters fails', async () => {
+ const mockRequest = {
+ id: 'test-parameter-provider-id',
+ revision: {
+ version: 1,
+ clientId: 'test-client-id'
+ }
+ };
+ const mockError = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+
+ mockParameterProviderService.fetchParameters.mockReturnValue(throwError(() => mockError));
+ mockErrorHelper.showErrorInContext.mockReturnValue(true);
+
+ actions$.next(
+ ParameterProviderActions.fetchParameterProviderParametersAndOpenDialog({ request: mockRequest })
+ );
+
+ const action = await firstValueFrom(effects.fetchParameterProviderParametersAndOpenDialog$);
+
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(mockError);
+ expect(action.type).toContain('Snackbar Error');
+ });
+
+ it('should handle full screen error when showErrorInContext returns false', async () => {
+ const mockRequest = {
+ id: 'test-parameter-provider-id',
+ revision: {
+ version: 1,
+ clientId: 'test-client-id'
+ }
+ };
+ const mockError = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ const mockFullScreenErrorAction = { type: 'FULL_SCREEN_ERROR' };
+
+ mockParameterProviderService.fetchParameters.mockReturnValue(throwError(() => mockError));
+ mockErrorHelper.showErrorInContext.mockReturnValue(false);
+ mockErrorHelper.fullScreenError.mockReturnValue(mockFullScreenErrorAction);
+
+ actions$.next(
+ ParameterProviderActions.fetchParameterProviderParametersAndOpenDialog({ request: mockRequest })
+ );
+
+ const action = await firstValueFrom(effects.fetchParameterProviderParametersAndOpenDialog$);
+
+ expect(action).toEqual(mockFullScreenErrorAction);
+ expect(mockErrorHelper.fullScreenError).toHaveBeenCalledWith(mockError);
+ });
+ });
+
+ describe('configureParameterProvider$', () => {
+ it('should dispatch configureParameterProviderSuccess action when parameter provider is configured successfully', async () => {
+ const mockRequest = {
+ id: 'test-parameter-provider-id',
+ uri: 'test-uri',
+ payload: {
+ revision: { version: 1, clientId: 'test-client-id' },
+ component: { name: 'Updated Parameter Provider' }
+ },
+ postUpdateNavigation: undefined,
+ postUpdateNavigationBoundary: undefined
+ };
+ const mockResponse = createMockParameterProviderEntity();
+
+ mockParameterProviderService.updateParameterProvider.mockReturnValue(of(mockResponse));
- const result = await new Promise((resolve) =>
- effects.loadParameterProvidersError$.pipe(take(1)).subscribe(resolve)
+ actions$.next(ParameterProviderActions.configureParameterProvider({ request: mockRequest }));
+
+ const action = await firstValueFrom(effects.configureParameterProvider$);
+
+ expect(action).toEqual(
+ ParameterProviderActions.configureParameterProviderSuccess({
+ response: {
+ id: mockRequest.id,
+ parameterProvider: mockResponse,
+ postUpdateNavigation: mockRequest.postUpdateNavigation,
+ postUpdateNavigationBoundary: mockRequest.postUpdateNavigationBoundary
+ }
+ })
+ );
+ expect(mockParameterProviderService.updateParameterProvider).toHaveBeenCalledWith(mockRequest);
+ });
+
+ it('should handle error with banner when showErrorInContext returns true', async () => {
+ const mockRequest = {
+ id: 'test-parameter-provider-id',
+ uri: 'test-uri',
+ payload: {
+ revision: { version: 1, clientId: 'test-client-id' },
+ component: { name: 'Updated Parameter Provider' }
+ },
+ postUpdateNavigation: undefined,
+ postUpdateNavigationBoundary: undefined
+ };
+ const mockError = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' });
+
+ mockParameterProviderService.updateParameterProvider.mockReturnValue(throwError(() => mockError));
+ mockErrorHelper.showErrorInContext.mockReturnValue(true);
+
+ actions$.next(ParameterProviderActions.configureParameterProvider({ request: mockRequest }));
+
+ const action = await firstValueFrom(effects.configureParameterProvider$);
+
+ expect(action).toEqual(
+ ParameterProviderActions.parameterProvidersBannerApiError({
+ error: 'Test error message'
+ })
);
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(mockError);
+ });
+
+ it('should handle full screen error when showErrorInContext returns false', async () => {
+ const mockRequest = {
+ id: 'test-parameter-provider-id',
+ uri: 'test-uri',
+ payload: {
+ revision: { version: 1, clientId: 'test-client-id' },
+ component: { name: 'Updated Parameter Provider' }
+ },
+ postUpdateNavigation: undefined,
+ postUpdateNavigationBoundary: undefined
+ };
+ const mockError = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ const mockFullScreenErrorAction = { type: 'FULL_SCREEN_ERROR' };
+
+ mockParameterProviderService.updateParameterProvider.mockReturnValue(throwError(() => mockError));
+ mockErrorHelper.showErrorInContext.mockReturnValue(false);
+ mockErrorHelper.fullScreenError.mockReturnValue(mockFullScreenErrorAction);
+
+ actions$.next(ParameterProviderActions.configureParameterProvider({ request: mockRequest }));
+
+ const action = await firstValueFrom(effects.configureParameterProvider$);
- expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, error);
- expect(result).toEqual(errorAction);
+ expect(action).toEqual(mockFullScreenErrorAction);
+ expect(mockErrorHelper.fullScreenError).toHaveBeenCalledWith(mockError);
});
});
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.ts
index cfb2a545d1..86cae3a77a 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.effects.ts
@@ -796,4 +796,37 @@ export class ParameterProvidersEffects {
),
{ dispatch: false }
);
+
+ clearParameterProviderBulletins$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(ParameterProviderActions.clearParameterProviderBulletins),
+ map((action) => action.request),
+ switchMap((request) =>
+ from(
+ this.parameterProviderService.clearBulletins({
+ uri: request.uri,
+ fromTimestamp: request.fromTimestamp
+ })
+ ).pipe(
+ map((response) =>
+ ParameterProviderActions.clearParameterProviderBulletinsSuccess({
+ response: {
+ componentId: request.componentId,
+ bulletinsCleared: response.bulletinsCleared || 0,
+ bulletins: response.bulletins || [],
+ componentType: request.componentType
+ }
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ ErrorActions.snackBarError({
+ error: this.errorHelper.getErrorString(errorResponse)
+ })
+ )
+ )
+ )
+ )
+ )
+ );
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.spec.ts
new file mode 100644
index 0000000000..bb1e2c9cc7
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.spec.ts
@@ -0,0 +1,194 @@
+/*
+ * 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 { parameterProvidersReducer, initialParameterProvidersState } from './parameter-providers.reducer';
+import { clearParameterProviderBulletinsSuccess } from './parameter-providers.actions';
+import { ParameterProvidersState } from './index';
+import { BulletinEntity, ComponentType } from '@nifi/shared';
+
+describe('Parameter Providers Reducer', () => {
+ describe('clearParameterProviderBulletinsSuccess', () => {
+ let testState: ParameterProvidersState;
+ const testTimestamp = new Date('2023-01-01T12:00:00.000Z').getTime();
+ const olderTimestamp = new Date('2023-01-01T11:00:00.000Z').getTime();
+ const newerTimestamp = new Date('2023-01-01T13:00:00.000Z').getTime();
+
+ const createTestBulletin = (id: number, timestamp: number): BulletinEntity => {
+ const isoString = new Date(timestamp).toISOString();
+ const timeOnly = isoString.substring(11, 19) + ' UTC'; // Extract HH:MM:SS and add UTC
+ return {
+ id,
+ canRead: true,
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: timeOnly,
+ timestampIso: isoString,
+ bulletin: {
+ id,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'INFO',
+ message: `Test message ${id}`,
+ timestamp: timeOnly,
+ timestampIso: isoString
+ }
+ };
+ };
+
+ beforeEach(() => {
+ testState = {
+ ...initialParameterProvidersState,
+ parameterProviders: [
+ {
+ id: 'parameter-provider-1',
+ uri: 'test-uri-1',
+ bulletins: [
+ createTestBulletin(1, olderTimestamp), // Should be removed
+ createTestBulletin(2, testTimestamp), // Should be kept (equal)
+ createTestBulletin(3, newerTimestamp) // Should be kept
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'parameter-provider-1',
+ name: 'Test Parameter Provider',
+ type: 'org.apache.nifi.TestParameterProvider',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ affectedComponents: [],
+ parameterGroupConfigurations: [],
+ comments: '',
+ deprecated: false,
+ extensionMissing: false,
+ multipleVersionsAvailable: false,
+ persistsState: false,
+ referencingParameterContexts: [],
+ restricted: false,
+ validationStatus: 'VALID'
+ }
+ },
+ {
+ id: 'parameter-provider-2',
+ uri: 'test-uri-2',
+ bulletins: [
+ createTestBulletin(4, olderTimestamp) // Should be removed
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'parameter-provider-2',
+ name: 'Another Parameter Provider',
+ type: 'org.apache.nifi.AnotherParameterProvider',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ affectedComponents: [],
+ parameterGroupConfigurations: [],
+ comments: '',
+ deprecated: false,
+ extensionMissing: false,
+ multipleVersionsAvailable: false,
+ persistsState: false,
+ referencingParameterContexts: [],
+ restricted: false,
+ validationStatus: 'VALID'
+ }
+ }
+ ]
+ };
+ });
+
+ it('should replace bulletins for the specified parameter provider', () => {
+ // Server returns bulletins that remain after clearing (equal and newer bulletins)
+ const action = clearParameterProviderBulletinsSuccess({
+ response: {
+ componentId: 'parameter-provider-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, testTimestamp), createTestBulletin(3, newerTimestamp)],
+ componentType: ComponentType.ParameterProvider
+ }
+ });
+
+ const newState = parameterProvidersReducer(testState, action);
+
+ const parameterProvider = newState.parameterProviders.find((pp) => pp.id === 'parameter-provider-1');
+ expect(parameterProvider).toBeDefined();
+ expect(parameterProvider!.bulletins).toHaveLength(2);
+ expect(parameterProvider!.bulletins!.map((b) => b.id)).toEqual([2, 3]); // Equal and newer bulletins remain
+ });
+
+ it('should not affect other parameter providers', () => {
+ const action = clearParameterProviderBulletinsSuccess({
+ response: {
+ componentId: 'parameter-provider-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, testTimestamp), createTestBulletin(3, newerTimestamp)],
+ componentType: ComponentType.ParameterProvider
+ }
+ });
+
+ const newState = parameterProvidersReducer(testState, action);
+
+ const otherParameterProvider = newState.parameterProviders.find((pp) => pp.id === 'parameter-provider-2');
+ expect(otherParameterProvider).toBeDefined();
+ expect(otherParameterProvider!.bulletins).toHaveLength(1); // Original count unchanged
+ expect(otherParameterProvider!.bulletins![0].id).toBe(4);
+ });
+
+ it('should handle parameter provider with no bulletins', () => {
+ testState.parameterProviders[0].bulletins = [] as any;
+
+ const action = clearParameterProviderBulletinsSuccess({
+ response: {
+ componentId: 'parameter-provider-1',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.ParameterProvider
+ }
+ });
+
+ const newState = parameterProvidersReducer(testState, action);
+
+ const parameterProvider = newState.parameterProviders.find((pp) => pp.id === 'parameter-provider-1');
+ expect(parameterProvider).toBeDefined();
+ expect(parameterProvider!.bulletins).toEqual([]);
+ });
+
+ it('should handle non-existent parameter provider gracefully', () => {
+ const action = clearParameterProviderBulletinsSuccess({
+ response: {
+ componentId: 'non-existent-provider',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.ParameterProvider
+ }
+ });
+
+ const newState = parameterProvidersReducer(testState, action);
+
+ // State should remain unchanged
+ expect(newState).toEqual(testState);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.ts
index b705a8ff28..33cc7f6f04 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/parameter-providers/parameter-providers.reducer.ts
@@ -18,6 +18,7 @@
import { ParameterProvidersState } from './index';
import { createReducer, on } from '@ngrx/store';
import {
+ clearParameterProviderBulletinsSuccess,
configureParameterProvider,
configureParameterProviderSuccess,
createParameterProvider,
@@ -146,5 +147,17 @@ export const parameterProvidersReducer = createReducer(
on(deleteParameterProviderParametersUpdateRequest, (state) => ({
...state,
saving: false
- }))
+ })),
+ on(clearParameterProviderBulletinsSuccess, (state, { response }) => {
+ return produce(state, (draftState) => {
+ const componentIndex: number = draftState.parameterProviders.findIndex(
+ (provider: any) => provider.id === response.componentId
+ );
+ if (componentIndex > -1) {
+ const provider = draftState.parameterProviders[componentIndex];
+ // Replace bulletins with the current bulletins from the server
+ provider.bulletins = response.bulletins || [];
+ }
+ });
+ })
);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.actions.ts
index c6402ca8b4..54885e0754 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.actions.ts
@@ -27,6 +27,7 @@ import {
LoadRegistryClientsResponse,
SelectRegistryClientRequest
} from './index';
+import { ClearBulletinsRequest, ClearBulletinsResponse } from '../../../../state/shared';
export const resetRegistryClientsState = createAction('[Registry Clients] Reset Registry Clients State');
@@ -103,3 +104,13 @@ export const selectClient = createAction(
'[Registry Clients] Select Registry Client',
props<{ request: SelectRegistryClientRequest }>()
);
+
+export const clearRegistryClientBulletins = createAction(
+ '[Registry Clients] Clear Registry Client Bulletins',
+ props<{ request: ClearBulletinsRequest }>()
+);
+
+export const clearRegistryClientBulletinsSuccess = createAction(
+ '[Registry Clients] Clear Registry Client Bulletins Success',
+ props<{ response: ClearBulletinsResponse }>()
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.spec.ts
index d7064a4149..3c6e05de1b 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.spec.ts
@@ -6,7 +6,7 @@
* (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
+ * 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,
@@ -17,211 +17,507 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
-import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { provideMockStore } from '@ngrx/store/testing';
import { Action } from '@ngrx/store';
-import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { ReplaySubject, of, throwError, firstValueFrom } from 'rxjs';
+import { Router } from '@angular/router';
+import { MatDialog } from '@angular/material/dialog';
import { HttpErrorResponse } from '@angular/common/http';
-import * as RegistryClientsActions from './registry-clients.actions';
import { RegistryClientsEffects } from './registry-clients.effects';
+import * as RegistryClientsActions from './registry-clients.actions';
import { RegistryClientService } from '../../service/registry-client.service';
import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
import { ErrorHelper } from '../../../../service/error-helper.service';
-import { MatDialog } from '@angular/material/dialog';
-import { Router } from '@angular/router';
import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
-import { Client } from '../../../../service/client.service';
-import { NiFiCommon } from '@nifi/shared';
-import { ClusterConnectionService } from '../../../../service/cluster-connection.service';
-import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { ComponentType } from '@nifi/shared';
+import { RegistryClientEntity } from '../../../../state/shared';
+import { registryClientsFeatureKey } from './index';
import { initialState } from './registry-clients.reducer';
+import { settingsFeatureKey } from '../index';
+import * as ErrorActions from '../../../../state/error/error.actions';
describe('RegistryClientsEffects', () => {
- interface SetupOptions {
- registryClientsState?: any;
- }
+ let actions$: ReplaySubject<Action>;
+ let effects: RegistryClientsEffects;
+ let mockRegistryClientService: jest.Mocked<RegistryClientService>;
+ let mockErrorHelper: jest.Mocked<ErrorHelper>;
+ let mockDialog: jest.Mocked<MatDialog>;
+
+ beforeEach(() => {
+ const registryClientServiceSpy = {
+ getRegistryClients: jest.fn(),
+ createRegistryClient: jest.fn(),
+ updateRegistryClient: jest.fn(),
+ deleteRegistryClient: jest.fn(),
+ clearBulletins: jest.fn()
+ } as unknown as jest.Mocked<RegistryClientService>;
+
+ const errorHelperSpy = {
+ handleLoadingError: jest.fn().mockReturnValue({ type: '[Error] Loading Error' }),
+ getErrorString: jest.fn().mockReturnValue('Test error message'),
+ showErrorInContext: jest.fn(),
+ fullScreenError: jest.fn().mockReturnValue({ type: 'FULL_SCREEN_ERROR' })
+ } as unknown as jest.Mocked<ErrorHelper>;
+
+ const dialogSpy = {
+ open: jest.fn(),
+ closeAll: jest.fn()
+ } as unknown as jest.Mocked<MatDialog>;
- const mockRegistryClientsResponse = {
- registries: [],
- currentTime: '2023-01-01 12:00:00 EST'
- };
+ const routerSpy = {
+ navigate: jest.fn()
+ } as unknown as jest.Mocked<Router>;
- async function setup({ registryClientsState = initialState }: SetupOptions = {}) {
- await TestBed.configureTestingModule({
+ TestBed.configureTestingModule({
providers: [
RegistryClientsEffects,
- provideMockActions(() => action$),
+ provideMockActions(() => actions$),
provideMockStore({
initialState: {
- settings: {
- registryClients: registryClientsState
+ [settingsFeatureKey]: {
+ [registryClientsFeatureKey]: initialState
}
}
}),
- {
- provide: RegistryClientService,
- useValue: {
- getRegistryClients: jest.fn()
- }
- },
- {
- provide: ManagementControllerServiceService,
- useValue: {}
- },
- {
- provide: ErrorHelper,
- useValue: {
- handleLoadingError: jest.fn()
- }
- },
- {
- provide: MatDialog,
- useValue: { open: jest.fn(), closeAll: jest.fn() }
- },
- {
- provide: Router,
- useValue: { navigate: jest.fn() }
- },
- { provide: PropertyTableHelperService, useValue: {} },
- { provide: Client, useValue: {} },
- { provide: NiFiCommon, useValue: {} },
- { provide: ClusterConnectionService, useValue: {} },
- { provide: ExtensionTypesService, useValue: {} }
+ { provide: RegistryClientService, useValue: registryClientServiceSpy },
+ { provide: ManagementControllerServiceService, useValue: {} },
+ { provide: ErrorHelper, useValue: errorHelperSpy },
+ { provide: Router, useValue: routerSpy },
+ { provide: MatDialog, useValue: dialogSpy },
+ { provide: PropertyTableHelperService, useValue: {} }
]
- }).compileComponents();
-
- const store = TestBed.inject(MockStore);
- const effects = TestBed.inject(RegistryClientsEffects);
- const registryClientService = TestBed.inject(RegistryClientService);
- const errorHelper = TestBed.inject(ErrorHelper);
-
- return { effects, store, registryClientService, errorHelper };
- }
+ });
- let action$: ReplaySubject<Action>;
- beforeEach(() => {
- action$ = new ReplaySubject<Action>();
+ effects = TestBed.inject(RegistryClientsEffects);
+ mockRegistryClientService = TestBed.inject(RegistryClientService) as jest.Mocked<RegistryClientService>;
+ mockErrorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ mockDialog = TestBed.inject(MatDialog) as jest.Mocked<MatDialog>;
+ actions$ = new ReplaySubject(1);
});
- it('should create', async () => {
- const { effects } = await setup();
- expect(effects).toBeTruthy();
- });
+ describe('loadRegistryClients$', () => {
+ it('should dispatch loadRegistryClientsSuccess action when registry clients are loaded successfully', async () => {
+ const mockResponse = {
+ registries: [createMockRegistryClientEntity()],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
+ mockRegistryClientService.getRegistryClients.mockReturnValue(of(mockResponse));
- describe('Load Registry Clients', () => {
- it('should load registry clients successfully', async () => {
- const { effects, registryClientService } = await setup();
+ actions$.next(RegistryClientsActions.loadRegistryClients());
- action$.next(RegistryClientsActions.loadRegistryClients());
- jest.spyOn(registryClientService, 'getRegistryClients').mockReturnValueOnce(
- of(mockRegistryClientsResponse) as never
- );
- const result = await new Promise((resolve) =>
- effects.loadRegistryClients$.pipe(take(1)).subscribe(resolve)
- );
+ const result = await firstValueFrom(effects.loadRegistryClients$);
expect(result).toEqual(
RegistryClientsActions.loadRegistryClientsSuccess({
response: {
- registryClients: mockRegistryClientsResponse.registries,
- loadedTimestamp: mockRegistryClientsResponse.currentTime
+ registryClients: mockResponse.registries,
+ loadedTimestamp: mockResponse.currentTime
}
})
);
+ expect(mockRegistryClientService.getRegistryClients).toHaveBeenCalled();
});
- it('should fail to load registry clients on initial load with hasExistingData=false', async () => {
- const { effects, registryClientService } = await setup();
+ it('should dispatch loadRegistryClientsError with status pending on initial load failure', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ mockRegistryClientService.getRegistryClients.mockReturnValue(throwError(() => errorResponse));
- action$.next(RegistryClientsActions.loadRegistryClients());
- const error = new HttpErrorResponse({ status: 500 });
- jest.spyOn(registryClientService, 'getRegistryClients').mockImplementationOnce(() =>
- throwError(() => error)
- );
+ actions$.next(RegistryClientsActions.loadRegistryClients());
- const result = await new Promise((resolve) =>
- effects.loadRegistryClients$.pipe(take(1)).subscribe(resolve)
- );
+ const result = await firstValueFrom(effects.loadRegistryClients$);
expect(result).toEqual(
RegistryClientsActions.loadRegistryClientsError({
- errorResponse: error,
+ errorResponse,
loadedTimestamp: initialState.loadedTimestamp,
status: 'pending'
})
);
});
+ });
+
+ describe('loadRegistryClientsError$', () => {
+ it('should handle error on initial load (hasExistingData=false)', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500 });
+ const errorAction = RegistryClientsActions.registryClientsBannerApiError({ error: 'Error loading' });
+
+ mockErrorHelper.handleLoadingError.mockReturnValue(errorAction);
+
+ actions$.next(
+ RegistryClientsActions.loadRegistryClientsError({
+ errorResponse,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+
+ const result = await firstValueFrom(effects.loadRegistryClientsError$);
+
+ expect(mockErrorHelper.handleLoadingError).toHaveBeenCalledWith(false, errorResponse);
+ expect(result).toEqual(errorAction);
+ });
- it('should fail to load registry clients on refresh with hasExistingData=true', async () => {
- const stateWithData = {
- ...initialState,
- loadedTimestamp: '2023-01-01 11:00:00 EST'
+ it('should handle error on refresh (hasExistingData=true)', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500 });
+ const errorAction = RegistryClientsActions.registryClientsBannerApiError({ error: 'Error loading' });
+
+ mockErrorHelper.handleLoadingError.mockReturnValue(errorAction);
+
+ actions$.next(
+ RegistryClientsActions.loadRegistryClientsError({
+ errorResponse,
+ loadedTimestamp: '2023-01-01 11:00:00 EST',
+ status: 'success'
+ })
+ );
+
+ const result = await firstValueFrom(effects.loadRegistryClientsError$);
+
+ expect(mockErrorHelper.handleLoadingError).toHaveBeenCalledWith(true, errorResponse);
+ expect(result).toEqual(errorAction);
+ });
+ });
+
+ describe('createRegistryClient$', () => {
+ it('should dispatch createRegistryClientSuccess action when registry client is created successfully', async () => {
+ const mockRequest = {
+ revision: { version: 0, clientId: 'test-client' },
+ disconnectedNodeAcknowledged: false,
+ component: {
+ name: 'Test Registry Client',
+ type: 'org.apache.nifi.TestRegistryClient',
+ bundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-bundle',
+ version: '1.0.0'
+ },
+ description: 'Test Description'
+ }
};
- const { effects, registryClientService } = await setup({
- registryClientsState: stateWithData
- });
+ const mockResponse = createMockRegistryClientEntity();
+ mockRegistryClientService.createRegistryClient.mockReturnValue(of(mockResponse));
+
+ actions$.next(RegistryClientsActions.createRegistryClient({ request: mockRequest }));
- action$.next(RegistryClientsActions.loadRegistryClients());
- const error = new HttpErrorResponse({ status: 500 });
- jest.spyOn(registryClientService, 'getRegistryClients').mockImplementationOnce(() =>
- throwError(() => error)
+ const result = await firstValueFrom(effects.createRegistryClient$);
+
+ expect(result).toEqual(
+ RegistryClientsActions.createRegistryClientSuccess({
+ response: {
+ registryClient: mockResponse
+ }
+ })
);
+ expect(mockRegistryClientService.createRegistryClient).toHaveBeenCalledWith(mockRequest);
+ });
+
+ it('should handle error and close dialog when creating registry client fails', async () => {
+ const mockRequest = {
+ revision: { version: 0, clientId: 'test-client' },
+ disconnectedNodeAcknowledged: false,
+ component: {
+ name: 'Test Registry Client',
+ type: 'org.apache.nifi.TestRegistryClient',
+ bundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-bundle',
+ version: '1.0.0'
+ },
+ description: 'Test Description'
+ }
+ };
+ const errorResponse = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' });
+ mockRegistryClientService.createRegistryClient.mockReturnValue(throwError(() => errorResponse));
- const result = await new Promise((resolve) =>
- effects.loadRegistryClients$.pipe(take(1)).subscribe(resolve)
+ actions$.next(RegistryClientsActions.createRegistryClient({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.createRegistryClient$);
+
+ expect(mockDialog.closeAll).toHaveBeenCalled();
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(errorResponse);
+ expect(result).toEqual(
+ RegistryClientsActions.registryClientsSnackbarApiError({
+ error: 'Test error message'
+ })
);
+ });
+ });
+
+ describe('deleteRegistryClient$', () => {
+ it('should dispatch deleteRegistryClientSuccess action when registry client is deleted successfully', async () => {
+ const mockRequest = {
+ registryClient: createMockRegistryClientEntity()
+ };
+ const mockResponse = createMockRegistryClientEntity();
+ mockRegistryClientService.deleteRegistryClient.mockReturnValue(of(mockResponse));
+
+ actions$.next(RegistryClientsActions.deleteRegistryClient({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.deleteRegistryClient$);
expect(result).toEqual(
- RegistryClientsActions.loadRegistryClientsError({
- errorResponse: error,
- loadedTimestamp: stateWithData.loadedTimestamp,
- status: 'success'
+ RegistryClientsActions.deleteRegistryClientSuccess({
+ response: {
+ registryClient: mockResponse
+ }
})
);
+ expect(mockRegistryClientService.deleteRegistryClient).toHaveBeenCalledWith(mockRequest);
});
- it('should handle loadRegistryClientsError with pending status (no existing data)', async () => {
- const { effects, errorHelper } = await setup();
+ it('should handle error when deleting registry client fails', async () => {
+ const mockRequest = {
+ registryClient: createMockRegistryClientEntity()
+ };
+ const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ mockRegistryClientService.deleteRegistryClient.mockReturnValue(throwError(() => errorResponse));
- action$.next(
- RegistryClientsActions.loadRegistryClientsError({
- errorResponse: new HttpErrorResponse({ status: 500 }),
- loadedTimestamp: initialState.loadedTimestamp,
- status: 'pending'
+ actions$.next(RegistryClientsActions.deleteRegistryClient({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.deleteRegistryClient$);
+
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(errorResponse);
+ expect(result).toEqual(
+ RegistryClientsActions.registryClientsSnackbarApiError({
+ error: 'Test error message'
})
);
+ });
+ });
- const errorAction = RegistryClientsActions.registryClientsBannerApiError({ error: 'Error loading' });
- jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+ describe('clearRegistryClientBulletins$', () => {
+ it('should dispatch clearRegistryClientBulletinsSuccess action when bulletins are cleared successfully', async () => {
+ const mockRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-component-id',
+ componentType: ComponentType.FlowRegistryClient
+ };
+ const mockResponse = {
+ bulletinsCleared: 5,
+ bulletins: []
+ };
+ mockRegistryClientService.clearBulletins.mockReturnValue(of(mockResponse));
+
+ actions$.next(RegistryClientsActions.clearRegistryClientBulletins({ request: mockRequest }));
- const result = await new Promise((resolve) =>
- effects.loadRegistryClientsError$.pipe(take(1)).subscribe(resolve)
+ const result = await firstValueFrom(effects.clearRegistryClientBulletins$);
+
+ expect(result).toEqual(
+ RegistryClientsActions.clearRegistryClientBulletinsSuccess({
+ response: {
+ componentId: mockRequest.componentId,
+ bulletinsCleared: 5,
+ bulletins: mockResponse.bulletins || [],
+ componentType: mockRequest.componentType
+ }
+ })
);
+ expect(mockRegistryClientService.clearBulletins).toHaveBeenCalledWith({
+ uri: mockRequest.uri,
+ fromTimestamp: mockRequest.fromTimestamp
+ });
+ });
- expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(false, expect.any(HttpErrorResponse));
- expect(result).toEqual(errorAction);
+ it('should handle undefined bulletinsCleared response', async () => {
+ const mockRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-component-id',
+ componentType: ComponentType.FlowRegistryClient
+ };
+ const mockResponse = {
+ bulletins: []
+ }; // No bulletinsCleared property
+ mockRegistryClientService.clearBulletins.mockReturnValue(of(mockResponse));
+
+ actions$.next(RegistryClientsActions.clearRegistryClientBulletins({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.clearRegistryClientBulletins$);
+
+ expect(result).toEqual(
+ RegistryClientsActions.clearRegistryClientBulletinsSuccess({
+ response: {
+ componentId: mockRequest.componentId,
+ bulletinsCleared: 0, // Should default to 0
+ bulletins: mockResponse.bulletins || [],
+ componentType: mockRequest.componentType
+ }
+ })
+ );
});
- it('should handle loadRegistryClientsError with success status (existing data)', async () => {
- const { effects, errorHelper } = await setup();
+ it('should pass through all required fields in the success response', async () => {
+ const mockRequest = {
+ uri: 'test-uri',
+ fromTimestamp: '2023-01-01T12:00:00.000Z',
+ componentId: 'test-component-id',
+ componentType: ComponentType.FlowRegistryClient
+ };
+ const mockResponse = {
+ bulletinsCleared: 3,
+ bulletins: []
+ };
+ mockRegistryClientService.clearBulletins.mockReturnValue(of(mockResponse));
- action$.next(
- RegistryClientsActions.loadRegistryClientsError({
- errorResponse: new HttpErrorResponse({ status: 503 }),
- loadedTimestamp: '2023-01-01 11:00:00 EST',
- status: 'success'
+ actions$.next(RegistryClientsActions.clearRegistryClientBulletins({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.clearRegistryClientBulletins$);
+
+ const successAction = result as any;
+ expect(successAction.response.componentId).toBe(mockRequest.componentId);
+ expect(successAction.response.componentType).toBe(mockRequest.componentType);
+ expect(successAction.response.bulletinsCleared).toBe(3);
+ });
+ });
+
+ describe('configureRegistryClient$', () => {
+ it('should dispatch configureRegistryClientSuccess action when registry client is configured successfully', async () => {
+ const mockRequest = {
+ id: 'test-id',
+ uri: 'test-uri',
+ payload: {
+ revision: { version: 1, clientId: 'test-client' },
+ component: {
+ id: 'test-id',
+ name: 'Updated Registry Client'
+ }
+ },
+ postUpdateNavigation: undefined,
+ postUpdateNavigationBoundary: undefined
+ };
+ const mockResponse = createMockRegistryClientEntity();
+ mockRegistryClientService.updateRegistryClient.mockReturnValue(of(mockResponse));
+
+ actions$.next(RegistryClientsActions.configureRegistryClient({ request: mockRequest }));
+
+ const result = await firstValueFrom(effects.configureRegistryClient$);
+
+ expect(result).toEqual(
+ RegistryClientsActions.configureRegistryClientSuccess({
+ response: {
+ id: mockRequest.id,
+ registryClient: mockResponse,
+ postUpdateNavigation: mockRequest.postUpdateNavigation,
+ postUpdateNavigationBoundary: mockRequest.postUpdateNavigationBoundary
+ }
})
);
+ expect(mockRegistryClientService.updateRegistryClient).toHaveBeenCalledWith(mockRequest);
+ });
- const errorAction = RegistryClientsActions.registryClientsBannerApiError({ error: 'Error loading' });
- jest.spyOn(errorHelper, 'handleLoadingError').mockReturnValueOnce(errorAction);
+ it('should handle error with banner when showErrorInContext returns true', async () => {
+ const mockRequest = {
+ id: 'test-id',
+ uri: 'test-uri',
+ payload: {
+ revision: { version: 1, clientId: 'test-client' },
+ component: {
+ id: 'test-id',
+ name: 'Updated Registry Client'
+ }
+ },
+ postUpdateNavigation: undefined,
+ postUpdateNavigationBoundary: undefined
+ };
+ const errorResponse = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' });
+ mockRegistryClientService.updateRegistryClient.mockReturnValue(throwError(() => errorResponse));
+
+ actions$.next(RegistryClientsActions.configureRegistryClient({ request: mockRequest }));
- const result = await new Promise((resolve) =>
- effects.loadRegistryClientsError$.pipe(take(1)).subscribe(resolve)
+ const result = await firstValueFrom(effects.configureRegistryClient$);
+
+ expect(mockErrorHelper.getErrorString).toHaveBeenCalledWith(errorResponse);
+ expect(result).toEqual(
+ RegistryClientsActions.registryClientsBannerApiError({
+ error: 'Test error message'
+ })
);
+ });
+ });
- expect(errorHelper.handleLoadingError).toHaveBeenCalledWith(true, expect.any(HttpErrorResponse));
- expect(result).toEqual(errorAction);
+ describe('createRegistryClientSuccess$', () => {
+ it('should close dialog and dispatch selectClient action', async () => {
+ const mockResponse = {
+ registryClient: createMockRegistryClientEntity()
+ };
+
+ actions$.next(RegistryClientsActions.createRegistryClientSuccess({ response: mockResponse }));
+
+ const result = await firstValueFrom(effects.createRegistryClientSuccess$);
+
+ expect(mockDialog.closeAll).toHaveBeenCalled();
+ expect(result).toEqual(
+ RegistryClientsActions.selectClient({
+ request: {
+ id: mockResponse.registryClient.id
+ }
+ })
+ );
});
});
+
+ describe('registryClientsBannerApiError$', () => {
+ it('should dispatch addBannerError action', async () => {
+ const errorMessage = 'Test error message';
+
+ actions$.next(RegistryClientsActions.registryClientsBannerApiError({ error: errorMessage }));
+
+ const result = await firstValueFrom(effects.registryClientsBannerApiError$);
+
+ expect(result).toEqual(
+ ErrorActions.addBannerError({
+ errorContext: {
+ errors: [errorMessage],
+ context: 'registry-clients' as any
+ }
+ })
+ );
+ });
+ });
+
+ describe('registryClientsSnackbarApiError$', () => {
+ it('should dispatch snackBarError action', async () => {
+ const errorMessage = 'Test error message';
+
+ actions$.next(RegistryClientsActions.registryClientsSnackbarApiError({ error: errorMessage }));
+
+ const result = await firstValueFrom(effects.registryClientsSnackbarApiError$);
+
+ expect(result).toEqual(ErrorActions.snackBarError({ error: errorMessage }));
+ });
+ });
+
+ // Mock data factory
+ function createMockRegistryClientEntity(): RegistryClientEntity {
+ return {
+ id: 'test-registry-client-id',
+ uri: 'test-uri',
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ revision: {
+ version: 1,
+ clientId: 'test-client'
+ },
+ component: {
+ id: 'test-registry-client-id',
+ name: 'Test Registry Client',
+ description: 'Test Description',
+ type: 'org.apache.nifi.TestRegistryClient',
+ bundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-bundle',
+ version: '1.0.0'
+ },
+ properties: {},
+ descriptors: {},
+ validationErrors: [],
+ validationStatus: 'VALID'
+ },
+ bulletins: []
+ };
+ }
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.ts
index 1912319f7c..ec57ecbd9d 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.effects.ts
@@ -439,4 +439,37 @@ export class RegistryClientsEffects {
),
{ dispatch: false }
);
+
+ clearRegistryClientBulletins$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(RegistryClientsActions.clearRegistryClientBulletins),
+ map((action) => action.request),
+ switchMap((request) =>
+ from(
+ this.registryClientService.clearBulletins({
+ uri: request.uri,
+ fromTimestamp: request.fromTimestamp
+ })
+ ).pipe(
+ map((response) =>
+ RegistryClientsActions.clearRegistryClientBulletinsSuccess({
+ response: {
+ componentId: request.componentId,
+ bulletinsCleared: response.bulletinsCleared || 0,
+ bulletins: response.bulletins || [],
+ componentType: request.componentType
+ }
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ RegistryClientsActions.registryClientsSnackbarApiError({
+ error: this.errorHelper.getErrorString(errorResponse)
+ })
+ )
+ )
+ )
+ )
+ )
+ );
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.spec.ts
new file mode 100644
index 0000000000..20a9769c42
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.spec.ts
@@ -0,0 +1,174 @@
+/*
+ * 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 { registryClientsReducer, initialState } from './registry-clients.reducer';
+import { clearRegistryClientBulletinsSuccess } from './registry-clients.actions';
+import { RegistryClientsState } from './index';
+import { BulletinEntity, ComponentType } from '@nifi/shared';
+
+describe('Registry Clients Reducer', () => {
+ describe('clearRegistryClientBulletinsSuccess', () => {
+ let testState: RegistryClientsState;
+ const testTimestamp = new Date('2023-01-01T12:00:00.000Z').getTime();
+ const olderTimestamp = new Date('2023-01-01T11:00:00.000Z').getTime();
+ const newerTimestamp = new Date('2023-01-01T13:00:00.000Z').getTime();
+
+ const createTestBulletin = (id: number, timestamp: number): BulletinEntity => {
+ const isoString = new Date(timestamp).toISOString();
+ const timeOnly = isoString.substring(11, 19) + ' UTC'; // Extract HH:MM:SS and add UTC
+ return {
+ id,
+ canRead: true,
+ sourceId: 'source1',
+ groupId: 'group1',
+ timestamp: timeOnly,
+ timestampIso: isoString,
+ bulletin: {
+ id,
+ nodeAddress: 'localhost',
+ category: 'test',
+ groupId: 'group1',
+ sourceId: 'source1',
+ sourceName: 'Test Source',
+ sourceType: 'COMPONENT',
+ level: 'DEBUG',
+ message: `Test message ${id}`,
+ timestamp: timeOnly,
+ timestampIso: isoString
+ }
+ };
+ };
+
+ beforeEach(() => {
+ testState = {
+ ...initialState,
+ registryClients: [
+ {
+ id: 'registry-client-1',
+ uri: 'test-uri-1',
+ bulletins: [
+ createTestBulletin(1, olderTimestamp), // Should be removed
+ createTestBulletin(2, testTimestamp), // Should be kept (equal)
+ createTestBulletin(3, newerTimestamp) // Should be kept
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'registry-client-1',
+ name: 'Test Registry Client',
+ type: 'org.apache.nifi.TestRegistryClient',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ properties: {},
+ descriptors: {},
+ validationErrors: []
+ }
+ },
+ {
+ id: 'registry-client-2',
+ uri: 'test-uri-2',
+ bulletins: [
+ createTestBulletin(4, olderTimestamp) // Should be removed
+ ],
+ revision: { version: 1, clientId: 'test' },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id: 'registry-client-2',
+ name: 'Another Registry Client',
+ type: 'org.apache.nifi.AnotherRegistryClient',
+ bundle: { group: 'org.apache.nifi', artifact: 'test', version: '1.0.0' },
+ properties: {},
+ descriptors: {},
+ validationErrors: []
+ }
+ }
+ ]
+ };
+ });
+
+ it('should replace bulletins for the specified registry client', () => {
+ // Server returns bulletins that remain after clearing (equal and newer bulletins)
+ const action = clearRegistryClientBulletinsSuccess({
+ response: {
+ componentId: 'registry-client-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, testTimestamp), createTestBulletin(3, newerTimestamp)],
+ componentType: ComponentType.FlowRegistryClient
+ }
+ });
+
+ const newState = registryClientsReducer(testState, action);
+
+ const registryClient = newState.registryClients.find((rc) => rc.id === 'registry-client-1');
+ expect(registryClient).toBeDefined();
+ expect(registryClient!.bulletins).toHaveLength(2);
+ expect(registryClient!.bulletins!.map((b) => b.id)).toEqual([2, 3]); // Equal and newer bulletins remain
+ });
+
+ it('should not affect other registry clients', () => {
+ const action = clearRegistryClientBulletinsSuccess({
+ response: {
+ componentId: 'registry-client-1',
+ bulletinsCleared: 1,
+ bulletins: [createTestBulletin(2, testTimestamp), createTestBulletin(3, newerTimestamp)],
+ componentType: ComponentType.FlowRegistryClient
+ }
+ });
+
+ const newState = registryClientsReducer(testState, action);
+
+ const otherRegistryClient = newState.registryClients.find((rc) => rc.id === 'registry-client-2');
+ expect(otherRegistryClient).toBeDefined();
+ expect(otherRegistryClient!.bulletins).toHaveLength(1); // Original count unchanged
+ expect(otherRegistryClient!.bulletins![0].id).toBe(4);
+ });
+
+ it('should handle registry client with no bulletins', () => {
+ testState.registryClients[0].bulletins = undefined;
+
+ const action = clearRegistryClientBulletinsSuccess({
+ response: {
+ componentId: 'registry-client-1',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.FlowRegistryClient
+ }
+ });
+
+ const newState = registryClientsReducer(testState, action);
+
+ const registryClient = newState.registryClients.find((rc) => rc.id === 'registry-client-1');
+ expect(registryClient).toBeDefined();
+ expect(registryClient!.bulletins).toEqual([]);
+ });
+
+ it('should handle non-existent registry client gracefully', () => {
+ const action = clearRegistryClientBulletinsSuccess({
+ response: {
+ componentId: 'non-existent-client',
+ bulletinsCleared: 0,
+ bulletins: [],
+ componentType: ComponentType.FlowRegistryClient
+ }
+ });
+
+ const newState = registryClientsReducer(testState, action);
+
+ // State should remain unchanged
+ expect(newState).toEqual(testState);
+ });
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.ts
index a9f12a0178..453836d59e 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/registry-clients/registry-clients.reducer.ts
@@ -19,6 +19,7 @@ import { createReducer, on } from '@ngrx/store';
import { produce } from 'immer';
import { RegistryClientsState } from './index';
import {
+ clearRegistryClientBulletinsSuccess,
configureRegistryClient,
configureRegistryClientSuccess,
createRegistryClient,
@@ -92,5 +93,17 @@ export const registryClientsReducer = createReducer(
}
draftState.saving = false;
});
+ }),
+ on(clearRegistryClientBulletinsSuccess, (state, { response }) => {
+ return produce(state, (draftState) => {
+ const componentIndex: number = draftState.registryClients.findIndex(
+ (client: any) => client.id === response.componentId
+ );
+ if (componentIndex > -1) {
+ const client = draftState.registryClients[componentIndex];
+ // Replace bulletins with the current bulletins from the server
+ client.bulletins = response.bulletins || [];
+ }
+ });
})
);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.actions.ts
index 9cb54e2776..9fefe5809c 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.actions.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.actions.ts
@@ -31,7 +31,7 @@ import {
StopReportingTaskRequest,
StopReportingTaskSuccess
} from './index';
-import { FetchComponentVersionsRequest } from '../../../../state/shared';
+import { ClearBulletinsRequest, ClearBulletinsResponse, FetchComponentVersionsRequest } from '../../../../state/shared';
export const resetReportingTasksState = createAction('[Reporting Tasks] Reset Reporting Tasks State');
@@ -143,3 +143,13 @@ export const openChangeReportingTaskVersionDialog = createAction(
`[Reporting Tasks] Open Change Reporting Task Version Dialog`,
props<{ request: FetchComponentVersionsRequest }>()
);
+
+export const clearReportingTaskBulletins = createAction(
+ '[Reporting Tasks] Clear Reporting Task Bulletins',
+ props<{ request: ClearBulletinsRequest }>()
+);
+
+export const clearReportingTaskBulletinsSuccess = createAction(
+ '[Reporting Tasks] Clear Reporting Task Bulletins Success',
+ props<{ response: ClearBulletinsResponse }>()
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.spec.ts
index 391b44cec8..047bd6b51a 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/settings/state/reporting-tasks/reporting-tasks.effects.spec.ts
@@ -6,7 +6,7 @@
* (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
+ * 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,
@@ -17,175 +17,619 @@
import { TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
-import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { provideMockStore } from '@ngrx/store/testing';
import { Action } from '@ngrx/store';
-import { ReplaySubject, of, take, throwError } from 'rxjs';
+import { ReplaySubject, of, throwError, firstValueFrom } from 'rxjs';
+import { Router } from '@angular/router';
+import { MatDialog } from '@angular/material/dialog';
import { HttpErrorResponse } from '@angular/common/http';
-import * as ReportingTaskActions from './reporting-tasks.actions';
import { ReportingTasksEffects } from './reporting-tasks.effects';
+import * as ReportingTasksActions from './reporting-tasks.actions';
import { ReportingTaskService } from '../../service/reporting-task.service';
import { ManagementControllerServiceService } from '../../service/management-controller-service.service';
import { ErrorHelper } from '../../../../service/error-helper.service';
-import { MatDialog } from '@angular/material/dialog';
-import { Router } from '@angular/router';
import { PropertyTableHelperService } from '../../../../service/property-table-helper.service';
import { ExtensionTypesService } from '../../../../service/extension-types.service';
+import { ComponentType } from '@nifi/shared';
+import { ReportingTaskEntity, reportingTasksFeatureKey } from './index';
import { initialState } from './reporting-tasks.reducer';
+import { settingsFeatureKey } from '../index';
+import * as ErrorActions from '../../../../state/error/error.actions';
describe('ReportingTasksEffects', () => {
- interface SetupOptions {
- reportingTasksState?: any;
- }
+ let actions$: ReplaySubject<Action>;
+ let effects: ReportingTasksEffects;
+ let mockReportingTaskService: jest.Mocked<ReportingTaskService>;
+ let mockErrorHelper: jest.Mocked<ErrorHelper>;
+ let mockDialog: jest.Mocked<MatDialog>;
+
+ beforeEach(() => {
+ const reportingTaskServiceSpy = {
+ getReportingTasks: jest.fn(),
+ createReportingTask: jest.fn(),
+ updateReportingTask: jest.fn(),
+ deleteReportingTask: jest.fn(),
+ startReportingTask: jest.fn(),
+ stopReportingTask: jest.fn(),
+ clearBulletins: jest.fn()
+ } as unknown as jest.Mocked<ReportingTaskService>;
+
+ const errorHelperSpy = {
+ handleLoadingError: jest.fn().mockReturnValue({ type: '[Error] Loading Error' }),
+ getErrorString: jest.fn().mockReturnValue('Test error message'),
+ showErrorInContext: jest.fn(),
+ fullScreenError: jest.fn().mockReturnValue({ type: 'FULL_SCREEN_ERROR' })
+ } as unknown as jest.Mocked<ErrorHelper>;
+
+ const dialogSpy = {
+ open: jest.fn(),
+ closeAll: jest.fn()
+ } as unknown as jest.Mocked<MatDialog>;
- const mockResponse = {
- reportingTasks: [],
- currentTime: '2023-01-01 12:00:00 EST'
- };
+ const routerSpy = {
+ navigate: jest.fn()
+ } as unknown as jest.Mocked<Router>;
- async function setup({ reportingTasksState = initialState }: SetupOptions = {}) {
- await TestBed.configureTestingModule({
+ const propertyTableHelperServiceSpy = {
+ getComponentHistory: jest.fn(),
+ createNewProperty: jest.fn(),
+ createNewService: jest.fn()
+ } as unknown as jest.Mocked<PropertyTableHelperService>;
+
+ const extensionTypesServiceSpy = {
+ getReportingTaskVersionsForType: jest.fn()
+ } as unknown as jest.Mocked<ExtensionTypesService>;
+
+ TestBed.configureTestingModule({
providers: [
ReportingTasksEffects,
- provideMockActions(() => action$),
+ provideMockActions(() => actions$),
provideMockStore({
initialState: {
- settings: {
- reportingTasks: reportingTasksState
+ [settingsFeatureKey]: {
+ [reportingTasksFeatureKey]: initialState
}
}
}),
- { provide: ReportingTaskService, useValue: { getReportingTasks: jest.fn() } },
+ { provide: ReportingTaskService, useValue: reportingTaskServiceSpy },
{ provide: ManagementControllerServiceService, useValue: {} },
- { provide: ErrorHelper, useValue: { handleLoadingError: jest.fn() } },
- { provide: MatDialog, useValue: { open: jest.fn(), closeAll: jest.fn() } },
- { provide: Router, useValue: { navigate: jest.fn() } },
- { provide: PropertyTableHelperService, useValue: {} },
- { provide: ExtensionTypesService, useValue: {} }
+ { provide: ErrorHelper, useValue: errorHelperSpy },
+ { provide: Router, useValue: routerSpy },
+ { provide: MatDialog, useValue: dialogSpy },
+ { provide: PropertyTableHelperService, useValue: propertyTableHelperServiceSpy },
+ { provide: ExtensionTypesService, useValue: extensionTypesServiceSpy }
]
- }).compileComponents();
-
- const store = TestBed.inject(MockStore);
- const effects = TestBed.inject(ReportingTasksEffects);
- const reportingTaskService = TestBed.inject(ReportingTaskService);
- const errorHelper = TestBed.inject(ErrorHelper);
-
- return { effects, store, reportingTaskService, errorHelper };
- }
-
- let action$: ReplaySubject<Action>;
- beforeEach(() => {
- action$ = new ReplaySubject<Action>();
- });
+ });
- it('should create', async () => {
- const { effects } = await setup();
- expect(effects).toBeTruthy();
+ effects = TestBed.inject(ReportingTasksEffects);
+ mockReportingTaskService = TestBed.inject(ReportingTaskService) as jest.Mocked<ReportingTaskService>;
+ mockErrorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ mockDialog = TestBed.inject(MatDialog) as jest.Mocked<MatDialog>;
+ actions$ = new ReplaySubject(1);
});
- describe('Load Reporting Tasks', () => {
- it('should load reporting tasks successfully', async () => {
- const { effects, reportingTaskService } = await setup();
+ describe('loadReportingTasks$', () => {
+ it('should dispatch loadReportingTasksSuccess action when reporting tasks are loaded successfully', async () => {
+ const mockResponse = {
+ reportingTasks: [createMockReportingTaskEntity()],
+ currentTime: '2023-01-01 12:00:00 EST'
+ };
+ mockReportingTaskService.getReportingTasks.mockReturnValue(of(mockResponse));
- action$.next(ReportingTaskActions.loadReportingTasks());
- jest.spyOn(reportingTaskService, 'getReportingTasks').mockReturnValueOnce(of(mockResponse) as never);
+ actions$.next(ReportingTasksActions.loadReportingTasks());
- const result = await new Promise((resolve) => effects.loadReportingTasks$.pipe(take(1)).subscribe(resolve));
+ const result = await firstValueFrom(effects.loadReportingTasks$);
expect(result).toEqual(
- ReportingTaskActions.loadReportingTasksSuccess({
+ ReportingTasksActions.loadReportingTasksSuccess({
response: {
reportingTasks: mockResponse.reportingTasks,
loadedTimestamp: mockResponse.currentTime
}
})
);
+ expect(mockReportingTaskService.getReportingTasks).toHaveBeenCalled();
});
- it('should fail to load reporting tasks on initial load with hasExistingData=false', async () => {
- const { effects, reportingTaskService } = await setup();
+ it('should dispatch loadReportingTasksError with status pending on initial load failure', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500, statusText: 'Server Error' });
+ mockReportingTaskService.getReportingTasks.mockReturnValue(throwError(() => errorResponse));
- action$.next(ReportingTaskActions.loadReportingTasks());
- const error = new HttpErrorResponse({ status: 500 });
- jest.spyOn(reportingTaskService, 'getReportingTasks').mockImplementationOnce(() => throwError(() => error));
+ actions$.next(ReportingTasksActions.loadReportingTasks());
- const result = await new Promise((resolve) => effects.loadReportingTasks$.pipe(take(1)).subscribe(resolve));
+ const result = await firstValueFrom(effects.loadReportingTasks$);
+ // On initial load (no existing timestamp), status should be 'pending'
expect(result).toEqual(
- ReportingTaskActions.loadReportingTasksError({
- errorResponse: error,
+ ReportingTasksActions.loadReportingTasksError({
+ errorResponse,
+ loadedTimestamp: initialState.loadedTimestamp,
+ status: 'pending'
+ })
+ );
+ });
+ });
+
+ describe('loadReportingTasksError$', () => {
+ it('should handle error on initial load (hasExistingData=false)', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500 });
+ const errorAction = ReportingTasksActions.reportingTasksBannerApiError({ error: 'Error loading' });
+
+ mockErrorHelper.handleLoadingError.mockReturnValue(errorAction);
+
+ actions$.next(
+ ReportingTasksActions.loadReportingTasksError({
+ errorResponse,
loadedTimestamp: initialState.loadedTimestamp,
status: 'pending'
})
);
+
+ const result = await firstValueFrom(effects.loadReportingTasksError$);
+
+ // Should call handleLoadingError with false for initial load
+ expect(mockErrorHelper.handleLoadingError).toHaveBeenCalledWith(false, errorResponse);
+ expect(result).toEqual(errorAction);
+ });
+
+ it('should handle error on refresh (hasExistingData=true)', async () => {
+ const errorResponse = new HttpErrorResponse({ status: 500 });
+ const errorAction = ReportingTasksActions.reportingTasksBannerApiError({ error: 'Error loading' });
+
... 5795 lines suppressed ...