From 4f2dbe033ea09c4f4065cd58c4147040ed8c5a2b Mon Sep 17 00:00:00 2001 From: TatoniMatteo Date: Tue, 5 May 2026 17:39:27 +0200 Subject: [PATCH 1/2] Improve topology UX: add tree auto-layout and recenter actions, optimize connection status checks, and refresh topology styling --- .../client/console/topology/Topology.java | 24 + .../META-INF/resources/css/idmTopology.scss | 124 ++ .../META-INF/resources/js/idmTopology.js | 1005 +++++++++++++---- .../wicket/markup/html/form/ActionLink.java | 2 + .../wicket/markup/html/form/ActionPanel.java | 13 + .../markup/html/form/ActionsPanel.properties | 8 + .../html/form/ActionsPanel_it.properties | 8 + 7 files changed, 978 insertions(+), 206 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 95bbcac9129..4aae9400c50 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 @@ -197,6 +197,26 @@ public void onClick(final AjaxRequestTarget target, final Serializable ignore) { } }, ActionLink.ActionType.ZOOM_OUT, IdMEntitlement.CONNECTOR_LIST).disableIndicator().hideLabel(); + zoomActionPanel.add(new ActionLink<>() { + + private static final long serialVersionUID = -3722207913631435501L; + + @Override + public void onClick(final AjaxRequestTarget target, final Serializable ignore) { + target.appendJavaScript("autoLayoutTree({ centerInView: false });"); + } + }, ActionLink.ActionType.AUTO_LAYOUT, IdMEntitlement.CONNECTOR_LIST).disableIndicator().hideLabel(); + + zoomActionPanel.add(new ActionLink<>() { + + private static final long serialVersionUID = -3722207913631435501L; + + @Override + public void onClick(final AjaxRequestTarget target, final Serializable ignore) { + target.appendJavaScript("recenterToTree();"); + } + }, ActionLink.ActionType.RECENTER, IdMEntitlement.CONNECTOR_LIST).disableIndicator().hideLabel(); + body.add(zoomActionPanel); // ----------------------------------------- @@ -464,6 +484,10 @@ public void renderHead(final Component component, final IHeaderResponse response 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 });}"); response.render(OnDomReadyHeaderItem.forScript(jsPlumbConf.toString())); } diff --git a/client/idm/console/src/main/resources/META-INF/resources/css/idmTopology.scss b/client/idm/console/src/main/resources/META-INF/resources/css/idmTopology.scss index bd244d07ab4..092f501d454 100644 --- a/client/idm/console/src/main/resources/META-INF/resources/css/idmTopology.scss +++ b/client/idm/console/src/main/resources/META-INF/resources/css/idmTopology.scss @@ -17,3 +17,127 @@ * under the License. */ +:root { + --topology-node-border: rgba(15, 23, 42, 0.14); + --topology-node-shadow: 0 10px 28px rgba(2, 6, 23, 0.10); + --topology-node-shadow-hover: 0 14px 34px rgba(2, 6, 23, 0.14); + + --topology-toolbar-bg: rgba(255, 255, 255, 0.92); + --topology-toolbar-border: rgba(15, 23, 42, 0.10); + --topology-toolbar-fg: rgba(15, 23, 42, 0.78); + --topology-toolbar-fg-hover: rgba(15, 23, 42, 0.92); +} + +#zoom { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 6px; + + ul.menu { + display: flex; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + } + + ul.menu > li { + margin: 0; + padding: 0; + } + + ul.menu > li > a.btn, + .topology-toolbar-btn { + appearance: none; + border: 1px solid var(--topology-toolbar-border); + background: var(--topology-toolbar-bg); + color: var(--topology-toolbar-fg); + border-radius: 999px; + padding: 6px 10px; + line-height: 1; + box-shadow: 0 8px 18px rgba(2, 6, 23, 0.10); + transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, color 120ms ease; + } + + ul.menu > li > a.btn:hover, + .topology-toolbar-btn:hover { + color: var(--topology-toolbar-fg-hover); + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 10px 22px rgba(2, 6, 23, 0.14); + transform: translateY(-1px); + text-decoration: none; + } + + ul.menu > li > a.btn:focus, + .topology-toolbar-btn:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.25), 0 10px 22px rgba(2, 6, 23, 0.14); + } + + ul.menu > li > a.btn i, + .topology-toolbar-btn i { + font-size: 1.05rem; + padding-right: 0; + } +} + +#topology { + cursor: grab; +} + +#topology.topology-panning { + cursor: grabbing; +} + +#drawing .window { + opacity: 1; + border: 1px solid var(--topology-node-border); + box-shadow: var(--topology-node-shadow); + border-radius: 14px; + width: 200px; + min-height: 72px; + height: auto; + line-height: normal; + padding: 10px 12px; + + display: flex; + align-items: center; + justify-content: center; +} + +#drawing .window:hover { + box-shadow: var(--topology-node-shadow); +} + +#drawing .window[data-original-title]:hover::after { + content: none !important; + display: none !important; +} + +#drawing .window.topology_root { + background-color: rgba(22, 163, 74, 0.18); +} + +#drawing .window.topology_cs { + background-color: rgba(14, 165, 233, 0.12); +} + +#drawing .window.topology_conn { + background-color: rgba(139, 92, 246, 0.10); +} + +#drawing .window.topology_conn_errored { + background-color: rgba(244, 63, 94, 0.18); +} + +#drawing .window.topology_res { + background-color: rgba(245, 158, 11, 0.14); +} + +#drawing .window p { + font-weight: 700; + letter-spacing: 0.2px; + font-size: 1.25em; + line-height: 1.25; +} 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 8592edb3725..b604f532c23 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,296 +16,889 @@ * specific language governing permissions and limitations * under the License. */ -var def = { - paintStyle: { - lineWidth: 2, - strokeStyle: "rgba(204,204,204, 1)", - outlineColor: "#666", - outlineWidth: 1 - }, - connectorPaintStyle: { - lineWidth: 2 - }, - anchor: "AutoDefault", - detachable: false, - endpointStyle: { - gradient: { - stops: [ - [0, "rgba(204,204,204, 1)"], [1, "rgba(180, 180, 200, 1)"] - ], - offset: 5.5, - innerRadius: 3.5 +const def = { + paintStyle: { + lineWidth: 2, + strokeStyle: "#94a3b8", + outlineColor: "rgba(15, 23, 42, 0.18)", + outlineWidth: 1.5, + lineCap: "round", + lineJoin: "round" + }, + connectorPaintStyle: { + lineWidth: 2 }, - radius: 3.5 - } + anchor: "AutoDefault", + detachable: false, + endpointStyle: { + fillStyle: "#cbd5e1", + outlineColor: "rgba(15, 23, 42, 0.18)", + outlineWidth: 1, + radius: 3 + } }; -var failedConnectorStyle = { - lineWidth: 2, - strokeStyle: "rgba(220, 220, 220, 1)", - outlineColor: "#666", - outlineWidth: 1 +const failedConnectorStyle = { + lineWidth: 2, + strokeStyle: "rgba(148, 163, 184, 0.55)", + outlineColor: "rgba(15, 23, 42, 0.18)", + outlineWidth: 1.5 }; -var failedConnectorHoverStyle = { - strokeStyle: "#FFFFFF" +const failedConnectorHoverStyle = { + strokeStyle: "#FFFFFF" }; -var failedEndpointStyle = { - gradient: { - stops: [ - [0, "rgba(220, 220, 220, 1)"], [1, "rgba(180, 180, 200, 1)"] - ], - offset: 5.5, - innerRadius: 0 - }, - radius: 0 +const failedEndpointStyle = { + fillStyle: "rgba(148, 163, 184, 0.45)", + outlineColor: "rgba(15, 23, 42, 0.18)", + outlineWidth: 1, + radius: 3 }; -var disabledConnectorStyle = { - lineWidth: 2, - strokeStyle: "rgba(255, 69, 0, 1)", - outlineColor: "#666", - outlineWidth: 1 +const disabledConnectorStyle = { + lineWidth: 2, + strokeStyle: "#fb7185", + outlineColor: "rgba(15, 23, 42, 0.18)", + outlineWidth: 1.5 }; -var disabledConnectorHoverStyle = { - strokeStyle: "#FF8C00" +const disabledConnectorHoverStyle = { + strokeStyle: "#FF8C00" }; -var disabledEndpointStyle = { - gradient: { - stops: [ - [0, "rgba(255, 69, 0, 1)"], [1, "rgba(180, 180, 200, 1)"] - ], - offset: 5.5, - innerRadius: 1 - }, - radius: 1 +const disabledEndpointStyle = { + fillStyle: "rgba(251, 113, 133, 0.8)", + outlineColor: "rgba(15, 23, 42, 0.18)", + outlineWidth: 1, + radius: 3 }; -var enabledConnectorStyle = { - lineWidth: 2, - strokeStyle: "rgba(65, 155, 30, 1)", - outlineColor: "#666", - outlineWidth: 1 +const enabledConnectorStyle = { + lineWidth: 2, + strokeStyle: "#22c55e", + outlineColor: "rgba(15, 23, 42, 0.18)", + outlineWidth: 1.5 }; -var enabledConnectorHoverStyle = { - strokeStyle: "#00FF00" +const enabledConnectorHoverStyle = { + strokeStyle: "#16a34a" }; -var enabledEndpointStyle = { - gradient: { - stops: [ - [0, "rgba(65, 155, 30, 0.1)"], [1, "rgba(180, 180, 200, 0.1)"] - ], - offset: 5.5, - innerRadius: 2 - }, - radius: 2 +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); - }); + 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); - }); + 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); - }); + jsPlumb.ready(function () { + jsPlumb.select({target: targetName}).setPaintStyle(failedConnectorStyle).setHoverPaintStyle(failedConnectorHoverStyle); + jsPlumb.selectEndpoints({element: [targetName]}).setPaintStyle(failedEndpointStyle); + }); } window.unknown = function (targetName) { } function getTopology() { - var topology = $.cookie("topology"); - - if (topology == null) { - var val = {}; - } else { - var val = JSON.parse(decodeURIComponent(topology)); - } + const topology = $.cookie("topology"); + let val; + if (topology == null) { + val = {}; + } else { + val = JSON.parse(decodeURIComponent(topology)); + } - return val; + return val; } window.refreshPosition = function (element) { - var val = getTopology(); + const val = getTopology(); - var id = $(element).attr('id'); - var left = $(element).css('left'); - var top = $(element).css('top'); + const id = $(element).attr('id'); + const left = $(element).css('left'); + const top = $(element).css('top'); - if (val[id] == null) { - val[id] = {'top': top, 'left': left}; - } else { - val[id].top = top; - val[id].left = left; - } + if (val[id] == null) { + val[id] = {'top': top, 'left': left}; + } else { + val[id].top = top; + val[id].left = left; + } - $.cookie("topology", JSON.stringify(val), {expires: 9999}); + $.cookie("topology", JSON.stringify(val), {expires: 9999}); } window.setPosition = function (id, x, y) { - var val = getTopology(); - - try { - // We cannot use jQuery selector for id since the syntax of connector server id - var element = $(document.getElementById(id)); - - 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); + const val = getTopology(); + + try { + // We cannot use jQuery selector for id since the syntax of connector server id + const element = $(document.getElementById(id)); + + 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); } - } catch (err) { - console.log("Failure setting position for ", id); - } } window.setZoom = function (el, zoom, instance, transformOrigin) { - transformOrigin = transformOrigin || [0.5, 0.5]; - instance = instance || jsPlumb; - el = el || instance.getContainer(); + transformOrigin = transformOrigin || [0.5, 0.5]; + instance = instance || jsPlumb; + el = el || instance.getContainer(); - var p = ["webkit", "moz", "ms", "o"], - s = "scale(" + zoom + ")", - oString = (transformOrigin[0] * 100) + "% " + (transformOrigin[1] * 100) + "%"; + const p = ["webkit", "moz", "ms", "o"], + s = "scale(" + zoom + ")", + oString = (transformOrigin[0] * 100) + "% " + (transformOrigin[1] * 100) + "%"; - for (var i = 0; i < p.length; i++) { - el.style[p[i] + "Transform"] = s; - el.style[p[i] + "TransformOrigin"] = oString; - } + 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; + el.style["transform"] = s; + el.style["transformOrigin"] = oString; - instance.setZoom(zoom); + instance.setZoom(zoom); }; window.zoomIn = function (el, instance, transformOrigin) { - var val = getTopology(); - if (val.__zoom__ == null) { - var zoom = 0.69; - } else { - var zoom = val.__zoom__ + 0.01; - } + const val = getTopology(); + let zoom; + if (val.__zoom__ == null) { + zoom = 0.69; + } else { + zoom = val.__zoom__ + 0.01; + } - setZoom(el, zoom, instance, transformOrigin); + setZoom(el, zoom, instance, transformOrigin); - val['__zoom__'] = zoom; - $.cookie("topology", JSON.stringify(val), {expires: 9999}); + val['__zoom__'] = zoom; + $.cookie("topology", JSON.stringify(val), {expires: 9999}); }; window.zoomOut = function (el, instance, transformOrigin) { - var val = getTopology(); - if (val.__zoom__ == null) { - var zoom = 0.67; - } else { - var zoom = val.__zoom__ - 0.01; - } + const val = getTopology(); + let zoom; + if (val.__zoom__ == null) { + zoom = 0.67; + } else { + zoom = val.__zoom__ - 0.01; + } - setZoom(el, zoom, instance, transformOrigin); + setZoom(el, zoom, instance, transformOrigin); - val['__zoom__'] = zoom; - $.cookie("topology", JSON.stringify(val), {expires: 9999}); + 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); - } - }); + 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.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"); + + let dragging = false; + let startX = 0; + let startY = 0; + let startLeft = 0; + let startTop = 0; + + 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 = 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; + } + 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(); + }); + })(); + + const val = getTopology(); + if (val.__zoom__ == null) { + setZoom($("#drawing")[0], zoom); + } else { + setZoom($("#drawing")[0], val.__zoom__); + } + }); +} - jsPlumb.Defaults.MaxConnections = 1000; +window.checkConnection = function () { + jsPlumb.ready(function () { + const items = []; + + 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}); + } + }); + + window.__topologyCheckRunId = (window.__topologyCheckRunId || 0) + 1; + const runId = window.__topologyCheckRunId; + + const batchSize = 25; + let idx = 0; + + function step() { + if (window.__topologyCheckRunId !== runId) { + return; + } + const end = Math.min(idx + batchSize, items.length); + + if (jsPlumb.setSuspendDrawing) { + jsPlumb.setSuspendDrawing(true); + } + 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 + "\" }"); + } + } finally { + if (jsPlumb.setSuspendDrawing) { + jsPlumb.setSuspendDrawing(false, true); + } else { + jsPlumb.repaintEverything(); + } + } + + idx = end; + if (idx < items.length) { + window.setTimeout(step, 0); + } + } + + step(); + }); +} + +window.addEndpoint = function (source, target, scope) { + const sourceElement = $(document.getElementById(source)); - $("#drawing").draggable({ - containment: 'topology', - cursor: 'move' + 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); }); +} - var val = getTopology(); - if (val.__zoom__ == null) { - setZoom($("#drawing")[0], zoom); - } else { - setZoom($("#drawing")[0], val.__zoom__); +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; } -window.checkConnection = function () { - jsPlumb.ready(function () { - jsPlumb.select({scope: "CONNECTOR"}).each(function (connection) { - Wicket.WebSocket.send("{ \"kind\":\"CHECK_CONNECTOR\", \"target\":\"" + connection.target.id + "\" }"); - }); - jsPlumb.select({scope: "RESOURCE"}).each(function (connection) { - Wicket.WebSocket.send("{ \"kind\":\"CHECK_RESOURCE\", \"target\":\"" + connection.target.id + "\" }"); +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(); + } + } + }, 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); + } + }); }); - }); +}; + +function topologyGetZoom() { + const topoCookie = getTopology(); + const z = (jsPlumb.getZoom && jsPlumb.getZoom()) || topoCookie.__zoom__ || 1; + return z || 1; } -window.addEndpoint = function (source, target, scope) { - var sourceElement = $(document.getElementById(source)); +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; - var top = parseFloat(sourceElement.css("top")) + 10; - var left = parseFloat(sourceElement.css("left")) - 150; + const cx = (bbox.minX + bbox.maxX) / 2; + const cy = (bbox.minY + bbox.maxY) / 2; - setPosition(target, left, top); - jsPlumb.ready(function () { - jsPlumb.draggable(jsPlumb.getSelector(document.getElementById(target))); - jsPlumb.connect({source: source, target: target, scope: scope}, def); - }); + 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 () { + 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}; + } + + topologyRecenterOnBBox(bbox); + }); + }); +}; + jsPlumb.importDefaults({ - Connector: ["Straight"], - DragOptions: { - cursor: "pointer", - zIndex: 2000 - }, - HoverClass: "connector-hover" + Connector: ["Bezier", { curviness: 10 }], + DragOptions: { + cursor: "pointer", + zIndex: 2000 + }, + HoverClass: "connector-hover" }); -jQuery(function ($) { - Wicket.Event.subscribe("/websocket/message", function (jqEvent, message) { - var val = JSON.parse(decodeURIComponent(message)); - switch (val.status) { - case 'UNKNOWN': - unknown(val.target); - break; - case 'REACHABLE': - enable(val.target); - break; - case 'UNREACHABLE': - disable(val.target); - break; - case 'FAILURE': - failure(val.target); - break; - default: - break; +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; + } + + for (const [k] of entries) { + delete pendingStatusByTarget[k]; + } + + 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(); + } + } + }); + } + + function scheduleFlush() { + if (flushScheduled) { + return; + } + flushScheduled = true; + window.requestAnimationFrame(flushStatuses); + } + + Wicket.Event.subscribe("/websocket/message", function (_jqEvent, message) { + const val = JSON.parse(decodeURIComponent(message)); + pendingStatusByTarget[val.target] = val.status; + scheduleFlush(); + }); }); diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/form/ActionLink.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/form/ActionLink.java index 3757adc5e65..922295c25a3 100644 --- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/form/ActionLink.java +++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/form/ActionLink.java @@ -105,6 +105,8 @@ public enum ActionType { PUSH_TASKS("read"), ZOOM_IN("zoomin"), ZOOM_OUT("zoomout"), + AUTO_LAYOUT("autoLayout"), + RECENTER("recenter"), VIEW_EXECUTIONS("read"), VIEW_DETAILS("read"), MANAGE_APPROVAL("edit"), diff --git a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/form/ActionPanel.java b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/form/ActionPanel.java index 8b7e01ed8a8..684d622877d 100644 --- a/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/form/ActionPanel.java +++ b/client/idrepo/console/src/main/java/org/apache/syncope/client/console/wicket/markup/html/form/ActionPanel.java @@ -27,7 +27,9 @@ import org.apache.syncope.client.ui.commons.Constants; import org.apache.syncope.client.ui.commons.markup.html.form.IndicatingOnConfirmAjaxLink; import org.apache.wicket.AttributeModifier; +import org.apache.wicket.ajax.AjaxChannel; import org.apache.wicket.ajax.AjaxRequestTarget; +import org.apache.wicket.ajax.attributes.AjaxRequestAttributes; import org.apache.wicket.authroles.authorization.strategies.role.metadata.MetaDataRoleAuthorizationStrategy; import org.apache.wicket.event.Broadcast; import org.apache.wicket.extensions.ajax.markup.html.IndicatingAjaxLink; @@ -117,6 +119,17 @@ public String getAjaxIndicatorMarkupId() { private static final long serialVersionUID = -7978723352517770644L; + @Override + protected void updateAjaxAttributes(final AjaxRequestAttributes attributes) { + super.updateAjaxAttributes(attributes); + switch (action.getType()) { + case ZOOM_IN, ZOOM_OUT, AUTO_LAYOUT, RECENTER -> + attributes.setChannel(new AjaxChannel("ui-fast-actions", AjaxChannel.Type.DROP)); + default -> { + } + } + } + @Override public void onClick(final AjaxRequestTarget target) { beforeOnClick(target); diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel.properties index d23e63a0d91..f840c70ddc4 100644 --- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel.properties +++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel.properties @@ -261,6 +261,14 @@ zoom_out.class=fa fa-search-minus zoom_out.title=zoom-out zoom_out.alt=zoom-out icon +auto_layout.class=fas fa-sitemap +auto_layout.title=auto layout +auto_layout.alt=auto layout icon + +recenter.class=fas fa-crosshairs +recenter.title=recenter +recenter.alt=recenter icon + manage_accounts.class=fas fa-users manage_accounts.title=manage accounts manage_accounts.alt=manage accounts icon diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_it.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_it.properties index c0ee2d42656..bb9cc1f1f21 100644 --- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_it.properties +++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_it.properties @@ -241,6 +241,14 @@ zoom_in.alt=zoom-in icon zoom_out.class=fa fa-search-minus zoom_out.title=rimpicciolisci zoom_out.alt=zoom-out icon + +auto_layout.class=fas fa-sitemap +auto_layout.title=riordina +auto_layout.alt=auto layout icon + +recenter.class=fas fa-crosshairs +recenter.title=ricentra +recenter.alt=recenter icon reconciliation_push.class=fa fa-chevron-circle-right reconciliation_push.title=push reconciliation_push.alt=reconciliation push icon From 1db7d11038e5f72ff665e8131de64a96567650d6 Mon Sep 17 00:00:00 2001 From: TatoniMatteo Date: Wed, 6 May 2026 10:01:10 +0200 Subject: [PATCH 2/2] Provided translations for all languages --- .../markup/html/form/ActionsPanel_fr_CA.properties | 9 +++++++++ .../wicket/markup/html/form/ActionsPanel_ja.properties | 8 ++++++++ .../markup/html/form/ActionsPanel_pt_BR.properties | 9 +++++++++ .../wicket/markup/html/form/ActionsPanel_ru.properties | 8 ++++++++ 4 files changed, 34 insertions(+) diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_fr_CA.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_fr_CA.properties index 2ac88542656..66e3a6bc1fb 100644 --- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_fr_CA.properties +++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_fr_CA.properties @@ -205,6 +205,15 @@ zoom_in.alt=ic\u00f4ne zoom-in zoom_out.class=fa fa-search-minus zoom_out.title=zoom-out zoom_out.alt=ic\u00f4ne zoom-out + +auto_layout.class=fas fa-sitemap +auto_layout.title=réorganiser +auto_layout.alt=icône de disposition automatique + +recenter.class=fas fa-crosshairs +recenter.title=recentrer +recenter.alt=icône de recentrage + manage_accounts.class=fas fa-users manage_accounts.title=g\u00e9rer comptes manage_accounts.alt=ic\u00f4ne g\u00e9rer groupes diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ja.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ja.properties index 18c6d2465ae..930eb52fdb3 100644 --- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ja.properties +++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ja.properties @@ -242,6 +242,14 @@ zoom_out.class=fa fa-search-minus zoom_out.title=\u30ba\u30fc\u30e0\u30a2\u30a6\u30c8 zoom_out.alt=\u30ba\u30fc\u30e0\u30a2\u30a6\u30c8 +auto_layout.class=fas fa-sitemap +auto_layout.title=\u518d\u914d\u7f6e +auto_layout.alt=\u81ea\u52d5\u30ec\u30a4\u30a2\u30a6\u30c8\u30a2\u30a4\u30b3\u30f3 + +recenter.class=fas fa-crosshairs +recenter.title=\u4e2d\u592e\u306b\u623b\u3059 +recenter.alt=\u518d\u30bb\u30f3\u30bf\u30fc\u30a2\u30a4\u30b3\u30f3 + reconciliation_push.class=fa fa-chevron-circle-right reconciliation_push.title=\u30d7\u30c3\u30b7\u30e5 reconciliation_push.alt=\u7167\u5408\u30d7\u30c3\u30b7\u30e5 icon diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_pt_BR.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_pt_BR.properties index 1290d247d5b..506345d269a 100644 --- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_pt_BR.properties +++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_pt_BR.properties @@ -256,6 +256,15 @@ zoom_in.alt=zoom-in icon zoom_out.class=fa fa-search-minus zoom_out.title=zoom-out zoom_out.alt=zoom-out icon + +auto_layout.class=fas fa-sitemap +auto_layout.title=reorganizar +auto_layout.alt=ícone de layout automático + +recenter.class=fas fa-crosshairs +recenter.title=recentralizar +recenter.alt=ícone de recentralização + reconciliation_push.class=fa fa-chevron-circle-right reconciliation_push.title=push reconciliation_push.alt=reconciliation push icon diff --git a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ru.properties b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ru.properties index d2fb2dfd016..421d055f5fa 100644 --- a/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ru.properties +++ b/client/idrepo/console/src/main/resources/org/apache/syncope/client/console/wicket/markup/html/form/ActionsPanel_ru.properties @@ -242,6 +242,14 @@ zoom_out.class=fa fa-search-minus zoom_out.title=zoom-out zoom_out.alt=zoom-out icon +auto_layout.class=fas fa-sitemap +auto_layout.title=\u043f\u0435\u0440\u0435\u0441\u0442\u0440\u043e\u0438\u0442\u044c +auto_layout.alt=\u0438\u043a\u043e\u043d\u043a\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0439 \u043a\u043e\u043c\u043f\u043e\u043d\u043e\u0432\u043a\u0438 + +recenter.class=fas fa-crosshairs +recenter.title=\u0446\u0435\u043d\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c +recenter.alt=\u0438\u043a\u043e\u043d\u043a\u0430 \u0446\u0435\u043d\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f + reconciliation_push.class=fa fa-chevron-circle-right reconciliation_push.title=push reconciliation_push.alt=reconciliation push icon