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

ilgrosso pushed a commit to branch 4_2_X
in repository https://gitbox.apache.org/repos/asf/syncope.git


The following commit(s) were added to refs/heads/4_2_X by this push:
     new b4a52539b6 Improve topology persistence, connection checks and zoom 
controls (#1589)
b4a52539b6 is described below

commit b4a52539b6a4728f58c80a6ef28eebca045e1f90
Author: Matteo Tatoni <[email protected]>
AuthorDate: Tue Oct 27 11:29:55 2026 +0100

    Improve topology persistence, connection checks and zoom controls (#1589)
---
 .../syncope/client/console/topology/Topology.java  |   29 +-
 .../topology/TopologyWebSocketBehavior.java        |  212 ++--
 .../resources/META-INF/resources/css/topology.scss |   51 +-
 .../resources/META-INF/resources/js/idmTopology.js | 1066 +++++---------------
 .../META-INF/resources/js/idmTopologyCore.js       |  223 ++++
 .../META-INF/resources/js/idmTopologyLayout.js     |  282 ++++++
 .../META-INF/resources/js/idmTopologyNavigation.js |  220 ++++
 .../syncope/client/console/topology/Topology.html  |   41 +-
 8 files changed, 1140 insertions(+), 984 deletions(-)

diff --git 
a/client/idm/console/src/main/java/org/apache/syncope/client/console/topology/Topology.java
 
b/client/idm/console/src/main/java/org/apache/syncope/client/console/topology/Topology.java
index 8416ec1272..7708dbe392 100644
--- 
a/client/idm/console/src/main/java/org/apache/syncope/client/console/topology/Topology.java
+++ 
b/client/idm/console/src/main/java/org/apache/syncope/client/console/topology/Topology.java
@@ -174,30 +174,30 @@ public class Topology extends BasePage {
         body.add(togglePanel);
 
         // -----------------------------------------
-        // Add Zoom panel
+        // Add topology actions
         // -----------------------------------------
-        ActionsPanel<Serializable> zoomActionPanel = new 
ActionsPanel<>("zoom", null);
-
-        zoomActionPanel.add(new ActionLink<>() {
+        ActionsPanel<Serializable> topologyActions = new 
ActionsPanel<>("topologyActions", null);
+        topologyActions.add(new ActionLink<>() {
 
             private static final long serialVersionUID = -3722207913631435501L;
 
             @Override
             public void onClick(final AjaxRequestTarget target, final 
Serializable ignore) {
-                target.appendJavaScript("zoomIn($('#drawing')[0]);");
+                target.appendJavaScript("zoomOut();");
             }
-        }, ActionLink.ActionType.ZOOM_IN, 
IdMEntitlement.CONNECTOR_LIST).disableIndicator().hideLabel();
-        zoomActionPanel.add(new ActionLink<>() {
+        }, ActionLink.ActionType.ZOOM_OUT, 
IdMEntitlement.CONNECTOR_LIST).disableIndicator().hideLabel();
+
+        topologyActions.add(new ActionLink<>() {
 
             private static final long serialVersionUID = -3722207913631435501L;
 
             @Override
             public void onClick(final AjaxRequestTarget target, final 
Serializable ignore) {
-                target.appendJavaScript("zoomOut($('#drawing')[0]);");
+                target.appendJavaScript("zoomIn();");
             }
-        }, ActionLink.ActionType.ZOOM_OUT, 
IdMEntitlement.CONNECTOR_LIST).disableIndicator().hideLabel();
+        }, ActionLink.ActionType.ZOOM_IN, 
IdMEntitlement.CONNECTOR_LIST).disableIndicator().hideLabel();
 
-        zoomActionPanel.add(new ActionLink<>() {
+        topologyActions.add(new ActionLink<>() {
 
             private static final long serialVersionUID = -3722207913631435501L;
 
@@ -207,7 +207,7 @@ public class Topology extends BasePage {
             }
         }, ActionLink.ActionType.AUTO_LAYOUT, 
IdMEntitlement.CONNECTOR_LIST).disableIndicator().hideLabel();
 
-        zoomActionPanel.add(new ActionLink<>() {
+        topologyActions.add(new ActionLink<>() {
 
             private static final long serialVersionUID = -3722207913631435501L;
 
@@ -217,7 +217,7 @@ public class Topology extends BasePage {
             }
         }, ActionLink.ActionType.RECENTER, 
IdMEntitlement.CONNECTOR_LIST).disableIndicator().hideLabel();
 
-        body.add(zoomActionPanel);
+        body.add(topologyActions);
         // -----------------------------------------
 
         // -----------------------------------------
@@ -484,10 +484,7 @@ public class Topology extends BasePage {
                 jsPlumbConf.append(String.format(Locale.US, "activate(%.2f);", 
0.68f));
 
                 createConnections(connections).forEach(jsPlumbConf::append);
-                // Apply the tree layout on first load (when no saved node 
positions exist yet).
-                jsPlumbConf.append("var __topo=getTopology();var 
__hasPos=false;"
-                        + "for(var __k in 
__topo){if(__k!=='__zoom__'){__hasPos=true;break;}}"
-                        + "if(!__hasPos){autoLayoutTree({ centerInView: false 
});}");
+                jsPlumbConf.append("restoreTopologyLayout();");
 
                 
response.render(OnDomReadyHeaderItem.forScript(jsPlumbConf.toString()));
             }
diff --git 
a/client/idm/console/src/main/java/org/apache/syncope/client/console/topology/TopologyWebSocketBehavior.java
 
b/client/idm/console/src/main/java/org/apache/syncope/client/console/topology/TopologyWebSocketBehavior.java
index 15cfd536ce..4d58c17fe4 100644
--- 
a/client/idm/console/src/main/java/org/apache/syncope/client/console/topology/TopologyWebSocketBehavior.java
+++ 
b/client/idm/console/src/main/java/org/apache/syncope/client/console/topology/TopologyWebSocketBehavior.java
@@ -18,14 +18,16 @@
  */
 package org.apache.syncope.client.console.topology;
 
+import java.io.IOException;
+import java.io.Serializable;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Map;
-import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import org.apache.syncope.client.console.SyncopeConsoleSession;
@@ -35,12 +37,10 @@ import 
org.apache.syncope.client.console.wicket.ws.BasePageWebSocketBehavior;
 import org.apache.syncope.common.keymaster.client.api.ConfParamOps;
 import org.apache.syncope.common.keymaster.client.api.ServiceOps;
 import org.apache.syncope.common.keymaster.client.api.model.NetworkService;
-import org.apache.syncope.common.lib.jackson.SyncopeJsonMapper;
 import org.apache.wicket.protocol.ws.api.WebSocketRequestHandler;
 import org.apache.wicket.protocol.ws.api.message.TextMessage;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
-import tools.jackson.core.JacksonException;
 import tools.jackson.databind.JsonNode;
 import tools.jackson.databind.json.JsonMapper;
 
@@ -50,7 +50,7 @@ public class TopologyWebSocketBehavior extends 
BasePageWebSocketBehavior.OnMessa
 
     protected static final Logger LOG = 
LoggerFactory.getLogger(TopologyWebSocketBehavior.class);
 
-    protected static final JsonMapper MAPPER = new SyncopeJsonMapper();
+    protected static final JsonMapper MAPPER = 
JsonMapper.builder().findAndAddModules().build();
 
     protected static final String CONNECTOR_TEST_TIMEOUT_PARAMETER = 
"connector.test.timeout";
 
@@ -64,11 +64,11 @@ public class TopologyWebSocketBehavior extends 
BasePageWebSocketBehavior.OnMessa
 
     protected final ResourceRestClient resourceRestClient;
 
-    protected final Map<String, String> connectors = 
Collections.synchronizedMap(new HashMap<>());
+    protected final Map<String, StatusMessage> connectors = 
Collections.synchronizedMap(new HashMap<>());
 
     protected final Set<String> runningConnCheck = 
Collections.synchronizedSet(new HashSet<>());
 
-    protected final Map<String, String> resources = 
Collections.synchronizedMap(new HashMap<>());
+    protected final Map<String, StatusMessage> resources = 
Collections.synchronizedMap(new HashMap<>());
 
     protected final Set<String> runningResCheck = 
Collections.synchronizedSet(new HashSet<>());
 
@@ -78,9 +78,9 @@ public class TopologyWebSocketBehavior extends 
BasePageWebSocketBehavior.OnMessa
 
     protected String jwt;
 
-    protected Integer connectorTestTimeout = null;
+    protected Integer connectorTestTimeout;
 
-    protected Integer resourceTestTimeout = null;
+    protected Integer resourceTestTimeout;
 
     public TopologyWebSocketBehavior(
             final ServiceOps serviceOps,
@@ -97,109 +97,36 @@ public class TopologyWebSocketBehavior extends 
BasePageWebSocketBehavior.OnMessa
         domain = SyncopeConsoleSession.get().getDomain();
         jwt = SyncopeConsoleSession.get().getJWT();
 
-        // Handling with timeout as per SYNCOPE-1379
-        try {
-            connectorTestTimeout = confParamOps.get(domain, 
CONNECTOR_TEST_TIMEOUT_PARAMETER, null, Integer.class);
-            resourceTestTimeout = confParamOps.get(domain, 
RESOURCE_TEST_TIMEOUT_PARAMETER, null, Integer.class);
-        } catch (Exception e) {
-            LOG.debug("No {} or {} conf parameters found",
-                    CONNECTOR_TEST_TIMEOUT_PARAMETER, 
RESOURCE_TEST_TIMEOUT_PARAMETER, e);
-        }
-    }
-
-    protected void timeoutHandlingConnectionChecker(
-            final Checker checker,
-            final Integer timeout,
-            final Map<String, String> responses,
-            final Set<String> running) {
-
-        String response;
-        try {
-            if (timeout == null || timeout < 0) {
-                LOG.debug("No timeouts for resource connection checking ... ");
-                response = checker.call();
-            } else {
-                LOG.debug("Timeouts provided for resource connection checking 
... ");
-                response = 
SyncopeConsoleSession.get().execute(checker).get(timeout, TimeUnit.SECONDS);
-            }
-        } catch (InterruptedException | TimeoutException e) {
-            LOG.warn("Connection with {} timed out", checker.key);
-            response = "{ \"status\": \"%s\", \"target\": \"%s\"}".
-                    formatted(TopologyNode.Status.UNREACHABLE, checker.key);
-        } catch (Exception e) {
-            LOG.error("Unexpected exception connecting to {}", checker.key, e);
-            response = "{ \"status\": \"%s\", \"target\": \"%s\"}".
-                    formatted(TopologyNode.Status.FAILURE, checker.key);
-        }
-
-        Optional.ofNullable(response).ifPresent(r -> 
responses.put(checker.key, r));
-
-        running.remove(checker.key);
+        connectorTestTimeout = confParamOps.get(domain, 
CONNECTOR_TEST_TIMEOUT_PARAMETER, 30, Integer.class);
+        resourceTestTimeout = confParamOps.get(domain, 
RESOURCE_TEST_TIMEOUT_PARAMETER, 30, Integer.class);
     }
 
     @Override
     protected void onMessage(final WebSocketRequestHandler handler, final 
TextMessage message) {
         try {
             JsonNode obj = MAPPER.readTree(message.getText());
+            String target = obj.get("target").asString();
             switch 
(Topology.SupportedOperation.valueOf(obj.get("kind").asString())) {
-                case CHECK_CONNECTOR -> {
-                    String ckey = obj.get("target").asString();
-
-                    if (connectors.containsKey(ckey)) {
-                        handler.push(connectors.get(ckey));
-                    } else {
-                        handler.push("{ \"status\": \"%s\", \"target\": 
\"%s\"}".
-                                formatted(TopologyNode.Status.UNKNOWN, ckey));
-                    }
-
-                    if (runningConnCheck.contains(ckey)) {
-                        LOG.debug("Running connection check for connector {}", 
ckey);
-                    } else {
-                        try {
-                            timeoutHandlingConnectionChecker(
-                                    new ConnectorChecker(ckey), 
connectorTestTimeout, connectors, runningConnCheck);
-
-                            runningConnCheck.add(ckey);
-                        } catch (Exception e) {
-                            LOG.error("Unexpected error", e);
-                        }
-                    }
+                case CHECK_CONNECTOR ->
+                    checkConnection(
+                            new ConnectorChecker(target),
+                            connectorTestTimeout, connectors, 
runningConnCheck, handler);
+
+                case CHECK_RESOURCE ->
+                    checkConnection(
+                            new ResourceChecker(target),
+                            resourceTestTimeout, resources, runningResCheck, 
handler);
+
+                case ADD_ENDPOINT -> {
+                    String source = obj.get("source").asString();
+                    String scope = obj.get("scope").asString();
+                    handler.appendJavaScript("addEndpoint('%s', '%s', 
'%s');".formatted(source, target, scope));
                 }
 
-                case CHECK_RESOURCE -> {
-                    String rkey = obj.get("target").asString();
-
-                    if (resources.containsKey(rkey)) {
-                        handler.push(resources.get(rkey));
-                    } else {
-                        handler.push("{ \"status\": \"%s\", \"target\": 
\"%s\"}".
-                                formatted(TopologyNode.Status.UNKNOWN, rkey));
-                    }
-
-                    if (runningResCheck.contains(rkey)) {
-                        LOG.debug("Running connection check for resource {}", 
rkey);
-                    } else {
-                        try {
-                            timeoutHandlingConnectionChecker(
-                                    new ResourceChecker(rkey), 
resourceTestTimeout, resources, runningResCheck);
-
-                            runningResCheck.add(rkey);
-                        } catch (Exception e) {
-                            LOG.error("Unexpected error", e);
-                        }
-                    }
-                }
-
-                case ADD_ENDPOINT ->
-                    handler.appendJavaScript("addEndpoint('%s', '%s', 
'%s');".formatted(
-                            obj.get("source").asString(),
-                            obj.get("target").asString(),
-                            obj.get("scope").asString()));
-
                 default -> {
                 }
             }
-        } catch (JacksonException e) {
+        } catch (Exception e) {
             LOG.error("Error managing websocket message", e);
         }
     }
@@ -212,7 +139,70 @@ public class TopologyWebSocketBehavior extends 
BasePageWebSocketBehavior.OnMessa
         return this.resources.keySet().containsAll(resources);
     }
 
-    protected abstract class Checker implements Callable<String> {
+    protected void checkConnection(
+            final Checker checker,
+            final Integer timeout,
+            final Map<String, StatusMessage> responses,
+            final Set<String> running,
+            final WebSocketRequestHandler handler) {
+
+        StatusMessage response = responses.get(checker.key);
+        handler.push(MAPPER.valueToTree(response == null
+                ? new StatusMessage(TopologyNode.Status.UNKNOWN, checker.key)
+                : response).toString());
+
+        if (response == null && running.add(checker.key)) {
+            SyncopeConsoleSession session = SyncopeConsoleSession.get();
+            session.execute(() -> {
+                try {
+                    StatusMessage result = checkWithTimeout(checker, timeout, 
session);
+                    responses.put(checker.key, result);
+                    handler.push(MAPPER.valueToTree(result).toString());
+                } finally {
+                    running.remove(checker.key);
+                }
+            });
+        }
+    }
+
+    protected StatusMessage checkWithTimeout(
+            final Checker checker,
+            final Integer timeout,
+            final SyncopeConsoleSession session) {
+
+        Future<Boolean> task = null;
+        try {
+            boolean reachable;
+            if (timeout == null || timeout < 0) {
+                reachable = checker.call();
+            } else {
+                task = session.execute(checker);
+                reachable = task.get(timeout, TimeUnit.SECONDS);
+            }
+            return new StatusMessage(reachable
+                    ? TopologyNode.Status.REACHABLE
+                    : TopologyNode.Status.UNREACHABLE, checker.key);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            return new StatusMessage(TopologyNode.Status.UNREACHABLE, 
checker.key);
+        } catch (TimeoutException e) {
+            LOG.warn("Connection with {} timed out", checker.key);
+            return new StatusMessage(TopologyNode.Status.UNREACHABLE, 
checker.key);
+        } catch (Exception e) {
+            LOG.warn("Error checking connection for {}", checker.key, e);
+            return new StatusMessage(TopologyNode.Status.FAILURE, checker.key);
+        } finally {
+            if (task != null && !task.isDone()) {
+                task.cancel(true);
+            }
+        }
+    }
+
+    protected record StatusMessage(TopologyNode.Status status, String target) 
implements Serializable {
+
+    }
+
+    protected abstract static class Checker implements Callable<Boolean> {
 
         protected final String key;
 
@@ -228,15 +218,8 @@ public class TopologyWebSocketBehavior extends 
BasePageWebSocketBehavior.OnMessa
         }
 
         @Override
-        public String call() {
-            try {
-                return "{ \"status\": \"%s\", \"target\": \"%s\"}".formatted(
-                        connectorRestClient.check(coreAddress, domain, jwt, 
key)
-                        ? TopologyNode.Status.REACHABLE : 
TopologyNode.Status.UNREACHABLE, key);
-            } catch (Exception e) {
-                LOG.warn("Error checking connection for {}", key, e);
-                return "{ \"status\": \"%s\", \"target\": 
\"%s\"}".formatted(TopologyNode.Status.FAILURE, key);
-            }
+        public Boolean call() throws IOException {
+            return connectorRestClient.check(coreAddress, domain, jwt, key);
         }
     }
 
@@ -247,15 +230,8 @@ public class TopologyWebSocketBehavior extends 
BasePageWebSocketBehavior.OnMessa
         }
 
         @Override
-        public String call() {
-            try {
-                return "{ \"status\": \"%s\", \"target\": \"%s\"}".formatted(
-                        resourceRestClient.check(coreAddress, domain, jwt, key)
-                        ? TopologyNode.Status.REACHABLE : 
TopologyNode.Status.UNREACHABLE, key);
-            } catch (Exception e) {
-                LOG.warn("Error checking connection for {}", key, e);
-                return "{ \"status\": \"%s\", \"target\": 
\"%s\"}".formatted(TopologyNode.Status.FAILURE, key);
-            }
+        public Boolean call() throws IOException {
+            return resourceRestClient.check(coreAddress, domain, jwt, key);
         }
     }
 }
diff --git 
a/client/idm/console/src/main/resources/META-INF/resources/css/topology.scss 
b/client/idm/console/src/main/resources/META-INF/resources/css/topology.scss
index de890ca91c..d1ce20859e 100644
--- a/client/idm/console/src/main/resources/META-INF/resources/css/topology.scss
+++ b/client/idm/console/src/main/resources/META-INF/resources/css/topology.scss
@@ -21,7 +21,13 @@
 @import 'topology-theme';
 @import 'utils';
 
+.app-wrapper {
+  height: 100dvh;
+  min-height: 0;
+}
+
 .content-wrapper.accent-success {
+  min-height: 0;
   overflow: hidden;
 }
 
@@ -32,7 +38,8 @@
 #topology {
   position: relative;
   border: 0;
-  height: calc(100vh - 175px);
+  flex: 1;
+  min-height: 0;
   overflow: hidden;
   cursor: grab;
 }
