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 "%r" %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