org.slf4j
slf4j-api
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/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..9c65501d4ccc
--- /dev/null
+++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/InfoPanelRenderer.java
@@ -0,0 +1,198 @@
+/*
+ * 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.
+ *
+ *
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 {
+
+ 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);
+ 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();
+ }
+ final List items = new ArrayList<>();
+ try (final DirectoryStream stream = Files.newDirectoryStream(templateDir, "*.html")) {
+ for (final Path path : stream) {
+ try {
+ final RenderResult result = render(path, context);
+ 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"));
+ // 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 | 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) {
+ 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 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, templateContext));
+ }
+ }
+
+ private Map createContext(final OnmsNode node) {
+ 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) {
+ 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..aadcf023bdb6
--- /dev/null
+++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/MeasurementsWrapper.java
@@ -0,0 +1,215 @@
+/*
+ * 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.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. 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);
+ request.setStart(start);
+ request.setEnd(end);
+ request.setStep(step);
+
+ final Source sourceIn = new Source();
+ sourceIn.setAggregation(aggregation);
+ sourceIn.setTransient(false);
+ sourceIn.setAttribute("ifHCInOctets");
+ sourceIn.setFallbackAttribute("ifInOctets");
+ sourceIn.setResourceId(resource);
+ sourceIn.setLabel("ifInOctets");
+
+ final Source sourceOut = new Source();
+ sourceOut.setAggregation(aggregation);
+ sourceOut.setTransient(false);
+ sourceOut.setAttribute("ifHCOutOctets");
+ sourceOut.setFallbackAttribute("ifOutOctets");
+ sourceOut.setResourceId(resource);
+ sourceOut.setLabel("ifOutOctets");
+
+ request.setSources(Arrays.asList(sourceIn, sourceOut));
+
+ 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 = Math.min(valuesIn.length, valuesOut.length) - 1; i >= 0; i--) {
+ if (!Double.isNaN(valuesIn[i]) && !Double.isNaN(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);
+ }
+
+ 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..0a28f9da6f42
--- /dev/null
+++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestService.java
@@ -0,0 +1,147 @@
+/*
+ * 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.opennms.netmgt.model.OnmsSnmpInterface;
+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);
+ }
+
+ /**
+ * 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) {
+ 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..ed4b298fafcf
--- /dev/null
+++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/InfoPanelRendererTest.java
@@ -0,0 +1,145 @@
+/*
+ * 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;
+import org.opennms.netmgt.model.OnmsSnmpInterface;
+
+/**
+ * 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()));
+ }
+
+ @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()));
+ }
+}
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..e08b725303c2
--- /dev/null
+++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/infopanel/TopologyInfopanelRestServiceIT.java
@@ -0,0 +1,118 @@
+/*
+ * 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());
+ }
+
+ @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());
+ }
+}
diff --git a/opennms-webapp/pom.xml b/opennms-webapp/pom.xml
index 575b648b63b0..26b3c0410393 100644
--- a/opennms-webapp/pom.xml
+++ b/opennms-webapp/pom.xml
@@ -836,6 +836,41 @@
org.apache.servicemix.bundles.jdom
1.1_4
+
+
+