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 219b57e421 Improve Hop Web performance, fixes #8435 (#8442)
219b57e421 is described below

commit 219b57e421a9a8892592373d3f07d37017bb70dc
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Sep 18 11:54:34 2026 +0200

    Improve Hop Web performance, fixes #8435 (#8442)
    
    * Improve Hop Web performance, fixes #8435
    
    * minor improvements
---
 docker/resources/server.xml                        |  73 ++++
 docker/unified.Dockerfile                          |   5 +-
 docker/web.Dockerfile                              |   2 +
 .../modules/ROOT/pages/hop-gui/hop-web.adoc        |  18 +
 .../java/org/apache/hop/pipeline/Pipeline.java     |   4 +-
 .../hop/pipeline/transform/TransformStatus.java    |  19 +-
 .../dialog/ContextDialogSvgRendererHandler.java    | 145 ++++++--
 .../hop/ui/core/widget/LogConsoleFacadeImpl.java   |  29 ++
 .../apache/hop/ui/core/widget/WebLogConsole.java   | 395 +++++++++++++++++++++
 .../hop/ui/core/widget/svg/SvgLabelFacadeImpl.java |  27 ++
 .../apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java  |  94 +++++
 .../main/java/org/apache/hop/ui/hopgui/HopWeb.java |   1 +
 .../org/apache/hop/ui/hopgui/HopWebEntryPoint.java |  14 +
 .../org/apache/hop/ui/hopgui/canvas-svg.js         |  97 ++++-
 .../org/apache/hop/ui/hopgui/dark-mode.css         |   9 +-
 .../org/apache/hop/ui/hopgui/light-mode.css        |   9 +-
 .../org/apache/hop/ui/hopgui/log-console.js        | 377 ++++++++++++++++++++
 .../hop/ui/core/widget/LogConsoleFacadeImpl.java   |  29 ++
 .../apache/hop/ui/core/dialog/ContextDialog.java   |  32 +-
 .../ui/core/dialog/ContextDialogSvgRenderer.java   |   5 +-
 .../org/apache/hop/ui/core/widget/ILogConsole.java |  57 +++
 .../hop/ui/core/widget/LogConsoleFacade.java       |  46 +++
 .../ui/hopgui/file/pipeline/HopGuiLogBrowser.java  |  64 +++-
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  11 +-
 .../delegates/HopGuiPipelineGridDelegate.java      |   4 +-
 .../delegates/HopGuiPipelineLogDelegate.java       |  20 +-
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  |  11 +-
 .../delegates/HopGuiWorkflowLogDelegate.java       |  20 +-
 .../ui/hopgui/messages/messages_en_US.properties   |   1 +
 29 files changed, 1520 insertions(+), 98 deletions(-)

