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

dominikriemer pushed a commit to branch remove-max-retries-health-check
in repository https://gitbox.apache.org/repos/asf/streampipes.git


The following commit(s) were added to 
refs/heads/remove-max-retries-health-check by this push:
     new 4105d0a0cc Add exponential backoff strategy
4105d0a0cc is described below

commit 4105d0a0cc53951fa1a39965e2340cf576c6970b
Author: Dominik Riemer <[email protected]>
AuthorDate: Mon Jul 20 18:36:12 2026 +0200

    Add exponential backoff strategy
---
 .../pe/InvocablePipelineElementManagement.java     |   2 +-
 .../health/monitoring/ExtensionHealthCheck.java    |  10 +-
 .../health/monitoring/PipelineHealthCheck.java     |  92 +++++++++++++---
 .../health/monitoring/PipelineRecoveryBackoff.java |  94 ++++++++++++++++
 .../health/monitoring/PipelineHealthCheckTest.java | 109 ++++++++++++++++++-
 .../monitoring/PipelineRecoveryBackoffTest.java    |  77 +++++++++++++
 .../pipeline-operation-status.component.html       |  93 ++++++++++------
 .../pipeline-operation-status.component.scss       | 119 ++++++++++++++++++++-
 .../pipeline-operation-status.component.ts         |  31 ++++--
 ui/src/scss/sp/main.scss                           |   2 +-
 10 files changed, 566 insertions(+), 63 deletions(-)

diff --git 
a/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/pe/InvocablePipelineElementManagement.java
 