@@ -170,14 +177,45 @@
 }
 
 /*
- * Zoom toolbar
+ * Topology toolbar
  */
 
-#zoom {
+#topology-toolbar {
+  flex-shrink: 0;
   display: flex;
   justify-content: flex-end;
   align-items: center;
   gap: 6px;
+  flex-wrap: wrap;
+
+  .topology-zoom-control,
+  ul.menu > li > a.btn,
+  .topology-toolbar-btn {
+    display: inline-flex;
+    align-items: center;
+    box-sizing: border-box;
+    height: 38px;
+    border: 1px solid var(--topology-toolbar-border);
+    border-radius: 8px;
+    background: var(--topology-toolbar-bg);
+    color: var(--topology-toolbar-fg);
+  }
+
+  .topology-zoom-control {
+    gap: 4px;
+    padding: 4px 6px;
+
+    input {
+      width: clamp(80px, 10vw, 140px);
+      cursor: pointer;
+    }
+
+    output {
+      min-width: 5ch;
+      text-align: right;
+      font-variant-numeric: tabular-nums;
+    }
+  }
 
   ul.menu {
     display: flex;
@@ -197,12 +235,7 @@
   ul.menu > li > a.btn,
   .topology-toolbar-btn {
     appearance: none;
-
-    border: 1px solid var(--topology-toolbar-border);
-    border-radius: 999px;
-
-    background: var(--topology-toolbar-bg);
-    color: var(--topology-toolbar-fg);
+    justify-content: center;
 
     padding: 6px 10px;
 
diff --git 
a/client/idm/console/src/main/resources/META-INF/resources/js/idmTopology.js 
b/client/idm/console/src/main/resources/META-INF/resources/js/idmTopology.js
index b604f532c2..9ed7f7fd2a 100644
--- a/client/idm/console/src/main/resources/META-INF/resources/js/idmTopology.js
+++ b/client/idm/console/src/main/resources/META-INF/resources/js/idmTopology.js
@@ -16,889 +16,319 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-const def = {
-    paintStyle: {
+
+// Graph connections, status updates and page initialization.
+
+(function (topology) {
+    let checkRunId = 0;
+
+    const def = {
+        paintStyle: {
+            lineWidth: 2,
+            strokeStyle: "#94a3b8",
+            outlineColor: "rgba(15, 23, 42, 0.18)",
+            outlineWidth: 1.5,
+            lineCap: "round",
+            lineJoin: "round"
+        },
+        connectorPaintStyle: {
+            lineWidth: 2
+        },
+        anchor: "AutoDefault",
+        detachable: false,
+        endpointStyle: {
+            fillStyle: "#cbd5e1",
+            outlineColor: "rgba(15, 23, 42, 0.18)",
+            outlineWidth: 1,
+            radius: 3
+        }
+    };
+
+    const failedConnectorStyle = {
         lineWidth: 2,
-        strokeStyle: "#94a3b8",
+        strokeStyle: "rgba(148, 163, 184, 0.55)",
         outlineColor: "rgba(15, 23, 42, 0.18)",
-        outlineWidth: 1.5,
-        lineCap: "round",
-        lineJoin: "round"
-    },
-    connectorPaintStyle: {
-        lineWidth: 2
-    },
-    anchor: "AutoDefault",
-    detachable: false,
-    endpointStyle: {
-        fillStyle: "#cbd5e1",
+        outlineWidth: 1.5
+    };
+
+    const failedConnectorHoverStyle = {
+        strokeStyle: "#FFFFFF"
+    };
+
+    const failedEndpointStyle = {
+        fillStyle: "rgba(148, 163, 184, 0.45)",
         outlineColor: "rgba(15, 23, 42, 0.18)",
         outlineWidth: 1,
         radius: 3
-    }
-};
-
-const failedConnectorStyle = {
-    lineWidth: 2,
-    strokeStyle: "rgba(148, 163, 184, 0.55)",
-    outlineColor: "rgba(15, 23, 42, 0.18)",
-    outlineWidth: 1.5
-};
-
-const failedConnectorHoverStyle = {
-    strokeStyle: "#FFFFFF"
-};
-
-const failedEndpointStyle = {
-    fillStyle: "rgba(148, 163, 184, 0.45)",
-    outlineColor: "rgba(15, 23, 42, 0.18)",
-    outlineWidth: 1,
-    radius: 3
-};
-
-const disabledConnectorStyle = {
-    lineWidth: 2,
-    strokeStyle: "#fb7185",
-    outlineColor: "rgba(15, 23, 42, 0.18)",
-    outlineWidth: 1.5
-};
-
-const disabledConnectorHoverStyle = {
-    strokeStyle: "#FF8C00"
-};
-
-const disabledEndpointStyle = {
-    fillStyle: "rgba(251, 113, 133, 0.8)",
-    outlineColor: "rgba(15, 23, 42, 0.18)",
-    outlineWidth: 1,
-    radius: 3
-};
-
-const enabledConnectorStyle = {
-    lineWidth: 2,
-    strokeStyle: "#22c55e",
-    outlineColor: "rgba(15, 23, 42, 0.18)",
-    outlineWidth: 1.5
-};
-
-const enabledConnectorHoverStyle = {
-    strokeStyle: "#16a34a"
-};
-
-const enabledEndpointStyle = {
-    fillStyle: "rgba(34, 197, 94, 0.35)",
-    outlineColor: "rgba(15, 23, 42, 0.18)",
-    outlineWidth: 1,
-    radius: 3
-};
-
-window.disable = function (targetName) {
-    jsPlumb.ready(function () {
-        jsPlumb.select({target: 
targetName}).setPaintStyle(disabledConnectorStyle).setHoverPaintStyle(disabledConnectorHoverStyle);
-        jsPlumb.selectEndpoints({element: 
[targetName]}).setPaintStyle(disabledEndpointStyle);
-    });
-}
-
-window.enable = function (targetName) {
-    jsPlumb.ready(function () {
-        jsPlumb.select({target: 
targetName}).setPaintStyle(enabledConnectorStyle).setHoverPaintStyle(enabledConnectorHoverStyle);
-        jsPlumb.selectEndpoints({element: 
[targetName]}).setPaintStyle(enabledEndpointStyle);
-    });
-}
-
-window.failure = function (targetName) {
-    jsPlumb.ready(function () {
-        jsPlumb.select({target: 
targetName}).setPaintStyle(failedConnectorStyle).setHoverPaintStyle(failedConnectorHoverStyle);
-        jsPlumb.selectEndpoints({element: 
[targetName]}).setPaintStyle(failedEndpointStyle);
-    });
-}
-
-window.unknown = function (targetName) {
-}
-
-function getTopology() {
-    const topology = $.cookie("topology");
-    let val;
-    if (topology == null) {
-        val = {};
-    } else {
-        val = JSON.parse(decodeURIComponent(topology));
-    }
+    };
 
-    return val;
-}
-
-window.refreshPosition = function (element) {
-    const val = getTopology();
+    const disabledConnectorStyle = {
+        lineWidth: 2,
+        strokeStyle: "#fb7185",
+        outlineColor: "rgba(15, 23, 42, 0.18)",
+        outlineWidth: 1.5
+    };
 
-    const id = $(element).attr('id');
-    const left = $(element).css('left');
-    const top = $(element).css('top');
+    const disabledConnectorHoverStyle = {
+        strokeStyle: "#FF8C00"
+    };
 
-    if (val[id] == null) {
-        val[id] = {'top': top, 'left': left};
-    } else {
-        val[id].top = top;
-        val[id].left = left;
-    }
+    const disabledEndpointStyle = {
+        fillStyle: "rgba(251, 113, 133, 0.8)",
+        outlineColor: "rgba(15, 23, 42, 0.18)",
+        outlineWidth: 1,
+        radius: 3
+    };
 
-    $.cookie("topology", JSON.stringify(val), {expires: 9999});
-}
+    const enabledConnectorStyle = {
+        lineWidth: 2,
+        strokeStyle: "#22c55e",
+        outlineColor: "rgba(15, 23, 42, 0.18)",
+        outlineWidth: 1.5
+    };
 
-window.setPosition = function (id, x, y) {
-    const val = getTopology();
+    const enabledConnectorHoverStyle = {
+        strokeStyle: "#16a34a"
+    };
 
-    try {
-        // We cannot use jQuery selector for id since the syntax of connector 
server id
-        const element = $(document.getElementById(id));
+    const enabledEndpointStyle = {
+        fillStyle: "rgba(34, 197, 94, 0.35)",
+        outlineColor: "rgba(15, 23, 42, 0.18)",
+        outlineWidth: 1,
+        radius: 3
+    };
 
-        if (val[id] == null) {
-            element.css("left", x + "px");
-            element.css("top", y + "px");
-        } else {
-            element.css("left", val[id].left);
-            element.css("top", val[id].top);
-        }
-    } catch (err) {
-        console.log("Failure setting position for ", id);
+    // Connect existing nodes once jsPlumb is ready.
+    function connect(source, target, scope) {
+        jsPlumb.ready(function () {
+            if (jsPlumb.select({source: source, target: target, scope: scope}) 
!= null) {
+                jsPlumb.connect({source: source, target: target, scope: 
scope}, def);
+            }
+        });
     }
-}
 
-window.setZoom = function (el, zoom, instance, transformOrigin) {
-    transformOrigin = transformOrigin || [0.5, 0.5];
-    instance = instance || jsPlumb;
-    el = el || instance.getContainer();
+    // Position a new node near its source, make it draggable and connect it.
+    function addEndpoint(source, target, scope) {
+        const sourceElement = $(document.getElementById(source));
 
-    const p = ["webkit", "moz", "ms", "o"],
-        s = "scale(" + zoom + ")",
-        oString = (transformOrigin[0] * 100) + "% " + (transformOrigin[1] * 
100) + "%";
+        const top = parseFloat(sourceElement.css("top")) + 10;
+        const left = parseFloat(sourceElement.css("left")) - 150;
 
-    for (let i = 0; i < p.length; i++) {
-        el.style[p[i] + "Transform"] = s;
-        el.style[p[i] + "TransformOrigin"] = oString;
+        topology.setPosition(target, left, top);
+        jsPlumb.ready(function () {
+            
jsPlumb.draggable(jsPlumb.getSelector(document.getElementById(target)));
+            jsPlumb.connect({source: source, target: target, scope: scope}, 
def);
+        });
     }
 
-    el.style["transform"] = s;
-    el.style["transformOrigin"] = oString;
-
-    instance.setZoom(zoom);
-};
-
-window.zoomIn = function (el, instance, transformOrigin) {
-    const val = getTopology();
-    let zoom;
-    if (val.__zoom__ == null) {
-        zoom = 0.69;
-    } else {
-        zoom = val.__zoom__ + 0.01;
+    // Apply a connection state to both its incoming lines and endpoints.
+    function topologyApplyStyle(targetName, connectorStyle, hoverStyle, 
endpointStyle) {
+        jsPlumb.ready(function () {
+            jsPlumb.select({target: 
targetName}).setPaintStyle(connectorStyle).setHoverPaintStyle(hoverStyle);
+            jsPlumb.selectEndpoints({element: 
[targetName]}).setPaintStyle(endpointStyle);
+        });
     }
 
-    setZoom(el, zoom, instance, transformOrigin);
-
-    val['__zoom__'] = zoom;
-    $.cookie("topology", JSON.stringify(val), {expires: 9999});
-};
-
-window.zoomOut = function (el, instance, transformOrigin) {
-    const val = getTopology();
-    let zoom;
-    if (val.__zoom__ == null) {
-        zoom = 0.67;
-    } else {
-        zoom = val.__zoom__ - 0.01;
+    // Display the reachable state for a node.
+    function enable(targetName) {
+        topologyApplyStyle(targetName, enabledConnectorStyle, 
enabledConnectorHoverStyle, enabledEndpointStyle);
     }
 
-    setZoom(el, zoom, instance, transformOrigin);
-
-    val['__zoom__'] = zoom;
-    $.cookie("topology", JSON.stringify(val), {expires: 9999});
-};
-
-window.connect = function (source, target, scope) {
-    jsPlumb.ready(function () {
-        if (jsPlumb.select({source: source, target: target, scope: scope}) != 
null) {
-            jsPlumb.connect({source: source, target: target, scope: scope}, 
def);
-        }
-    });
-}
-
-window.activate = function (zoom) {
-    jsPlumb.ready(function () {
-        jsPlumb.draggable(jsPlumb.getSelector(".window"));
-        jsPlumb.setContainer("drawing");
-
-        jsPlumb.Defaults.MaxConnections = 1000;
-
-        (function initTopologyPanning() {
-            const $topology = $("#topology");
-            const $drawing = $("#drawing");
-            if ($topology.length === 0 || $drawing.length === 0) {
-                return;
-            }
-
-            $topology.off(".topopanning");
-            $(document).off(".topopanning");
+    // Display the unreachable state for a node.
+    function disable(targetName) {
+        topologyApplyStyle(targetName, disabledConnectorStyle, 
disabledConnectorHoverStyle, disabledEndpointStyle);
+    }
 
-            let dragging = false;
-            let startX = 0;
-            let startY = 0;
-            let startLeft = 0;
-            let startTop = 0;
+    // Display a failed connection check for a node.
+    function failure(targetName) {
+        topologyApplyStyle(targetName, failedConnectorStyle, 
failedConnectorHoverStyle, failedEndpointStyle);
+    }
 
-            function panBlocked(target) {
-                return $(target).closest(".window, ._jsPlumb_connector, 
._jsPlumb_endpoint, ._jsPlumb_overlay").length > 0;
-            }
+    // Send checks in batches; a newer run stops any requests not yet sent.
+    function checkConnection() {
+        jsPlumb.ready(function () {
+            const items = [];
 
-            $topology.on("mousedown.topopanning", function (e) {
-                if (e.button !== 0) {
-                    return;
-                }
-                if (panBlocked(e.target)) {
-                    return;
+            jsPlumb.select({scope: "CONNECTOR"}).each(function (connection) {
+                const id = connection && connection.target ? 
connection.target.id : null;
+                if (id) {
+                    items.push({kind: "CHECK_CONNECTOR", id: id});
                 }
-
-                dragging = true;
-                startX = e.clientX;
-                startY = e.clientY;
-                startLeft = topologyParsePx($drawing.css("left"), 0);
-                startTop = topologyParsePx($drawing.css("top"), 0);
-                $topology.addClass("topology-panning");
-                e.preventDefault();
             });
-
-            $(document).on("mousemove.topopanning", function (e) {
-                if (!dragging) {
-                    return;
+            jsPlumb.select({scope: "RESOURCE"}).each(function (connection) {
+                const id = connection && connection.target ? 
connection.target.id : null;
+                if (id) {
+                    items.push({kind: "CHECK_RESOURCE", id: id});
                 }
-                const dx = e.clientX - startX;
-                const dy = e.clientY - startY;
-                $drawing.css("left", (startLeft + dx) + "px");
-                $drawing.css("top", (startTop + dy) + "px");
             });
 
-            $(document).on("mouseup.topopanning", function () {
-                if (!dragging) {
+            checkRunId++;
+            const runId = checkRunId;
+
+            const batchSize = 25;
+            let idx = 0;
+
+            // Send the next batch only if this is still the latest check run.
+            function step() {
+                if (checkRunId !== runId) {
                     return;
                 }
-                dragging = false;
-                $("#topology").removeClass("topology-panning");
-                jsPlumb.repaintEverything();
-            });
-        })();
+                const end = Math.min(idx + batchSize, items.length);
 
-        const val = getTopology();
-        if (val.__zoom__ == null) {
-            setZoom($("#drawing")[0], zoom);
-        } else {
-            setZoom($("#drawing")[0], val.__zoom__);
-        }
-    });
-}
-
-window.checkConnection = function () {
-    jsPlumb.ready(function () {
-        const items = [];
+                // Keep the last known colors while checks run; sending a 
request needs no repaint.
+                for (let i = idx; i < end; i++) {
+                    const it = items[i];
+                    Wicket.WebSocket.send(JSON.stringify({kind: it.kind, 
target: it.id}));
+                }
 
-        jsPlumb.select({scope: "CONNECTOR"}).each(function (connection) {
-            const id = connection && connection.target ? connection.target.id 
: null;
-            if (id) {
-                items.push({kind: "CHECK_CONNECTOR", id: id, conn: 
connection});
-            }
-        });
-        jsPlumb.select({scope: "RESOURCE"}).each(function (connection) {
-            const id = connection && connection.target ? connection.target.id 
: null;
-            if (id) {
-                items.push({kind: "CHECK_RESOURCE", id: id, conn: connection});
+                idx = end;
+                if (idx < items.length) {
+                    window.setTimeout(step, 0);
+                }
             }
-        });
 
-        window.__topologyCheckRunId = (window.__topologyCheckRunId || 0) + 1;
-        const runId = window.__topologyCheckRunId;
+            step();
+        });
+    }
 
-        const batchSize = 25;
-        let idx = 0;
+    // Queue WebSocket results by node and skip states already applied.
+    function initTopologyStatuses() {
+        const pendingStatusByTarget = new Map();
+        const appliedStatusByTarget = new Map();
+        let flushScheduled = false;
 
-        function step() {
-            if (window.__topologyCheckRunId !== runId) {
+        // Map backend connection states to their visual styles.
+        function applyStatus(targetId, status) {
+            if (!targetId) {
                 return;
             }
-            const end = Math.min(idx + batchSize, items.length);
-
-            if (jsPlumb.setSuspendDrawing) {
-                jsPlumb.setSuspendDrawing(true);
+            switch (status) {
+                case 'REACHABLE':
+                    enable(targetId);
+                    break;
+                case 'UNREACHABLE':
+                    disable(targetId);
+                    break;
+                case 'FAILURE':
+                    failure(targetId);
+                    break;
+                default:
+                    break;
             }
-            try {
-                for (let i = idx; i < end; i++) {
-                    const it = items[i];
-                    if (it.conn) {
-                        it.conn.setPaintStyle(def.paintStyle);
-                    }
-                    jsPlumb.selectEndpoints({element: 
[it.id]}).setPaintStyle(def.endpointStyle);
+        }
 
-                    Wicket.WebSocket.send("{ \"kind\":\"" + it.kind + "\", 
\"target\":\"" + it.id + "\" }");
+        // Apply a small batch of pending states, then yield to browser input.
+        function flushStatuses() {
+            flushScheduled = false;
+            let count = 0;
+            for (const [targetId, status] of pendingStatusByTarget) {
+                pendingStatusByTarget.delete(targetId);
+                if (appliedStatusByTarget.get(targetId) !== status) {
+                    applyStatus(targetId, status);
+                    appliedStatusByTarget.set(targetId, status);
                 }
-            } finally {
-                if (jsPlumb.setSuspendDrawing) {
-                    jsPlumb.setSuspendDrawing(false, true);
-                } else {
-                    jsPlumb.repaintEverything();
+                // Apply a small batch, leaving the browser free to handle 
input.
+                if (++count >= 8) {
+                    break;
                 }
             }
-
-            idx = end;
-            if (idx < items.length) {
-                window.setTimeout(step, 0);
+            if (pendingStatusByTarget.size > 0) {
+                scheduleFlush();
             }
         }
 
-        step();
-    });
-}
-
-window.addEndpoint = function (source, target, scope) {
-    const sourceElement = $(document.getElementById(source));
-
-    const top = parseFloat(sourceElement.css("top")) + 10;
-    const left = parseFloat(sourceElement.css("left")) - 150;
-
-    setPosition(target, left, top);
-    jsPlumb.ready(function () {
-        
jsPlumb.draggable(jsPlumb.getSelector(document.getElementById(target)));
-        jsPlumb.connect({source: source, target: target, scope: scope}, def);
-    });
-}
-
-function topologyParsePx(val, fallback) {
-    if (val == null) {
-        return fallback;
-    }
-    if (typeof val === 'number') {
-        return val;
-    }
-    const n = parseFloat(String(val).replace("px", ""));
-    return isNaN(n) ? fallback : n;
-}
-
-function topologyUniqPush(arr, value) {
-    for (let i = 0; i < arr.length; i++) {
-        if (arr[i] === value) {
-            return;
-        }
-    }
-    arr.push(value);
-}
-
-function topologyBuildChildren(connections) {
-    const childrenById = {};
-    const indegree = {};
-    for (let i = 0; i < connections.length; i++) {
-        const c = connections[i];
-        if (!c || !c.source || !c.target) {
-            continue;
-        }
-        const sourceId = c.source.id;
-        const targetId = c.target.id;
-        if (!sourceId || !targetId || sourceId === targetId) {
-            continue;
-        }
-
-        if (!childrenById[sourceId]) {
-            childrenById[sourceId] = [];
-        }
-        topologyUniqPush(childrenById[sourceId], targetId);
-
-        if (indegree[targetId] == null) {
-            indegree[targetId] = 1;
-        } else {
-            indegree[targetId] = indegree[targetId] + 1;
-        }
-        if (indegree[sourceId] == null) {
-            indegree[sourceId] = 0;
-        }
-    }
-    return {childrenById: childrenById, indegree: indegree};
-}
-
-function topologyPickRoot(allNodeIds, childrenById, indegree) {
-    const rootEl = $("#drawing .window.topology_root")[0];
-    if (rootEl && rootEl.id) {
-        return rootEl.id;
-    }
-
-    for (let i = 0; i < allNodeIds.length; i++) {
-        const id = allNodeIds[i];
-        if ((indegree[id] == null || indegree[id] === 0) && childrenById[id] 
&& childrenById[id].length > 0) {
-            return id;
-        }
-    }
-
-    return allNodeIds.length > 0 ? allNodeIds[0] : null;
-}
-
-function topologyComputeSubtreeWidth(nodeId, childrenById, nodeSizeById, hGap, 
visiting, memo) {
-    if (memo[nodeId] != null) {
-        return memo[nodeId];
-    }
-    if (visiting[nodeId]) {
-        memo[nodeId] = nodeSizeById[nodeId] ? nodeSizeById[nodeId].w : 140;
-        return memo[nodeId];
-    }
-
-    visiting[nodeId] = true;
-    const children = childrenById[nodeId] || [];
-    const ownW = nodeSizeById[nodeId] ? nodeSizeById[nodeId].w : 140;
-    if (children.length === 0) {
-        memo[nodeId] = ownW;
-        visiting[nodeId] = false;
-        return memo[nodeId];
-    }
-
-    let total = 0;
-    for (let i = 0; i < children.length; i++) {
-        total += topologyComputeSubtreeWidth(children[i], childrenById, 
nodeSizeById, hGap, visiting, memo);
-    }
-    total += hGap * (children.length - 1);
-    memo[nodeId] = Math.max(ownW, total);
-    visiting[nodeId] = false;
-    return memo[nodeId];
-}
-
-function topologyAssignPositions(nodeId, childrenById, nodeSizeById, 
subtreeWById, levelY, xLeft, hGap, vGap, outPos, visited) {
-    if (visited[nodeId]) {
-        return;
-    }
-    visited[nodeId] = true;
-
-    const nodeSize = nodeSizeById[nodeId] || {w: 140, h: 55};
-    const subtreeW = subtreeWById[nodeId] != null ? subtreeWById[nodeId] : 
nodeSize.w;
-    const xCenter = xLeft + subtreeW / 2;
-    outPos[nodeId] = {x: xCenter - nodeSize.w / 2, y: levelY};
-
-    const children = childrenById[nodeId] || [];
-    if (children.length === 0) {
-        return;
-    }
-
-    const nextY = levelY + nodeSize.h + vGap;
-    let childX = xLeft;
-    for (let i = 0; i < children.length; i++) {
-        const childId = children[i];
-        const childSubtreeW = subtreeWById[childId] != null
-            ? subtreeWById[childId]
-            : (nodeSizeById[childId] ? nodeSizeById[childId].w : 140);
-
-        topologyAssignPositions(childId, childrenById, nodeSizeById, 
subtreeWById, nextY, childX, hGap, vGap, outPos, visited);
-        childX += childSubtreeW + hGap;
-    }
-}
-
-function topologyShowVeil() {
-    const veil = document.getElementById("veil");
-    if (veil) {
-        veil.style.display = "block";
-    }
-}
-
-function topologyHideVeil() {
-    const veil = document.getElementById("veil");
-    if (veil) {
-        veil.style.display = "none";
-    }
-}
-
-function topologyRunWithOptionalVeil(fn, options) {
-    options = options || {};
-    const delayMs = options.delayMs != null ? options.delayMs : 120;
-    let shown = false;
-
-    const t = window.setTimeout(function () {
-        topologyShowVeil();
-        shown = true;
-    }, delayMs);
-
-    window.setTimeout(function () {
-        try {
-            fn();
-        } finally {
-            window.clearTimeout(t);
-            if (shown) {
-                topologyHideVeil();
+        // Keep at most one timer waiting to apply queued states.
+        function scheduleFlush() {
+            if (flushScheduled) {
+                return;
             }
+            flushScheduled = true;
+            window.setTimeout(flushStatuses, 32);
         }
-    }, 0);
-}
-
-window.autoLayoutTree = function (options) {
-    options = options || {};
-    const hGap = options.hGap != null ? options.hGap : 70;
-    const vGap = options.vGap != null ? options.vGap : 80;
-    const padding = options.padding != null ? options.padding : 10;
-    const centerInView = options.centerInView != null ? options.centerInView : 
false;
-
-    topologyRunWithOptionalVeil(function () {
-        jsPlumb.ready(function () {
-            try {
-                const nodeEls = $("#drawing .window").toArray();
-                if (!nodeEls || nodeEls.length === 0) {
-                    return;
-                }
-
-                // Build node sizes (unscaled), and stable list of ids.
-                const nodeSizeById = {};
-                const nodeIds = [];
-                for (let i = 0; i < nodeEls.length; i++) {
-                    const el = nodeEls[i];
-                    if (!el || !el.id) {
-                        continue;
-                    }
-                    nodeIds.push(el.id);
-                    nodeSizeById[el.id] = {w: el.offsetWidth || 140, h: 
el.offsetHeight || 55};
-                }
-
-                const connections = jsPlumb.getAllConnections ? 
jsPlumb.getAllConnections() : [];
-                const graph = topologyBuildChildren(connections);
-                const rootId = topologyPickRoot(nodeIds, graph.childrenById, 
graph.indegree);
-                if (!rootId) {
-                    return;
-                }
-
-                // Compute subtree widths.
-                const subtreeWById = {};
-                for (let j = 0; j < nodeIds.length; j++) {
-                    topologyComputeSubtreeWidth(
-                        nodeIds[j],
-                        graph.childrenById,
-                        nodeSizeById,
-                        hGap,
-                        {},
-                        subtreeWById);
-                }
-
-                // Assign relative positions starting from root.
-                const relPos = {};
-                topologyAssignPositions(
-                    rootId,
-                    graph.childrenById,
-                    nodeSizeById,
-                    subtreeWById,
-                    0,
-                    0,
-                    hGap,
-                    vGap,
-                    relPos,
-                    {});
-
-                // Translate either to the current viewport center or keep 
root on its current position (default).
-                let dx = 0;
-                let dy = 0;
-                if (centerInView) {
-                    let minX = null, minY = null, maxX = null, maxY = null;
-                    for (let bb = 0; bb < nodeIds.length; bb++) {
-                        const bid = nodeIds[bb];
-                        const rp = relPos[bid];
-                        if (!rp) {
-                            continue;
-                        }
-                        const sz = nodeSizeById[bid] || {w: 140, h: 55};
-                        minX = minX == null ? rp.x : Math.min(minX, rp.x);
-                        minY = minY == null ? rp.y : Math.min(minY, rp.y);
-                        maxX = maxX == null ? (rp.x + sz.w) : Math.max(maxX, 
rp.x + sz.w);
-                        maxY = maxY == null ? (rp.y + sz.h) : Math.max(maxY, 
rp.y + sz.h);
-                    }
-
-                    if (minX != null && minY != null && maxX != null && maxY 
!= null) {
-                        const layoutCx = (minX + maxX) / 2;
-                        const layoutCy = (minY + maxY) / 2;
-
-                        const $topology = $("#topology");
-                        const $drawing = $("#drawing");
-                        const topoW = $topology.width() || 0;
-                        const topoH = $topology.height() || 0;
-                        const drawingLeft = 
topologyParsePx($drawing.css("left"), 0);
-                        const drawingTop = 
topologyParsePx($drawing.css("top"), 0);
-
-                        const topoCookie = getTopology();
-                        const z = (jsPlumb.getZoom && jsPlumb.getZoom()) || 
topoCookie.__zoom__ || 1 || 1;
-
-                        const viewCx = (topoW / 2 - drawingLeft) / z;
-                        const viewCy = (topoH / 2 - drawingTop) / z;
-
-                        dx = viewCx - layoutCx;
-                        dy = viewCy - layoutCy;
-                    }
-                } else {
-                    const rootEl = document.getElementById(rootId);
-                    let rootLeft = topologyParsePx(rootEl && rootEl.style ? 
rootEl.style.left : null, 0);
-                    let rootTop = topologyParsePx(rootEl && rootEl.style ? 
rootEl.style.top : null, 0);
-                    if (isNaN(rootLeft) || isNaN(rootTop) || (rootLeft === 0 
&& rootTop === 0)) {
-                        rootLeft = topologyParsePx($(rootEl).css("left"), 0);
-                        rootTop = topologyParsePx($(rootEl).css("top"), 0);
-                    }
-                    const rootRel = relPos[rootId];
-                    dx = rootLeft - (rootRel ? rootRel.x : 0);
-                    dy = rootTop - (rootRel ? rootRel.y : 0);
-                }
-
-                const topo = getTopology();
-                let placedMinX = null;
-                let placedMaxX = null;
-                let placedMaxY = null;
-
-                if (jsPlumb.setSuspendDrawing) {
-                    jsPlumb.setSuspendDrawing(true);
-                }
-
-                for (let k = 0; k < nodeIds.length; k++) {
-                    const id = nodeIds[k];
-                    const p = relPos[id];
-                    if (!p) {
-                        continue;
-                    }
-                    const x = Math.round(p.x + dx + padding);
-                    const y = Math.round(p.y + dy + padding);
-
-                    const nEl = document.getElementById(id);
-                    if (!nEl) {
-                        continue;
-                    }
-                    nEl.style.left = x + "px";
-                    nEl.style.top = y + "px";
-
-                    placedMinX = placedMinX == null ? x : Math.min(placedMinX, 
x);
-                    placedMaxX = placedMaxX == null ? x : Math.max(placedMaxX, 
x + (nodeSizeById[id] ? nodeSizeById[id].w : 140));
-                    placedMaxY = placedMaxY == null ? y : Math.max(placedMaxY, 
y + (nodeSizeById[id] ? nodeSizeById[id].h : 55));
-
-                    if (topo[id] == null) {
-                        topo[id] = {top: y + "px", left: x + "px"};
-                    } else {
-                        topo[id].top = y + "px";
-                        topo[id].left = x + "px";
-                    }
-                }
-
-                const unplaced = [];
-                for (let u = 0; u < nodeIds.length; u++) {
-                    if (!relPos[nodeIds[u]]) {
-                        unplaced.push(nodeIds[u]);
-                    }
-                }
-                if (unplaced.length > 0) {
-                    let startX = (placedMaxX == null ? 0 : placedMaxX) + hGap 
* 2;
-                    let startY = (placedMinX == null ? 0 : (placedMinX - 20));
-                    let colX = startX;
-                    let rowY = startY;
-                    let colW = 0;
-                    const maxColH = Math.max(420, (placedMaxY == null ? 420 : 
(placedMaxY - startY)));
-                    for (let ui = 0; ui < unplaced.length; ui++) {
-                        const uid = unplaced[ui];
-                        const us = nodeSizeById[uid] || {w: 140, h: 55};
-                        if ((rowY - startY) + us.h > maxColH) {
-                            colX = colX + colW + hGap;
-                            rowY = startY;
-                            colW = 0;
-                        }
-
-                        const ux = Math.round(colX + padding);
-                        const uy = Math.round(rowY + padding);
-                        const uEl = document.getElementById(uid);
-                        if (uEl) {
-                            uEl.style.left = ux + "px";
-                            uEl.style.top = uy + "px";
-                        }
-                        topo[uid] = {top: uy + "px", left: ux + "px"};
-
-                        rowY += us.h + 12;
-                        colW = Math.max(colW, us.w);
-                    }
-                }
-
-                $.cookie("topology", JSON.stringify(topo), {expires: 9999});
-
-                let treeMinX = null, treeMinY = null, treeMaxX = null, 
treeMaxY = null;
-                for (let tb = 0; tb < nodeIds.length; tb++) {
-                    const tid = nodeIds[tb];
-                    const tp = relPos[tid];
-                    if (!tp) {
-                        continue;
-                    }
-                    const ts = nodeSizeById[tid] || {w: 140, h: 55};
-                    const ax = tp.x + dx + padding;
-                    const ay = tp.y + dy + padding;
-                    treeMinX = treeMinX == null ? ax : Math.min(treeMinX, ax);
-                    treeMinY = treeMinY == null ? ay : Math.min(treeMinY, ay);
-                    treeMaxX = treeMaxX == null ? (ax + ts.w) : 
Math.max(treeMaxX, ax + ts.w);
-                    treeMaxY = treeMaxY == null ? (ay + ts.h) : 
Math.max(treeMaxY, ay + ts.h);
-                }
-                window.__topologyTreeBBox = (treeMinX == null) ? null : {
-                    minX: treeMinX,
-                    minY: treeMinY,
-                    maxX: treeMaxX,
-                    maxY: treeMaxY
-                };
-
-                if (jsPlumb.setSuspendDrawing) {
-                    jsPlumb.setSuspendDrawing(false, true);
-                } else {
-                    jsPlumb.repaintEverything();
-                }
 
-                if (window.__topologyTreeBBox) {
-                    recenterToTree();
-                }
-            } catch (err) {
-                console.debug("autoLayoutTree failed", err);
+        Wicket.Event.subscribe("/websocket/message", function (_jqEvent, 
message) {
+            const val = JSON.parse(message);
+            if (!val.target || val.status === "UNKNOWN") {
+                return;
             }
+            pendingStatusByTarget.set(val.target, val.status);
+            scheduleFlush();
         });
-    });
-};
-
-function topologyGetZoom() {
-    const topoCookie = getTopology();
-    const z = (jsPlumb.getZoom && jsPlumb.getZoom()) || topoCookie.__zoom__ || 
1;
-    return z || 1;
-}
-
-function topologyRecenterOnBBox(bbox) {
-    if (!bbox) {
-        return;
-    }
-    const $topology = $("#topology");
-    const $drawing = $("#drawing");
-    if ($topology.length === 0 || $drawing.length === 0) {
-        return;
     }
 
-    const topoW = $topology.width() || 0;
-    const topoH = $topology.height() || 0;
-    if (topoW <= 0 || topoH <= 0) {
-        return;
-    }
-
-    const z = topologyGetZoom();
-    const drawingW = $drawing.width() || 0;
-    const drawingH = $drawing.height() || 0;
-    const originX = drawingW * 0.5;
-    const originY = drawingH * 0.5;
-
-    const cx = (bbox.minX + bbox.maxX) / 2;
-    const cy = (bbox.minY + bbox.maxY) / 2;
-
-    const viewportCenterX = topoW / 2;
-    const viewportCenterY = topoH / 2;
-
-    const newLeft = viewportCenterX - originX - (cx - originX) * z;
-    const newTop = viewportCenterY - originY - (cy - originY) * z;
-
-    $drawing.css("left", Math.round(newLeft) + "px");
-    $drawing.css("top", Math.round(newTop) + "px");
-    jsPlumb.repaintEverything();
-}
-
-window.recenterToTree = function () {
-    topologyRunWithOptionalVeil(function () {
+    // Keep a complete saved layout; rebuild the tree if any node lacks a 
valid position.
+    function restoreTopologyLayout() {
         jsPlumb.ready(function () {
-            let bbox = window.__topologyTreeBBox;
-            if (!bbox) {
-                const nodeEls = $("#drawing .window").toArray();
-                let minX = null, minY = null, maxX = null, maxY = null;
-                for (let i = 0; i < nodeEls.length; i++) {
-                    const el = nodeEls[i];
-                    if (!el) {
-                        continue;
-                    }
-                    const x = topologyParsePx($(el).css("left"), 0);
-                    const y = topologyParsePx($(el).css("top"), 0);
-                    const w = el.offsetWidth || 140;
-                    const h = el.offsetHeight || 55;
-                    minX = minX == null ? x : Math.min(minX, x);
-                    minY = minY == null ? y : Math.min(minY, y);
-                    maxX = maxX == null ? (x + w) : Math.max(maxX, x + w);
-                    maxY = maxY == null ? (y + h) : Math.max(maxY, y + h);
-                }
-                bbox = (minX == null) ? null : {minX: minX, minY: minY, maxX: 
maxX, maxY: maxY};
+            const state = topology.getState();
+            const nodes = $("#drawing .window").toArray();
+            if (nodes.length > 0 && nodes.every(node => 
topology.hasPosition(state, node.id))) {
+                topology.treeBounds = null;
+                topology.recenterToTree();
+            } else {
+                topology.autoLayoutTree({centerInView: false});
             }
-
-            topologyRecenterOnBBox(bbox);
         });
-    });
-};
-
-jsPlumb.importDefaults({
-    Connector: ["Bezier", { curviness: 10 }],
-    DragOptions: {
-        cursor: "pointer",
-        zIndex: 2000
-    },
-    HoverClass: "connector-hover"
-});
-
-jQuery(function () {
-    const pendingStatusByTarget = {};
-    let flushScheduled = false;
-
-    function applyStatus(targetId, status) {
-        if (!targetId) {
-            return;
-        }
-        switch (status) {
-            case 'UNKNOWN':
-                unknown(targetId);
-                break;
-            case 'REACHABLE':
-                enable(targetId);
-                break;
-            case 'UNREACHABLE':
-                disable(targetId);
-                break;
-            case 'FAILURE':
-                failure(targetId);
-                break;
-            default:
-                break;
-        }
     }
 
-    function flushStatuses() {
-        flushScheduled = false;
-        const entries = Object.entries(pendingStatusByTarget);
-        if (entries.length === 0) {
-            return;
-        }
+    // Initialize storage, dragging, navigation and the saved or default zoom.
+    function activate(zoom) {
+        topology.initStorage();
+        jsPlumb.ready(function () {
+            jsPlumb.draggable(jsPlumb.getSelector(".window"));
+            jsPlumb.setContainer("drawing");
+            topology.initZoom();
 
-        for (const [k] of entries) {
-            delete pendingStatusByTarget[k];
-        }
+            jsPlumb.Defaults.MaxConnections = 1000;
 
-        jsPlumb.ready(function () {
-            if (jsPlumb.setSuspendDrawing) {
-                jsPlumb.setSuspendDrawing(true);
-            }
-            try {
-                for (let i = 0; i < entries.length; i++) {
-                    const [targetId, status] = entries[i];
-                    applyStatus(targetId, status);
-                }
-            } finally {
-                if (jsPlumb.setSuspendDrawing) {
-                    jsPlumb.setSuspendDrawing(false, true);
-                } else {
-                    jsPlumb.repaintEverything();
-                }
+            topology.initPanning();
+
+            const val = topology.getState();
+            if (!Number.isFinite(val.__zoom__) || val.__zoom__ <= 0) {
+                topology.setZoom($("#drawing")[0], zoom);
+            } else {
+                topology.setZoom($("#drawing")[0], val.__zoom__);
             }
         });
     }
 
-    function scheduleFlush() {
-        if (flushScheduled) {
-            return;
-        }
-        flushScheduled = true;
-        window.requestAnimationFrame(flushStatuses);
-    }
+    jsPlumb.importDefaults({
+        Connector: ["Bezier", { curviness: 10 }],
+        DragOptions: {
+            cursor: "pointer",
+            zIndex: 2000,
+            stop: topology.savePositions
+        },
+        HoverClass: "connector-hover"
+    });
 
-    Wicket.Event.subscribe("/websocket/message", function (_jqEvent, message) {
-        const val = JSON.parse(decodeURIComponent(message));
-        pendingStatusByTarget[val.target] = val.status;
-        scheduleFlush();
+    Object.assign(topology, {
+        connect,
+        addEndpoint,
+        enable,
+        disable,
+        failure,
+        checkConnection,
+        restoreTopologyLayout,
+        activate
     });
-});
+
+    // Preserve the entry points invoked by Wicket and existing page scripts.
+    Object.assign(window, {
+        activate: topology.activate,
+        setPosition: topology.setPosition,
+        restoreTopologyLayout: topology.restoreTopologyLayout,
+        autoLayoutTree: topology.autoLayoutTree,
+        recenterToTree: topology.recenterToTree,
+        checkConnection: topology.checkConnection,
+        connect: topology.connect,
+        addEndpoint: topology.addEndpoint,
+        setZoom: topology.setZoom,
+        zoomIn: topology.zoomIn,
+        zoomOut: topology.zoomOut,
+        enable: topology.enable,
+        disable: topology.disable,
+        failure: topology.failure
+    });
+
+    jQuery(initTopologyStatuses);
+})(window.SyncopeTopology);
diff --git 
a/client/idm/console/src/main/resources/META-INF/resources/js/idmTopologyCore.js
 
b/client/idm/console/src/main/resources/META-INF/resources/js/idmTopologyCore.js
new file mode 100644
index 0000000000..655d62176a
--- /dev/null
+++ 
b/client/idm/console/src/main/resources/META-INF/resources/js/idmTopologyCore.js
@@ -0,0 +1,223 @@
+/*
+ * 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.
+ */
+
+// Shared state, persistence and geometry.
+
+window.SyncopeTopology = window.SyncopeTopology || {};
+
+(function (topology) {
+    const config = topology.config = {
+        minZoom: 0.1,
+        maxZoom: 2,
+        zoomStep: 1.1,
+        nodeSize: {w: 140, h: 55}
+    };
+
+    topology.treeBounds = null;
+
+    // Read a CSS coordinate as a number, falling back when it is missing or 
invalid.
+    function topologyParsePx(val, fallback) {
+        if (val == null) {
+            return fallback;
+        }
+        if (typeof val === 'number') {
+            return val;
+        }
+        const n = parseFloat(String(val).replace("px", ""));
+        return isNaN(n) ? fallback : n;
+    }
+
+    // Show the shared loading overlay when it is available.
+    function topologyShowVeil() {
+        const veil = document.getElementById("veil");
+        if (veil) {
+            veil.style.display = "block";
+        }
+    }
+
+    // Hide the shared loading overlay.
+    function topologyHideVeil() {
+        const veil = document.getElementById("veil");
+        if (veil) {
+            veil.style.display = "none";
+        }
+    }
+
+    // Schedule layout work and clean up its loading-overlay timer afterward.
+    function topologyRunWithOptionalVeil(fn, options) {
+        options = options || {};
+        const delayMs = options.delayMs != null ? options.delayMs : 120;
+        let shown = false;
+
+        const t = window.setTimeout(function () {
+            topologyShowVeil();
+            shown = true;
+        }, delayMs);
+
+        window.setTimeout(function () {
+            try {
+                fn();
+            } finally {
+                window.clearTimeout(t);
+                if (shown) {
+                    topologyHideVeil();
+                }
+            }
+        }, 0);
+    }
+
+    let topologyState;
+
+    // Load the browser layout, keeping an empty state if storage cannot be 
read.
+    function initTopologyStorage() {
+        topologyState = Object.create(null);
+        try {
+            const saved = 
JSON.parse(window.localStorage.getItem("syncope.topology"));
+            if (saved && typeof saved === "object" && !Array.isArray(saved)) {
+                topologyState = saved;
+            }
+        } catch (err) {
+            console.debug("Unable to restore topology state", err);
+        }
+    }
+
+    // Return the in-memory layout, loading it on first use.
+    function getTopology() {
+        if (topologyState == null) {
+            initTopologyStorage();
+        }
+        return topologyState;
+    }
+
+    // Update the in-memory layout and persist it when browser storage is 
available.
+    function saveTopology(state) {
+        topologyState = state;
+        try {
+            window.localStorage.setItem("syncope.topology", 
JSON.stringify(state));
+        } catch (err) {
+            // Keep the current page usable even when browser storage is 
unavailable or full.
+            console.debug("Unable to persist topology state", err);
+        }
+    }
+
+    // Accept only saved entries with finite coordinates for both axes.
+    function topologyHasPosition(state, id) {
+        const position = Object.prototype.hasOwnProperty.call(state, id) ? 
state[id] : null;
+        return position && Number.isFinite(parseFloat(position.left)) && 
Number.isFinite(parseFloat(position.top));
+    }
+
+    // Prefer the active jsPlumb zoom, then a valid saved zoom, then the 
default scale.
+    function topologyGetZoom() {
+        const state = getTopology();
+        const current = jsPlumb.getZoom && jsPlumb.getZoom();
+        if (Number.isFinite(current) && current > 0) {
+            return current;
+        }
+        return Number.isFinite(state.__zoom__) && state.__zoom__ > 0 ? 
state.__zoom__ : 1;
+    }
+
+    // Save current nodes and zoom, discard stale entries and invalidate 
cached bounds.
+    function topologySavePositions() {
+        const state = Object.create(null);
+        state.__zoom__ = topologyGetZoom();
+        const nodes = $("#drawing .window").toArray();
+        for (const node of nodes) {
+            state[node.id] = {left: $(node).css("left"), top: 
$(node).css("top")};
+        }
+        saveTopology(state);
+        topology.treeBounds = null;
+    }
+
+    // Use the saved node position when valid, otherwise use the supplied 
coordinates.
+    function setPosition(id, x, y) {
+        const val = getTopology();
+
+        try {
+            // Connector server IDs can contain characters that are special in 
CSS selectors.
+            const element = $(document.getElementById(id));
+
+            if (!topologyHasPosition(val, id)) {
+                element.css("left", x + "px");
+                element.css("top", y + "px");
+            } else {
+                element.css("left", val[id].left);
+                element.css("top", val[id].top);
+            }
+        } catch (err) {
+            console.log("Failure setting position for ", id);
+        }
+    }
+
+    // Snapshot current node positions and unscaled sizes for layout or 
centering.
+    function topologyMeasureNodes() {
+        const layout = {nodeIds: [], nodeSizeById: {}, positions: {}};
+        for (const node of $("#drawing .window").toArray()) {
+            if (!node || !node.id) {
+                continue;
+            }
+            layout.nodeIds.push(node.id);
+            layout.nodeSizeById[node.id] = {
+                w: node.offsetWidth || config.nodeSize.w,
+                h: node.offsetHeight || config.nodeSize.h
+            };
+            layout.positions[node.id] = {
+                x: topologyParsePx($(node).css("left"), 0),
+                y: topologyParsePx($(node).css("top"), 0)
+            };
+        }
+        return layout;
+    }
+
+    // Measure only nodes with positions, optionally translating them into 
drawing coordinates.
+    function topologyGetBounds(layout, offset = {x: 0, y: 0}, padding = 0) {
+        let bounds = null;
+        for (const id of layout.nodeIds) {
+            const position = layout.positions[id];
+            if (!position) {
+                continue;
+            }
+            const size = layout.nodeSizeById[id] || config.nodeSize;
+            const x = position.x + offset.x + padding;
+            const y = position.y + offset.y + padding;
+            if (bounds == null) {
+                bounds = {minX: x, minY: y, maxX: x + size.w, maxY: y + 
size.h};
+            } else {
+                bounds.minX = Math.min(bounds.minX, x);
+                bounds.minY = Math.min(bounds.minY, y);
+                bounds.maxX = Math.max(bounds.maxX, x + size.w);
+                bounds.maxY = Math.max(bounds.maxY, y + size.h);
+            }
+        }
+        return bounds;
+    }
+
+    Object.assign(topology, {
+        parsePx: topologyParsePx,
+        runWithOptionalVeil: topologyRunWithOptionalVeil,
+        initStorage: initTopologyStorage,
+        getState: getTopology,
+        saveState: saveTopology,
+        hasPosition: topologyHasPosition,
+        getZoom: topologyGetZoom,
+        savePositions: topologySavePositions,
+        setPosition,
+        measureNodes: topologyMeasureNodes,
+        getBounds: topologyGetBounds
+    });
+})(window.SyncopeTopology);
diff --git 
a/client/idm/console/src/main/resources/META-INF/resources/js/idmTopologyLayout.js
 
b/client/idm/console/src/main/resources/META-INF/resources/js/idmTopologyLayout.js
new file mode 100644
index 0000000000..d831979d78
--- /dev/null
+++ 
b/client/idm/console/src/main/resources/META-INF/resources/js/idmTopologyLayout.js
@@ -0,0 +1,282 @@
+/*
+ * 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.
+ */
+
+// Tree layout and disconnected node placement.
+
+(function (topology) {
+    const config = topology.config;
+
+    // Append a child only once, preserving its original order.
+    function topologyUniqPush(arr, value) {
+        for (let i = 0; i < arr.length; i++) {
+            if (arr[i] === value) {
+                return;
+            }
+        }
+        arr.push(value);
+    }
+
+    // Build child lists and incoming-edge counts, ignoring invalid edges and 
self-links.
+    function topologyBuildChildren(connections) {
+        const childrenById = {};
+        const indegree = {};
+        for (let i = 0; i < connections.length; i++) {
+            const c = connections[i];
+            if (!c || !c.source || !c.target) {
+                continue;
+            }
+            const sourceId = c.source.id;
+            const targetId = c.target.id;
+            if (!sourceId || !targetId || sourceId === targetId) {
+                continue;
+            }
+
+            if (!childrenById[sourceId]) {
+                childrenById[sourceId] = [];
+            }
+            topologyUniqPush(childrenById[sourceId], targetId);
+
+            if (indegree[targetId] == null) {
+                indegree[targetId] = 1;
+            } else {
+                indegree[targetId] = indegree[targetId] + 1;
+            }
+            if (indegree[sourceId] == null) {
+                indegree[sourceId] = 0;
+            }
+        }
+        return {childrenById: childrenById, indegree: indegree};
+    }
+
+    // Prefer the Syncope root, then a node without incoming edges, then the 
first node.
+    function topologyPickRoot(allNodeIds, childrenById, indegree) {
+        const rootEl = $("#drawing .window.topology_root")[0];
+        if (rootEl && rootEl.id) {
+            return rootEl.id;
+        }
+
+        for (let i = 0; i < allNodeIds.length; i++) {
+            const id = allNodeIds[i];
+            if ((indegree[id] == null || indegree[id] === 0) && 
childrenById[id] && childrenById[id].length > 0) {
+                return id;
+            }
+        }
+
+        return allNodeIds.length > 0 ? allNodeIds[0] : null;
+    }
+
+    // Cache subtree widths and stop recursion when a cycle is encountered.
+    function topologyComputeSubtreeWidth(nodeId, childrenById, nodeSizeById, 
hGap, visiting, memo) {
+        if (memo[nodeId] != null) {
+            return memo[nodeId];
+        }
+        if (visiting[nodeId]) {
+            memo[nodeId] = nodeSizeById[nodeId] ? nodeSizeById[nodeId].w : 
config.nodeSize.w;
+            return memo[nodeId];
+        }
+
+        visiting[nodeId] = true;
+        const children = childrenById[nodeId] || [];
+        const ownW = nodeSizeById[nodeId] ? nodeSizeById[nodeId].w : 
config.nodeSize.w;
+        if (children.length === 0) {
+            memo[nodeId] = ownW;
+            visiting[nodeId] = false;
+            return memo[nodeId];
+        }
+
+        let total = 0;
+        for (let i = 0; i < children.length; i++) {
+            total += topologyComputeSubtreeWidth(children[i], childrenById, 
nodeSizeById, hGap, visiting, memo);
+        }
+        total += hGap * (children.length - 1);
+        memo[nodeId] = Math.max(ownW, total);
+        visiting[nodeId] = false;
+        return memo[nodeId];
+    }
+
+    // Center each node above its children and avoid placing the same node 
twice.
+    function topologyAssignPositions(nodeId, childrenById, nodeSizeById, 
subtreeWById, levelY, xLeft, hGap, vGap, outPos, visited) {
+        if (visited[nodeId]) {
+            return;
+        }
+        visited[nodeId] = true;
+
+        const nodeSize = nodeSizeById[nodeId] || config.nodeSize;
+        const subtreeW = subtreeWById[nodeId] != null ? subtreeWById[nodeId] : 
nodeSize.w;
+        const xCenter = xLeft + subtreeW / 2;
+        outPos[nodeId] = {x: xCenter - nodeSize.w / 2, y: levelY};
+
+        const children = childrenById[nodeId] || [];
+        if (children.length === 0) {
+            return;
+        }
+
+        const nextY = levelY + nodeSize.h + vGap;
+        let childX = xLeft;
+        for (let i = 0; i < children.length; i++) {
+            const childId = children[i];
+            const childSubtreeW = subtreeWById[childId] != null
+                ? subtreeWById[childId]
+                : (nodeSizeById[childId] ? nodeSizeById[childId].w : 
config.nodeSize.w);
+
+            topologyAssignPositions(childId, childrenById, nodeSizeById, 
subtreeWById, nextY, childX, hGap, vGap, outPos, visited);
+            childX += childSubtreeW + hGap;
+        }
+    }
+
+    // Compute relative tree positions; disconnected nodes remain without a 
position.
+    function topologyLayoutTree(layout, graph, rootId, hGap, vGap) {
+        const subtreeWidths = {};
+        for (const id of layout.nodeIds) {
+            topologyComputeSubtreeWidth(id, graph.childrenById, 
layout.nodeSizeById, hGap, {}, subtreeWidths);
+        }
+        layout.positions = {};
+        topologyAssignPositions(rootId, graph.childrenById, 
layout.nodeSizeById, subtreeWidths,
+            0, 0, hGap, vGap, layout.positions, {});
+    }
+
+    // Anchor the layout to the current viewport center or to the root's 
existing position.
+    function topologyLayoutOffset(layout, rootId, centerInView) {
+        if (centerInView) {
+            const bounds = topology.getBounds(layout);
+            if (!bounds) {
+                return {x: 0, y: 0};
+            }
+            const $topology = $("#topology");
+            const $drawing = $("#drawing");
+            const zoom = topology.getZoom();
+            return {
+                x: (($topology.width() || 0) / 2 - 
topology.parsePx($drawing.css("left"), 0)) / zoom
+                    - (bounds.minX + bounds.maxX) / 2,
+                y: (($topology.height() || 0) / 2 - 
topology.parsePx($drawing.css("top"), 0)) / zoom
+                    - (bounds.minY + bounds.maxY) / 2
+            };
+        }
+        const root = document.getElementById(rootId);
+        let left = topology.parsePx(root && root.style ? root.style.left : 
null, 0);
+        let top = topology.parsePx(root && root.style ? root.style.top : null, 
0);
+        if (isNaN(left) || isNaN(top) || (left === 0 && top === 0)) {
+            left = topology.parsePx($(root).css("left"), 0);
+            top = topology.parsePx($(root).css("top"), 0);
+        }
+        const position = layout.positions[rootId];
+        return {x: left - (position ? position.x : 0), y: top - (position ? 
position.y : 0)};
+    }
+
+    // Apply rounded tree positions and track the extents used to place 
disconnected nodes.
+    function topologyPlaceTree(layout, offset, padding) {
+        let placed = null;
+        for (const id of layout.nodeIds) {
+            const position = layout.positions[id];
+            const node = document.getElementById(id);
+            if (!position || !node) {
+                continue;
+            }
+            const size = layout.nodeSizeById[id] || config.nodeSize;
+            const x = Math.round(position.x + offset.x + padding);
+            const y = Math.round(position.y + offset.y + padding);
+            node.style.left = x + "px";
+            node.style.top = y + "px";
+            if (placed == null) {
+                placed = {minX: x, maxX: x, maxY: y};
+            } else {
+                placed.minX = Math.min(placed.minX, x);
+                placed.maxX = Math.max(placed.maxX, x + size.w);
+                placed.maxY = Math.max(placed.maxY, y + size.h);
+            }
+        }
+        return placed;
+    }
+
+    // Arrange nodes outside the root's tree in columns beside the connected 
nodes.
+    function topologyPlaceDisconnected(layout, placed, hGap, padding) {
+        const startX = (placed ? placed.maxX : 0) + hGap * 2;
+        const startY = placed ? placed.minX - 20 : 0;
+        const maxColH = Math.max(420, placed ? placed.maxY - startY : 420);
+        let colX = startX;
+        let rowY = startY;
+        let colW = 0;
+        for (const id of layout.nodeIds) {
+            if (layout.positions[id]) {
+                continue;
+            }
+            const size = layout.nodeSizeById[id] || config.nodeSize;
+            if ((rowY - startY) + size.h > maxColH) {
+                colX += colW + hGap;
+                rowY = startY;
+                colW = 0;
+            }
+            const node = document.getElementById(id);
+            if (node) {
+                node.style.left = Math.round(colX + padding) + "px";
+                node.style.top = Math.round(rowY + padding) + "px";
+            }
+            rowY += size.h + 12;
+            colW = Math.max(colW, size.w);
+        }
+    }
+
+    // Lay out the tree and disconnected nodes, save their positions and 
center the view.
+    function autoLayoutTree(options) {
+        options = options || {};
+        const hGap = options.hGap != null ? options.hGap : 70;
+        const vGap = options.vGap != null ? options.vGap : 80;
+        const padding = options.padding != null ? options.padding : 10;
+        const centerInView = options.centerInView != null ? 
options.centerInView : false;
+
+        topology.runWithOptionalVeil(function () {
+            jsPlumb.ready(function () {
+                try {
+                    const layout = topology.measureNodes();
+                    const connections = jsPlumb.getAllConnections ? 
jsPlumb.getAllConnections() : [];
+                    const graph = topologyBuildChildren(connections);
+                    const rootId = topologyPickRoot(layout.nodeIds, 
graph.childrenById, graph.indegree);
+                    if (!rootId) {
+                        return;
+                    }
+                    topologyLayoutTree(layout, graph, rootId, hGap, vGap);
+                    const offset = topologyLayoutOffset(layout, rootId, 
centerInView);
+
+                    if (jsPlumb.setSuspendDrawing) {
+                        jsPlumb.setSuspendDrawing(true);
+                    }
+                    const placed = topologyPlaceTree(layout, offset, padding);
+                    topologyPlaceDisconnected(layout, placed, hGap, padding);
+                    topology.savePositions();
+                    topology.treeBounds = topology.getBounds(layout, offset, 
padding);
+
+                    if (jsPlumb.setSuspendDrawing) {
+                        jsPlumb.setSuspendDrawing(false, true);
+                    } else {
+                        jsPlumb.repaintEverything();
+                    }
+                    if (topology.treeBounds) {
+                        topology.recenterToTree();
+                    }
+                } catch (err) {
+                    console.debug("autoLayoutTree failed", err);
+                }
+            });
+        });
+    }
+
+    Object.assign(topology, {
+        autoLayoutTree
+    });
+})(window.SyncopeTopology);
diff --git 
a/client/idm/console/src/main/resources/META-INF/resources/js/idmTopologyNavigation.js
 
b/client/idm/console/src/main/resources/META-INF/resources/js/idmTopologyNavigation.js
new file mode 100644
index 0000000000..79e0326166
--- /dev/null
+++ 
b/client/idm/console/src/main/resources/META-INF/resources/js/idmTopologyNavigation.js
@@ -0,0 +1,220 @@
+/*
+ * 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.
+ */
+
+// Zoom, centering and background panning.
+
+(function (topology) {
+    const config = topology.config;
+
+    // Apply the bounded scale to the drawing and jsPlumb, then synchronize 
zoom controls.
+    function setZoom(el, zoom, instance, transformOrigin) {
+        zoom = Math.max(config.minZoom, Math.min(config.maxZoom, zoom));
+        transformOrigin = transformOrigin || [0.5, 0.5];
+        instance = instance || jsPlumb;
+        el = el || instance.getContainer();
+
+        const p = ["webkit", "moz", "ms", "o"],
+            s = "scale(" + zoom + ")",
+            oString = (transformOrigin[0] * 100) + "% " + (transformOrigin[1] 
* 100) + "%";
+
+        for (let i = 0; i < p.length; i++) {
+            el.style[p[i] + "Transform"] = s;
+            el.style[p[i] + "TransformOrigin"] = oString;
+        }
+
+        el.style["transform"] = s;
+        el.style["transformOrigin"] = oString;
+
+        instance.setZoom(zoom);
+        $("#topology-zoom").val(Math.round(zoom * 100));
+        $("#topology-zoom-value").text(Math.round(zoom * 100) + "%");
+    }
+
+    // Change scale around the viewport center and persist the resulting zoom.
+    function topologyZoomTo(zoom, el) {
+        const $drawing = $(el || document.getElementById("drawing"));
+        const $topology = $("#topology");
+        zoom = Math.max(config.minZoom, Math.min(config.maxZoom, zoom));
+        const ratio = zoom / topology.getZoom();
+        const originX = $drawing.width() / 2;
+        const originY = $drawing.height() / 2;
+        const centerX = $topology.width() / 2;
+        const centerY = $topology.height() / 2;
+        const left = topology.parsePx($drawing.css("left"), 0);
+        const top = topology.parsePx($drawing.css("top"), 0);
+
+        // Keep the graph point at the viewport center fixed while its scale 
changes.
+        $drawing.css({
+            left: centerX - originX - (centerX - left - originX) * ratio,
+            top: centerY - originY - (centerY - top - originY) * ratio
+        });
+        setZoom($drawing[0], zoom);
+
+        const state = topology.getState();
+        state.__zoom__ = zoom;
+        topology.saveState(state);
+    }
+
+    // Increase the current scale by ten percent around the viewport center.
+    function zoomIn(el) {
+        topologyZoomTo(topology.getZoom() * config.zoomStep, el);
+    }
+
+    // Apply the inverse zoom step around the viewport center.
+    function zoomOut(el) {
+        topologyZoomTo(topology.getZoom() / config.zoomStep, el);
+    }
+
+    // Normalize Ctrl+wheel input and use it for graph zoom instead of browser 
zoom.
+    function topologyOnWheel(event) {
+        if (!event.ctrlKey || event.deltaY === 0) {
+            return;
+        }
+        event.preventDefault();
+        const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 
this.clientHeight : 1;
+        const delta = Math.max(-100, Math.min(100, event.deltaY * unit));
+        topologyZoomTo(topology.getZoom() * Math.exp(-delta * 0.002));
+    }
+
+    // Bind slider and wheel input; zoom buttons are handled by Wicket Ajax 
actions.
+    function initTopologyZoom() {
+        $("#topology-zoom").off(".topologyzoom").on("input.topologyzoom", 
function () {
+            topologyZoomTo(Number(this.value) / 100);
+        });
+        const topology = document.getElementById("topology");
+        topology.removeEventListener("wheel", topologyOnWheel);
+        topology.addEventListener("wheel", topologyOnWheel, {passive: false});
+    }
+
+    // Translate the drawing to center the supplied bounds, accounting for 
scale and origin.
+    function topologyRecenterOnBBox(bbox) {
+        if (!bbox) {
+            return;
+        }
+        const $topology = $("#topology");
+        const $drawing = $("#drawing");
+        if ($topology.length === 0 || $drawing.length === 0) {
+            return;
+        }
+
+        const topoW = $topology.width() || 0;
+        const topoH = $topology.height() || 0;
+        if (topoW <= 0 || topoH <= 0) {
+            return;
+        }
+
+        const z = topology.getZoom();
+        const drawingW = $drawing.width() || 0;
+        const drawingH = $drawing.height() || 0;
+        const originX = drawingW * 0.5;
+        const originY = drawingH * 0.5;
+
+        const cx = (bbox.minX + bbox.maxX) / 2;
+        const cy = (bbox.minY + bbox.maxY) / 2;
+
+        const viewportCenterX = topoW / 2;
+        const viewportCenterY = topoH / 2;
+
+        const newLeft = viewportCenterX - originX - (cx - originX) * z;
+        const newTop = viewportCenterY - originY - (cy - originY) * z;
+
+        $drawing.css("left", Math.round(newLeft) + "px");
+        $drawing.css("top", Math.round(newTop) + "px");
+        jsPlumb.repaintEverything();
+    }
+
+    // Center cached tree bounds, or measure current nodes when those bounds 
are unavailable.
+    function recenterToTree() {
+        topology.runWithOptionalVeil(function () {
+            jsPlumb.ready(function () {
+                const bbox = topology.treeBounds || 
topology.getBounds(topology.measureNodes());
+                topologyRecenterOnBBox(bbox);
+            });
+        });
+    }
+
+    // Pan the background while leaving node dragging to jsPlumb.
+    function initTopologyPanning() {
+        const $topology = $("#topology");
+        const $drawing = $("#drawing");
+        if ($topology.length === 0 || $drawing.length === 0) {
+            return;
+        }
+
+        $topology.off(".topopanning");
+        $(document).off(".topopanning");
+
+        let dragging = false;
+        let startX = 0;
+        let startY = 0;
+        let startLeft = 0;
+        let startTop = 0;
+
+        // Keep node and connector interactions from starting a background pan.
+        function panBlocked(target) {
+            return $(target).closest(".window, ._jsPlumb_connector, 
._jsPlumb_endpoint, ._jsPlumb_overlay").length > 0;
+        }
+
+        $topology.on("mousedown.topopanning", function (e) {
+            if (e.button !== 0) {
+                return;
+            }
+            if (panBlocked(e.target)) {
+                return;
+            }
+
+            dragging = true;
+            startX = e.clientX;
+            startY = e.clientY;
+            startLeft = topology.parsePx($drawing.css("left"), 0);
+            startTop = topology.parsePx($drawing.css("top"), 0);
+            $topology.addClass("topology-panning");
+            e.preventDefault();
+        });
+
+        $(document).on("mousemove.topopanning", function (e) {
+            if (!dragging) {
+                return;
+            }
+            const dx = e.clientX - startX;
+            const dy = e.clientY - startY;
+            $drawing.css("left", (startLeft + dx) + "px");
+            $drawing.css("top", (startTop + dy) + "px");
+        });
+
+        $(document).on("mouseup.topopanning", function () {
+            if (!dragging) {
+                return;
+            }
+            dragging = false;
+            $("#topology").removeClass("topology-panning");
+            jsPlumb.repaintEverything();
+        });
+    }
+
+    Object.assign(topology, {
+        setZoom,
+        zoomIn,
+        zoomOut,
+        initZoom: initTopologyZoom,
+        recenterOnBounds: topologyRecenterOnBBox,
+        recenterToTree,
+        initPanning: initTopologyPanning
+    });
+})(window.SyncopeTopology);
diff --git 
a/client/idm/console/src/main/resources/org/apache/syncope/client/console/topology/Topology.html
 
b/client/idm/console/src/main/resources/org/apache/syncope/client/console/topology/Topology.html
index d2ec3c78a5..ad62deb692 100644
--- 
a/client/idm/console/src/main/resources/org/apache/syncope/client/console/topology/Topology.html
+++ 
b/client/idm/console/src/main/resources/org/apache/syncope/client/console/topology/Topology.html
@@ -19,52 +19,47 @@ under the License.
 <html xmlns="http://www.w3.org/1999/xhtml"; 
xmlns:wicket="http://wicket.apache.org";>
   <wicket:head>
     <script type="text/javascript" 
src="webjars/jsplumb/${jsplumb.version}/js/jsPlumb-${jsplumb.version}-min.js"></script>
-    <script type="text/javascript" 
src="webjars/jquery-cookie/${jquery-cookie.version}/jquery.cookie.js"></script>
+    <script type="text/javascript" src="js/idmTopologyCore.js"></script>
+    <script type="text/javascript" src="js/idmTopologyLayout.js"></script>
+    <script type="text/javascript" src="js/idmTopologyNavigation.js"></script>
     <script type="text/javascript" src="js/idmTopology.js"></script>
     <link href="css/topology.css" rel="stylesheet"/>
-
-    <style type="text/css">
-      legend {
-        font-weight: bold;
-      }
-      #sentMessages {
-        display: none;
-      }
-      #messages {
-        margin: 5px;
-        padding: 5px;
-        border: 2px groove #f5f5f5;
-      }
-    </style>
   </wicket:head>
+
   <wicket:extend>
     <span wicket:id="toggle"/>
 
-    <div id="zoom" class="ui-button ui-widget ui-state-default ui-corner-all 
ui-button-text-only pt-2 pe-2 pb-1">
-      <span wicket:id="zoom">[Actions]</span>
+    <div id="topology-toolbar" class="ui-button ui-widget ui-state-default 
ui-corner-all ui-button-text-only pt-2 pe-2 pb-1">
+      <div class="topology-zoom-control" role="group" 
aria-labelledby="topology-zoom-label">
+        <label id="topology-zoom-label" class="visually-hidden" 
for="topology-zoom">Zoom</label>
+        <input id="topology-zoom" type="range" min="10" max="200" step="1" 
value="68"/>
+        <output id="topology-zoom-value" for="topology-zoom">68%</output>
+      </div>
+      <span wicket:id="topologyActions">[Actions]</span>
     </div>
+
     <div id="topology">
       <div id="drawing">
-        <div class="window" wicket:id="syncope" 
onmouseup="refreshPosition(this)">[syncope]</div>
+        <div class="window" wicket:id="syncope">[syncope]</div>
         <span wicket:id="connectorServers">
-          <div class="window" wicket:id="cs" 
onmouseup="refreshPosition(this)">[connector server]</div>
+          <div class="window" wicket:id="cs">[connector server]</div>
         </span>
         <span wicket:id="filePaths">
-          <div class="window" wicket:id="fp" 
onmouseup="refreshPosition(this)">[file path]</div>
+          <div class="window" wicket:id="fp">[file path]</div>
         </span>
         <span wicket:id="conns">
           <span wicket:id="conns">
-            <div class="window" wicket:id="conn" 
onmouseup="refreshPosition(this)">[connector]</div>
+            <div class="window" wicket:id="conn">[connector]</div>
           </span>
         </span>
         <span wicket:id="resources">
           <span wicket:id="resources">
-            <div class="window" wicket:id="res" 
onmouseup="refreshPosition(this)">[resource]</div>
+            <div class="window" wicket:id="res">[resource]</div>
           </span>
         </span>
         <div wicket:id="newlyCreatedContainer">
           <span wicket:id="newlyCreated">
-            <div class="window" wicket:id="el" 
onmouseup="refreshPosition(this)">[resource]</div>
+            <div class="window" wicket:id="el">[resource]</div>
           </span>
         </div>
       </div>

Reply via email to