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 1daf998807 Show the metrics of a workflow running on a server, fixes 
#3685 (#7543)
1daf998807 is described below

commit 1daf9988071660058426b6bc87056691231eddc2
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Thu Jul 16 17:19:02 2026 +0200

    Show the metrics of a workflow running on a server, fixes #3685 (#7543)
---
 .../org/apache/hop/core/gui/WorkflowTracker.java   |  70 ++++++++++++
 .../java/org/apache/hop/workflow/ActionResult.java |  63 +++++++++++
 .../engines/remote/RemoteWorkflowEngine.java       |  36 +++++-
 .../apache/hop/www/GetWorkflowStatusServlet.java   |   6 +
 .../apache/hop/www/HopServerWorkflowStatus.java    |  31 ++++++
 .../apache/hop/core/gui/WorkflowTrackerTest.java   |  96 ++++++++++++++++
 .../engines/remote/RemoteWorkflowEngineTest.java   | 123 +++++++++++++++++++++
 .../hop/www/HopServerWorkflowStatusTest.java       |  70 ++++++++++++
 8 files changed, 494 insertions(+), 1 deletion(-)

diff --git a/engine/src/main/java/org/apache/hop/core/gui/WorkflowTracker.java 
b/engine/src/main/java/org/apache/hop/core/gui/WorkflowTracker.java
index ceed48b485..6f48dc69b5 100644
--- a/engine/src/main/java/org/apache/hop/core/gui/WorkflowTracker.java
+++ b/engine/src/main/java/org/apache/hop/core/gui/WorkflowTracker.java
@@ -25,15 +25,24 @@ import java.util.concurrent.locks.ReentrantReadWriteLock;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.util.EnvUtil;
+import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.workflow.ActionResult;
 import org.apache.hop.workflow.WorkflowMeta;
 import org.apache.hop.workflow.action.ActionMeta;
