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