diff --git a/container/features/src/main/resources/features.xml b/container/features/src/main/resources/features.xml index 50f75b8e5b55..c029dbe4e161 100644 --- a/container/features/src/main/resources/features.xml +++ b/container/features/src/main/resources/features.xml @@ -1753,6 +1753,7 @@ opennms-graph-provider-bsm opennms-graph-provider-application opennms-graph-provider-legacy + opennms-graph-provider-pathoutage opennms-graph-provider-topology @@ -1815,6 +1816,11 @@ opennms-topology-api mvn:org.opennms.features.graph.provider/org.opennms.features.graph.provider.legacy/${project.version} + + opennms-graph-service + opennms-model + mvn:org.opennms.features.graph.provider/org.opennms.features.graph.provider.pathoutage/${project.version} + opennms-model opennms-graph-service diff --git a/features/graph/provider/pathoutage/pom.xml b/features/graph/provider/pathoutage/pom.xml new file mode 100644 index 000000000000..1283805a3bf5 --- /dev/null +++ b/features/graph/provider/pathoutage/pom.xml @@ -0,0 +1,129 @@ + + + + org.opennms.features.graph + org.opennms.features.graph.provider + 36.0.2-SNAPSHOT + + 4.0.0 + org.opennms.features.graph.provider + org.opennms.features.graph.provider.pathoutage + OpenNMS :: Features :: Graph :: Provider :: Path Outage + Builds a graph from the node parent / critical-path (path outage) relationships. + bundle + + + + org.apache.felix + maven-bundle-plugin + true + + + + + + org.osgi + osgi.core + ${osgiVersion} + provided + + + org.osgi + osgi.cmpn + provided + + + org.opennms.features.graph + org.opennms.features.graph.api + ${project.version} + + + org.opennms + opennms-dao-api + ${project.version} + provided + + + org.opennms + opennms-model + ${project.version} + provided + + + junit + junit + test + + + org.mockito + mockito-core + test + + + + org.opennms + opennms-config + test + + + org.opennms + opennms-dao + test + + + org.opennms.core.test-api + org.opennms.core.test-api.lib + test + + + org.opennms.core.test-api + org.opennms.core.test-api.db + test + + + org.opennms.core.test-api + org.opennms.core.test-api.services + test + + + org.opennms + opennms-rrdtool-api + test + + + org.opennms.features.collection + org.opennms.features.collection.persistence.osgi + test + + + org.opennms.features.collection + org.opennms.features.collection.persistence.rrd + test + + + org.opennms.dependencies + jrrd2-dependencies + pom + test + + + org.opennms.features.config + org.opennms.features.config.mock + ${project.version} + test + + + org.opennms.features.config + org.opennms.features.config.upgrade + ${project.version} + test + + + org.hibernate + hibernate-validator + test + + + diff --git a/features/graph/provider/pathoutage/src/main/java/org/opennms/netmgt/graph/provider/pathoutage/PathOutageGraphProvider.java b/features/graph/provider/pathoutage/src/main/java/org/opennms/netmgt/graph/provider/pathoutage/PathOutageGraphProvider.java new file mode 100644 index 000000000000..3e5a37b7b658 --- /dev/null +++ b/features/graph/provider/pathoutage/src/main/java/org/opennms/netmgt/graph/provider/pathoutage/PathOutageGraphProvider.java @@ -0,0 +1,146 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.graph.provider.pathoutage; + +import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.opennms.netmgt.dao.api.NodeDao; +import org.opennms.netmgt.dao.api.SessionUtils; +import org.opennms.netmgt.graph.api.ImmutableGraph; +import org.opennms.netmgt.graph.api.generic.GenericEdge; +import org.opennms.netmgt.graph.api.generic.GenericGraph; +import org.opennms.netmgt.graph.api.generic.GenericProperties; +import org.opennms.netmgt.graph.api.generic.GenericVertex; +import org.opennms.netmgt.graph.api.info.DefaultGraphInfo; +import org.opennms.netmgt.graph.api.info.GraphInfo; +import org.opennms.netmgt.graph.api.service.GraphProvider; +import org.opennms.netmgt.model.OnmsNode; + +import com.google.common.collect.Maps; + +/** + * Builds a read-only graph from the node parent / critical-path relationships + * ({@link OnmsNode#getParent()} -- the {@code nodeParentID} column), the same + * data the legacy Vaadin "Path Outage" topology renders. Each participating + * node becomes a vertex and each parent points to its child with a directed + * edge, so the result is the node-parent forest (one tree per top-level node). + * + *

Only nodes that take part in a parent-child relationship are included -- + * a node with neither a parent nor any children would render as an isolated + * dot that conveys nothing here. On an installation where no node parents are + * set the graph is empty, and the UI shows its discovered-empty state.

+ * + *

Registered as a bare {@link GraphProvider}; the graph service wraps it in + * a single-graph container whose id defaults to the namespace, so it is served + * at {@code /api/v2/graphs/pathoutage/pathoutage}.

+ */ +public class PathOutageGraphProvider implements GraphProvider { + + public static final String NAMESPACE = "pathoutage"; + private static final String LABEL = "Path Outage"; + private static final String DESCRIPTION = + "Node parent / critical-path hierarchy derived from each node's parent relationship."; + + private final NodeDao nodeDao; + private final SessionUtils sessionUtils; + + public PathOutageGraphProvider(final NodeDao nodeDao, final SessionUtils sessionUtils) { + this.nodeDao = Objects.requireNonNull(nodeDao); + this.sessionUtils = Objects.requireNonNull(sessionUtils); + } + + @Override + public ImmutableGraph loadGraph() { + // OnmsNode.getParent() is a LAZY @ManyToOne, so the whole build -- including + // reading each parent's id/label -- must run inside a read transaction. + return sessionUtils.withReadOnlyTransaction(() -> { + final GenericGraph.GenericGraphBuilder builder = GenericGraph.builder() + .graphInfo(getGraphInfo()) + .id(NAMESPACE) + // Resolve node + alarm-based status for the vertices, like the other + // node-backed providers; the SPA also colors by nodeID independently. + .property(GenericProperties.Enrichment.RESOLVE_NODES, true) + .property(GenericProperties.Enrichment.DEFAULT_STATUS, true); + + final List nodes = nodeDao.findAll(); + + // First pass: collect the participating node ids (children with a parent, + // plus those parents) and their labels, and remember the parent->child pairs. + final Map labelById = Maps.newHashMap(); + final Set participating = new LinkedHashSet<>(); + // child -> parent: value-based (unlike a Set of arrays), and a child + // has exactly one parent, so duplicates are structurally impossible. + final Map parentByChild = new LinkedHashMap<>(); + for (final OnmsNode node : nodes) { + final OnmsNode parent = node.getParent(); + if (parent == null) { + continue; + } + final int childId = node.getId(); + final int parentId = parent.getId(); + labelById.put(childId, node.getLabel()); + labelById.put(parentId, parent.getLabel()); + participating.add(childId); + participating.add(parentId); + parentByChild.put(childId, parentId); + } + + // Vertices first (an edge requires its endpoints to already be present). + for (final Integer nodeId : participating) { + builder.addVertex(GenericVertex.builder() + .namespace(NAMESPACE) + .id(String.valueOf(nodeId)) + .property(GenericProperties.LABEL, labelById.get(nodeId)) + .property(GenericProperties.NODE_ID, String.valueOf(nodeId)) + .build()); + } + + // No explicit edge id: GenericEdge derives a deterministic one from + // source->target, stable across loads (a counter would depend on + // NodeDao.findAll() ordering). + for (final Map.Entry pc : parentByChild.entrySet()) { + builder.addEdge(GenericEdge.builder() + .namespace(NAMESPACE) + .source(NAMESPACE, String.valueOf(pc.getValue())) + .target(NAMESPACE, String.valueOf(pc.getKey())) + .build()); + } + + // Show the whole hierarchy by default; the SPA frames it to fit. + builder.focus().all().apply(); + return builder.build(); + }); + } + + @Override + public GraphInfo getGraphInfo() { + final DefaultGraphInfo graphInfo = new DefaultGraphInfo(NAMESPACE); + graphInfo.setLabel(LABEL); + graphInfo.setDescription(DESCRIPTION); + return graphInfo; + } +} diff --git a/features/graph/provider/pathoutage/src/main/resources/OSGI-INF/blueprint/blueprint.xml b/features/graph/provider/pathoutage/src/main/resources/OSGI-INF/blueprint/blueprint.xml new file mode 100644 index 000000000000..9db35dcd9d6d --- /dev/null +++ b/features/graph/provider/pathoutage/src/main/resources/OSGI-INF/blueprint/blueprint.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/features/graph/provider/pathoutage/src/test/java/org/opennms/netmgt/graph/provider/pathoutage/PathOutageGraphProviderIT.java b/features/graph/provider/pathoutage/src/test/java/org/opennms/netmgt/graph/provider/pathoutage/PathOutageGraphProviderIT.java new file mode 100644 index 000000000000..3f916ed9a96c --- /dev/null +++ b/features/graph/provider/pathoutage/src/test/java/org/opennms/netmgt/graph/provider/pathoutage/PathOutageGraphProviderIT.java @@ -0,0 +1,140 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.graph.provider.pathoutage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.test.OpenNMSJUnit4ClassRunner; +import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; +import org.opennms.netmgt.dao.api.MonitoringLocationDao; +import org.opennms.netmgt.dao.api.NodeDao; +import org.opennms.netmgt.dao.api.SessionUtils; +import org.opennms.netmgt.graph.api.generic.GenericEdge; +import org.opennms.netmgt.graph.api.generic.GenericGraph; +import org.opennms.netmgt.graph.api.generic.GenericProperties; +import org.opennms.netmgt.graph.api.generic.GenericVertex; +import org.opennms.netmgt.model.OnmsNode; +import org.opennms.test.JUnitConfigurationEnvironment; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; + +/** + * Exercises {@link PathOutageGraphProvider} against the real Hibernate + * {@link NodeDao} on a temporary database. The mockito unit test can't cover + * the one risky part: {@code OnmsNode.getParent()} is a LAZY many-to-one, so + * the provider must traverse it inside its own read-only transaction. This + * test therefore deliberately runs WITHOUT a test-managed transaction -- + * {@code loadGraph()} is called the way the Graph REST service calls it, and + * would die with a LazyInitializationException if the provider's transaction + * wrapping were missing. + */ +@RunWith(OpenNMSJUnit4ClassRunner.class) +@ContextConfiguration(locations = { + "classpath:/META-INF/opennms/applicationContext-commonConfigs.xml", + "classpath:/META-INF/opennms/applicationContext-minimal-conf.xml", + "classpath:/META-INF/opennms/applicationContext-soa.xml", + "classpath:/META-INF/opennms/applicationContext-dao.xml", + "classpath:/META-INF/opennms/applicationContext-mockSnmpPeerFactory.xml", + "classpath:/META-INF/opennms/applicationContext-mockConfigManager.xml" }) +@JUnitConfigurationEnvironment +@JUnitTemporaryDatabase(reuseDatabase = false) +public class PathOutageGraphProviderIT { + + @Autowired + private NodeDao nodeDao; + + @Autowired + private MonitoringLocationDao locationDao; + + @Autowired + private SessionUtils sessionUtils; + + private PathOutageGraphProvider provider; + + private int routerId; + private int switchId; + private int serverId; + private int standaloneId; + + private OnmsNode node(final String label, final OnmsNode parent) { + final OnmsNode node = new OnmsNode(locationDao.getDefaultLocation(), label); + node.setParent(parent); + nodeDao.save(node); + return node; + } + + @Before + public void setUp() { + // router -> switch -> server, plus a standalone node outside the hierarchy. + sessionUtils.withTransaction(() -> { + final OnmsNode router = node("router", null); + final OnmsNode sw = node("switch", router); + final OnmsNode server = node("server", sw); + final OnmsNode standalone = node("standalone", null); + routerId = router.getId(); + switchId = sw.getId(); + serverId = server.getId(); + standaloneId = standalone.getId(); + }); + provider = new PathOutageGraphProvider(nodeDao, sessionUtils); + } + + @Test + public void loadsTheParentHierarchyWithoutACallerTransaction() { + // No surrounding transaction here, on purpose -- see the class javadoc. + final GenericGraph graph = provider.loadGraph().asGenericGraph(); + + assertEquals(PathOutageGraphProvider.NAMESPACE, graph.getNamespace()); + + final List vertexIds = graph.getVertices().stream() + .map(GenericVertex::getId) + .collect(Collectors.toList()); + assertTrue(vertexIds.containsAll(List.of( + String.valueOf(routerId), String.valueOf(switchId), String.valueOf(serverId)))); + // The standalone node takes part in no parent-child relation -> excluded. + assertEquals(3, graph.getVertices().size()); + assertTrue(!vertexIds.contains(String.valueOf(standaloneId))); + + // The parent's label was read through the lazy proxy. + final GenericVertex switchVertex = graph.getVertex(String.valueOf(switchId)); + assertEquals("switch", switchVertex.getProperty(GenericProperties.LABEL)); + assertEquals(String.valueOf(switchId), switchVertex.getProperty(GenericProperties.NODE_ID)); + + // Directed parent -> child: router -> switch and switch -> server. + assertEquals(2, graph.getEdges().size()); + for (final GenericEdge edge : graph.getEdges()) { + if (edge.getTarget().getId().equals(String.valueOf(switchId))) { + assertEquals(String.valueOf(routerId), edge.getSource().getId()); + } else { + assertEquals(String.valueOf(switchId), edge.getSource().getId()); + assertEquals(String.valueOf(serverId), edge.getTarget().getId()); + } + } + } +} diff --git a/features/graph/provider/pathoutage/src/test/java/org/opennms/netmgt/graph/provider/pathoutage/PathOutageGraphProviderTest.java b/features/graph/provider/pathoutage/src/test/java/org/opennms/netmgt/graph/provider/pathoutage/PathOutageGraphProviderTest.java new file mode 100644 index 000000000000..7b3bae89239c --- /dev/null +++ b/features/graph/provider/pathoutage/src/test/java/org/opennms/netmgt/graph/provider/pathoutage/PathOutageGraphProviderTest.java @@ -0,0 +1,122 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.netmgt.graph.provider.pathoutage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.Collections; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.Test; +import org.opennms.netmgt.dao.api.NodeDao; +import org.opennms.netmgt.dao.api.SessionUtils; +import org.opennms.netmgt.graph.api.generic.GenericEdge; +import org.opennms.netmgt.graph.api.generic.GenericGraph; +import org.opennms.netmgt.graph.api.generic.GenericProperties; +import org.opennms.netmgt.graph.api.generic.GenericVertex; +import org.opennms.netmgt.model.OnmsNode; + +public class PathOutageGraphProviderTest { + + private NodeDao nodeDao; + private PathOutageGraphProvider provider; + + @Before + public void setUp() { + nodeDao = mock(NodeDao.class); + final SessionUtils sessionUtils = mock(SessionUtils.class); + // Run the supplier directly; the transaction wrapper is irrelevant to the mock-backed test. + when(sessionUtils.withReadOnlyTransaction(any(Supplier.class))) + .thenAnswer(invocation -> invocation.getArgument(0, Supplier.class).get()); + provider = new PathOutageGraphProvider(nodeDao, sessionUtils); + } + + private static OnmsNode node(int id, String label, OnmsNode parent) { + final OnmsNode node = new OnmsNode(); + node.setId(id); + node.setLabel(label); + node.setParent(parent); + return node; + } + + @Test + public void graphInfoUsesThePathoutageNamespace() { + assertEquals(PathOutageGraphProvider.NAMESPACE, provider.getGraphInfo().getNamespace()); + assertNotNull(provider.getGraphInfo().getLabel()); + } + + @Test + public void buildsParentChildForestAndSkipsIsolatedNodes() { + final OnmsNode router = node(1, "router", null); + final OnmsNode sw = node(2, "switch", router); + final OnmsNode server = node(3, "server", sw); + final OnmsNode standalone = node(4, "standalone", null); + when(nodeDao.findAll()).thenReturn(Arrays.asList(router, sw, server, standalone)); + + final GenericGraph graph = provider.loadGraph().asGenericGraph(); + + assertEquals(PathOutageGraphProvider.NAMESPACE, graph.getNamespace()); + // standalone has neither parent nor children -> excluded + assertEquals(Arrays.asList("1", "2", "3"), + graph.getVertices().stream().map(GenericVertex::getId).sorted().collect(Collectors.toList())); + assertEquals(2, graph.getEdges().size()); + + // Vertices carry the node linkage + label the REST consumers rely on + final GenericVertex switchVertex = graph.getVertex("2"); + assertEquals("switch", switchVertex.getProperty(GenericProperties.LABEL)); + assertEquals("2", switchVertex.getProperty(GenericProperties.NODE_ID)); + + // Edges are directed parent -> child + for (final GenericEdge edge : graph.getEdges()) { + if (edge.getTarget().getId().equals("2")) { + assertEquals("1", edge.getSource().getId()); + } else { + assertEquals("2", edge.getSource().getId()); + assertEquals("3", edge.getTarget().getId()); + } + } + } + + @Test + public void emptyWhenNoParentsAreConfigured() { + when(nodeDao.findAll()).thenReturn(Arrays.asList(node(1, "a", null), node(2, "b", null))); + final GenericGraph graph = provider.loadGraph().asGenericGraph(); + assertTrue(graph.getVertices().isEmpty()); + assertTrue(graph.getEdges().isEmpty()); + } + + @Test + public void emptyWhenThereAreNoNodes() { + when(nodeDao.findAll()).thenReturn(Collections.emptyList()); + final GenericGraph graph = provider.loadGraph().asGenericGraph(); + assertTrue(graph.getVertices().isEmpty()); + assertTrue(graph.getEdges().isEmpty()); + } +} diff --git a/features/graph/provider/pom.xml b/features/graph/provider/pom.xml index 2ea9b6caf4a2..8e663d53138f 100644 --- a/features/graph/provider/pom.xml +++ b/features/graph/provider/pom.xml @@ -16,6 +16,7 @@ bsm graphml legacy + pathoutage persistence-test topology