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

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


The following commit(s) were added to refs/heads/main by this push:
     new 7ba76f017c Issue #8501 : Show the finished check only when every copy 
is done (#8573)
7ba76f017c is described below

commit 7ba76f017ca9d616fbd001169f870207c4eb15aa
Author: Matt Casters <[email protected]>
AuthorDate: Sat Sep 26 12:39:41 2026 +0200

    Issue #8501 : Show the finished check only when every copy is done (#8573)
    
    * Issue #8501 : Show the finished check only when every copy is done
    
    The canvas painted the success check as soon as one copy finished.
    Show the finished-copy count until every copy is done, and document it.
    
    * Issue #8501: Fix failure icon detection and prevent overlap with success 
indicator
    
    - Detect transform errors from component copies in 
PipelinePainter#hasTransformFailureIcon
    - Update HopGuiPipelineGraph to trigger checkErrorVisuals and 
stopRedrawTimer on stop and finish
    - Position status badge to the right of the error icon when errors are 
present
    - Add unit tests verifying failure and success badge rendering
---
 .../hop-gui/pipeline/specify-copies-running.png    | Bin 0 -> 48325 bytes
 .../pages/pipeline/run-preview-debug-pipeline.adoc |   2 +-
 .../ROOT/pages/pipeline/specify-copies.adoc        |  11 ++
 .../org/apache/hop/pipeline/PipelinePainter.java   | 132 +++++++++----
 .../hop/pipeline/TransformCopyCompletion.java      | 101 ++++++++++
 .../hop/pipeline/TransformCopyCompletionTest.java  | 147 +++++++++++++++
 .../canvas/PipelineCanvasSvgRendererTest.java      | 209 +++++++++++++++++++++
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  46 ++++-
 .../ui/hopgui/messages/messages_en_US.properties   |   1 +
 9 files changed, 609 insertions(+), 40 deletions(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/specify-copies-running.png
 
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/specify-copies-running.png
new file mode 100644
index 0000000000..479a351212
Binary files /dev/null and 
b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/pipeline/specify-copies-running.png
 differ
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
index 55f91350fc..aec76ae9bb 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/run-preview-debug-pipeline.adoc
@@ -54,7 +54,7 @@ Choose the logging level as described below.
 
 Make sure your configuration is selected and hit 'Launch'.
 
-When a pipeline has run succesfully green check marks are displayed in the 
right upper corner on the transforms.
+When a pipeline has run successfully, a check mark is displayed in the upper 
right corner of each transform. With more than one copy, that check is shown 
only when every copy of the transform has finished. Until then the corner shows 
how many copies are done. See 
xref:pipeline/specify-copies.adoc#finished-copies[Specify copies].
 
 image::hop-gui/pipeline/executed-pipeline.png[Executed 
pipeline,75%,align="left"]
 
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/specify-copies.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/specify-copies.adoc
index 841f0ae585..03f1f0afc7 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/specify-copies.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/specify-copies.adoc
@@ -45,6 +45,17 @@ When your pipeline starts, Apache Hop will create the 
specified number of copies
 
 image::hop-gui/pipeline/specify-copies-expanded.png[Specify copies expanded, 
width="50%", align="left"]
 
+[[finished-copies]]
+== Finished copies
+
+Copies of the same transform do not finish at the same time. A check mark in 
the upper right corner means every copy of that transform is done. While any 
copy is still working, that corner shows how many copies have already finished.
+
+In the pipeline below, `5 rows` is finished. `random-wait.hpl` runs in five 
copies (`x5` in the upper left). Three of those copies have finished, so the 
icon shows `3`. The other two are still running.
+
+image::hop-gui/pipeline/specify-copies-running.png[Three of five copies 
finished, width="65%", align="left"]
+
+Hover the transform icon to see the same count, for example `Copies finished: 
3 of 5`. The check mark replaces the number only after the last copy finishes. 
The transform metrics tab still lists one row per copy.
+
 == Use cases
 
 Increasing the number of copies for a limited number of transforms in your 
pipelines can help to improve your pipeline's performance, but the option 
should be used with great care.
diff --git a/engine/src/main/java/org/apache/hop/pipeline/PipelinePainter.java 
b/engine/src/main/java/org/apache/hop/pipeline/PipelinePainter.java
index dd15713c24..f42f999b32 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/PipelinePainter.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/PipelinePainter.java
@@ -47,7 +47,6 @@ import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.partition.PartitionSchema;
-import org.apache.hop.pipeline.engine.EngineComponent.ComponentExecutionStatus;
 import org.apache.hop.pipeline.engine.IEngineComponent;
 import org.apache.hop.pipeline.engine.IPipelineEngine;
 import org.apache.hop.pipeline.transform.ITransformIOMeta;
@@ -68,6 +67,12 @@ public class PipelinePainter extends 
BasePainter<PipelineHopMeta, TransformMeta>
   public static final String STRING_HOP_TYPE_COPY = "HopTypeCopy";
   public static final String STRING_ROW_DISTRIBUTION = "RowDistribution";
 
+  /** Same fill as {@code ui/images/success.svg}. Used for the partial-copy 
count disc. */
+  private static final int COPY_BADGE_RED = 92;
+
+  private static final int COPY_BADGE_GREEN = 192;
+  private static final int COPY_BADGE_BLUE = 196;
+
   private PipelineMeta pipelineMeta;
 
   private Map<String, String> transformLogMap;
@@ -667,42 +672,85 @@ public class PipelinePainter extends 
BasePainter<PipelineHopMeta, TransformMeta>
 
   private void drawTransformStatusIndicator(TransformMeta transformMeta) 
throws HopException {
 
-    if (transformMeta == null) {
+    if (transformMeta == null || pipeline == null) {
       return;
     }
 
-    // draw status indicator
-    if (pipeline != null) {
+    Point pt = transformMeta.getLocation();
+    if (pt == null) {
+      pt = new Point(50, 50);
+    }
 
-      Point pt = transformMeta.getLocation();
-      if (pt == null) {
-        pt = new Point(50, 50);
-      }
+    Point screen = real2screen(pt.x, pt.y);
+    int x = screen.x;
+    int y = screen.y;
 
-      Point screen = real2screen(pt.x, pt.y);
-      int x = screen.x;
-      int y = screen.y;
+    if (hasTransformFailureIcon(transformMeta)) {
+      x += miniIconSize;
+    }
 
-      if (pipeline != null) {
-        List<IEngineComponent> transforms = 
pipeline.getComponentCopies(transformMeta.getName());
+    TransformCopyCompletion.Summary summary =
+        
TransformCopyCompletion.of(pipeline.getComponentCopies(transformMeta.getName()));
+    switch (summary.badge()) {
+      case PAUSED ->
+          gc.drawImage(
+              EImage.WAITING,
+              (x + iconSize) - (miniIconSize / 2) + 1,
+              y - (miniIconSize / 2) - 1,
+              magnification);
+      case FINISHED ->
+          gc.drawImage(
+              EImage.SUCCESS,
+              (x + iconSize) - (miniIconSize / 2) + 1,
+              y - (miniIconSize / 2) - 1,
+              magnification);
+      case PARTIAL -> drawPartialCopyBadge(x, y, summary.finished());
+      case NONE -> {
+        // Still starting, still running, or stopped before any copy finished.
+      }
+    }
+  }
 
-        for (IEngineComponent transform : transforms) {
-          if (transform.getStatus() == ComponentExecutionStatus.STATUS_PAUSED) 
{
-            gc.drawImage(
-                EImage.WAITING,
-                (x + iconSize) - (miniIconSize / 2) + 1,
-                y - (miniIconSize / 2) - 1,
-                magnification);
-          } else if (transform.getStatus() == 
ComponentExecutionStatus.STATUS_FINISHED) {
-            gc.drawImage(
-                EImage.SUCCESS,
-                (x + iconSize) - (miniIconSize / 2) + 1,
-                y - (miniIconSize / 2) - 1,
-                magnification);
+  private boolean hasTransformFailureIcon(TransformMeta transformMeta) {
+    if (transformMeta == null) {
+      return false;
+    }
+    if (!Utils.isEmpty(transformLogMap)
+        && !Utils.isEmpty(transformLogMap.get(transformMeta.getName()))) {
+      return true;
+    }
+    if (pipeline != null) {
+      List<IEngineComponent> copies = 
pipeline.getComponentCopies(transformMeta.getName());
+      if (copies != null) {
+        for (IEngineComponent copy : copies) {
+          if (copy != null && copy.getErrors() > 0) {
+            return true;
           }
         }
       }
     }
+    return false;
+  }
+
+  /** Azure disc with the number of finished copies, anchored on the icon's 
top-right corner. */
+  private void drawPartialCopyBadge(int x, int y, int finished) {
+    String label = Integer.toString(finished);
+    gc.setFont(EFont.TINY);
+    Point extent = gc.textExtent(label);
+    int badgeHeight = miniIconSize;
+    int badgeWidth = Math.max(miniIconSize, extent.x + 4);
+    int centerX = (x + iconSize) + 1;
+    int centerY = y - 1;
+    int badgeX = centerX - badgeWidth / 2;
+    int badgeY = centerY - badgeHeight / 2;
+
+    gc.setBackground(COPY_BADGE_RED, COPY_BADGE_GREEN, COPY_BADGE_BLUE);
+    gc.fillRoundRectangle(badgeX, badgeY, badgeWidth, badgeHeight, 
badgeHeight, badgeHeight);
+
+    gc.setForeground(EColor.WHITE);
+    int textX = badgeX + Math.max(0, (badgeWidth - extent.x) / 2);
+    int textY = badgeY + Math.max(0, (badgeHeight - extent.y) / 2);
+    gc.drawText(label, textX, textY, true);
   }
 
   private void drawTransformOutputIndicator(TransformMeta transformMeta) 
throws HopException {
@@ -817,13 +865,7 @@ public class PipelinePainter extends 
BasePainter<PipelineHopMeta, TransformMeta>
     int x = screen.x;
     int y = screen.y;
 
-    boolean transformError = false;
-    if (!Utils.isEmpty(transformLogMap)) {
-      String log = transformLogMap.get(transformMeta.getName());
-      if (!Utils.isEmpty(log)) {
-        transformError = true;
-      }
-    }
+    boolean transformError = hasTransformFailureIcon(transformMeta);
 
     // PARTITIONING
 
@@ -993,10 +1035,30 @@ public class PipelinePainter extends 
BasePainter<PipelineHopMeta, TransformMeta>
               transformMeta));
     }
 
-    // If there was an error during the run, the map "transformLogMap" is not 
empty and not null.
+    // If there was an error during the run, show the failure icon in the 
upper right corner...
     //
     if (transformError) {
-      String log = transformLogMap.get(transformMeta.getName());
+      String log = null;
+      if (!Utils.isEmpty(transformLogMap)) {
+        log = transformLogMap.get(transformMeta.getName());
+      }
+      if (Utils.isEmpty(log) && pipeline != null) {
+        List<IEngineComponent> copies = 
pipeline.getComponentCopies(transformMeta.getName());
+        if (copies != null) {
+          for (IEngineComponent copy : copies) {
+            if (copy != null && copy.getErrors() > 0) {
+              String text = copy.getLogText();
+              if (!Utils.isEmpty(text)) {
+                log = text;
+                break;
+              }
+            }
+          }
+        }
+      }
+      if (Utils.isEmpty(log)) {
+        log = STRING_TRANSFORM_ERROR_LOG;
+      }
 
       // Show an error lines icon in the upper right corner of the transform...
       //
diff --git 
a/engine/src/main/java/org/apache/hop/pipeline/TransformCopyCompletion.java 
b/engine/src/main/java/org/apache/hop/pipeline/TransformCopyCompletion.java
new file mode 100644
index 0000000000..807b7c768f
--- /dev/null
+++ b/engine/src/main/java/org/apache/hop/pipeline/TransformCopyCompletion.java
@@ -0,0 +1,101 @@
+/*
+ * 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.hop.pipeline;
+
+import java.util.List;
+import org.apache.hop.pipeline.engine.EngineComponent.ComponentExecutionStatus;
+import org.apache.hop.pipeline.engine.IEngineComponent;
+
+/**
+ * Canvas badge for one transform, decided from every copy rather than from 
the first copy that
+ * finishes.
+ */
+public final class TransformCopyCompletion {
+
+  /** What to paint in the top-right corner of the transform icon. */
+  public enum Badge {
+    /** No status badge. */
+    NONE,
+    /** Waiting icon. Nothing is finished and every remaining copy is paused. 
*/
+    PAUSED,
+    /** Azure count of finished copies. At least one copy is still not 
finished. */
+    PARTIAL,
+    /** Success check. Every copy is finished. */
+    FINISHED
+  }
+
+  /**
+   * @param badge which badge to draw
+   * @param finished number of copies whose status is finished
+   * @param total number of copies considered
+   */
+  public record Summary(Badge badge, int finished, int total) {}
+
+  private TransformCopyCompletion() {}
+
+  /**
+   * @param copies the copies of one transform, or null when the engine has 
not reported any
+   * @return the single badge for those copies
+   */
+  public static Summary of(List<IEngineComponent> copies) {
+    if (copies == null || copies.isEmpty()) {
+      return new Summary(Badge.NONE, 0, 0);
+    }
+
+    int finished = 0;
+    int paused = 0;
+    int active = 0;
+    for (IEngineComponent copy : copies) {
+      ComponentExecutionStatus status = copy == null ? null : copy.getStatus();
+      if (status == ComponentExecutionStatus.STATUS_FINISHED) {
+        finished++;
+      } else if (status == ComponentExecutionStatus.STATUS_PAUSED) {
+        paused++;
+      } else if (isStillActive(status)) {
+        active++;
+      }
+    }
+
+    int total = copies.size();
+    Badge badge;
+    if (finished == total) {
+      badge = Badge.FINISHED;
+    } else if (finished > 0) {
+      badge = Badge.PARTIAL;
+    } else if (paused > 0 && active == 0) {
+      badge = Badge.PAUSED;
+    } else {
+      badge = Badge.NONE;
+    }
+    return new Summary(badge, finished, total);
+  }
+
+  /**
+   * Statuses that mean the copy has not reached a terminal paused or finished 
state. Stopped and
+   * disposed copies are neither finished nor still active.
+   */
+  private static boolean isStillActive(ComponentExecutionStatus status) {
+    return status == null
+        || status == ComponentExecutionStatus.STATUS_RUNNING
+        || status == ComponentExecutionStatus.STATUS_INIT
+        || status == ComponentExecutionStatus.STATUS_IDLE
+        || status == ComponentExecutionStatus.STATUS_HALTING
+        || status == ComponentExecutionStatus.STATUS_EMPTY
+        || status == ComponentExecutionStatus.STATUS_HALTED;
+  }
+}
diff --git 
a/engine/src/test/java/org/apache/hop/pipeline/TransformCopyCompletionTest.java 
b/engine/src/test/java/org/apache/hop/pipeline/TransformCopyCompletionTest.java
new file mode 100644
index 0000000000..8dfd26d98a
--- /dev/null
+++ 
b/engine/src/test/java/org/apache/hop/pipeline/TransformCopyCompletionTest.java
@@ -0,0 +1,147 @@
+/*
+ * 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.hop.pipeline;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.apache.hop.pipeline.TransformCopyCompletion.Badge;
+import org.apache.hop.pipeline.engine.EngineComponent;
+import org.apache.hop.pipeline.engine.EngineComponent.ComponentExecutionStatus;
+import org.apache.hop.pipeline.engine.IEngineComponent;
+import org.junit.jupiter.api.Test;
+
+class TransformCopyCompletionTest {
+
+  @Test
+  void singleCopyShowsTheCheckOnlyWhenThatCopyIsFinished() {
+    assertEquals(Badge.NONE, TransformCopyCompletion.of(List.of(copy(0, 
null))).badge());
+    assertEquals(
+        Badge.NONE,
+        TransformCopyCompletion.of(List.of(copy(0, 
ComponentExecutionStatus.STATUS_RUNNING)))
+            .badge());
+    TransformCopyCompletion.Summary finished =
+        TransformCopyCompletion.of(List.of(copy(0, 
ComponentExecutionStatus.STATUS_FINISHED)));
+    assertEquals(Badge.FINISHED, finished.badge());
+    assertEquals(1, finished.finished());
+    assertEquals(1, finished.total());
+  }
+
+  @Test
+  void partialCountUntilEveryCopyIsFinished() {
+    TransformCopyCompletion.Summary oneOfFour =
+        TransformCopyCompletion.of(
+            List.of(
+                copy(0, ComponentExecutionStatus.STATUS_FINISHED),
+                copy(1, ComponentExecutionStatus.STATUS_RUNNING),
+                copy(2, ComponentExecutionStatus.STATUS_RUNNING),
+                copy(3, ComponentExecutionStatus.STATUS_INIT)));
+    assertEquals(Badge.PARTIAL, oneOfFour.badge());
+    assertEquals(1, oneOfFour.finished());
+    assertEquals(4, oneOfFour.total());
+
+    TransformCopyCompletion.Summary threeOfFour =
+        TransformCopyCompletion.of(
+            List.of(
+                copy(0, ComponentExecutionStatus.STATUS_FINISHED),
+                copy(1, ComponentExecutionStatus.STATUS_FINISHED),
+                copy(2, ComponentExecutionStatus.STATUS_FINISHED),
+                copy(3, ComponentExecutionStatus.STATUS_RUNNING)));
+    assertEquals(Badge.PARTIAL, threeOfFour.badge());
+    assertEquals(3, threeOfFour.finished());
+
+    TransformCopyCompletion.Summary allFour =
+        TransformCopyCompletion.of(
+            List.of(
+                copy(0, ComponentExecutionStatus.STATUS_FINISHED),
+                copy(1, ComponentExecutionStatus.STATUS_FINISHED),
+                copy(2, ComponentExecutionStatus.STATUS_FINISHED),
+                copy(3, ComponentExecutionStatus.STATUS_FINISHED)));
+    assertEquals(Badge.FINISHED, allFour.badge());
+    assertEquals(4, allFour.finished());
+    assertEquals(4, allFour.total());
+  }
+
+  @Test
+  void stoppedCopyDoesNotCountAsFinished() {
+    TransformCopyCompletion.Summary summary =
+        TransformCopyCompletion.of(
+            List.of(
+                copy(0, ComponentExecutionStatus.STATUS_FINISHED),
+                copy(1, ComponentExecutionStatus.STATUS_STOPPED)));
+    assertEquals(Badge.PARTIAL, summary.badge());
+    assertEquals(1, summary.finished());
+    assertEquals(2, summary.total());
+
+    assertEquals(
+        Badge.NONE,
+        TransformCopyCompletion.of(
+                List.of(
+                    copy(0, ComponentExecutionStatus.STATUS_STOPPED),
+                    copy(1, ComponentExecutionStatus.STATUS_DISPOSED)))
+            .badge());
+  }
+
+  @Test
+  void pausedOnlyWhenNothingIsStillActive() {
+    assertEquals(
+        Badge.PAUSED,
+        TransformCopyCompletion.of(
+                List.of(
+                    copy(0, ComponentExecutionStatus.STATUS_PAUSED),
+                    copy(1, ComponentExecutionStatus.STATUS_PAUSED)))
+            .badge());
+
+    assertEquals(
+        Badge.NONE,
+        TransformCopyCompletion.of(
+                List.of(
+                    copy(0, ComponentExecutionStatus.STATUS_PAUSED),
+                    copy(1, ComponentExecutionStatus.STATUS_RUNNING)))
+            .badge());
+
+    TransformCopyCompletion.Summary finishedAndPaused =
+        TransformCopyCompletion.of(
+            List.of(
+                copy(0, ComponentExecutionStatus.STATUS_FINISHED),
+                copy(1, ComponentExecutionStatus.STATUS_PAUSED)));
+    assertEquals(Badge.PARTIAL, finishedAndPaused.badge());
+    assertEquals(1, finishedAndPaused.finished());
+  }
+
+  @Test
+  void emptyListAndNullStatusDoNotShowABadge() {
+    assertEquals(Badge.NONE, TransformCopyCompletion.of(null).badge());
+    assertEquals(Badge.NONE, 
TransformCopyCompletion.of(Collections.emptyList()).badge());
+    assertEquals(0, TransformCopyCompletion.of(List.of()).total());
+
+    TransformCopyCompletion.Summary nullStatus =
+        TransformCopyCompletion.of(Arrays.asList(copy(0, null), null));
+    assertEquals(Badge.NONE, nullStatus.badge());
+    assertEquals(0, nullStatus.finished());
+    assertEquals(2, nullStatus.total());
+  }
+
+  private static IEngineComponent copy(int copyNr, ComponentExecutionStatus 
status) {
+    EngineComponent component = new EngineComponent("Load", copyNr);
+    component.setStatus(status);
+    return component;
+  }
+}
diff --git 
a/engine/src/test/java/org/apache/hop/pipeline/canvas/PipelineCanvasSvgRendererTest.java
 
b/engine/src/test/java/org/apache/hop/pipeline/canvas/PipelineCanvasSvgRendererTest.java
index 7345c3ea08..d2b8d0b8e7 100644
--- 
a/engine/src/test/java/org/apache/hop/pipeline/canvas/PipelineCanvasSvgRendererTest.java
+++ 
b/engine/src/test/java/org/apache/hop/pipeline/canvas/PipelineCanvasSvgRendererTest.java
@@ -17,20 +17,49 @@
 
 package org.apache.hop.pipeline.canvas;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.batik.anim.dom.SAXSVGDocumentFactory;
+import org.apache.batik.util.XMLResourceDescriptor;
 import org.apache.hop.core.gui.CanvasSvgRenderResult;
 import org.apache.hop.core.gui.DPoint;
 import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.svg.SvgCache;
 import org.apache.hop.core.variables.Variables;
 import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.engine.EngineComponent;
+import org.apache.hop.pipeline.engine.EngineComponent.ComponentExecutionStatus;
+import org.apache.hop.pipeline.engine.IEngineComponent;
+import org.apache.hop.pipeline.engine.IPipelineEngine;
 import org.apache.hop.pipeline.transform.TransformMeta;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.w3c.dom.svg.SVGDocument;
 
 class PipelineCanvasSvgRendererTest {
 
+  /**
+   * Status icons live in the UI module. The engine test classpath does not 
contain them, so seed
+   * the SVG cache from the UI sources before a badge is drawn.
+   */
+  @BeforeAll
+  static void cacheStatusIcons() throws Exception {
+    cacheCanvasIcon("ui/images/success.svg");
+    cacheCanvasIcon("ui/images/failure.svg");
+    cacheCanvasIcon("ui/images/waiting.svg");
+  }
+
   @Test
   void rendersPipelineSvgWithClickRegions() throws Exception {
     PipelineMeta pipelineMeta = new PipelineMeta();
@@ -100,4 +129,184 @@ class PipelineCanvasSvgRendererTest {
         result.getSvg().contains("rgb(50,50,50)") || 
result.getSvg().contains("rgb(50, 50, 50)"),
         "Dark canvas background should use graph color rgb(50,50,50)");
   }
+
+  @Test
+  void partialCopiesDrawTheFinishedCountAndNotTheSuccessCheck() throws 
Exception {
+    String svg =
+        renderWithCopies(
+                List.of(
+                    copy(0, ComponentExecutionStatus.STATUS_FINISHED),
+                    copy(1, ComponentExecutionStatus.STATUS_RUNNING),
+                    copy(2, ComponentExecutionStatus.STATUS_RUNNING),
+                    copy(3, ComponentExecutionStatus.STATUS_RUNNING)),
+                null)
+            .getSvg();
+
+    assertFalse(svg.contains("#5cc0c4"), svg);
+    assertTrue(svg.contains(">1<") || svg.contains(">1</text>"), svg);
+    assertTrue(svg.contains("rgb(92,192,196)") || svg.contains("rgb(92, 192, 
196)"), svg);
+  }
+
+  @Test
+  void allCopiesFinishedDrawTheSuccessCheckWithoutACountDisc() throws 
Exception {
+    String svg =
+        renderWithCopies(
+                List.of(
+                    copy(0, ComponentExecutionStatus.STATUS_FINISHED),
+                    copy(1, ComponentExecutionStatus.STATUS_FINISHED)),
+                null)
+            .getSvg();
+
+    assertTrue(svg.contains("#5cc0c4"), svg);
+    assertEquals(1, countOf(svg, "#5cc0c4"));
+    assertFalse(svg.contains("rgb(92,192,196)") || svg.contains("rgb(92, 192, 
196)"), svg);
+  }
+
+  @Test
+  void failureIconIsNotCoveredByTheSuccessCheck() throws Exception {
+    Map<String, String> transformLogMap = new HashMap<>();
+    transformLogMap.put("Load", "transform failed");
+    String svg =
+        renderWithCopies(
+                List.of(
+                    copy(0, ComponentExecutionStatus.STATUS_FINISHED),
+                    copy(1, ComponentExecutionStatus.STATUS_FINISHED)),
+                transformLogMap)
+            .getSvg();
+
+    assertTrue(svg.contains("#5cc0c4"), svg);
+    assertTrue(svg.contains("#ea102a"), svg);
+  }
+
+  @Test
+  void failureIconWithPartialCopiesDrawsBothBadges() throws Exception {
+    Map<String, String> transformLogMap = new HashMap<>();
+    transformLogMap.put("Load", "transform failed");
+    String svg =
+        renderWithCopies(
+                List.of(
+                    copy(0, ComponentExecutionStatus.STATUS_FINISHED),
+                    copy(1, ComponentExecutionStatus.STATUS_RUNNING)),
+                transformLogMap)
+            .getSvg();
+
+    assertTrue(svg.contains("#ea102a"), svg);
+    assertTrue(svg.contains("rgb(92,192,196)") || svg.contains("rgb(92, 192, 
196)"), svg);
+    assertTrue(svg.contains(">1<") || svg.contains(">1</text>"), svg);
+  }
+
+  @Test
+  void failureIconDrawnFromComponentErrorsWithoutTransformLogMap() throws 
Exception {
+    EngineComponent failedCopy = copy(0, 
ComponentExecutionStatus.STATUS_STOPPED);
+    failedCopy.setErrors(1);
+    EngineComponent runningCopy = copy(1, 
ComponentExecutionStatus.STATUS_RUNNING);
+
+    String svg = renderWithCopies(List.of(failedCopy, runningCopy), 
null).getSvg();
+
+    assertTrue(svg.contains("#ea102a"), "Failure icon should be drawn from 
copy errors");
+  }
+
+  @Test
+  void finishedWithErrorsDrawsFailureAndSuccessWhenTransformLogMapIsNull() 
throws Exception {
+    EngineComponent copy0 = copy(0, ComponentExecutionStatus.STATUS_FINISHED);
+    copy0.setErrors(1);
+    EngineComponent copy1 = copy(1, ComponentExecutionStatus.STATUS_FINISHED);
+
+    String svg = renderWithCopies(List.of(copy0, copy1), null).getSvg();
+
+    assertTrue(svg.contains("#ea102a"), "Failure icon should be drawn from 
copy errors");
+    assertTrue(svg.contains("#5cc0c4"), "Success check should be drawn 
alongside failure icon");
+  }
+
+  @Test
+  void pausedCopiesDrawTheWaitingIconOnce() throws Exception {
+    String svg =
+        renderWithCopies(
+                List.of(
+                    copy(0, ComponentExecutionStatus.STATUS_PAUSED),
+                    copy(1, ComponentExecutionStatus.STATUS_PAUSED)),
+                null)
+            .getSvg();
+
+    assertTrue(svg.contains("#800080"), svg);
+    assertEquals(1, countOf(svg, "#800080"));
+    assertFalse(svg.contains("#5cc0c4"), svg);
+  }
+
+  private static CanvasSvgRenderResult renderWithCopies(
+      List<IEngineComponent> copies, Map<String, String> transformLogMap) 
throws Exception {
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("copies");
+
+    TransformMeta transform = new TransformMeta();
+    transform.setName("Load");
+    transform.setLocation(100, 100);
+    pipelineMeta.addTransform(transform);
+
+    @SuppressWarnings("unchecked")
+    IPipelineEngine<PipelineMeta> pipeline = mock(IPipelineEngine.class);
+    when(pipeline.getComponentCopies("Load")).thenReturn(copies);
+
+    PipelineCanvasSvgRenderer.Context context = baseContext(pipelineMeta);
+    context.pipeline = pipeline;
+    context.transformLogMap = transformLogMap;
+    return PipelineCanvasSvgRenderer.render(context);
+  }
+
+  private static PipelineCanvasSvgRenderer.Context baseContext(PipelineMeta 
pipelineMeta) {
+    PipelineCanvasSvgRenderer.Context context = new 
PipelineCanvasSvgRenderer.Context();
+    context.variables = new Variables();
+    context.pipelineMeta = pipelineMeta;
+    context.canvasSize = new Point(800, 600);
+    context.offset = new DPoint(0, 0);
+    context.iconSize = 32;
+    context.lineWidth = 1;
+    context.gridSize = 16;
+    context.noteFontName = "Arial";
+    context.noteFontHeight = 10;
+    context.zoomFactor = 1.0;
+    context.magnification = 1.0f;
+    context.screenMagnification = 1.0f;
+    context.maximum = pipelineMeta.getMaximum();
+    context.showingNavigationView = false;
+    context.showOriginBoundary = false;
+    context.showingSelectedTransformMetrics = false;
+    context.drawingBorderAroundName = false;
+    return context;
+  }
+
+  private static EngineComponent copy(int copyNr, ComponentExecutionStatus 
status) {
+    EngineComponent component = new EngineComponent("Load", copyNr);
+    component.setStatus(status);
+    return component;
+  }
+
+  private static void cacheCanvasIcon(String filename) throws Exception {
+    if (SvgCache.findSvg(filename) != null) {
+      return;
+    }
+    Path fromModule = Path.of("..", "ui", "src", "main", 
"resources").resolve(filename);
+    Path fromRoot = Path.of("ui", "src", "main", 
"resources").resolve(filename);
+    Path file = Files.exists(fromModule) ? fromModule : fromRoot;
+    SAXSVGDocumentFactory factory =
+        new 
SAXSVGDocumentFactory(XMLResourceDescriptor.getXMLParserClassName());
+    SVGDocument document;
+    try (InputStream in = Files.newInputStream(file)) {
+      document = factory.createSVGDocument(filename, in);
+    }
+    SvgCache.addSvg(filename, document, 24, 24, 0, 0);
+  }
+
+  private static int countOf(String text, String token) {
+    int count = 0;
+    int from = 0;
+    while (true) {
+      int found = text.indexOf(token, from);
+      if (found < 0) {
+        return count;
+      }
+      count++;
+      from = found + token.length();
+    }
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index 81f5436ec3..bd4b10340f 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -118,6 +118,7 @@ import org.apache.hop.pipeline.PipelineHopMeta;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.PipelineMetaLayout;
 import org.apache.hop.pipeline.PipelinePainter;
+import org.apache.hop.pipeline.TransformCopyCompletion;
 import org.apache.hop.pipeline.canvas.PipelineCanvasSvgRenderer;
 import org.apache.hop.pipeline.config.PipelineRunConfiguration;
 import org.apache.hop.pipeline.debug.PipelineDebugMeta;
@@ -4796,6 +4797,30 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     return enabled;
   }
 
+  /**
+   * When a transform runs in more than one copy, say how many of those copies 
have finished. A
+   * single copy keeps the icon tooltip unchanged.
+   */
+  private void appendCopyCompletionTip(StringBuilder tip, TransformMeta 
transformMeta) {
+    if (pipeline == null || transformMeta == null) {
+      return;
+    }
+    TransformCopyCompletion.Summary summary =
+        
TransformCopyCompletion.of(pipeline.getComponentCopies(transformMeta.getName()));
+    if (summary.total() <= 1) {
+      return;
+    }
+    if (!tip.isEmpty()) {
+      tip.append(Const.CR);
+    }
+    tip.append(
+        BaseMessages.getString(
+            PKG,
+            "HopGuiPipelineGraph.TransformCopiesFinished.Tooltip",
+            Integer.toString(summary.finished()),
+            Integer.toString(summary.total())));
+  }
+
   private void setToolTip(int x, int y, int screenX, int screenY) {
     AreaOwner subject = null;
 
@@ -4986,6 +5011,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
             tip.append(
                 BaseMessages.getString(PKG, 
"HopGuiPipelineGraph.PipelineSource.TooltipSuffix"));
           }
+          appendCopyCompletionTip(tip, iconTransformMeta);
           break;
         case TRANSFORM_OUTPUT_DATA:
           RowBuffer rowBuffer = (RowBuffer) areaOwner.getOwner();
@@ -6374,6 +6400,8 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
           e);
     }
 
+    stopRedrawTimer();
+    checkErrorVisuals();
     updateGui();
   }
 
@@ -6381,6 +6409,8 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     log.logBasic(
         BaseMessages.getString(
             PKG, "PipelineLog.Log.ProcessingOfPipelineStopped", 
pipelineMeta.getName()));
+    stopRedrawTimer();
+    checkErrorVisuals();
     updateGui();
   }
 
@@ -6836,14 +6866,14 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   }
 
   private void checkErrorVisuals() {
-    if (pipeline.getErrors() > 0) {
+    if (pipeline != null) {
       // Get the logging text and filter it out. Store it in the 
transformLogMap...
       // Use non-empty placeholder when log is null/empty so the transform is 
still marked red
       // (e.g. invalid copies transform never ran init so has no log output).
       //
       transformLogMap = new HashMap<>();
       for (IEngineComponent component : pipeline.getComponents()) {
-        if (component.getErrors() > 0) {
+        if (component != null && component.getErrors() > 0) {
           String logText = component.getLogText();
           transformLogMap.put(
               component.getName(),
@@ -6853,13 +6883,21 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
                   : logText);
         }
       }
-
+      if (transformLogMap.isEmpty()) {
+        transformLogMap = null;
+      }
     } else {
       transformLogMap = null;
     }
     // Redraw the canvas to show the error icons etc.
     //
-    hopDisplay().asyncExec(this::redraw);
+    hopDisplay()
+        .asyncExec(
+            () -> {
+              if (canvas != null && !canvas.isDisposed()) {
+                canvas.redraw();
+              }
+            });
   }
 
   public synchronized void showLastPreviewResults() {
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index 67cdf0f13c..83ee807799 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -208,6 +208,7 @@ PipelineGraph.DeprecatedTransform.Tooltip.Message3=We 
suggest\nusing {0} instead
 HopGuiPipelineGraph.PipelineSource.Keyword=pipeline source
 HopGuiPipelineGraph.PipelineSource.TooltipSuffix=Can start without incoming 
hops (pipeline source).
 HopGuiPipelineGraph.TransformName.Tooltip=Click to edit this transform
+HopGuiPipelineGraph.TransformCopiesFinished.Tooltip=Copies finished: {0} of {1}
 PipelineGraph.DeprecatedTransform.Tooltip.Title=This is a deprecated transform 
and will be removed.
 PipelineGraph.Dialog.CouldntFindFields.Message=I couldn''t find any fields\!
 PipelineGraph.Dialog.CouldntFindFields.Title=Fields info

Reply via email to