diff --git a/docker/resources/server.xml b/docker/resources/server.xml
new file mode 100644
index 0000000000..f898073677
--- /dev/null
+++ b/docker/resources/server.xml
@@ -0,0 +1,73 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  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.
+-->
+<!--
+  Tomcat configuration of the apache/hop-web image (docker/web.Dockerfile and 
the web stage of
+  docker/unified.Dockerfile). It is the effective configuration of the 
tomcat:10 base image with one
+  change: response compression on the HTTP connector.
+
+  Hop Web talks to the browser in JSON, and the pipeline canvas and the 
transform selector are sent
+  as SVG documents that run to several hundred kB for a large pipeline or a 
full plugin list. gzip
+  takes those down 4-5x (issue #8435). image/svg+xml is added to Tomcat's 
default list because the
+  Hop Web icons are served as SVG resources.
+
+  The UserDatabase realm is what the documented Tomcat BASIC authentication 
(tomcat-users.xml) relies
+  on; keep it.
+-->
+<Server port="8005" shutdown="SHUTDOWN">
+  <Listener className="org.apache.catalina.startup.VersionLoggerListener" />
+  <Listener className="org.apache.catalina.core.AprLifecycleListener" />
+  <Listener 
className="org.apache.catalina.core.JreMemoryLeakPreventionListener" />
+  <Listener 
className="org.apache.catalina.mbeans.GlobalResourcesLifecycleListener" />
+  <Listener 
className="org.apache.catalina.core.ThreadLocalLeakPreventionListener" />
+
+  <GlobalNamingResources>
+    <Resource name="UserDatabase" auth="Container"
+              type="org.apache.catalina.UserDatabase"
+              description="User database that can be updated and saved"
+              factory="org.apache.catalina.users.MemoryUserDatabaseFactory"
+              pathname="conf/tomcat-users.xml" />
+  </GlobalNamingResources>
+
+  <Service name="Catalina">
+
+    <Connector port="8080" protocol="HTTP/1.1"
+               connectionTimeout="20000"
+               redirectPort="8443"
+               maxParameterCount="1000"
+               compression="on"
+               
compressibleMimeType="text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml,image/svg+xml"
+               />
+
+    <Engine name="Catalina" defaultHost="localhost">
+
+      <Realm className="org.apache.catalina.realm.LockOutRealm">
+        <Realm className="org.apache.catalina.realm.UserDatabaseRealm"
+               resourceName="UserDatabase"/>
+      </Realm>
+
+      <Host name="localhost" appBase="webapps"
+            unpackWARs="true" autoDeploy="true">
+
+        <Valve className="org.apache.catalina.valves.AccessLogValve" 
directory="logs"
+               prefix="localhost_access_log" suffix=".txt"
+               pattern="%h %l %u %t &quot;%r&quot; %s %b" />
+
+      </Host>
+    </Engine>
+  </Service>
+</Server>
diff --git a/docker/unified.Dockerfile b/docker/unified.Dockerfile
index 6424ea20f5..424c3340fc 100644
--- a/docker/unified.Dockerfile
+++ b/docker/unified.Dockerfile
@@ -245,7 +245,10 @@ RUN mkdir -p /build/hop-web-prepared/webapps/ROOT && \
     cp -r /build/assemblies/client/target/hop/lib/core/* 
/build/hop-web-prepared/webapps/ROOT/WEB-INF/lib/ && \
     rm /build/hop-web-prepared/webapps/ROOT/WEB-INF/lib/hop-ui-rcp* && \
     cp /build/docker/resources/run-web.sh /build/hop-web-prepared/run-web.sh 
&& \
-    chmod +x /build/hop-web-prepared/run-web.sh
+    chmod +x /build/hop-web-prepared/run-web.sh && \
+    # Tomcat configuration with response compression (see the comments in the 
file)
+    mkdir -p /build/hop-web-prepared/conf && \
+    cp /build/docker/resources/server.xml 
/build/hop-web-prepared/conf/server.xml
 
 # Make scripts executable
 RUN chmod +x /build/hop-web-prepared/webapps/ROOT/*.sh
diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile
index e1bf3b8592..b4feff6a6b 100644
--- a/docker/web.Dockerfile
+++ b/docker/web.Dockerfile
@@ -86,6 +86,8 @@ COPY ./assemblies/client/target/hop/lib/core 
"${CATALINA_HOME}"/webapps/ROOT/WEB
 COPY ./assemblies/client/target/hop/plugins "${CATALINA_HOME}"/plugins
 COPY ./assemblies/client/target/hop/lib/jdbc/ "${CATALINA_HOME}"/jdbc-drivers
 COPY --chown=hop ./docker/resources/run-web.sh /tmp/
+# Tomcat configuration with response compression (see the comments in the file)
+COPY ./docker/resources/server.xml "${CATALINA_HOME}"/conf/server.xml
 
 # Desktop (RCP) UI fragment must not ship in Hop Web. Client lib/core is 
shared with the
 # desktop assembly and includes hop-ui-rcp; loading its GuiPlugins (e.g. 
ContentEditorWidget)
diff --git a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
index 280eff2360..279b492f91 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/hop-web.adoc
@@ -149,6 +149,24 @@ The official image already includes the standard plugin 
set; extra JDBC drivers
 
 The full persist/replace table, Compose example, and upgrade steps are in the 
xref:hop-web-docker.adoc[Hop Web in Docker] page.
 
+== Response compression
+
+Hop Web talks to the browser in JSON, and the pipeline canvas and the 
transform selector are sent as SVG documents that run to several hundred kB for 
a large pipeline or a full plugin list.
+gzip shrinks these responses four to five times, which keeps the interface 
responsive on slower links.
+
+The `apache/hop-web` image enables compression on its Tomcat connector; its 
`conf/server.xml` is `docker/resources/server.xml` in the Hop source tree.
+If you deploy `hop.war` on your own Tomcat, add it to the HTTP connector in 
`conf/server.xml`; `image/svg+xml` is not in Tomcat's default list:
+
+[source,xml]
+----
+<Connector port="8080" protocol="HTTP/1.1"
+           compression="on"
+           
compressibleMimeType="text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml,image/svg+xml"
+           ... />
+----
+
+Other servlet containers and reverse proxies have an equivalent setting.
+
 == Using the Hop CLI tools in Hop Web.
 
 Hop Web contains the default xref:hop-tools/index.adoc[Hop tools] like 
xref:hop-tools/hop-conf/hop-conf.adoc[hop-conf], 
xref:hop-run/index.adoc[hop-run] etc.
diff --git a/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java 
b/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
index 6e5e6ba7e7..a01b0ee78a 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/Pipeline.java
@@ -3525,7 +3525,9 @@ public abstract class Pipeline
             }
             metrics.setComponentMetric(combi.transform, METRIC_BUFFER_OUT, 
outputBufferSize);
 
-            TransformStatus transformStatus = new 
TransformStatus(combi.transform);
+            // Only the speed is needed here: leave the transform's log text 
alone, it is the
+            // whole log formatted again on every call.
+            TransformStatus transformStatus = new 
TransformStatus(combi.transform, false);
             metrics.setComponentSpeed(combi.transform, 
transformStatus.getSpeed());
             metrics.setComponentStatus(
                 combi.transform, combi.transform.getStatus().getDescription());
diff --git 
a/engine/src/main/java/org/apache/hop/pipeline/transform/TransformStatus.java 
b/engine/src/main/java/org/apache/hop/pipeline/transform/TransformStatus.java
index be173a7c2b..78e1ada809 100644
--- 
a/engine/src/main/java/org/apache/hop/pipeline/transform/TransformStatus.java
+++ 
b/engine/src/main/java/org/apache/hop/pipeline/transform/TransformStatus.java
@@ -83,10 +83,23 @@ public class TransformStatus {
   }
 
   public TransformStatus(IEngineComponent component) {
-    updateAll(component);
+    this(component, true);
+  }
+
+  /**
+   * @param includeLogText whether to copy the component's log text as well. 
That text is the
+   *     component's complete log formatted from the central log buffer, which 
is expensive; the GUI
+   *     refreshes a status per transform every second and only needs the 
numbers.
+   */
+  public TransformStatus(IEngineComponent component, boolean includeLogText) {
+    updateAll(component, includeLogText);
   }
 
   public synchronized void updateAll(IEngineComponent component) {
+    updateAll(component, true);
+  }
+
+  public synchronized void updateAll(IEngineComponent component, boolean 
includeLogText) {
     // Proc: nr of lines processed: input + output!
 
     this.transformName = component.getName();
@@ -112,7 +125,9 @@ public class TransformStatus {
     this.errors = errors + component.getErrors();
     this.accumulatedRuntime = accumulatedRuntime + 
component.getExecutionDuration();
     this.statusDescription = component.getStatusDescription();
-    this.logText = component.getLogText();
+    if (includeLogText) {
+      this.logText = component.getLogText();
+    }
 
     long inProc = Math.max(linesInput, linesRead);
     long outProc = Math.max(linesOutput + linesUpdated, linesWritten + 
linesRejected);
diff --git 
a/rap/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRendererHandler.java
 
b/rap/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRendererHandler.java
index 2bfb22c2ab..33bd7ade1e 100644
--- 
a/rap/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRendererHandler.java
+++ 
b/rap/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRendererHandler.java
@@ -17,12 +17,11 @@
 
 package org.apache.hop.ui.core.dialog;
 
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
+import java.util.Objects;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.ui.hopgui.canvas.AreaOwnerJsonSerializer;
+import org.eclipse.rap.json.JsonArray;
 import org.eclipse.rap.rwt.RWT;
 import org.eclipse.rap.rwt.remote.Connection;
 import org.eclipse.rap.rwt.remote.RemoteObject;
@@ -30,11 +29,37 @@ import org.eclipse.rap.rwt.widgets.WidgetUtil;
 import org.eclipse.swt.graphics.Rectangle;
 import org.eclipse.swt.widgets.Canvas;
 
-/** Manages the client-side SVG renderer remote object for Hop Web 
ContextDialog. */
+/**
+ * Publishes the {@link ContextDialog} item list to the browser as one SVG 
document (see
+ * context-dialog-svg.js).
+ *
+ * <p>RAP has no paint coalescing: every {@code Canvas.redraw()} and every 
resize of the canvas
+ * fires {@code SWT.Paint} synchronously. Opening the dialog paints a dozen 
times in a single
+ * request ({@code filter()} + {@code selectItem()}, then RAP's text-size 
measurement enlarging and
+ * restoring every shell by 1000px), and rendering a few hundred icons to SVG 
is expensive enough
+ * that on a slow host each of those requests exceeded RAP's 1s wait-hint 
timeout and greyed out the
+ * whole UI (issue #8435). So a paint only <em>schedules</em> a render: one 
{@code asyncExec} per
+ * request, executed once the event queue has drained and before the response 
is written. What it
+ * produces is only sent to the client when it differs from what the client 
already has, so the
+ * measurement round trips that follow the open carry no SVG at all.
+ *
+ * <p>All state lives on the canvas widget itself: RAP widget ids are per 
session, so a static map
+ * keyed by id would let two users' dialogs overwrite each other.
+ */
 public final class ContextDialogSvgRendererHandler {
 
-  private static final Map<String, RemoteObject> remoteObjects = new 
ConcurrentHashMap<>();
-  private static final Set<String> renderingCanvases = 
ConcurrentHashMap.newKeySet();
+  private static final String DATA_KEY_STATE = 
ContextDialogSvgRendererHandler.class.getName();
+
+  /** What this canvas has on the client, and whether a render is pending or 
running. */
+  private static final class State {
+    private RemoteObject remoteObject;
+    private boolean renderScheduled;
+    private boolean rendering;
+    private int publishedContentHeight = -1;
+    private String publishedSvg;
+    private String publishedAreas;
+    private String publishedTooltip;
+  }
 
   private ContextDialogSvgRendererHandler() {}
 
@@ -43,29 +68,32 @@ public final class ContextDialogSvgRendererHandler {
       return;
     }
     try {
-      String canvasId = WidgetUtil.getId(canvas);
+      State state = getOrCreateState(canvas);
+      if (state.remoteObject != null) {
+        return;
+      }
       Connection connection = RWT.getUISession().getConnection();
       RemoteObject remoteObject = 
connection.createRemoteObject("hop.ContextDialogSvgRenderer");
-      remoteObject.set("canvasId", canvasId);
+      remoteObject.set("canvasId", WidgetUtil.getId(canvas));
       if (dialog.getTooltipLabel() != null && 
!dialog.getTooltipLabel().isDisposed()) {
         remoteObject.set("tooltipId", 
WidgetUtil.getId(dialog.getTooltipLabel()));
       }
-      remoteObjects.put(canvasId, remoteObject);
+      state.remoteObject = remoteObject;
     } catch (Exception e) {
       LogChannel.UI.logError("Failed to register ContextDialog SVG renderer 
RemoteObject", e);
     }
   }
 
   public static void unregister(Canvas canvas) {
-    if (canvas == null) {
+    if (canvas == null || canvas.isDisposed()) {
       return;
     }
     try {
-      String canvasId = WidgetUtil.getId(canvas);
-      renderingCanvases.remove(canvasId);
-      RemoteObject remoteObject = remoteObjects.remove(canvasId);
-      if (remoteObject != null) {
-        remoteObject.destroy();
+      State state = (State) canvas.getData(DATA_KEY_STATE);
+      canvas.setData(DATA_KEY_STATE, null);
+      if (state != null && state.remoteObject != null) {
+        state.remoteObject.destroy();
+        state.remoteObject = null;
       }
     } catch (Exception e) {
       LogChannel.UI.logDebug(
@@ -73,21 +101,43 @@ public final class ContextDialogSvgRendererHandler {
     }
   }
 
+  /**
+   * Called from every {@code SWT.Paint} of the dialog canvas. Schedules a 
single render for the
+   * current request instead of rendering on the spot.
+   */
   public static void renderAndPublish(Canvas canvas, ContextDialog dialog) {
     if (canvas == null || canvas.isDisposed() || dialog == null) {
       return;
     }
-    String canvasId = WidgetUtil.getId(canvas);
-    if (!renderingCanvases.add(canvasId)) {
+    State state = getOrCreateState(canvas);
+    // A paint caused by our own render (the canvas is resized to the content 
height) or one that
+    // is already covered by a scheduled render: nothing to do.
+    if (state.rendering || state.renderScheduled) {
       return;
     }
+    state.renderScheduled = true;
+    canvas
+        .getDisplay()
+        .asyncExec(
+            () -> {
+              state.renderScheduled = false;
+              // Unregistered (dialog closing) in the meantime: the state is 
gone from the canvas.
+              if (canvas.isDisposed()
+                  || dialog.isDisposed()
+                  || canvas.getData(DATA_KEY_STATE) != state) {
+                return;
+              }
+              render(canvas, dialog, state);
+            });
+  }
+
+  private static void render(Canvas canvas, ContextDialog dialog, State state) 
{
+    state.rendering = true;
     try {
-      RemoteObject remoteObject = remoteObjects.get(canvasId);
-      if (remoteObject == null) {
+      if (state.remoteObject == null) {
         register(canvas, dialog);
-        remoteObject = remoteObjects.get(canvasId);
       }
-      if (remoteObject == null) {
+      if (state.remoteObject == null) {
         return;
       }
 
@@ -109,6 +159,8 @@ public final class ContextDialogSvgRendererHandler {
       dialog.setAreaOwners(result.areaOwners());
       dialog.updateContentHeight(result.totalContentHeight());
 
+      // Applying the content height can show or hide the vertical scrollbar, 
which changes the
+      // width the icons flow into: render once more at the final width.
       if (dialog.getScrolledComposite() != null && 
!dialog.getScrolledComposite().isDisposed()) {
         Rectangle newArea = dialog.getScrolledComposite().getClientArea();
         if (newArea.width > 0 && newArea.width != areaWidth) {
@@ -119,19 +171,50 @@ public final class ContextDialogSvgRendererHandler {
         }
       }
 
-      remoteObject.set("contentHeight", result.totalContentHeight());
-      remoteObject.set("svg", result.svg());
-      remoteObject.set("areas", 
AreaOwnerJsonSerializer.toJsonArray(result.areaOwners()));
-      if (dialog.getSelectedItem() != null && 
dialog.getSelectedItem().getAction() != null) {
-        remoteObject.set(
-            "selectedTooltip", 
Const.NVL(dialog.getSelectedItem().getAction().getTooltip(), ""));
-      } else {
-        remoteObject.set("selectedTooltip", "");
-      }
+      publish(state, result, selectedTooltip(dialog));
     } catch (Exception e) {
       LogChannel.UI.logError("Failed to render and publish ContextDialog SVG", 
e);
     } finally {
-      renderingCanvases.remove(canvasId);
+      state.rendering = false;
+    }
+  }
+
+  /** Sends only the properties that changed since the client last received 
them. */
+  private static void publish(State state, ContextDialogSvgRenderResult 
result, String tooltip) {
+    RemoteObject remoteObject = state.remoteObject;
+    if (result.totalContentHeight() != state.publishedContentHeight) {
+      remoteObject.set("contentHeight", result.totalContentHeight());
+      state.publishedContentHeight = result.totalContentHeight();
+    }
+    if (!Objects.equals(result.svg(), state.publishedSvg)) {
+      remoteObject.set("svg", result.svg());
+      state.publishedSvg = result.svg();
+    }
+    JsonArray areas = AreaOwnerJsonSerializer.toJsonArray(result.areaOwners());
+    String areasJson = areas.toString();
+    if (!areasJson.equals(state.publishedAreas)) {
+      remoteObject.set("areas", areas);
+      state.publishedAreas = areasJson;
+    }
+    if (!tooltip.equals(state.publishedTooltip)) {
+      remoteObject.set("selectedTooltip", tooltip);
+      state.publishedTooltip = tooltip;
+    }
+  }
+
+  private static String selectedTooltip(ContextDialog dialog) {
+    if (dialog.getSelectedItem() != null && 
dialog.getSelectedItem().getAction() != null) {
+      return Const.NVL(dialog.getSelectedItem().getAction().getTooltip(), "");
+    }
+    return "";
+  }
+
+  private static State getOrCreateState(Canvas canvas) {
+    State state = (State) canvas.getData(DATA_KEY_STATE);
+    if (state == null) {
+      state = new State();
+      canvas.setData(DATA_KEY_STATE, state);
     }
+    return state;
   }
 }
diff --git 
a/rap/src/main/java/org/apache/hop/ui/core/widget/LogConsoleFacadeImpl.java 
b/rap/src/main/java/org/apache/hop/ui/core/widget/LogConsoleFacadeImpl.java
new file mode 100644
index 0000000000..700089e646
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/core/widget/LogConsoleFacadeImpl.java
@@ -0,0 +1,29 @@
+/*
+ * 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.ui.core.widget;
+
+import org.eclipse.swt.widgets.Composite;
+
+/** Hop Web: the incremental log console backed by log-console.js. */
+public class LogConsoleFacadeImpl extends LogConsoleFacade {
+
+  @Override
+  TextComposite createInternal(Composite parent, int style) {
+    return new WebLogConsole(parent, style);
+  }
+}
diff --git a/rap/src/main/java/org/apache/hop/ui/core/widget/WebLogConsole.java 
b/rap/src/main/java/org/apache/hop/ui/core/widget/WebLogConsole.java
new file mode 100644
index 0000000000..48282ac4e6
--- /dev/null
+++ b/rap/src/main/java/org/apache/hop/ui/core/widget/WebLogConsole.java
@@ -0,0 +1,395 @@
+/*
+ * 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.ui.core.widget;
+
+import java.util.ArrayDeque;
+import java.util.List;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.variables.DescribedVariable;
+import org.apache.hop.ui.core.FormDataBuilder;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.eclipse.rap.json.JsonArray;
+import org.eclipse.rap.json.JsonObject;
+import org.eclipse.rap.json.JsonValue;
+import org.eclipse.rap.rwt.RWT;
+import org.eclipse.rap.rwt.remote.AbstractOperationHandler;
+import org.eclipse.rap.rwt.remote.Connection;
+import org.eclipse.rap.rwt.remote.RemoteObject;
+import org.eclipse.rap.rwt.widgets.WidgetUtil;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.MenuDetectEvent;
+import org.eclipse.swt.events.MenuDetectListener;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.graphics.Color;
+import org.eclipse.swt.graphics.Font;
+import org.eclipse.swt.graphics.FontData;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Menu;
+
+/**
+ * The Hop Web log view: a browser-side console (log-console.js) that receives 
only the lines added
+ * since the last request.
+ *
+ * <p>The plain Text this replaces was rewritten in full on every append, and 
RAP then re-sent the
+ * whole log: with the default unbounded log size a pipeline that logged 30k 
rows pushed 4-17 MB per
+ * second to the browser and every request took over a second (issue #8435). 
Here a request carries
+ * the new lines only, and both ends keep no more than {@link #getMaxLines()} 
of them.
+ *
+ * <p>The text is also kept on the server so the log toolbar keeps working: 
copy all, show errors
+ * and the filter rebuild read {@link #getText()}. Selection is the browser's 
own; what the user
+ * selected is reported back so "copy selection" works, capped so a select-all 
does not upload the
+ * whole log again.
+ */
+public class WebLogConsole extends TextComposite implements ILogConsole {
+
+  /** Lines kept when {@code HOP_MAX_LOG_SIZE_IN_LINES} says "keep everything" 
(0). */
+  public static final int DEFAULT_MAX_LINES = 20000;
+
+  /** Longest selection the browser reports back, in characters. */
+  static final int MAX_REPORTED_SELECTION = 64 * 1024;
+
+  /**
+   * Lines one refresh may add. At row level a pipeline logs tens of thousands 
of lines a second;
+   * shipping them all cost a 2.8 s request of 18 MB per second and the 
browser dropped all but the
+   * last {@link #DEFAULT_MAX_LINES} of them on arrival.
+   */
+  static final int MAX_LINES_PER_REFRESH = 5000;
+
+  private static final String REMOTE_TYPE = "hop.LogConsole";
+
+  /** The composite the browser-side console is mounted in; it gets the font, 
colours and menu. */
+  private final Composite host;
+
+  private final RemoteObject remoteObject;
+  private final ArrayDeque<String> lines = new ArrayDeque<>();
+  private final int maxLines;
+  private int charCount;
+  private String selectionText = "";
+  private boolean editable;
+
+  public WebLogConsole(Composite parent, int style) {
+    super(parent, SWT.NONE, true, TextComposite.STYLE_TYPE_LOG);
+    maxLines = configuredMaxLines();
+
+    host = new Composite(this, style & SWT.BORDER);
+    PropsUi.setLook(host);
+    Control top = getTopControl();
+    FormDataBuilder layout = FormDataBuilder.builder().fullWidth().bottom();
+    host.setLayoutData((top != null ? layout.top(top) : layout.top()).build());
+
+    Connection connection = RWT.getUISession().getConnection();
+    remoteObject = connection.createRemoteObject(REMOTE_TYPE);
+    remoteObject.set("parent", WidgetUtil.getId(host));
+    remoteObject.set("maxLines", maxLines);
+    remoteObject.set("maxSelection", MAX_REPORTED_SELECTION);
+    // getText() joins lines with the platform separator, like the desktop 
widget; the browser
+    // needs its length to map the offsets that setSelection() sends.
+    remoteObject.set("separatorLength", Const.CR.length());
+    remoteObject.setHandler(
+        new AbstractOperationHandler() {
+          @Override
+          public void handleNotify(String event, JsonObject properties) {
+            if ("selectionChanged".equals(event)) {
+              JsonValue value = properties.get("text");
+              selectionText = value != null && value.isString() ? 
value.asString() : "";
+              updateToolbar();
+            }
+          }
+        });
+    remoteObject.listen("selectionChanged", true);
+    remoteObject.set("color", toCss(host.getForeground()));
+    host.addListener(SWT.Dispose, event -> remoteObject.destroy());
+  }
+
+  /** The configured maximum, or {@link #DEFAULT_MAX_LINES} when the log store 
is unbounded. */
+  static int configuredMaxLines() {
+    DescribedVariable variable =
+        
HopConfig.getInstance().findDescribedVariable(Const.HOP_MAX_LOG_SIZE_IN_LINES);
+    int configured =
+        variable == null
+            ? Const.MAX_NR_LOG_LINES
+            : Const.toInt(variable.getValue(), Const.MAX_NR_LOG_LINES);
+    return configured > 0 ? configured : DEFAULT_MAX_LINES;
+  }
+
+  public int getMaxLines() {
+    return maxLines;
+  }
+
+  @Override
+  public int getMaxLinesPerRefresh() {
+    return Math.min(maxLines, MAX_LINES_PER_REFRESH);
+  }
+
+  // ILogConsole
+
+  @Override
+  public void appendLines(List<Line> newLines) {
+    if (newLines == null || newLines.isEmpty() || isDisposed()) {
+      return;
+    }
+    JsonArray texts = new JsonArray();
+    JsonArray errors = new JsonArray();
+    for (Line line : newLines) {
+      String text = Const.NVL(line.text(), "");
+      lines.addLast(text);
+      charCount += text.length() + Const.CR.length();
+      texts.add(text);
+      errors.add(line.error());
+    }
+    while (lines.size() > maxLines) {
+      charCount -= lines.removeFirst().length() + Const.CR.length();
+    }
+    JsonObject parameters = new JsonObject();
+    parameters.add("lines", texts);
+    parameters.add("errors", errors);
+    remoteObject.call("append", parameters);
+  }
+
+  @Override
+  public void clear() {
+    lines.clear();
+    charCount = 0;
+    selectionText = "";
+    if (!isDisposed()) {
+      remoteObject.call("clear", null);
+    }
+  }
+
+  @Override
+  public void setHighlight(String term, boolean caseSensitive) {
+    if (isDisposed()) {
+      return;
+    }
+    remoteObject.set("highlight", Const.NVL(term, ""));
+    remoteObject.set("highlightCaseSensitive", caseSensitive);
+  }
+
+  // TextComposite
+
+  /**
+   * The lines joined with the platform separator, as the desktop widget holds 
them: the log
+   * delegates split on {@code Const.CR} ("show error lines"), which is CRLF 
on a Windows server.
+   */
+  @Override
+  public String getText() {
+    if (lines.isEmpty()) {
+      return "";
+    }
+    StringBuilder builder = new StringBuilder(charCount);
+    for (String line : lines) {
+      builder.append(line).append(Const.CR);
+    }
+    return builder.toString();
+  }
+
+  /** Replaces everything; the log browser uses this to empty the view before 
a rebuild. */
+  @Override
+  public void setText(String text) {
+    clear();
+    if (text == null || text.isBlank()) {
+      return;
+    }
+    List<Line> newLines =
+        text.lines().filter(line -> !line.isEmpty()).map(line -> new 
Line(line, false)).toList();
+    appendLines(newLines);
+  }
+
+  @Override
+  public void insert(String string) {
+    if (string != null && !string.isEmpty()) {
+      appendLines(
+          string
+              .lines()
+              .filter(line -> !line.isEmpty())
+              .map(line -> new Line(line, false))
+              .toList());
+    }
+  }
+
+  @Override
+  public int getCharCount() {
+    return charCount;
+  }
+
+  @Override
+  public String getSelectionText() {
+    return selectionText;
+  }
+
+  @Override
+  public int getSelectionCount() {
+    return selectionText.length();
+  }
+
+  @Override
+  public void setSelection(int start) {
+    setSelection(start, start);
+  }
+
+  @Override
+  public void setSelection(int start, int end) {
+    if (isDisposed()) {
+      return;
+    }
+    JsonObject parameters = new JsonObject();
+    parameters.add("start", Math.max(0, Math.min(start, end)));
+    parameters.add("end", Math.max(0, Math.max(start, end)));
+    remoteObject.call("select", parameters);
+  }
+
+  @Override
+  public int getCaretPosition() {
+    return charCount;
+  }
+
+  @Override
+  public void setCaretPosition(int position) {
+    setSelection(position);
+  }
+
+  @Override
+  public void selectAll() {
+    setSelection(0, charCount);
+  }
+
+  @Override
+  public void copy() {
+    String text = selectionText.isEmpty() ? getText() : selectionText;
+    if (!text.isEmpty()) {
+      GuiResource.getInstance().toClipboard(text);
+    }
+  }
+
+  @Override
+  public void cut() {
+    // read-only
+  }
+
+  @Override
+  public void paste() {
+    // read-only
+  }
+
+  @Override
+  public boolean isEditable() {
+    return editable;
+  }
+
+  @Override
+  public void setEditable(boolean editable) {
+    this.editable = editable;
+  }
+
+  @Override
+  public void addModifyListener(ModifyListener listener) {
+    // nothing is typed here
+  }
+
+  @Override
+  public void addLineStyleListener() {
+    // the console colours error lines itself
+  }
+
+  @Override
+  public void addLineStyleListener(List<String> keywords) {
+    // the console colours error lines itself
+  }
+
+  @Override
+  public void addMenuDetectListener(MenuDetectListener listener) {
+    host.addListener(SWT.MenuDetect, event -> listener.menuDetected(new 
MenuDetectEvent(event)));
+  }
+
+  @Override
+  public void setMenu(Menu menu) {
+    host.setMenu(menu);
+  }
+
+  @Override
+  public void addListener(int eventType, org.eclipse.swt.widgets.Listener 
listener) {
+    host.addListener(eventType, listener);
+  }
+
+  @Override
+  public void addMouseListener(org.eclipse.swt.events.MouseListener listener) {
+    host.addMouseListener(listener);
+  }
+
+  @Override
+  public boolean setFocus() {
+    return host.setFocus();
+  }
+
+  /**
+   * RAP does not put a composite's font on its element, so the console is 
told explicitly; this is
+   * what makes the log toolbar's zoom buttons work.
+   */
+  @Override
+  public void setFont(Font font) {
+    super.setFont(font);
+    host.setFont(font);
+    if (font != null && !font.isDisposed() && !isDisposed()) {
+      remoteObject.set("font", toCss(font.getFontData()[0]));
+    }
+  }
+
+  /** A CSS font shorthand for a font description; RAP sizes are pixels. */
+  static String toCss(FontData fontData) {
+    StringBuilder css = new StringBuilder();
+    if ((fontData.getStyle() & SWT.ITALIC) != 0) {
+      css.append("italic ");
+    }
+    if ((fontData.getStyle() & SWT.BOLD) != 0) {
+      css.append("bold ");
+    }
+    css.append(Math.max(1, fontData.getHeight())).append("px ");
+    String family = Const.NVL(fontData.getName(), "").replace("\"", "");
+    if (!family.isEmpty()) {
+      css.append('"').append(family).append("\", ");
+    }
+    css.append("monospace");
+    return css.toString();
+  }
+
+  @Override
+  public void setBackground(Color color) {
+    super.setBackground(color);
+    host.setBackground(color);
+  }
+
+  /** Like the font, the text colour has to be told to the console (dark 
mode). */
+  @Override
+  public void setForeground(Color color) {
+    super.setForeground(color);
+    host.setForeground(color);
+    if (color != null && !color.isDisposed() && !isDisposed()) {
+      remoteObject.set("color", toCss(color));
+    }
+  }
+
+  static String toCss(Color color) {
+    if (color == null || color.isDisposed()) {
+      return "";
+    }
+    return "rgb(" + color.getRed() + "," + color.getGreen() + "," + 
color.getBlue() + ")";
+  }
+}
diff --git 
a/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacadeImpl.java 
b/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacadeImpl.java
index 6772449618..128fe4bde2 100644
--- 
a/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacadeImpl.java
+++ 
b/rap/src/main/java/org/apache/hop/ui/core/widget/svg/SvgLabelFacadeImpl.java
@@ -34,6 +34,17 @@ import org.eclipse.swt.widgets.ToolItem;
 
 public class SvgLabelFacadeImpl extends SvgLabelFacade {
 
+  /**
+   * The enabled / shaded state the client was last told about, kept on the 
label. The toolbars are
+   * re-evaluated on every updateGui() - once a second while a pipeline runs, 
and again for every
+   * event in between - and each evaluation used to emit one JavaScript 
snippet per toolbar item
+   * whether or not anything changed. Those calls do not coalesce, so a busy 
request shipped
+   * thousands of them (1.6 MB of JavaScript for a 40-item toolbar, see issue 
#8435).
+   */
+  private static final String DATA_KEY_ENABLED = 
SvgLabelFacadeImpl.class.getName() + ".enabled";
+
+  private static final String DATA_KEY_SHADED = 
SvgLabelFacadeImpl.class.getName() + ".shaded";
+
   /**
    * Build a data URI for the SVG with dark-mode colors applied using the same 
color-contrasting map
    * that the desktop uses in SwtUniversalImageSvg. Returns null if the SVG is 
not in the cache.
@@ -80,6 +91,8 @@ public class SvgLabelFacadeImpl extends SvgLabelFacade {
       jsonProps.add("id", id);
       jsonProps.add("enabled", true);
       label.setData("props", jsonProps);
+      label.setData(DATA_KEY_ENABLED, Boolean.TRUE);
+      label.setData(DATA_KEY_SHADED, Boolean.FALSE);
     } catch (Exception e) {
       System.err.println(
           "Error setting internal data on tool-item " + id + " label for 
filename: " + imageFile);
@@ -89,6 +102,13 @@ public class SvgLabelFacadeImpl extends SvgLabelFacade {
 
   @Override
   public void enableInternal(ToolItem toolItem, String id, Label label, 
boolean enable) {
+    if (label == null
+        || label.isDisposed()
+        || Boolean.valueOf(enable).equals(label.getData(DATA_KEY_ENABLED))) {
+      return;
+    }
+    label.setData(DATA_KEY_ENABLED, enable);
+
     // Show/Hide the label
     // This causes an event in svg-label.js
     //
@@ -114,6 +134,13 @@ public class SvgLabelFacadeImpl extends SvgLabelFacade {
 
   @Override
   public void shadeSvgInternal(Label label, String id, boolean shaded) {
+    if (label == null
+        || label.isDisposed()
+        || Boolean.valueOf(shaded).equals(label.getData(DATA_KEY_SHADED))) {
+      return;
+    }
+    label.setData(DATA_KEY_SHADED, shaded);
+
     String color;
     if (shaded) {
       color = "'rgb(180,180,180)'";
diff --git 
a/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
index 622824c2ac..8368cea113 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/CanvasSvgFacadeImpl.java
@@ -40,8 +40,30 @@ import org.eclipse.rap.rwt.widgets.WidgetUtil;
 import org.eclipse.swt.widgets.Canvas;
 import org.eclipse.swt.widgets.Composite;
 
+/**
+ * Hop Web implementation: the graph is rendered to SVG on the server and 
fetched by the browser.
+ *
+ * <p>RAP paints synchronously on every {@code redraw()} and every resize, and 
the graph classes
+ * call {@code redraw()} liberally (mouse down, mouse up, updateGui, resize, 
...). On top of that,
+ * RAP's text-size measurement enlarges and restores every shell by 1000px 
after each new dialog,
+ * which resizes every open graph tab twice. A single click on a transform in 
a 120-transform
+ * pipeline rendered the SVG a dozen times per request (issue #8435). So a 
paint does not render: it
+ * schedules one repaint that runs once the request's event queue has drained, 
and only that repaint
+ * renders. Canvases that are not on screen (background tabs) are not rendered 
at all; the tab's
+ * Show/Resize listeners repaint them when they come to the front.
+ */
 public class CanvasSvgFacadeImpl extends CanvasSvgFacade {
 
+  private static final String DATA_KEY_RENDER_STATE =
+      CanvasSvgFacadeImpl.class.getName() + ".renderState";
+
+  /** Paint coalescing state, kept on the canvas widget (RAP widget ids are 
per session). */
+  private static final class RenderState {
+    private boolean repaintScheduled;
+    private boolean repainting;
+    private CanvasSvgRenderResult lastResult;
+  }
+
   @Override
   void registerCanvasInternal(Canvas canvas, Object graph) {
     String canvasId = WidgetUtil.getId(canvas);
@@ -62,9 +84,14 @@ public class CanvasSvgFacadeImpl extends CanvasSvgFacade {
       PipelineCanvasSvgRenderer.Context context,
       float magnification,
       DPoint offset) {
+    RenderState state = renderState(canvas);
+    if (!shouldRenderNow(canvas, state)) {
+      return state.lastResult;
+    }
     try {
       CanvasSvgRenderResult result = PipelineCanvasSvgRenderer.render(context);
       publishSnapshotInternal(canvas, result, magnification, offset, 
context.canvasSize);
+      state.lastResult = result;
       return result;
     } catch (HopException e) {
       LogChannel.UI.logError("Failed to render pipeline SVG for web canvas", 
e);
@@ -78,9 +105,14 @@ public class CanvasSvgFacadeImpl extends CanvasSvgFacade {
       WorkflowCanvasSvgRenderer.Context context,
       float magnification,
       DPoint offset) {
+    RenderState state = renderState(canvas);
+    if (!shouldRenderNow(canvas, state)) {
+      return state.lastResult;
+    }
     try {
       CanvasSvgRenderResult result = WorkflowCanvasSvgRenderer.render(context);
       publishSnapshotInternal(canvas, result, magnification, offset, 
context.canvasSize);
+      state.lastResult = result;
       return result;
     } catch (HopException e) {
       LogChannel.UI.logError("Failed to render workflow SVG for web canvas", 
e);
@@ -88,6 +120,68 @@ public class CanvasSvgFacadeImpl extends CanvasSvgFacade {
     }
   }
 
+  /**
+   * Decides whether the paint that called us renders. Only the coalesced 
repaint scheduled below
+   * does, and only for a canvas that is on screen; any other paint just makes 
sure that repaint is
+   * scheduled and gets the previous result back, which is what the graph 
already holds.
+   */
+  private static boolean shouldRenderNow(Canvas canvas, RenderState state) {
+    if (state.repainting) {
+      return canvas.isVisible();
+    }
+    if (!state.repaintScheduled) {
+      state.repaintScheduled = true;
+      // Runs after the events and RAP-internal actions of this request, 
before the response is
+      // written: RAP's Display.readAndDispatch drains async runnables inside 
the same request.
+      canvas.getDisplay().asyncExec(new CoalescedRepaint(canvas, state));
+    }
+    return false;
+  }
+
+  /** The one repaint of a request that actually renders. */
+  private static final class CoalescedRepaint implements Runnable {
+    private final Canvas canvas;
+    private final RenderState state;
+    private boolean yielded;
+
+    private CoalescedRepaint(Canvas canvas, RenderState state) {
+      this.canvas = canvas;
+      this.state = state;
+    }
+
+    @Override
+    public void run() {
+      if (canvas.isDisposed() || canvas.getData(DATA_KEY_RENDER_STATE) != 
state) {
+        state.repaintScheduled = false;
+        return;
+      }
+      // The graph classes redraw from asyncExec runnables of their own 
(updateGui, the Show
+      // listener, tab activation). Those were queued behind us, so step back 
once and let them
+      // land first: they then find the repaint still scheduled and add 
nothing.
+      if (!yielded) {
+        yielded = true;
+        canvas.getDisplay().asyncExec(this);
+        return;
+      }
+      state.repaintScheduled = false;
+      state.repainting = true;
+      try {
+        canvas.redraw();
+      } finally {
+        state.repainting = false;
+      }
+    }
+  }
+
+  private static RenderState renderState(Canvas canvas) {
+    RenderState state = (RenderState) canvas.getData(DATA_KEY_RENDER_STATE);
+    if (state == null) {
+      state = new RenderState();
+      canvas.setData(DATA_KEY_RENDER_STATE, state);
+    }
+    return state;
+  }
+
   @Override
   void publishSnapshotInternal(
       Canvas canvas,
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
index 002801248a..df5c5ab51f 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWeb.java
@@ -169,6 +169,7 @@ public class HopWeb implements ApplicationConfiguration {
             "org/apache/hop/ui/hopgui/canvas-zoom.js",
             "org/apache/hop/ui/hopgui/canvas-svg.js",
             "org/apache/hop/ui/hopgui/context-dialog-svg.js",
+            "org/apache/hop/ui/hopgui/log-console.js",
             "org/apache/hop/ui/hopgui/gc-pending-images.js",
             "org/apache/hop/ui/hopgui/monaco-editor.js",
             "org/apache/hop/ui/hopgui/mac-command-keys.js")
diff --git a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java 
b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
index ac7d8900b0..ed9833a429 100644
--- a/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
+++ b/rap/src/main/java/org/apache/hop/ui/hopgui/HopWebEntryPoint.java
@@ -45,6 +45,7 @@ import org.eclipse.rap.rwt.application.AbstractEntryPoint;
 import org.eclipse.rap.rwt.client.service.JavaScriptExecutor;
 import org.eclipse.rap.rwt.client.service.JavaScriptLoader;
 import org.eclipse.rap.rwt.client.service.StartupParameters;
+import org.eclipse.rap.rwt.internal.client.ConnectionMessages;
 import org.eclipse.rap.rwt.service.ResourceManager;
 import org.eclipse.rap.rwt.service.UISessionEvent;
 import org.eclipse.rap.rwt.service.UISessionListener;
@@ -56,6 +57,9 @@ import org.eclipse.swt.widgets.Display;
 
 public class HopWebEntryPoint extends AbstractEntryPoint {
 
+  /** How long a request may run before the browser shows RAP's wait hint (RAP 
default: 1000). */
+  static final int WAIT_HINT_TIMEOUT_MS = 1500;
+
   /**
    * Shortcuts that must remain active (sent to the server) but must not be 
cancelled in the
    * browser, so native text editing still works (e.g. Ctrl+C/V/X in input 
fields).
@@ -166,11 +170,21 @@ public class HopWebEntryPoint extends AbstractEntryPoint {
     ResourceManager resourceManager = RWT.getResourceManager();
     JavaScriptLoader jsLoader = 
RWT.getClient().getService(JavaScriptLoader.class);
 
+    // RAP dims the whole page (the "wait hint") when a request runs longer 
than 1 s. Response-time
+    // guidance puts the point where feedback is due at about a second, but a 
grey-out reads as a
+    // freeze, so give ordinary work another half second before the hint comes 
up; the theme keeps
+    // the hint itself light (SystemMessage-DisplayOverlay in light-mode.css / 
dark-mode.css).
+    ConnectionMessages connectionMessages = 
RWT.getClient().getService(ConnectionMessages.class);
+    if (connectionMessages != null) {
+      connectionMessages.setWaitHintTimeout(WAIT_HINT_TIMEOUT_MS);
+    }
+
     // Load canvas zoom handler and Monaco editor client script
     String jsLocation = resourceManager.getLocation("js/canvas-zoom.js");
     jsLoader.require(jsLocation);
     jsLoader.require(resourceManager.getLocation("js/canvas-svg.js"));
     jsLoader.require(resourceManager.getLocation("js/context-dialog-svg.js"));
+    jsLoader.require(resourceManager.getLocation("js/log-console.js"));
     // RAP's GC leaves image onload handlers alive after dispose; see the 
script.
     jsLoader.require(resourceManager.getLocation("js/gc-pending-images.js"));
     jsLoader.require(resourceManager.getLocation("js/monaco-editor.js"));
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
index f6033d4a38..8ce8ceecd0 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/canvas-svg.js
@@ -325,8 +325,12 @@
         this._areas = [];
         this._props = {};
         this._remoteObject = null;
+        this._fetchInFlight = false;
+        this._pendingFetchRev = null;
+        this._hoveredNameKey = null;
+        this._hoveredNameArea = null;
+        this._hoveredNameElement = null;
         this._pollTimer = null;
-        this._pollCount = 0;
         this._emptyRetries = 0;
         this._mousemoveHandler = null;
         this._mouseleaveHandler = null;
@@ -598,7 +602,6 @@
 
             if (!this._pollTimer) {
                 this._pollTimer = setInterval(function () {
-                    self._pollCount++;
                     if (self._canvas && !self._canvas.parentNode) {
                         self._canvas = null;
                         self._findAndAttachCanvas();
@@ -607,9 +610,11 @@
                     if (self._canvas) {
                         self._syncOverlayLayout(self._canvas);
                     }
-                    // Periodically force a full refresh to recover from 
missed updates.
-                    var clientRev = (self._pollCount % 10 === 0) ? 0 : 
self._revision;
-                    self._fetchAndRender(clientRev);
+                    // Every publish bumps the server revision, so this 
conditional poll (304 when
+                    // unchanged) already recovers a missed update within half 
a second. It used to
+                    // force a full fetch every 10th poll as well, which 
re-downloaded a large
+                    // pipeline's SVG every 5 seconds for as long as the tab 
was open.
+                    self._fetchAndRender(self._revision);
                 }, 500);
             }
 
@@ -718,6 +723,26 @@
             if (!this._sessionUuid || !this._canvasId) {
                 return;
             }
+            // One download at a time. Binding a canvas asks for the SVG from 
several places at
+            // once (attach, canvasId, renderRevision, the first poll), all 
before the first
+            // response has set _revision, so each of them fetched the whole 
document again. Remember
+            // the most demanding request (0 = unconditional) and issue it 
once this one is done.
+            if (this._fetchInFlight) {
+                var wanted = clientRev || 0;
+                this._pendingFetchRev = this._pendingFetchRev === null
+                    ? wanted : Math.min(this._pendingFetchRev, wanted);
+                return;
+            }
+            this._fetchInFlight = true;
+            var finished = function () {
+                self._fetchInFlight = false;
+                if (self._pendingFetchRev !== null && !self._destroyed) {
+                    var rev = self._pendingFetchRev;
+                    self._pendingFetchRev = null;
+                    // A conditional follow-up should use what we have by now, 
not a stale number.
+                    self._fetchAndRender(rev === 0 ? 0 : self._revision);
+                }
+            };
             fetch(this._serviceUrl(clientRev), { credentials: "same-origin" })
                 .then(function (response) {
                     if (response.status === 304) {
@@ -761,6 +786,8 @@
                             svg.style.display = "block";
                             svg.style.pointerEvents = "none";
                         }
+                        self._hoveredNameElement = null;
+                        self._applyHoveredName();
                         if (self._canvas) {
                             self._syncOverlayLayout(self._canvas);
                         }
@@ -772,7 +799,8 @@
                     if (window.console && console.debug) {
                         console.debug("Hop canvas SVG fetch failed", err);
                     }
-                });
+                })
+                .then(finished, finished);
         },
 
         _updateHoverChrome: function (area, graphX, graphY) {
@@ -806,6 +834,63 @@
             } else {
                 this._canvas.style.cursor = "";
             }
+            this._setHoveredName(
+                area && (area.areaType === "TRANSFORM_NAME" || area.areaType 
=== "ACTION_NAME") ? area : null);
+        },
+
+        /**
+         * Bold the hovered transform / action name. The server used to do 
this by re-rendering
+         * the whole graph on every name enter and leave (issue #8435); the 
<text> is right here.
+         */
+        _setHoveredName: function (area) {
+            var key = area ? area.areaType + ":" + area.x + ":" + area.y : 
null;
+            if (key === this._hoveredNameKey) {
+                return;
+            }
+            this._clearHoveredName();
+            this._hoveredNameKey = key;
+            this._hoveredNameArea = area;
+            this._applyHoveredName();
+        },
+
+        _clearHoveredName: function () {
+            if (this._hoveredNameElement) {
+                this._hoveredNameElement.style.fontWeight = "";
+                this._hoveredNameElement = null;
+            }
+            this._hoveredNameKey = null;
+            this._hoveredNameArea = null;
+        },
+
+        _applyHoveredName: function () {
+            var area = this._hoveredNameArea;
+            if (!area || !this._svgHost || !this._overlay) {
+                return;
+            }
+            var label = area.owner && area.owner.kind === "label" ? 
area.owner.value : null;
+            var props = this._getCanvasProps();
+            if (props.magnification == null && this._props && 
this._props.magnification != null) {
+                props = this._props;
+            }
+            var rect = graphRectToScreen(area.x, area.y, area.width, 
area.height, props);
+            var overlayRect = this._overlay.getBoundingClientRect();
+            var left = overlayRect.left + rect.left;
+            var top = overlayRect.top + rect.top;
+            var texts = this._svgHost.querySelectorAll("text");
+            for (var i = 0; i < texts.length; i++) {
+                var text = texts[i];
+                if (label != null && text.textContent.trim() !== label) {
+                    continue;
+                }
+                var box = text.getBoundingClientRect();
+                var cx = box.left + box.width / 2;
+                var cy = box.top + box.height / 2;
+                if (cx >= left && cx <= left + rect.width && cy >= top && cy 
<= top + rect.height) {
+                    text.style.fontWeight = "bold";
+                    this._hoveredNameElement = text;
+                    return;
+                }
+            }
         },
 
         _ensureNoteHandle: function (index) {
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
index a7bf859b86..243f96439e 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/dark-mode.css
@@ -94,9 +94,14 @@ Display {
     font: 12px Verdana, "Lucida Sans", Arial, Helvetica, sans-serif;
 }
 
+/*
+ * The wait hint RAP shows while a request runs longer than the timeout set in 
HopWebEntryPoint.
+ * A near-transparent overlay (it still blocks input, which is what it is for) 
with RAP's small
+ * spinner in the middle, instead of greying out the page, which read as a 
freeze (issue #8435).
+ */
 SystemMessage-DisplayOverlay {
-    background-color: rgba(128, 128, 128, 0.3);
-    background-image: none;
+    background-color: rgba(128, 128, 128, 0.06);
+    background-image: url(resource/widget/rap/display/loading.gif);
 }
 
 ErrorBox-Titlebar {
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
index 0287216aa6..5bba0f10ca 100644
--- a/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/light-mode.css
@@ -89,9 +89,14 @@ Display {
     font: 12px Verdana, "Lucida Sans", Arial, Helvetica, sans-serif;
 }
 
+/*
+ * The wait hint RAP shows while a request runs longer than the timeout set in 
HopWebEntryPoint.
+ * A near-transparent overlay (it still blocks input, which is what it is for) 
with RAP's small
+ * spinner in the middle, instead of greying out the page, which read as a 
freeze (issue #8435).
+ */
 SystemMessage-DisplayOverlay {
-    background-color: rgba(128, 128, 128, 0.3);
-    background-image: none;
+    background-color: rgba(128, 128, 128, 0.06);
+    background-image: url(resource/widget/rap/display/loading.gif);
 }
 
 ErrorBox-Titlebar {
diff --git a/rap/src/main/resources/org/apache/hop/ui/hopgui/log-console.js 
b/rap/src/main/resources/org/apache/hop/ui/hopgui/log-console.js
new file mode 100644
index 0000000000..f63b9315ec
--- /dev/null
+++ b/rap/src/main/resources/org/apache/hop/ui/hopgui/log-console.js
@@ -0,0 +1,377 @@
+/*
+ * 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.
+ */
+
+/*
+ * The Hop Web log view (see WebLogConsole.java): a scrolling <div> of lines 
inside a RAP
+ * composite. The server sends the new lines only; this side keeps at most 
maxLines of them,
+ * colours error lines, marks the filter term, follows the tail unless the 
user scrolled up and
+ * reports what the user selected so "copy selection" on the server works.
+ */
+(function () {
+    "use strict";
+
+    var SELECTION_DEBOUNCE_MS = 250;
+
+    function getParentElement(parentId) {
+        var obj = rap.getObject(parentId);
+        if (obj) {
+            if (obj.$el) {
+                var el = obj.$el.get ? obj.$el.get(0) : (obj.$el[0] || 
obj.$el);
+                if (el) {
+                    return el;
+                }
+            }
+            if (obj.getDomNode) {
+                return obj.getDomNode();
+            }
+            if (obj._domNode) {
+                return obj._domNode;
+            }
+        }
+        return document.getElementById(parentId);
+    }
+
+    rwt.define("hop");
+
+    hop.LogConsole = function (properties) {
+        this._parentId = properties.parent;
+        this._maxLines = properties.maxLines > 0 ? properties.maxLines : 20000;
+        this._maxSelection = properties.maxSelection > 0 ? 
properties.maxSelection : 65536;
+        // Length of the line separator the server counts between lines (1 or 
2).
+        this._separatorLength = properties.separatorLength > 0 ? 
properties.separatorLength : 1;
+        this._highlight = properties.highlight || "";
+        this._highlightCaseSensitive = properties.highlightCaseSensitive === 
true;
+        this._font = properties.font || "";
+        this._color = properties.color || "";
+        this._container = null;
+        this._lineCount = 0;
+        this._followTail = true;
+        this._pending = [];
+        this._retryId = null;
+        this._selectionTimer = null;
+        this._lastReportedSelection = "";
+        this._destroyed = false;
+        this._mount();
+    };
+
+    hop.LogConsole.prototype = {
+
+        _mount: function () {
+            var self = this;
+            var parentEl = getParentElement(this._parentId);
+            if (!parentEl) {
+                // The composite's element is created lazily; try again 
shortly.
+                if (!this._retryId) {
+                    this._retryId = setInterval(function () {
+                        if (self._destroyed) {
+                            clearInterval(self._retryId);
+                            self._retryId = null;
+                            return;
+                        }
+                        if (getParentElement(self._parentId)) {
+                            clearInterval(self._retryId);
+                            self._retryId = null;
+                            self._mount();
+                        }
+                    }, 100);
+                }
+                return;
+            }
+            var container = document.createElement("div");
+            container.setAttribute("data-hop-log-console", "true");
+            var style = container.style;
+            style.position = "absolute";
+            style.left = "0";
+            style.top = "0";
+            style.right = "0";
+            style.bottom = "0";
+            style.overflow = "auto";
+            style.whiteSpace = "pre";
+            style.font = this._font || "inherit";
+            style.color = this._color || "inherit";
+            style.background = "transparent";
+            style.cursor = "text";
+            style.userSelect = "text";
+            style.webkitUserSelect = "text";
+            style.padding = "2px 4px";
+            style.boxSizing = "border-box";
+            parentEl.appendChild(container);
+            this._container = container;
+            // RAP cancels native text selection unless the widget under the 
mouse is selectable.
+            try {
+                var widget = 
rwt.remote.ObjectRegistry.getObject(this._parentId);
+                if (widget && typeof widget.setSelectable === "function") {
+                    widget.setSelectable(true);
+                }
+            } catch (ignored) {}
+
+            container.addEventListener("scroll", function () {
+                self._followTail =
+                    container.scrollTop + container.clientHeight >= 
container.scrollHeight - 4;
+            });
+            this._selectionHandler = function () {
+                self._scheduleSelectionReport();
+            };
+            document.addEventListener("selectionchange", 
this._selectionHandler);
+
+            var pending = this._pending;
+            this._pending = [];
+            for (var i = 0; i < pending.length; i++) {
+                this.append(pending[i]);
+            }
+        },
+
+        destroy: function () {
+            this._destroyed = true;
+            if (this._retryId) {
+                clearInterval(this._retryId);
+                this._retryId = null;
+            }
+            if (this._selectionTimer) {
+                clearTimeout(this._selectionTimer);
+                this._selectionTimer = null;
+            }
+            if (this._selectionHandler) {
+                document.removeEventListener("selectionchange", 
this._selectionHandler);
+                this._selectionHandler = null;
+            }
+            if (this._container && this._container.parentNode) {
+                this._container.parentNode.removeChild(this._container);
+            }
+            this._container = null;
+        },
+
+        setMaxLines: function (value) {
+            this._maxLines = value > 0 ? value : 20000;
+            this._trim();
+        },
+
+        setMaxSelection: function (value) {
+            this._maxSelection = value > 0 ? value : 65536;
+        },
+
+        setSeparatorLength: function (value) {
+            this._separatorLength = value > 0 ? value : 1;
+        },
+
+        setHighlight: function (value) {
+            this._highlight = value || "";
+        },
+
+        setFont: function (value) {
+            this._font = value || "";
+            if (this._container) {
+                this._container.style.font = this._font || "inherit";
+            }
+        },
+
+        setColor: function (value) {
+            this._color = value || "";
+            if (this._container) {
+                this._container.style.color = this._color || "inherit";
+            }
+        },
+
+        setHighlightCaseSensitive: function (value) {
+            this._highlightCaseSensitive = value === true;
+        },
+
+        /** Server call: lines and their error flags, in order. */
+        append: function (params) {
+            if (!this._container) {
+                this._pending.push(params);
+                return;
+            }
+            var lines = params.lines || [];
+            var errors = params.errors || [];
+            var fragment = document.createDocumentFragment();
+            for (var i = 0; i < lines.length; i++) {
+                fragment.appendChild(this._lineElement(lines[i], errors[i] === 
true));
+            }
+            this._container.appendChild(fragment);
+            this._lineCount += lines.length;
+            this._trim();
+            if (this._followTail) {
+                this._container.scrollTop = this._container.scrollHeight;
+            }
+        },
+
+        clear: function () {
+            this._pending = [];
+            this._lineCount = 0;
+            this._followTail = true;
+            if (this._container) {
+                this._container.textContent = "";
+            }
+        },
+
+        /** Server call: select a character range, offsets into the text as 
the server holds it. */
+        select: function (params) {
+            if (!this._container || !window.getSelection) {
+                return;
+            }
+            var start = params.start || 0;
+            var end = params.end || start;
+            var range = document.createRange();
+            var startPoint = this._pointAt(start);
+            var endPoint = this._pointAt(end);
+            if (!startPoint || !endPoint) {
+                return;
+            }
+            range.setStart(startPoint.node, startPoint.offset);
+            range.setEnd(endPoint.node, endPoint.offset);
+            var selection = window.getSelection();
+            selection.removeAllRanges();
+            selection.addRange(range);
+            var line = startPoint.node.parentNode;
+            if (line && line.scrollIntoView) {
+                line.scrollIntoView({ block: "center" });
+            }
+        },
+
+        _lineElement: function (text, error) {
+            var line = document.createElement("div");
+            if (error) {
+                line.style.color = "#d0021b";
+            }
+            var term = this._highlight;
+            if (term) {
+                this._fillWithMarks(line, text, term);
+            } else {
+                line.textContent = text;
+            }
+            // Keep the text node count predictable for select(): one line = 
one <div>.
+            if (!line.firstChild) {
+                line.appendChild(document.createTextNode(""));
+            }
+            return line;
+        },
+
+        _fillWithMarks: function (line, text, term) {
+            var haystack = this._highlightCaseSensitive ? text : 
text.toLowerCase();
+            var needle = this._highlightCaseSensitive ? term : 
term.toLowerCase();
+            var from = 0;
+            var idx = haystack.indexOf(needle, from);
+            if (idx < 0) {
+                line.textContent = text;
+                return;
+            }
+            while (idx >= 0) {
+                if (idx > from) {
+                    
line.appendChild(document.createTextNode(text.substring(from, idx)));
+                }
+                var mark = document.createElement("mark");
+                mark.textContent = text.substring(idx, idx + needle.length);
+                line.appendChild(mark);
+                from = idx + needle.length;
+                idx = haystack.indexOf(needle, from);
+            }
+            if (from < text.length) {
+                
line.appendChild(document.createTextNode(text.substring(from)));
+            }
+        },
+
+        _trim: function () {
+            if (!this._container) {
+                return;
+            }
+            while (this._lineCount > this._maxLines && 
this._container.firstChild) {
+                this._container.removeChild(this._container.firstChild);
+                this._lineCount--;
+            }
+        },
+
+        /** The text node and offset for a character offset into the server's 
joined text. */
+        _pointAt: function (offset) {
+            var line = this._container.firstChild;
+            var remaining = offset;
+            while (line) {
+                var length = line.textContent.length;
+                if (remaining <= length) {
+                    return this._pointInLine(line, remaining);
+                }
+                remaining -= length + this._separatorLength;
+                line = line.nextSibling;
+            }
+            var last = this._container.lastChild;
+            return last ? this._pointInLine(last, last.textContent.length) : 
null;
+        },
+
+        _pointInLine: function (line, offset) {
+            var node = line.firstChild;
+            var remaining = offset;
+            while (node) {
+                var text = node.nodeType === 3 ? node : node.firstChild;
+                var length = text ? text.textContent.length : 0;
+                if (remaining <= length) {
+                    return { node: text || line, offset: text ? remaining : 0 
};
+                }
+                remaining -= length;
+                node = node.nextSibling;
+            }
+            var lastText = line.lastChild && line.lastChild.nodeType === 3
+                ? line.lastChild : (line.lastChild ? line.lastChild.firstChild 
: null);
+            return { node: lastText || line, offset: lastText ? 
lastText.textContent.length : 0 };
+        },
+
+        _scheduleSelectionReport: function () {
+            var self = this;
+            if (this._selectionTimer) {
+                clearTimeout(this._selectionTimer);
+            }
+            this._selectionTimer = setTimeout(function () {
+                self._selectionTimer = null;
+                self._reportSelection();
+            }, SELECTION_DEBOUNCE_MS);
+        },
+
+        _reportSelection: function () {
+            if (this._destroyed || !this._container || !window.getSelection) {
+                return;
+            }
+            var selection = window.getSelection();
+            var text = "";
+            if (selection && selection.rangeCount > 0 && 
!selection.isCollapsed) {
+                var range = selection.getRangeAt(0);
+                if (this._container.contains(range.commonAncestorContainer)) {
+                    text = selection.toString();
+                }
+            }
+            if (text.length > this._maxSelection) {
+                text = text.substring(0, this._maxSelection);
+            }
+            if (text === this._lastReportedSelection) {
+                return;
+            }
+            this._lastReportedSelection = text;
+            var remoteObject = rap.getRemoteObject(this);
+            if (remoteObject) {
+                remoteObject.notify("selectionChanged", { text: text });
+            }
+        }
+    };
+
+    rap.registerTypeHandler("hop.LogConsole", {
+        factory: function (properties) {
+            return new hop.LogConsole(properties);
+        },
+        destructor: "destroy",
+        properties: [ "maxLines", "maxSelection", "separatorLength", 
"highlight", "highlightCaseSensitive", "font", "color" ],
+        events: [ "selectionChanged" ],
+        methods: [ "append", "clear", "select" ]
+    });
+})();
diff --git 
a/rcp/src/main/java/org/apache/hop/ui/core/widget/LogConsoleFacadeImpl.java 
b/rcp/src/main/java/org/apache/hop/ui/core/widget/LogConsoleFacadeImpl.java
new file mode 100644
index 0000000000..64bf213663
--- /dev/null
+++ b/rcp/src/main/java/org/apache/hop/ui/core/widget/LogConsoleFacadeImpl.java
@@ -0,0 +1,29 @@
+/*
+ * 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.ui.core.widget;
+
+import org.eclipse.swt.widgets.Composite;
+
+/** Desktop: no incremental log view, the log delegates keep their StyledText. 
*/
+public class LogConsoleFacadeImpl extends LogConsoleFacade {
+
+  @Override
+  TextComposite createInternal(Composite parent, int style) {
+    return null;
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
index 5a2119a19c..565cdbc9f6 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
@@ -707,14 +707,12 @@ public class ContextDialog extends Dialog {
 
     removePlacementArmFilters();
 
-    // Store the toolbar settings
-    storeDialogSettings();
-
     if (ContextDialogSvgFacade.isSupported() && wCanvas != null) {
       ContextDialogSvgFacade.unregister(wCanvas);
     }
 
-    // Close the dialog window
+    // Close the dialog window. The SWT.Close listener stores the dialog 
settings, so they are not
+    // stored a second time here.
     shell.close();
 
     // Do not dispose item images. They are cached by GuiResource so that 
they're only ever loaded
@@ -1080,12 +1078,20 @@ public class ContextDialog extends Dialog {
     int correctedIconSize = (int) (iconSize / props.getZoomFactor());
     Display display = shell != null && !shell.isDisposed() ? 
shell.getDisplay() : null;
 
+    // Hop Web draws the items as one SVG document straight from the icon 
files, so rasterizing a
+    // bitmap per action here would only slow down the first open of every 
session.
+    boolean loadBitmaps = !ContextDialogSvgFacade.isSupported();
+
     items.clear();
     for (GuiAction action : actions) {
       ClassLoader classLoader = action.getClassLoader();
       if (classLoader == null) {
         classLoader = ClassLoader.getSystemClassLoader();
       }
+      if (!loadBitmaps) {
+        items.add(new Item(action, null));
+        continue;
+      }
       Image image;
       try {
         image =
@@ -1454,6 +1460,14 @@ public class ContextDialog extends Dialog {
   }
 
   private void selectItem(Item selectedItem, boolean scroll) {
+    selectItem(selectedItem, scroll, true);
+  }
+
+  /**
+   * @param redraw false when the caller redraws the canvas itself afterwards. 
Without paint
+   *     coalescing (Hop Web) every redraw renders the whole item list again.
+   */
+  private void selectItem(Item selectedItem, boolean scroll, boolean redraw) {
 
     for (Item item : items) {
       item.setSelected(false);
@@ -1494,7 +1508,9 @@ public class ContextDialog extends Dialog {
       }
     }
 
-    wCanvas.redraw();
+    if (redraw) {
+      wCanvas.redraw();
+    }
   }
 
   /**
@@ -1563,20 +1579,20 @@ public class ContextDialog extends Dialog {
     }
 
     if (filteredItems.isEmpty()) {
-      selectItem(null, false);
+      selectItem(null, false, false);
     }
 
     // Typing something's full name selects that thing, even if the selection 
was already on a
     // result that survived the narrowing.
     //
     else if (exactMatch != null) {
-      selectItem(exactMatch, false);
+      selectItem(exactMatch, false, false);
     }
 
     // if selected item is exclude, change to a new default selection: first 
in the list
     //
     else if (!filteredItems.contains(selectedItem)) {
-      selectItem(filteredItems.get(0), false);
+      selectItem(filteredItems.get(0), false, false);
     }
 
     // Update vertical bar
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRenderer.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRenderer.java
index f924abd175..bbc02564c2 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRenderer.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialogSvgRenderer.java
@@ -285,9 +285,10 @@ public final class ContextDialogSvgRenderer {
         }
       }
 
+      // Nobody reads this document: indentation is 15% of a payload that is 
sent on every
+      // keystroke.
       Transformer transformer = 
XmlHandler.createSecureTransformerFactory().newTransformer();
-      transformer.setOutputProperty(OutputKeys.INDENT, "yes");
-      
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount";, "2");
+      transformer.setOutputProperty(OutputKeys.INDENT, "no");
       StringWriter writer = new StringWriter();
       transformer.transform(new DOMSource(root), new StreamResult(writer));
       return new ContextDialogSvgRenderResult(writer.toString(), areaOwners, 
totalContentHeight);
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/ILogConsole.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/ILogConsole.java
new file mode 100644
index 0000000000..a4c053a960
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/ILogConsole.java
@@ -0,0 +1,57 @@
+/*
+ * 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.ui.core.widget;
+
+import java.util.List;
+
+/**
+ * A log view that takes its lines incrementally.
+ *
+ * <p>The desktop log view is a StyledText that is appended to in place. Hop 
Web has no StyledText
+ * and used a plain Text, which RAP re-sends in full every time a line is 
added: a pipeline that
+ * logs a lot shipped the whole log to the browser once a second (issue 
#8435). A console gets only
+ * the new lines, keeps the last {@code maxLines} of them itself and holds the 
same text the desktop
+ * view would, so the copy / filter / "show errors" features that read it keep 
working.
+ */
+public interface ILogConsole {
+
+  /** One log line as it is shown: the formatted text and whether it is an 
error line. */
+  record Line(String text, boolean error) {}
+
+  /** Adds lines at the end, in order. */
+  void appendLines(List<Line> lines);
+
+  /**
+   * How many lines one refresh may add. Row-level logging produces far more 
lines per second than a
+   * browser can take, and everything beyond what the console keeps would be 
dropped on arrival
+   * anyway; the log browser sends the newest lines up to this number and says 
how many it skipped.
+   */
+  int getMaxLinesPerRefresh();
+
+  /** Empties the view. */
+  void clear();
+
+  /**
+   * Marks occurrences of a term in the lines shown from now on. Lines already 
shown are not
+   * re-marked; the log browser rebuilds the view when the filter changes.
+   *
+   * @param term the text to mark, null or empty to stop marking
+   * @param caseSensitive whether the match is case-sensitive
+   */
+  void setHighlight(String term, boolean caseSensitive);
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/widget/LogConsoleFacade.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/LogConsoleFacade.java
new file mode 100644
index 0000000000..843cd89638
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/LogConsoleFacade.java
@@ -0,0 +1,46 @@
+/*
+ * 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.ui.core.widget;
+
+import org.apache.hop.ui.hopgui.ImplementationLoader;
+import org.eclipse.swt.widgets.Composite;
+
+/**
+ * Creates the platform's incremental log view (see {@link ILogConsole}). The 
desktop has none: it
+ * keeps its StyledText, which is appended to in place already.
+ */
+public abstract class LogConsoleFacade {
+
+  private static final LogConsoleFacade IMPL;
+
+  static {
+    IMPL = (LogConsoleFacade) 
ImplementationLoader.newInstance(LogConsoleFacade.class);
+  }
+
+  /**
+   * @param parent the composite to create the log view in
+   * @param style SWT style bits, of which the border is honoured
+   * @return a text composite that also implements {@link ILogConsole}, or 
null when this platform
+   *     has no incremental log view
+   */
+  public static TextComposite create(Composite parent, int style) {
+    return IMPL.createInternal(parent, style);
+  }
+
+  abstract TextComposite createInternal(Composite parent, int style);
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiLogBrowser.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiLogBrowser.java
index 02e43a7728..26f7495fea 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiLogBrowser.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiLogBrowser.java
@@ -44,6 +44,7 @@ import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.ILogConsole;
 import org.apache.hop.ui.core.widget.OsHelper;
 import org.apache.hop.ui.core.widget.StyledTextVar;
 import org.apache.hop.ui.core.widget.TextComposite;
@@ -127,17 +128,18 @@ public class HopGuiLogBrowser {
               String initial = OsHelper.isMac() ? Const.CR : "";
               if (styledText != null && !styledText.isDisposed()) {
                 styledText.setText(initial);
+              } else if (text instanceof ILogConsole console) {
+                console.setHighlight(highlightMatches ? filterText : null, 
caseSensitive);
+                console.clear();
               } else {
                 text.setText(initial);
               }
 
-              for (HopLoggingEvent event : logLines) {
-                appendLogEvent(event, styledText);
-              }
+              appendLogEvents(logLines, styledText);
 
               trimToMaxSize(styledText);
 
-              if (!text.isDisposed()) {
+              if (!text.isDisposed() && !(text instanceof ILogConsole)) {
                 text.setSelection(text.getCharCount());
               }
             }
@@ -199,13 +201,11 @@ public class HopGuiLogBrowser {
                             StyledText styledText = getStyledText();
 
                             synchronized (text) {
-                              for (HopLoggingEvent event : logLines) {
-                                appendLogEvent(event, styledText);
-                              }
+                              appendLogEvents(logLines, styledText);
 
                               trimToMaxSize(styledText);
 
-                              if (!text.isDisposed()) {
+                              if (!text.isDisposed() && !(text instanceof 
ILogConsole)) {
                                 text.setSelection(text.getCharCount());
                               }
                             }
@@ -287,6 +287,51 @@ public class HopGuiLogBrowser {
     return null;
   }
 
+  /**
+   * Appends the events of one refresh. A console (Hop Web) takes them as one 
batch: a request then
+   * carries the new lines only instead of the whole log once per line.
+   */
+  private void appendLogEvents(List<HopLoggingEvent> events, StyledText 
styledText) {
+    if (text instanceof ILogConsole console) {
+      console.appendLines(consoleLines(events, 
console.getMaxLinesPerRefresh()));
+      return;
+    }
+    for (HopLoggingEvent event : events) {
+      appendLogEvent(event, styledText);
+    }
+  }
+
+  /**
+   * The newest {@code budget} displayable lines of a refresh, oldest first, 
preceded by a line
+   * saying how many earlier ones were left out. Walks the events backwards so 
that only the lines
+   * that will be shown are formatted.
+   */
+  List<ILogConsole.Line> consoleLines(List<HopLoggingEvent> events, int 
budget) {
+    ArrayList<ILogConsole.Line> newestFirst = new 
ArrayList<>(Math.min(events.size(), budget));
+    int index = events.size() - 1;
+    for (; index >= 0 && newestFirst.size() < budget; index--) {
+      HopLoggingEvent event = events.get(index);
+      String line = logLayout.format(event).trim();
+      if (line.isEmpty() || !shouldDisplayLine(line)) {
+        continue;
+      }
+      boolean isError =
+          event.getLevel() != null && event.getLevel().getLevel() == 
LogLevel.ERROR.getLevel();
+      newestFirst.add(new ILogConsole.Line(line, isError));
+    }
+    List<ILogConsole.Line> lines = new ArrayList<>(newestFirst.size() + 1);
+    int skipped = index + 1;
+    if (skipped > 0) {
+      lines.add(
+          new ILogConsole.Line(
+              BaseMessages.getString(PKG, "LogBrowser.Console.LinesSkipped", 
skipped), false));
+    }
+    for (int i = newestFirst.size() - 1; i >= 0; i--) {
+      lines.add(newestFirst.get(i));
+    }
+    return lines;
+  }
+
   private void appendLogEvent(HopLoggingEvent event, StyledText styledText) {
     String line = logLayout.format(event).trim();
     if (line.isEmpty() || !shouldDisplayLine(line)) {
@@ -398,6 +443,9 @@ public class HopGuiLogBrowser {
   }
 
   private void trimToMaxSize(StyledText styledText) {
+    if (text instanceof ILogConsole) {
+      return; // keeps its own maximum on both ends
+    }
     int maxSize = getMaxLogSize();
     String textContent = text.getText();
     int size;
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 3cf86d1bd0..7958d6081d 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
@@ -751,16 +751,9 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
 
   /** Handles hover events from the Hop Web SVG canvas overlay. */
   public void handleWebCanvasHover(int graphX, int graphY, int screenX, int 
screenY) {
+    // Only the tooltip needs the server. The bold name under the mouse is 
drawn by canvas-svg.js;
+    // re-rendering the whole graph for it cost a full SVG render per name 
entered or left.
     setToolTip(graphX, graphY, screenX, screenY);
-    if (!EnvironmentUtils.getInstance().isWeb()) {
-      return;
-    }
-    AreaOwner areaOwner = getVisibleAreaOwner(graphX, graphY);
-    boolean interactionInProgress =
-        startHopTransform != null || selectionRegion != null || dragSelection;
-    if (applyMouseOverNameHover(areaOwner, interactionInProgress)) {
-      redraw();
-    }
   }
 
   @Override
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineGridDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineGridDelegate.java
index 43ab143b95..bdfeab9325 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineGridDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineGridDelegate.java
@@ -1439,7 +1439,9 @@ public class HopGuiPipelineGridDelegate {
   }
 
   private void updateRowFromBaseTransform(ITransform baseTransform, TableItem 
row) {
-    TransformStatus transformStatus = new TransformStatus(baseTransform);
+    // The row shows the numbers only; the log text would be the transform's 
whole log formatted
+    // once a second (issue #8435).
+    TransformStatus transformStatus = new TransformStatus(baseTransform, 
false);
 
     String[] fields = transformStatus.getPipelineLogFields();
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineLogDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineLogDelegate.java
index 2110d67393..5ed691805e 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineLogDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineLogDelegate.java
@@ -35,6 +35,7 @@ import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.core.widget.LogConsoleFacade;
 import org.apache.hop.ui.core.widget.OsHelper;
 import org.apache.hop.ui.core.widget.StyledTextComp;
 import org.apache.hop.ui.core.widget.StyledTextVar;
@@ -127,15 +128,18 @@ public class HopGuiPipelineLogDelegate {
     fd.right = new FormAttachment(100, 0);
     toolbar.setLayoutData(fd);
 
-    // Use StyledTextComp for web (uses Text widget), StyledTextVar for 
desktop (uses StyledText
-    // for highlighting)
+    // Hop Web: an incremental console (a Text would be re-sent in full on 
every appended line);
+    // desktop: StyledText for highlighting.
     if (EnvironmentUtils.getInstance().isWeb()) {
-      pipelineLogText =
-          new StyledTextComp(
-              pipelineGraph.getVariables(),
-              pipelineLogComposite,
-              SWT.READ_ONLY | SWT.BORDER | SWT.MULTI | SWT.V_SCROLL | 
SWT.H_SCROLL,
-              TextComposite.STYLE_TYPE_LOG);
+      pipelineLogText = LogConsoleFacade.create(pipelineLogComposite, 
SWT.BORDER);
+      if (pipelineLogText == null) {
+        pipelineLogText =
+            new StyledTextComp(
+                pipelineGraph.getVariables(),
+                pipelineLogComposite,
+                SWT.READ_ONLY | SWT.BORDER | SWT.MULTI | SWT.V_SCROLL | 
SWT.H_SCROLL,
+                TextComposite.STYLE_TYPE_LOG);
+      }
     } else {
       pipelineLogText =
           new StyledTextVar(
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index 9ae8e84f2a..3466691867 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -640,16 +640,9 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
 
   /** Handles hover events from the Hop Web SVG canvas overlay. */
   public void handleWebCanvasHover(int graphX, int graphY, int screenX, int 
screenY) {
+    // Only the tooltip needs the server. The bold name under the mouse is 
drawn by canvas-svg.js;
+    // re-rendering the whole graph for it cost a full SVG render per name 
entered or left.
     setToolTip(graphX, graphY, screenX, screenY);
-    if (!EnvironmentUtils.getInstance().isWeb()) {
-      return;
-    }
-    AreaOwner areaOwner = getVisibleAreaOwner(graphX, graphY);
-    boolean interactionInProgress =
-        startHopAction != null || selectionRegion != null || dragSelection;
-    if (applyMouseOverNameHover(areaOwner, interactionInProgress)) {
-      redraw();
-    }
   }
 
   protected void hideToolTips() {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowLogDelegate.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowLogDelegate.java
index 49d2805001..1e568e58ec 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowLogDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/delegates/HopGuiWorkflowLogDelegate.java
@@ -32,6 +32,7 @@ import org.apache.hop.ui.core.dialog.EnterSelectionDialog;
 import org.apache.hop.ui.core.gui.GuiResource;
 import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
 import org.apache.hop.ui.core.gui.IToolbarContainer;
+import org.apache.hop.ui.core.widget.LogConsoleFacade;
 import org.apache.hop.ui.core.widget.OsHelper;
 import org.apache.hop.ui.core.widget.StyledTextComp;
 import org.apache.hop.ui.core.widget.StyledTextVar;
@@ -132,15 +133,18 @@ public class HopGuiWorkflowLogDelegate {
     fd.right = new FormAttachment(100, 0);
     toolbar.setLayoutData(fd);
 
-    // Use StyledTextComp for web (uses Text widget), StyledTextVar for 
desktop (uses StyledText
-    // for highlighting)
+    // Hop Web: an incremental console (a Text would be re-sent in full on 
every appended line);
+    // desktop: StyledText for highlighting.
     if (EnvironmentUtils.getInstance().isWeb()) {
-      workflowLogText =
-          new StyledTextComp(
-              workflowGraph.getVariables(),
-              workflowLogComposite,
-              SWT.READ_ONLY | SWT.BORDER | SWT.MULTI | SWT.V_SCROLL | 
SWT.H_SCROLL,
-              TextComposite.STYLE_TYPE_LOG);
+      workflowLogText = LogConsoleFacade.create(workflowLogComposite, 
SWT.BORDER);
+      if (workflowLogText == null) {
+        workflowLogText =
+            new StyledTextComp(
+                workflowGraph.getVariables(),
+                workflowLogComposite,
+                SWT.READ_ONLY | SWT.BORDER | SWT.MULTI | SWT.V_SCROLL | 
SWT.H_SCROLL,
+                TextComposite.STYLE_TYPE_LOG);
+      }
     } else {
       workflowLogText =
           new StyledTextVar(
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 35ebd58c8b..a2f5a8088a 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
@@ -184,6 +184,7 @@ 
HopGuiPipelineGraph.ContextualActionDialog.InsertTransform.Header=Select the tra
 HopGuiPipelineGraph.OpenReferencedObject.Selection.Message=Select the 
referenced file to open:
 HopGuiPipelineGraph.OpenReferencedObject.Selection.Title=Open referenced file
 LogBrowser.CopySelectionToClipboard.MenuItem=Copy selected text to clipboard
+LogBrowser.Console.LinesSkipped=... {0} earlier log lines are not shown here 
to keep Hop Web responsive; the complete log is in the execution information ...
 LogBrowser.Filter.Text.Label=Filter text\: 
 LogBrowser.Filter.Text.Tooltip=Filter log lines. Without Highlight or Exclude, 
only matching lines remain. Highlight shows all lines with matches marked. 
Exclude hides matching lines.
 LogBrowser.Filter.Highlight.Label=Highlight

Reply via email to