From d5f0998bbab205440167a7006ad2dd42119c6e57 Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Mon, 8 Jun 2026 11:41:52 -0400 Subject: [PATCH 1/8] Add /api/v2/topology/infopanel: render etc/infopanel templates for a node A Vaadin-free, REST-exposed counterpart of the legacy topology map's GenericInfoPanelItemProvider, so operators' existing etc/infopanel Jinjava templates surface in the new Vue topology Inspector. GET /api/v2/topology/infopanel?nodeId= returns [{title, order, html}] for the node: each etc/infopanel/*.html template is rendered with an equivalent context (node, nodeResource, nodeDao, resourceDao, measurements, System:currentTimeMillis), and the visible, error-free results are sorted by order. - InfoPanelRenderer: the jinjava rendering (node-backed; vertex/edge are not populated, so vertex-only templates are simply not shown). - NodeDaoWrapper / ResourceDaoWrapper / MeasurementsWrapper: read-only context wrappers mirroring the legacy ones so templates render unchanged. - TopologyInfopanelRestService: read-only, @Transactional so templates can traverse lazy node/resource associations; measurements optional. The returned HTML is operator-authored; the client must sanitize before injecting into the DOM. --- opennms-webapp-rest/pom.xml | 12 ++ .../web/rest/v2/infopanel/InfoPanelItem.java | 75 ++++++++ .../rest/v2/infopanel/InfoPanelRenderer.java | 172 ++++++++++++++++++ .../v2/infopanel/MeasurementsWrapper.java | 171 +++++++++++++++++ .../web/rest/v2/infopanel/NodeDaoWrapper.java | 117 ++++++++++++ .../rest/v2/infopanel/ResourceDaoWrapper.java | 68 +++++++ .../TopologyInfopanelRestService.java | 102 +++++++++++ .../v2/infopanel/InfoPanelRendererTest.java | 112 ++++++++++++ 8 files changed, 829 insertions(+) create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelItem.java create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/MeasurementsWrapper.java create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/NodeDaoWrapper.java create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/ResourceDaoWrapper.java create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestService.java create mode 100644 opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/InfoPanelRendererTest.java diff --git a/opennms-webapp-rest/pom.xml b/opennms-webapp-rest/pom.xml index 38b733ee39f6..424ddd0b9c63 100644 --- a/opennms-webapp-rest/pom.xml +++ b/opennms-webapp-rest/pom.xml @@ -205,6 +205,18 @@ org.opennms.core.config ${onmsLibScope} + + + org.opennms.features.measurements + org.opennms.features.measurements.api + ${onmsLibScope} + + + com.hubspot.jinjava + jinjava + 2.8.3 + org.slf4j slf4j-api diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelItem.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelItem.java new file mode 100644 index 000000000000..25d6954f10a5 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelItem.java @@ -0,0 +1,75 @@ +/* + * 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.web.rest.v2.infopanel; + +import org.codehaus.jackson.annotate.JsonProperty; + +/** + * One rendered info-panel item: a titled HTML fragment produced from an + * {@code etc/infopanel/} Jinjava template. {@code order} controls display + * sequence (ascending). The HTML is operator-authored; the consuming client is + * responsible for sanitizing it before injecting into the DOM. + */ +public class InfoPanelItem { + + @JsonProperty("title") + private String title; + + @JsonProperty("order") + private int order; + + @JsonProperty("html") + private String html; + + public InfoPanelItem() { + } + + public InfoPanelItem(final String title, final int order, final String html) { + this.title = title; + this.order = order; + this.html = html; + } + + public String getTitle() { + return title; + } + + public void setTitle(final String title) { + this.title = title; + } + + public int getOrder() { + return order; + } + + public void setOrder(final int order) { + this.order = order; + } + + public String getHtml() { + return html; + } + + public void setHtml(final String html) { + this.html = html; + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java new file mode 100644 index 000000000000..9f2f1744ce8f --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java @@ -0,0 +1,172 @@ +/* + * 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.web.rest.v2.infopanel; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.opennms.netmgt.dao.api.NodeDao; +import org.opennms.netmgt.dao.api.ResourceDao; +import org.opennms.netmgt.measurements.api.MeasurementsService; +import org.opennms.netmgt.model.OnmsNode; +import org.opennms.netmgt.model.OnmsResource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.hubspot.jinjava.Jinjava; +import com.hubspot.jinjava.interpret.RenderResult; +import com.hubspot.jinjava.interpret.TemplateError; +import com.hubspot.jinjava.lib.fn.ELFunctionDefinition; + +/** + * Renders operator-authored Jinjava templates from {@code $OPENNMS_HOME/etc/infopanel/} + * into structured {@link InfoPanelItem}s for a selected node. This is the + * Vaadin-free counterpart of the legacy topology map's + * {@code GenericInfoPanelItemProvider}: it builds an equivalent template context + * (so existing templates render unchanged) but returns HTML rather than Vaadin + * components, for the REST layer. + * + *

Scope is node-backed elements (a placed node carries a real OnmsNode id). + * The {@code vertex}/{@code edge} context variables of the legacy provider are + * not populated here; templates guard their use with {@code vertex != null}, so + * vertex-only templates are simply not shown. + */ +public class InfoPanelRenderer { + + private static final Logger LOG = LoggerFactory.getLogger(InfoPanelRenderer.class); + + private final NodeDao nodeDao; + private final ResourceDao resourceDao; + private final MeasurementsService measurementsService; // may be null + private final Path templateDir; + + private final Jinjava jinjava; + private final NodeDaoWrapper nodeDaoWrapper; + private final ResourceDaoWrapper resourceDaoWrapper; + + public InfoPanelRenderer(final NodeDao nodeDao, + final ResourceDao resourceDao, + final MeasurementsService measurementsService, + final Path templateDir) { + this.nodeDao = Objects.requireNonNull(nodeDao); + this.resourceDao = Objects.requireNonNull(resourceDao); + this.measurementsService = measurementsService; + this.templateDir = Objects.requireNonNull(templateDir); + + this.jinjava = withClassLoaderFix(Jinjava::new); + this.jinjava.getGlobalContext().registerFunction( + new ELFunctionDefinition("System", "currentTimeMillis", System.class, "currentTimeMillis")); + + this.nodeDaoWrapper = new NodeDaoWrapper(nodeDao); + this.resourceDaoWrapper = new ResourceDaoWrapper(resourceDao); + } + + /** + * Render every applicable {@code etc/infopanel/} template for the given node, + * returning the visible, error-free items sorted by their {@code order}. + * Returns an empty list when no template directory exists (the common case). + */ + public List renderForNode(final OnmsNode node) { + Objects.requireNonNull(node); + if (!Files.isDirectory(templateDir)) { + return Collections.emptyList(); + } + final List items = new ArrayList<>(); + try (final DirectoryStream stream = Files.newDirectoryStream(templateDir, "*.html")) { + for (final Path path : stream) { + try { + final RenderResult result = render(path, node); + final boolean fatal = result.getErrors().stream() + .anyMatch(e -> e.getSeverity() == TemplateError.ErrorType.FATAL); + if (fatal) { + LOG.warn("Skipping info-panel template {} due to fatal render error(s): {}", path, result.getErrors()); + continue; + } + if (Boolean.TRUE.equals(result.getContext().getOrDefault("visible", false))) { + final String title = String.valueOf(result.getContext().getOrDefault("title", "No title defined")); + final int order = ((Number) result.getContext().getOrDefault("order", 0L)).intValue(); + items.add(new InfoPanelItem(title, order, result.getOutput())); + } + } catch (final IOException e) { + LOG.warn("Failed to read info-panel template {}: {}", path, e.getMessage()); + } + } + } catch (final IOException e) { + LOG.warn("Failed to read info-panel template directory {}: {}", templateDir, e.getMessage()); + return Collections.emptyList(); + } + items.sort(Comparator.comparingInt(InfoPanelItem::getOrder)); + return items; + } + + private RenderResult render(final Path path, final OnmsNode node) throws IOException { + final Map context = createContext(node); + try (final Stream lines = Files.lines(path, StandardCharsets.UTF_8)) { + final String template = lines.collect(Collectors.joining("\n")); + return withClassLoaderFix(() -> jinjava.renderForResult(template, context)); + } + } + + /** Build a fresh context per template; templates write visible/title/order into it. */ + private Map createContext(final OnmsNode node) { + final Map context = new HashMap<>(); + context.put("node", node); + final OnmsResource resource = resourceDao.getResourceForNode(node); + if (resource != null) { + context.put("nodeResource", resource); + } + context.put("nodeDao", nodeDaoWrapper); + context.put("resourceDao", resourceDaoWrapper); + if (measurementsService != null) { + context.put("measurements", new MeasurementsWrapper(measurementsService)); + } + return context; + } + + /** + * Jinjava's JUEL expression engine resolves helper classes via the thread + * context classloader; pin it to this class's loader for the duration of the + * call so it finds the bundled dependencies (mirrors the legacy provider). + */ + private static T withClassLoaderFix(final Supplier supplier) { + final ClassLoader previous = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(InfoPanelRenderer.class.getClassLoader()); + return supplier.get(); + } finally { + Thread.currentThread().setContextClassLoader(previous); + } + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/MeasurementsWrapper.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/MeasurementsWrapper.java new file mode 100644 index 000000000000..afc89dc8ff61 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/MeasurementsWrapper.java @@ -0,0 +1,171 @@ +/* + * 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.web.rest.v2.infopanel; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.opennms.netmgt.measurements.api.MeasurementsService; +import org.opennms.netmgt.measurements.api.exceptions.MeasurementException; +import org.opennms.netmgt.measurements.model.Expression; +import org.opennms.netmgt.measurements.model.QueryRequest; +import org.opennms.netmgt.measurements.model.QueryResponse; +import org.opennms.netmgt.measurements.model.Source; +import org.opennms.netmgt.model.OnmsNode; +import org.opennms.netmgt.model.OnmsSnmpInterface; + +import com.google.common.primitives.Doubles; + +/** + * Wrapper around {@link MeasurementsService} placed into the Jinjava template + * context as {@code measurements}, so templates can embed last-values, + * arbitrary queries, and interface utilization. + * + *

Mirrors the legacy topology map's wrapper of the same name so existing + * {@code etc/infopanel/} templates render unchanged. + */ +public class MeasurementsWrapper { + + private final MeasurementsService measurementsService; + + public MeasurementsWrapper(final MeasurementsService measurementsService) { + this.measurementsService = measurementsService; + } + + /** Last value for a resource/attribute over the default 15-minute window (AVERAGE). */ + public double getLastValue(final String resource, final String attribute) throws MeasurementException { + return getLastValue(resource, attribute, "AVERAGE"); + } + + /** Last value for a resource/attribute with the given aggregation. */ + public double getLastValue(final String resource, final String attribute, final String aggregation) throws MeasurementException { + return getLastValue(resource, attribute, aggregation, true); + } + + public double getLastValue(final String resource, final String attribute, final String aggregation, final boolean relaxed) throws MeasurementException { + final long end = System.currentTimeMillis(); + final long start = end - (15 * 60 * 1000); + + final QueryResponse.WrappedPrimitive[] columns = queryInt(resource, attribute, start, end, 300000, aggregation, relaxed).getColumns(); + + if (columns.length > 0) { + final double[] values = columns[0].getList(); + for (int i = values.length - 1; i >= 0; i--) { + if (!Double.isNaN(values[i])) { + return values[i]; + } + } + } + return Double.NaN; + } + + /** Query a resource/attribute over an explicit window, returning the series. */ + public List query(final String resource, final String attribute, final long start, final long end, final long step, final String aggregation, final boolean relaxed) throws MeasurementException { + final QueryResponse.WrappedPrimitive[] columns = queryInt(resource, attribute, start, end, step, aggregation, relaxed).getColumns(); + if (columns.length > 0) { + return Doubles.asList(columns[0].getList()); + } + return Collections.emptyList(); + } + + /** + * Compute the in/out percentage utilization of a node interface (by ifName), + * using HC octet attributes with non-HC fallbacks. + */ + public List computeUtilization(final OnmsNode node, final String ifName) throws MeasurementException { + final long end = System.currentTimeMillis(); + final long start = end - (15 * 60 * 1000); + + for (final OnmsSnmpInterface snmpInterface : node.getSnmpInterfaces()) { + if (ifName.equals(snmpInterface.getIfName())) { + final String resourceId = "node[" + node.getId() + "].interfaceSnmp[" + snmpInterface.computeLabelForRRD() + "]"; + return computeUtilization(resourceId, start, end, 300000, "AVERAGE"); + } + } + return Arrays.asList(Double.NaN, Double.NaN); + } + + /** Compute the in/out percentage utilization of an interface resource over a window. */ + public List computeUtilization(final String resource, final long start, final long end, final long step, final String aggregation) throws MeasurementException { + final QueryRequest request = new QueryRequest(); + request.setRelaxed(true); + request.setStart(start); + request.setEnd(end); + request.setStep(step); + + final Source sourceIn = new Source(); + sourceIn.setAggregation(aggregation); + sourceIn.setTransient(true); + sourceIn.setAttribute("ifHCInOctets"); + sourceIn.setFallbackAttribute("ifInOctets"); + sourceIn.setResourceId(resource); + sourceIn.setLabel("ifInOctets"); + + final Source sourceOut = new Source(); + sourceOut.setAggregation(aggregation); + sourceOut.setTransient(true); + sourceOut.setAttribute("ifHCOutOctets"); + sourceOut.setFallbackAttribute("ifOutOctets"); + sourceOut.setResourceId(resource); + sourceOut.setLabel("ifOutOctets"); + + request.setExpressions(Arrays.asList( + new Expression("ifInPercent", "(8 * ifInOctects / 1000000) / ifInOctets.ifHighSpeed * 100", false), + new Expression("ifOutPercent", "(8 * ifOutOctects / 1000000) / ifOutOctets.ifHighSpeed * 100", false))); + request.setSources(Arrays.asList(sourceIn, sourceOut)); + + final QueryResponse.WrappedPrimitive[] columns = measurementsService.query(request).getColumns(); + final double[] valuesIn = columns[0].getList(); + final double[] valuesOut = columns[1].getList(); + + for (int i = valuesIn.length - 1; i >= 0; i--) { + if (!Double.isNaN(valuesIn[i]) && !Double.isNaN(valuesOut[i])) { + return Arrays.asList(valuesIn[i], valuesOut[i]); + } + } + return Arrays.asList(Double.NaN, Double.NaN); + } + + /** Direct pass-through to the measurements query API. */ + public QueryResponse query(final QueryRequest request) throws MeasurementException { + return measurementsService.query(request); + } + + private QueryResponse queryInt(final String resource, final String attribute, final long start, final long end, final long step, final String aggregation, final boolean relaxed) throws MeasurementException { + final QueryRequest request = new QueryRequest(); + request.setRelaxed(relaxed); + request.setStart(start); + request.setEnd(end); + request.setStep(step); + + final Source source = new Source(); + source.setAggregation(aggregation); + source.setTransient(false); + source.setAttribute(attribute); + source.setResourceId(resource); + source.setLabel(attribute); + + request.setSources(Collections.singletonList(source)); + return measurementsService.query(request); + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/NodeDaoWrapper.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/NodeDaoWrapper.java new file mode 100644 index 000000000000..5eda18d66b95 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/NodeDaoWrapper.java @@ -0,0 +1,117 @@ +/* + * 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.web.rest.v2.infopanel; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.opennms.core.criteria.Criteria; +import org.opennms.netmgt.dao.api.NodeDao; +import org.opennms.netmgt.model.OnmsNode; + +/** + * Read-only wrapper around {@link NodeDao} placed into the Jinjava template + * context used by {@link InfoPanelRenderer}. Only query/lookup methods are + * delegated; mutating operations are deliberately not exposed. + * + *

Because Jinjava resolves methods via reflection on the runtime class, + * keeping this as a concrete POJO (rather than a {@code NodeDao} subclass) + * guarantees only the methods declared here are reachable from templates. + * + *

Mirrors the legacy topology map's wrapper of the same name so existing + * {@code etc/infopanel/} templates render unchanged. + */ +public class NodeDaoWrapper { + + private final NodeDao delegate; + + public NodeDaoWrapper(final NodeDao delegate) { + this.delegate = Objects.requireNonNull(delegate); + } + + /** Look up a node by its integer database ID. */ + public OnmsNode get(final Integer id) { + return delegate.get(id); + } + + /** Look up a node by criteria string (nodeId or foreignSource:foreignId). */ + public OnmsNode get(final String lookupCriteria) { + return delegate.get(lookupCriteria); + } + + /** Load a node by ID; throws if not found. */ + public OnmsNode load(final Integer id) { + return delegate.load(id); + } + + /** Map of every nodeId → nodeLabel. Cheaper than {@code findAll()}. */ + public Map getAllLabelsById() { + return delegate.getAllLabelsById(); + } + + /** Return just the label for one node. */ + public String getLabelForId(final Integer id) { + return delegate.getLabelForId(id); + } + + /** Return the monitoring-location name for one node. */ + public String getLocationForId(final Integer id) { + return delegate.getLocationForId(id); + } + + /** Return all nodes (expensive on large systems -- use sparingly). */ + public List findAll() { + return delegate.findAll(); + } + + /** Find nodes whose label matches exactly. */ + public List findByLabel(final String label) { + return delegate.findByLabel(label); + } + + /** Count all nodes. */ + public int countAll() { + return delegate.countAll(); + } + + /** Count nodes matching the supplied {@link Criteria}. */ + public int countMatching(final Criteria criteria) { + return delegate.countMatching(criteria); + } + + /** Return nodes matching the supplied {@link Criteria}. */ + public List findMatching(final Criteria criteria) { + return delegate.findMatching(criteria); + } + + /** Map of foreignId → nodeId for every node that has a foreignSource. */ + public Map getForeignIdToNodeIdMap(final String foreignSource) { + return delegate.getForeignIdToNodeIdMap(foreignSource); + } + + /** Map of foreignSource → set-of-foreignIds. */ + public Map> getForeignIdsPerForeignSourceMap() { + return delegate.getForeignIdsPerForeignSourceMap(); + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/ResourceDaoWrapper.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/ResourceDaoWrapper.java new file mode 100644 index 000000000000..13e5e37e6e50 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/ResourceDaoWrapper.java @@ -0,0 +1,68 @@ +/* + * 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.web.rest.v2.infopanel; + +import java.util.List; +import java.util.Objects; + +import org.opennms.netmgt.dao.api.ResourceDao; +import org.opennms.netmgt.model.OnmsNode; +import org.opennms.netmgt.model.OnmsResource; +import org.opennms.netmgt.model.ResourceId; + +/** + * Read-only wrapper around {@link ResourceDao} placed into the Jinjava template + * context used by {@link InfoPanelRenderer}. Only query/lookup methods are + * delegated; the mutating {@code deleteResourceById} is not exposed. + * + *

Mirrors the legacy topology map's wrapper of the same name so existing + * {@code etc/infopanel/} templates render unchanged. + */ +public class ResourceDaoWrapper { + + private final ResourceDao delegate; + + public ResourceDaoWrapper(final ResourceDao delegate) { + this.delegate = Objects.requireNonNull(delegate); + } + + /** + * Retrieve a resource by its unique {@link ResourceId} + * (e.g. {@code node[1].nodeSnmp[]}), or {@code null} if not found. + */ + public OnmsResource getResourceById(final ResourceId id) { + return delegate.getResourceById(id); + } + + /** Retrieve the resource tree rooted at the given node (children populated). */ + public OnmsResource getResourceForNode(final OnmsNode node) { + return delegate.getResourceForNode(node); + } + + /** + * Return all top-level resources (typically one per monitored node). + * Can be expensive -- use with care in templates. + */ + public List findTopLevelResources() { + return delegate.findTopLevelResources(); + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestService.java new file mode 100644 index 000000000000..dbed2ad63d0a --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestService.java @@ -0,0 +1,102 @@ +/* + * 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.web.rest.v2.infopanel; + +import java.nio.file.Paths; +import java.util.List; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import org.opennms.netmgt.dao.api.NodeDao; +import org.opennms.netmgt.dao.api.ResourceDao; +import org.opennms.netmgt.measurements.api.MeasurementsService; +import org.opennms.netmgt.model.OnmsNode; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * Serves operator-configured topology info-panel content for a node: the + * rendered {@code $OPENNMS_HOME/etc/infopanel/} Jinjava templates as + * {@code [{title, order, html}]}. The new Vue topology Inspector appends these + * below its native fields, so the existing template investment carries over. + * + *

Read-only and open to any authenticated user via the default + * {@code /api/v2/**} security rules. {@code @Transactional} keeps a Hibernate + * session open so templates can traverse lazy node/resource associations. + */ +@Component +@Path("topology/infopanel") +@Tag(name = "Topology", description = "Topology map APIs") +@Produces(MediaType.APPLICATION_JSON) +public class TopologyInfopanelRestService { + + @Autowired + private NodeDao m_nodeDao; + + @Autowired + private ResourceDao m_resourceDao; + + // Optional: templates that embed metrics use it; absent installs degrade + // gracefully (those template sections are skipped, not fatal to the rest). + @Autowired(required = false) + private MeasurementsService m_measurementsService; + + private volatile InfoPanelRenderer m_renderer; + + @GET + @Transactional(readOnly = true) + public List getForNode(@QueryParam("nodeId") final Integer nodeId) { + if (nodeId == null) { + throw webException(Response.Status.BAD_REQUEST, "A nodeId query parameter is required"); + } + final OnmsNode node = m_nodeDao.get(nodeId); + if (node == null) { + throw webException(Response.Status.NOT_FOUND, "No node with id " + nodeId); + } + return renderer().renderForNode(node); + } + + private InfoPanelRenderer renderer() { + if (m_renderer == null) { + synchronized (this) { + if (m_renderer == null) { + final java.nio.file.Path dir = Paths.get(System.getProperty("opennms.home", "."), "etc", "infopanel"); + m_renderer = new InfoPanelRenderer(m_nodeDao, m_resourceDao, m_measurementsService, dir); + } + } + } + return m_renderer; + } + + private static javax.ws.rs.WebApplicationException webException(final Response.Status status, final String message) { + return new javax.ws.rs.WebApplicationException( + Response.status(status).entity(message).type(MediaType.TEXT_PLAIN).build()); + } +} diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/InfoPanelRendererTest.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/InfoPanelRendererTest.java new file mode 100644 index 000000000000..a28601f1d67d --- /dev/null +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/InfoPanelRendererTest.java @@ -0,0 +1,112 @@ +/* + * 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.web.rest.v2.infopanel; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.opennms.netmgt.dao.api.NodeDao; +import org.opennms.netmgt.dao.api.ResourceDao; +import org.opennms.netmgt.model.OnmsNode; + +/** + * Unit-tests the Jinjava info-panel rendering against a temporary template + * directory and mocked DAOs -- no Spring context or database. + */ +public class InfoPanelRendererTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private NodeDao nodeDao; + private ResourceDao resourceDao; + private OnmsNode node; + + @Before + public void setUp() { + nodeDao = mock(NodeDao.class); + resourceDao = mock(ResourceDao.class); + when(resourceDao.getResourceForNode(org.mockito.ArgumentMatchers.any())).thenReturn(null); + node = new OnmsNode(); + node.setId(42); + node.setLabel("test-node"); + } + + private void writeTemplate(final String name, final String body) throws Exception { + final Path p = tmp.getRoot().toPath().resolve(name); + Files.write(p, body.getBytes(StandardCharsets.UTF_8)); + } + + private InfoPanelRenderer renderer() { + return new InfoPanelRenderer(nodeDao, resourceDao, null, tmp.getRoot().toPath()); + } + + @Test + public void rendersVisibleTemplatesSortedByOrderWithNodeContext() throws Exception { + writeTemplate("a-second.html", + "{% set visible = true %}{% set title = \"Second\" %}{% set order = 10 %}" + + "{{ node.label }} (id {{ node.id }})"); + writeTemplate("b-first.html", + "{% set visible = true %}{% set title = \"First\" %}{% set order = 5 %}hello"); + + final List items = renderer().renderForNode(node); + + assertThat(items, hasSize(2)); + // sorted by order ascending regardless of filename + assertThat(items.stream().map(InfoPanelItem::getTitle).collect(Collectors.toList()), + contains("First", "Second")); + assertThat(items.get(0).getOrder(), is(5)); + assertThat(items.get(1).getOrder(), is(10)); + // node context resolved + assertThat(items.get(1).getHtml(), containsString("test-node (id 42)")); + } + + @Test + public void skipsTemplatesThatAreNotVisible() throws Exception { + writeTemplate("hidden.html", "{% set visible = false %}{% set title = \"Nope\" %}should not show"); + + assertThat(renderer().renderForNode(node), is(empty())); + } + + @Test + public void missingTemplateDirYieldsNoItems() { + final Path absent = tmp.getRoot().toPath().resolve("does-not-exist"); + final InfoPanelRenderer r = new InfoPanelRenderer(nodeDao, resourceDao, null, absent); + assertThat(r.renderForNode(node), is(empty())); + } +} From e507780c0d298aba5d6b2cef570344b8cbc58315 Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Mon, 8 Jun 2026 12:27:11 -0400 Subject: [PATCH 2/8] Package jinjava in the webapp for the topology info-panel endpoint opennms-webapp enumerates the 3rd-party libs that belong in WEB-INF/lib; add jinjava (compile) so it and its runtime closure (JUEL shaded in, plus javassist/re2j/big-math/hubspot-immutables/java-ipv6) ship in the webapp and are visible -- via the shared webapp classloader -- to the opennms-webapp-rest /api/v2/topology/infopanel renderer. Verified live: the endpoint renders etc/infopanel templates for a node (context, visible filter, and order all honored). --- opennms-webapp/pom.xml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/opennms-webapp/pom.xml b/opennms-webapp/pom.xml index 271c602a4dd2..359145d512d0 100644 --- a/opennms-webapp/pom.xml +++ b/opennms-webapp/pom.xml @@ -836,6 +836,16 @@ org.apache.servicemix.bundles.jdom 1.1_4 + + + + com.hubspot.jinjava + jinjava + 2.8.3 + org.opennms.extremecomponents extremecomponents From 9672ef9da6070db65d4c33b1134f66da7b712204 Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Mon, 8 Jun 2026 16:16:00 -0400 Subject: [PATCH 3/8] Add REST integration test for /api/v2/topology/infopanel Exercises the endpoint through the full REST stack: 400 without a nodeId, 404 for an unknown node, and a 200 JSON array for a real node (empty in a stock test environment with no etc/infopanel templates). Confirms the resource and its autowired NodeDao/ResourceDao/MeasurementsService wire up. Template rendering itself is covered by InfoPanelRendererTest. --- .../TopologyInfopanelRestServiceIT.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestServiceIT.java diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestServiceIT.java new file mode 100644 index 000000000000..cfc454a933b4 --- /dev/null +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestServiceIT.java @@ -0,0 +1,94 @@ +/* + * 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.web.rest.v2.infopanel; + +import static org.junit.Assert.assertEquals; + +import org.json.JSONArray; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.test.MockLogAppender; +import org.opennms.core.test.OpenNMSJUnit4ClassRunner; +import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; +import org.opennms.core.test.rest.AbstractSpringJerseyRestTestCase; +import org.opennms.test.JUnitConfigurationEnvironment; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.web.WebAppConfiguration; + +/** + * Integration test for {@code GET /api/v2/topology/infopanel}. Exercises the + * resource through the REST stack: that it is wired (beans autowire), validates + * its input (400 without a nodeId), 404s an unknown node, and returns a JSON + * array for a real node. The rendering of {@code etc/infopanel} templates is + * covered by {@code InfoPanelRendererTest}; a stock test environment has no + * such templates, so a real node yields an empty array here. + */ +@RunWith(OpenNMSJUnit4ClassRunner.class) +@WebAppConfiguration +@ContextConfiguration(locations = { + "classpath:/META-INF/opennms/applicationContext-soa.xml", + "classpath:/META-INF/opennms/applicationContext-commonConfigs.xml", + "classpath:/META-INF/opennms/applicationContext-minimal-conf.xml", + "classpath:/META-INF/opennms/applicationContext-dao.xml", + "classpath:/META-INF/opennms/applicationContext-mockConfigManager.xml", + "classpath*:/META-INF/opennms/component-service.xml", + "classpath*:/META-INF/opennms/component-dao.xml", + "classpath:/META-INF/opennms/applicationContext-databasePopulator.xml", + "classpath:/META-INF/opennms/mockEventIpcManager.xml", + "file:src/main/webapp/WEB-INF/applicationContext-svclayer.xml", + "file:src/main/webapp/WEB-INF/applicationContext-cxf-common.xml" +}) +@JUnitConfigurationEnvironment(systemProperties = "org.opennms.timeseries.strategy=integration") +@JUnitTemporaryDatabase +public class TopologyInfopanelRestServiceIT extends AbstractSpringJerseyRestTestCase { + + public TopologyInfopanelRestServiceIT() { + super(CXF_REST_V2_CONTEXT_PATH); + } + + @Override + protected void afterServletStart() throws Exception { + MockLogAppender.setupLogging(true, "DEBUG"); + } + + @Test + @JUnitTemporaryDatabase + public void infopanelEndpoint() throws Exception { + // A nodeId is required. + sendRequest(GET, "/topology/infopanel", 400); + + // Unknown node -> 404 (before any template is created). + sendRequest(GET, "/topology/infopanel", parseParamData("nodeId=999999"), 404); + + // Create a node, then the endpoint returns a JSON array for it. With no + // etc/infopanel templates in the test environment, the array is empty. + final String node = "" + + "H" + + "TestMachine1" + + ".1.3.6.1.4.1.8072.3.2.255" + + ""; + sendPost("/nodes", node, 201); + + final String body = sendRequest(GET, "/topology/infopanel", parseParamData("nodeId=1"), 200); + assertEquals(0, new JSONArray(body).length()); + } +} From 6158d16e2ca123c8f2772cf2c690f73a432d337e Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Thu, 11 Jun 2026 12:12:50 -0400 Subject: [PATCH 4/8] Render edge-scoped info-panel templates for links Edge-scoped etc/infopanel/ templates (legacy createEdgeContext put the selected edge into the Jinjava context) now have a counterpart: GET /api/v2/topology/infopanel/edge?sourceNodeId=&targetNodeId= [&sourcePort=&targetPort=&protocol=] renders templates with an 'edge' variable mirroring the legacy LinkdEdge surface (discoveredBy, sourcePort/targetPort with node, ifName, ifIndex). Port names resolve to the node's OnmsSnmpInterface by ifName/ifDescr when they match; that resolved interface is also the identity future link status and metrics work will key on. --- .../web/rest/v2/infopanel/EdgeInfo.java | 96 +++++++++++++++++++ .../rest/v2/infopanel/InfoPanelRenderer.java | 43 +++++++-- .../TopologyInfopanelRestService.java | 45 +++++++++ .../v2/infopanel/InfoPanelRendererTest.java | 33 +++++++ 4 files changed, 207 insertions(+), 10 deletions(-) create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/EdgeInfo.java diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/EdgeInfo.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/EdgeInfo.java new file mode 100644 index 000000000000..0f81d83c26d0 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/EdgeInfo.java @@ -0,0 +1,96 @@ +/* + * 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.web.rest.v2.infopanel; + +import org.opennms.netmgt.model.OnmsNode; +import org.opennms.netmgt.model.OnmsSnmpInterface; + +/** + * The {@code edge} variable for edge-scoped info-panel templates: the + * Vaadin-free counterpart of the legacy {@code LinkdEdge} the old map put + * into the template context. The legacy surface templates used -- + * {@code edge.discoveredBy}, {@code edge.sourcePort.ifIndex}, + * {@code edge.sourcePort.vertex} (the node) -- maps onto this shape as + * {@code edge.discoveredBy}, {@code edge.sourcePort.ifIndex} and + * {@code edge.sourcePort.node}. + * + *

Each side also exposes the resolved {@link OnmsSnmpInterface} (when the + * port name matches one), which is the identity that future link status / + * metrics work keys on as well -- extend here, not in a parallel model. + */ +public class EdgeInfo { + + /** One endpoint of the edge: a node plus (optionally) a resolved port. */ + public static class Port { + private final OnmsNode node; + private final String ifName; + private final OnmsSnmpInterface snmpInterface; + + public Port(final OnmsNode node, final String ifName, final OnmsSnmpInterface snmpInterface) { + this.node = node; + this.ifName = ifName; + this.snmpInterface = snmpInterface; + } + + public OnmsNode getNode() { + return node; + } + + /** The port label persisted with the link (typically the ifName). */ + public String getIfName() { + return ifName; + } + + /** Resolved SNMP interface for the port name, or null when unmatched. */ + public OnmsSnmpInterface getSnmpInterface() { + return snmpInterface; + } + + /** Legacy-parity convenience ({@code LinkdPort.getIfIndex()}). */ + public Integer getIfIndex() { + return snmpInterface != null ? snmpInterface.getIfIndex() : null; + } + } + + private final String discoveredBy; + private final Port sourcePort; + private final Port targetPort; + + public EdgeInfo(final String discoveredBy, final Port sourcePort, final Port targetPort) { + this.discoveredBy = discoveredBy; + this.sourcePort = sourcePort; + this.targetPort = targetPort; + } + + /** Discovery protocol (lldp, cdp, ospf, isis, bridge), or null for drawn links. */ + public String getDiscoveredBy() { + return discoveredBy; + } + + public Port getSourcePort() { + return sourcePort; + } + + public Port getTargetPort() { + return targetPort; + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java index 9f2f1744ce8f..77140c0b211b 100644 --- a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java @@ -58,10 +58,11 @@ * (so existing templates render unchanged) but returns HTML rather than Vaadin * components, for the REST layer. * - *

Scope is node-backed elements (a placed node carries a real OnmsNode id). - * The {@code vertex}/{@code edge} context variables of the legacy provider are - * not populated here; templates guard their use with {@code vertex != null}, so - * vertex-only templates are simply not shown. + *

Node selections render with the {@code node}/{@code nodeResource} + * context; edge selections render with the {@code edge} context (see + * {@link EdgeInfo} for how the legacy {@code LinkdEdge} surface maps). The + * legacy {@code vertex} variable is not populated; templates guard their use + * with {@code vertex != null}, so vertex-only templates are simply not shown. */ public class InfoPanelRenderer { @@ -100,6 +101,22 @@ public InfoPanelRenderer(final NodeDao nodeDao, */ public List renderForNode(final OnmsNode node) { Objects.requireNonNull(node); + return renderAll(createContext(node)); + } + + /** + * Render every applicable template for the given edge ({@code edge} in the + * template context, alongside the shared DAO/measurements helpers), same + * visible/title/order contract as {@link #renderForNode}. + */ + public List renderForEdge(final EdgeInfo edge) { + Objects.requireNonNull(edge); + final Map context = sharedContext(); + context.put("edge", edge); + return renderAll(context); + } + + private List renderAll(final Map context) { if (!Files.isDirectory(templateDir)) { return Collections.emptyList(); } @@ -107,7 +124,7 @@ public List renderForNode(final OnmsNode node) { try (final DirectoryStream stream = Files.newDirectoryStream(templateDir, "*.html")) { for (final Path path : stream) { try { - final RenderResult result = render(path, node); + final RenderResult result = render(path, context); final boolean fatal = result.getErrors().stream() .anyMatch(e -> e.getSeverity() == TemplateError.ErrorType.FATAL); if (fatal) { @@ -131,22 +148,28 @@ public List renderForNode(final OnmsNode node) { return items; } - private RenderResult render(final Path path, final OnmsNode node) throws IOException { - final Map context = createContext(node); + private RenderResult render(final Path path, final Map context) throws IOException { + // Fresh map per template: templates write visible/title/order into it. + final Map templateContext = new HashMap<>(context); try (final Stream lines = Files.lines(path, StandardCharsets.UTF_8)) { final String template = lines.collect(Collectors.joining("\n")); - return withClassLoaderFix(() -> jinjava.renderForResult(template, context)); + return withClassLoaderFix(() -> jinjava.renderForResult(template, templateContext)); } } - /** Build a fresh context per template; templates write visible/title/order into it. */ private Map createContext(final OnmsNode node) { - final Map context = new HashMap<>(); + final Map context = sharedContext(); context.put("node", node); final OnmsResource resource = resourceDao.getResourceForNode(node); if (resource != null) { context.put("nodeResource", resource); } + return context; + } + + /** Context entries common to node- and edge-scoped renders. */ + private Map sharedContext() { + final Map context = new HashMap<>(); context.put("nodeDao", nodeDaoWrapper); context.put("resourceDao", resourceDaoWrapper); if (measurementsService != null) { diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestService.java index dbed2ad63d0a..0a28f9da6f42 100644 --- a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestService.java +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestService.java @@ -35,6 +35,7 @@ import org.opennms.netmgt.dao.api.ResourceDao; import org.opennms.netmgt.measurements.api.MeasurementsService; import org.opennms.netmgt.model.OnmsNode; +import org.opennms.netmgt.model.OnmsSnmpInterface; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -83,6 +84,50 @@ public List getForNode(@QueryParam("nodeId") final Integer nodeId return renderer().renderForNode(node); } + /** + * Edge-scoped panels for a link between two nodes: templates render with + * an {@code edge} context (see {@link EdgeInfo}). Port names and protocol + * are optional -- they come from a link's discovery binding when present. + */ + @GET + @javax.ws.rs.Path("edge") + @Transactional(readOnly = true) + public List getForEdge(@QueryParam("sourceNodeId") final Integer sourceNodeId, + @QueryParam("targetNodeId") final Integer targetNodeId, + @QueryParam("sourcePort") final String sourcePort, + @QueryParam("targetPort") final String targetPort, + @QueryParam("protocol") final String protocol) { + if (sourceNodeId == null || targetNodeId == null) { + throw webException(Response.Status.BAD_REQUEST, "sourceNodeId and targetNodeId query parameters are required"); + } + final OnmsNode source = m_nodeDao.get(sourceNodeId); + final OnmsNode target = m_nodeDao.get(targetNodeId); + if (source == null || target == null) { + throw webException(Response.Status.NOT_FOUND, + "No node with id " + (source == null ? sourceNodeId : targetNodeId)); + } + final EdgeInfo edge = new EdgeInfo(protocol, + new EdgeInfo.Port(source, sourcePort, resolveSnmpInterface(source, sourcePort)), + new EdgeInfo.Port(target, targetPort, resolveSnmpInterface(target, targetPort))); + return renderer().renderForEdge(edge); + } + + /** + * Match a persisted port label against the node's SNMP interfaces by + * ifName, then ifDescr (the two labels discovery writes). Null when the + * port is unknown or nothing matches -- templates handle a null + * {@code snmpInterface}/{@code ifIndex} themselves. + */ + private static OnmsSnmpInterface resolveSnmpInterface(final OnmsNode node, final String port) { + if (port == null || port.isBlank()) { + return null; + } + return node.getSnmpInterfaces().stream() + .filter(s -> port.equals(s.getIfName()) || port.equals(s.getIfDescr())) + .findFirst() + .orElse(null); + } + private InfoPanelRenderer renderer() { if (m_renderer == null) { synchronized (this) { diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/InfoPanelRendererTest.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/InfoPanelRendererTest.java index a28601f1d67d..ed4b298fafcf 100644 --- a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/InfoPanelRendererTest.java +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/InfoPanelRendererTest.java @@ -43,6 +43,7 @@ import org.opennms.netmgt.dao.api.NodeDao; import org.opennms.netmgt.dao.api.ResourceDao; import org.opennms.netmgt.model.OnmsNode; +import org.opennms.netmgt.model.OnmsSnmpInterface; /** * Unit-tests the Jinjava info-panel rendering against a temporary template @@ -109,4 +110,36 @@ public void missingTemplateDirYieldsNoItems() { final InfoPanelRenderer r = new InfoPanelRenderer(nodeDao, resourceDao, null, absent); assertThat(r.renderForNode(node), is(empty())); } + + @Test + public void rendersEdgeScopedTemplatesWithEdgeContext() throws Exception { + writeTemplate("edge-panel.html", + "{% if edge %}{% set visible = true %}{% set title = \"Link\" %}" + + "{{ edge.discoveredBy }}: {{ edge.sourcePort.node.label }}/{{ edge.sourcePort.ifName }}" + + " -> {{ edge.targetPort.node.label }}/{{ edge.targetPort.ifIndex }}{% endif %}"); + writeTemplate("node-panel.html", + "{% if node %}{% set visible = true %}{% set title = \"Node\" %}{{ node.label }}{% endif %}"); + + final OnmsNode target = new OnmsNode(); + target.setId(43); + target.setLabel("far-node"); + final OnmsSnmpInterface snmp = new OnmsSnmpInterface(); + snmp.setIfIndex(7); + final EdgeInfo edge = new EdgeInfo("lldp", + new EdgeInfo.Port(node, "eth0", null), + new EdgeInfo.Port(target, "eth7", snmp)); + + final List items = renderer().renderForEdge(edge); + // the node-scoped template guards on `node` and must not render + assertThat(items, hasSize(1)); + assertThat(items.get(0).getTitle(), is("Link")); + assertThat(items.get(0).getHtml(), is("lldp: test-node/eth0 -> far-node/7")); + } + + @Test + public void nodeScopedRenderDoesNotExposeEdge() throws Exception { + writeTemplate("edge-panel.html", + "{% if edge %}{% set visible = true %}{% set title = \"Link\" %}x{% endif %}"); + assertThat(renderer().renderForNode(node), is(empty())); + } } From 225241f84bd1be4a2e53da905f1c2a3661007ca7 Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Thu, 11 Jun 2026 14:34:58 -0400 Subject: [PATCH 5/8] NMS-19876: address review feedback - computeUtilization no longer builds JEXL expressions (the legacy expressions this was ported from reference misspelled variables and lean on engine quirks): the in/out rates are queried as plain columns and the percentages computed in Java from the interface's ifHighSpeed constant in the query response. Missing speed or columns degrade to NaN, matching the method's existing contract. - The renderer skips a template on any per-template failure (IOException or RuntimeException) instead of letting e.g. a ClassCastException from a non-numeric 'order' fail the whole request; the order value itself is also extracted defensively. - IT now exercises the edge endpoint: parameter validation (400), unknown node (404), and a rendered (empty) result for a real node. --- .../rest/v2/infopanel/InfoPanelRenderer.java | 9 ++- .../v2/infopanel/MeasurementsWrapper.java | 68 +++++++++++++++---- .../TopologyInfopanelRestServiceIT.java | 24 +++++++ 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java index 77140c0b211b..9c65501d4ccc 100644 --- a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java @@ -133,11 +133,14 @@ private List renderAll(final Map context) { } if (Boolean.TRUE.equals(result.getContext().getOrDefault("visible", false))) { final String title = String.valueOf(result.getContext().getOrDefault("title", "No title defined")); - final int order = ((Number) result.getContext().getOrDefault("order", 0L)).intValue(); + // Templates control this value; tolerate non-numeric junk. + final Object rawOrder = result.getContext().getOrDefault("order", 0L); + final int order = rawOrder instanceof Number ? ((Number) rawOrder).intValue() : 0; items.add(new InfoPanelItem(title, order, result.getOutput())); } - } catch (final IOException e) { - LOG.warn("Failed to read info-panel template {}: {}", path, e.getMessage()); + } catch (final IOException | RuntimeException e) { + // A broken template skips itself, never the whole request. + LOG.warn("Failed to render info-panel template {}: {}", path, e.getMessage()); } } } catch (final IOException e) { diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/MeasurementsWrapper.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/MeasurementsWrapper.java index afc89dc8ff61..aadcf023bdb6 100644 --- a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/MeasurementsWrapper.java +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/MeasurementsWrapper.java @@ -27,7 +27,6 @@ import org.opennms.netmgt.measurements.api.MeasurementsService; import org.opennms.netmgt.measurements.api.exceptions.MeasurementException; -import org.opennms.netmgt.measurements.model.Expression; import org.opennms.netmgt.measurements.model.QueryRequest; import org.opennms.netmgt.measurements.model.QueryResponse; import org.opennms.netmgt.measurements.model.Source; @@ -105,7 +104,13 @@ public List computeUtilization(final OnmsNode node, final String ifName) return Arrays.asList(Double.NaN, Double.NaN); } - /** Compute the in/out percentage utilization of an interface resource over a window. */ + /** + * Compute the in/out percentage utilization of an interface resource over a + * window. The rates are queried as plain columns and the percentages are + * computed here from the interface's {@code ifHighSpeed} constant (exposed + * in the query response, prefixed by the source label) -- the legacy JEXL + * expressions referenced misspelled variables and relied on engine quirks. + */ public List computeUtilization(final String resource, final long start, final long end, final long step, final String aggregation) throws MeasurementException { final QueryRequest request = new QueryRequest(); request.setRelaxed(true); @@ -115,7 +120,7 @@ public List computeUtilization(final String resource, final long start, final Source sourceIn = new Source(); sourceIn.setAggregation(aggregation); - sourceIn.setTransient(true); + sourceIn.setTransient(false); sourceIn.setAttribute("ifHCInOctets"); sourceIn.setFallbackAttribute("ifInOctets"); sourceIn.setResourceId(resource); @@ -123,29 +128,68 @@ public List computeUtilization(final String resource, final long start, final Source sourceOut = new Source(); sourceOut.setAggregation(aggregation); - sourceOut.setTransient(true); + sourceOut.setTransient(false); sourceOut.setAttribute("ifHCOutOctets"); sourceOut.setFallbackAttribute("ifOutOctets"); sourceOut.setResourceId(resource); sourceOut.setLabel("ifOutOctets"); - request.setExpressions(Arrays.asList( - new Expression("ifInPercent", "(8 * ifInOctects / 1000000) / ifInOctets.ifHighSpeed * 100", false), - new Expression("ifOutPercent", "(8 * ifOutOctects / 1000000) / ifOutOctets.ifHighSpeed * 100", false))); request.setSources(Arrays.asList(sourceIn, sourceOut)); - final QueryResponse.WrappedPrimitive[] columns = measurementsService.query(request).getColumns(); - final double[] valuesIn = columns[0].getList(); - final double[] valuesOut = columns[1].getList(); + final QueryResponse response = measurementsService.query(request); + final double speedMbps = constantAsDouble(response, "ifInOctets.ifHighSpeed", "ifOutOctets.ifHighSpeed"); + if (Double.isNaN(speedMbps) || speedMbps <= 0) { + return Arrays.asList(Double.NaN, Double.NaN); + } + + final int inIndex = indexOfLabel(response, "ifInOctets"); + final int outIndex = indexOfLabel(response, "ifOutOctets"); + if (inIndex < 0 || outIndex < 0) { + return Arrays.asList(Double.NaN, Double.NaN); + } + final double[] valuesIn = response.getColumns()[inIndex].getList(); + final double[] valuesOut = response.getColumns()[outIndex].getList(); - for (int i = valuesIn.length - 1; i >= 0; i--) { + for (int i = Math.min(valuesIn.length, valuesOut.length) - 1; i >= 0; i--) { if (!Double.isNaN(valuesIn[i]) && !Double.isNaN(valuesOut[i])) { - return Arrays.asList(valuesIn[i], valuesOut[i]); + // octets/s -> Mbit/s, as a percentage of the interface speed (Mbit/s) + return Arrays.asList( + (8 * valuesIn[i] / 1_000_000d) / speedMbps * 100d, + (8 * valuesOut[i] / 1_000_000d) / speedMbps * 100d); } } return Arrays.asList(Double.NaN, Double.NaN); } + /** First parseable value among the named response constants, else NaN. */ + private static double constantAsDouble(final QueryResponse response, final String... keys) { + if (response.getConstants() == null) { + return Double.NaN; + } + for (final String key : keys) { + for (final QueryResponse.QueryConstant constant : response.getConstants()) { + if (key.equals(constant.getKey()) && constant.getValue() != null) { + try { + return Double.parseDouble(constant.getValue()); + } catch (final NumberFormatException ignored) { + // fall through to the next candidate + } + } + } + } + return Double.NaN; + } + + private static int indexOfLabel(final QueryResponse response, final String label) { + final String[] labels = response.getLabels(); + for (int i = 0; labels != null && i < labels.length; i++) { + if (label.equals(labels[i])) { + return i; + } + } + return -1; + } + /** Direct pass-through to the measurements query API. */ public QueryResponse query(final QueryRequest request) throws MeasurementException { return measurementsService.query(request); diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestServiceIT.java index cfc454a933b4..e08b725303c2 100644 --- a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestServiceIT.java +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestServiceIT.java @@ -91,4 +91,28 @@ public void infopanelEndpoint() throws Exception { final String body = sendRequest(GET, "/topology/infopanel", parseParamData("nodeId=1"), 200); assertEquals(0, new JSONArray(body).length()); } + + @Test + @JUnitTemporaryDatabase + public void edgeEndpointValidatesAndRenders() throws Exception { + // Both node ids are required. + sendRequest(GET, "/topology/infopanel/edge", 400); + sendRequest(GET, "/topology/infopanel/edge", parseParamData("sourceNodeId=1"), 400); + + // Unknown nodes are a 404. + sendRequest(GET, "/topology/infopanel/edge", parseParamData("sourceNodeId=1&targetNodeId=999999"), 404); + + // Happy path: create a node, then the endpoint renders for a link with + // that node on both ends. With no etc/infopanel templates in the test + // environment the rendered array is empty. + final String node = "" + + "H" + + "TestMachine1" + + ".1.3.6.1.4.1.8072.3.2.255" + + ""; + sendPost("/nodes", node, 201); + final String body = sendRequest(GET, "/topology/infopanel/edge", + parseParamData("sourceNodeId=1&targetNodeId=1&sourcePort=eth0&targetPort=eth1&protocol=lldp"), 200); + assertEquals(0, new JSONArray(body).length()); + } } From e8d49424da26a7a852e41f2f89f8df2cca39a874 Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Thu, 11 Jun 2026 17:47:48 -0400 Subject: [PATCH 6/8] NMS-19876: declare licenses for jinjava's transitive dependencies The tarball assembly's license-maven-plugin add-third-party check requires every aggregated dependency to declare a license. Four of jinjava's transitives (com.hubspot:algebra and the three com.hubspot.immutables support jars) ship no license metadata in their poms; all are HubSpot OSS under the Apache License 2.0, same as jinjava itself. Map them in THIRD-PARTY.properties. --- opennms-assemblies/xsds/src/license/THIRD-PARTY.properties | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/opennms-assemblies/xsds/src/license/THIRD-PARTY.properties b/opennms-assemblies/xsds/src/license/THIRD-PARTY.properties index 9ef7bdfabe85..f56888e939ff 100644 --- a/opennms-assemblies/xsds/src/license/THIRD-PARTY.properties +++ b/opennms-assemblies/xsds/src/license/THIRD-PARTY.properties @@ -1,4 +1,8 @@ avalon-framework--avalon-framework--4.1.5=apache_v1_1 +com.hubspot--algebra--1.5=apache_v2 +com.hubspot.immutables--hubspot-style--1.4=apache_v2 +com.hubspot.immutables--immutable-collection-encodings--1.4=apache_v2 +com.hubspot.immutables--immutables-exceptions--1.9=apache_v2 javax.transaction--jta--1.1=cddl_v1_1 xalan--serializer--2.7.3=apache_v2 xalan--xalan--2.7.3=apache_v2 From 31e64107bfb25a44b44a7a417be49551b10185fc Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Fri, 12 Jun 2026 07:55:31 -0400 Subject: [PATCH 7/8] NMS-19876: declare jinjava transitive licenses in the full assembly too Same four HubSpot Apache-2.0 mappings as the xsds assembly: the license check walks each assembly module in turn, and the full assembly aggregates the webapp closure as well. --- opennms-full-assembly/src/license/THIRD-PARTY.properties | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/opennms-full-assembly/src/license/THIRD-PARTY.properties b/opennms-full-assembly/src/license/THIRD-PARTY.properties index f8c847053b3d..10758a32d478 100644 --- a/opennms-full-assembly/src/license/THIRD-PARTY.properties +++ b/opennms-full-assembly/src/license/THIRD-PARTY.properties @@ -2,6 +2,10 @@ alt.dev.jmta--jmta--1.0=Raphael Szwarc Public Source License, Version 1.0 avalon-framework--avalon-framework--4.1.5=apache_v1_1 bsh--bsh--1.3.0=spl_v1_0 colt--colt--1.2.0=mit +com.hubspot--algebra--1.5=apache_v2 +com.hubspot.immutables--hubspot-style--1.4=apache_v2 +com.hubspot.immutables--immutable-collection-encodings--1.4=apache_v2 +com.hubspot.immutables--immutables-exceptions--1.9=apache_v2 commons-discovery--commons-discovery--0.2=apache_v2 findbugs--annotations--1.0.0=lgpl_v2_1 jakarta-regexp--jakarta-regexp--1.4=apache_v2 From fc56f229de7ea23ca4b9f0af80c684375551e589 Mon Sep 17 00:00:00 2001 From: Marshall Massengill Date: Fri, 12 Jun 2026 12:09:32 -0400 Subject: [PATCH 8/8] NMS-19876: don't bundle platform-provided jars into WEB-INF/lib jinjava transitively pulls the jackson stack, javassist, and commons-net into the webapp. All of these already ship (same versions) in $OPENNMS_HOME/lib on the webapp's parent classloader, and the duplicate copies broke webapp startup in the smoke environment with LinkageError: loader constraint violation ... ObjectMapper while initializing the cxf-rest-v2 bean, taking the whole container down. Exclude them from the jinjava dependency so only jinjava's genuinely new closure (re2j, java-ipv6, big-math, the HubSpot jars) lands in WEB-INF/lib. --- opennms-webapp-rest/pom.xml | 25 +++++++++++++++++++++++++ opennms-webapp/pom.xml | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/opennms-webapp-rest/pom.xml b/opennms-webapp-rest/pom.xml index fc04d52e45d5..ee83143092b6 100644 --- a/opennms-webapp-rest/pom.xml +++ b/opennms-webapp-rest/pom.xml @@ -216,6 +216,31 @@ com.hubspot.jinjava jinjava 2.8.3 + + + + com.fasterxml.jackson.core + * + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + + + org.javassist + javassist + + + commons-net + commons-net + + org.slf4j diff --git a/opennms-webapp/pom.xml b/opennms-webapp/pom.xml index cc591fa91bf7..26b3c0410393 100644 --- a/opennms-webapp/pom.xml +++ b/opennms-webapp/pom.xml @@ -845,6 +845,31 @@ com.hubspot.jinjava jinjava 2.8.3 + + + + com.fasterxml.jackson.core + * + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + + + org.javassist + javassist + + + commons-net + commons-net + + org.opennms.extremecomponents