+import org.w3c.dom.Node;
 
 /** Responsible for tracking the execution of a workflow as a hierarchy. */
 @Getter
 @Setter
 public class WorkflowTracker<T extends WorkflowMeta> {
+  public static final String XML_TAG = "workflow_tracker";
+
+  private static final String XML_TAG_TRACKERS = "workflow_trackers";
+  private static final String TAG_WORKFLOW_NAME = "workflow_name";
+  private static final String TAG_WORKFLOW_FILENAME = "workflow_filename";
+
   /**
    * The trackers for each individual action. Since we invoke 
LinkedList.removeFirst() there is no
    * sense in lurking the field behind the interface
@@ -101,6 +110,67 @@ public class WorkflowTracker<T extends WorkflowMeta> {
     this.result = result;
   }
 
+  /**
+   * Reads a tracker hierarchy back from the XML written by {@link #getXml()}. 
Used to show the
+   * execution of a remotely running workflow, where the tracker is maintained 
on the server.
+   *
+   * <p>This is a factory method rather than a constructor because a 
constructor taking a node would
+   * be ambiguous with {@link #WorkflowTracker(WorkflowMeta)} for a null 
argument.
+   *
+   * @param node the {@link #XML_TAG} node to read
+   * @return the tracker described by the node
+   */
+  public static WorkflowTracker<WorkflowMeta> fromXml(Node node) throws 
HopException {
+    WorkflowTracker<WorkflowMeta> tracker = new WorkflowTracker<>(null);
+
+    tracker.workflowName = XmlHandler.getTagValue(node, TAG_WORKFLOW_NAME);
+    tracker.workflowFilename = XmlHandler.getTagValue(node, 
TAG_WORKFLOW_FILENAME);
+
+    Node resultNode = XmlHandler.getSubNode(node, ActionResult.XML_TAG);
+    if (resultNode != null) {
+      tracker.result = new ActionResult(resultNode);
+    }
+
+    Node trackersNode = XmlHandler.getSubNode(node, XML_TAG_TRACKERS);
+    if (trackersNode != null) {
+      int nrTrackers = XmlHandler.countNodes(trackersNode, XML_TAG);
+      for (int i = 0; i < nrTrackers; i++) {
+        WorkflowTracker<WorkflowMeta> child =
+            fromXml(XmlHandler.getSubNodeByNr(trackersNode, XML_TAG, i));
+        child.setParentWorkflowTracker(tracker);
+        tracker.addWorkflowTracker(child);
+      }
+    }
+    return tracker;
+  }
+
+  /**
+   * Serializes this tracker and, recursively, the trackers of any child 
workflow. Child workflows
+   * are kept nested so that the hierarchy survives the trip from a workflow 
running on a server to
+   * the client showing it.
+   */
+  public String getXml() {
+    lock.readLock().lock();
+    try {
+      StringBuilder xml = new StringBuilder();
+      xml.append(XmlHandler.openTag(XML_TAG));
+      xml.append(XmlHandler.addTagValue(TAG_WORKFLOW_NAME, workflowName));
+      xml.append(XmlHandler.addTagValue(TAG_WORKFLOW_FILENAME, 
workflowFilename));
+      if (result != null) {
+        xml.append(result.getXml());
+      }
+      xml.append(XmlHandler.openTag(XML_TAG_TRACKERS));
+      for (WorkflowTracker workflowTracker : workflowTrackers) {
+        xml.append(workflowTracker.getXml());
+      }
+      xml.append(XmlHandler.closeTag(XML_TAG_TRACKERS));
+      xml.append(XmlHandler.closeTag(XML_TAG));
+      return xml.toString();
+    } finally {
+      lock.readLock().unlock();
+    }
+  }
+
   public void addWorkflowTracker(WorkflowTracker workflowTracker) {
     lock.writeLock().lock();
     try {
diff --git a/engine/src/main/java/org/apache/hop/workflow/ActionResult.java 
b/engine/src/main/java/org/apache/hop/workflow/ActionResult.java
index 3133d24bf7..800128ea12 100644
--- a/engine/src/main/java/org/apache/hop/workflow/ActionResult.java
+++ b/engine/src/main/java/org/apache/hop/workflow/ActionResult.java
@@ -21,7 +21,11 @@ import java.util.Comparator;
 import java.util.Date;
 import lombok.Getter;
 import lombok.Setter;
+import org.apache.hop.core.Const;
 import org.apache.hop.core.Result;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.xml.XmlHandler;
+import org.w3c.dom.Node;
 
 /**
  * This class holds the result of a action after it was executed. Things we 
want to keep track of
@@ -34,6 +38,18 @@ import org.apache.hop.core.Result;
 @Getter
 @Setter
 public class ActionResult implements Cloneable, Comparator<ActionResult>, 
Comparable<ActionResult> {
+  public static final String XML_TAG = "action_result";
+
+  private static final String TAG_ACTION_NAME = "action_name";
+  private static final String TAG_COMMENT = "comment";
+  private static final String TAG_REASON = "reason";
+  private static final String TAG_LOG_DATE = "log_date";
+  private static final String TAG_ACTION_FILENAME = "action_filename";
+  private static final String TAG_LOG_CHANNEL_ID = "log_channel_id";
+  private static final String TAG_CHECKPOINT = "checkpoint";
+  private static final String TAG_BYTES_READ = "bytes_read";
+  private static final String TAG_BYTES_WRITTEN = "bytes_written";
+
   private Result result;
   private String actionName;
 
@@ -100,6 +116,53 @@ public class ActionResult implements Cloneable, 
Comparator<ActionResult>, Compar
     this.bytesWritten = bytesWritten;
   }
 
+  /**
+   * Reads an action result back from the XML written by {@link #getXml()}.
+   *
+   * @param node the {@link #XML_TAG} node to read
+   */
+  public ActionResult(Node node) throws HopException {
+    actionName = XmlHandler.getTagValue(node, TAG_ACTION_NAME);
+    comment = XmlHandler.getTagValue(node, TAG_COMMENT);
+    reason = XmlHandler.getTagValue(node, TAG_REASON);
+    logDate = XmlHandler.stringToDate(XmlHandler.getTagValue(node, 
TAG_LOG_DATE));
+    actionFilename = XmlHandler.getTagValue(node, TAG_ACTION_FILENAME);
+    logChannelId = XmlHandler.getTagValue(node, TAG_LOG_CHANNEL_ID);
+    checkpoint = "Y".equalsIgnoreCase(XmlHandler.getTagValue(node, 
TAG_CHECKPOINT));
+    bytesRead = Const.toLong(XmlHandler.getTagValue(node, TAG_BYTES_READ), 0L);
+    bytesWritten = Const.toLong(XmlHandler.getTagValue(node, 
TAG_BYTES_WRITTEN), 0L);
+
+    // A result is only present once the action finished: the "start of 
action" entries carry none.
+    //
+    Node resultNode = XmlHandler.getSubNode(node, Result.XML_TAG);
+    if (resultNode != null) {
+      result = new Result(resultNode);
+    }
+  }
+
+  /**
+   * Serializes this action result, including the fields the workflow metrics 
view displays. The
+   * result itself is left out when the action has not finished yet.
+   */
+  public String getXml() {
+    StringBuilder xml = new StringBuilder();
+    xml.append(XmlHandler.openTag(XML_TAG));
+    xml.append(XmlHandler.addTagValue(TAG_ACTION_NAME, actionName));
+    xml.append(XmlHandler.addTagValue(TAG_COMMENT, comment));
+    xml.append(XmlHandler.addTagValue(TAG_REASON, reason));
+    xml.append(XmlHandler.addTagValue(TAG_LOG_DATE, 
XmlHandler.date2string(logDate)));
+    xml.append(XmlHandler.addTagValue(TAG_ACTION_FILENAME, actionFilename));
+    xml.append(XmlHandler.addTagValue(TAG_LOG_CHANNEL_ID, logChannelId));
+    xml.append(XmlHandler.addTagValue(TAG_CHECKPOINT, checkpoint));
+    xml.append(XmlHandler.addTagValue(TAG_BYTES_READ, bytesRead));
+    xml.append(XmlHandler.addTagValue(TAG_BYTES_WRITTEN, bytesWritten));
+    if (result != null) {
+      xml.append(result.getBasicXml());
+    }
+    xml.append(XmlHandler.closeTag(XML_TAG));
+    return xml.toString();
+  }
+
   @Override
   public Object clone() {
     try {
diff --git 
a/engine/src/main/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngine.java
 
b/engine/src/main/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngine.java
index 98e3666f60..31ce63a55f 100644
--- 
a/engine/src/main/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngine.java
+++ 
b/engine/src/main/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngine.java
@@ -214,7 +214,17 @@ public class RemoteWorkflowEngine extends Variables 
implements IWorkflowEngine<W
       loggingObject = new LoggingObject(this);
       logLevel = logChannel.getLogLevel();
 
-      workflowTracker = new WorkflowTracker(workflowMeta);
+      // Reset the tracker rather than replace it: the GUI picks up this 
instance right after
+      // execution starts and keeps reading from it while the workflow runs. 
Handing it a tracker
+      // that is thrown away here would leave it looking at an object nothing 
ever updates.
+      //
+      if (workflowTracker == null) {
+        workflowTracker = new WorkflowTracker(workflowMeta);
+      } else {
+        workflowTracker.clear();
+        workflowTracker.setWorkflowName(workflowMeta.getName());
+        workflowTracker.setWorkflowFilename(workflowMeta.getFilename());
+      }
 
       if (previousResult == null) {
         result = new Result();
@@ -357,11 +367,35 @@ public class RemoteWorkflowEngine extends Variables 
implements IWorkflowEngine<W
         }
       }
 
+      updateWorkflowTracker(workflowStatus.getWorkflowTracker());
+
     } catch (Exception e) {
       throw new HopException("Error getting workflow status", e);
     }
   }
 
+  /**
+   * Copies what the server reported into the tracker of this engine, keeping 
the tracker instance
+   * itself. The GUI holds on to that instance to show the workflow metrics, 
so it has to be updated
+   * in place rather than swapped out.
+   *
+   * @param serverTracker the tracker as reported by the server, null when the 
server does not send
+   *     one
+   */
+  private void updateWorkflowTracker(WorkflowTracker serverTracker) {
+    if (serverTracker == null) {
+      return;
+    }
+    workflowTracker.setWorkflowName(serverTracker.getWorkflowName());
+    workflowTracker.setWorkflowFilename(serverTracker.getWorkflowFilename());
+
+    List<WorkflowTracker> children = serverTracker.getWorkflowTrackers();
+    for (WorkflowTracker child : children) {
+      child.setParentWorkflowTracker(workflowTracker);
+    }
+    workflowTracker.setWorkflowTrackers(children);
+  }
+
   @Override
   public void stopExecution() {
     try {
diff --git 
a/engine/src/main/java/org/apache/hop/www/GetWorkflowStatusServlet.java 
b/engine/src/main/java/org/apache/hop/www/GetWorkflowStatusServlet.java
index f293959768..dcc26956c2 100644
--- a/engine/src/main/java/org/apache/hop/www/GetWorkflowStatusServlet.java
+++ b/engine/src/main/java/org/apache/hop/www/GetWorkflowStatusServlet.java
@@ -152,6 +152,12 @@ public class GetWorkflowStatusServlet extends 
BaseHttpServlet implements IHopSer
             workflowStatus.getActionStatusList().add(actionState);
           }
 
+          // Send the tracker along: it holds the start and end of every 
action, the comments,
+          // reasons and timings, and it nests the trackers of child 
workflows. A client that is not
+          // running the workflow itself has no other way to show its metrics.
+          //
+          workflowStatus.setWorkflowTracker(workflow.getWorkflowTracker());
+
           // The log can be quite large at times, we are going to putIfAbsent 
a base64 encoding
           // around a compressed
           // stream
diff --git 
a/engine/src/main/java/org/apache/hop/www/HopServerWorkflowStatus.java 
b/engine/src/main/java/org/apache/hop/www/HopServerWorkflowStatus.java
index 6608d3354e..1f20c87291 100644
--- a/engine/src/main/java/org/apache/hop/www/HopServerWorkflowStatus.java
+++ b/engine/src/main/java/org/apache/hop/www/HopServerWorkflowStatus.java
@@ -28,6 +28,7 @@ import lombok.Setter;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.Result;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.WorkflowTracker;
 import org.apache.hop.core.util.EnvUtil;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.xml.XmlHandler;
@@ -52,6 +53,15 @@ public class HopServerWorkflowStatus {
   @Getter @Setter private List<ActionStatus> actionStatusList;
   @Getter @Setter private Result result;
 
+  /**
+   * The tracker of the running workflow, holding a start and an end entry per 
executed action. This
+   * is what a client needs to show the workflow metrics of a workflow it is 
not running itself.
+   *
+   * <p>Deliberately kept out of the JSON representation: the tracker refers 
back to its parent and
+   * carries a lock, neither of which survives a trip through Jackson.
+   */
+  private WorkflowTracker workflowTracker;
+
   @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = 
"yyyy-MM-dd'T'HH:mm:ss.SSSZ")
   @Getter
   @Setter
@@ -83,6 +93,16 @@ public class HopServerWorkflowStatus {
     this.statusDescription = statusDescription;
   }
 
+  @JsonIgnore
+  public WorkflowTracker getWorkflowTracker() {
+    return workflowTracker;
+  }
+
+  @JsonIgnore
+  public void setWorkflowTracker(WorkflowTracker workflowTracker) {
+    this.workflowTracker = workflowTracker;
+  }
+
   @JsonIgnore
   public String getXml() throws HopException {
     boolean sendResultXmlWithStatus =
@@ -116,6 +136,10 @@ public class HopServerWorkflowStatus {
     }
     xml.append("  
").append(XmlHandler.closeTag(CONST_ACTION_STATUS)).append(Const.CR);
 
+    if (workflowTracker != null) {
+      xml.append(workflowTracker.getXml()).append(Const.CR);
+    }
+
     if (result != null) {
       String resultXML = sendResultXmlWithStatus ? result.getXml() : 
result.getBasicXml();
       xml.append(resultXML);
@@ -151,6 +175,13 @@ public class HopServerWorkflowStatus {
       actionStatusList.add(actionStatus);
     }
 
+    // Older servers do not send a tracker, in which case there is simply 
nothing to show.
+    //
+    Node workflowTrackerNode = XmlHandler.getSubNode(workflowStatusNode, 
WorkflowTracker.XML_TAG);
+    if (workflowTrackerNode != null) {
+      workflowTracker = WorkflowTracker.fromXml(workflowTrackerNode);
+    }
+
     String loggingString64 = XmlHandler.getTagValue(workflowStatusNode, 
"logging_string");
 
     if (!Utils.isEmpty(loggingString64)) {
diff --git 
a/engine/src/test/java/org/apache/hop/core/gui/WorkflowTrackerTest.java 
b/engine/src/test/java/org/apache/hop/core/gui/WorkflowTrackerTest.java
index c93a5c8306..be5f5b0322 100644
--- a/engine/src/test/java/org/apache/hop/core/gui/WorkflowTrackerTest.java
+++ b/engine/src/test/java/org/apache/hop/core/gui/WorkflowTrackerTest.java
@@ -23,7 +23,10 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
+import java.util.Date;
 import org.apache.hop.core.Const;
+import org.apache.hop.core.Result;
+import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
 import org.apache.hop.workflow.ActionResult;
 import org.apache.hop.workflow.WorkflowMeta;
@@ -97,6 +100,99 @@ class WorkflowTrackerTest {
     assertEquals(children[1], workflowTracker.findWorkflowTracker(actionMeta));
   }
 
+  /**
+   * A tracker is sent to clients that are not running the workflow 
themselves, so everything the
+   * workflow metrics view shows has to survive the round trip. See issue 
#3685.
+   */
+  @Test
+  void xmlRoundTripKeepsActionResults() throws Exception {
+    WorkflowTracker<WorkflowMeta> tracker = new 
WorkflowTracker<>((WorkflowMeta) null);
+    tracker.setWorkflowName("a-workflow");
+    tracker.setWorkflowFilename("/tmp/a-workflow.hwf");
+
+    // The start of an action carries no result yet, which is how the metrics 
view recognizes it.
+    ActionResult start = new ActionResult();
+    start.setActionName("an action");
+    start.setComment("Start of action");
+    start.setReason("Started by a hop");
+    start.setLogDate(new Date());
+    tracker.addWorkflowTracker(new WorkflowTracker<>((WorkflowMeta) null, 
start));
+
+    Result result = new Result();
+    result.setResult(true);
+    result.setEntryNr(1);
+    result.setElapsedTimeMillis(1234L);
+
+    ActionResult end = new ActionResult();
+    end.setActionName("an action");
+    end.setComment("Action finished");
+    end.setReason("Followed unconditional link");
+    end.setLogDate(new Date());
+    end.setActionFilename("/tmp/an-action.txt");
+    end.setLogChannelId("channel-1");
+    end.setBytesRead(11L);
+    end.setBytesWritten(22L);
+    end.setResult(result);
+    tracker.addWorkflowTracker(new WorkflowTracker<>((WorkflowMeta) null, 
end));
+
+    String xml = tracker.getXml();
+    WorkflowTracker<WorkflowMeta> copy =
+        WorkflowTracker.fromXml(
+            XmlHandler.getSubNode(XmlHandler.loadXmlString(xml), 
WorkflowTracker.XML_TAG));
+
+    assertEquals(xml, copy.getXml(), "The XML should match after rebuilding 
from XML");
+    assertEquals("a-workflow", copy.getWorkflowName());
+    assertEquals("/tmp/a-workflow.hwf", copy.getWorkflowFilename());
+    assertEquals(2, copy.nrWorkflowTrackers());
+
+    ActionResult copiedStart = copy.getWorkflowTracker(0).getActionResult();
+    assertEquals("an action", copiedStart.getActionName());
+    assertEquals("Start of action", copiedStart.getComment());
+    assertEquals("Started by a hop", copiedStart.getReason());
+    assertNull(copiedStart.getResult(), "A start of action must stay without a 
result");
+    assertEquals(start.getLogDate().toString(), 
copiedStart.getLogDate().toString());
+
+    ActionResult copiedEnd = copy.getWorkflowTracker(1).getActionResult();
+    assertEquals("Action finished", copiedEnd.getComment());
+    assertEquals("Followed unconditional link", copiedEnd.getReason());
+    assertEquals("/tmp/an-action.txt", copiedEnd.getActionFilename());
+    assertEquals("channel-1", copiedEnd.getLogChannelId());
+    assertEquals(11L, copiedEnd.getBytesRead());
+    assertEquals(22L, copiedEnd.getBytesWritten());
+    assertTrue(copiedEnd.getResult().isResult());
+    assertEquals(1, copiedEnd.getResult().getEntryNr());
+    assertEquals(1234L, copiedEnd.getResult().getElapsedTimeMillis());
+  }
+
+  /** Child workflows are nested in the tracker and the metrics view shows 
them that way. */
+  @Test
+  void xmlRoundTripKeepsChildWorkflows() throws Exception {
+    WorkflowTracker<WorkflowMeta> tracker = new 
WorkflowTracker<>((WorkflowMeta) null);
+    tracker.setWorkflowName("parent");
+
+    WorkflowTracker<WorkflowMeta> child = new WorkflowTracker<>((WorkflowMeta) 
null);
+    child.setWorkflowName("child");
+    ActionResult childResult = new ActionResult();
+    childResult.setActionName("child action");
+    child.addWorkflowTracker(new WorkflowTracker<>((WorkflowMeta) null, 
childResult));
+    tracker.addWorkflowTracker(child);
+
+    String xml = tracker.getXml();
+    WorkflowTracker<WorkflowMeta> copy =
+        WorkflowTracker.fromXml(
+            XmlHandler.getSubNode(XmlHandler.loadXmlString(xml), 
WorkflowTracker.XML_TAG));
+
+    assertEquals(xml, copy.getXml(), "The XML should match after rebuilding 
from XML");
+    assertEquals(1, copy.nrWorkflowTrackers());
+
+    WorkflowTracker copiedChild = copy.getWorkflowTracker(0);
+    assertEquals("child", copiedChild.getWorkflowName());
+    assertEquals(1, copiedChild.nrWorkflowTrackers());
+    assertEquals(
+        "child action", 
copiedChild.getWorkflowTracker(0).getActionResult().getActionName());
+    assertEquals(copy, copiedChild.getParentWorkflowTracker(), "The child 
should know its parent");
+  }
+
   private static WorkflowTracker createTracker() {
     return createTracker(null);
   }
diff --git 
a/engine/src/test/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngineTest.java
 
b/engine/src/test/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngineTest.java
new file mode 100644
index 0000000000..d8d38ce701
--- /dev/null
+++ 
b/engine/src/test/java/org/apache/hop/workflow/engines/remote/RemoteWorkflowEngineTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.workflow.engines.remote;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.hop.core.Result;
+import org.apache.hop.core.gui.WorkflowTracker;
+import org.apache.hop.workflow.ActionResult;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.apache.hop.www.HopServerWorkflowStatus;
+import org.apache.hop.www.RemoteHopServer;
+import org.junit.jupiter.api.Test;
+
+class RemoteWorkflowEngineTest {
+
+  private static final String WORKFLOW_NAME = "remote-workflow";
+  private static final String CONTAINER_ID = "container-1";
+
+  /**
+   * The workflow metrics view reads the tracker of the engine, so a workflow 
running on a server
+   * has to have the tracker it reports copied into the engine. See issue 
#3685.
+   */
+  @Test
+  void getWorkflowStatusFillsTheTracker() throws Exception {
+    RemoteWorkflowEngine engine = createEngine(createServerTracker());
+
+    engine.getWorkflowStatus();
+
+    WorkflowTracker tracker = engine.getWorkflowTracker();
+    assertEquals(WORKFLOW_NAME, tracker.getWorkflowName());
+    assertEquals(2, tracker.nrWorkflowTrackers());
+    assertEquals("an action", 
tracker.getWorkflowTracker(0).getActionResult().getActionName());
+    assertEquals("Start of action", 
tracker.getWorkflowTracker(0).getActionResult().getComment());
+    assertEquals("Action finished", 
tracker.getWorkflowTracker(1).getActionResult().getComment());
+  }
+
+  /**
+   * The tracker instance has to be kept: the GUI holds on to it from the 
moment the workflow starts
+   * and would otherwise keep reading an object that is never updated.
+   */
+  @Test
+  void getWorkflowStatusKeepsTheTrackerInstance() throws Exception {
+    RemoteWorkflowEngine engine = createEngine(createServerTracker());
+    WorkflowTracker trackerBefore = engine.getWorkflowTracker();
+
+    engine.getWorkflowStatus();
+
+    assertSame(trackerBefore, engine.getWorkflowTracker());
+    assertEquals(2, trackerBefore.nrWorkflowTrackers());
+  }
+
+  /** A server that does not report a tracker simply leaves the tracker of the 
engine alone. */
+  @Test
+  void getWorkflowStatusWithoutTrackerLeavesTrackerEmpty() throws Exception {
+    RemoteWorkflowEngine engine = createEngine(null);
+
+    engine.getWorkflowStatus();
+
+    assertEquals(0, engine.getWorkflowTracker().nrWorkflowTrackers());
+  }
+
+  private RemoteWorkflowEngine createEngine(WorkflowTracker<WorkflowMeta> 
serverTracker)
+      throws Exception {
+    RemoteWorkflowEngine engine = new RemoteWorkflowEngine();
+
+    WorkflowMeta workflowMeta = mock(WorkflowMeta.class);
+    when(workflowMeta.getName()).thenReturn(WORKFLOW_NAME);
+    engine.setWorkflowMeta(workflowMeta);
+    engine.containerId = CONTAINER_ID;
+
+    HopServerWorkflowStatus status =
+        new HopServerWorkflowStatus(WORKFLOW_NAME, CONTAINER_ID, "Finished");
+    status.setResult(new Result());
+    status.setWorkflowTracker(serverTracker);
+
+    RemoteHopServer hopServer = mock(RemoteHopServer.class);
+    when(hopServer.requestWorkflowStatus(any(), eq(WORKFLOW_NAME), 
eq(CONTAINER_ID), anyInt()))
+        .thenReturn(status);
+    engine.hopServer = hopServer;
+
+    return engine;
+  }
+
+  private WorkflowTracker<WorkflowMeta> createServerTracker() {
+    WorkflowTracker<WorkflowMeta> serverTracker = new 
WorkflowTracker<>((WorkflowMeta) null);
+    serverTracker.setWorkflowName(WORKFLOW_NAME);
+
+    ActionResult start = new ActionResult();
+    start.setActionName("an action");
+    start.setComment("Start of action");
+    serverTracker.addWorkflowTracker(new WorkflowTracker<>((WorkflowMeta) 
null, start));
+
+    ActionResult end = new ActionResult();
+    end.setActionName("an action");
+    end.setComment("Action finished");
+    end.setResult(new Result());
+    serverTracker.addWorkflowTracker(new WorkflowTracker<>((WorkflowMeta) 
null, end));
+
+    return serverTracker;
+  }
+}
diff --git 
a/engine/src/test/java/org/apache/hop/www/HopServerWorkflowStatusTest.java 
b/engine/src/test/java/org/apache/hop/www/HopServerWorkflowStatusTest.java
index e25c390818..7c539fb5e0 100644
--- a/engine/src/test/java/org/apache/hop/www/HopServerWorkflowStatusTest.java
+++ b/engine/src/test/java/org/apache/hop/www/HopServerWorkflowStatusTest.java
@@ -19,6 +19,7 @@ package org.apache.hop.www;
 
 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 java.io.IOException;
 import java.util.Arrays;
@@ -30,12 +31,16 @@ import java.util.UUID;
 import org.apache.hop.core.HopEnvironment;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopRuntimeException;
+import org.apache.hop.core.gui.WorkflowTracker;
+import org.apache.hop.core.json.HopJson;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.xml.XmlHandler;
 import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
 import org.apache.hop.pipeline.Pipeline;
 import 
org.apache.hop.pipeline.transforms.loadsave.validator.IFieldLoadSaveValidator;
 import org.apache.hop.server.HttpUtil;
+import org.apache.hop.workflow.ActionResult;
+import org.apache.hop.workflow.WorkflowMeta;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -103,6 +108,71 @@ class HopServerWorkflowStatusTest {
         "The \"log_date\" node should match the original value");
   }
 
+  /**
+   * The tracker is what a client needs to show the metrics of a workflow 
running on a server, so it
+   * has to travel along with the status. See issue #3685.
+   */
+  @Test
+  void trackerSurvivesXmlRoundTrip() throws HopException {
+    HopServerWorkflowStatus status =
+        new HopServerWorkflowStatus(
+            "tracked", UUID.randomUUID().toString(), Pipeline.STRING_RUNNING);
+
+    WorkflowTracker<WorkflowMeta> tracker = new 
WorkflowTracker<>((WorkflowMeta) null);
+    tracker.setWorkflowName("tracked");
+    ActionResult actionResult = new ActionResult();
+    actionResult.setActionName("an action");
+    actionResult.setComment("Start of action");
+    tracker.addWorkflowTracker(new WorkflowTracker<>((WorkflowMeta) null, 
actionResult));
+    status.setWorkflowTracker(tracker);
+
+    String xml = status.getXml();
+    HopServerWorkflowStatus copy = HopServerWorkflowStatus.fromXml(xml);
+
+    assertEquals(xml, copy.getXml(), "The XML document should match after 
rebuilding from XML");
+    assertEquals("tracked", copy.getWorkflowTracker().getWorkflowName());
+    assertEquals(1, copy.getWorkflowTracker().nrWorkflowTrackers());
+    assertEquals(
+        "an action",
+        
copy.getWorkflowTracker().getWorkflowTracker(0).getActionResult().getActionName());
+  }
+
+  /** A status without a tracker, as sent by an older server, rebuilds without 
one. */
+  @Test
+  void statusWithoutTrackerRoundTrips() throws HopException {
+    HopServerWorkflowStatus status =
+        new HopServerWorkflowStatus("plain", UUID.randomUUID().toString(), 
Pipeline.STRING_RUNNING);
+
+    HopServerWorkflowStatus copy = 
HopServerWorkflowStatus.fromXml(status.getXml());
+
+    assertNull(copy.getWorkflowTracker());
+  }
+
+  /**
+   * The status is also served as JSON. The tracker refers back to its parent 
and holds a lock, so
+   * it is left out there rather than breaking the whole document.
+   */
+  @Test
+  void trackerIsLeftOutOfJson() throws Exception {
+    HopServerWorkflowStatus status =
+        new HopServerWorkflowStatus(
+            "tracked", UUID.randomUUID().toString(), Pipeline.STRING_RUNNING);
+
+    WorkflowTracker<WorkflowMeta> tracker = new 
WorkflowTracker<>((WorkflowMeta) null);
+    tracker.setWorkflowName("tracked");
+    WorkflowTracker<WorkflowMeta> child = new WorkflowTracker<>((WorkflowMeta) 
null);
+    child.setParentWorkflowTracker(tracker);
+    tracker.addWorkflowTracker(child);
+    status.setWorkflowTracker(tracker);
+
+    String json = 
HopJson.newMapper().writerWithDefaultPrettyPrinter().writeValueAsString(status);
+
+    assertFalse(json.contains("workflowTracker"), "The tracker should not be 
part of the JSON");
+    assertEquals(
+        "tracked",
+        HopJson.newMapper().readValue(json, 
HopServerWorkflowStatus.class).getWorkflowName());
+  }
+
   @Test
   void testSerialization() throws HopException {
     //  Add Result

Reply via email to