This is an automated email from the ASF dual-hosted git repository.
ilgrosso pushed a commit to branch 4_1_X
in repository https://gitbox.apache.org/repos/asf/syncope.git
The following commit(s) were added to refs/heads/4_1_X by this push:
new abbbe8c9f7 Improve topology persistence, connection checks and zoom
controls (#1589)
abbbe8c9f7 is described below
commit abbbe8c9f7731699d5d19130f043e011c3fd8197
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 | 203 ++--
.../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, 1135 insertions(+), 980 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 3d69553533..45ff299f29 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
@@ -21,14 +21,15 @@ package org.apache.syncope.client.console.topology;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.json.JsonMapper;
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;
@@ -63,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<>());
@@ -77,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,
@@ -96,105 +97,30 @@ 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").asText();
switch
(Topology.SupportedOperation.valueOf(obj.get("kind").asText())) {
- case CHECK_CONNECTOR -> {
- String ckey = obj.get("target").asText();
-
- 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").asText();
+ String scope = obj.get("scope").asText();
+ handler.appendJavaScript("addEndpoint('%s', '%s',
'%s');".formatted(source, target, scope));
}
- case CHECK_RESOURCE -> {
- String rkey = obj.get("target").asText();
-
- 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").asText(),
- obj.get("target").asText(),
- obj.get("scope").asText()));
-
default -> {
}
}
@@ -211,7 +137,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;
@@ -227,15 +216,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);
}
}
@@ -246,15 +228,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>