b/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/pe/InvocablePipelineElementManagement.java
index 4e67c458e9..66069e40d6 100644
--- 
a/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/pe/InvocablePipelineElementManagement.java
+++ 
b/streampipes-extensions-management/src/main/java/org/apache/streampipes/extensions/management/pe/InvocablePipelineElementManagement.java
@@ -64,7 +64,7 @@ public abstract class InvocablePipelineElementManagement<
 
   public Response invokeRuntime(String appId, K graph) {
     if (isDebug()) {
-      LOG.info("SP_DEBUG env variable is set - overriding broker hostname and 
port for local development");
+      LOG.debug("SP_DEBUG env variable is set - overriding broker hostname and 
port for local development");
       graph = createGroundingDebugInformation(graph);
     }
 
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheck.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheck.java
index 265cca67ea..5b92be9ff5 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheck.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/ExtensionHealthCheck.java
@@ -40,6 +40,7 @@ public class ExtensionHealthCheck implements Runnable {
   private final IExtensionsServiceStorage extensionsServiceStorage;
   private final SpResourceManager resourceManager;
   private final List<HealthCheck> registeredHealthChecks;
+  private final PipelineRecoveryBackoff pipelineRecoveryBackoff;
 
   public ExtensionHealthCheck(ResourceProvider resourceProvider,
                               IExtensionsServiceStorage 
extensionsServiceStorage,
@@ -51,6 +52,7 @@ public class ExtensionHealthCheck implements Runnable {
     this.extensionRequestManager = extensionRequestManager;
     this.resourceManager = resourceManager;
     this.registeredHealthChecks = registeredHealthChecks;
+    this.pipelineRecoveryBackoff = new PipelineRecoveryBackoff();
   }
 
   @Override
@@ -100,7 +102,13 @@ public class ExtensionHealthCheck implements Runnable {
 
   protected List<HealthCheck> getBuiltInHealthChecks(HealthCheckData 
healthCheckData) {
     return List.of(
-        new PipelineHealthCheck(healthCheckData, extensionRequestManager, 
resourceProvider, resourceManager),
+        new PipelineHealthCheck(
+            healthCheckData,
+            extensionRequestManager,
+            resourceProvider,
+            resourceManager,
+            pipelineRecoveryBackoff
+        ),
         new AdapterHealthCheck(healthCheckData)
     );
   }
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineHealthCheck.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineHealthCheck.java
index 5d6b5263a4..1158e962af 100644
--- 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineHealthCheck.java
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineHealthCheck.java
@@ -43,6 +43,8 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 public class PipelineHealthCheck implements HealthCheck {
@@ -54,15 +56,25 @@ public class PipelineHealthCheck implements HealthCheck {
   private final ExtensionServiceRequestManager requestManager;
   private final ResourceProvider resourceProvider;
   private final SpResourceManager resourceManager;
+  private final PipelineRecoveryBackoff recoveryBackoff;
 
   public PipelineHealthCheck(HealthCheckData healthCheckData,
                              ExtensionServiceRequestManager requestManager,
                              ResourceProvider resourceProvider,
                              SpResourceManager resourceManager) {
+    this(healthCheckData, requestManager, resourceProvider, resourceManager, 
new PipelineRecoveryBackoff());
+  }
+
+  PipelineHealthCheck(HealthCheckData healthCheckData,
+                      ExtensionServiceRequestManager requestManager,
+                      ResourceProvider resourceProvider,
+                      SpResourceManager resourceManager,
+                      PipelineRecoveryBackoff recoveryBackoff) {
     this.healthCheckData = healthCheckData;
     this.requestManager = requestManager;
     this.resourceProvider = resourceProvider;
     this.resourceManager = resourceManager;
+    this.recoveryBackoff = recoveryBackoff;
   }
 
   @Override
@@ -102,8 +114,9 @@ public class PipelineHealthCheck implements HealthCheck {
   }
 
   private void checkAndRestorePipelineElements() {
+    recoveryBackoff.retainOnly(getActiveRecoveryKeys());
     healthCheckData.activeResources().runningPipelines().forEach(pipeline -> {
-      List<String> failedInstances = new ArrayList<>();
+      List<String> missingInstances = new ArrayList<>();
       List<String> recoveredInstances = new ArrayList<>();
       List<String> pipelineNotifications = new ArrayList<>();
       List<InvocableStreamPipesEntity> runningPipelineElements = Stream.concat(
@@ -114,25 +127,37 @@ public class PipelineHealthCheck implements HealthCheck {
       runningPipelineElements.forEach(pipelineElement -> {
         String instanceId = 
HealthCheckUtils.extractInstanceId(pipelineElement);
         if (isNowhereRunning(instanceId)) {
-          boolean success = restorePipelineElement(pipelineElement, 
pipeline.getPipelineId());
-          if (!success) {
-            failedInstances.add(instanceId);
-            
HealthCheckUtils.addFailedAttemptNotification(pipelineNotifications, 
pipelineElement);
-            LOG.info("Could not restore pipeline element {} of pipeline {}",
-                pipelineElement.getName(), pipeline.getName());
-          } else {
+          missingInstances.add(instanceId);
+          if (recoveryBackoff.isAttemptDue(pipeline.getPipelineId(), 
instanceId)) {
+            boolean success = restorePipelineElement(pipelineElement, 
pipeline.getPipelineId());
+            if (!success) {
+              var state = 
recoveryBackoff.recordFailure(pipeline.getPipelineId(), instanceId);
+              
HealthCheckUtils.addFailedAttemptNotification(pipelineNotifications, 
pipelineElement);
+              logFailedRecovery(pipeline, pipelineElement, state);
+            } else {
+              missingInstances.remove(instanceId);
+              recoveredInstances.add(instanceId);
+              int previousFailures = 
recoveryBackoff.reset(pipeline.getPipelineId(), instanceId);
+              logSuccessfulRecovery(pipeline, pipelineElement, 
previousFailures);
+            }
+          }
+        } else {
+          int previousFailures = 
recoveryBackoff.reset(pipeline.getPipelineId(), instanceId);
+          if (previousFailures > 0) {
             recoveredInstances.add(instanceId);
-            LOG.info("Successfully restored pipeline element {} of pipeline 
{}",
-                pipelineElement.getName(), pipeline.getName());
+            LOG.info("Pipeline element {} of pipeline {} is running again 
after {} failed recovery attempts",
+                pipelineElement.getName(), pipeline.getName(), 
previousFailures);
           }
         }
       });
-      if (!failedInstances.isEmpty() || !recoveredInstances.isEmpty()) {
+      boolean recoveredBeforeThisCheck = missingInstances.isEmpty()
+          && pipeline.getHealthStatus() == PipelineHealthStatus.FAILURE;
+      if (!missingInstances.isEmpty() || !recoveredInstances.isEmpty() || 
recoveredBeforeThisCheck) {
         var currentPipeline = 
resourceProvider.pipelineStorage().getElementById(pipeline.getPipelineId());
-        if (!failedInstances.isEmpty()) {
+        if (!missingInstances.isEmpty()) {
           currentPipeline.setHealthStatus(PipelineHealthStatus.FAILURE);
           pipelinesStats.failedIncrease();
-        } else if (!recoveredInstances.isEmpty()) {
+        } else {
           currentPipeline.setHealthStatus(PipelineHealthStatus.OK);
           pipelinesStats.attentionRequiredIncrease();
         }
@@ -154,6 +179,47 @@ public class PipelineHealthCheck implements HealthCheck {
     
pipelinesStats.setElementCount(getElementsCount(healthCheckData.activeResources().allPipelines()));
   }
 
+  private Set<PipelineRecoveryBackoff.RecoveryKey> getActiveRecoveryKeys() {
+    return healthCheckData.activeResources().runningPipelines().stream()
+        .flatMap(pipeline -> Stream.concat(pipeline.getSepas().stream(), 
pipeline.getActions().stream())
+            .map(pipelineElement -> new PipelineRecoveryBackoff.RecoveryKey(
+                pipeline.getPipelineId(),
+                HealthCheckUtils.extractInstanceId(pipelineElement)
+            )))
+        .collect(Collectors.toSet());
+  }
+
+  private void logFailedRecovery(Pipeline pipeline,
+                                 InvocableStreamPipesEntity pipelineElement,
+                                 PipelineRecoveryBackoff.RecoveryState state) {
+    var logMessage = "Could not restore pipeline element {} of pipeline {} on 
attempt {}; "
+        + "next attempt in {} seconds";
+    var delaySeconds = state.delay().toSeconds();
+    if (state.failedAttempts() == 1) {
+      LOG.warn(logMessage, pipelineElement.getName(), pipeline.getName(), 
state.failedAttempts(), delaySeconds);
+    } else if (isPowerOfTwo(state.failedAttempts())) {
+      LOG.info(logMessage, pipelineElement.getName(), pipeline.getName(), 
state.failedAttempts(), delaySeconds);
+    } else {
+      LOG.debug(logMessage, pipelineElement.getName(), pipeline.getName(), 
state.failedAttempts(), delaySeconds);
+    }
+  }
+
+  private void logSuccessfulRecovery(Pipeline pipeline,
+                                     InvocableStreamPipesEntity 
pipelineElement,
+                                     int previousFailures) {
+    if (previousFailures == 0) {
+      LOG.info("Successfully restored pipeline element {} of pipeline {}",
+          pipelineElement.getName(), pipeline.getName());
+    } else {
+      LOG.info("Successfully restored pipeline element {} of pipeline {} after 
{} failed attempts",
+          pipelineElement.getName(), pipeline.getName(), previousFailures);
+    }
+  }
+
+  private boolean isPowerOfTwo(int value) {
+    return (value & (value - 1)) == 0;
+  }
+
   protected boolean restorePipelineElement(InvocableStreamPipesEntity 
pipelineElement,
                                            String pipelineId) {
     try {
diff --git 
a/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoff.java
 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoff.java
new file mode 100644
index 0000000000..82a9bcbeee
--- /dev/null
+++ 
b/streampipes-health-monitoring/src/main/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoff.java
@@ -0,0 +1,94 @@
+/*
+ * 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.streampipes.health.monitoring;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+final class PipelineRecoveryBackoff {
+
+  static final Duration DEFAULT_INITIAL_DELAY = Duration.ofSeconds(30);
+  static final Duration DEFAULT_MAX_DELAY = Duration.ofMinutes(10);
+
+  private final Clock clock;
+  private final Duration initialDelay;
+  private final Duration maxDelay;
+  private final Map<RecoveryKey, RecoveryState> recoveryStates = new 
ConcurrentHashMap<>();
+
+  PipelineRecoveryBackoff() {
+    this(Clock.systemUTC(), DEFAULT_INITIAL_DELAY, DEFAULT_MAX_DELAY);
+  }
+
+  PipelineRecoveryBackoff(Clock clock,
+                          Duration initialDelay,
+                          Duration maxDelay) {
+    this.clock = clock;
+    this.initialDelay = initialDelay;
+    this.maxDelay = maxDelay;
+  }
+
+  boolean isAttemptDue(String pipelineId,
+                       String instanceId) {
+    var state = recoveryStates.get(new RecoveryKey(pipelineId, instanceId));
+    return state == null || !clock.instant().isBefore(state.nextAttemptAt());
+  }
+
+  RecoveryState recordFailure(String pipelineId,
+                              String instanceId) {
+    var key = new RecoveryKey(pipelineId, instanceId);
+    return recoveryStates.compute(key, (ignored, previousState) -> {
+      int failedAttempts = previousState == null ? 1 : 
previousState.failedAttempts() + 1;
+      Duration delay = calculateDelay(failedAttempts);
+      return new RecoveryState(failedAttempts, delay, 
clock.instant().plus(delay));
+    });
+  }
+
+  int reset(String pipelineId,
+            String instanceId) {
+    var previousState = recoveryStates.remove(new RecoveryKey(pipelineId, 
instanceId));
+    return previousState == null ? 0 : previousState.failedAttempts();
+  }
+
+  void retainOnly(Set<RecoveryKey> activeInstances) {
+    recoveryStates.keySet().retainAll(activeInstances);
+  }
+
+  RecoveryState getState(String pipelineId,
+                         String instanceId) {
+    return recoveryStates.get(new RecoveryKey(pipelineId, instanceId));
+  }
+
+  private Duration calculateDelay(int failedAttempts) {
+    int exponent = Math.min(failedAttempts - 1, 30);
+    long multiplier = 1L << exponent;
+    Duration calculatedDelay = initialDelay.multipliedBy(multiplier);
+    return calculatedDelay.compareTo(maxDelay) > 0 ? maxDelay : 
calculatedDelay;
+  }
+
+  record RecoveryKey(String pipelineId, String instanceId) {
+  }
+
+  record RecoveryState(int failedAttempts,
+                       Duration delay,
+                       Instant nextAttemptAt) {
+  }
+}
diff --git 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineHealthCheckTest.java
 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineHealthCheckTest.java
index 6f5dc89493..de5278042b 100644
--- 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineHealthCheckTest.java
+++ 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineHealthCheckTest.java
@@ -22,15 +22,21 @@ import 
org.apache.streampipes.health.monitoring.model.HealthCheckData;
 import org.apache.streampipes.health.monitoring.utils.HealthCheckUtils;
 import org.apache.streampipes.model.base.InvocableStreamPipesEntity;
 import org.apache.streampipes.model.graph.DataProcessorInvocation;
+import org.apache.streampipes.model.health.ExtensionInstanceHealth;
 import org.apache.streampipes.model.pipeline.Pipeline;
 import org.apache.streampipes.model.pipeline.PipelineHealthStatus;
 import org.apache.streampipes.storage.api.pipeline.IPipelineStorage;
 
 import org.junit.jupiter.api.Test;
 
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.ArgumentMatchers.any;
@@ -42,16 +48,46 @@ public class PipelineHealthCheckTest {
   @Test
   public void retriesRestorationBeyondPreviousLimit() {
     var pipeline = pipeline(processor("missing", "Missing processor"));
-    var healthCheck = healthCheck(pipeline, Map.of("missing", false));
+    var clock = new MutableClock();
+    var recoveryBackoff = new PipelineRecoveryBackoff(
+        clock,
+        PipelineRecoveryBackoff.DEFAULT_INITIAL_DELAY,
+        PipelineRecoveryBackoff.DEFAULT_MAX_DELAY
+    );
+    var healthCheck = healthCheck(pipeline, Map.of("missing", false), 
recoveryBackoff);
 
     for (int i = 0; i < 11; i++) {
       healthCheck.runCheck();
+      clock.advance(PipelineRecoveryBackoff.DEFAULT_MAX_DELAY);
     }
 
     assertEquals(11, healthCheck.restoreAttempts("missing"));
     assertEquals(PipelineHealthStatus.FAILURE, pipeline.getHealthStatus());
   }
 
+  @Test
+  public void waitsUntilBackoffExpiresBeforeRetrying() {
+    var pipeline = pipeline(processor("missing", "Missing processor"));
+    var clock = new MutableClock();
+    var recoveryBackoff = new PipelineRecoveryBackoff(
+        clock,
+        PipelineRecoveryBackoff.DEFAULT_INITIAL_DELAY,
+        PipelineRecoveryBackoff.DEFAULT_MAX_DELAY
+    );
+    var healthCheck = healthCheck(pipeline, Map.of("missing", false), 
recoveryBackoff);
+
+    healthCheck.runCheck();
+    healthCheck.runCheck();
+
+    assertEquals(1, healthCheck.restoreAttempts("missing"));
+    assertEquals(PipelineHealthStatus.FAILURE, pipeline.getHealthStatus());
+
+    clock.advance(PipelineRecoveryBackoff.DEFAULT_INITIAL_DELAY);
+    healthCheck.runCheck();
+
+    assertEquals(2, healthCheck.restoreAttempts("missing"));
+  }
+
   @Test
   public void recoveredElementDoesNotMaskFailedElement() {
     var pipeline = pipeline(
@@ -85,8 +121,41 @@ public class PipelineHealthCheckTest {
     assertEquals(PipelineHealthStatus.OK, pipeline.getHealthStatus());
   }
 
+  @Test
+  public void observedRunningElementClearsFailureAndBackoffState() {
+    var pipeline = pipeline(processor("running", "Running processor"));
+    pipeline.setHealthStatus(PipelineHealthStatus.FAILURE);
+    var recoveryBackoff = new PipelineRecoveryBackoff();
+    recoveryBackoff.recordFailure(pipeline.getPipelineId(), "running");
+    var healthCheck = healthCheck(
+        pipeline,
+        Map.of(),
+        recoveryBackoff,
+        Set.of("running")
+    );
+
+    healthCheck.runCheck();
+
+    assertEquals(PipelineHealthStatus.OK, pipeline.getHealthStatus());
+    assertEquals(0, recoveryBackoff.reset(pipeline.getPipelineId(), 
"running"));
+    assertEquals(0, healthCheck.restoreAttempts("running"));
+  }
+
   private TestPipelineHealthCheck healthCheck(Pipeline pipeline,
                                               Map<String, Boolean> 
restorationResults) {
+    return healthCheck(pipeline, restorationResults, new 
PipelineRecoveryBackoff());
+  }
+
+  private TestPipelineHealthCheck healthCheck(Pipeline pipeline,
+                                              Map<String, Boolean> 
restorationResults,
+                                              PipelineRecoveryBackoff 
recoveryBackoff) {
+    return healthCheck(pipeline, restorationResults, recoveryBackoff, 
Set.of());
+  }
+
+  private TestPipelineHealthCheck healthCheck(Pipeline pipeline,
+                                              Map<String, Boolean> 
restorationResults,
+                                              PipelineRecoveryBackoff 
recoveryBackoff,
+                                              Set<String> runningInstanceIds) {
     IPipelineStorage pipelineStorage = mock(IPipelineStorage.class);
     
when(pipelineStorage.getElementById(pipeline.getPipelineId())).thenReturn(pipeline);
     when(pipelineStorage.updateElement(any())).thenAnswer(invocation -> 
invocation.getArgument(0));
@@ -102,10 +171,15 @@ public class PipelineHealthCheckTest {
         resourceProvider,
         activeResources,
         Map.of(),
-        Map.of()
+        Map.of("service-id", new ExtensionInstanceHealth(Map.of(), 
runningInstanceIds))
     );
 
-    return new TestPipelineHealthCheck(healthCheckData, resourceProvider, 
restorationResults);
+    return new TestPipelineHealthCheck(
+        healthCheckData,
+        resourceProvider,
+        restorationResults,
+        recoveryBackoff
+    );
   }
 
   private Pipeline pipeline(DataProcessorInvocation... processors) {
@@ -133,8 +207,9 @@ public class PipelineHealthCheckTest {
 
     TestPipelineHealthCheck(HealthCheckData healthCheckData,
                             ResourceProvider resourceProvider,
-                            Map<String, Boolean> restorationResults) {
-      super(healthCheckData, null, resourceProvider, null);
+                            Map<String, Boolean> restorationResults,
+                            PipelineRecoveryBackoff recoveryBackoff) {
+      super(healthCheckData, null, resourceProvider, null, recoveryBackoff);
       this.restorationResults = restorationResults;
     }
 
@@ -150,4 +225,28 @@ public class PipelineHealthCheckTest {
       return restorationAttempts.getOrDefault(instanceId, 0);
     }
   }
+
+  private static class MutableClock extends Clock {
+
+    private Instant instant = Instant.EPOCH;
+
+    @Override
+    public ZoneId getZone() {
+      return ZoneId.of("UTC");
+    }
+
+    @Override
+    public Clock withZone(ZoneId zone) {
+      return this;
+    }
+
+    @Override
+    public Instant instant() {
+      return instant;
+    }
+
+    void advance(Duration duration) {
+      instant = instant.plus(duration);
+    }
+  }
 }
diff --git 
a/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoffTest.java
 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoffTest.java
new file mode 100644
index 0000000000..802d21691e
--- /dev/null
+++ 
b/streampipes-health-monitoring/src/test/java/org/apache/streampipes/health/monitoring/PipelineRecoveryBackoffTest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.streampipes.health.monitoring;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class PipelineRecoveryBackoffTest {
+
+  @Test
+  public void delayIncreasesExponentiallyAndIsCapped() {
+    var recoveryBackoff = recoveryBackoff();
+    var expectedDelays = new long[]{30, 60, 120, 240, 480, 600, 600};
+
+    for (long expectedDelay : expectedDelays) {
+      var state = recoveryBackoff.recordFailure("pipeline", "instance");
+      assertEquals(Duration.ofSeconds(expectedDelay), state.delay());
+    }
+  }
+
+  @Test
+  public void resetAllowsImmediateRecoveryAttempt() {
+    var recoveryBackoff = recoveryBackoff();
+    recoveryBackoff.recordFailure("pipeline", "instance");
+
+    assertFalse(recoveryBackoff.isAttemptDue("pipeline", "instance"));
+    assertEquals(1, recoveryBackoff.reset("pipeline", "instance"));
+    assertTrue(recoveryBackoff.isAttemptDue("pipeline", "instance"));
+  }
+
+  @Test
+  public void inactiveInstancesAreRemoved() {
+    var recoveryBackoff = recoveryBackoff();
+    recoveryBackoff.recordFailure("pipeline", "active");
+    recoveryBackoff.recordFailure("pipeline", "inactive");
+
+    recoveryBackoff.retainOnly(Set.of(
+        new PipelineRecoveryBackoff.RecoveryKey("pipeline", "active")
+    ));
+
+    assertEquals(1, recoveryBackoff.getState("pipeline", 
"active").failedAttempts());
+    assertNull(recoveryBackoff.getState("pipeline", "inactive"));
+  }
+
+  private PipelineRecoveryBackoff recoveryBackoff() {
+    return new PipelineRecoveryBackoff(
+        Clock.fixed(Instant.EPOCH, ZoneOffset.UTC),
+        PipelineRecoveryBackoff.DEFAULT_INITIAL_DELAY,
+        PipelineRecoveryBackoff.DEFAULT_MAX_DELAY
+    );
+  }
+}
diff --git 
a/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.html
 
b/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.html
index 88dde64932..59b4670f55 100644
--- 
a/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.html
+++ 
b/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.html
@@ -16,39 +16,66 @@
 ~
 -->
 
-@for (msg of pipelineOperationStatus()?.elementStatus ?? []; track msg) {
-    <div fxFlex="100" fxLayout="column" class="status-outer mt-10">
-        <div fxFlex="100" fxLayout="column">
-            <div
-                fxFlex="100"
-                fxLayout="row"
-                fxLayoutAlign="start center"
-                class="p-15"
-            >
-                @if (msg.success) {
-                    <mat-icon color="accent">done</mat-icon>
-                }
-                @if (!msg.success) {
-                    <mat-icon style="color: red">warning</mat-icon>
-                }
-                <div fxFlex="100" fxLayout="column" class="ml-5">
-                    <span
-                        ><b>{{ msg.elementName }}</b></span
-                    >
-                    <small>{{
-                        msg.elementId.substr(0, msg.elementId.lastIndexOf('/'))
-                    }}</small>
-                </div>
+<div class="status-list" role="list">
+    @for (msg of elementStatuses(); track msg.elementId + $index) {
+        @if (!msg.success) {
+            <div class="status-entry status-error" role="listitem">
+                <sp-alert-banner
+                    type="error"
+                    [title]="msg.elementName || ('Error' | translate)"
+                >
+                    <div class="status-meta">
+                        <sp-label
+                            tone="error"
+                            size="small"
+                            variant="soft"
+                            shape="badge"
+                            textCase="uppercase"
+                            [labelText]="'Error' | translate"
+                        ></sp-label>
+                        @if (msg.elementId) {
+                            <code class="status-id" [title]="msg.elementId">{{
+                                getDisplayElementId(msg)
+                            }}</code>
+                        }
+                    </div>
+                    @if (msg.optionalMessage) {
+                        <pre
+                            class="error-detail"
+                            [textContent]="msg.optionalMessage"
+                        ></pre>
+                    }
+                </sp-alert-banner>
             </div>
-            <div>
-                @if (msg.optionalMessage) {
-                    <div fxFlex="100" fxLayout="column" class="mt-10">
-                        <div class="error-message">
-                            <div class="p-10">{{ msg.optionalMessage }}</div>
-                        </div>
+        } @else {
+            <div class="status-entry status-success" role="listitem">
+                <mat-icon class="status-icon" aria-hidden="true"
+                    >check_circle</mat-icon
+                >
+                <div class="status-content">
+                    <div class="status-heading">
+                        <span class="status-name">{{ msg.elementName }}</span>
+                        <sp-label
+                            tone="success"
+                            size="small"
+                            variant="soft"
+                            shape="badge"
+                            textCase="uppercase"
+                            [labelText]="'Success' | translate"
+                        ></sp-label>
                     </div>
-                }
+                    @if (msg.elementId) {
+                        <code class="status-id" [title]="msg.elementId">{{
+                            getDisplayElementId(msg)
+                        }}</code>
+                    }
+                    @if (msg.optionalMessage) {
+                        <div class="success-detail">
+                            {{ msg.optionalMessage }}
+                        </div>
+                    }
+                </div>
             </div>
-        </div>
-    </div>
-}
+        }
+    }
+</div>
diff --git 
a/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.scss
 
b/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.scss
index 16a5951fa8..db67c36927 100644
--- 
a/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.scss
+++ 
b/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.scss
@@ -16,6 +16,121 @@
  *
  */
 
-.status-outer {
-    border: 1px solid var(--color-bg-3);
+.status-list {
+    display: flex;
+    flex-direction: column;
+    gap: var(--space-sm);
+    width: 100%;
+    margin-top: var(--space-md);
+}
+
+.status-entry {
+    min-width: 0;
+    animation: status-entry-in var(--motion-duration-standard)
+        var(--motion-easing-standard) both;
+}
+
+.status-error {
+    display: block;
+}
+
+.status-error sp-alert-banner {
+    display: block;
+}
+
+.status-meta,
+.status-heading {
+    display: flex;
+    align-items: center;
+    gap: var(--space-sm);
+    min-width: 0;
+}
+
+.status-meta {
+    margin-top: var(--space-2xs);
+}
+
+.status-id {
+    min-width: 0;
+    color: var(--fg-muted);
+    font-size: var(--font-size-xs);
+    line-height: var(--line-height-normal);
+    overflow-wrap: anywhere;
+}
+
+.error-detail {
+    max-height: 14rem;
+    margin: var(--space-sm) 0 0;
+    padding: var(--space-sm) var(--space-md);
+    overflow: auto;
+    border-radius: var(--radius-xs);
+    background: color-mix(in srgb, var(--color-bg-0) 72%, transparent);
+    color: var(--mat-sys-on-surface);
+    font-size: var(--font-size-xs);
+    line-height: var(--line-height-relaxed);
+    white-space: pre-wrap;
+    overflow-wrap: anywhere;
+}
+
+.status-success {
+    display: flex;
+    align-items: flex-start;
+    gap: var(--space-sm);
+    padding: var(--space-sm) var(--space-md);
+    border-bottom: 1px solid var(--color-border-subtle);
+    transition: background-color var(--motion-duration-fast)
+        var(--motion-easing-standard);
+}
+
+.status-success:hover {
+    background-color: var(--color-surface-interactive-hover);
+}
+
+.status-icon {
+    flex: 0 0 auto;
+    color: var(--color-success);
+}
+
+.status-content {
+    flex: 1 1 auto;
+    min-width: 0;
+}
+
+.status-heading {
+    justify-content: space-between;
+}
+
+.status-name {
+    min-width: 0;
+    font-weight: var(--font-weight-medium);
+    overflow-wrap: anywhere;
+}
+
+.success-detail {
+    margin-top: var(--space-2xs);
+    color: var(--fg-muted);
+    font-size: var(--font-size-sm);
+    line-height: var(--line-height-normal);
+    overflow-wrap: anywhere;
+}
+
+@keyframes status-entry-in {
+    from {
+        opacity: 0;
+        transform: translateY(var(--space-xs));
+    }
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+@media (prefers-reduced-motion: reduce) {
+    .status-entry {
+        animation: none;
+    }
+
+    .status-success {
+        transition: none;
+    }
 }
diff --git 
a/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.ts
 
b/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.ts
index cbfd29fe76..666ef51c17 100644
--- 
a/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.ts
+++ 
b/ui/src/app/core-ui/pipeline/pipeline-operation-status/pipeline-operation-status.component.ts
@@ -16,23 +16,40 @@
  *
  */
 
-import { Component, input } from '@angular/core';
-import { PipelineOperationStatus } from '@streampipes/platform-services';
+import { Component, computed, input } from '@angular/core';
 import {
-    FlexDirective,
-    LayoutAlignDirective,
-    LayoutDirective,
-} from '@ngbracket/ngx-layout/flex';
+    PipelineElementStatus,
+    PipelineOperationStatus,
+} from '@streampipes/platform-services';
 import { MatIcon } from '@angular/material/icon';
+import {
+    SpAlertBannerComponent,
+    SpLabelComponent,
+} from '@streampipes/shared-ui';
+import { TranslatePipe } from '@ngx-translate/core';
 
 @Component({
     selector: 'sp-pipeline-operation-status',
     templateUrl: './pipeline-operation-status.component.html',
     styleUrls: ['./pipeline-operation-status.component.scss'],
-    imports: [FlexDirective, LayoutDirective, LayoutAlignDirective, MatIcon],
+    imports: [MatIcon, SpAlertBannerComponent, SpLabelComponent, 
TranslatePipe],
 })
 export class PipelineOperationStatusComponent {
     readonly pipelineOperationStatus = input<
         PipelineOperationStatus | undefined
     >(undefined);
+
+    readonly elementStatuses = computed(() =>
+        [...(this.pipelineOperationStatus()?.elementStatus ?? [])].sort(
+            (first, second) => Number(first.success) - Number(second.success),
+        ),
+    );
+
+    getDisplayElementId(status: PipelineElementStatus): string {
+        const elementId = status.elementId ?? '';
+        const lastSeparatorIndex = elementId.lastIndexOf('/');
+        return lastSeparatorIndex > 0
+            ? elementId.substring(0, lastSeparatorIndex)
+            : elementId;
+    }
 }
diff --git a/ui/src/scss/sp/main.scss b/ui/src/scss/sp/main.scss
index e5d0bb9f30..9aabe57d2d 100644
--- a/ui/src/scss/sp/main.scss
+++ b/ui/src/scss/sp/main.scss
@@ -715,7 +715,7 @@ label {
     font:
         0.7rem Inconsolata,
         monospace;
-    color: var(--mat-sys-on-surface);
+    color: var(--color-bg-0);
     width: 100%;
     max-width: 100%;
 }


Reply via email to