From 7f4f435721725f3880fa0acc57d1aafc29e14136 Mon Sep 17 00:00:00 2001 From: Atlassian Bamboo Date: Wed, 11 Oct 2023 16:34:28 +0000 Subject: [PATCH 01/35] typo in impl --- pom.xml.orig | 5378 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 5378 insertions(+) create mode 100644 pom.xml.orig diff --git a/pom.xml.orig b/pom.xml.orig new file mode 100644 index 000000000000..74b863ca6af2 --- /dev/null +++ b/pom.xml.orig @@ -0,0 +1,5378 @@ + + + 4.0.0 + org.opennms + opennms + 2023.1.8 + pom + OpenNMS + http://opennms.org/ + + OpenNMS is the world's first enterprise grade network management platform developed under the open source model. It consists of a community supported open-source project as well as a commercial services, training and support organization. + + + + GNU Affero General Public License + http://www.gnu.org/licenses/agpl.html + + This Maven metadata is provided for informational purposes only. For specific licensing + questions, please contact: OpenNMS(R) Licensing (license@opennms.org) + + + + + Jira + http://issues.opennms.org/ + + + CircleCI + https://app.circleci.com/pipelines/github/OpenNMS/opennms + + + + soleger + Seth Leger + seth@opennms.org + + OGP Member + Software Architect + + The OpenNMS Group + -5 + + + brozow + Matt Brozowski + brozow@opennms.org + + OGP Member + Software Architect + + The OpenNMS Group + -5 + + + rangerrick + Benjamin Reed + ranger@opennms.org + + OGP Member + Software Developer + + The OpenNMS Group + -5 + + + + scm:git:https://github.com/OpenNMS/opennms.git + scm:git:https://${user.name}@github.com/OpenNMS/opennms.git + https://github.com/OpenNMS/opennms + + + + dependencies + core + container + features + integrations + protocols + tests + integration-tests + ui + + + opennms-util + opennms-bootstrap + opennms-model + opennms-ackd + opennms-alarms + opennms-config-tester + opennms-dao-api + opennms-dao + opennms-dao-mock + opennms-icmp + opennms-javamail + opennms-rrd + opennms-config-api + opennms-config-jaxb + opennms-config + opennms-config-model + opennms-provision + opennms-enterprise-reporting + opennms-services + opennms-taglib + opennms-jetty + opennms-correlation + opennms-reporting + opennms-web-api + opennms-web-dependencies + opennms-webapp + opennms-webapp-rest + opennms-wmi + opennms-asterisk + opennms-config-dao + + + + + org.apache.maven.wagon + wagon-ssh-external + 3.5.3 + + + + + + org.jacoco + jacoco-maven-plugin + ${jacocoVersion} + + + org.codehaus.mojo + license-maven-plugin + 2.0.0.ONMS.1 + true + + + module-third-party + + add-third-party + + generate-resources + + + + true + true + xmlOutput + system,test + true + true + true + true + true + true + true + true + false + + + + agpl_v3 + | AGPL-3.0 + | GNU Affero General Public License + + + + apache + | Apache + | Apache License + | Apache Software Licenses + | Similar to Apache License but with the acknowledgment clause removed + + + apache_v1_1 + | Apache-1.1 + | Apache Software License, version 1.1 + + + + apache_v2 + | ALv2 + | Apache 2 + | Apache-2.0 + | Apache 2.0 + | Apache 2.0 License + | Apache License 2.0 + | Apache License, Version 2.0 + | Apache License, version 2.0 + | Apache License V2.0 + | Apache License v2.0 + | Apache License Version 2.0 + | Apache License version 2.0 + | Apache Software License 2.0 + | Apache Software License - Version 2.0 + | Apache Software License, version 2.0 + | ASF 2.0 + | ASL, version 2 + | Atomikos Multiple Licensing Scheme + | GWT Terms + | The Apache License, Version 2.0 + | The Apache Software License, Version 2.0 + | This material is licensed under the Apache Software License, Version 2.0 + + + bsd + | BSD + | BSD License + | BSD licence + | BSD license + | BSD style + | ISC/BSD License + | The BSD License + + + bsd_2 + | BSD-2-Clause + | BSD 2-Clause + | BSD 2-Clause License + | The BSD 2-Clause License + + + + bsd_3 + | 3-Clause BSD License + | BSD 3-Clause + | BSD-3-Clause + | DOM4J + | DOM4J License (BSD-like) + | Eclipse Distribution License - v 1.0 + | Eclipse Distribution License v. 1.0 + | EDL 1.0 + | license.txt + | New BSD License + | Revised BSD + | The BSD 3-Clause License + | The New BSD License + | The OpenLDAP Public License + + + cddl_v1 + | CDDL + | CDDL 1.0 + | CDDL license + | CDDL-1.0 + | COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0 + | Common Development And Distribution License (CDDL) Version 1.0 + + + cddl_v1_1 + | CDDL 1.1 + | CDDL-1.1 + | COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.1 + | Common Development And Distribution License (CDDL) Version 1.1 + + + cddl_v1 or gpl_v2_cpe + | CDDL+GPL License + | CDDL/GPLv2+CE + | CDDL-1.0 + GPL-2.0+CPE + | CDDL + GPLv2 with classpath exception + + + cpl_v1 + | Common Public License - v 1.0 + | CPL + | CPL-1.0 + + + epl_v1 + | EPL 1.0 + | EPL-1.0 + | Eclipse Public License + | Eclipse Public License - Version 1.0 + | Eclipse Public License - v 1.0 + | Eclipse Public License, Version 1.0 + | Eclipse Public License 1.0 + | Eclipse Public License v1.0 + + + epl_v2 + | EPL 2.0 + | EPL-2.0 + | Eclipse Public License - v 2.0 + + + gpl_v2 + | GPL-2 + | GNU GENERAL PUBLIC LICENSE Version 2, June 1991 + | GNU General Public Library + | GNU General Public License Version 2 + | GNU General Public License, Version 2 + | GNU General Public License, version 2 + + + gpl_v2_cpe + | GNU General Public License (GPL), version 2, with the Classpath exception + | GPL-2.0+CPE + | GPL2 w/ CPE + | GPLv2 with classpath exception + | GPLv2+CE + + + gpl_v3 + | GPL-3.0 + | GNU Public License, Version 3.0 + + + indiana_extreme_v1_1_1 + | Indiana Extreme 1.1.1 + | Indiana University Extreme! Lab Software License, vesion 1.1.1 + + + lgpl_v2_1 + | GNU LESSER GENERAL PUBLIC LICENSE + | GNU Lesser General Public License + | GNU Lesser General Public Licence + | GNU Lesser General Public License (LGPL), Version 2.1 + | GNU Lesser General Public License Version 2.1 + | GNU Lesser General Public License, Version 2.1 + | GNU Lesser General Public License, version 2.1 + | GNU Lesser General Public License, Version 2 + | GNU Lesser Public License + | GNU Library General Public License v2.1 or later + | LGPL + | LGPL 2.1 + | LGPL license + | LGPL, version 2.1 + | LGPL-2 + | LGPL-2.1 + + + lgpl_v3 + | GNU Lesser General Public License, Version 3 + | GNU Lesser/Library Public License, Version 3.0 + | GNU Lessor/Library Public License, Version 3.0 + | GNU LGPLv3 + | LGPL-3.0 + | LGPL-3.0-or-later + | Lesser General Public License, version 3 or greater + + + mit + | Bouncy Castle Licence + | ICU License + | MIT + | MIT License + | MIT license + | The JSON License + | The MIT License + | provided without support or warranty + + + mpl + | Mozilla Public License + | MPL + + + mpl_1_1 + | MPL 1.1 + | MPL-1.1 + | Mozilla Public License 1.1 + | Mozilla Public License version 1.1 + + + mpl_2_0 + | Mozilla Public License 2.0 + | Mozilla Public License version 2.0 + + + mpl_2_0 or epl_1_0 + | MPL 2.0 or EPL 1.0 + + + public_domain + | Public Domain + | Public Domain, per Creative Commons CC0 + | Public domain, Sun Microsoystems + | The SAX License + | WTFPL + + + spl_v1_0 + | SPL-1.0 + + + sun_entitlement + | Sun Entitlement + | Sun Microsystems, Inc. ("Sun") ENTITLEMENT for SOFTWARE + + + w3c + | The W3C License + | The W3C Software License + | W3C Software Copyright Notice and License + | W3C Software License + + + + + + + + + org.apache.maven + maven-artifact + ${mavenApiVersion} + + + org.apache.maven + maven-core + ${mavenApiVersion} + + + org.apache.maven + maven-model + ${mavenApiVersion} + + + org.apache.maven + maven-plugin-api + ${mavenApiVersion} + + + org.apache.maven.resolver + maven-resolver-api + ${mavenResolverApiVersion} + + + + + org.codehaus.mojo + keytool-maven-plugin + 1.7 + + + + generateKeyPair + + generate-test-resources + + + + ${project.build.directory} + true + ${test.keystore} + ${test.storepass} + ${test.keyalias} + ${test.keypass} + cn=${test.dnameCn} + 2 + + + + org.sonarsource.scanner.maven + sonar-maven-plugin + ${sonarVersion} + + + org.eclipse.jetty + apache-jsp + ${jettyVersion} + + + org.mortbay.jasper + apache-el + + + org.mortbay.jasper + apache-jsp + + + + + javax.el + javax.el-api + 3.0.0 + + + + + + org.apache.maven.plugins + maven-install-plugin + ${maven.install.plugin.version} + + + org.apache.maven.plugins + maven-site-plugin + 3.12.1 + + + net.nicoulaj.maven.plugins + checksum-maven-plugin + 1.11 + + + org.apache.servicemix.tooling + depends-maven-plugin + 1.2 + + + org.apache.felix + maven-bundle-plugin + ${maven.bundle.plugin.version} + true + + NONE + + + <_removeheaders>Import-Service + + + + + org.codehaus.mojo + build-helper-maven-plugin + 1.9.1 + + + org.dstovall + onejar-maven-plugin + 1.4.4 + + + + 0.96 + true + + package + + one-jar + + + + + + org.codehaus.mojo + gwt-maven-plugin + ${gwtPluginVersion} + + + com.google.gwt + gwt-user + ${gwtVersion} + + + com.google.gwt + gwt-dev + ${gwtVersion} + + + + + + compile + + + + test + test + + test + + + + + + org.codehaus.mojo + buildnumber-maven-plugin + 1.2 + + false + true + 0 + + + + validate + create + + + + + org.apache.maven.plugins + maven-jxr-plugin + 3.3.0 + + + org.apache.maven.plugins + maven-deploy-plugin + 3.1.1 + + true + 2 + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.6.0 + + + org.apache.maven.plugins + maven-javadoc-plugin + ${maven.javadoc.plugin.version} + + 3g + true + ${project.basedir}/src/main;${project.build.directory}/generated-sources;${project.build.directory}/generated-sources/annotations;${project.build.directory}/generated-sources/cxf;${project.build.directory}/generated-sources/groovy-stubs + + + none + + -J-Djdk.util.zip.disableZip64ExtraFieldValidation=true + + + + + + + + hibernate.class + a + Hibernate class: + + + hibernate.column + a + Hibernate column: + + + hibernate.component + a + Hibernate component: + + + hibernate.composite-element + a + Hibernate composite-element: + + + hibernate.id + a + Hibernate id: + + + hibernate.key + a + Hibernate key: + + + hibernate.list + a + Hibernate list: + + + hibernate.list-index + a + Hibernate list-index: + + + hibernate.many-to-many + a + Hibernate many-to-many: + + + hibernate.many-to-one + a + Hibernate many-to-one: + + + hibernate.mapping + a + Hibernate mapping: + + + hibernate.one-to-many + a + Hibernate one-to-many: + + + hibernate.property + a + Hibernate property: + + + hibernate.set + a + Hibernate set: + + + hibernate.version + a + Hibernate version: + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + org.apache.maven.plugins + maven-project-info-reports-plugin + 3.4.5 + + + org.apache.maven.plugins + maven-assembly-plugin + 3.6.0 + + 0 + 0 + posix + + + + org.apache.maven.plugins + maven-clean-plugin + 3.2.0 + + + org.apache.maven.plugins + maven-eclipse-plugin + ${maven.eclipse.plugin.version} + + + org.ops4j + maven-pax-plugin + ${maven.pax.plugin.version} + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.testing.plugin.version} + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven.testing.plugin.version} + + + org.apache.maven.plugins + maven-war-plugin + 3.4.0 + + true + @{artifactId}@-@{baseVersion}@.@{extension}@ + + + true + custom + WEB-INF/lib/$${artifact.groupIdPath}/$${artifact.artifactId}-$${artifact.baseVersion}$${dashClassifier?}.$${artifact.extension} + + + + + + org.eclipse.jetty + jetty-maven-plugin + ${jettyVersion} + + + org.eclipse.jetty + jetty-jspc-maven-plugin + ${jettyVersion} + + + + org.eclipse.m2e + lifecycle-mapping + 1.0.0 + + + + + + org.codehaus.gmaven + gmaven-plugin + [1.0,) + + execute + + + + + + + + + org.codehaus.gmaven + gmaven-plugin + [1.0,) + + compile + testCompile + + + + + + + + + org.codehaus.mojo + axistools-maven-plugin + [1.4,) + + wsdl2java + + + + + + + + + org.commonjava.maven.plugins + directory-maven-plugin + [1.0,) + + directory-of + + + + + + + + + com.sun.tools.jxc.maven2 + maven-jaxb-schemagen-plugin + [1.2,2.0) + + generate + + + + + + + + + org.eclipse.jetty + jetty-jspc-maven-plugin + [1.0,) + + jspc + + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + [1.0,) + + add-source + + + + + true + true + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + [1.0,) + + check + + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + [1.0.0,) + + copy + copy-dependencies + unpack + + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + [1.0.0,) + + properties + + + + + + + + + org.apache.servicemix.tooling + depends-maven-plugin + [1.2,) + + generate-depends-file + + + + + + + + + org.codehaus.mojo + gwt-maven-plugin + [2.5.1,) + + resources + + + + + + + + + com.vaadin + vaadin-maven-plugin + [1.0.2,) + + resources + update-widgetset + + + + + + + + + org.codehaus.mojo + exec-maven-plugin + [1.0,) + + java + + + + + + + + + org.apache.karaf.tooling + karaf-maven-plugin + [4.0,) + + assembly + + + + + + + + + + + + org.jvnet.jaxb2.maven2 + maven-jaxb2-plugin + 0.15.3 + + + de.qaware.maven + go-offline-maven-plugin + 1.2.8 + + + + org.apache.maven.surefire + surefire-junit3 + ${maven.testing.plugin.version} + PLUGIN + + + org.apache.maven.surefire + surefire-junit4 + ${maven.testing.plugin.version} + PLUGIN + + + org.apache.maven.plugins + maven-antrun-plugin + ${maven.antrun.plugin.version} + PLUGIN + + + org.apache.maven.plugins + maven-assembly-plugin + 3.0.0 + PLUGIN + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + PLUGIN + + + org.apache.maven.plugins + maven-failsafe-plugin + ${maven.testing.plugin.version} + PLUGIN + + + org.apache.maven.plugins + maven-install-plugin + ${maven.install.plugin.version} + PLUGIN + + + org.apache.maven.plugins + maven-resources-plugin + ${maven.resources.plugin.version} + PLUGIN + + + org.opennms.maven.plugins + structure-maven-plugin + 1.0 + PLUGIN + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + 3.3.0 + + + org.opennms.maven.plugins + structure-maven-plugin + 1.0 + + + + + + org.commonjava.maven.plugins + directory-maven-plugin + 1.0 + + + directories + directory-of + initialize + + opennms.build.basedir + + org.opennms + opennms + + + + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + enforce-maven + + enforce + + + ${enforcer-skip-maven} + + + + [3.5,) + + + [11,12) + + + + + + enforce-banned-dependencies + + enforce + + + ${enforcer-skip-banned-dependencies} + + + + + + com.sun.xml.bind:* + com.sun.xml.bind.mvn:* + org.glassfish.jaxb:jaxb-core + + xml-apis:xml-apis + + asm:* + org.ow2.asm:asm-all + bouncycastle:* + bsh:* + c3p0:c3p0 + commons-logging:* + com.github.detro:* + com.github.detro.ghostdriver:* + dom4j:dom4j + javax.servlet:jstl + javax.servlet.jsp:jsp-api + javax.ws.rs:jsr311-api + javax.xml.bind:jaxb-api + jdom:jdom + log4j:log4j + org.apache.velocity:velocity + org.springframework:* + org.springframework.security:* + org.springframework.security.kerberos:* + + + + + + + enforce-dependency-convergence + + enforce + + + ${enforcer-skip-dependency-convergence} + + + + + true + + + + + + org.apache.maven.plugins + maven-clean-plugin + false + + + remove-source-tarball + process-resources + clean + + true + + + target + **/* + ${project.name}-${project.version}-source.* + + + + + + clean + clean + clean + + + + + org.apache.maven.plugins + maven-assembly-plugin + false + + + process-resources + single + + ${install.package.name}-${project.version} + + src/assembly/source.xml + + source + ${build.skip.tarball} + 0 + 0 + posix + + + + + + org.apache.maven.plugins + maven-eclipse-plugin + + + + .settings/org.eclipse.jdt.core.prefs + https://opennms.svn.sourceforge.net/svnroot/opennms/opennms/trunk/src/main/eclipse/org.eclipse.jdt.core.prefs + + + + + + org.ops4j + maven-pax-plugin + + + org.apache.maven.plugins + maven-resources-plugin + ${maven.resources.plugin.version} + + UTF-8 + \ + + ${*} + + false + + + + org.apache.maven.plugins + maven-compiler-plugin + ${maven.compiler.plugin.version} + + 11 + 11 + UTF-8 + true + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.0 + + + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven.testing.plugin.version} + + + ${skipSurefire} + + + false + true + CLDR,COMPAT + ${mock.debug} + ${mock.rundbtests} + ${mock.leaveDatabase} + ${mock.leaveDatabaseOnFailure} + ${mock.db.driver} + ${mock.db.url} + ${mock.db.adminUser} + ${mock.db.adminPassword} + ${mock.runSnmpTests} + ${mock.snmpHost} + ${opennms.library.jicmp} + ${opennms.library.jrrd} + ${install.rrdtool.bin} + ${runRemedyTests} + ${runOtrsTests} + ${runRtTests} + ${runMailTests} + ${runPingTests} + ${skipIpv6Tests} + ${proxy.port} + ${project.version} + + + 1 + false + ${ci.rerunFailingTestsCount} + false + 120 + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + ${skipFailsafe} + + + CLDR,COMPAT + + + 1 + + ${test.fork.timeout} + false + ${ci.rerunFailingTestsCount} + ${skipITs} + false + + + + + integration-test + verify + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + aggregate + aggregate + site + + false + true + + + + + + org.apache.karaf.tooling + karaf-maven-plugin + ${maven.karaf.plugin.version} + true + + + com.atlassian.maven.plugins + maven-clover2-plugin + ${cloverVersion} + + true + true + true + + + + org.apache.cxf + cxf-xjc-plugin + ${cxfXjcVersion} + + + + + + + + + org.apache.maven.plugins + maven-surefire-report-plugin + ${maven.testing.plugin.version} + + + unit-tests + + report-only + + + + integration-tests + + failsafe-report-only + + + + + + org.apache.maven.plugins + maven-pmd-plugin + 3.21.0 + + 1.8 + + + /rulesets/basic.xml + + /rulesets/controversial.xml + + + xml + true + utf-8 + 100 + + + + + org.apache.maven.plugins + maven-jxr-plugin + 3.3.0 + + + + + + + false + false + + true + + + -Xmx4g -XX:ReservedCodeCacheSize=1g + ${argLineMemory} -Djava.security.egd=file:/dev/./urandom + true + false + + 2 + + + CLDR,COMPAT + + false + + UTF-8 + UTF-8 + + + 3.1.2 + + 2.10 + 3.5.0 + 4.4.3 + 1.6.0 + 3.5.0 + 2.7.7 + 1.1.2-ONMS-20131018-1 + + + 1.8 + 3.11.0 + 3.1.1 + 3.3.1 + 5.1.9 + + + WARN + false + true + false + false + org.postgresql.Driver + jdbc:postgresql://localhost:5432/ + postgres + + false + localhost + ${project.version} + ${install.version} + ${dist.dir}/${dist.name} + 2023.1.8 + ${project.basedir} + ${root.dir}/target + opennms-${project.version} + + + + meridian + OpenNMS Meridian + ${opennms.home} + ${install.dir}/bin + ${install.dir}/webapps + ${install.webapps.dir}/opennms + ${install.dir}/share + ${install.dir}/bin + ${install.dir}/contrib + ${install.dir}/etc + ${install.dir}/logs + ${install.logs.dir}/opennms.pid + + postgresql + opennms + opennms + opennms + postgres + + org.postgresql.Driver + /usr/bin + /usr/bin/rrdtool + + true + + + /opt/opennms + rpmbuild + 1 + false + false + opennms + + opennms + opennms + + ${project.build.directory}/test-keystore.jks + opennms + opennms-keystore-key + opennms + cn=${user.name} + + + false + + interval:480 + ${user.home}/.m2/clover.license + + 2.9.0 + 2.16.3 + + + 2.4.8 + 1.10.13 + 2.33 + 9.6 + 5.15.9 + 3.9.2 + 1.17 + 1.76 + 2.4.0 + 3.5.0 + 2.8.0 + 2.19.1 + 3.11.2.0 + + 3.11.5 + 3.2.0 + 1.9.4 + 1.2 + 1.16.0 + 3.2.2 + 4.4 + 1.23.0 + 1.10 + 1.5 + 2.1 + 1.2 + 2.1.1 + 1.3 + 2.13.0 + 2.6 + 3.13.0 + 3.6.1 + 3.9.0 + 2.4.3 + 1.7 + 2.6.1 + 3.5 + 0.9.5.5 + 5.5.0 + 5.5.0 + 3.5.5 + 3.3.2 + 1.1.0 + 3.5.2_1 + 2.1.4 + 3.1.2 + 4.4.2 + 2.1.0.RELEASE + 2.5.1 + 4.1.69.Final + 7.6.2 + 1.9.22 + 4.0.12 + 3.0.6 + 2.9 + 2.3.32 + 2.57 + + 2.5.1 + 1.4.0 + 2.15.0 + 1.1.1 + 2.5.22 + 1.46.0 + 2.9.1 + 25.1-jre + 25.1.0 + 28.2-jre + 2.9.0 + ${gwtVersion} + 1.4.197 + 4.3.2.Final + 4.1.0.Final + 5.0.1 + 1.6.0 + 4.4.16 + 4.5.14 + 4.1.5 + 5.4.0 + 1.9.14-atlassian-6 + 2.14.2 + 1.33 + 1.3.6 + 0.8.9 + 6.20.0 + 1.0-beta-4-OPENNMS-20160912-1 + 1.9.3 + 3.29.2-GA + + 2.3.3 + + 2.3.3 + + 1.3.1 + 1.4.7 + 3.5.3.Final + 2.1.6 + 1.0.24 + 2.0.6.1 + 1.4.1 + 9.4.51.v20230217 + 9.4.51 + 5.3.4 + ${gsonVersion} + 1.5.4 + 3.0.4 + 3.0.4 + 3.4.0 + 4.3 + 1.37 + 1.0_01-ea + 5.12.1 + 2.12.5 + 1.7.2 + 1.6.5 + 1.1.0 + 2.0.3 + 9.2.6.0 + 0.2.11 + 20230618 + 1.13 + 2.8.0 + 2.4.8 + 1.7.2 + 2.4 + 2.4_1 + 4.13.2 + 5.9.2 + 1.9.2 + 4.3.6 + 3.3.2_1 + 3.3.2 + 1.9.10 + 3.6.3 + 3.4.4 + 2.20.0 + 1.2.12 + 1.5.2.Final + 3.9.4 + 1.9.15 + 2.2.2 + 3.12.4 + 3.10.6.Final + 4.1.95.Final + 1.5.7 + 4.13.5 + 2.0.14 + 1.8.3 + 7.3.23 + 3.0.10 + 3.16.3 + 42.5.4 + 2.0.9 + 4.9.3 + 3.6.0 + 1.5.0 + 1.5.0 + 7.0.0 + 7.0.0 + 7.0.0 + 7.0.0 + 1.2.3 + 20170515.1 + 1.1.0.ONMS + 1.7.1 + 2.3 + 0.32.0 + 1.8.1 + 0.11.5 + 1.8.0 + 2.3.2 + 2.0.0 + 2.0.2 + 0.17.0 + 8.5.4 + 2.13.7 + 3.9.5 + 2.13 + 3.1.0 + 1.7.36 + 4.0.6 + 1.1.10.3 + 2.5.5 + 2.5.3 + 3.9.1.2184 + 2.3-groovy-2.5 + 3.13.0 + 2.1.12 + 2.0.33 + 2.37.0 + 1.0.7 + 2.0.0-beta1 + 0.7 + 3.0.6 + 1.15.3 + 1.2.0.vaadin1 + 2.3 + 2.7.3.ONMS.1 + 2.7.3_0_ONMS_1 + 2.12.2 + 1.3.04 + 2.9 + 1.4.20 + 1.6.3 + 1.2.3 + 3.5.10 + 1.5.2-1 + 2.10.0 + ${karafVersion}.ONMS_1 + + 4.2.9.RELEASE_1 + 4.2.9.RELEASE_1.ONMS.1 + + + 1.9.9.1_1 + 2.3.4.RELEASE + 4.2.21.RELEASE_ONMS_3 + 2.3.8.RELEASE + 1.0.1.RELEASE_ONMS_3 + + + 2.0.1 + 8.14.3 + + ${vaadinVersion} + 3.1.0 + 3.2.0 + 1g + ${maxCpus} + true + + + 1.12.1 + v16.18.1 + 8.19.3 + v1.22.19 + + + 2.3.0 + + 1.7 + + + 0 + + 1800 + + + + + production + + + build.type + production + + + + false + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + + jar + + jar + + + + + + + + + + opennms-ossrh + https://oss.sonatype.org/content/repositories/snapshots + + true + never + + + false + + + + + + coverage + + + coverage + true + + + + + + org.jacoco + jacoco-maven-plugin + ${jacocoVersion} + + + org/drools/**/* + + + + + prepare-agent + + prepare-agent + + + + report + + report + + + + XML + + + + + report-aggregate + + report-aggregate + + false + + + XML + + + + + + + + + + enable.license + + enable.licensetrue + + + + + org.codehaus.mojo + license-maven-plugin + + + + + + enable.snapshots + + + enable.snapshots + true + + + + + + central + Maven Central + https://repo1.maven.org/maven2/ + + true + never + + + true + ${updatePolicy} + + + + opennms-snapshots + OpenNMS Snapshot Maven Mega-Repository + https://maven.opennms.org/repository/everything/ + + false + + + true + ${updatePolicy} + + + + opennms-ossrh + https://oss.sonatype.org/content/repositories/snapshots + + false + + + true + ${updatePolicy} + + + + + + + central + Maven Central + https://repo1.maven.org/maven2/ + + true + never + + + true + ${updatePolicy} + + + + opennms-snapshots + OpenNMS Snapshot Maven Repository + https://maven.opennms.org/content/groups/opennms.org-snapshot/ + + false + + + true + ${updatePolicy} + + + + + + release + + + + org.simplify4u.plugins + sign-maven-plugin + 1.0.1 + + + sign-artifacts + verify + + sign + + + false + 701E145FE26283F8C073BAAE697677243260D071 + + + + + + + + + opt-local-rrdtool + + + /opt/local/bin/rrdtool + + + + /opt/local/bin/rrdtool + + + + fink-rrdtool + + + /sw/bin/rrdtool + + + + /sw/bin/rrdtool + + + + usr-bin-rrdtool + + + /usr/bin/rrdtool + + + + /usr/bin/rrdtool + + + + usr-local-bin-rrdtool + + + /usr/local/bin/rrdtool + + + + /usr/local/bin/rrdtool + + + + usr-local-rrdtool-bin-rrdtool + + + /usr/local/rrdtool/bin/rrdtool + + + + /usr/local/rrdtool/bin/rrdtool + + + + + solaris-blastwave-rrdtool + + + /opt/csw/bin/rrdtool + + + + /opt/csw/bin/rrdtool + + + + assemblies + + opennms-full-assembly + + + + build-bamboo + + opennms-full-assembly + + + + run-expensive-tasks + + false + + + + smoke + + + smoke + + + + true + + + smoke-test + + + + jdk7+ + + [1.7,9) + + + + + com.sun.tools.jxc.maven2 + maven-jaxb-schemagen-plugin + 1.2 + + + com.sun + tools + 1.8 + system + ${java.home}/../lib/tools.jar + + + + + + + + checkstyle + + + !disable.checkstyle + + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.3.0 + + + com.github.sevntu-checkstyle + sevntu-checks + 1.44.1 + + + + ${opennms.build.basedir}/src/main/resources/nms_checks.xml + ${opennms.build.basedir}/src/main/resources/checkstyle-suppressions.xml + + ${project.build.sourceDirectory} + true + true + true + false + true + + + + check-license + + check + + process-resources + + ${opennms.build.basedir}/src/main/resources/check-license.xml + ${opennms.build.basedir}/src/main/resources/license-suppressions.xml + ${opennms.build.basedir}/src/main/resources/AGPL-LICENSE.regexp.txt + false + true + false + ${project.build.sourceDirectory} + false + true + + + + check-style + + check + + process-resources + + + + + + + + + + + + + org.opennms.integration.api + api + ${opennmsApiVersion} + + + org.opennms.integration.api + common + ${opennmsApiVersion} + + + org.opennms.integration.api + config + ${opennmsApiVersion} + + + org.opennms.integration.api + karaf-features + ${opennmsApiVersion} + pom + + + + + + org.opennms + opennms-ackd + ${project.version} + + + org.opennms.core + org.opennms.core.api + ${project.version} + + + org.opennms.core + org.opennms.core.logging + ${project.version} + + + org.opennms.core + org.opennms.core.logging.log4j + ${project.version} + + + org.opennms.core + org.opennms.core.camel + ${project.version} + + + org.opennms.core + org.opennms.core.config + ${project.version} + + + org.opennms.core + org.opennms.core.criteria + ${project.version} + + + org.opennms.core + org.opennms.core.db + ${project.version} + + + org.opennms.core + org.opennms.core.db.install + ${project.version} + + + org.opennms.core + org.opennms.core.lib + ${project.version} + + + org.opennms.core + org.opennms.core.osgid + ${project.version} + + + org.opennms.core.ipc.rpc + org.opennms.core.ipc.rpc.api + ${project.version} + + + org.opennms.core.ipc.rpc + org.opennms.core.ipc.rpc.camel + ${project.version} + + + org.opennms.core.ipc.rpc + org.opennms.core.ipc.rpc.common + ${project.version} + + + org.opennms.core.ipc.rpc + org.opennms.core.ipc.rpc.commands + ${project.version} + + + org.opennms.core.ipc.rpc + org.opennms.core.ipc.rpc.utils + ${project.version} + + + org.opennms.core.ipc.rpc + org.opennms.core.ipc.rpc.xml + ${project.version} + + + org.opennms.core.ipc.sink + org.opennms.core.ipc.sink.api + ${project.version} + + + org.opennms.core.ipc.sink + org.opennms.core.ipc.sink.xml + ${project.version} + + + org.opennms.core.ipc.common + org.opennms.core.ipc.common.kafka-shell + ${project.version} + + + org.opennms.core.mate + org.opennms.core.mate.api + ${project.version} + + + org.opennms.core.mate + org.opennms.core.mate.model + ${project.version} + + + org.opennms.core + org.opennms.core.runtime + ${project.version} + + + org.opennms.core + org.opennms.core.schema + ${project.version} + + + org.opennms.core + org.opennms.core.schema + liquibase + ${project.version} + + + org.opennms.core.snmp + org.opennms.core.snmp.api + ${project.version} + + + org.opennms.core + org.opennms.core.soa + ${project.version} + + + org.opennms.core + org.opennms.core.spring + ${project.version} + + + org.opennms.core + org.opennms.core.spring-web + ${project.version} + + + org.opennms.core + org.opennms.core.tasks + ${project.version} + + + org.opennms.core + org.opennms.core.upgrade + ${project.version} + + + org.opennms.core + org.opennms.core.web + ${project.version} + + + org.opennms.core + org.opennms.core.web-assets + ${project.version} + + + org.opennms.core + org.opennms.core.xml + ${project.version} + + + org.opennms.core.test-api + org.opennms.core.test-api.lib + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.activemq + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.camel + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.collection + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.db + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.dns + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.http + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.rest + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.services + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.snmp + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.ssh + ${project.version} + test + + + org.opennms.core.test-api + org.opennms.core.test-api.xml + ${project.version} + test + + + org.opennms.features + org.opennms.features.dhcpd + ${project.version} + + + org.opennms.features + org.opennms.features.discovery + ${project.version} + + + org.opennms.features.geocoder + org.opennms.features.geocoder.api + ${project.version} + + + org.opennms.features.geocoder + org.opennms.features.geocoder.google + ${project.version} + + + org.opennms.features.geocoder + org.opennms.features.geocoder.nominatim + ${project.version} + + + org.opennms.features + org.opennms.features.jdbc-collector + ${project.version} + + + org.opennms.features + org.opennms.features.name-cutter + ${project.version} + + + org.opennms.features.notifications + org.opennms.features.notifications.api + ${project.version} + + + org.opennms.features + org.opennms.features.mib-compiler + ${project.version} + + + org.opennms.features + org.opennms.features.request-tracker + ${project.version} + + + org.opennms.features + org.opennms.features.phonebook + ${project.version} + + + org.opennms.features + org.opennms.features.system-report + ${project.version} + + + org.opennms.features.events + org.opennms.features.events.api + ${project.version} + + + org.opennms.features.events + org.opennms.features.events.daemon + ${project.version} + + + org.opennms.features.events + org.opennms.features.events.syslog + ${project.version} + + + org.opennms.features.events + org.opennms.features.events.traps + ${project.version} + + + org.opennms.features.enlinkd + org.opennms.features.enlinkd.service.api + ${project.version} + + + org.opennms.features.enlinkd + org.opennms.features.enlinkd.persistence.api + ${project.version} + + + org.opennms.features.collection + org.opennms.features.collection.api + ${project.version} + + + org.opennms.features.collection + org.opennms.features.collection.collectors + ${project.version} + + + org.opennms.features.collection + org.opennms.features.collection.core + ${project.version} + + + org.opennms.features.collection + org.opennms.features.collection.client-rpc + ${project.version} + + + org.opennms.features.collection + org.opennms.features.collection.persistence.osgi + ${project.version} + + + org.opennms.features.collection + org.opennms.features.collection.persistence.rrd + ${project.version} + + + org.opennms.features.collection + org.opennms.features.collection.test-api + ${project.version} + + + org.opennms.features.collection + org.opennms.features.collection.thresholding.api + ${project.version} + + + org.opennms.features.collection + org.opennms.features.collection.persistence.tcp + ${project.version} + + + org.opennms.features.poller + org.opennms.features.poller.api + ${project.version} + + + org.opennms.features.poller + org.opennms.features.poller.client-rpc + ${project.version} + + + org.opennms.features.poller + org.opennms.features.poller.shell + ${project.version} + + + org.opennms.features + org.opennms.features.instrumentationLogReader + ${project.version} + + + org.opennms.features + org.opennms.features.springframework-security + ${project.version} + + + org.opennms.features.reporting + org.opennms.features.reporting.api + ${project.version} + + + org.opennms.features.reporting + org.opennms.features.reporting.core + ${project.version} + + + org.opennms.features.reporting + org.opennms.features.reporting.dao + ${project.version} + + + org.opennms.features.reporting + org.opennms.features.reporting.sdo + ${project.version} + + + org.opennms.features.reporting + org.opennms.features.reporting.model + ${project.version} + + + org.opennms.features.reporting + org.opennms.features.reporting.jasper-reports + ${project.version} + + + org.opennms.features.reporting + org.opennms.features.reporting.availability + ${project.version} + + + org.opennms.features.reporting + org.opennms.features.reporting.repository + ${project.version} + + + org.opennms.features.ticketing + org.opennms.features.ticketing.api + ${project.version} + + + org.opennms.features.provisioning + org.opennms.features.provisioning.api + ${project.version} + + + org.opennms.features.provisioning + org.opennms.features.provisioning.lib + ${project.version} + + + org.opennms.features.topology + org.opennms.features.topology.api + ${project.version} + + + org.opennms.features + vaadin + ${project.version} + pom + + + org.opennms + opennms-map-provisioning-adapter + ${project.version} + + + org.opennms + opennms-rancid-provisioning-adapter + ${project.version} + + + org.opennms + opennms-wsman-asset-provisioning-adapter + ${project.version} + + + org.opennms + opennms-snmp-asset-provisioning-adapter + ${project.version} + + + org.opennms + opennms-snmp-hardware-inventory-provisioning-adapter + ${project.version} + + + org.opennms + opennms-geoip-provisioning-adapter + ${project.version} + + + org.opennms + opennms-snmp-metadata-provisioning-adapter + ${project.version} + + + org.opennms.assemblies + org.opennms.assemblies.webapp-full + ${project.version} + + + org.opennms + opennms-alarmd + ${project.version} + + + org.opennms + opennms-alarm-northbounder-email + ${project.version} + + + org.opennms + opennms-alarm-northbounder-syslog + ${project.version} + + + org.opennms + opennms-alarm-northbounder-snmptrap + ${project.version} + + + org.opennms + opennms-alarm-northbounder-jms + ${project.version} + + + org.opennms + opennms-alarm-northbounder-bsf + ${project.version} + + + org.opennms + opennms-alarm-northbounder-drools + ${project.version} + + + org.opennms + drools-correlation-engine + ${project.version} + + + org.opennms + opennms-mock-simpleserver + ${project.version} + test + + + org.opennms.tests + org.opennms.tests.mock-snmp-agent + ${project.version} + test + + + org.opennms.dependencies + activemq-dependencies + ${project.version} + pom + + + org.opennms.dependencies + activemq-web-dependencies + ${project.version} + pom + + + org.opennms.dependencies + atomikos-dependencies + ${project.version} + pom + + + org.opennms.dependencies + aws-dependencies + ${project.version} + pom + + + org.opennms.dependencies + camel-dependencies + ${project.version} + pom + + + org.opennms.dependencies + camel-test-dependencies + ${project.version} + pom + test + + + org.opennms.dependencies + liquibase-dependencies + ${project.version} + pom + + + org.opennms.dependencies + felix-dependencies + ${project.version} + pom + + + org.opennms.dependencies + geronimo-dependencies + ${project.version} + pom + + + org.opennms.dependencies + gwt-maps-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jackson1-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jackson2-dependencies + ${project.version} + pom + + + org.json + json + ${jsonVersion} + + + org.opennms.dependencies + jasypt-dependencies + ${project.version} + pom + + + org.jasypt + jasypt + ${jasyptVersion} + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.jasypt + ${jasyptVersion}_1 + + + org.opennms.dependencies + javamail-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jaxb-dependencies + ${project.version} + pom + + + jaxen + jaxen + 2.0.0 + + + org.opennms.dependencies + jcifs-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jfreechart-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jinterop-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jna-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jradius-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jrobin-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jrrd-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jrrd2-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jstl-dependencies + ${project.version} + pom + + + org.opennms.dependencies + oia-dependencies + ${project.version} + pom + + + org.opennms.dependencies + pax-exam-dependencies + ${project.version} + pom + test + + + org.opennms.dependencies + quartz-dependencies + ${project.version} + pom + + + org.opennms.dependencies + servlet-dependencies + ${project.version} + pom + + + org.opennms.dependencies + spring-security-core-dependencies + ${project.version} + pom + + + org.opennms.dependencies + spring-security-dependencies + ${project.version} + pom + + + org.opennms + opennms-bootstrap + ${project.version} + + + org.opennms + opennms-config-api + ${project.version} + + + org.opennms + opennms-config-jaxb + ${project.version} + + + org.opennms + opennms-config + ${project.version} + + + org.opennms + opennms-config-model + ${project.version} + + + org.opennms + opennms-config-model + ${project.version} + xsds + + + org.opennms + opennms-config-tester + ${project.version} + test + + + org.opennms + opennms-config-utils + ${project.version} + + + org.opennms + opennms-correlator + ${project.version} + + + org.opennms.core + org.opennms.core.daemon + ${project.version} + + + org.opennms + opennms-dao + ${project.version} + + + org.opennms + opennms-dao-api + ${project.version} + + + org.opennms + opennms-dao-mock + ${project.version} + + + org.opennms + opennms-base-assembly + ${project.version} + + + org.opennms + opennms-detector-bsf + ${project.version} + + + org.opennms + opennms-detector-datagram + ${project.version} + + + org.opennms + opennms-detector-dhcp + ${project.version} + + + org.opennms + opennms-detector-generic + ${project.version} + + + org.opennms + opennms-detector-jdbc + ${project.version} + + + org.opennms + opennms-detector-jmx + ${project.version} + + + org.opennms + opennms-detector-jms + ${project.version} + + + org.opennms + opennms-detector-rdns-lookup + ${project.version} + + + org.opennms + opennms-detector-lineoriented + ${project.version} + + + org.opennms + opennms-detector-simple + ${project.version} + + + org.opennms + opennms-detector-ssh + ${project.version} + + + org.opennms + opennms-detector-web + ${project.version} + + + org.opennms + opennms-detectorclient-rpc + ${project.version} + + + org.opennms + opennms-provision-command + ${project.version} + + + org.opennms + opennms-requisition-service + ${project.version} + + + org.opennms.dependencies + drools-dependencies + ${project.version} + pom + + + org.opennms.dependencies + hibernate-dependencies + ${project.version} + pom + + + org.opennms + opennms-full-assembly + ${project.version} + tar.gz + dist + + + org.opennms + opennms-icmp-api + ${project.version} + + + org.opennms + opennms-icmp-jni + ${project.version} + + + org.opennms + opennms-icmp-jni6 + ${project.version} + + + org.opennms + opennms-icmp-jna + ${project.version} + + + org.opennms + opennms-icmp-best + ${project.version} + + + org.opennms.core + org.opennms.core.icmp-jna + ${project.version} + + + org.opennms + opennms-javamail-api + ${project.version} + + + org.opennms.features + opennms-integration-otrs + ${project.version} + + + org.opennms.features + opennms-integration-otrs-31 + ${project.version} + + + org.opennms.features + opennms-integration-rt + ${project.version} + + + org.opennms.features + opennms-integration-remedy + ${project.version} + + + org.opennms + opennms-install + ${project.version} + + + org.opennms.dependencies + cxf-dependencies + ${project.version} + pom + + + org.opennms + opennms-jetty + ${project.version} + + + org.opennms.dependencies + mina-dependencies + ${project.version} + pom + + + org.opennms.dependencies + netty-dependencies + ${project.version} + pom + + + org.opennms + opennms-model + ${project.version} + + + org.opennms + opennms-model + ${project.version} + xsds + + + org.opennms + opennms-provision-api + ${project.version} + + + org.opennms + opennms-provision-persistence + ${project.version} + + + org.opennms + opennms-provision-persistence + ${project.version} + test-jar + test + + + org.opennms + opennms-provisiond + ${project.version} + + + org.opennms.features + jmxconfiggenerator + onejar + ${project.version} + + + org.opennms.tests + org.opennms.tests.mock-elements + ${project.version} + test + + + org.opennms.core + org.opennms.core.jstl-support + ${project.version} + + + org.opennms + opennms-reporting + ${project.version} + + + org.opennms + opennms-reportd + ${project.version} + + + org.opennms + opennms-rrd-api + ${project.version} + + + org.opennms + opennms-rrd-model + ${project.version} + + + org.opennms + opennms-rrdtool-api + ${project.version} + + + org.opennms + opennms-rrdcached-api + ${project.version} + + + org.opennms + opennms-rrd-jrobin + ${project.version} + + + org.opennms + opennms-rrd-tcp + ${project.version} + + + org.opennms + opennms-rws + ${project.version} + + + org.opennms + opennms-services + ${project.version} + + + org.opennms.features.measurements + org.opennms.features.measurements.api + ${project.version} + + + org.opennms.features.measurements + org.opennms.features.measurements.impl + ${project.version} + + + org.opennms + opennms-site + ${project.version} + + + org.opennms.dependencies + jasper-dependencies + ${project.version} + pom + + + org.opennms.dependencies + spring-dependencies + ${project.version} + pom + + + org.opennms.dependencies + spring-web-dependencies + ${project.version} + pom + + + org.opennms.dependencies + spring-test-dependencies + ${project.version} + pom + test + + + org.opennms + opennms-util + ${project.version} + + + org.opennms + opennms-version + ${project.version} + provided + + + org.opennms + opennms-web-api + ${project.version} + + + org.opennms + opennms-web-dependencies + ${project.version} + pom + + + org.opennms + opennms-webapp + ${project.version} + + + org.opennms + opennms-webapp + ${project.version} + war + + + org.opennms + opennms-webapp + ${project.version} + tar.gz + meta-inf + + + org.opennms + opennms-webapp + ${project.version} + tar.gz + lib + + + org.opennms.features + root-webapp + ${project.version} + war + + + org.opennms + opennms-webapp-ng + ${project.version} + + + org.opennms + opennms-wmi + ${project.version} + + + org.opennms + opennms-vmware + ${project.version} + + + org.opennms.dependencies + newts-dependencies + ${project.version} + pom + + + org.opennms.dependencies + smack-dependencies + ${project.version} + pom + + + org.opennms.dependencies + rancid-dependencies + ${project.version} + pom + + + org.opennms + opennms-asterisk + ${project.version} + + + org.opennms.dependencies + asterisk-dependencies + ${project.version} + pom + + + org.opennms.dependencies + dnsjava-dependencies + ${project.version} + pom + + + org.opennms.dependencies + snmp4j-dependencies + ${project.version} + pom + + + org.opennms.dependencies + snmp4j-agent-dependencies + ${project.version} + pom + + + org.opennms.dependencies + snmp-dependencies + ${project.version} + pom + + + org.opennms.dependencies + snmp-test-dependencies + ${project.version} + pom + test + + + org.opennms.dependencies + twitter4j-dependencies + ${project.version} + pom + + + org.opennms.dependencies + tracker-dependencies + ${project.version} + pom + + + org.opennms.dependencies + jmx-dependencies + ${project.version} + pom + + + org.opennms.dependencies + owasp-dependencies + ${project.version} + pom + + + + + + org.ow2.asm + asm + ${asmVersion} + + + org.ow2.asm + asm-analysis + ${asmVersion} + + + org.ow2.asm + asm-commons + ${asmVersion} + + + org.ow2.asm + asm-tree + ${asmVersion} + + + org.ow2.asm + asm-util + ${asmVersion} + + + org.apache.ant + ant + 1.10.14 + + + antlr + antlr + ${antlr.version} + + + org.apache.camel + camel-jms + ${camelVersion} + + + org.apache.cxf + cxf-core + ${cxfVersion} + + + org.apache.cxf + cxf-rt-frontend-jaxrs + ${cxfVersion} + + + org.apache.cxf + cxf-rt-frontend-jaxws + ${cxfVersion} + + + org.apache.cxf + cxf-rt-rs-client + ${cxfVersion} + + + org.apache.cxf + cxf-rt-rs-extension-providers + ${cxfVersion} + + + org.apache.cxf + cxf-rt-rs-extension-search + ${cxfVersion} + + + org.apache.cxf + cxf-rt-rs-service-description-openapi-v3 + ${cxfVersion} + + + org.apache.cxf + cxf-rt-transports-http + ${cxfVersion} + + + org.apache.cxf + cxf-rt-transports-local + ${cxfVersion} + + + org.apache.cxf + cxf-rt-ws-addr + ${cxfVersion} + + + org.apache.cxf + cxf-rt-ws-mex + ${cxfVersion} + + + org.apache.camel + camel-spring + ${camelVersion} + + + org.apache.curator + curator-client + ${curatorVersion} + + + log4j + log4j + + + + + org.apache.curator + curator-framework + ${curatorVersion} + + + log4j + log4j + + + + + org.apache.curator + curator-recipes + ${curatorVersion} + + + log4j + log4j + + + + + org.apache.curator + curator-test + ${curatorTestVersion} + + + log4j + log4j + + + + + org.apache.maven + maven-artifact + ${mavenApiVersion} + + + org.apache.maven + maven-core + ${mavenApiVersion} + + + org.apache.maven + maven-model + ${mavenApiVersion} + + + org.apache.maven + maven-plugin-api + ${mavenApiVersion} + + + org.apache.maven.resolver + maven-resolver-api + ${mavenResolverApiVersion} + + + org.apache.xmlgraphics + batik-dom + ${batikVersion} + + + org.apache.xmlgraphics + batik-svg-dom + ${batikVersion} + + + org.apache.xmlgraphics + batik-swing + ${batikVersion} + + + org.apache.xmlgraphics + batik-transcoder + ${batikVersion} + + + org.apache.xmlgraphics + batik-xml + ${batikVersion} + + + org.apache.xmlgraphics + batik-util + ${batikVersion} + + + org.apache.xmlgraphics + xmlgraphics-commons + ${xmlgraphicsCommonsVersion} + + + commons-logging + commons-logging + + + + + bsf + bsf + 2.4.0 + + + commons-logging + commons-logging + + + + + org.apache-extras.beanshell + bsh + 2.0b6 + + + org.mongodb + bson + ${bsonVersion} + + + com.github.ben-manes.caffeine + caffeine + ${caffeineVersion} + + + com.thoughtworks.xstream + xstream + ${xstreamVersion} + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.xstream + ${xstreamVersion}_1 + + + cglib + cglib-nodep + 2.2.2 + + + com.helger + dhcp4java + ${dhcp4javaVersion} + + + com.mchange + c3p0 + ${c3p0Version} + + + commons-beanutils + commons-beanutils + ${commonsBeanutilsVersion} + + + commons-logging + commons-logging + + + + + commons-chain + commons-chain + 1.2 + + + commons-cli + commons-cli + 1.2 + + + commons-codec + commons-codec + ${commonsCodecVersion} + + + org.apache.commons + commons-collections4 + ${commonsCollections4Version} + + + commons-collections + commons-collections + ${commonsCollectionsVersion} + + + commons-configuration + commons-configuration + ${commonsConfigurationVersion} + + + commons-logging + commons-logging + + + + + commons-dbcp + commons-dbcp + 1.4 + + + commons-digester + commons-digester + ${commonsDigesterVersion} + + + commons-logging + commons-logging + + + + + org.apache.commons + commons-exec + 1.3 + + + org.apache.commons + commons-csv + ${commonsCsvVersion} + + + org.apache.httpcomponents + httpcore + ${httpcoreVersion} + + + org.apache.httpcomponents + httpcore-nio + ${httpcoreVersion} + + + org.apache.httpcomponents + httpcore-osgi + ${httpcoreVersion} + + + org.apache.httpcomponents + httpclient + ${httpclientVersion} + + + commons-logging + commons-logging + + + + + org.apache.httpcomponents + httpclient-cache + ${httpclientVersion} + + + commons-logging + commons-logging + + + + + org.apache.httpcomponents + httpclient-osgi + ${httpclientVersion} + + + commons-logging + commons-logging + + + + + org.apache.httpcomponents + httpmime + ${httpclientVersion} + + + commons-logging + commons-logging + + + + + org.apache.httpcomponents + httpasyncclient + ${httpasyncclientVersion} + + + commons-logging + commons-logging + + + + + org.apache.httpcomponents + httpasyncclient-cache + ${httpasyncclientVersion} + + + commons-logging + commons-logging + + + + + org.apache.httpcomponents + httpasyncclient-osgi + ${httpasyncclientVersion} + + + commons-logging + commons-logging + + + + + commons-io + commons-io + ${commonsIoVersion} + + + commons-jxpath + commons-jxpath + ${commonsJxpathVersion} + + + commons-lang + commons-lang + ${commonsLangVersion} + + + org.apache.commons + commons-lang3 + ${commonsLang3Version} + + + org.apache.commons + commons-math3 + ${commonsMath3Version} + + + commons-net + commons-net + ${commonsNetVersion} + + + commons-pool + commons-pool + 1.6 + + + commons-validator + commons-validator + ${commonsValidatorVersion} + + + commons-logging + commons-logging + + + + + commons-validator + commons-validator-core + ${commonsValidatorVersion} + + + commons-logging + commons-logging + + + + + org.dbunit + dbunit + 2.4.8 + test + + + org.dom4j + dom4j + ${dom4jVersion} + + + geoGoogle + geoGoogle + 1.5.1-20110518onms1 + + + commons-codec + commons-codec + + + commons-collections + commons-collections + + + commons-io + commons-io + + + commons-lang + commons-lang + + + commons-logging + commons-logging + + + javax.xml.bind + jaxb-api + + + com.sun.xml.bind + jaxb-core + + + com.sun.xml.bind + jaxb-impl + + + + + org.reflections + reflections + 0.9.10 + + + com.googlecode.concurrent-locks + concurrent-locks + 1.0.0 + + + com.google.errorprone + error_prone_annotations + 2.10.0 + + + com.google.guava + guava + ${guavaVersion} + + + + com.google.code.findbugs + jsr305 + + + + + com.google.gwt + gwt-user + ${gwtVersion} + true + + + com.google.gwt + gwt-dev + ${gwtVersion} + + + + com.google.code.findbugs + jsr305 + + + + + + com.vaadin + vaadin-compatibility-server + ${vaadinVersion} + + + com.vaadin + vaadin-compatibility-shared + ${vaadinVersion} + + + com.vaadin + vaadin-compatibility-client-compiled + ${vaadinVersion} + + + com.vaadin + vaadin-compatibility-client + ${vaadinVersion} + + + com.vaadin + vaadin-compatibility-themes + ${vaadinVersion} + + + com.vaadin + vaadin-server + ${vaadinVersion} + + + com.vaadin + vaadin-shared + ${vaadinVersion} + + + com.vaadin + vaadin-client-compiled + ${vaadinVersion} + + + com.vaadin + vaadin-client + ${vaadinVersion} + + + com.vaadin + vaadin-themes + ${vaadinVersion} + + + com.vaadin + vaadin-context-menu + ${vaadinAddonContextMenuVersion} + + + org.vaadin.addon + confirmdialog + ${vaadinAddonConfirmDialogVersion} + + + org.opennms.features.vaadin-components + extender-service + ${project.version} + + + opensymphony + ognl + 2.6.11 + + + com.opencsv + opencsv + 5.8 + + + net.sf.json-lib + json-lib + ${jsonlibVersion} + jdk15 + + + net.sf.jung + jung-api + ${jungVersion} + + + net.sf.jung + jung-graph-impl + ${jungVersion} + + + net.sf.jung + jung-algorithms + ${jungVersion} + + + net.sf.jung + jung-visualization + ${jungVersion} + + + net.sf.opencsv + opencsv + ${opencsvVersion} + + + org.powermock + powermock-module-junit4 + ${powermockVersion} + test + + + org.powermock + powermock-api-mockito2 + ${powermockVersion} + test + + + net.sf.ehcache + ehcache + 1.8.0 + pom + + + org.jboss.logging + jboss-logging + ${jbossLoggingVersion} + + + org.jetbrains.kotlin + kotlin-osgi-bundle + ${kotlinVersion} + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlinVersion} + + + org.jetbrains.kotlin + kotlin-stdlib-common + ${kotlinVersion} + + + com.github.seancfoley + ipaddress + ${ipaddressVersion} + + + com.maxmind.geoip2 + geoip2 + ${geoipVersion} + + + com.codeborne + phantomjsdriver + 1.5.0 + + + org.codehaus.jackson + jackson-core-asl + ${jacksonVersion} + + + org.codehaus.jackson + jackson-jaxrs + ${jacksonVersion} + + + org.codehaus.jackson + jackson-mapper-asl + ${jacksonVersion} + + + org.codehaus.jackson + jackson-xc + ${jacksonVersion} + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson2Version} + + + com.fasterxml.jackson.core + jackson-core + ${jackson2Version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson2Version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-cbor + ${jackson2Version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-csv + ${jackson2Version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-smile + ${jackson2Version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + ${jackson2Version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + ${jackson2Version} + + + org.yaml + snakeyaml + ${snakeyamlVersion} + + + com.fasterxml.jackson.datatype + jackson-datatype-guava + ${jackson2Version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + ${jackson2Version} + + + com.fasterxml.jackson.datatype + jackson-datatype-json-org + ${jackson2Version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson2Version} + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-base + ${jackson2Version} + + + com.fasterxml.jackson.jaxrs + jackson-jaxrs-json-provider + ${jackson2Version} + + + com.fasterxml.jackson.module + jackson-module-jaxb-annotations + ${jackson2Version} + + + com.fasterxml.jackson.module + jackson-module-paranamer + ${jackson2Version} + + + com.fasterxml.jackson.module + jackson-module-scala_2.12 + ${jackson2Version} + + + com.fasterxml.jackson.module + jackson-module-scala_2.13 + ${jackson2Version} + + + org.hibernate + hibernate-validator + ${hibernateValidatorVersion} + + + + com.sun.istack + istack-commons-runtime + 3.0.10 + + + org.glassfish.jaxb + txw2 + ${jaxbRuntimeVersion} + + + javax.annotation + javax.annotation-api + ${javaxAnnotationApiVersion} + + + javassist + javassist + 9999.use-org-javassist-not-javassist + + + org.javassist + javassist + ${javassistVersion} + + + javax.annotation + jsr250-api + 1.0 + + + + com.sun.activation + javax.activation + 1.2.0 + + + javax.activation + activation + 1.1.1 + + + javax.mail + mail + ${javaxMailVersion} + + + + + com.sun.activation + jakarta.activation + 1.2.1 + + + jakarta.activation + jakarta.activation-api + 1.2.2 + + + + org.hibernate.javax.persistence + hibernate-jpa-2.0-api + 1.0.1.Final + + + org.hibernate + hibernate-validator-annotation-processor + ${hibernateValidatorAnnotationProcessorVersion} + + + javax.validation + validation-api + 1.0.0.GA + + + javax.validation + validation-api + 1.0.0.GA + sources + + + javax.ws.rs + javax.ws.rs-api + 2.1.1 + + + javax.websocket + javax.websocket-api + 1.1 + + + jakarta.xml.bind + jakarta.xml.bind-api + ${jakartaXmlBindVersion} + + + io.dropwizard.metrics + metrics-core + ${dropwizardMetricsVersion} + + + org.jdom + jdom2 + ${jdom2Version} + + + org.eclipse.jetty + apache-jsp + ${jettyVersion} + + + org.eclipse.jetty + jetty-annotations + ${jettyVersion} + + + org.eclipse.jetty + jetty-continuation + ${jettyVersion} + + + org.eclipse.jetty + jetty-deploy + ${jettyVersion} + + + org.eclipse.jetty + jetty-http + ${jettyVersion} + + + org.eclipse.jetty + jetty-io + ${jettyVersion} + + + org.eclipse.jetty + jetty-jaas + ${jettyVersion} + + + org.eclipse.jetty + jetty-jaspi + ${jettyVersion} + + + org.eclipse.jetty + jetty-jmx + ${jettyVersion} + + + org.eclipse.jetty + jetty-jndi + ${jettyVersion} + + + org.eclipse.jetty + apache-jstl + ${jettyVersion} + + + org.eclipse.jetty + jetty-plus + ${jettyVersion} + + + org.eclipse.jetty + jetty-rewrite + ${jettyVersion} + + + org.eclipse.jetty + jetty-security + ${jettyVersion} + + + org.eclipse.jetty + jetty-server + ${jettyVersion} + + + org.eclipse.jetty + jetty-servlet + ${jettyVersion} + + + org.eclipse.jetty + jetty-servlets + ${jettyVersion} + + + org.eclipse.jetty + jetty-util + ${jettyVersion} + + + org.eclipse.jetty + jetty-util-ajax + ${jettyVersion} + + + org.eclipse.jetty + jetty-webapp + ${jettyVersion} + + + org.eclipse.jetty + jetty-xml + ${jettyVersion} + + + org.eclipse.jetty.aggregate + jetty-all-server + ${jettyVersion} + + + org.eclipse.jetty.websocket + websocket-server + ${jettyVersion} + + + + org.apache.taglibs + taglibs-standard-impl + 1.2.5 + + + javax.servlet + javax.servlet-api + ${servletApiVersion} + + + com.github.mwiede + jsch + ${jschVersion} + + + org.mockito + mockito-core + ${mockitoVersion} + test + + + org.mockito + mockito-inline + ${mockitoVersion} + test + + + org.osgi + osgi.core + ${osgiVersion} + + + org.osgi + osgi.cmpn + ${osgiCompendiumVersion} + + + org.osgi + osgi.annotation + ${osgiAnnotationVersion} + + + org.osgi + osgi.enterprise + ${osgiEnterpriseVersion} + + + org.jolokia + jolokia-client-java + ${jolokiaClientVersion} + + + commons-logging + commons-logging + + + + + joda-time + joda-time + ${jodaTimeVersion} + + + org.skyscreamer + jsonassert + 1.5.1 + test + + + junit + junit + ${junit4Version} + test + + + org.hamcrest + hamcrest-library + 1.3 + test + + + com.spotify + hamcrest-pojo + 1.3.2 + test + + + org.awaitility + awaitility + 4.2.0 + test + + + net.sourceforge.jwebunit + jwebunit-htmlunit-plugin + 2.5 + test + + + org.apache.karaf.shell + org.apache.karaf.shell.core + ${karafVersion} + + + org.apache.logging.log4j + log4j-bom + ${log4j2Version} + import + pom + + + ch.qos.logback + logback-classic + ${logbackClassicVersion} + + + org.jsoup + jsoup + ${jsoupVersion} + + + org.apache.mina + mina-core + ${minaVersion} + + + mx4j + mx4j-tools + 3.0.1 + + + io.netty + netty-all + ${netty4Version} + + + io.netty + netty-codec + ${netty4Version} + + + io.netty + netty-transport + ${netty4Version} + + + io.netty + netty-handler + ${netty4Version} + + + io.netty + netty-buffer + ${netty4Version} + + + io.netty + netty-common + ${netty4Version} + + + com.novell.ldap + jldap + ${jldapVersion} + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-aop + ${patchedSpringVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-aspects + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-beans + ${patchedSpringVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-context + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-context-support + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-core + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-expression + ${patchedSpringVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-instrument + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-jdbc + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-jms + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-messaging + ${patchedSpringVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-orm + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-oxm + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-test + ${springVersion} + + + commons-logging + commons-logging + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-tx + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-web + ${patchedSpringVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-webmvc + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-webmvc-portlet + ${springVersion} + + + org.springframework + * + + + + + org.apache.servicemix.bundles + org.apache.servicemix.bundles.spring-websocket + ${springVersion} + + + org.springframework + * + + + + + org.slf4j + slf4j-api + ${slf4jVersion} + + + org.slf4j + jcl-over-slf4j + ${slf4jVersion} + runtime + + + org.slf4j + log4j-over-slf4j + ${slf4jVersion} + runtime + + + io.swagger.core.v3 + swagger-annotations + ${swaggerVersion} + + + io.swagger.core.v3 + swagger-core + ${swaggerVersion} + + + io.swagger.core.v3 + swagger-integration + ${swaggerVersion} + + + io.swagger.core.v3 + swagger-jaxrs2 + ${swaggerVersion} + + + io.swagger.core.v3 + swagger-models + ${swaggerVersion} + + + io.swagger.parser.v3 + swagger-parser + ${swaggerVersion} + + + org.postgresql + postgresql + ${postgresqlVersion} + + + com.google.protobuf + protobuf-java + ${protobufVersion} + + + com.rometools + rome + 1.18.0 + + + com.ximpleware + vtd-xml + 2.13.4 + + + org.apache.velocity + velocity-engine-core + ${velocityVersion} + + + wsdl4j + wsdl4j + ${wsdl4jVersion} + + + xalan + xalan + ${xalanVersion} + + + xml-apis + xml-apis + + + + + xalan + serializer + ${xalanVersion} + + + xml-apis + xml-apis + + + + + xerces + xercesImpl + ${xercesVersion} + + + xml-apis + xml-apis + + + + + xml-apis + xml-apis-ext + ${xmlApisExtVersion} + + + xmlunit + xmlunit + 1.6 + test + + + org.apache.commons + commons-jexl + ${commonsJexlVersion} + + + commons-logging + commons-logging + + + + + org.opennms.dependencies + groovy-dependencies + ${project.version} + pom + + + com.zaxxer + HikariCP + ${hikaricpVersion} + + + com.zaxxer + HikariCP-java7 + 9999.use-HikariCP-not-HikariCP-java7 + + + net.agkn + hll + ${hllVersion} + + + org.eclipse.jdt.core.compiler + ecj + ${ecjVersion} + + + com.swrve + rate-limited-logger + ${rateLimitedLoggerVersion} + + + + com.google.code.findbugs + jsr305 + + + + + org.spockframework + spock-core + ${spockVersion} + test + + + org.spockframework + spock-junit4 + ${spockVersion} + test + + + org.bouncycastle + bcpkix-jdk18on + ${bouncyCastleVersion} + + + org.bouncycastle + bcprov-jdk18on + ${bouncyCastleVersion} + + + + net.jqwik + jqwik + ${jqwikVersion} + test + + + org.junit.jupiter + junit-jupiter-api + ${junit5Version} + test + + + org.junit.platform + junit-platform-engine + ${junit5PlatformVersion} + test + + + org.junit.vintage + junit-vintage-engine + ${junit5Version} + test + + + org.openjdk.jmh + jmh-core + ${jmhVersion} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmhVersion} + + + io.vavr + vavr + 0.10.0 + + + org.apache.sshd + sshd-core + ${minaSshdVersion} + + + org.apache.sshd + sshd-osgi + ${minaSshdVersion} + + + org.apache.sshd + sshd-scp + ${minaSshdVersion} + + + org.apache.sshd + sshd-sftp + ${minaSshdVersion} + + + org.xerial.snappy + snappy-java + ${snappyJavaVersion} + + + + + + central + Maven Central + https://repo1.maven.org/maven2/ + + false + + + + opennms-repo + OpenNMS Mega-Repository + https://maven.opennms.org/repository/everything/ + + false + + + + + + + central + Maven Central + https://repo1.maven.org/maven2/ + + false + + + + opennms-repo + OpenNMS Mega-Repository + https://maven.opennms.org/repository/everything/ + + false + + + + opennms-ossrh + https://oss.sonatype.org/content/repositories/snapshots + + false + + + true + + + + + + opennms-ossrh + https://oss.sonatype.org/content/repositories/snapshots/ + + + opennms-ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + From 06f2264939413313fd738933379fcf6158f6bd39 Mon Sep 17 00:00:00 2001 From: Chance Newkirk <40219357+cnewkirk@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:02:55 -0700 Subject: [PATCH 02/35] docs(superpowers): add design specs and implementation plans Full library of specs and plans covering: JMX config generator, node detail, alarms list, outage migration, surveillance dashboard, topology lab, Perses replacement (4 phases), dashboard drag-resize, weathermap, edge labels, topology enhancements, app-wide design language, rounded-corners normalization, topology time-travel, and test fixture seed script. --- CLAUDE.md | 149 + docs/superpowers/TODO.md | 177 ++ .../plans/2026-04-05-alarms-list-page.md | 949 +++++++ ...26-04-05-availability-panel-ip-grouping.md | 83 + .../plans/2026-04-05-outage-vue-migration.md | 1115 ++++++++ .../2026-04-05-perses-phase1-foundation.md | 1635 +++++++++++ ...026-04-05-perses-phase2-resource-graphs.md | 545 ++++ .../2026-04-05-perses-phase3-dashboards.md | 993 +++++++ .../2026-04-05-perses-phase4-retirement.md | 414 +++ .../2026-04-05-test-fixture-seed-script.md | 388 +++ .../plans/2026-04-05-topology-lab.md | 1070 ++++++++ .../2026-04-06-app-wide-design-language.md | 1363 +++++++++ .../plans/2026-04-06-dashboard-drag-resize.md | 1087 ++++++++ ...026-04-06-rounded-corners-normalization.md | 887 ++++++ .../2026-04-06-surveillance-dashboard-vue.md | 1351 +++++++++ .../plans/2026-04-06-topology-weathermap.md | 966 +++++++ .../plans/2026-04-07-topology-edge-labels.md | 1141 ++++++++ .../plans/2026-04-07-topology-enhancements.md | 2443 +++++++++++++++++ .../plans/2026-04-08-topology-time-travel.md | 1377 ++++++++++ .../2026-04-05-alarm-detail-vue-design.md | 281 ++ .../2026-04-05-alarms-list-page-design.md | 84 + ...5-availability-panel-ip-grouping-design.md | 53 + .../specs/2026-04-05-outage-vue-design.md | 139 + .../2026-04-05-perses-replacement-design.md | 302 ++ ...2026-04-05-resource-graphs-panel-design.md | 226 ++ ...026-04-05-rounded-corners-normalization.md | 137 + .../2026-04-05-test-fixture-seed-design.md | 171 ++ .../specs/2026-04-05-topology-lab-design.md | 312 +++ .../2026-04-06-app-wide-design-language.md | 189 ++ ...2026-04-06-dashboard-drag-resize-design.md | 131 + .../2026-04-06-snmp-collections-config-vue.md | 249 ++ .../2026-04-06-surveillance-dashboard-vue.md | 219 ++ ...06-surveillance-views-config-vue-design.md | 202 ++ .../2026-04-06-topology-weathermap-design.md | 110 + .../2026-04-06-wallboard-config-vue-design.md | 260 ++ ...2026-04-07-topology-enhancements-design.md | 335 +++ .../2026-04-08-topology-time-travel-design.md | 283 ++ 37 files changed, 21816 insertions(+) create mode 100644 CLAUDE.md create mode 100644 docs/superpowers/TODO.md create mode 100644 docs/superpowers/plans/2026-04-05-alarms-list-page.md create mode 100644 docs/superpowers/plans/2026-04-05-availability-panel-ip-grouping.md create mode 100644 docs/superpowers/plans/2026-04-05-outage-vue-migration.md create mode 100644 docs/superpowers/plans/2026-04-05-perses-phase1-foundation.md create mode 100644 docs/superpowers/plans/2026-04-05-perses-phase2-resource-graphs.md create mode 100644 docs/superpowers/plans/2026-04-05-perses-phase3-dashboards.md create mode 100644 docs/superpowers/plans/2026-04-05-perses-phase4-retirement.md create mode 100644 docs/superpowers/plans/2026-04-05-test-fixture-seed-script.md create mode 100644 docs/superpowers/plans/2026-04-05-topology-lab.md create mode 100644 docs/superpowers/plans/2026-04-06-app-wide-design-language.md create mode 100644 docs/superpowers/plans/2026-04-06-dashboard-drag-resize.md create mode 100644 docs/superpowers/plans/2026-04-06-rounded-corners-normalization.md create mode 100644 docs/superpowers/plans/2026-04-06-surveillance-dashboard-vue.md create mode 100644 docs/superpowers/plans/2026-04-06-topology-weathermap.md create mode 100644 docs/superpowers/plans/2026-04-07-topology-edge-labels.md create mode 100644 docs/superpowers/plans/2026-04-07-topology-enhancements.md create mode 100644 docs/superpowers/plans/2026-04-08-topology-time-travel.md create mode 100644 docs/superpowers/specs/2026-04-05-alarm-detail-vue-design.md create mode 100644 docs/superpowers/specs/2026-04-05-alarms-list-page-design.md create mode 100644 docs/superpowers/specs/2026-04-05-availability-panel-ip-grouping-design.md create mode 100644 docs/superpowers/specs/2026-04-05-outage-vue-design.md create mode 100644 docs/superpowers/specs/2026-04-05-perses-replacement-design.md create mode 100644 docs/superpowers/specs/2026-04-05-resource-graphs-panel-design.md create mode 100644 docs/superpowers/specs/2026-04-05-rounded-corners-normalization.md create mode 100644 docs/superpowers/specs/2026-04-05-test-fixture-seed-design.md create mode 100644 docs/superpowers/specs/2026-04-05-topology-lab-design.md create mode 100644 docs/superpowers/specs/2026-04-06-app-wide-design-language.md create mode 100644 docs/superpowers/specs/2026-04-06-dashboard-drag-resize-design.md create mode 100644 docs/superpowers/specs/2026-04-06-snmp-collections-config-vue.md create mode 100644 docs/superpowers/specs/2026-04-06-surveillance-dashboard-vue.md create mode 100644 docs/superpowers/specs/2026-04-06-surveillance-views-config-vue-design.md create mode 100644 docs/superpowers/specs/2026-04-06-topology-weathermap-design.md create mode 100644 docs/superpowers/specs/2026-04-06-wallboard-config-vue-design.md create mode 100644 docs/superpowers/specs/2026-04-07-topology-enhancements-design.md create mode 100644 docs/superpowers/specs/2026-04-08-topology-time-travel-design.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..8b8e23abc8d7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,149 @@ +# CLAUDE.md + +## DO THE WORK — NO EXCEPTIONS + +This is the #1 rule. Everything else is secondary. + +- **When you change code, build and deploy it immediately.** Do not describe what the user should do. Do not say "now run X". Run it yourself. The change is not done until it is LIVE in the container. +- **When a service is stopped, start it.** When a container is down, bring it up. When a build is needed, run it. Never hand the user a command to run. +- **Trust the user's observations.** When they say something is broken, fix it — do not waste cycles reproducing what they already told you. +- **Do not ask permission for obvious next steps.** Build, deploy, verify, report. If the user says "fix X", that means fix it, deploy it, verify it works, and tell them to hard-refresh. +- **Do not brainstorm, propose options, or over-explain** when the path is clear. Just do it. +- **Fix things right the first time.** Read memories, project docs, and prior conversation before acting. Do not iterate through 3-4 broken attempts. Each rebuild cycle costs minutes and dollars. + +## HARD RULES + +### Git Hygiene + +1. **NEVER commit to `master`, `develop`, or any upstream branch.** All work goes on `feature/*` or `feat/*` branches. +2. **Push ONLY to `fork` remote** (cnewkirk/opennms). Never push to `origin`. +3. **NEVER open PRs against OpenNMS/opennms** without the user explicitly typing "OpenNMS/opennms" in their message. PRs are opened on `cnewkirk/opennms` (fork-to-fork). Unauthorized upstream PRs are irreversible and cause public embarrassment. +4. **Never add Co-Authored-By Claude or credit Claude on commits/PRs.** Never commit Claude-specific files to the repo. +5. **Squash iterative commits** into logical groups before pushing. Maintainers don't want to see the sausage being made. +6. **Git identity:** `Chance Newkirk ` + +### UI Build & Deploy (for any change under `ui/src/`) + +Every UI change MUST follow this exact sequence — no shortcuts, no skipping steps: + +**Bootstrap (fresh clone only):** `ui/target/node/` does not exist until Maven has run. Bootstrap once with: +```bash +cd ui && mvn install -DskipTests -Prun-npm +``` +This downloads the correct Node and Yarn versions into `ui/target/node/`. + +1. `cd ui && ./target/node/yarn/dist/bin/yarn build` + - **NEVER use `npm run build`** — it writes to `ui/dist/` which the deploy script does NOT read. Silent failure. + - Run from the repo root `ui/` directory. The yarn binary lives in `target/node/` (downloaded by Maven). +2. Verify `ui/src/main/dist/index.html` has `src="/opennms/ui/assets/index-*.js"` paths +3. Verify built CSS has no bare `--feather-*` values (must be wrapped in `var()`) +4. `./ui/deploy-to-container.sh test-opennms` (if container is stopped, START IT FIRST) +5. Verify live bundle hash matches built hash: + - `podman exec test-opennms grep -o 'assets/index-[^"]*\.js' /opt/opennms/jetty-webapps/opennms/ui/index.html` + - `grep -o 'assets/index-[^"]*\.js' ui/src/main/dist/index.html` + - Both MUST match. +6. `curl -s -o /dev/null -w "%{http_code}" -L http://localhost:8980/opennms/ui/assets/index-*.js` → must be 200 +7. Tell user to hard refresh (`Cmd+Option+R` in Safari, Shift+click reload in Chrome) + +**Never claim something is fixed without completing ALL steps above.** + +### Containers & Podman + +- **Always use `--privileged`** with podman on macOS. JVM gets "Operation not permitted" without it. +- **Container startup takes ~15-20 seconds.** Cap health-check polling at 12 iterations x 5s = 60s. If not ready, check logs and fail. Never wait 5-10 minutes. +- **Use HTTP health checks**, not `opennms status` (unreliable in containers). +- **Verify overlay images before starting containers.** After `podman build`, spot-check critical files. Treat build warnings as errors. +- **NEVER run OpenNMS natively on macOS** — always use container overlay testing. +- **Set test container password to `notdefault` via Jasypt** (SHA-256, iterations=100000, saltSizeBytes=16). Never PUT plaintext passwords to REST API. +- **JSP changes need full container rebuild+restart** — `podman cp` does NOT trigger JSP recompilation. + +### Testing & Verification + +- **E2E tests against live test harness are MANDATORY before any git push.** No exceptions. +- **Never claim something works without proof.** Show actual output — HTTP codes, test results, live verification. +- **Never remove code outside current task scope.** Fix it or leave it alone. + +## Project Overview + +OpenNMS Horizon — open-source enterprise network monitoring. Version 35.x, Java 17, Maven multi-module (~500+ submodules). AGPL v3. + +## Build Commands + +Ships its own Maven in `maven/bin/mvn`. `compile.pl` and `assemble.pl` wrappers handle Java/Maven detection. + +```bash +# Full build (skip tests, ~15-30 min first time) +./compile.pl -DskipTests + +# Assemble into runnable target directory +./assemble.pl -p dir -DskipTests + +# Single module + deps +./compile.pl -DskipTests --projects :opennms-dao -am install + +# Downstream dependents +./compile.pl -t --projects :opennms-dao -amd install + +# Single test class (cd into module first) +cd features/timeseries && ../../maven/bin/mvn test -Dtest=RingBufferTimeseriesWriterTest + +# License validation (CI tarball) +./compile.pl -DskipTests -Denable.license=true -Passemblies -Psmoke install +``` + +## Architecture + +### Module Structure + +- **`core/`** — Shared libraries (API, IPC, SNMP, Spring, DB schema, web assets) +- **`features/`** — Plugins (timeseries, flows, alarms, collection, discovery, events, topology, Kafka/ES) +- **`opennms-dao/`** / **`opennms-dao-api/`** — Data access (Hibernate/PostgreSQL) +- **`opennms-model/`** — Domain model (nodes, events, alarms) +- **`opennms-services/`** — Core daemons (pollerd, collectd, eventd) +- **`opennms-config*/`** — Config model, JAXB, DAO for XML configs +- **`container/`** — Karaf OSGi container (features XML, assembly, Spring↔Karaf bridge) +- **`opennms-full-assembly/`** — Final Horizon assembly +- **`smoke-test/`** — Selenium smoke tests (Testcontainers, `*IT.java`) + +### Karaf/OSGi + +OpenNMS predates Karaf. Web UI + Spring Security are traditional Spring context; Karaf is embedded above. + +**Adding a new Karaf feature requires 3 places:** +1. Feature definition in `container/features/src/main/resources/features*.xml` +2. `featuresBoot` entry in the corresponding `.cfg` file +3. `` section in `container/karaf/pom.xml` + +Key gotchas: +- `` = "skip if packages already exported" (NOT "required dependency") +- `prerequisite="true"` = force parent to fully load before this feature +- OSGi resolution errors in `karaf.log`: read `Unable to resolve root:` chains backwards + +### IPC (3 patterns, each with JMS/Kafka/gRPC impls) + +- **RPC** — Request/response to Minions +- **Sink** — One-way from Minions to Horizon +- **Twin** — Config sync Horizon→Minions + +### Timeseries + +`features/timeseries/` — TSS integration layer, `RingBufferTimeseriesWriter`. Plugins implement `TimeSeriesStorage`. + +## Branch Strategy + +- **`develop`** — Next major release +- **`release-XX.x`** — Stable releases +- CI auto-merges: `foundation-2024` → `release-33.x` → `develop` +- Tags: `opennms-XX.X.X-1` + +## Code Quality + +- **Checkstyle** (`src/main/resources/nms_checks.xml`): `StandardCharsets.UTF_8` not string literals, `MoreObjects.toStringHelper()` not Spring's +- **CI (CircleCI):** Dynamic config auto-detects changes. Force paths via commit keywords: `!build-deploy`, `!smoke`, `!oci`, `!doc`, `!ui` + +## Key Conventions + +- Maven Enforcer blocks banned/duplicate deps — fix with `` +- Custom Spring/Security forks (patched 4.2.x) +- Apache Servicemix wrappers for OSGi +- Artifact IDs without groupId if unique: `--projects :opennms-dao` diff --git a/docs/superpowers/TODO.md b/docs/superpowers/TODO.md new file mode 100644 index 000000000000..3c0b2f8012cc --- /dev/null +++ b/docs/superpowers/TODO.md @@ -0,0 +1,177 @@ +# OpenNMS Superpowers — TODO + +## API Token Smoke Tests + +The feature is implemented and upstream-PR-ready, but has no smoke test coverage in the +`smoke-test/` module. These need to be written as a follow-up PR after the feature lands. + +### What to write + +**File:** `smoke-test/src/test/java/org/opennms/smoketest/ApiTokenIT.java` + +This should be a standalone REST-based IT (no Selenium, no UI) that uses +`OpenNMSStack.MINIMAL` — same pattern as `NodeRestIT.java` and `RestSessionIT.java`. +No new containers needed; it exercises the REST API directly via `HttpClient` or JAX-RS +`Client` (see `RestClient` for the helper, or raw Apache HttpClient for bearer header +control since `RestClient` is basic-auth-only). + +**Test cases to cover** (mirrors the manual E2E suite we've validated): + +#### Authentication +- Basic auth still works on `/api/v2/apiTokens` → 200 +- Valid bearer token (`onms_...`) → 200 +- Invalid bearer (correct prefix, wrong hash) → 401 +- Malformed bearer (no `onms_` prefix) → 401 +- No `Authorization` header → 401 + +#### Token lifecycle +- `POST /api/v2/apiTokens` returns 201 with `token`, `id`, `createdAt`, `expiresAt` +- Returned `token` starts with `onms_` and is 69 characters +- `lastUsedAt` is null on a fresh token, non-null after the token is used for auth +- `DELETE /api/v2/apiTokens/{id}` returns 204 +- Using a revoked token returns 401 +- Revoking a non-existent token returns 404 + +#### Information leakage +- `GET /api/v2/apiTokens` response does NOT contain `tokenHash` field +- `POST` with `expiresInDays: 9999` returns 400, body does not contain "365" +- `POST` with `expiresInDays: -1` returns 400 + +#### Admin cross-user operations +- Admin can create token for another user (`POST ?username=rtc`) → 201 +- Admin can list another user's tokens (`GET ?username=rtc`) → 200 +- Admin can revoke another user's token (`DELETE /{id}` as admin) → 204 + +#### Bulk revoke +- `DELETE /api/v2/apiTokens?username=admin` → 204 +- Subsequent `GET /api/v2/apiTokens` returns empty list + +### Implementation notes + +- `RestClient` in `smoke-test/src/main/java/org/opennms/smoketest/utils/RestClient.java` + is JAX-RS-based and only supports basic auth. For bearer token tests you'll need to + construct requests manually — use `javax.ws.rs.client.ClientBuilder` with an + `Authorization: Bearer ` header directly, or Apache `HttpClient` (already on + the classpath via `RestSessionIT`). +- The `rtc` user exists in the default OpenNMS install with no `ROLE_REST`. Admin + cross-user tests should use `admin:admin` credentials (basic) for the admin actor. + Token-holder is `rtc` (or create a separate user via REST if you need a ROLE_REST + non-admin for IDOR coverage). +- Token response JSON is not currently backed by a JAXB type in the smoke-test classpath + — parse with Jackson `ObjectMapper` or use `Response.readEntity(Map.class)` via a + `GenericType>`. +- Test cleanup: revoke all tokens for `admin` and `rtc` in `@After`. + +### Where this lives in the build + +The `smoke-test` module is gated behind the `smoke` Maven profile: + +```bash +./compile.pl -DskipTests -Dsmoke --projects :smoke-test -am install +./compile.pl -t -Dsmoke --projects :smoke-test install -Dtest=ApiTokenIT +``` + +The test class must end in `IT` and extend nothing (or `OpenNMSSeleniumIT` if Selenium +is needed, but it isn't here). Mark with `@Category(FlakyTests.class)` only if +genuinely flaky — REST-only tests shouldn't be. + +### Include in the same PR as the feature + +`ApiTokenIT.java` should be part of the upstream feature PR, not a follow-up. +Reviewers will ask for tests if they're absent, and the smoke test framework +runs against the current build — there's no chicken-and-egg problem. +`OpenNMSStack.MINIMAL` spins up a container from the source being reviewed, +so the tests exercise the feature code directly. + +--- + +## Vaadin → Vue: High-Complexity Pages (Deferred) + +These three Vaadin-backed admin pages require significant REST API work and/or complex UI +before a Vue migration is practical. Document here so they aren't forgotten. + +### SNMP Collections Admin (`admin/manageSnmpCollections.jsp`) + +**Current:** Vaadin iframe at `admin/admin-snmp-collections`. Powered by +`features/vaadin-snmp-events-and-metrics` (`SnmpCollectionAdminApplication`). + +**What it does:** Deep hierarchical editor for `datacollection-config.xml` — collections, +groups, MBeans, system definitions. Multi-level tree with inline forms for each node type. + +**Why deferred:** No v2 REST API for reading or writing the collection config. The existing +`/rest/config/datacollection` (v1) is read-only XML. A new writable REST service would be +needed before Vue components are meaningful. The data model is also deeply nested (collection +→ group → MBean → attribute). + +**Prerequisites before Vue migration:** +- New `DataCollectionConfigRestService` (v2) with read/write for collections, groups, MBeans +- Decide on scope: full editor vs. read-only viewer with file upload/download + +--- + +### BSM Admin (`admin/bsm/adminpage.jsp`) + +**Current:** Vaadin application (BSM-specific Vaadin module in `features/bsm/vaadin-adminpage`). + +**What it does:** Graph editor for Business Service topology — create/edit/delete business +services, add edges (IP service, reduction key, child service), set map/reduce functions, +preview impact propagation. + +**Why deferred:** No REST API for BSM CRUD that is suitable for a Vue graph editor. +The BSM domain model is complex (reduction functions, map functions, edge weights, threshold +expressions). A viable Vue replacement would need either a new REST layer or to adapt the +existing `/api/v2/businessservices` endpoints (which are read-heavy and mutation-limited). + +**Prerequisites before Vue migration:** +- Audit existing BSM REST endpoints for completeness +- Graph visualization library selection (topology map pattern, or a dedicated graph lib like vis.js) + +--- + +### Surveillance View Display (`surveillance-view.jsp`, `dashboard.jsp`) + +**Current:** `surveillance-view.jsp` embeds a Vaadin live status matrix +(`vaadin-surveillance-views`); `dashboard.jsp` is the main dashboard wrapping it. + +**What it does:** Live N×M matrix of node categories (rows × columns) showing outage/availability +status per cell. Clicking a cell shows a detail panel with nodes, alarms, notifications, graphs. +Auto-refreshes on a configurable interval. + +**Why deferred:** Substantial real-time data requirements. Each cell requires aggregating +availability data per category intersection — the existing REST (`/rest/availability`) +supports this but needs careful caching/polling. The detail panel has five sub-tables +(nodes, alarms, notifications, graphs, outages) — comparable in scope to the node detail page. + +**Prerequisites before Vue migration:** +- Verify availability API covers row×column intersection queries +- Decide on refresh strategy (polling vs. WebSocket/SSE) +- Consider whether `surveillance-view.jsp` and `dashboard.jsp` should become one Vue route + +--- + +## Dashboard Outages Widget: service name blank + +**Component:** `ui/src/components/Dashboard/widgets/OutagesWidget.vue:57` + +```vue +{{ outage.serviceName }} +``` + +**Root cause:** The OpenNMS v2 REST API (`/api/v2/outages`) serializes `OnmsOutage` +with the service info nested under `monitoredService.serviceType.name`. There is no +flat `serviceName` field in the response — `getServiceType()` is `@JsonIgnore` in the +`OnmsOutage` model. The `Outage` TypeScript type (`ui/src/types/index.ts:302`) declares +`serviceName: string` but this field will always be `undefined` at runtime. + +**Correct path:** `outage.monitoredService.serviceType.name` + +**Same issue in:** `ui/src/components/Nodes/OutagesTable.vue:26` (node detail page). + +**Fix:** Either (a) update the `Outage` TS type to reflect the nested shape and update +both templates, or (b) add a `@XmlElement(name="serviceName") @Transient` convenience +getter to `OnmsOutage.java` (like `getNodeLabel()`) that returns +`getMonitoredService().getServiceType().getName()`. + +Option (b) is smaller and consistent with the existing pattern in `OnmsOutage` +(`getNodeId`, `getNodeLabel`, `getIpAddress` are all `@Transient @XmlElement` helpers +over the same nested object graph). diff --git a/docs/superpowers/plans/2026-04-05-alarms-list-page.md b/docs/superpowers/plans/2026-04-05-alarms-list-page.md new file mode 100644 index 000000000000..b397580498ee --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-alarms-list-page.md @@ -0,0 +1,949 @@ +# Alarms List Page Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a full-featured `/alarms` Vue SPA page with filtering, sorting, pagination, and row-level ack/escalate/clear actions, and wire the Summary widget KPI cards as router-links. + +**Architecture:** Follows the `Nodes.vue` / `NodesTable.vue` pattern — a thin container (`Alarms.vue`) with breadcrumbs and layout, and a self-contained table component (`AlarmsListTable.vue`) that owns all data-fetching, filter state, sort, pagination, and row actions. No new services or types needed. + +**Tech Stack:** Vue 3 ` + + +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /Users/chance/git/opennms/ui && node_modules/.bin/vite build 2>&1 | tail -5 +``` + +Expected: `✓ built in` — no type errors, no missing imports. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/components/Alarms/AlarmsListTable.vue +git commit -m "feat(alarms): add AlarmsListTable component" +``` + +--- + +### Task 3: Build Alarms container page + +**Files:** +- Create: `ui/src/containers/Alarms.vue` + +- [ ] **Step 1: Create the container** + +Create `ui/src/containers/Alarms.vue`: + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /Users/chance/git/opennms/ui && node_modules/.bin/vite build 2>&1 | tail -5 +``` + +Expected: `✓ built in` — no errors. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/containers/Alarms.vue +git commit -m "feat(alarms): add Alarms container page" +``` + +--- + +### Task 4: Wire Summary widget KPI card links + +**Files:** +- Modify: `ui/src/components/Dashboard/widgets/SummaryWidget.vue` + +The three KPI cards become `` wrappers. The `Kpi` interface gets a `to` field. The card `div` is replaced with a `` that has the same class bindings. + +- [ ] **Step 1: Update `SummaryWidget.vue`** + +Replace the entire contents of `ui/src/components/Dashboard/widgets/SummaryWidget.vue` with: + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /Users/chance/git/opennms/ui && node_modules/.bin/vite build 2>&1 | tail -5 +``` + +Expected: `✓ built in` — no errors. + +- [ ] **Step 3: Deploy and verify visually** + +```bash +cd /Users/chance/git/opennms/ui && ./deploy-to-container.sh 2>&1 +``` + +Expected: `Done. Live bundle: assets/index-*.js` + +Open the dashboard in the browser. Verify: +1. All three KPI cards are clickable (pointer cursor on hover, subtle shadow) +2. "Active Alarms" card navigates to `/#/alarms` +3. "Total Nodes" card navigates to `/#/nodes` +4. "Active Outages" card navigates to `/#/outages` (404 page until Outages list is built — expected) +5. The `/alarms` page loads with filter bar, table, and pagination + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/components/Dashboard/widgets/SummaryWidget.vue +git commit -m "feat(alarms): wire summary widget KPI cards as router-links" +``` diff --git a/docs/superpowers/plans/2026-04-05-availability-panel-ip-grouping.md b/docs/superpowers/plans/2026-04-05-availability-panel-ip-grouping.md new file mode 100644 index 000000000000..bb04f34ac22e --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-availability-panel-ip-grouping.md @@ -0,0 +1,83 @@ +# Availability Panel IP Grouping Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reorganize the availability panel's flat service card grid into sections grouped by IP interface address. + +**Architecture:** The template's nested `v-for` is restructured so the outer loop iterates over `ipinterfaces` and renders an IP header label, while the inner loop renders service cards for that interface. The IP address is removed from individual cards since it is now carried by the section header. + +**Tech Stack:** Vue 3 (Composition API, ` + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/components/Outages/OutagesListTable.vue +git commit -m "feat(outages): add OutagesListTable component" +``` + +--- + +## Task 4: Outages container (list page) + +**Files:** +- Create: `ui/src/containers/Outages.vue` + +- [ ] **Step 1: Create the container** + +Create `ui/src/containers/Outages.vue`: + +```vue + + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/containers/Outages.vue +git commit -m "feat(outages): add Outages list container" +``` + +--- + +## Task 5: OutageDetail container (detail page) + +**Files:** +- Create: `ui/src/containers/OutageDetail.vue` + +- [ ] **Step 1: Create the container** + +Create `ui/src/containers/OutageDetail.vue`: + +```vue + + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/containers/OutageDetail.vue +git commit -m "feat(outages): add OutageDetail container" +``` + +--- + +## Task 6: Add Vue router routes + +**Files:** +- Modify: `ui/src/main/router/index.ts` + +- [ ] **Step 1: Add the two routes** + +In `ui/src/main/router/index.ts`, find the block containing the existing `/alarm/:id` route (around line 180). Add the outage routes immediately after it: + +```ts + { + path: '/outages', + name: 'Outages', + component: () => import('@/containers/Outages.vue') + }, + { + path: '/outage/:id', + name: 'Outage Detail', + component: () => import('@/containers/OutageDetail.vue') + }, +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/main/router/index.ts +git commit -m "feat(outages): add /outages and /outage/:id routes" +``` + +--- + +## Task 7: Update menu link + +**Files:** +- Modify: `opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json` + +- [ ] **Step 1: Update the Outages menu entry** + +Find the outages entry (around line 163): + +```json +{ + "id": "outages", + "name": "Outages", + "url": "outage/index.jsp", + "locationMatch": "outage", + "roles": null +}, +``` + +Change `"url"` to: + +```json +{ + "id": "outages", + "name": "Outages", + "url": "ui/index.html#/outages", + "locationMatch": "outage", + "roles": null +}, +``` + +- [ ] **Step 2: Commit** + +```bash +git add opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json +git commit -m "feat(menu): point Outages menu item to Vue SPA route" +``` + +--- + +## Task 8: Build, deploy, and verify + +**Files:** None (build + deploy steps) + +- [ ] **Step 1: Build the Vue SPA** + +```bash +cd /Users/chance/git/opennms/ui +/Users/chance/git/opennms/ui/target/node/yarn/dist/bin/yarn build 2>&1 | tail -20 +``` + +Expected: exit 0, no errors. + +- [ ] **Step 2: Verify built index.html references hashed assets** + +```bash +grep -o 'assets/index-[^"]*\.js' /Users/chance/git/opennms/ui/src/main/dist/index.html +``` + +Expected: one match like `assets/index-AbCdEfGh.js`. + +- [ ] **Step 3: Verify CSS has no bare --feather-* values** + +```bash +grep -c '\-\-feather-' /Users/chance/git/opennms/ui/src/main/dist/assets/*.css || echo "0" +``` + +Expected: 0 (all `--feather-*` must be inside `var()`). + +- [ ] **Step 4: Deploy to container** + +```bash +cd /Users/chance/git/opennms/ui && ./deploy-to-container.sh test-opennms +``` + +Expected: deploy script exits 0. + +- [ ] **Step 5: Verify live bundle hash matches built hash** + +```bash +podman exec test-opennms grep -o 'assets/index-[^"]*\.js' /opt/opennms/jetty-webapps/opennms/ui/index.html +grep -o 'assets/index-[^"]*\.js' /Users/chance/git/opennms/ui/src/main/dist/index.html +``` + +Both lines must be identical. + +- [ ] **Step 6: Verify the JS bundle is served** + +```bash +HASH=$(grep -o 'assets/index-[^"]*\.js' /Users/chance/git/opennms/ui/src/main/dist/index.html) +curl -s -o /dev/null -w "%{http_code}" -L "http://localhost:8980/opennms/ui/$HASH" +``` + +Expected: `200` + +- [ ] **Step 7: Smoke-test the list route** + +```bash +curl -s -o /dev/null -w "%{http_code}" -u admin:notdefault \ + "http://localhost:8980/opennms/api/v2/outages?limit=1" +``` + +Expected: `200` or `204` (confirms the API the Vue list calls is reachable) + +- [ ] **Step 8: Tell user to verify in browser** + +Navigate to `http://localhost:8980/opennms/ui/#/outages` — verify: +- List loads with Current filter active +- Clicking a row navigates to `/outage/:id` and shows the detail card +- Node links navigate to the Vue NodeDetails page +- Menu "Outages" item navigates to the Vue list + +Hard-refresh with `Cmd+Shift+R` before testing. diff --git a/docs/superpowers/plans/2026-04-05-perses-phase1-foundation.md b/docs/superpowers/plans/2026-04-05-perses-phase1-foundation.md new file mode 100644 index 000000000000..bb4e1f5e59b3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-perses-phase1-foundation.md @@ -0,0 +1,1635 @@ +# Perses Phase 1 — Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up the Java storage layer (`onms_dashboards` table + REST API) and the frontend infrastructure (OpenNMS Perses datasource plugin, React mount composable, theme bridge, `PersesPanel`/`PersesCanvas` Vue components) that all subsequent phases depend on. + +**Architecture:** A new `onms_dashboards` DB table stores Perses dashboard JSON, accessed via a JAX-RS v2 REST service. In the Vue SPA, `ReactDOM.createRoot` mounts Perses React panels into Vue-managed container divs. A theme bridge reads Feather DS CSS custom properties at mount time and builds the MUI theme. An OpenNMS-specific Perses datasource plugin translates `OpenNMSQuery` objects into `/rest/measurements` calls. + +**Tech Stack:** Java 17, Hibernate 3 (JPA annotations), CXF JAX-RS v2, Liquibase, Vue 3, TypeScript, React 18, `@perses-dev/core`, `@perses-dev/panels-plugin`, `@perses-dev/dashboards`, `@perses-dev/plugin-system`, MUI 5, Vitest + +--- + +## File Map + +**New Java files:** +- `core/schema/src/main/liquibase/36.0.0/changelog.xml` — DB migration +- `opennms-model/src/main/java/org/opennms/netmgt/model/OnmsDashboard.java` — Hibernate entity +- `opennms-dao-api/src/main/java/org/opennms/netmgt/dao/api/OnmsDashboardDao.java` — DAO interface +- `opennms-dao/src/main/java/org/opennms/netmgt/dao/hibernate/OnmsDashboardDaoHibernate.java` — Hibernate impl +- `opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java` — JAX-RS REST +- `opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java` — integration test + +**Modified Java files:** +- `core/schema/src/main/liquibase/changelog.xml` — add 36.0.0 include +- `opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml` — register DAO bean + +**New frontend files:** +- `ui/src/datasource/opennms/types.ts` — OpenNMSQuery type +- `ui/src/datasource/opennms/client.ts` — /rest/measurements fetch client +- `ui/src/datasource/opennms/plugin.ts` — Perses DataSourcePlugin implementation +- `ui/src/datasource/opennms/QueryEditor.tsx` — React query editor component +- `ui/src/datasource/opennms/index.ts` — plugin registration export +- `ui/src/composables/usePerses.ts` — ReactDOM.createRoot mount composable +- `ui/src/theme/persesTheme.ts` — Feather DS → MUI theme bridge +- `ui/src/components/Perses/PersesPanel.vue` — single panel Vue wrapper +- `ui/src/components/Perses/PersesCanvas.vue` — full dashboard Vue wrapper + +**New frontend tests:** +- `ui/tests/datasource/opennms-client.test.ts` +- `ui/tests/datasource/opennms-plugin.test.ts` +- `ui/tests/composables/usePerses.test.ts` +- `ui/tests/theme/persesTheme.test.ts` + +**Modified frontend files:** +- `ui/package.json` — add Perses + React deps + +--- + +## Task 1: Liquibase Migration + +**Files:** +- Create: `core/schema/src/main/liquibase/36.0.0/changelog.xml` +- Modify: `core/schema/src/main/liquibase/changelog.xml` + +- [ ] **Step 1: Create the 36.0.0 directory and changelog** + +```bash +mkdir -p core/schema/src/main/liquibase/36.0.0 +``` + +Create `core/schema/src/main/liquibase/36.0.0/changelog.xml`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 2: Register in master changelog** + +In `core/schema/src/main/liquibase/changelog.xml`, add after the `35.0.0` include line: + +```xml + +``` + +- [ ] **Step 3: Commit** + +```bash +git add core/schema/src/main/liquibase/36.0.0/changelog.xml \ + core/schema/src/main/liquibase/changelog.xml +git commit -m "feat(schema): add onms_dashboards table for Perses dashboard storage" +``` + +--- + +## Task 2: OnmsDashboard Hibernate Entity + +**Files:** +- Create: `opennms-model/src/main/java/org/opennms/netmgt/model/OnmsDashboard.java` + +The `org.opennms.netmgt.model` package is already in the Hibernate session factory's `packagesToScan` list (`applicationContext-shared.xml:40`), so no additional registration is needed. + +- [ ] **Step 1: Create the entity** + +```java +// opennms-model/src/main/java/org/opennms/netmgt/model/OnmsDashboard.java +package org.opennms.netmgt.model; + +import java.io.Serializable; +import java.util.Date; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; + +import com.google.common.base.MoreObjects; + +@Entity +@Table(name = "onms_dashboards") +@XmlRootElement(name = "dashboard") +@XmlAccessorType(XmlAccessType.NONE) +public class OnmsDashboard implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "id", length = 36, nullable = false) + private String id; + + @Column(name = "name", length = 255, nullable = false) + private String name; + + @Column(name = "description", length = 1024) + private String description; + + @Column(name = "spec", nullable = false, columnDefinition = "text") + private String spec; + + @Column(name = "created_by", length = 255) + private String createdBy; + + @Column(name = "created_at", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date createdAt; + + @Column(name = "updated_at", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date updatedAt; + + @PrePersist + protected void onCreate() { + final Date now = new Date(); + if (createdAt == null) createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = new Date(); + } + + // --- getters / setters --- + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getSpec() { return spec; } + public void setSpec(String spec) { this.spec = spec; } + + public String getCreatedBy() { return createdBy; } + public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } + + public Date getCreatedAt() { return createdAt; } + public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; } + + public Date getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("name", name) + .toString(); + } +} +``` + +- [ ] **Step 2: Compile the model module** + +```bash +./compile.pl -DskipTests --projects :opennms-model install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 3: Commit** + +```bash +git add opennms-model/src/main/java/org/opennms/netmgt/model/OnmsDashboard.java +git commit -m "feat(model): add OnmsDashboard Hibernate entity" +``` + +--- + +## Task 3: OnmsDashboardDao Interface + +**Files:** +- Create: `opennms-dao-api/src/main/java/org/opennms/netmgt/dao/api/OnmsDashboardDao.java` + +- [ ] **Step 1: Create the DAO interface** + +```java +// opennms-dao-api/src/main/java/org/opennms/netmgt/dao/api/OnmsDashboardDao.java +package org.opennms.netmgt.dao.api; + +import org.opennms.netmgt.model.OnmsDashboard; + +public interface OnmsDashboardDao extends OnmsDao { +} +``` + +- [ ] **Step 2: Compile** + +```bash +./compile.pl -DskipTests --projects :opennms-dao-api install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 3: Commit** + +```bash +git add opennms-dao-api/src/main/java/org/opennms/netmgt/dao/api/OnmsDashboardDao.java +git commit -m "feat(dao-api): add OnmsDashboardDao interface" +``` + +--- + +## Task 4: OnmsDashboardDaoHibernate + Spring Registration + +**Files:** +- Create: `opennms-dao/src/main/java/org/opennms/netmgt/dao/hibernate/OnmsDashboardDaoHibernate.java` +- Modify: `opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml` + +- [ ] **Step 1: Create the Hibernate implementation** + +```java +// opennms-dao/src/main/java/org/opennms/netmgt/dao/hibernate/OnmsDashboardDaoHibernate.java +package org.opennms.netmgt.dao.hibernate; + +import org.opennms.netmgt.dao.api.OnmsDashboardDao; +import org.opennms.netmgt.model.OnmsDashboard; + +public class OnmsDashboardDaoHibernate extends AbstractDaoHibernate + implements OnmsDashboardDao { + + public OnmsDashboardDaoHibernate() { + super(OnmsDashboard.class); + } +} +``` + +- [ ] **Step 2: Register the Spring bean** + +In `opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml`, add after the `memoDao` bean (around line 122): + +```xml + + + + +``` + +- [ ] **Step 3: Compile the dao module** + +```bash +./compile.pl -DskipTests --projects :opennms-dao install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit** + +```bash +git add opennms-dao/src/main/java/org/opennms/netmgt/dao/hibernate/OnmsDashboardDaoHibernate.java \ + opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml +git commit -m "feat(dao): add OnmsDashboardDaoHibernate + Spring bean registration" +``` + +--- + +## Task 5: DashboardRestService + +**Files:** +- Create: `opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java` + +The CXF v2 server (`applicationContext-cxf-rest-v2.xml:74`) auto-discovers all `@Component @Path` classes in `org.opennms.web.rest.v2` — no XML registration needed. + +- [ ] **Step 1: Create the REST service** + +```java +// opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java +package org.opennms.web.rest.v2; + +import java.net.URI; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriInfo; + +import io.swagger.v3.oas.annotations.tags.Tag; +import org.opennms.netmgt.dao.api.OnmsDashboardDao; +import org.opennms.netmgt.model.OnmsDashboard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Path("dashboards") +@Transactional +@Tag(name = "Dashboards", description = "Perses Dashboard Storage API") +public class DashboardRestService { + + private static final Logger LOG = LoggerFactory.getLogger(DashboardRestService.class); + + @Autowired + private OnmsDashboardDao dashboardDao; + + /** List all dashboards — returns lightweight summaries (no spec). */ + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response list() { + final List summaries = dashboardDao.findAll().stream() + .map(DashboardSummary::from) + .collect(Collectors.toList()); + return Response.ok(summaries).build(); + } + + /** Get one dashboard including full spec. */ + @GET + @Path("{id}") + @Produces(MediaType.APPLICATION_JSON) + public Response get(@PathParam("id") final String id) { + final OnmsDashboard dashboard = dashboardDao.get(id); + if (dashboard == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + return Response.ok(dashboard).build(); + } + + /** Create a new dashboard. */ + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response create(@Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + final OnmsDashboard dashboard) { + dashboard.setId(UUID.randomUUID().toString()); + dashboard.setCreatedBy(securityContext.getUserPrincipal() != null + ? securityContext.getUserPrincipal().getName() : "anonymous"); + dashboard.setCreatedAt(new Date()); + dashboard.setUpdatedAt(new Date()); + dashboardDao.save(dashboard); + final URI location = uriInfo.getAbsolutePathBuilder() + .path(dashboard.getId()).build(); + return Response.created(location).entity(dashboard).build(); + } + + /** Replace a dashboard spec. */ + @PUT + @Path("{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response update(@PathParam("id") final String id, + final OnmsDashboard incoming) { + final OnmsDashboard existing = dashboardDao.get(id); + if (existing == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + existing.setName(incoming.getName()); + existing.setDescription(incoming.getDescription()); + existing.setSpec(incoming.getSpec()); + existing.setUpdatedAt(new Date()); + dashboardDao.saveOrUpdate(existing); + return Response.ok(existing).build(); + } + + /** Delete a dashboard. */ + @DELETE + @Path("{id}") + public Response delete(@PathParam("id") final String id) { + final OnmsDashboard dashboard = dashboardDao.get(id); + if (dashboard == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + dashboardDao.delete(dashboard); + return Response.noContent().build(); + } + + // --- Summary DTO (no spec field) --- + + public static class DashboardSummary { + public String id; + public String name; + public String description; + public String createdBy; + public Date createdAt; + public Date updatedAt; + + public static DashboardSummary from(final OnmsDashboard d) { + final DashboardSummary s = new DashboardSummary(); + s.id = d.getId(); + s.name = d.getName(); + s.description = d.getDescription(); + s.createdBy = d.getCreatedBy(); + s.createdAt = d.getCreatedAt(); + s.updatedAt = d.getUpdatedAt(); + return s; + } + } +} +``` + +- [ ] **Step 2: Compile webapp-rest** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp-rest install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 3: Commit** + +```bash +git add opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java +git commit -m "feat(rest): add DashboardRestService at /rest/dashboards" +``` + +--- + +## Task 6: DashboardRestService Integration Test + +**Files:** +- Create: `opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java` + +- [ ] **Step 1: Write the failing test** + +```java +// opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java +package org.opennms.web.rest.v2; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +import javax.ws.rs.core.MediaType; + +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; + +@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/applicationContext-databasePopulator.xml", + "classpath:/META-INF/opennms/applicationContext-mockEventIpcManager.xml", + "classpath:META-INF/opennms/applicationContext-jersey-test.xml" +}) +@JUnitConfigurationEnvironment +@JUnitTemporaryDatabase +public class DashboardRestServiceIT extends AbstractSpringJerseyRestTestCase { + + @Override + protected void afterServletStart() { + MockLogAppender.setupLogging(true, "DEBUG"); + } + + @Test + public void testCreateAndRetrieve() throws Exception { + // Create + final String body = "{\"name\":\"My Dashboard\",\"description\":\"test\",\"spec\":\"{\\\"panels\\\":[]}\"}"; + final String location = sendPost("/dashboards", body, 201, MediaType.APPLICATION_JSON); + assertThat(location, containsString("/rest/dashboards/")); + + // Extract ID from Location header + final String id = location.substring(location.lastIndexOf('/') + 1); + + // GET by ID — spec is present + final String json = sendRequest(GET, "/dashboards/" + id, 200); + assertThat(json, containsString("My Dashboard")); + assertThat(json, containsString("panels")); + + // GET list — spec is NOT present in summary + final String list = sendRequest(GET, "/dashboards", 200); + assertThat(list, containsString("My Dashboard")); + assertThat(list, not(containsString("panels"))); + } + + @Test + public void testUpdateAndDelete() throws Exception { + final String body = "{\"name\":\"ToUpdate\",\"spec\":\"{}\"}"; + final String location = sendPost("/dashboards", body, 201, MediaType.APPLICATION_JSON); + final String id = location.substring(location.lastIndexOf('/') + 1); + + // Update + final String updated = "{\"name\":\"Updated\",\"spec\":\"{\\\"v\\\":2}\"}"; + sendRequest(PUT, "/dashboards/" + id, updated, 200); + final String json = sendRequest(GET, "/dashboards/" + id, 200); + assertThat(json, containsString("Updated")); + + // Delete + sendRequest(DELETE, "/dashboards/" + id, 204); + sendRequest(GET, "/dashboards/" + id, 404); + } +} +``` + +- [ ] **Step 2: Run the test — verify it fails (no table yet in test DB)** + +```bash +cd opennms-webapp-rest && \ + ../../maven/bin/mvn test -Dtest=DashboardRestServiceIT -t 1 +``` + +Expected: test failure due to missing table (Liquibase hasn't run in test context yet). If test runner auto-applies Liquibase, the test may pass. Either way, note the output. + +- [ ] **Step 3: Run with full dependency build to ensure Liquibase applies** + +```bash +./compile.pl -t --projects :opennms-webapp-rest install +``` + +Expected: `DashboardRestServiceIT` PASSES. Build SUCCESS. + +- [ ] **Step 4: Commit** + +```bash +git add opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java +git commit -m "test(rest): add DashboardRestServiceIT integration tests" +``` + +--- + +## Task 7: Install Perses + React Dependencies + +**Files:** +- Modify: `ui/package.json` + +- [ ] **Step 1: Add dependencies** + +In `ui/package.json`, add to `"dependencies"`: + +```json + "@emotion/react": "^11.13.0", + "@emotion/styled": "^11.13.0", + "@mui/material": "^5.16.0", + "@perses-dev/core": "^0.51.0", + "@perses-dev/components": "^0.51.0", + "@perses-dev/dashboards": "^0.51.0", + "@perses-dev/panels-plugin": "^0.51.0", + "@perses-dev/plugin-system": "^0.51.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", +``` + +Add to `"devDependencies"`: + +```json + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", +``` + +- [ ] **Step 2: Install** + +```bash +cd ui && yarn install +``` + +Expected: lockfile updated, no peer dependency errors. If Perses pins an incompatible MUI version, adjust `@mui/material` to match what Perses requires (check `node_modules/@perses-dev/components/package.json`). + +- [ ] **Step 3: Verify TypeScript can import Perses types** + +```bash +cd ui && node -e "const p = require('@perses-dev/core'); console.log(Object.keys(p).slice(0,5))" +``` + +Expected: prints some exported type names without error. + +- [ ] **Step 4: Commit** + +```bash +git add ui/package.json ui/yarn.lock +git commit -m "feat(ui): add @perses-dev/* and react 18 dependencies" +``` + +--- + +## Task 8: OpenNMS Datasource Types + +**Files:** +- Create: `ui/src/datasource/opennms/types.ts` + +- [ ] **Step 1: Create the types file** + +```typescript +// ui/src/datasource/opennms/types.ts + +/** A single metric query sent to /rest/measurements */ +export interface OpenNMSQuerySpec { + /** e.g. "node[1].interfaceSnmp[eth0-000000000000]" */ + resourceId: string + /** RRD attribute name, e.g. "ifInOctets" */ + attribute: string + /** Aggregation function */ + aggregation: 'AVERAGE' | 'MIN' | 'MAX' | 'LAST' + /** Optional label override (defaults to attribute) */ + label?: string + /** JEXL expression — present on CDEF metrics, absent on DEF metrics */ + expression?: string + /** If true, fetch but don't render (used in CDEF chains) */ + transient?: boolean +} + +/** Shape of a single source entry in the /rest/measurements request body */ +export interface MeasurementsSource { + aggregation: string + attribute: string + label: string + resourceId: string + transient: boolean +} + +/** Shape of a JEXL expression entry in the /rest/measurements request body */ +export interface MeasurementsExpression { + value: string + label: string + transient: boolean +} + +/** POST body for /rest/measurements */ +export interface MeasurementsPayload { + start: number + end: number + step: number + source: MeasurementsSource[] + expression?: MeasurementsExpression[] +} + +/** Column of values in the /rest/measurements response */ +export interface MeasurementsColumn { + values: (number | null)[] +} + +/** Response from /rest/measurements */ +export interface MeasurementsResponse { + start: number + end: number + step: number + timestamps: number[] + labels: string[] + columns: MeasurementsColumn[] +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep -i "datasource" || echo "OK" +``` + +Expected: no errors mentioning `datasource/opennms/types.ts` + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/datasource/opennms/types.ts +git commit -m "feat(datasource): add OpenNMS query and measurements types" +``` + +--- + +## Task 9: OpenNMS Measurements Client + +**Files:** +- Create: `ui/src/datasource/opennms/client.ts` +- Create: `ui/tests/datasource/opennms-client.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/datasource/opennms-client.test.ts +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { fetchMeasurements } from '@/datasource/opennms/client' +import type { MeasurementsPayload, MeasurementsResponse } from '@/datasource/opennms/types' + +const mockResponse: MeasurementsResponse = { + start: 1000000, + end: 2000000, + step: 300000, + timestamps: [1000000, 1300000, 1600000, 1900000], + labels: ['ifInOctets'], + columns: [{ values: [10.0, 20.0, 15.0, 25.0] }] +} + +describe('fetchMeasurements', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse) + })) + }) + + test('POSTs to /rest/measurements with correct body', async () => { + const payload: MeasurementsPayload = { + start: 1000000, + end: 2000000, + step: 300000, + source: [{ aggregation: 'AVERAGE', attribute: 'ifInOctets', label: 'ifInOctets', resourceId: 'node[1].interfaceSnmp[eth0]', transient: false }] + } + + const result = await fetchMeasurements(payload) + + expect(fetch).toHaveBeenCalledWith('/rest/measurements', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(payload) + }) + expect(result.labels).toEqual(['ifInOctets']) + expect(result.columns[0].values).toHaveLength(4) + }) + + test('throws on HTTP error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })) + await expect(fetchMeasurements({ start: 0, end: 1, step: 300000, source: [] })) + .rejects.toThrow('Measurements request failed: 500') + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/datasource/opennms-client.test.ts +``` + +Expected: FAIL — `fetchMeasurements` not found + +- [ ] **Step 3: Implement the client** + +```typescript +// ui/src/datasource/opennms/client.ts +import type { MeasurementsPayload, MeasurementsResponse } from './types' + +/** + * POST to /rest/measurements and return the response. + * Auth is handled by session cookies (same-origin request). + */ +export async function fetchMeasurements(payload: MeasurementsPayload): Promise { + const response = await fetch('/rest/measurements', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(payload) + }) + + if (!response.ok) { + throw new Error(`Measurements request failed: ${response.status}`) + } + + return response.json() as Promise +} +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/datasource/opennms-client.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/datasource/opennms/client.ts \ + ui/tests/datasource/opennms-client.test.ts +git commit -m "feat(datasource): add fetchMeasurements client with tests" +``` + +--- + +## Task 10: OpenNMS Perses Datasource Plugin + +**Files:** +- Create: `ui/src/datasource/opennms/plugin.ts` +- Create: `ui/tests/datasource/opennms-plugin.test.ts` + +This implements the Perses `TimeSeriesQueryPlugin` interface. The plugin receives an `OpenNMSQuerySpec` plus time range context, fetches measurements, and returns `TimeSeriesData`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/datasource/opennms-plugin.test.ts +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { OpenNMSTimeSeriesQueryPlugin } from '@/datasource/opennms/plugin' +import * as client from '@/datasource/opennms/client' +import type { MeasurementsResponse } from '@/datasource/opennms/types' + +const mockMeasurements: MeasurementsResponse = { + start: 1700000000000, + end: 1700003600000, + step: 300000, + timestamps: [1700000000000, 1700000300000], + labels: ['ifInOctets'], + columns: [{ values: [100.0, 200.0] }] +} + +describe('OpenNMSTimeSeriesQueryPlugin', () => { + beforeEach(() => { + vi.spyOn(client, 'fetchMeasurements').mockResolvedValue(mockMeasurements) + }) + + test('maps measurements response to Perses TimeSeriesData', async () => { + const result = await OpenNMSTimeSeriesQueryPlugin.getTimeSeriesData( + { + resourceId: 'node[1].interfaceSnmp[eth0]', + attribute: 'ifInOctets', + aggregation: 'AVERAGE' + }, + { + timeRange: { start: new Date(1700000000000), end: new Date(1700003600000) }, + suggestedStepMs: 300000, + datasource: undefined + } + ) + + expect(result.series).toHaveLength(1) + expect(result.series[0].name).toBe('ifInOctets') + // Perses values are [timestamp_seconds, value] + expect(result.series[0].values[0]).toEqual([1700000000, 100.0]) + expect(result.series[0].values[1]).toEqual([1700000300, 200.0]) + }) + + test('passes null values through as null', async () => { + vi.spyOn(client, 'fetchMeasurements').mockResolvedValue({ + ...mockMeasurements, + columns: [{ values: [null, 200.0] }] + }) + + const result = await OpenNMSTimeSeriesQueryPlugin.getTimeSeriesData( + { resourceId: 'x', attribute: 'y', aggregation: 'AVERAGE' }, + { timeRange: { start: new Date(1700000000000), end: new Date(1700003600000) }, suggestedStepMs: 300000, datasource: undefined } + ) + + expect(result.series[0].values[0][1]).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/datasource/opennms-plugin.test.ts +``` + +Expected: FAIL + +- [ ] **Step 3: Implement the plugin** + +```typescript +// ui/src/datasource/opennms/plugin.ts +import type { TimeSeriesQueryPlugin, TimeSeriesData, TimeSeriesQueryContext } from '@perses-dev/core' +import { fetchMeasurements } from './client' +import type { OpenNMSQuerySpec, MeasurementsPayload, MeasurementsSource } from './types' + +export const OpenNMSTimeSeriesQueryPlugin: TimeSeriesQueryPlugin = { + async getTimeSeriesData( + spec: OpenNMSQuerySpec, + context: TimeSeriesQueryContext + ): Promise { + const { timeRange, suggestedStepMs } = context + const start = timeRange.start.getTime() + const end = timeRange.end.getTime() + const step = Math.max(suggestedStepMs ?? 300000, 60000) + + const payload: MeasurementsPayload = { + start, + end, + step, + source: [] + } + + if (spec.expression) { + // CDEF metric — use expression array + payload.source = [] + payload.expression = [{ + value: spec.expression, + label: spec.label ?? spec.attribute, + transient: spec.transient ?? false + }] + } else { + const source: MeasurementsSource = { + aggregation: spec.aggregation, + attribute: spec.attribute, + label: spec.label ?? spec.attribute, + resourceId: spec.resourceId, + transient: spec.transient ?? false + } + payload.source = [source] + } + + const response = await fetchMeasurements(payload) + + // Map to Perses TimeSeriesData format + // Perses expects values as [unix_timestamp_seconds, value | null][] + const series = response.labels.map((label, colIdx) => ({ + name: label, + values: response.timestamps.map((ts, rowIdx) => [ + ts / 1000, // ms → seconds + response.columns[colIdx]?.values[rowIdx] ?? null + ] as [number, number | null]) + })) + + return { + timeRange: { start: timeRange.start, end: timeRange.end }, + series + } + } +} +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/datasource/opennms-plugin.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/datasource/opennms/plugin.ts \ + ui/tests/datasource/opennms-plugin.test.ts +git commit -m "feat(datasource): implement OpenNMS Perses TimeSeriesQueryPlugin" +``` + +--- + +## Task 11: QueryEditor React Component + +**Files:** +- Create: `ui/src/datasource/opennms/QueryEditor.tsx` + +This is the React component Perses renders in the dashboard editor's query section. It lets users pick a resource and metric. + +Note: Ensure `ui/tsconfig.json` (or `vite.config.ts`) has `"jsx": "react-jsx"` or the equivalent Vite React plugin configured. + +- [ ] **Step 1: Add React JSX support to Vite config** + +Check `ui/vite.config.ts`. If `@vitejs/plugin-vue` is present but `@vitejs/plugin-react` is not, add it: + +```bash +cd ui && yarn add -D @vitejs/plugin-react +``` + +In `ui/vite.config.ts`, add: + +```typescript +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [ + vue(), + react() // add this line + ], + // ... +}) +``` + +- [ ] **Step 2: Create the QueryEditor component** + +```tsx +// ui/src/datasource/opennms/QueryEditor.tsx +import React, { useState } from 'react' +import TextField from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' +import Box from '@mui/material/Box' +import type { OpenNMSQuerySpec } from './types' + +interface QueryEditorProps { + value: OpenNMSQuerySpec + onChange: (spec: OpenNMSQuerySpec) => void +} + +const AGGREGATIONS = ['AVERAGE', 'MIN', 'MAX', 'LAST'] as const + +export const QueryEditor: React.FC = ({ value, onChange }) => { + const [spec, setSpec] = useState(value) + + const update = (patch: Partial) => { + const updated = { ...spec, ...patch } + setSpec(updated) + onChange(updated) + } + + return ( + + update({ resourceId: e.target.value })} + helperText="e.g. node[1].interfaceSnmp[eth0-000000000000]" + fullWidth + /> + update({ attribute: e.target.value })} + helperText="e.g. ifInOctets" + fullWidth + /> + update({ aggregation: e.target.value as OpenNMSQuerySpec['aggregation'] })} + fullWidth + > + {AGGREGATIONS.map(agg => ( + {agg} + ))} + + update({ label: e.target.value || undefined })} + fullWidth + /> + + ) +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "QueryEditor" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/datasource/opennms/QueryEditor.tsx ui/vite.config.ts ui/package.json ui/yarn.lock +git commit -m "feat(datasource): add QueryEditor React component + Vite React plugin" +``` + +--- + +## Task 12: Datasource Plugin Registration + +**Files:** +- Create: `ui/src/datasource/opennms/index.ts` + +- [ ] **Step 1: Create the index** + +```typescript +// ui/src/datasource/opennms/index.ts +import { QueryEditor } from './QueryEditor' +import { OpenNMSTimeSeriesQueryPlugin } from './plugin' + +export const OPENNMS_DATASOURCE_KIND = 'OpenNMSTimeSeries' as const + +/** + * Perses plugin definition for OpenNMS time series queries. + * Register this with the Perses PluginRegistry at app startup. + */ +export const OpenNMSPlugin = { + kind: OPENNMS_DATASOURCE_KIND, + plugin: OpenNMSTimeSeriesQueryPlugin, + queryEditor: QueryEditor +} + +export { OpenNMSTimeSeriesQueryPlugin } from './plugin' +export { QueryEditor } from './QueryEditor' +export type { OpenNMSQuerySpec } from './types' +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/datasource/opennms/index.ts +git commit -m "feat(datasource): add OpenNMS Perses plugin registration index" +``` + +--- + +## Task 13: usePerses Composable + +**Files:** +- Create: `ui/src/composables/usePerses.ts` +- Create: `ui/tests/composables/usePerses.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/composables/usePerses.test.ts +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' +import { ref, nextTick } from 'vue' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' + +// Mock ReactDOM to avoid JSDOM React rendering issues in tests +vi.mock('react-dom/client', () => ({ + createRoot: vi.fn(() => ({ + render: vi.fn(), + unmount: vi.fn() + })) +})) +vi.mock('react', () => ({ + createElement: vi.fn(() => ({})), + default: { createElement: vi.fn(() => ({})) } +})) + +import { usePerses } from '@/composables/usePerses' +import * as ReactDOM from 'react-dom/client' + +describe('usePerses', () => { + test('creates root when container ref is set', async () => { + const containerRef = ref(null) + const specRef = ref({ kind: 'TimeSeriesChart', spec: {} }) + + mount(defineComponent({ + setup() { + usePerses(containerRef, specRef, vi.fn()) + return {} + }, + template: '
' + })) + + const div = document.createElement('div') + containerRef.value = div + await nextTick() + + expect(ReactDOM.createRoot).toHaveBeenCalledWith(div) + }) + + test('unmounts on component unmount', async () => { + const mockUnmount = vi.fn() + vi.mocked(ReactDOM.createRoot).mockReturnValue({ render: vi.fn(), unmount: mockUnmount } as any) + + const containerRef = ref(null) + const specRef = ref({ kind: 'TimeSeriesChart', spec: {} }) + + const wrapper = mount(defineComponent({ + setup() { + usePerses(containerRef, specRef, vi.fn()) + return {} + }, + template: '
' + })) + + containerRef.value = document.createElement('div') + await nextTick() + + wrapper.unmount() + expect(mockUnmount).toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/composables/usePerses.test.ts +``` + +Expected: FAIL + +- [ ] **Step 3: Implement the composable** + +```typescript +// ui/src/composables/usePerses.ts +import { watch, onUnmounted, type Ref } from 'vue' +import { createRoot, type Root } from 'react-dom/client' +import { createElement, type ComponentType } from 'react' + +type PanelSpec = { kind: string; spec: Record } + +/** + * Mounts a React Perses component into a Vue-managed container element. + * + * @param containerRef - ref to the DOM element to mount into + * @param specRef - reactive panel spec; re-renders React when changed + * @param renderFn - called with (createElement, spec) — returns the React element to render + * + * Usage: + * const containerRef = ref(null) + * usePerses(containerRef, specRef, (h, spec) => h(PersesPanel, { spec, datasource })) + */ +export function usePerses( + containerRef: Ref, + specRef: Ref, + renderFn: (spec: PanelSpec) => ReturnType +): void { + let root: Root | null = null + + watch([containerRef, specRef], ([el, spec]) => { + if (!el) return + if (!root) { + root = createRoot(el) + } + root.render(renderFn(spec)) + }, { deep: true }) + + onUnmounted(() => { + root?.unmount() + root = null + }) +} +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/composables/usePerses.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/composables/usePerses.ts \ + ui/tests/composables/usePerses.test.ts +git commit -m "feat(composable): add usePerses ReactDOM mount composable" +``` + +--- + +## Task 14: Feather DS → MUI Theme Bridge + +**Files:** +- Create: `ui/src/theme/persesTheme.ts` +- Create: `ui/tests/theme/persesTheme.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/theme/persesTheme.test.ts +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { buildPersesTheme } from '@/theme/persesTheme' + +describe('buildPersesTheme', () => { + beforeEach(() => { + // Mock getComputedStyle to return known Feather DS values + vi.stubGlobal('getComputedStyle', () => ({ + getPropertyValue: (prop: string) => { + const map: Record = { + '--feather-color-scheme': ' light', + '--feather-primary-interactive-default': ' #6200ee', + '--feather-background': ' #ffffff', + '--feather-surface-fill': ' #f5f5f5', + '--feather-text-color': ' #212121' + } + return map[prop] ?? '' + } + })) + }) + + test('builds light theme from Feather CSS vars', () => { + const theme = buildPersesTheme() + expect(theme.palette.mode).toBe('light') + expect(theme.palette.primary?.main).toBe('#6200ee') + expect(theme.palette.background?.default).toBe('#ffffff') + }) + + test('returns dark theme when open-dark class is set', () => { + vi.stubGlobal('getComputedStyle', () => ({ + getPropertyValue: (prop: string) => (prop === '--feather-color-scheme' ? ' dark' : ' #000000') + })) + const theme = buildPersesTheme() + expect(theme.palette.mode).toBe('dark') + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/theme/persesTheme.test.ts +``` + +Expected: FAIL + +- [ ] **Step 3: Implement the theme bridge** + +```typescript +// ui/src/theme/persesTheme.ts +import { createTheme, type Theme } from '@mui/material/styles' + +/** + * Builds a Perses/MUI theme by reading Feather DS CSS custom properties + * from the computed style of document.body at call time. + * + * Call this at React subtree mount time (and re-call when dark mode changes). + */ +export function buildPersesTheme(): Theme { + const style = getComputedStyle(document.body) + const get = (prop: string) => style.getPropertyValue(prop).trim() + + const mode = get('--feather-color-scheme') === 'dark' ? 'dark' : 'light' + + return createTheme({ + palette: { + mode, + primary: { + main: get('--feather-primary-interactive-default') || '#1976d2' + }, + background: { + default: get('--feather-background') || (mode === 'dark' ? '#121212' : '#ffffff'), + paper: get('--feather-surface-fill') || (mode === 'dark' ? '#1e1e1e' : '#f5f5f5') + }, + text: { + primary: get('--feather-text-color') || (mode === 'dark' ? '#ffffff' : '#212121') + } + } + }) +} +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/theme/persesTheme.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/theme/persesTheme.ts \ + ui/tests/theme/persesTheme.test.ts +git commit -m "feat(theme): add Feather DS → MUI theme bridge for Perses" +``` + +--- + +## Task 15: PersesPanel Vue Component + +**Files:** +- Create: `ui/src/components/Perses/PersesPanel.vue` + +Mounts a single Perses `TimeSeriesChart` panel (React) into a Vue-managed div. Used by Resource Graphs (Phase 2). + +- [ ] **Step 1: Create the component** + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "PersesPanel" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/components/Perses/PersesPanel.vue +git commit -m "feat(components): add PersesPanel Vue component (React mount)" +``` + +--- + +## Task 16: PersesCanvas Vue Component + +**Files:** +- Create: `ui/src/components/Perses/PersesCanvas.vue` + +Mounts the full Perses `DashboardProvider` + `Dashboard` React tree. Used by the Dashboards pages (Phase 3). + +- [ ] **Step 1: Create the component** + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "PersesCanvas" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 3: Run all frontend tests** + +```bash +cd ui && yarn test +``` + +Expected: all tests pass + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/components/Perses/PersesCanvas.vue +git commit -m "feat(components): add PersesCanvas Vue component (full dashboard runtime)" +``` + +--- + +## Phase 1 Complete + +At this point: +- `onms_dashboards` table exists and is schema-managed by Liquibase +- `/rest/dashboards` CRUD endpoints are live and tested +- The OpenNMS Perses datasource plugin can translate `OpenNMSQuerySpec` → `/rest/measurements` → `TimeSeriesData` +- React panels can be mounted in Vue components via `usePerses` +- `PersesPanel` and `PersesCanvas` are ready for use in Phases 2 and 3 + +Proceed to `2026-04-05-perses-phase2-resource-graphs.md`. diff --git a/docs/superpowers/plans/2026-04-05-perses-phase2-resource-graphs.md b/docs/superpowers/plans/2026-04-05-perses-phase2-resource-graphs.md new file mode 100644 index 000000000000..4a46f3e65d33 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-perses-phase2-resource-graphs.md @@ -0,0 +1,545 @@ +# Perses Phase 2 — Resource Graphs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace Chart.js rendering in the Vue SPA Resource Graphs page with Perses `TimeSeriesChart` panels by updating `RrdGraphConverter` to emit `PersesGraphSpec` and replacing `Graph.vue`'s canvas+Chart.js setup with ``. + +**Architecture:** `RrdGraphConverter` gains a second output type (`PersesGraphSpec`) alongside the existing `ConvertedGraphData`. `Graph.vue` is refactored to use `` with the converted spec. `HtmlLegendPlugin`, `LegendFormatter`, and `chartjs-plugin-zoom` are removed. The `GraphDataTable` tab is retained as-is. + +**Prerequisites:** Phase 1 complete (`PersesPanel.vue` and the OpenNMS datasource plugin are in place). + +**Tech Stack:** Vue 3, TypeScript, `@perses-dev/panels-plugin`, Vitest + +--- + +## File Map + +**Modified:** +- `ui/src/components/Resources/utils/RrdGraphConverter.class.ts` — add `PersesGraphSpec` output +- `ui/src/components/Resources/Graph.vue` — replace Chart.js with `` +- `ui/package.json` — remove `chart.js`, `chartjs-plugin-zoom` from dependencies + +**Deleted:** +- `ui/src/components/Resources/plugins/HtmlLegendPlugin.ts` +- `ui/src/components/Resources/utils/LegendFormatter.ts` + +**New tests:** +- `ui/tests/components/Resources/RrdGraphConverter.perses.test.ts` + +--- + +## Task 1: Add PersesGraphSpec Types + +**Files:** +- Modify: `ui/src/types/index.ts` (or wherever `ConvertedGraphData` is defined — check `ui/src/types/`) + +- [ ] **Step 1: Find where graph types are defined** + +```bash +grep -rn "ConvertedGraphData\|PrintStatement\|Series" ui/src/types/ | head -10 +``` + +Note the file path (likely `ui/src/types/index.ts`). + +- [ ] **Step 2: Add PersesGraphSpec to the types file** + +In `ui/src/types/index.ts` (or wherever `ConvertedGraphData` is defined), add: + +```typescript +import type { OpenNMSQuerySpec } from '@/datasource/opennms' + +/** Visual config for a single series in a Perses TimeSeriesChart */ +export interface PersesSeriesOverride { + /** Matches the query label */ + name: string + color?: string + type?: 'line' | 'area' | 'stack' +} + +/** + * Output of RrdGraphConverter when targeting Perses rendering. + * Replaces the old ConvertedGraphData → Chart.js pipeline. + */ +export interface PersesGraphSpec { + /** Panel title */ + title: string + /** Y-axis label (from VERTICAL_LABEL in graph def) */ + yAxisLabel: string + /** One query per DEF/CDEF metric */ + queries: OpenNMSQuerySpec[] + /** Per-series visual overrides (color, type) */ + seriesOverrides: PersesSeriesOverride[] + /** Legend text items rendered below the panel */ + printStatements: PrintStatement[] +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "PersesGraphSpec" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/types/ +git commit -m "feat(types): add PersesGraphSpec for Perses panel output" +``` + +--- + +## Task 2: Extend RrdGraphConverter to Emit PersesGraphSpec + +**Files:** +- Modify: `ui/src/components/Resources/utils/RrdGraphConverter.class.ts` +- Create: `ui/tests/components/Resources/RrdGraphConverter.perses.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/components/Resources/RrdGraphConverter.perses.test.ts +import { describe, test, expect } from 'vitest' +import RrdGraphConverter from '@/components/Resources/utils/RrdGraphConverter.class' + +// Minimal graph definition as returned by /rest/graphs/ +const sampleGraphDef = { + title: 'Interface Throughput', + verticalLabel: 'Bytes/s', + propertiesValues: [], + columns: ['ifInOctets', 'ifOutOctets'], + command: '--start {rrdstart} --end {rrdend} ' + + 'DEF:a={rrd1}:ifInOctets:AVERAGE ' + + 'DEF:b={rrd2}:ifOutOctets:AVERAGE ' + + 'LINE1:a#0000ff:"In" ' + + 'LINE1:b#ff0000:"Out"' +} + +const resourceId = 'node[1].interfaceSnmp[eth0-000000000000]' + +describe('RrdGraphConverter.toPersesGraphSpec()', () => { + test('produces one OpenNMSQuery per DEF', () => { + const converter = new RrdGraphConverter({ graphDef: sampleGraphDef, resourceId }) + const spec = converter.toPersesGraphSpec() + + expect(spec.queries).toHaveLength(2) + expect(spec.queries[0].attribute).toBe('ifInOctets') + expect(spec.queries[0].resourceId).toBe(resourceId) + expect(spec.queries[0].aggregation).toBe('AVERAGE') + expect(spec.queries[1].attribute).toBe('ifOutOctets') + }) + + test('maps LINE series to seriesOverrides with type=line and color', () => { + const converter = new RrdGraphConverter({ graphDef: sampleGraphDef, resourceId }) + const spec = converter.toPersesGraphSpec() + + expect(spec.seriesOverrides).toHaveLength(2) + expect(spec.seriesOverrides[0]).toMatchObject({ name: 'In', color: '#0000ff', type: 'line' }) + expect(spec.seriesOverrides[1]).toMatchObject({ name: 'Out', color: '#ff0000', type: 'line' }) + }) + + test('sets title and yAxisLabel from graph definition', () => { + const converter = new RrdGraphConverter({ graphDef: sampleGraphDef, resourceId }) + const spec = converter.toPersesGraphSpec() + + expect(spec.title).toBe('Interface Throughput') + expect(spec.yAxisLabel).toBe('Bytes/s') + }) + + test('marks CDEF metrics as expression queries', () => { + const cdefGraphDef = { + ...sampleGraphDef, + columns: ['ifInOctets'], + command: '--start {rrdstart} --end {rrdend} ' + + 'DEF:a={rrd1}:ifInOctets:AVERAGE ' + + 'CDEF:bps=a,8,* ' + + 'LINE1:bps#0000ff:"In bps"' + } + const converter = new RrdGraphConverter({ graphDef: cdefGraphDef, resourceId }) + const spec = converter.toPersesGraphSpec() + + const cdefQuery = spec.queries.find(q => q.expression) + expect(cdefQuery).toBeDefined() + expect(cdefQuery?.expression).toContain('a') + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/components/Resources/RrdGraphConverter.perses.test.ts +``` + +Expected: FAIL — `toPersesGraphSpec` method does not exist + +- [ ] **Step 3: Add `toPersesGraphSpec()` to RrdGraphConverter** + +In `ui/src/components/Resources/utils/RrdGraphConverter.class.ts`, add the following import at the top and method to the class: + +Add import: +```typescript +import type { PersesGraphSpec, PersesSeriesOverride } from '@/types' +import type { OpenNMSQuerySpec } from '@/datasource/opennms' +``` + +Add method to the `RrdGraphConverter` class (after the constructor): + +```typescript + /** + * Produces a PersesGraphSpec suitable for rendering with . + * This is the Perses equivalent of `this.model` (the Chart.js output). + */ + toPersesGraphSpec(): PersesGraphSpec { + // Build one OpenNMSQuery per metric in this.model.metrics + const queries: OpenNMSQuerySpec[] = this.model.metrics.map(metric => { + if (metric.expression) { + return { + resourceId: this.resourceId as string, + attribute: metric.name as string, + aggregation: 'AVERAGE', + label: metric.name as string, + expression: metric.expression as string, + transient: metric.transient as boolean + } satisfies OpenNMSQuerySpec + } + return { + resourceId: this.resourceId as string, + attribute: metric.attribute as string, + aggregation: (metric.aggregation as OpenNMSQuerySpec['aggregation']) ?? 'AVERAGE', + label: metric.name as string, + transient: metric.transient as boolean + } satisfies OpenNMSQuerySpec + }) + + // Map series to per-series visual overrides + const seriesOverrides: PersesSeriesOverride[] = this.model.series + .filter(s => s.name && s.type !== 'hidden') + .map(s => ({ + name: s.name as string, + color: s.color as string | undefined, + type: (s.type === 'stack' ? 'stack' : s.type === 'area' ? 'area' : 'line') as PersesSeriesOverride['type'] + })) + + return { + title: this.model.title as string, + yAxisLabel: this.model.verticalLabel as string, + queries, + seriesOverrides, + printStatements: this.model.printStatements + } + } +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/components/Resources/RrdGraphConverter.perses.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Run all frontend tests to catch regressions** + +```bash +cd ui && yarn test +``` + +Expected: all pass + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/components/Resources/utils/RrdGraphConverter.class.ts \ + ui/tests/components/Resources/RrdGraphConverter.perses.test.ts +git commit -m "feat(converter): add toPersesGraphSpec() to RrdGraphConverter" +``` + +--- + +## Task 3: Refactor Graph.vue to Use PersesPanel + +**Files:** +- Modify: `ui/src/components/Resources/Graph.vue` + +- [ ] **Step 1: Read the current Graph.vue** + +Read `ui/src/components/Resources/Graph.vue` (already done in prior context — 337 lines). The file uses ``, Chart.js, `HtmlLegendPlugin`, and `LegendFormatter`. + +- [ ] **Step 2: Replace Graph.vue** + +Replace the entire file with: + +```vue + + + + + + + + +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep -E "Graph\.vue|error" | head -20 +``` + +Expected: no errors for `Graph.vue` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/components/Resources/Graph.vue +git commit -m "feat(graph): replace Chart.js with PersesPanel in Graph.vue" +``` + +--- + +## Task 4: Delete Removed Files + +**Files:** +- Delete: `ui/src/components/Resources/plugins/HtmlLegendPlugin.ts` +- Delete: `ui/src/components/Resources/utils/LegendFormatter.ts` + +- [ ] **Step 1: Verify nothing else imports these files** + +```bash +grep -rn "HtmlLegendPlugin\|LegendFormatter" ui/src/ --include="*.ts" --include="*.vue" +``` + +Expected: no results (Graph.vue was the only consumer) + +- [ ] **Step 2: Delete the files** + +```bash +rm ui/src/components/Resources/plugins/HtmlLegendPlugin.ts +rm ui/src/components/Resources/utils/LegendFormatter.ts +``` + +- [ ] **Step 3: Verify TypeScript still compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | head -20 +``` + +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add -A ui/src/components/Resources/plugins/ \ + ui/src/components/Resources/utils/ +git commit -m "chore(cleanup): remove HtmlLegendPlugin and LegendFormatter (replaced by Perses)" +``` + +--- + +## Task 5: Remove Chart.js Dependencies + +**Files:** +- Modify: `ui/package.json` + +- [ ] **Step 1: Verify nothing else uses chart.js** + +```bash +grep -rn "chart\.js\|chartjs\|from 'chart" ui/src/ --include="*.ts" --include="*.vue" --include="*.tsx" +``` + +Expected: no results + +- [ ] **Step 2: Remove from package.json** + +In `ui/package.json` dependencies, remove: +``` +"chart.js": "^3.9.1", +"chartjs-plugin-zoom": "^2.0.1", +``` + +- [ ] **Step 3: Install to update lockfile** + +```bash +cd ui && yarn install +``` + +Expected: packages removed from lockfile + +- [ ] **Step 4: Full build to confirm no broken imports** + +```bash +cd ui && yarn build 2>&1 | tail -5 +``` + +Expected: build completes with no errors + +- [ ] **Step 5: Run all tests** + +```bash +cd ui && yarn test +``` + +Expected: all pass + +- [ ] **Step 6: Commit** + +```bash +git add ui/package.json ui/yarn.lock +git commit -m "chore(deps): remove chart.js and chartjs-plugin-zoom" +``` + +--- + +## Phase 2 Complete + +At this point: +- `RrdGraphConverter.toPersesGraphSpec()` converts prefab graph definitions to Perses panel specs +- `Graph.vue` renders all Resource Graphs via Perses `TimeSeriesChart` panels +- `HtmlLegendPlugin`, `LegendFormatter`, `chart.js`, `chartjs-plugin-zoom` are gone +- The Data tab still works (raw measurements still fetched for `GraphDataTable`) + +Proceed to `2026-04-05-perses-phase3-dashboards.md`. diff --git a/docs/superpowers/plans/2026-04-05-perses-phase3-dashboards.md b/docs/superpowers/plans/2026-04-05-perses-phase3-dashboards.md new file mode 100644 index 000000000000..09372796056b --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-perses-phase3-dashboards.md @@ -0,0 +1,993 @@ +# Perses Phase 3 — Dashboards Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the Dashboards section of the Vue SPA (list, view, edit) backed by `/rest/dashboards`, add server-side redirects from legacy KSC and graph URLs to the new Vue routes, and enable `org.opennms.web.graphs.engine=perses` in the container environment. + +**Architecture:** Two new Vue pages (`DashboardList`, `DashboardViewer`) mount `PersesCanvas` for full Grafana-like editing. New Vue router routes at `/dashboards/*`. Four existing Spring MVC controllers are modified to redirect to the Vue SPA when `org.opennms.web.graphs.engine=perses`. The container overlay `opennms.properties` is updated to activate the Perses engine. + +**Prerequisites:** Phase 1 complete (PersesCanvas, /rest/dashboards API). + +**Tech Stack:** Vue 3, TypeScript, Vue Router 4, Spring MVC (Java), Vitest + +--- + +## File Map + +**New frontend files:** +- `ui/src/components/Dashboards/DashboardList.vue` +- `ui/src/components/Dashboards/DashboardViewer.vue` +- `ui/src/services/dashboardService.ts` +- `ui/tests/services/dashboardService.test.ts` + +**Modified frontend files:** +- `ui/src/router/index.ts` — add `/dashboards/*` routes + +**Modified Java files:** +- `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/IndexController.java` +- `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomReportController.java` +- `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomViewController.java` +- `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomGraphEditDetailsController.java` +- `opennms-webapp/src/main/java/org/opennms/web/controller/GraphResultsController.java` +- `opennms-webapp/src/main/webapp/WEB-INF/jsp/graph/forecast.jsp` — add redirect meta tag + +**Container env:** +- Container overlay `opennms.properties` — set `org.opennms.web.graphs.engine=perses` + +--- + +## Task 1: Dashboard REST Service Client + +**Files:** +- Create: `ui/src/services/dashboardService.ts` +- Create: `ui/tests/services/dashboardService.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/services/dashboardService.test.ts +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { + listDashboards, + getDashboard, + createDashboard, + updateDashboard, + deleteDashboard +} from '@/services/dashboardService' + +const mockSummary = { id: 'abc-123', name: 'Test', description: '', createdBy: 'admin', createdAt: '2026-01-01', updatedAt: '2026-01-01' } +const mockFull = { ...mockSummary, spec: '{"panels":[]}' } + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +describe('dashboardService', () => { + test('listDashboards GETs /rest/dashboards', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, json: () => Promise.resolve([mockSummary]) } as any) + const result = await listDashboards() + expect(fetch).toHaveBeenCalledWith('/rest/dashboards', expect.objectContaining({ method: 'GET' })) + expect(result[0].id).toBe('abc-123') + }) + + test('getDashboard GETs /rest/dashboards/:id', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFull) } as any) + const result = await getDashboard('abc-123') + expect(fetch).toHaveBeenCalledWith('/rest/dashboards/abc-123', expect.anything()) + expect(result.spec).toBe('{"panels":[]}') + }) + + test('createDashboard POSTs and returns created dashboard', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, status: 201, json: () => Promise.resolve(mockFull) } as any) + const result = await createDashboard({ name: 'Test', spec: '{}' }) + expect(fetch).toHaveBeenCalledWith('/rest/dashboards', expect.objectContaining({ method: 'POST' })) + expect(result.id).toBe('abc-123') + }) + + test('updateDashboard PUTs to /rest/dashboards/:id', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFull) } as any) + await updateDashboard('abc-123', { name: 'Updated', spec: '{}' }) + expect(fetch).toHaveBeenCalledWith('/rest/dashboards/abc-123', expect.objectContaining({ method: 'PUT' })) + }) + + test('deleteDashboard DELETEs /rest/dashboards/:id', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, status: 204, text: () => Promise.resolve('') } as any) + await deleteDashboard('abc-123') + expect(fetch).toHaveBeenCalledWith('/rest/dashboards/abc-123', expect.objectContaining({ method: 'DELETE' })) + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/services/dashboardService.test.ts +``` + +Expected: FAIL + +- [ ] **Step 3: Implement the service** + +```typescript +// ui/src/services/dashboardService.ts + +export interface DashboardSummary { + id: string + name: string + description?: string + createdBy?: string + createdAt: string + updatedAt: string +} + +export interface DashboardFull extends DashboardSummary { + spec: string // Perses DashboardSpec JSON string +} + +export interface DashboardCreate { + name: string + description?: string + spec: string +} + +const BASE = '/rest/dashboards' +const JSON_HEADERS = { 'Content-Type': 'application/json', 'Accept': 'application/json' } + +export async function listDashboards(): Promise { + const res = await fetch(BASE, { method: 'GET', headers: { 'Accept': 'application/json' } }) + if (!res.ok) throw new Error(`Failed to list dashboards: ${res.status}`) + return res.json() +} + +export async function getDashboard(id: string): Promise { + const res = await fetch(`${BASE}/${id}`, { method: 'GET', headers: { 'Accept': 'application/json' } }) + if (!res.ok) throw new Error(`Failed to get dashboard ${id}: ${res.status}`) + return res.json() +} + +export async function createDashboard(payload: DashboardCreate): Promise { + const res = await fetch(BASE, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify(payload) + }) + if (!res.ok) throw new Error(`Failed to create dashboard: ${res.status}`) + return res.json() +} + +export async function updateDashboard(id: string, payload: DashboardCreate): Promise { + const res = await fetch(`${BASE}/${id}`, { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify(payload) + }) + if (!res.ok) throw new Error(`Failed to update dashboard ${id}: ${res.status}`) + return res.json() +} + +export async function deleteDashboard(id: string): Promise { + const res = await fetch(`${BASE}/${id}`, { method: 'DELETE' }) + if (!res.ok) throw new Error(`Failed to delete dashboard ${id}: ${res.status}`) +} +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/services/dashboardService.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/services/dashboardService.ts \ + ui/tests/services/dashboardService.test.ts +git commit -m "feat(service): add dashboardService REST client" +``` + +--- + +## Task 2: DashboardList Vue Page + +**Files:** +- Create: `ui/src/components/Dashboards/DashboardList.vue` + +- [ ] **Step 1: Create the component** + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "DashboardList" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/components/Dashboards/DashboardList.vue +git commit -m "feat(dashboards): add DashboardList page" +``` + +--- + +## Task 3: DashboardViewer Vue Page + +**Files:** +- Create: `ui/src/components/Dashboards/DashboardViewer.vue` + +Handles view mode, edit mode, and new dashboard creation. Mounts `PersesCanvas`. + +- [ ] **Step 1: Create the component** + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "DashboardViewer" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/components/Dashboards/DashboardViewer.vue +git commit -m "feat(dashboards): add DashboardViewer page (view/edit/new)" +``` + +--- + +## Task 4: Register Vue Router Routes + +**Files:** +- Modify: `ui/src/router/index.ts` + +- [ ] **Step 1: Read the current router file** + +```bash +head -60 ui/src/router/index.ts +``` + +Note the import pattern used for other lazy-loaded components. + +- [ ] **Step 2: Add dashboard routes** + +In `ui/src/router/index.ts`, add the following imports near the top with other lazy component imports: + +```typescript +const DashboardList = () => import('@/components/Dashboards/DashboardList.vue') +const DashboardViewer = () => import('@/components/Dashboards/DashboardViewer.vue') +``` + +In the routes array, add: + +```typescript + { + path: '/dashboards', + component: DashboardList, + name: 'Dashboards' + }, + { + path: '/dashboards/new', + component: DashboardViewer, + name: 'NewDashboard' + }, + { + path: '/dashboards/:id', + component: DashboardViewer, + name: 'DashboardView' + }, + { + path: '/dashboards/:id/edit', + component: DashboardViewer, + name: 'DashboardEdit' + }, +``` + +- [ ] **Step 3: Verify TypeScript compiles and build succeeds** + +```bash +cd ui && yarn build 2>&1 | tail -5 +``` + +Expected: build succeeds + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/router/index.ts +git commit -m "feat(router): add /dashboards/* routes" +``` + +--- + +## Task 5: Add Dashboards Nav Link + +**Files:** +- Modify: wherever the Vue SPA's side navigation links are defined + +- [ ] **Step 1: Find the nav menu definition** + +```bash +grep -rn "Resource Graphs\|resource-graphs\|Alarms\|NodeDetail" ui/src/ --include="*.vue" --include="*.ts" | grep -i "nav\|menu\|link\|route" | head -10 +``` + +Note the file that defines the navigation items. + +- [ ] **Step 2: Add the Dashboards nav entry** + +In the nav definition file, add a Dashboards entry following the same pattern as existing entries. Example (adjust to match actual nav structure): + +```typescript +{ + label: 'Dashboards', + icon: 'dashboard', // or whichever Feather icon is appropriate + to: '/dashboards' +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | head -10 +``` + +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add -p ui/src/ +git commit -m "feat(nav): add Dashboards link to Vue SPA navigation" +``` + +--- + +## Task 6: KSC Controller Redirects + +**Files:** +- Modify: `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/IndexController.java` +- Modify: `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomReportController.java` +- Modify: `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomViewController.java` +- Modify: `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomGraphEditDetailsController.java` + +Follow the same pattern as `AlarmDetailController.java:112-119` — check `System.getProperty`, call `response.sendRedirect`, return null. + +The system property is `org.opennms.web.graphs.engine`. Redirect when it equals `"perses"`. + +- [ ] **Step 1: Read each controller to find the handler method** + +```bash +grep -n "handleRequestInternal\|handleRequest\|@RequestMapping\|ModelAndView" \ + opennms-webapp/src/main/java/org/opennms/web/controller/ksc/IndexController.java | head -10 +grep -n "handleRequestInternal\|ModelAndView" \ + opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomReportController.java | head -10 +``` + +- [ ] **Step 2: Add redirect to IndexController** + +At the top of `handleRequestInternal` (or equivalent handler method) in `IndexController.java`, add: + +```java +// Redirect to Vue SPA Dashboards when Perses engine is active +if ("perses".equals(System.getProperty("org.opennms.web.graphs.engine", "backshift"))) { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/dashboards"); + return null; +} +``` + +- [ ] **Step 3: Add redirect to CustomReportController** + +At the top of its handler method in `CustomReportController.java`, add: + +```java +if ("perses".equals(System.getProperty("org.opennms.web.graphs.engine", "backshift"))) { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/dashboards"); + return null; +} +``` + +- [ ] **Step 4: Add redirect to CustomViewController** + +At the top of its handler method in `CustomViewController.java`, add: + +```java +if ("perses".equals(System.getProperty("org.opennms.web.graphs.engine", "backshift"))) { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/dashboards"); + return null; +} +``` + +- [ ] **Step 5: Add redirect to CustomGraphEditDetailsController** + +At the top of its handler method in `CustomGraphEditDetailsController.java`, add: + +```java +if ("perses".equals(System.getProperty("org.opennms.web.graphs.engine", "backshift"))) { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/dashboards/new"); + return null; +} +``` + +- [ ] **Step 6: Compile opennms-webapp** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 7: Commit** + +```bash +git add opennms-webapp/src/main/java/org/opennms/web/controller/ksc/ +git commit -m "feat(ksc): redirect KSC controllers to Vue Dashboards when engine=perses" +``` + +--- + +## Task 7: Graph Results Controller Redirect + +**Files:** +- Modify: `opennms-webapp/src/main/java/org/opennms/web/controller/GraphResultsController.java` + +- [ ] **Step 1: Read the current handler** + +```bash +grep -n "handleRequestInternal\|resourceId\|reports\|sendRedirect" \ + opennms-webapp/src/main/java/org/opennms/web/controller/GraphResultsController.java | head -15 +``` + +- [ ] **Step 2: Add redirect at the top of `handleRequestInternal`** + +The existing handler reads `resourceId` and `reports` from request params. Preserve those for the redirect URL: + +```java +@Override +protected ModelAndView handleRequestInternal(HttpServletRequest request, HttpServletResponse response) throws Exception { + // Redirect to Vue SPA Resource Graphs when Perses engine is active + if ("perses".equals(System.getProperty("org.opennms.web.graphs.engine", "backshift"))) { + final String resourceId = request.getParameter("resourceId"); + if (resourceId != null && !resourceId.trim().isEmpty()) { + // Encode the resourceId for use in a hash fragment + final String encoded = java.net.URLEncoder.encode(resourceId.trim(), StandardCharsets.UTF_8.name()); + response.sendRedirect(request.getContextPath() + + "/ui/index.html#/resource-graphs/graphs/" + encoded); + } else { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/resource-graphs"); + } + return null; + } + // ... existing code continues unchanged below this block +``` + +Note: add `import java.nio.charset.StandardCharsets;` if not already present. + +- [ ] **Step 3: Compile** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit** + +```bash +git add opennms-webapp/src/main/java/org/opennms/web/controller/GraphResultsController.java +git commit -m "feat(graph): redirect graph/results.htm to Vue SPA when engine=perses" +``` + +--- + +## Task 8: Retire forecast.jsp + +**Files:** +- Modify: `opennms-webapp/src/main/webapp/graph/forecast.jsp` + +The forecast page is retired — no Vue redirect exists yet (Perses forecast panel is future work). Add a redirect to the Dashboards list so old bookmarks don't 404. + +- [ ] **Step 1: Read the current forecast.jsp header** + +```bash +head -20 opennms-webapp/src/main/webapp/graph/forecast.jsp +``` + +- [ ] **Step 2: Add a redirect at the very top of forecast.jsp** + +Add before any other content at the top of `forecast.jsp`: + +```jsp +<%-- + forecast.jsp — retired in favor of Perses-based forecast (future feature). + Redirect to Dashboards when Perses engine is active. +--%> +<%@ page import="org.opennms.core.utils.TimeSeries" %> +<% + if ("perses".equals(TimeSeries.getGraphEngine())) { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/dashboards"); + return; + } +%> +``` + +- [ ] **Step 3: Compile** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit** + +```bash +git add opennms-webapp/src/main/webapp/graph/forecast.jsp +git commit -m "feat(forecast): redirect to Vue Dashboards when engine=perses" +``` + +--- + +## Task 9: Update TimeSeries.java to Recognize "perses" + +**Files:** +- Modify: `core/lib/src/main/java/org/opennms/core/utils/TimeSeries.java` + +The `getGraphEngine()` method currently returns `"backshift"` for the `"auto"` value. Add `"perses"` as a recognized valid value (currently it passes through as-is, which is fine — but document it clearly). + +- [ ] **Step 1: Read TimeSeries.java** + +Already read in prior context. The key constant is `DEFAULT_GRAPHS_ENGINE_TYPE = "backshift"`. + +- [ ] **Step 2: Add perses constant and update javadoc** + +In `TimeSeries.java`, add after the existing constants: + +```java + public static final String PERSES_GRAPHS_ENGINE_TYPE = "perses"; +``` + +Update the `getGraphEngine()` javadoc: + +```java + /** + * Returns the configured graph engine. + * Valid values: "backshift" (default), "png", "placeholder", "perses". + * Set via system property: org.opennms.web.graphs.engine + */ + public static String getGraphEngine() { +``` + +- [ ] **Step 3: Compile** + +```bash +./compile.pl -DskipTests --projects :opennms-core install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit** + +```bash +git add core/lib/src/main/java/org/opennms/core/utils/TimeSeries.java +git commit -m "feat(timeseries): document perses as valid graph engine value" +``` + +--- + +## Task 10: Set engine=perses in Container Environment + +**Files:** +- Modify: container overlay `opennms.properties` (in the container build/overlay directory) + +- [ ] **Step 1: Find the container overlay opennms.properties** + +```bash +find /Users/chance/git/opennms -name "opennms.properties" | grep -v target | grep -v node_modules | head -5 +``` + +Note the path used in the container overlay (check `.topology-lab/` or the existing container overlay scripts from prior work). + +- [ ] **Step 2: Add the Perses engine property** + +In the container overlay `opennms.properties` (the one copied into the container at build time), add: + +```properties +# Enable Perses-based graph/dashboard rendering +org.opennms.web.graphs.engine=perses +``` + +- [ ] **Step 3: Rebuild and verify the container picks up the property** + +Follow the existing container overlay build procedure. After startup, verify: + +```bash +podman exec grep "graphs.engine" /opt/opennms/etc/opennms.properties +``` + +Expected: `org.opennms.web.graphs.engine=perses` + +- [ ] **Step 4: Smoke test — verify KSC redirect works** + +```bash +curl -s -o /dev/null -w "%{http_code} %{redirect_url}" \ + -u admin:notdefault \ + http://localhost:8980/opennms/KSC/index.htm +``` + +Expected: `302 http://localhost:8980/opennms/ui/index.html#/dashboards` + +- [ ] **Step 5: Commit the overlay change** + +```bash +git add +git commit -m "feat(container): set org.opennms.web.graphs.engine=perses in overlay" +``` + +--- + +## Task 11: Run All Tests + +- [ ] **Step 1: Run all frontend tests** + +```bash +cd ui && yarn test +``` + +Expected: all pass + +- [ ] **Step 2: Build the frontend** + +```bash +cd ui && yarn build 2>&1 | tail -5 +``` + +Expected: build success + +- [ ] **Step 3: Compile affected Java modules** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp,:opennms-webapp-rest -am install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit if any fixes were needed** + +```bash +git add -p +git commit -m "fix(phase3): address any build issues found during final test run" +``` + +--- + +## Phase 3 Complete + +At this point: +- `/dashboards`, `/dashboards/new`, `/dashboards/:id`, `/dashboards/:id/edit` are live Vue routes +- `DashboardList` shows all saved Perses dashboards with create/delete +- `DashboardViewer` mounts `PersesCanvas` for view, edit, and new dashboard workflows +- `/KSC/*`, `/graph/results.htm`, `/graph/forecast.jsp` redirect to Vue SPA when engine=perses +- Container environment runs with engine=perses + +Proceed to `2026-04-05-perses-phase4-retirement.md`. diff --git a/docs/superpowers/plans/2026-04-05-perses-phase4-retirement.md b/docs/superpowers/plans/2026-04-05-perses-phase4-retirement.md new file mode 100644 index 000000000000..eac06c75261c --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-perses-phase4-retirement.md @@ -0,0 +1,414 @@ +# Perses Phase 4 — Backshift Retirement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the legacy Backshift/Flot graph rendering infrastructure now that it has been fully replaced by Perses. Delete vendor JS bundles, the `onms-graph` and `forecast` webpack apps, remove `bootstrap.jsp`'s backshift asset loading, and change the `TimeSeries` default engine to `"perses"`. + +**Architecture:** Pure cleanup — no new code is written. Every deletion is preceded by a grep to confirm no remaining callers. The `usebackshift` and `renderGraphs` bootstrap.jsp flags are removed. The `DEFAULT_GRAPHS_ENGINE_TYPE` constant changes to `"perses"`. + +**Prerequisites:** Phase 2 and Phase 3 complete and verified in the container environment. + +**Tech Stack:** Java, JSP, webpack (legacy `core/web-assets`) + +--- + +## File Map + +**Deleted:** +- `core/web-assets/src/main/assets/js/apps/onms-graph/index.js` +- `core/web-assets/src/main/assets/js/apps/forecast/index.js` +- `core/web-assets/src/main/assets/js/vendor/backshift-js.js` + +**Modified:** +- `core/web-assets/package.json` — remove `backshift` npm dependency +- `core/web-assets/webpack.config.js` — remove `onms-graph` and `forecast` entry points +- `opennms-webapp/src/main/webapp/includes/bootstrap.jsp` — remove backshift/flot asset loading +- `core/lib/src/main/java/org/opennms/core/utils/TimeSeries.java` — change default engine to `"perses"` + +**Possibly deleted** (verify first): +- `core/web-assets/src/main/assets/js/vendor/flot-js.js` — only if not used by anything other than backshift/NRTG + +--- + +## Task 1: Confirm No Remaining Callers of Backshift + +Before deleting anything, verify all consumers have been replaced. + +- [ ] **Step 1: Check for backshift imports in web-assets** + +```bash +grep -rn "backshift\|Backshift" core/web-assets/src/main/assets/js/ --include="*.js" --include="*.ts" | grep -v "vendor/backshift-js" +``` + +Expected: only `onms-graph/index.js` and `forecast/index.js` import backshift. If any other file imports it, stop and investigate before proceeding. + +- [ ] **Step 2: Check for graph-container divs in JSPs** + +```bash +grep -rn "graph-container\|renderGraphs\|usebackshift" opennms-webapp/src/main/webapp/ --include="*.jsp" | grep -v "#" +``` + +Expected: only `bootstrap.jsp` references these flags. The KSC and graph result JSPs should no longer inject `graph-container` divs when the engine is `perses` (because the controllers redirect before the JSP renders). If any JSP still uses `graph-container` outside of a perses-gate, flag it. + +- [ ] **Step 3: Check for flot.js usage outside backshift** + +```bash +grep -rn "flot-js\|require.*flot\|import.*flot" core/web-assets/src/main/assets/js/ --include="*.js" | grep -v "vendor/backshift-js\|apps/forecast\|apps/onms-graph" +grep -rn "flot-js\|require.*flot" opennms-webapp/src/main/webapp/ --include="*.jsp" +``` + +If `flot-js` is referenced by NRTG (`nrtg.jsp`) or any other page outside the backshift chain, note it — flot.js deletion may need to be deferred. + +- [ ] **Step 4: Document findings** + +If all checks are clean, proceed. If any unexpected caller is found, open a follow-up task before deleting. + +--- + +## Task 2: Delete onms-graph App + +**Files:** +- Delete: `core/web-assets/src/main/assets/js/apps/onms-graph/index.js` + +- [ ] **Step 1: Verify no callers** + +```bash +grep -rn "onms-graph\|apps/onms-graph" core/web-assets/src/ --include="*.js" --include="*.ts" --include="*.json" +``` + +Expected: references only in `webpack.config.js` (entry point) and possibly `bootstrap.jsp` (asset load). Both will be cleaned up in subsequent tasks. + +- [ ] **Step 2: Delete the file** + +```bash +rm core/web-assets/src/main/assets/js/apps/onms-graph/index.js +# Remove the directory if it's now empty: +rmdir core/web-assets/src/main/assets/js/apps/onms-graph/ 2>/dev/null || true +``` + +- [ ] **Step 3: Commit** + +```bash +git add core/web-assets/src/main/assets/js/apps/onms-graph/ +git commit -m "chore(cleanup): remove onms-graph webpack app (replaced by Perses)" +``` + +--- + +## Task 3: Delete forecast App + +**Files:** +- Delete: `core/web-assets/src/main/assets/js/apps/forecast/index.js` + +- [ ] **Step 1: Verify no callers outside forecast.jsp** + +```bash +grep -rn "apps/forecast\|forecast/index" core/web-assets/src/ --include="*.js" --include="*.json" +grep -rn "asset.*forecast\|forecast.*asset" opennms-webapp/src/main/webapp/ --include="*.jsp" +``` + +Expected: only `webpack.config.js` and `forecast.jsp`. The `forecast.jsp` now redirects immediately when engine=perses so the asset is never loaded in the Perses path. + +- [ ] **Step 2: Delete the file** + +```bash +rm core/web-assets/src/main/assets/js/apps/forecast/index.js +rmdir core/web-assets/src/main/assets/js/apps/forecast/ 2>/dev/null || true +``` + +- [ ] **Step 3: Commit** + +```bash +git add core/web-assets/src/main/assets/js/apps/forecast/ +git commit -m "chore(cleanup): remove forecast webpack app (Perses forecast is future work)" +``` + +--- + +## Task 4: Delete backshift-js Vendor Bundle + +**Files:** +- Delete: `core/web-assets/src/main/assets/js/vendor/backshift-js.js` + +- [ ] **Step 1: Final check for any remaining backshift import** + +```bash +grep -rn "backshift-js\|require.*backshift\|vendor/backshift" core/web-assets/src/ --include="*.js" +``` + +Expected: zero results (onms-graph and forecast were the only importers, now deleted) + +- [ ] **Step 2: Delete the vendor bundle** + +```bash +rm core/web-assets/src/main/assets/js/vendor/backshift-js.js +``` + +- [ ] **Step 3: Commit** + +```bash +git add core/web-assets/src/main/assets/js/vendor/backshift-js.js +git commit -m "chore(cleanup): remove backshift-js vendor bundle" +``` + +--- + +## Task 5: Remove backshift npm Dependency + +**Files:** +- Modify: `core/web-assets/package.json` +- Modify: `core/web-assets/webpack.config.js` + +- [ ] **Step 1: Read the current webpack config entries** + +```bash +grep -n "onms-graph\|forecast\|backshift" core/web-assets/webpack.config.js | head -10 +``` + +Note the exact entry point keys to remove. + +- [ ] **Step 2: Remove entry points from webpack.config.js** + +Remove the `'onms-graph'` and `'forecast'` entries from the webpack `entry` object in `core/web-assets/webpack.config.js`. + +- [ ] **Step 3: Remove backshift from package.json** + +In `core/web-assets/package.json`, remove the `backshift` dependency entry (it's pinned to a GitHub tarball). Run: + +```bash +cd core/web-assets && grep "backshift" package.json +``` + +Remove that line from `package.json`. + +- [ ] **Step 4: Install to clean up lockfile** + +```bash +cd core/web-assets && npm install +``` + +Expected: backshift removed from `package-lock.json` + +- [ ] **Step 5: Build the web-assets module to verify** + +```bash +./compile.pl -DskipTests --projects :org.opennms.features.distributed:core-web-assets install +``` + +If the artifact ID differs, find it with: +```bash +tools/development/pom-artifact.sh core/web-assets/pom.xml +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 6: Commit** + +```bash +git add core/web-assets/package.json core/web-assets/package-lock.json core/web-assets/webpack.config.js +git commit -m "chore(deps): remove backshift npm dependency and webpack entry points" +``` + +--- + +## Task 6: Clean Up bootstrap.jsp + +**Files:** +- Modify: `opennms-webapp/src/main/webapp/includes/bootstrap.jsp` + +- [ ] **Step 1: Read the relevant section** + +Read lines 172-197 of `bootstrap.jsp` (already read in prior context). The section to remove is: + +```jsp + + + + + + + + + + <%-- ... --%> + + + + +``` + +- [ ] **Step 2: Remove both c:if blocks** + +Remove the entire `renderGraphs` block (lines 172-184) and the entire `usebackshift` block (lines 186-197) from `bootstrap.jsp`. + +- [ ] **Step 3: Verify bootstrap.jsp still compiles** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit** + +```bash +git add opennms-webapp/src/main/webapp/includes/bootstrap.jsp +git commit -m "chore(cleanup): remove renderGraphs and usebackshift asset loading from bootstrap.jsp" +``` + +--- + +## Task 7: Optionally Delete flot-js + +- [ ] **Step 1: Check if flot-js is still used** + +```bash +grep -rn "flot-js\|require.*flot\|vendor/flot" \ + core/web-assets/src/main/assets/js/ \ + opennms-webapp/src/main/webapp/ \ + --include="*.js" --include="*.jsp" --include="*.ts" +``` + +- If **zero results**: delete `core/web-assets/src/main/assets/js/vendor/flot-js.js` and commit. +- If **results found** (e.g., `nrtg.jsp`): leave flot-js in place and open a follow-up task to handle the remaining consumer. + +```bash +# Only run if zero results above: +rm core/web-assets/src/main/assets/js/vendor/flot-js.js +git add core/web-assets/src/main/assets/js/vendor/flot-js.js +git commit -m "chore(cleanup): remove flot-js vendor bundle (no remaining consumers)" +``` + +--- + +## Task 8: Change Default Graph Engine to "perses" + +**Files:** +- Modify: `core/lib/src/main/java/org/opennms/core/utils/TimeSeries.java` + +- [ ] **Step 1: Change the constant** + +In `TimeSeries.java`, change: + +```java +public static final String DEFAULT_GRAPHS_ENGINE_TYPE = "backshift"; +``` + +to: + +```java +public static final String DEFAULT_GRAPHS_ENGINE_TYPE = "perses"; +``` + +- [ ] **Step 2: Verify `getGraphEngine()` still works** + +The method already handles `"auto"` specially (returns `DEFAULT_GRAPHS_ENGINE_TYPE`). Since the default is now `"perses"`, both `"auto"` and an unset property will return `"perses"`. + +- [ ] **Step 3: Compile** + +```bash +./compile.pl -DskipTests --projects :opennms-core install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Run TimeSeries-related tests if any exist** + +```bash +grep -rn "TimeSeries\|getGraphEngine" opennms-*/src/test/ --include="*.java" -l | head -5 +``` + +Run any found test files to confirm no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add core/lib/src/main/java/org/opennms/core/utils/TimeSeries.java +git commit -m "feat(timeseries): change default graph engine from backshift to perses" +``` + +--- + +## Task 9: Final Build Verification + +- [ ] **Step 1: Full build of affected modules** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp,:opennms-webapp-rest,:opennms-core -am install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 2: Full frontend build** + +```bash +cd ui && yarn build 2>&1 | tail -10 +``` + +Expected: success, no warnings about missing modules + +- [ ] **Step 3: Run all frontend tests** + +```bash +cd ui && yarn test +``` + +Expected: all pass + +- [ ] **Step 4: Container smoke test** + +Rebuild the overlay container and verify: + +```bash +# After container starts (health check passes): + +# 1. Backshift assets are NOT served +curl -s -o /dev/null -w "%{http_code}" \ + http://localhost:8980/opennms/assets/backshift-js.js +# Expected: 404 + +# 2. KSC redirect still works +curl -s -o /dev/null -w "%{http_code} %{redirect_url}" \ + -u admin:notdefault \ + http://localhost:8980/opennms/KSC/index.htm +# Expected: 302 .../ui/index.html#/dashboards + +# 3. Graph results redirect +curl -s -o /dev/null -w "%{http_code} %{redirect_url}" \ + -u admin:notdefault \ + "http://localhost:8980/opennms/graph/results.htm?resourceId=node%5B1%5D" +# Expected: 302 .../ui/index.html#/resource-graphs/graphs/node%5B1%5D + +# 4. Dashboards API is live +curl -s -u admin:notdefault \ + http://localhost:8980/opennms/rest/dashboards +# Expected: [] +``` + +- [ ] **Step 5: Commit any final fixes** + +```bash +git add -p +git commit -m "fix(phase4): final build and smoke test fixes" +``` + +--- + +## Phase 4 Complete — Backshift Infrastructure Retired + +At this point: +- `backshift-js.js`, `onms-graph/index.js`, `forecast/index.js`, `flot-js.js` (if clean) are gone +- `bootstrap.jsp` no longer loads backshift or flot assets +- `TimeSeries.DEFAULT_GRAPHS_ENGINE_TYPE` is `"perses"` +- The default engine for all new OpenNMS installations is Perses +- The legacy `"backshift"` value still works as an explicit opt-out (for operators who need the legacy rendering) + +**Total changes across all 4 phases:** +- New: Liquibase migration, Java entity/DAO/REST, OpenNMS Perses datasource plugin, React mount infrastructure, `DashboardList`, `DashboardViewer`, dashboard REST service +- Updated: `RrdGraphConverter` (Perses output), `Graph.vue` (PersesPanel), `TimeSeries.java`, `bootstrap.jsp`, 5 Spring MVC controllers, Vue router +- Deleted: `backshift-js.js`, `onms-graph/index.js`, `forecast/index.js`, `HtmlLegendPlugin.ts`, `LegendFormatter.ts`, `chart.js`, `chartjs-plugin-zoom` diff --git a/docs/superpowers/plans/2026-04-05-test-fixture-seed-script.md b/docs/superpowers/plans/2026-04-05-test-fixture-seed-script.md new file mode 100644 index 000000000000..4ac78a82e5e1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-test-fixture-seed-script.md @@ -0,0 +1,388 @@ +# Test Fixture Seed Script Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create `ui/seed-test-fixtures.sh` — an idempotent shell script that inserts a stable `UI-Test-Node` with one active and two resolved ICMP outages into the `test-opennms` container's PostgreSQL database. + +**Architecture:** Pure SQL via `psql` against the exposed port 5432. All inserts are wrapped in a single `DO $$ … $$ LANGUAGE plpgsql` block with `IF NOT EXISTS` guards. A `--reset` flag deletes all `UI-Test` rows before re-inserting. No provisioning pipeline, no polling, no fake events. + +**Tech Stack:** Bash, PostgreSQL 14 (psql client on macOS host), OpenNMS 35.x schema. + +--- + +## Schema Reference (confirmed from live container) + +| Table | PK column | Sequence | Notes | +|---|---|---|---| +| `node` | `nodeid` | `nodenxtid` | `location NOT NULL`, no `createtime` column | +| `ipinterface` | `id` | `opennmsnxtid` | `ipaddr TEXT NOT NULL`, `nodeid NOT NULL` | +| `ifservices` | `id` | `opennmsnxtid` | `serviceid=3` is ICMP | +| `outages` | `outageid` | `outagenxtid` | `ifserviceid NOT NULL`, `svclosteventid` nullable | + +DB credentials (host-side): `PGPASSWORD=opennms psql -h localhost -p 5432 -U opennms -d opennms` + +> **Note:** The spec listed PGUSER=postgres/PGPASSWORD=postgres — these are wrong. The correct credentials are PGUSER=opennms / PGPASSWORD=opennms. + +--- + +## File Structure + +| File | Action | Purpose | +|---|---|---| +| `ui/seed-test-fixtures.sh` | **Create** | Idempotent SQL seed script | + +No other files are created or modified. + +--- + +### Task 1: Write and verify the seed script + +**Files:** +- Create: `ui/seed-test-fixtures.sh` + +- [ ] **Step 1: Create the script** + +```bash +#!/usr/bin/env bash +# seed-test-fixtures.sh +# Inserts a stable UI-Test-Node with 1 active + 2 resolved ICMP outages +# into the running test-opennms container's PostgreSQL database. +# +# Usage: +# ./seed-test-fixtures.sh # no-op if already seeded +# ./seed-test-fixtures.sh --reset # drop UI-Test rows and re-insert +# +# DB connection env vars (all have defaults): +# PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE + +set -euo pipefail + +PGHOST="${PGHOST:-localhost}" +PGPORT="${PGPORT:-5432}" +PGUSER="${PGUSER:-opennms}" +PGPASSWORD="${PGPASSWORD:-opennms}" +PGDATABASE="${PGDATABASE:-opennms}" +export PGPASSWORD + +RESET=false +if [[ "${1:-}" == "--reset" ]]; then + RESET=true +fi + +# Verify psql is available +if ! command -v psql &>/dev/null; then + echo "ERROR: psql not found. Install postgresql client (brew install libpq)." >&2 + exit 1 +fi + +# Verify DB is reachable +if ! psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" \ + -c "SELECT 1;" &>/dev/null; then + echo "ERROR: Cannot connect to PostgreSQL at $PGHOST:$PGPORT as $PGUSER." >&2 + exit 1 +fi + +echo "Connected to $PGDATABASE at $PGHOST:$PGPORT" + +psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" <<'SQL' +DO $$ +DECLARE + v_nodeid INTEGER; + v_ifid INTEGER; + v_svcid INTEGER; +BEGIN + + -- ── Reset (optional) ────────────────────────────────────────────────────── + -- Cascades: node → ipinterface → ifservices → outages + IF current_setting('app.reset', true) = 'true' THEN + DELETE FROM node WHERE foreignsource = 'UI-Test'; + RAISE NOTICE 'UI-Test rows deleted (reset).'; + END IF; + + -- ── Node ────────────────────────────────────────────────────────────────── + SELECT nodeid INTO v_nodeid + FROM node + WHERE foreignsource = 'UI-Test' AND foreignid = 'test-node-01'; + + IF v_nodeid IS NULL THEN + v_nodeid := nextval('nodenxtid'); + INSERT INTO node (nodeid, nodelabel, foreignsource, foreignid, nodetype, location) + VALUES (v_nodeid, 'UI-Test-Node', 'UI-Test', 'test-node-01', 'A', 'Default'); + RAISE NOTICE 'Inserted node id=% (UI-Test-Node)', v_nodeid; + ELSE + RAISE NOTICE 'Node already exists id=% — skipping inserts.', v_nodeid; + RETURN; + END IF; + + -- ── IP Interface ────────────────────────────────────────────────────────── + v_ifid := nextval('opennmsnxtid'); + INSERT INTO ipinterface (id, nodeid, ipaddr, ismanaged, ipstatus, issnmpprimary) + VALUES (v_ifid, v_nodeid, '192.0.2.1', 'M', 1, 'P'); + RAISE NOTICE 'Inserted ipinterface id=% (192.0.2.1)', v_ifid; + + -- ── ifservices (ICMP = serviceid 3) ─────────────────────────────────────── + v_svcid := nextval('opennmsnxtid'); + INSERT INTO ifservices (id, ipinterfaceid, serviceid, status, source) + VALUES (v_svcid, v_ifid, 3, 'A', 'P'); + RAISE NOTICE 'Inserted ifservice id=% (ICMP)', v_svcid; + + -- ── Outages ─────────────────────────────────────────────────────────────── + -- Outage 1: Active (no ifregainedservice) + INSERT INTO outages (outageid, ifserviceid, iflostservice, ifregainedservice) + VALUES ( + nextval('outagenxtid'), + v_svcid, + NOW() - INTERVAL '2 hours', + NULL + ); + RAISE NOTICE 'Inserted active outage (lost 2h ago)'; + + -- Outage 2: Resolved ~10 min outage, 1 day ago + INSERT INTO outages (outageid, ifserviceid, iflostservice, ifregainedservice) + VALUES ( + nextval('outagenxtid'), + v_svcid, + NOW() - INTERVAL '1 day', + NOW() - INTERVAL '23 hours 50 minutes' + ); + RAISE NOTICE 'Inserted resolved outage (1 day ago)'; + + -- Outage 3: Resolved ~30 min outage, 2 days ago + INSERT INTO outages (outageid, ifserviceid, iflostservice, ifregainedservice) + VALUES ( + nextval('outagenxtid'), + v_svcid, + NOW() - INTERVAL '2 days', + NOW() - INTERVAL '1 day 23 hours 30 minutes' + ); + RAISE NOTICE 'Inserted resolved outage (2 days ago)'; + +END; +$$ LANGUAGE plpgsql; +SQL +``` + +The `--reset` flag cannot pass a variable into the heredoc `DO $$` block directly, so we use a PostgreSQL session-level setting. Replace the heredoc invocation with a two-step approach: + +```bash +# Set session var before the DO block based on $RESET +RESET_SETTING="SET app.reset = '$( [[ "$RESET" == "true" ]] && echo true || echo false )';" + +psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" \ + -c "$RESET_SETTING" \ + -f - <<'SQL' +DO $$ +... +SQL +``` + +> That `-c ... -f -` pattern won't work cleanly. Use a temp file approach instead — see full script below. + +Write the complete final script to `ui/seed-test-fixtures.sh`: + +```bash +#!/usr/bin/env bash +# seed-test-fixtures.sh +# Inserts UI-Test-Node with 1 active + 2 resolved ICMP outages into +# the running test-opennms container's PostgreSQL database. +# +# Usage: +# ./seed-test-fixtures.sh # no-op if already seeded +# ./seed-test-fixtures.sh --reset # drop UI-Test rows and re-insert +# +# DB connection (all overridable via env): +# PGHOST=localhost PGPORT=5432 PGUSER=opennms PGPASSWORD=opennms PGDATABASE=opennms + +set -euo pipefail + +PGHOST="${PGHOST:-localhost}" +PGPORT="${PGPORT:-5432}" +PGUSER="${PGUSER:-opennms}" +PGPASSWORD="${PGPASSWORD:-opennms}" +PGDATABASE="${PGDATABASE:-opennms}" +export PGPASSWORD + +RESET=false +if [[ "${1:-}" == "--reset" ]]; then + RESET=true +fi + +if ! command -v psql &>/dev/null; then + echo "ERROR: psql not found. Install with: brew install libpq" >&2 + exit 1 +fi + +if ! psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -c "SELECT 1;" &>/dev/null; then + echo "ERROR: Cannot connect to PostgreSQL at $PGHOST:$PGPORT as $PGUSER" >&2 + exit 1 +fi + +echo "Connected to $PGDATABASE at $PGHOST:$PGPORT (reset=$RESET)" + +psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" < **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up a 5-node leaf/spine virtual network (FRR + lldpd + net-snmp) alongside the test container, provisioned into OpenNMS so EnLinkd can discover and display real LLDP, OSPF, ISIS, Bridge, and IpNetToMedia topology. + +**Architecture:** A single `start-topology-lab.sh` script creates 7 podman networks, builds a custom `topology-node` image (FRR 9.1 + lldpd + net-snmp), starts 5 containers (2 spines + 3 leaves) with explicit interface-to-network assignments, attaches `test-opennms` to the management network, drops a requisition, and polls for OSPF convergence. All FRR configs, the Dockerfile, the entrypoint scripts, and the requisition XML are heredocs inside the script — no committed auxiliary files. The overlay image (`build-dark-mode-overlay.sh`) is extended to include a 30s EnLinkd rescan config and a SNMP range for 10.100.0.0/24. + +**Tech Stack:** podman, FRR 9.1 (ospfd + isisd + zebra), lldpd, net-snmp (snmpd with AgentX master), bash, OpenNMS REST API + +--- + +## File Map + +| File | Action | Purpose | +|---|---|---| +| `build-dark-mode-overlay.sh` | Modify | Add `enlinkd-configuration.xml` (30s rescan) + extend `snmp-config.xml` with topology-lab range | +| `start-topology-lab.sh` | Create | Full orchestration: image build, networks, containers, OpenNMS provisioning, convergence check | +| `stop-topology-lab.sh` | Create | Teardown helper | + +--- + +## Task 1: Extend the overlay image with EnLinkd config + SNMP range + +**Files:** +- Modify: `build-dark-mode-overlay.sh` + +The test container needs to be rebuilt after this task. EnLinkd's default rescan intervals are 86400000ms (24h) — useless for testing. We set them all to 30s. The SNMP range for 10.100.0.0/24 on port 161 tells OpenNMS how to poll the topology-lab nodes. + +- [ ] **Step 1: Update the `snmp-config.xml` heredoc** in `build-dark-mode-overlay.sh` (currently at the line starting `cat > "${OVERLAY_DIR}/etc/snmp-config.xml" <<'SNMPCFG'`). Replace the entire heredoc with: + +```bash +cat > "${OVERLAY_DIR}/etc/snmp-config.xml" <<'SNMPCFG' + + + 127.0.0.1 + + + + + +SNMPCFG +``` + +- [ ] **Step 2: Add the `enlinkd-configuration.xml` heredoc** immediately after the snmp-config block (before the `echo " snmpd.conf + ..."` line): + +```bash +cat > "${OVERLAY_DIR}/etc/enlinkd-configuration.xml" <<'ENLINKD' + + +ENLINKD +``` + +- [ ] **Step 3: Add the COPY directive** for `enlinkd-configuration.xml` in the Dockerfile heredoc (inside `build-dark-mode-overlay.sh`). Find the line: +``` +COPY --chown=10001:10001 etc/snmp-config.xml /opt/opennms/etc/snmp-config.xml +``` +Add immediately after it: +```dockerfile +COPY --chown=10001:10001 etc/enlinkd-configuration.xml /opt/opennms/etc/enlinkd-configuration.xml +``` + +- [ ] **Step 4: Update the echo confirmation line** (the one that says `snmpd.conf + entrypoint-wrapper.sh + snmp-config.xml + imports/Self.xml: staged`) to also mention enlinkd-configuration.xml: +```bash +echo " snmpd.conf + entrypoint-wrapper.sh + snmp-config.xml + enlinkd-configuration.xml + imports/Self.xml: staged" +``` + +- [ ] **Step 5: Rebuild the overlay image** +```bash +./build-dark-mode-overlay.sh 2>&1 | tail -20 +``` +Expected: `==> Build complete: localhost/opennms/horizon:35.0.5-dark-mode` + +- [ ] **Step 6: Verify enlinkd-configuration.xml is in the image** +```bash +podman run --rm --entrypoint cat localhost/opennms/horizon:35.0.5-dark-mode \ + /opt/opennms/etc/enlinkd-configuration.xml | grep lldp_rescan +``` +Expected: `lldp_rescan_interval="30000"` + +- [ ] **Step 7: Commit** +```bash +git add build-dark-mode-overlay.sh +git commit -m "feat(topology-lab): 30s enlinkd rescan + snmp range for test fabric" +``` + +--- + +## Task 2: Create `start-topology-lab.sh` — skeleton and topology-node image + +**Files:** +- Create: `start-topology-lab.sh` + +This task writes the script header, flag parsing, helper functions, and Phase 1 (image build). The topology-node Dockerfile, entrypoints, and snmpd.conf are all heredocs. + +- [ ] **Step 1: Create `start-topology-lab.sh`** with the following content: + +```bash +#!/usr/bin/env bash +# start-topology-lab.sh +# Spins up a 5-node leaf/spine topology lab alongside test-opennms. +# Nodes run FRR (OSPF + ISIS) + lldpd (LLDP) + net-snmp for EnLinkd testing. +# +# Usage: +# ./start-topology-lab.sh # start lab (teardown + rebuild if running) +# ./start-topology-lab.sh --teardown # teardown only +# ./start-topology-lab.sh --rebuild # force image rebuild + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TOPO_IMAGE="localhost/opennms/topology-node:latest" +FRR_BASE="docker.io/frrouting/frr:9.1.0" + +TEARDOWN_ONLY=false +FORCE_REBUILD=false +for arg in "$@"; do + case "$arg" in + --teardown) TEARDOWN_ONLY=true ;; + --rebuild) FORCE_REBUILD=true ;; + esac +done + +# --------------------------------------------------------------------------- +# Node definitions: name, mgmt-ip, role, and P2P network assignments +# --------------------------------------------------------------------------- +# Format: "name mgmt-ip role net1:iface1:ip1 [net2:iface2:ip2] ..." +NODES=( + "topo-spine-01 10.100.0.11 spine topo-s1-l1:eth1:10.101.1.1 topo-s1-l2:eth2:10.101.2.1 topo-s1-l3:eth3:10.101.3.1" + "topo-spine-02 10.100.0.12 spine topo-s2-l1:eth1:10.101.4.1 topo-s2-l2:eth2:10.101.5.1 topo-s2-l3:eth3:10.101.6.1" + "topo-leaf-01 10.100.0.21 leaf topo-s1-l1:eth1:10.101.1.2 topo-s2-l1:eth2:10.101.4.2" + "topo-leaf-02 10.100.0.22 leaf topo-s1-l2:eth1:10.101.2.2 topo-s2-l2:eth2:10.101.5.2" + "topo-leaf-03 10.100.0.23 leaf topo-s1-l3:eth1:10.101.3.2 topo-s2-l3:eth2:10.101.6.2" +) + +MGMT_NET="topology-mgmt" +MGMT_SUBNET="10.100.0.0/24" +P2P_NETS=( + "topo-s1-l1:10.101.1.0/30" + "topo-s1-l2:10.101.2.0/30" + "topo-s1-l3:10.101.3.0/30" + "topo-s2-l1:10.101.4.0/30" + "topo-s2-l2:10.101.5.0/30" + "topo-s2-l3:10.101.6.0/30" +) + +# --------------------------------------------------------------------------- +# Teardown +# --------------------------------------------------------------------------- +teardown() { + echo "==> Tearing down topology lab..." + + # Remove containers + for node_def in "${NODES[@]}"; do + name=$(echo "$node_def" | awk '{print $1}') + podman rm -f "$name" 2>/dev/null && echo " removed container: $name" || true + done + + # Disconnect OpenNMS from management network + podman network disconnect "${MGMT_NET}" test-opennms 2>/dev/null && \ + echo " disconnected test-opennms from ${MGMT_NET}" || true + + # Remove staged FRR configs + rm -rf "${SCRIPT_DIR}/.topology-lab" 2>/dev/null && echo " removed staged configs" || true + + # Remove networks + podman network rm -f "${MGMT_NET}" 2>/dev/null && echo " removed network: ${MGMT_NET}" || true + for entry in "${P2P_NETS[@]}"; do + net="${entry%%:*}" + podman network rm -f "$net" 2>/dev/null && echo " removed network: $net" || true + done + + echo "==> Teardown complete." +} + +if [[ "${TEARDOWN_ONLY}" == "true" ]]; then + teardown + exit 0 +fi + +# Always tear down first for clean state +teardown + +# --------------------------------------------------------------------------- +# Phase 1: Build topology-node image +# --------------------------------------------------------------------------- +echo "" +echo "==> [1/7] Building topology-node image..." + +if podman image exists "${TOPO_IMAGE}" && [[ "${FORCE_REBUILD}" == "false" ]]; then + echo " Image already exists, skipping build (use --rebuild to force)" +else + BUILD_DIR="$(mktemp -d)" + trap 'rm -rf "$BUILD_DIR"' EXIT + + # ---- snmpd.conf ---- + cat > "${BUILD_DIR}/snmpd.conf" <<'SNMPD' +master agentx +agentXSocket /var/agentx/master +rocommunity public +syslocation "OpenNMS Topology Lab" +syscontact "admin@localhost" +SNMPD + + # ---- entrypoint.sh ---- + cat > "${BUILD_DIR}/entrypoint.sh" <<'ENTRY' +#!/bin/bash +set -e + +# Create AgentX socket directory +mkdir -p /var/agentx +chmod 755 /var/agentx + +# Start snmpd (master AgentX agent) — must be up before subagents connect +/usr/sbin/snmpd -Lo -p /tmp/snmpd.pid -c /etc/snmp/snmpd.conf +sleep 1 + +# For leaf nodes: create bridge interface for Bridge-MIB +if [[ "${ROLE}" == "leaf" ]]; then + ip link add br0 type bridge 2>/dev/null || true + ip link add dummy0 type dummy 2>/dev/null || true + ip link set dummy0 master br0 2>/dev/null || true + ip link set br0 up + ip link set dummy0 up +fi + +# Start lldpd as AgentX subagent (-x = AgentX, -d = debug/foreground logs) +lldpd -x & +sleep 1 + +# Start FRR (zebra + ospfd + isisd via watchfrr) +exec /usr/lib/frr/docker-start +ENTRY + chmod +x "${BUILD_DIR}/entrypoint.sh" + + # ---- Dockerfile ---- + cat > "${BUILD_DIR}/Dockerfile" <&1 | head -40 +``` +Expected: image build completes, you see `Build complete.` (will fail later when trying to create networks, that's fine — we haven't written those phases yet) + +- [ ] **Step 4: Verify the image was built and has the right tools** +```bash +podman run --rm localhost/opennms/topology-node:latest which lldpd +podman run --rm localhost/opennms/topology-node:latest which snmpd +podman run --rm localhost/opennms/topology-node:latest which ospfd +``` +Expected: `/usr/sbin/lldpd`, `/usr/sbin/snmpd`, `/usr/lib/frr/ospfd` (or similar paths) + +- [ ] **Step 5: Commit** +```bash +git add start-topology-lab.sh +git commit -m "feat(topology-lab): add start script skeleton + topology-node image build" +``` + +--- + +## Task 3: Phase 2 — Network creation + Phase 3 — FRR config staging + +**Files:** +- Modify: `start-topology-lab.sh` (append after the Phase 1 block) + +- [ ] **Step 1: Append the network creation phase** to `start-topology-lab.sh` (after the Phase 1 closing `fi`): + +```bash +# --------------------------------------------------------------------------- +# Phase 2: Create networks +# --------------------------------------------------------------------------- +echo "" +echo "==> [2/7] Creating podman networks..." + +podman network create --subnet "${MGMT_SUBNET}" "${MGMT_NET}" +echo " created: ${MGMT_NET} (${MGMT_SUBNET})" + +for entry in "${P2P_NETS[@]}"; do + net="${entry%%:*}" + subnet="${entry##*:}" + podman network create --subnet "${subnet}" "${net}" + echo " created: ${net} (${subnet})" +done + +# --------------------------------------------------------------------------- +# Phase 3: Stage per-node FRR configs +# --------------------------------------------------------------------------- +echo "" +echo "==> [3/7] Staging FRR configs..." + +# Fixed location so bind-mount paths survive manual container restarts +CONFIGS="${SCRIPT_DIR}/.topology-lab/configs" +rm -rf "${CONFIGS}" +mkdir -p "${CONFIGS}" +echo " config staging dir: ${CONFIGS}" + +# vtysh.conf is identical for all nodes +VTYSH_CONF="service integrated-vtysh-config" + +# ---- spine-01 ---- +mkdir -p "${CONFIGS}/spine-01" +cat > "${CONFIGS}/spine-01/daemons" <<'DAEMONS' +zebra=yes +ospfd=yes +isisd=yes +bgpd=no +ripd=no +ospf6d=no +DAEMONS +cat > "${CONFIGS}/spine-01/vtysh.conf" <<< "${VTYSH_CONF}" +cat > "${CONFIGS}/spine-01/frr.conf" <<'FRR' +frr version 9.1 +frr defaults traditional +hostname spine-01 +agentx +! +interface lo + ip address 10.255.0.11/32 +! +interface eth1 + description link-to-leaf-01 + ip address 10.101.1.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth2 + description link-to-leaf-02 + ip address 10.101.2.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth3 + description link-to-leaf-03 + ip address 10.101.3.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +router ospf + ospf router-id 10.255.0.11 + network 10.255.0.11/32 area 0.0.0.0 +! +router isis FABRIC + net 49.0001.0aff.000b.00 + is-type level-2-only + metric-style wide +! +FRR + +# ---- spine-02 ---- +mkdir -p "${CONFIGS}/spine-02" +cat > "${CONFIGS}/spine-02/daemons" <<'DAEMONS' +zebra=yes +ospfd=yes +isisd=yes +bgpd=no +ripd=no +ospf6d=no +DAEMONS +cat > "${CONFIGS}/spine-02/vtysh.conf" <<< "${VTYSH_CONF}" +cat > "${CONFIGS}/spine-02/frr.conf" <<'FRR' +frr version 9.1 +frr defaults traditional +hostname spine-02 +agentx +! +interface lo + ip address 10.255.0.12/32 +! +interface eth1 + description link-to-leaf-01 + ip address 10.101.4.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth2 + description link-to-leaf-02 + ip address 10.101.5.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth3 + description link-to-leaf-03 + ip address 10.101.6.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +router ospf + ospf router-id 10.255.0.12 + network 10.255.0.12/32 area 0.0.0.0 +! +router isis FABRIC + net 49.0001.0aff.000c.00 + is-type level-2-only + metric-style wide +! +FRR + +# ---- leaf-01 ---- +mkdir -p "${CONFIGS}/leaf-01" +cat > "${CONFIGS}/leaf-01/daemons" <<'DAEMONS' +zebra=yes +ospfd=yes +isisd=yes +bgpd=no +ripd=no +ospf6d=no +DAEMONS +cat > "${CONFIGS}/leaf-01/vtysh.conf" <<< "${VTYSH_CONF}" +cat > "${CONFIGS}/leaf-01/frr.conf" <<'FRR' +frr version 9.1 +frr defaults traditional +hostname leaf-01 +agentx +! +interface lo + ip address 10.255.0.21/32 +! +interface eth1 + description uplink-to-spine-01 + ip address 10.101.1.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth2 + description uplink-to-spine-02 + ip address 10.101.4.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +router ospf + ospf router-id 10.255.0.21 + network 10.255.0.21/32 area 0.0.0.0 +! +router isis FABRIC + net 49.0001.0aff.0015.00 + is-type level-2-only + metric-style wide +! +FRR + +# ---- leaf-02 ---- +mkdir -p "${CONFIGS}/leaf-02" +cat > "${CONFIGS}/leaf-02/daemons" <<'DAEMONS' +zebra=yes +ospfd=yes +isisd=yes +bgpd=no +ripd=no +ospf6d=no +DAEMONS +cat > "${CONFIGS}/leaf-02/vtysh.conf" <<< "${VTYSH_CONF}" +cat > "${CONFIGS}/leaf-02/frr.conf" <<'FRR' +frr version 9.1 +frr defaults traditional +hostname leaf-02 +agentx +! +interface lo + ip address 10.255.0.22/32 +! +interface eth1 + description uplink-to-spine-01 + ip address 10.101.2.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth2 + description uplink-to-spine-02 + ip address 10.101.5.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +router ospf + ospf router-id 10.255.0.22 + network 10.255.0.22/32 area 0.0.0.0 +! +router isis FABRIC + net 49.0001.0aff.0016.00 + is-type level-2-only + metric-style wide +! +FRR + +# ---- leaf-03 ---- +mkdir -p "${CONFIGS}/leaf-03" +cat > "${CONFIGS}/leaf-03/daemons" <<'DAEMONS' +zebra=yes +ospfd=yes +isisd=yes +bgpd=no +ripd=no +ospf6d=no +DAEMONS +cat > "${CONFIGS}/leaf-03/vtysh.conf" <<< "${VTYSH_CONF}" +cat > "${CONFIGS}/leaf-03/frr.conf" <<'FRR' +frr version 9.1 +frr defaults traditional +hostname leaf-03 +agentx +! +interface lo + ip address 10.255.0.23/32 +! +interface eth1 + description uplink-to-spine-01 + ip address 10.101.3.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth2 + description uplink-to-spine-02 + ip address 10.101.6.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +router ospf + ospf router-id 10.255.0.23 + network 10.255.0.23/32 area 0.0.0.0 +! +router isis FABRIC + net 49.0001.0aff.0017.00 + is-type level-2-only + metric-style wide +! +FRR + +echo " FRR configs staged for all 5 nodes." +``` + +- [ ] **Step 2: Run to verify networks and configs are created** +```bash +./start-topology-lab.sh 2>&1 | head -60 +``` +Expected output ends with `FRR configs staged for all 5 nodes.` (will fail at Phase 4 which doesn't exist yet) + +- [ ] **Step 3: Verify networks exist** +```bash +podman network ls | grep -E "topology-mgmt|topo-s" +``` +Expected: 7 networks listed + +- [ ] **Step 4: Teardown to reset** +```bash +./start-topology-lab.sh --teardown +podman network ls | grep -E "topology-mgmt|topo-s" +``` +Expected: no networks in the grep output + +- [ ] **Step 5: Commit** +```bash +git add start-topology-lab.sh +git commit -m "feat(topology-lab): add network creation + FRR config staging phases" +``` + +--- + +## Task 4: Phase 4 — Container launch + +**Files:** +- Modify: `start-topology-lab.sh` (append after Phase 3) + +- [ ] **Step 1: Append the container launch phase** to `start-topology-lab.sh`: + +```bash +# --------------------------------------------------------------------------- +# Phase 4: Start containers +# --------------------------------------------------------------------------- +echo "" +echo "==> [4/7] Starting topology nodes..." + +# Each entry in NODES: "name mgmt-ip role net1:iface1:ip1 ..." +for node_def in "${NODES[@]}"; do + read -ra parts <<< "$node_def" + name="${parts[0]}" + mgmt_ip="${parts[1]}" + role="${parts[2]}" + # Derive the short hostname (strip "topo-" prefix) + hostname="${name#topo-}" + # Derive frr config dir from hostname + frr_dir="${CONFIGS}/${hostname}" + + # Build --network args as array: management first (eth0), then P2P links + net_args=( + "--network" "${MGMT_NET}:interface_name=eth0,ip=${mgmt_ip}" + ) + for net_entry in "${parts[@]:3}"; do + net="${net_entry%%:*}" + rest="${net_entry#*:}" + iface="${rest%%:*}" + ip="${rest##*:}" + net_args+=("--network" "${net}:interface_name=${iface},ip=${ip}") + done + + podman run -d --privileged \ + --name "${name}" \ + --hostname "${hostname}" \ + -e "ROLE=${role}" \ + -v "${frr_dir}:/etc/frr:ro" \ + "${net_args[@]}" \ + "${TOPO_IMAGE}" + + echo " started: ${name} (mgmt: ${mgmt_ip}, role: ${role})" +done +``` + +- [ ] **Step 2: Run through Phase 4** +```bash +./start-topology-lab.sh 2>&1 | head -80 +``` +Expected: all 5 containers started (will fail at Phase 5 which doesn't exist yet) + +- [ ] **Step 3: Verify containers are running** +```bash +podman ps --filter "name=topo-" --format "{{.Names}}\t{{.Status}}" +``` +Expected: 5 rows, all `Up` + +- [ ] **Step 4: Spot-check FRR is running inside a spine** +```bash +podman exec topo-spine-01 vtysh -c "show version" 2>&1 | head -5 +``` +Expected: `FRRouting 9.1.0` (or similar) + +- [ ] **Step 5: Spot-check lldpd is running** +```bash +podman exec topo-spine-01 lldpcli show chassis | head -5 +``` +Expected: chassis info for spine-01 + +- [ ] **Step 6: Spot-check snmpd is running** +```bash +podman exec topo-spine-01 snmpwalk -v2c -c public 127.0.0.1 sysName.0 +``` +Expected: `SNMPv2-MIB::sysName.0 = STRING: spine-01` + +- [ ] **Step 7: Teardown + commit** +```bash +./start-topology-lab.sh --teardown +git add start-topology-lab.sh +git commit -m "feat(topology-lab): add container launch phase" +``` + +--- + +## Task 5: Phase 5+6 — OpenNMS attachment and requisition import + +**Files:** +- Modify: `start-topology-lab.sh` (append after Phase 4) + +- [ ] **Step 1: Append the OpenNMS integration phase** to `start-topology-lab.sh`: + +```bash +# --------------------------------------------------------------------------- +# Phase 5: Attach test-opennms to management network +# --------------------------------------------------------------------------- +echo "" +echo "==> [5/7] Attaching test-opennms to ${MGMT_NET}..." + +if podman network inspect "${MGMT_NET}" \ + --format '{{range .Containers}}{{.Name}} {{end}}' 2>/dev/null \ + | grep -q "test-opennms"; then + echo " already connected, skipping" +else + podman network connect \ + --ip 10.100.0.10 \ + "${MGMT_NET}" test-opennms + echo " connected test-opennms (10.100.0.10)" +fi + +# --------------------------------------------------------------------------- +# Phase 6: Drop requisition and trigger import +# --------------------------------------------------------------------------- +echo "" +echo "==> [6/7] Provisioning topology nodes into OpenNMS..." + +REQ_FILE="$(mktemp)" +cat > "${REQ_FILE}" <<'REQUISITION' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +REQUISITION + +podman cp "${REQ_FILE}" test-opennms:/opt/opennms/etc/imports/Topology-Lab.xml +rm -f "${REQ_FILE}" + +# Trigger import via REST (provisiond picks it up within seconds) +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -u admin:notdefault \ + -X PUT \ + "http://localhost:8980/opennms/rest/requisitions/Topology-Lab/import?rescanExisting=true") +if [[ "${HTTP_CODE}" == "202" ]]; then + echo " Import triggered (HTTP 202)" +else + echo " WARNING: import REST call returned HTTP ${HTTP_CODE} (may still work if file was copied)" +fi +``` + +- [ ] **Step 2: Run through Phase 6** (requires `test-opennms` to be running and healthy) +```bash +./start-topology-lab.sh 2>&1 | grep -E "^\s*(==>| )" +``` +Expected: all 6 phases complete without errors + +- [ ] **Step 3: Verify test-opennms is on the management network** +```bash +podman network inspect topology-mgmt --format '{{range .Containers}}{{.Name}} {{end}}' +``` +Expected: output includes `test-opennms` and all 5 `topo-*` containers + +- [ ] **Step 4: Verify the requisition file landed** +```bash +podman exec test-opennms cat /opt/opennms/etc/imports/Topology-Lab.xml | grep node-label +``` +Expected: 5 `node-label` attributes (spine-01, spine-02, leaf-01, leaf-02, leaf-03) + +- [ ] **Step 5: Wait ~30s, then verify nodes appear in OpenNMS** +```bash +sleep 30 +curl -s -u admin:notdefault \ + "http://localhost:8980/opennms/rest/nodes?category=Topology-Lab" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{d[\"count\"]} nodes')" +``` +Expected: `5 nodes` + +- [ ] **Step 6: Teardown + commit** +```bash +./start-topology-lab.sh --teardown +git add start-topology-lab.sh +git commit -m "feat(topology-lab): add OpenNMS attach + requisition import phase" +``` + +--- + +## Task 6: Phase 7 — Convergence check and summary + +**Files:** +- Modify: `start-topology-lab.sh` (append after Phase 6) + +- [ ] **Step 1: Append the convergence check phase** to `start-topology-lab.sh`: + +```bash +# --------------------------------------------------------------------------- +# Phase 7: Wait for OSPF convergence +# --------------------------------------------------------------------------- +echo "" +echo "==> [7/7] Waiting for OSPF convergence (cap: 120s)..." + +CONVERGED=false +for i in $(seq 1 24); do + # Check spine-01 has 3 Full OSPF neighbors + count=$(podman exec topo-spine-01 vtysh -c "show ip ospf neighbor" 2>/dev/null \ + | grep -c "Full/" || true) + if [[ "${count}" -ge 3 ]]; then + CONVERGED=true + echo " OSPF converged after $((i * 5))s (spine-01 sees ${count} Full neighbors)" + break + fi + printf " ... waiting (%ds, spine-01 Full neighbors: %d/3)\r" "$((i * 5))" "${count}" + sleep 5 +done +echo "" + +if [[ "${CONVERGED}" == "false" ]]; then + echo "ERROR: OSPF did not converge within 120s. Diagnostics:" >&2 + echo "--- spine-01 ospf neighbors ---" >&2 + podman exec topo-spine-01 vtysh -c "show ip ospf neighbor" >&2 || true + echo "--- spine-01 isis neighbors ---" >&2 + podman exec topo-spine-01 vtysh -c "show isis neighbor" >&2 || true + echo "--- spine-01 lldp neighbors ---" >&2 + podman exec topo-spine-01 lldpcli show neighbors >&2 || true + exit 1 +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "============================================================" +echo " Topology Lab is UP" +echo "============================================================" +echo "" +echo " Nodes:" +for node_def in "${NODES[@]}"; do + read -ra parts <<< "$node_def" + printf " %-18s %s (%s)\n" "${parts[0]}" "${parts[1]}" "${parts[2]}" +done +echo "" +echo " Verify topology layers:" +echo " OSPF: podman exec topo-spine-01 vtysh -c 'show ip ospf neighbor'" +echo " ISIS: podman exec topo-spine-01 vtysh -c 'show isis neighbor'" +echo " LLDP: podman exec topo-spine-01 lldpcli show neighbors" +echo " SNMP: snmpwalk -v2c -c public 10.100.0.11 LLDP-MIB::lldpRemTable" +echo "" +echo " Wait ~30-60s for EnLinkd to collect, then open:" +echo " http://localhost:8980/opennms/ → navigate to /#/topology" +echo "" +echo " Teardown: ./start-topology-lab.sh --teardown" +echo "============================================================" +``` + +- [ ] **Step 2: Run the full script end-to-end** (requires running `test-opennms`) +```bash +./start-topology-lab.sh +``` +Expected: completes with `Topology Lab is UP` summary + +- [ ] **Step 3: Verify OSPF on both spines** +```bash +podman exec topo-spine-01 vtysh -c "show ip ospf neighbor" +podman exec topo-spine-02 vtysh -c "show ip ospf neighbor" +``` +Expected: each spine shows 3 neighbors in `Full/` state + +- [ ] **Step 4: Verify ISIS on spine-01** +```bash +podman exec topo-spine-01 vtysh -c "show isis neighbor" +``` +Expected: 3 entries in `Up` state + +- [ ] **Step 5: Verify LLDP on spine-01** +```bash +podman exec topo-spine-01 lldpcli show neighbors +``` +Expected: 3 neighbor entries (leaf-01, leaf-02, leaf-03) + +- [ ] **Step 6: Commit** +```bash +git add start-topology-lab.sh +git commit -m "feat(topology-lab): add convergence check + summary phase" +``` + +--- + +## Task 7: Create `stop-topology-lab.sh` + +**Files:** +- Create: `stop-topology-lab.sh` + +- [ ] **Step 1: Create `stop-topology-lab.sh`**: + +```bash +#!/usr/bin/env bash +# stop-topology-lab.sh — Tears down the topology lab. +# Equivalent to: ./start-topology-lab.sh --teardown +set -euo pipefail +exec "$(dirname "$0")/start-topology-lab.sh" --teardown +``` + +- [ ] **Step 2: Make executable and verify** +```bash +chmod +x stop-topology-lab.sh +./stop-topology-lab.sh +podman ps --filter "name=topo-" --format "{{.Names}}" +``` +Expected: no output (containers removed) + +- [ ] **Step 3: Commit** +```bash +git add stop-topology-lab.sh +git commit -m "feat(topology-lab): add stop-topology-lab.sh teardown helper" +``` + +--- + +## Task 8: End-to-end topology layer verification + +This task runs the full lab and verifies each topology layer is visible in OpenNMS via SNMP and the topology API. Run with `test-opennms` healthy and using the freshly rebuilt overlay image from Task 1. + +- [ ] **Step 1: Rebuild the overlay image** (picks up enlinkd-configuration.xml from Task 1) +```bash +./build-dark-mode-overlay.sh 2>&1 | tail -5 +``` +Expected: `==> Build complete: localhost/opennms/horizon:35.0.5-dark-mode` + +- [ ] **Step 2: Restart test-opennms with the new image** +```bash +podman rm -f test-opennms 2>/dev/null || true +podman run -d --name test-opennms --privileged \ + -p 8980:8980 -p 8101:8101 \ + -e POSTGRES_HOST=host.containers.internal \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e OPENNMS_DBNAME=opennms \ + -e OPENNMS_DBUSER=opennms \ + -e OPENNMS_DBPASS=opennms \ + localhost/opennms/horizon:35.0.5-dark-mode -s +``` + +- [ ] **Step 3: Wait for OpenNMS to be healthy** +```bash +for i in $(seq 1 12); do + code=$(curl -s -o /dev/null -w "%{http_code}" -u admin:admin http://localhost:8980/opennms/rest/info || echo 0) + [[ "$code" == "200" ]] && echo "ready" && break + echo " waiting... (${i}/12)" + sleep 5 +done +``` +Expected: `ready` within 60s + +- [ ] **Step 4: Set password** +```bash +HASH=$(podman exec test-opennms java -cp /opt/opennms/lib/jasypt-1.9.3.jar \ + org.jasypt.intf.cli.JasyptStringDigestCLI \ + input='notdefault' algorithm=SHA-256 saltSizeBytes=16 iterations=100000 2>/dev/null \ + | tail -3 | head -1) +podman exec test-opennms sed -i \ + "s|.*|${HASH}|" \ + /opt/opennms/etc/users.xml +``` + +- [ ] **Step 5: Start the topology lab** +```bash +./start-topology-lab.sh +``` +Expected: `Topology Lab is UP` + +- [ ] **Step 6: Verify SNMP LLDP-MIB on spine-01** +```bash +snmpwalk -v2c -c public 10.100.0.11 LLDP-MIB::lldpRemTable 2>&1 | head -20 +``` +Expected: OID entries for 3 remote neighbors + +- [ ] **Step 7: Verify SNMP OSPF-MIB on spine-01** +```bash +snmpwalk -v2c -c public 10.100.0.11 OSPF-MIB::ospfNbrTable 2>&1 | head -20 +``` +Expected: entries for 3 OSPF neighbors + +- [ ] **Step 8: Verify Bridge-MIB on leaf-01** +```bash +snmpwalk -v2c -c public 10.100.0.21 BRIDGE-MIB::dot1dBase 2>&1 +``` +Expected: `dot1dBaseNumPorts`, `dot1dBaseType` entries for br0 + +- [ ] **Step 9: Verify IpNetToMedia on spine-01** +```bash +snmpwalk -v2c -c public 10.100.0.11 IP-MIB::ipNetToMediaTable 2>&1 | head -10 +``` +Expected: ARP entries showing IPs on the P2P subnets + +- [ ] **Step 10: Wait for EnLinkd to collect (~60s), then check topology API** +```bash +sleep 60 +curl -s -u admin:notdefault \ + http://localhost:8980/opennms/api/v2/graphs \ + | python3 -m json.tool | grep -A2 '"id"' +``` +Expected: enlinkd graph container entries (lldp, ospf, isis, bridge, ipnettomedia namespaces) + +- [ ] **Step 11: Open topology viewer and visually confirm leaf/spine graph** + +Navigate to `http://localhost:8980/opennms/` → `/#/topology`. Select the EnLinkd LLDP layer. Expected: 5 nodes with spine-01 and spine-02 connected to all three leaf nodes, no cross-spine or cross-leaf links. + +- [ ] **Step 12: Final commit** +```bash +git add docs/superpowers/plans/2026-04-05-topology-lab.md +git commit -m "docs: add topology lab implementation plan" +``` diff --git a/docs/superpowers/plans/2026-04-06-app-wide-design-language.md b/docs/superpowers/plans/2026-04-06-app-wide-design-language.md new file mode 100644 index 000000000000..e747896c6081 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-app-wide-design-language.md @@ -0,0 +1,1363 @@ +# App-Wide Design Language — Problem-First Perspective Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a global "Problems | Full" perspective toggle that silences healthy content by default across Node Detail, Alarms, Outages, and Surveillance Dashboard. + +**Architecture:** A Pinia store (`usePerspectiveStore`) holds a single `'problems' | 'full'` value persisted to localStorage. A reusable `PerspectiveToggle.vue` pill renders on each applicable page. Components read `perspectiveStore.isProblems` (or receive it as a prop) and suppress healthy content accordingly. Three shared utility components (`ClearSummary`, `CollapsibleSection`, `StatusSummaryLine`) provide the visual language for collapsed/healthy state. + +**Tech Stack:** Vue 3 + Pinia, Feather DS (`@featherds/styles`), SCSS with `var($feather-*)` tokens. Build: `yarn build` in `ui/`. Deploy: `./deploy-to-container.sh test-opennms`. + +--- + +## File Map + +### New files +| File | Purpose | +|------|---------| +| `ui/src/stores/perspectiveStore.ts` | Global perspective state (`'problems' \| 'full'`) with localStorage persistence | +| `ui/src/components/Common/PerspectiveToggle.vue` | Two-segment pill button rendered on applicable pages | +| `ui/src/components/Common/ClearSummary.vue` | "✓ All X healthy" line replacing suppressed sections | +| `ui/src/components/Common/CollapsibleSection.vue` | Collapsible card wrapper for reference sections | +| `ui/src/components/Common/StatusSummaryLine.vue` | "Showing X of Y with problems" line for list pages | + +### Modified files +| File | Change | +|------|--------| +| `ui/src/containers/NodeDetails.vue` | Add page-controls bar (toggle + View Graphs link); wrap info/cat in CollapsibleSection; pass `problemsOnly` to sub-panels; hide ResourceGraphsPanel in problems mode | +| `ui/src/components/NodeDetail/AvailabilityPanel.vue` | Accept `problemsOnly` prop; filter 100% service cards; show ClearSummary when all healthy | +| `ui/src/components/NodeDetail/NetworkTab.vue` | Accept `problemsOnly` prop; filter to down-only endpoints; show ClearSummary when all up | +| `ui/src/components/Nodes/AlarmsTable.vue` | Accept `extraFiql?: string` prop; merge into FIQL query | +| `ui/src/components/Nodes/OutagesTable.vue` | Accept `filterFiql?: string` prop; merge into query params | +| `ui/src/components/NodeDetail/NodeActivityTab.vue` | Read perspectiveStore; pass active-only FIQL to AlarmsTable + OutagesTable | +| `ui/src/containers/Alarms.vue` | Add PerspectiveToggle | +| `ui/src/components/Alarms/AlarmsListTable.vue` | Watch perspectiveStore; reset ackStatus + severities on perspective change | +| `ui/src/containers/Outages.vue` | Add PerspectiveToggle | +| `ui/src/components/Outages/OutagesListTable.vue` | Watch perspectiveStore; reset statusFilter on perspective change | +| `ui/src/containers/SurveillanceDashboard.vue` | Add PerspectiveToggle; pass `dimHealthy` to SurveillanceGrid | +| `ui/src/components/SurveillanceDashboard/SurveillanceGrid.vue` | Accept `dimHealthy` prop; reduce opacity on NORMAL cells with 0 downCount | + +--- + +## Task 1: Perspective Store + +**Files:** +- Create: `ui/src/stores/perspectiveStore.ts` + +- [ ] **Step 1: Create the store** + +```ts +// ui/src/stores/perspectiveStore.ts +import { defineStore } from 'pinia' + +const STORAGE_KEY = 'onms.perspective' + +export const usePerspectiveStore = defineStore('perspectiveStore', () => { + const perspective = ref<'problems' | 'full'>( + (localStorage.getItem(STORAGE_KEY) as 'problems' | 'full') ?? 'problems' + ) + + const isProblems = computed(() => perspective.value === 'problems') + + function setPerspective(value: 'problems' | 'full') { + perspective.value = value + localStorage.setItem(STORAGE_KEY, value) + } + + return { perspective, isProblems, setPerspective } +}) +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/stores/perspectiveStore.ts +git commit -m "feat(perspective): add usePerspectiveStore with localStorage persistence" +``` + +--- + +## Task 2: Shared Components — PerspectiveToggle, ClearSummary, CollapsibleSection, StatusSummaryLine + +**Files:** +- Create: `ui/src/components/Common/PerspectiveToggle.vue` +- Create: `ui/src/components/Common/ClearSummary.vue` +- Create: `ui/src/components/Common/CollapsibleSection.vue` +- Create: `ui/src/components/Common/StatusSummaryLine.vue` + +- [ ] **Step 1: Create PerspectiveToggle.vue** + +```vue + + + + + + +``` + +- [ ] **Step 2: Create ClearSummary.vue** + +```vue + + + + + + +``` + +- [ ] **Step 3: Create CollapsibleSection.vue** + +```vue + + + + + + +``` + +- [ ] **Step 4: Create StatusSummaryLine.vue** + +```vue + + + + + + +``` + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/components/Common/PerspectiveToggle.vue \ + ui/src/components/Common/ClearSummary.vue \ + ui/src/components/Common/CollapsibleSection.vue \ + ui/src/components/Common/StatusSummaryLine.vue +git commit -m "feat(perspective): add shared PerspectiveToggle, ClearSummary, CollapsibleSection, StatusSummaryLine" +``` + +--- + +## Task 3: Node Detail — Availability Panel Problems Mode + +**Files:** +- Modify: `ui/src/components/NodeDetail/AvailabilityPanel.vue` + +The panel currently shows all service cards for every IP interface. In problems mode, filter out services at 100% availability. If all services are 100%, replace the entire panel with a ClearSummary. + +- [ ] **Step 1: Add `problemsOnly` prop and filter logic** + +In `AvailabilityPanel.vue`, add the prop and a computed for filtered interfaces. Replace the `availability.ipinterfaces` loop with `filteredInterfaces`. Add ClearSummary import and usage. + +The full updated ` + + + + diff --git a/ui/src/containers/EventDetail.vue b/ui/src/containers/EventDetail.vue new file mode 100644 index 000000000000..dc79f24c5848 --- /dev/null +++ b/ui/src/containers/EventDetail.vue @@ -0,0 +1,249 @@ + + + + + + + diff --git a/ui/src/containers/OutageDetail.vue b/ui/src/containers/OutageDetail.vue new file mode 100644 index 000000000000..df60f74b2fce --- /dev/null +++ b/ui/src/containers/OutageDetail.vue @@ -0,0 +1,218 @@ + + + + + + diff --git a/ui/src/containers/Outages.vue b/ui/src/containers/Outages.vue new file mode 100644 index 000000000000..29c2aabbbe03 --- /dev/null +++ b/ui/src/containers/Outages.vue @@ -0,0 +1,63 @@ + + + + + + diff --git a/ui/src/services/alarmService.ts b/ui/src/services/alarmService.ts index 92ffc364064e..4a9a6d3ea7a0 100644 --- a/ui/src/services/alarmService.ts +++ b/ui/src/services/alarmService.ts @@ -21,7 +21,7 @@ /// import { v2, rest } from './axiosInstances' -import { QueryParameters, AlarmQueryParameters, AlarmApiResponse } from '@/types' +import { QueryParameters, AlarmQueryParameters, AlarmApiResponse, Alarm, AlarmAcknowledgment } from '@/types' import { queryParametersHandler } from './serviceHelpers' const endpoint = '/alarms' @@ -62,4 +62,84 @@ const modifyAlarm = async (alarmId: string, alarmQueryParameters: AlarmQueryPara } } -export { getAlarms, modifyAlarm } +const getAlarmById = async (id: string | number): Promise => { + try { + const resp = await v2.get(`${endpoint}/${id}`) + return resp.data + } catch (err) { + return false + } +} + +const getNodeAlarms = async (nodeId: number, limit = 10): Promise => { + try { + const resp = await v2.get(endpoint, { + params: { '_s': `nodeId==${nodeId}`, limit } + }) + if (resp.status === 204) return [] + return resp.data?.alarm ?? [] + } catch { + return [] + } +} + +const getAlarmAcknowledgments = async (id: string | number): Promise => { + try { + const resp = await rest.get(`/acks?alarmId=${id}`) + return resp.data?.ack ?? [] + } catch (err) { + return [] + } +} + +const saveStickyMemo = async (id: string | number, body: string): Promise => { + try { + await v2.put(`${endpoint}/${id}/memo`, body, { + headers: { 'Content-Type': 'text/plain' } + }) + return true + } catch (err) { + return false + } +} + +const deleteStickyMemo = async (id: string | number): Promise => { + try { + await v2.delete(`${endpoint}/${id}/memo`) + return true + } catch (err) { + return false + } +} + +const saveJournalMemo = async (id: string | number, body: string): Promise => { + try { + await v2.put(`${endpoint}/${id}/journal`, body, { + headers: { 'Content-Type': 'text/plain' } + }) + return true + } catch (err) { + return false + } +} + +const deleteJournalMemo = async (id: string | number): Promise => { + try { + await v2.delete(`${endpoint}/${id}/journal`) + return true + } catch (err) { + return false + } +} + +export { + getAlarms, + getNodeAlarms, + modifyAlarm, + getAlarmById, + getAlarmAcknowledgments, + saveStickyMemo, + deleteStickyMemo, + saveJournalMemo, + deleteJournalMemo +} diff --git a/ui/src/services/eventService.ts b/ui/src/services/eventService.ts index d679a239340a..26f6afe27368 100644 --- a/ui/src/services/eventService.ts +++ b/ui/src/services/eventService.ts @@ -21,7 +21,7 @@ /// import { v2 } from './axiosInstances' -import { QueryParameters, EventApiResponse } from '@/types' +import { QueryParameters, EventApiResponse, Event } from '@/types' import { queryParametersHandler } from './serviceHelpers' const endpoint = '/events' @@ -47,4 +47,13 @@ const getEvents = async (queryParameters?: QueryParameters): Promise => { + try { + const resp = await v2.get(`${endpoint}/${id}`) + return resp.data + } catch (err) { + return false + } +} + +export { getEvents, getEventById } diff --git a/ui/src/services/localStorageService.ts b/ui/src/services/localStorageService.ts index 3f4539dfb653..adafde7c6207 100644 --- a/ui/src/services/localStorageService.ts +++ b/ui/src/services/localStorageService.ts @@ -21,7 +21,7 @@ /// import { useNodeQuery } from '@/components/Nodes/hooks/useNodeQuery' -import { NodePreferences, OpenNmsPreferences } from '@/types' +import { AlarmPreferences, NodePreferences, OpenNmsPreferences } from '@/types' const OPENNMS_PREFERENCES_STORAGE_KEY = 'opennms-preferences' @@ -84,10 +84,23 @@ const getCircularReplacer = () => { } } +const saveAlarmPreferences = (data: AlarmPreferences) => { + const prefs = loadPreferences() || defaultPreferences() + prefs.alarmPreferences = data + savePreferences(prefs) +} + +const loadAlarmPreferences = (): AlarmPreferences | null => { + const prefs = loadPreferences() || defaultPreferences() + return prefs.alarmPreferences ?? null +} + export { + loadAlarmPreferences, loadDefaultPreferences, loadNodePreferences, loadPreferences, + saveAlarmPreferences, saveNodePreferences, savePreferences } diff --git a/ui/src/services/outageService.ts b/ui/src/services/outageService.ts new file mode 100644 index 000000000000..81482f6ce530 --- /dev/null +++ b/ui/src/services/outageService.ts @@ -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. +/// + +import { v2 } from './axiosInstances' +import { type Outage, OutagesApiResponse } from '@/types' + +const endpoint = '/outages' + +/** + * Builds a FIQL search string for active outages, optionally filtered by node categories. + * Active outages are those where ifRegainedService is null (service still down). + * Multiple categories are OR'd together. + */ +const buildActiveOutageCriteria = (categories: string[]): string => { + const parts = ['ifRegainedService==null'] + if (categories.length === 1) { + parts.push(`node.categories.name==${categories[0]}`) + } else if (categories.length > 1) { + const catClause = categories.map(c => `node.categories.name==${c}`).join(',') + parts.push(`(${catClause})`) + } + return parts.join(';') +} + +const getOutages = async (params: Record): Promise => { + try { + const resp = await v2.get(endpoint, { params }) + if (resp.status === 204) { + return { outage: [], totalCount: 0, count: 0, offset: 0 } + } + const data: OutagesApiResponse = resp.data + // v2 API nests service name at monitoredService.serviceType.name — normalize to top-level + data.outage = (data.outage ?? []).map(o => ({ + ...o, + serviceName: o.serviceName ?? o.monitoredService?.serviceType?.name + })) + return data + } catch { + return false + } +} + +const getActiveOutages = async ( + categories: string[], + limit = 10, + offset = 0, + orderBy?: string, + order?: 'asc' | 'desc' +): Promise => { + const params: Record = { _s: buildActiveOutageCriteria(categories), limit, offset } + if (orderBy) params.orderBy = orderBy + if (order) params.order = order + return getOutages(params) +} + +const getActiveOutageCount = async (categories: string[]): Promise => { + const resp = await getOutages({ _s: buildActiveOutageCriteria(categories), limit: 0 }) + return resp ? resp.totalCount : 0 +} + +const getOutage = async (id: number | string): Promise => { + try { + const resp = await v2.get(`${endpoint}/${id}`) + const o = resp.data + // v2 single-outage response uses nested objects — normalize to flat fields + return { + ...o, + serviceName: o.serviceName ?? o.monitoredService?.serviceType?.name, + lostServiceEventId: o.lostServiceEventId ?? o.serviceLostEvent?.id, + regainedServiceEventId: o.regainedServiceEventId ?? o.serviceRegainedEvent?.id ?? null + } as Outage + } catch { + return false + } +} + +export { getOutages, getActiveOutages, getActiveOutageCount, buildActiveOutageCriteria, getOutage } diff --git a/ui/tests/services/measurementsService.test.ts b/ui/tests/services/measurementsService.test.ts new file mode 100644 index 000000000000..6ac62f7c4313 --- /dev/null +++ b/ui/tests/services/measurementsService.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest' +import { buildSnmpResourceId, pickBestInterface } from '@/services/measurementsService' +import { SnmpInterface } from '@/types' + +const makeIface = (overrides: Partial): SnmpInterface => ({ + collect: true, collectFlag: 'C', collectionUserSpecified: false, + hasEgressFlows: false, hasFlows: false, hasIngressFlows: false, + id: 1, ifAdminStatus: 1, ifAlias: null, ifDescr: 'eth0', ifIndex: 1, + ifName: 'eth0', ifOperStatus: 1, ifSpeed: 1_000_000_000, + ifType: 6, lastCapsdPoll: 0, lastEgressFlow: null, + lastIngressFlow: null, lastSnmpPoll: 0, physAddr: null, poll: true, + ...overrides +}) + +describe('buildSnmpResourceId', () => { + it('uses ifName-physAddr when physAddr is present', () => { + const iface = makeIface({ ifName: 'eth0', physAddr: 'aabbccddeeff' }) + expect(buildSnmpResourceId(42, iface)).toBe('node[42].interfaceSnmp[eth0-aabbccddeeff]') + }) + it('uses just ifName when physAddr is null', () => { + const iface = makeIface({ ifName: 'eth0', physAddr: null }) + expect(buildSnmpResourceId(42, iface)).toBe('node[42].interfaceSnmp[eth0]') + }) + it('falls back to ifDescr when ifName is null', () => { + const iface = makeIface({ ifName: null, ifDescr: 'GigabitEthernet0', physAddr: null }) + expect(buildSnmpResourceId(42, iface)).toBe('node[42].interfaceSnmp[GigabitEthernet0]') + }) +}) + +describe('pickBestInterface', () => { + it('returns null for empty list', () => { + expect(pickBestInterface([])).toBeNull() + }) + it('skips loopback interfaces (ifType 24)', () => { + const loopback = makeIface({ ifType: 24, ifSpeed: 10_000_000_000 }) + const eth = makeIface({ ifType: 6, ifSpeed: 1_000_000_000 }) + expect(pickBestInterface([loopback, eth])).toBe(eth) + }) + it('skips operationally down interfaces (ifOperStatus != 1)', () => { + const down = makeIface({ ifOperStatus: 2, ifSpeed: 10_000_000_000 }) + const up = makeIface({ ifOperStatus: 1, ifSpeed: 1_000_000 }) + expect(pickBestInterface([down, up])).toBe(up) + }) + it('returns the highest-speed interface', () => { + const slow = makeIface({ ifSpeed: 100_000_000 }) + const fast = makeIface({ ifSpeed: 10_000_000_000 }) + expect(pickBestInterface([slow, fast])).toBe(fast) + }) + it('returns null if all interfaces are loopback or down', () => { + const loopback = makeIface({ ifType: 24 }) + const down = makeIface({ ifOperStatus: 2 }) + expect(pickBestInterface([loopback, down])).toBeNull() + }) +}) From acc17839c1037c9e62e781efaf4c8b3d838d6b38 Mon Sep 17 00:00:00 2001 From: Chance Newkirk <40219357+cnewkirk@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:04:07 -0700 Subject: [PATCH 11/35] feat(perses): add Perses resource graphs integration (4 phases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — OpenNMS Perses datasource plugin: QueryEditor, client, fetchMeasurements with OpenNMS timestamps in ms, plugin registration. Phase 2 — PersesPanel/PersesCanvas React mount composable; Feather→MUI theme bridge. Phase 3 — ResourceGraphsPanel replacing Chart.js: ResourceSidebar, AttributeList, QueryBuilder, CustomChart, PinnedGraphs, ResourceAccordion, RrdGraphConverter. Phase 4 — Wire into NodeDetail, support CSV/JSON/XML export, hide-empty-rows toggle. --- ui/src/components/Perses/PersesCanvas.vue | 98 +++ ui/src/components/Perses/PersesPanel.vue | 178 +++++ ui/src/components/Resources/Graph.vue | 644 ++++++++++-------- .../components/Resources/GraphDataTable.vue | 136 +++- .../Resources/plugins/HtmlLegendPlugin.ts | 22 - .../utils/RrdGraphConverter.class.ts | 52 +- ui/src/composables/usePerses.ts | 45 ++ ui/src/composables/usePinnedGraphs.ts | 71 ++ ui/src/composables/useResourceGraphs.ts | 82 +++ .../opennms/OpenNMSDataQueriesProvider.tsx | 57 ++ ui/src/datasource/opennms/QueryEditor.tsx | 58 ++ ui/src/datasource/opennms/client.ts | 22 + ui/src/datasource/opennms/index.ts | 18 + ui/src/datasource/opennms/plugin.ts | 113 +++ ui/src/datasource/opennms/types.ts | 79 +++ ui/src/types/resourceGraphs.ts | 46 ++ .../RrdGraphConverter.perses.test.ts | 108 +++ ui/tests/composables/usePerses.test.ts | 62 ++ .../composables/useResourceGraphs.test.ts | 133 ++++ ui/tests/datasource/opennms-client.test.ts | 94 +++ ui/tests/datasource/opennms-plugin.test.ts | 54 ++ ui/tests/resourceGraphs.test.ts | 84 --- ui/tests/theme/persesTheme.test.ts | 43 ++ 23 files changed, 1901 insertions(+), 398 deletions(-) create mode 100644 ui/src/components/Perses/PersesCanvas.vue create mode 100644 ui/src/components/Perses/PersesPanel.vue create mode 100644 ui/src/composables/usePerses.ts create mode 100644 ui/src/composables/usePinnedGraphs.ts create mode 100644 ui/src/composables/useResourceGraphs.ts create mode 100644 ui/src/datasource/opennms/OpenNMSDataQueriesProvider.tsx create mode 100644 ui/src/datasource/opennms/QueryEditor.tsx create mode 100644 ui/src/datasource/opennms/client.ts create mode 100644 ui/src/datasource/opennms/index.ts create mode 100644 ui/src/datasource/opennms/plugin.ts create mode 100644 ui/src/datasource/opennms/types.ts create mode 100644 ui/src/types/resourceGraphs.ts create mode 100644 ui/tests/components/Resources/RrdGraphConverter.perses.test.ts create mode 100644 ui/tests/composables/usePerses.test.ts create mode 100644 ui/tests/composables/useResourceGraphs.test.ts create mode 100644 ui/tests/datasource/opennms-client.test.ts create mode 100644 ui/tests/datasource/opennms-plugin.test.ts delete mode 100644 ui/tests/resourceGraphs.test.ts create mode 100644 ui/tests/theme/persesTheme.test.ts diff --git a/ui/src/components/Perses/PersesCanvas.vue b/ui/src/components/Perses/PersesCanvas.vue new file mode 100644 index 000000000000..8582636a818d --- /dev/null +++ b/ui/src/components/Perses/PersesCanvas.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/ui/src/components/Perses/PersesPanel.vue b/ui/src/components/Perses/PersesPanel.vue new file mode 100644 index 000000000000..bc4b20360dce --- /dev/null +++ b/ui/src/components/Perses/PersesPanel.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/ui/src/components/Resources/Graph.vue b/ui/src/components/Resources/Graph.vue index a5cdbbbaafe2..366ec9dae366 100644 --- a/ui/src/components/Resources/Graph.vue +++ b/ui/src/components/Resources/Graph.vue @@ -1,336 +1,420 @@ +/// +/// 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. +/// - + - for (const [index, column] of graphData.value?.columns.entries() || []) { - const datasets = getDatasetsForColumn(index, column.values, graphData.value?.formattedLabels || []) - sets = [...sets, ...datasets] + + &__data-section { + padding: 14px; + } - diff --git a/ui/src/components/Resources/GraphDataTable.vue b/ui/src/components/Resources/GraphDataTable.vue index 0ee2fcc163d1..e96ddb465f60 100644 --- a/ui/src/components/Resources/GraphDataTable.vue +++ b/ui/src/components/Resources/GraphDataTable.vue @@ -1,11 +1,22 @@ @@ -24,6 +25,7 @@ import { ref, computed, onMounted, watch, onBeforeUnmount } from 'vue' import BreadCrumbs from '@/components/Layout/BreadCrumbs.vue' import TopologyToolbar from '@/components/Topology/TopologyToolbar.vue' import TopologyGraph from '@/components/Topology/TopologyGraph.vue' +import TopologyTimeControl from '@/components/Topology/TopologyTimeControl.vue' import { useTopologyStore } from '@/stores/topologyStore' import { useWeathermapStore } from '@/stores/weathermapStore' import { useMenuStore } from '@/stores/menuStore' @@ -39,6 +41,11 @@ const menuStore = useMenuStore() const viewStore = useTopologyViewStore() const authStore = useAuthStore() +const selectedTimeModel = computed({ + get: () => wmStore.selectedTime, + set: (t: Date | null) => wmStore.setTime(t) +}) + const graphRef = ref | null>(null) const toolbarRef = ref | null>(null) @@ -143,7 +150,7 @@ onBeforeUnmount(() => { .topology-page { display: flex; flex-direction: column; - height: calc(100vh - 120px); + height: calc(100vh - 160px); overflow: hidden; } From 207b188479e291a445eae65984c8c32e9dd06e5b Mon Sep 17 00:00:00 2001 From: Chance Newkirk <40219357+cnewkirk@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:26:06 -0700 Subject: [PATCH 24/35] feat(topology): add historical mode watermark to canvas Import useWeathermapStore into TopologyGraph.vue and overlay a pill-shaped watermark in the top-left corner of the canvas whenever wmStore.selectedTime is non-null, showing the formatted historical timestamp. --- ui/src/components/Topology/TopologyGraph.vue | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/ui/src/components/Topology/TopologyGraph.vue b/ui/src/components/Topology/TopologyGraph.vue index 0616ea45335c..9e18fc71c04f 100644 --- a/ui/src/components/Topology/TopologyGraph.vue +++ b/ui/src/components/Topology/TopologyGraph.vue @@ -25,6 +25,12 @@ ? { backgroundSize: `${viewStore.gridSnap.size}px ${viewStore.gridSnap.size}px` } : {}" /> +
+ ⏱ Historical: {{ formatWatermarkTime(wmStore.selectedTime) }} +
@@ -45,6 +51,7 @@ import useTopology from '@/composables/useTopology' import useSnackbar from '@/composables/useSnackbar' import { useTopologyStore } from '@/stores/topologyStore' import { useTopologyViewStore } from '@/stores/topologyViewStore' +import { useWeathermapStore } from '@/stores/weathermapStore' import { TopologyVertex } from '@/types/topology' import TopologyDetailPanel from './TopologyDetailPanel.vue' import TopologyEdgeTooltip from './TopologyEdgeTooltip.vue' @@ -52,6 +59,12 @@ import CreateLinkModal from './CreateLinkModal.vue' const store = useTopologyStore() const viewStore = useTopologyViewStore() +const wmStore = useWeathermapStore() + +const formatWatermarkTime = (d: Date | null): string => { + if (!d) return '' + return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) +} const { showSnackBar } = useSnackbar() const graphContainer = ref(null) @@ -177,5 +190,20 @@ defineExpose({ capturePositions, restorePositions, alignToGrid, saveLayout, rese font-size: 0.9rem; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); } + + &__watermark { + position: absolute; + top: 8px; + left: 8px; + pointer-events: none; + z-index: 10; + font-size: 0.75rem; + font-weight: 600; + color: var($primary-text-on-surface); + background: rgba(0, 0, 0, 0.45); + padding: 3px 10px; + border-radius: vars.$border-radius-pill; + letter-spacing: 0.02em; + } } From 87fe1d1bdaa06efe7ab1017c51e49dbf488ef9b0 Mon Sep 17 00:00:00 2001 From: Chance Newkirk <40219357+cnewkirk@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:29:17 -0700 Subject: [PATCH 25/35] feat(topology): add TopologyEdgeGraphs sparkline component Renders Chart.js sparklines for interface utilization, errors, and discards inside the edge hover tooltip. Fetches 2-hour time series data per endpoint, cancels in-flight requests on unmount, and re-fetches in live mode on refresh. --- .../Topology/TopologyEdgeGraphs.vue | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 ui/src/components/Topology/TopologyEdgeGraphs.vue diff --git a/ui/src/components/Topology/TopologyEdgeGraphs.vue b/ui/src/components/Topology/TopologyEdgeGraphs.vue new file mode 100644 index 000000000000..1f6e829fe3c4 --- /dev/null +++ b/ui/src/components/Topology/TopologyEdgeGraphs.vue @@ -0,0 +1,302 @@ + + + + + From c71f410995d8bb6d6981de8a2c9c968d801abb77 Mon Sep 17 00:00:00 2001 From: Chance Newkirk <40219357+cnewkirk@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:31:32 -0700 Subject: [PATCH 26/35] feat(topology): embed TopologyEdgeGraphs sparklines in edge tooltip --- ui/src/components/Topology/TopologyEdgeTooltip.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/src/components/Topology/TopologyEdgeTooltip.vue b/ui/src/components/Topology/TopologyEdgeTooltip.vue index 362dd99aa095..7bf15c3ab1c2 100644 --- a/ui/src/components/Topology/TopologyEdgeTooltip.vue +++ b/ui/src/components/Topology/TopologyEdgeTooltip.vue @@ -77,6 +77,10 @@ {{ formatBitsPerSec(tooltip.labelData.ifSpeed) }}bps
+ @@ -84,6 +88,7 @@ import { EdgeLabelData } from '@/stores/weathermapStore' import { getProtocolColor, utilizationColor, formatBitsPerSec, prettifyProtocol } from './protocolColors' import { humanize } from '@/components/Topology/fieldLabels' +import TopologyEdgeGraphs from './TopologyEdgeGraphs.vue' export interface EdgeTooltipState { x: number From 04785f7cbf99375fcb3a671ee2a0901fad7bee7f Mon Sep 17 00:00:00 2001 From: Chance Newkirk <40219357+cnewkirk@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:53:47 -0700 Subject: [PATCH 27/35] fix(topology): call cy.fit() after breadthfirst and cose layouts runLayout() only called cy.fit() on the preset path. The breadthfirst and cose paths positioned nodes but left the viewport untouched, causing nodes to render off-screen and disappear after layout. Regression was exposed by the TopologyTimeControl addition reducing canvas height by 40px. --- ui/src/composables/useTopology.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/src/composables/useTopology.ts b/ui/src/composables/useTopology.ts index ebc8e712d8ec..ed48b3772500 100644 --- a/ui/src/composables/useTopology.ts +++ b/ui/src/composables/useTopology.ts @@ -255,6 +255,7 @@ const useTopology = (containerRef: Ref) => { avoidOverlap: true, nodeDimensionsIncludeLabels: false } as cytoscape.LayoutOptions).run() + cy.fit(undefined, 40) return } @@ -269,6 +270,7 @@ const useTopology = (containerRef: Ref) => { gravity: 80, padding: 30 } as cytoscape.LayoutOptions).run() + cy.fit(undefined, 30) } const pendingLinkSource = ref(null) From 277a7513ffc09032b82fd1d18c54c8236b41e3d7 Mon Sep 17 00:00:00 2001 From: Chance Newkirk <40219357+cnewkirk@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:50:03 -0700 Subject: [PATCH 28/35] feat(topology): weathermap utilization, legend, and lab ifSpeed fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Topology weathermap improvements: - Fetch utilization from BOTH endpoints via LLDP-correlated interfaces; EdgeUtil now carries src/tgt EdgeEndpointUtil so the tooltip shows per-endpoint in/out rates and utilization badges - Idle links (0 bps with weathermap data) render teal; full traffic-light scale: teal → green → yellow → orange → red - Edge tooltip: node names replace generic (source)/(target) labels; RX/TX replace directional arrows; localMac ↔ remoteMac row - TopologyLegend: floating bottom-left panel with color swatches, protocol swatch, and reference-bandwidth selector; collapse/dismiss with localStorage persistence - referenceBps in weathermapStore: overrides ifSpeed as utilization denominator for CIR-based or lab-calibrated display Lab ifSpeed fix (Option C): - Remove -M zebra_snmp from zebra_options so net-snmp's built-in IF-MIB module handles interface data (zebra AgentX subagent was bypassing snmpd override directives) - Add `interface eth1/eth2/eth3 6 100000000` to snmpd.conf so data-plane interfaces report 100 Mbps instead of 10 Gbps; load-gen --max-mbps 100 now authentically exercises all five utilization color bands - load-gen.py: add --bidir to iperf3 so both ifHCInOctets and ifHCOutOctets increment on each endpoint --- .topology-lab/load-gen.py | 219 +++++++++++++++ DEVELOPMENT-SETUP.md | 156 +++++++++++ start-topology-lab.sh | 89 +++++- .../Topology/TopologyEdgeGraphs.vue | 32 ++- .../Topology/TopologyEdgeTooltip.vue | 78 ++++-- ui/src/components/Topology/TopologyGraph.vue | 2 + ui/src/components/Topology/TopologyLegend.vue | 258 ++++++++++++++++++ ui/src/components/Topology/protocolColors.ts | 9 +- ui/src/composables/useTopology.ts | 9 +- ui/src/stores/weathermapStore.ts | 162 ++++++----- 10 files changed, 909 insertions(+), 105 deletions(-) create mode 100755 .topology-lab/load-gen.py create mode 100644 DEVELOPMENT-SETUP.md create mode 100644 ui/src/components/Topology/TopologyLegend.vue diff --git a/.topology-lab/load-gen.py b/.topology-lab/load-gen.py new file mode 100755 index 000000000000..a2ae62854581 --- /dev/null +++ b/.topology-lab/load-gen.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +""" +Topology lab traffic generator. + +Drives time-varying UDP load across all 6 spine-leaf links so SNMP +interface counters show realistic utilization patterns for testing +time-based UI features. + +Usage: + python3 .topology-lab/load-gen.py [--max-mbps 100] [--interval 30] + +Press Ctrl+C to stop and clean up. +""" + +import argparse +import math +import random +import signal +import subprocess +import sys +import time + +# --------------------------------------------------------------------------- +# Topology +# --------------------------------------------------------------------------- + +CONTAINERS = [ + "topo-spine-01", + "topo-spine-02", + "topo-leaf-01", + "topo-leaf-02", + "topo-leaf-03", +] + +# (src_container, src_bind_ip, dst_container, dst_bind_ip, server_port, link_idx) +# src sends, dst hosts the iperf3 server. +LINKS = [ + ("topo-spine-01", "10.101.1.1", "topo-leaf-01", "10.101.1.2", 5201, 0), + ("topo-spine-01", "10.101.2.1", "topo-leaf-02", "10.101.2.2", 5202, 1), + ("topo-spine-01", "10.101.3.1", "topo-leaf-03", "10.101.3.2", 5203, 2), + ("topo-spine-02", "10.101.4.1", "topo-leaf-01", "10.101.4.2", 5204, 3), + ("topo-spine-02", "10.101.5.1", "topo-leaf-02", "10.101.5.2", 5205, 4), + ("topo-spine-02", "10.101.6.1", "topo-leaf-03", "10.101.6.2", 5206, 5), +] + +CLIENT_CONTAINERS = ["topo-spine-01", "topo-spine-02"] + +# iperf3 is baked into the topology-node image for all containers. +IPERF3 = {c: "/usr/bin/iperf3" for c in CONTAINERS} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def run(cmd: str, check: bool = False) -> subprocess.CompletedProcess: + return subprocess.run(cmd, shell=True, capture_output=True, text=True) + + +def pexec(container: str, sh_cmd: str) -> subprocess.CompletedProcess: + """Run sh_cmd inside container, return result.""" + return run(f"podman exec {container} sh -c {repr(sh_cmd)}") + + +def pexec_bg(container: str, sh_cmd: str) -> None: + """Run sh_cmd inside container in the background (fire-and-forget).""" + run(f"podman exec -d {container} sh -c {repr(sh_cmd)}") + + +# --------------------------------------------------------------------------- +# Bandwidth shape function +# --------------------------------------------------------------------------- + +def target_mbps(link_idx: int, t: float, max_mbps: float) -> float: + """ + Return a target bandwidth in Mbit/s for the given link at time t. + + Combines three overlapping sine waves at different periods and phases + so each link has a distinct, non-repeating-looking pattern, plus + uniform noise and rare saturation spikes. + + - Slow wave : 5-minute period, drives the broad utilization envelope + - Medium wave: 2-minute period, mid-frequency ripple + - Fast wave : 45-second period, short bursts + + Result is clamped to [2%, 98%] of max_mbps. + """ + # Each link gets a unique phase so the 6 links don't all peak together + phi = link_idx * math.pi / 3.0 + + slow = 0.40 * math.sin(2 * math.pi * t / 300.0 + phi) + medium = 0.15 * math.sin(2 * math.pi * t / 120.0 + phi * 1.7) + fast = 0.05 * math.sin(2 * math.pi * t / 45.0 + phi * 2.3) + noise = 0.08 * random.uniform(-1.0, 1.0) + + # Raw utilization fraction: centred around 0.45, range roughly 0.02–0.88 + frac = 0.45 + slow + medium + fast + noise + + # Occasional saturation spike: ~5% chance per link per interval + if random.random() < 0.05: + frac = random.uniform(0.85, 0.97) + + frac = max(0.02, min(0.98, frac)) + return frac * max_mbps + + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- + +def install_iperf3() -> None: + print("Checking iperf3 on all containers...") + for c in CONTAINERS: + bin_path = IPERF3[c] + r = pexec(c, f"{bin_path} --version 2>&1 | head -1") + if r.returncode == 0: + status = r.stdout.strip() or "ok" + else: + # Leaves have internet; spines need manual binary copy (see README) + r2 = pexec(c, "apk add -q --no-cache iperf3") + status = "installed via apk" if r2.returncode == 0 else f"MISSING — copy binary to {bin_path}" + print(f" {c}: {status}") + + +def stop_iperf3() -> None: + """Kill all iperf3 processes in all containers (servers and clients).""" + for c in CONTAINERS: + bin_path = IPERF3[c] + pexec(c, f"pkill -f '{bin_path}' 2>/dev/null; pkill -f iperf3 2>/dev/null; true") + + +def start_servers() -> None: + print("Starting iperf3 servers...") + time.sleep(0.5) # let pkill settle + for src, src_ip, dst, dst_ip, port, idx in LINKS: + bin_path = IPERF3[dst] + cmd = ( + f"nohup {bin_path} -s -B {dst_ip} -p {port} " + f"--logfile /tmp/iperf3-server-{port}.log " + f">/dev/null 2>&1 &" + ) + r = pexec(dst, cmd) + status = "ok" if r.returncode == 0 else f"FAILED: {r.stderr.strip()}" + print(f" server {dst} {dst_ip}:{port} {status}") + time.sleep(0.5) # give servers a moment to bind + + +# --------------------------------------------------------------------------- +# Traffic loop +# --------------------------------------------------------------------------- + +def fire_clients(t: float, interval: int, max_mbps: float) -> None: + # Kill previous client wave first + for c in CLIENT_CONTAINERS: + bin_path = IPERF3[c] + pexec(c, f"pkill -f '{bin_path} -c' 2>/dev/null; pkill -f 'iperf3 -c' 2>/dev/null; true") + time.sleep(0.3) + + print(f"\n[{time.strftime('%H:%M:%S')}] Link utilization:") + for src, src_ip, dst, dst_ip, port, idx in LINKS: + bw = target_mbps(idx, t, max_mbps) + bin_path = IPERF3[src] + # Run for interval+5s so the next cycle can kill-and-replace cleanly. + # --bidir generates traffic in both directions simultaneously so SNMP + # ifHCInOctets and ifHCOutOctets both increment on each endpoint. + cmd = ( + f"nohup {bin_path} -c {dst_ip} -p {port} " + f"-u -b {bw:.2f}M " + f"--bidir " + f"-t {interval + 5} " + f"-B {src_ip} " + f"--logfile /tmp/iperf3-client-{port}.log " + f">/dev/null 2>&1 &" + ) + pexec(src, cmd) + bar_len = int(bw / max_mbps * 30) + bar = "#" * bar_len + "." * (30 - bar_len) + print(f" {src} → {dst_ip} [{bar}] {bw:5.1f}/{max_mbps:.0f} Mbit/s") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--max-mbps", type=float, default=100.0, + help="Virtual link speed in Mbit/s (default: 100)") + parser.add_argument("--interval", type=int, default=30, + help="Seconds between rate updates (default: 30)") + args = parser.parse_args() + + def cleanup(sig, frame): + print("\n\nShutting down — killing iperf3 in all containers...") + stop_iperf3() + sys.exit(0) + + signal.signal(signal.SIGINT, cleanup) + signal.signal(signal.SIGTERM, cleanup) + + print("=== Topology Lab Traffic Generator ===") + print(f" Links : {len(LINKS)}") + print(f" Max speed : {args.max_mbps:.0f} Mbit/s per link") + print(f" Interval : {args.interval}s") + print() + + stop_iperf3() + install_iperf3() + start_servers() + + print(f"\nGenerating traffic — Ctrl+C to stop.\n") + + while True: + fire_clients(time.time(), args.interval, args.max_mbps) + time.sleep(args.interval) + + +if __name__ == "__main__": + main() diff --git a/DEVELOPMENT-SETUP.md b/DEVELOPMENT-SETUP.md new file mode 100644 index 000000000000..8f5dee8f81ef --- /dev/null +++ b/DEVELOPMENT-SETUP.md @@ -0,0 +1,156 @@ +# Local Development Setup + +Complete guide for getting the OpenNMS UI development environment running from scratch. + +--- + +## Prerequisites + +Install all required tooling: + +```bash +# Java 17 (required — enforcer rejects 11 or 23) +brew install openjdk@17 +export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home + +# Docker runtime (colima) +brew install colima docker docker-buildx docker-compose +mkdir -p ~/.docker/cli-plugins +ln -sf /opt/homebrew/opt/docker-buildx/bin/docker-buildx ~/.docker/cli-plugins/docker-buildx +ln -sf /opt/homebrew/opt/docker-compose/bin/docker-compose ~/.docker/cli-plugins/docker-compose + +# Podman (for test container lab) +brew install podman +podman machine init +podman machine set --rootful --memory 8192 +podman machine start + +# pnpm (for web-assets / Vue menu builds) +npm install -g pnpm +``` + +--- + +## Quick Start: UI Development (Test Container) + +This is the primary workflow for UI work. Uses the released `35.0.4` base image with an overlay of local UI changes — much faster than a full Maven build. + +### 1. Start Postgres + +```bash +cd tools/local_development/postgres +docker compose up -d +``` + +### 2. Start colima with enough memory + +OpenNMS needs at least 6–8 GB. The default 2 GB colima allocation causes an OOM kill. + +```bash +colima start --memory 8 --cpu 4 +# If already running with less memory: +colima stop && colima start --memory 8 --cpu 4 +``` + +### 3. Build the test container image + +```bash +./build-dark-mode-overlay.sh +``` + +This builds webpack assets, the Vue menu (vite), `opennms-webapp-rest`, then assembles an overlay on top of `opennms/horizon:35.0.4`. Result: `localhost/opennms/horizon:35.0.5-dark-mode`. + +### 4. Start the container + +```bash +podman rm -f test-opennms 2>/dev/null +podman run -d --name test-opennms --privileged \ + -p 8980:8980 -p 8101:8101 \ + -e POSTGRES_HOST=host.containers.internal \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e OPENNMS_DBNAME=opennms \ + -e OPENNMS_DBUSER=opennms \ + -e OPENNMS_DBPASS=opennms \ + localhost/opennms/horizon:35.0.5-dark-mode -s +``` + +### 5. Wait for startup, then set password + +```bash +# Wait for ready +until curl -s -o /dev/null -w '%{http_code}' -u admin:admin \ + http://localhost:8980/opennms/rest/info | grep -q 200; do sleep 5; done +echo "ready" + +# Set password to notdefault +podman exec test-opennms /opt/opennms/bin/password admin notdefault +``` + +### 6. Open + +**http://localhost:8980/opennms** — login: `admin` / `notdefault` + +--- + +## Topology Lab + +Spins up a 5-node spine/leaf network (FRR OSPF + ISIS + LLDP + SNMP) for EnLinkd testing. Requires `test-opennms` to be running first. + +```bash +./start-topology-lab.sh # start (tears down + rebuilds if already running) +./start-topology-lab.sh --teardown # teardown only +./start-topology-lab.sh --rebuild # force topology-node image rebuild +``` + +After ~30–60s for EnLinkd collection: **http://localhost:8980/opennms/#/topology** + +Verify layers: +```bash +podman exec topo-spine-01 vtysh -c "show ip ospf neighbor" # OSPF +podman exec topo-spine-01 vtysh -c "show isis neighbor" # ISIS +podman exec topo-spine-01 lldpcli show neighbors # LLDP +``` + +--- + +## Full Source Build (rarely needed) + +Only required when changing Java backend code. UI-only changes use the overlay workflow above. + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home + +# 1. Build all Maven modules +./compile.pl -DskipTests install + +# 2. Package assembly tarball +./assemble.pl -Dopennms.home=/opt/opennms -DskipTests + +# 3. Build and load Docker image (use oci+install, not image — colima uses a non-default buildx builder) +cd opennms-container/core && make oci && make install +``` + +--- + +## Branches + +| Branch | Purpose | +|--------|---------| +| `ui-refactor` | Full UI overhaul (topology panels, Perses dashboards, etc.) — was `feature/jmx-config-vue` | +| `develop` | Main branch | + +--- + +## Troubleshooting + +**OOM kill (exit 137 or exit 1 with `OutOfMemoryError: Java heap space`):** The podman machine needs at least 8 GB. Check with `podman machine inspect` — if Memory is 2048, run: `podman machine stop && podman machine set --memory 8192 && podman machine start`. Also ensure colima (used for Docker builds) has enough memory: `colima stop && colima start --memory 8 --cpu 4`. + +**`make image` fails with buildx error:** Use `make oci && make install` instead. The `image` target requires a buildx builder named exactly `default`, which colima doesn't provide. + +**npm `ETARGET` errors during Maven build:** The `package-lock.json` has stale pinned versions. Delete it and regenerate: `cd core/web-assets && rm package-lock.json && npm install` + +**`podman: Cannot connect`:** Run `podman machine start` + +**`docker buildx` not found:** `brew install docker-buildx && ln -sf /opt/homebrew/opt/docker-buildx/bin/docker-buildx ~/.docker/cli-plugins/docker-buildx` diff --git a/start-topology-lab.sh b/start-topology-lab.sh index 24adb5c5c4a5..bd10a3ad9c99 100755 --- a/start-topology-lab.sh +++ b/start-topology-lab.sh @@ -62,8 +62,15 @@ teardown() { podman network disconnect "${MGMT_NET}" test-opennms 2>/dev/null && \ echo " disconnected test-opennms from ${MGMT_NET}" || true - # Remove staged FRR configs - rm -rf "${SCRIPT_DIR}/.topology-lab" 2>/dev/null && echo " removed staged configs" || true + # Stop load generator + if [[ -f "${SCRIPT_DIR}/.topology-lab/load-gen.pid" ]]; then + LGPID=$(cat "${SCRIPT_DIR}/.topology-lab/load-gen.pid") + kill "${LGPID}" 2>/dev/null && echo " stopped load-gen (PID ${LGPID})" || true + rm -f "${SCRIPT_DIR}/.topology-lab/load-gen.pid" + fi + + # Remove staged FRR configs only — preserve load-gen.py and other lab scripts + rm -rf "${SCRIPT_DIR}/.topology-lab/configs" 2>/dev/null && echo " removed staged configs" || true # Remove networks podman network rm -f "${MGMT_NET}" 2>/dev/null && echo " removed network: ${MGMT_NET}" || true @@ -103,6 +110,14 @@ agentXPerms 0666 0755 rocommunity public syslocation "OpenNMS Topology Lab" syscontact "admin@localhost" + +# Override reported speed for data-plane interfaces. +# Podman/virtio-net always reports 10 Gbps; set to 100 Mbps so the +# load-gen's --max-mbps 100 exercises all five weathermap utilization bands. +# Type 6 = ethernetCsmacd (IANAifType). Speed in bits/sec. +interface eth1 6 100000000 +interface eth2 6 100000000 +interface eth3 6 100000000 SNMPD # ---- entrypoint.sh ---- @@ -131,9 +146,10 @@ if [[ "${ROLE}" == "leaf" ]]; then ip link set dummy0 up fi -# Start lldpd as AgentX subagent (-x = AgentX) +# Start lldpd as AgentX subagent (-x = AgentX), 1-second TX for fast lab discovery lldpd -x & sleep 1 +lldpcli configure lldp tx-interval 1 # Start FRR (zebra + ospfd + isisd via watchfrr, same as docker-start) source /usr/lib/frr/frrcommon.sh @@ -150,7 +166,8 @@ RUN apk update && apk add --no-cache \ lldpd \ net-snmp \ net-snmp-tools \ - iproute2 + iproute2 \ + iperf3 RUN mkdir -p /var/agentx /var/run/frr /var/log/frr /etc/frr && \ addgroup -S frr 2>/dev/null || true && \ adduser -S -G frr frr 2>/dev/null || true && \ @@ -238,7 +255,7 @@ ripd=no ospf6d=no watchfrr_enable=yes vtysh_enable=yes -zebra_options=" -A 127.0.0.1 -s 90000000 -M zebra_snmp" +zebra_options=" -A 127.0.0.1 -s 90000000" ospfd_options=" -A 127.0.0.1 -M ospfd_snmp" isisd_options=" -A 127.0.0.1 -M isisd_snmp" DAEMONS @@ -255,6 +272,7 @@ interface lo ip router isis FABRIC ! interface eth1 + bandwidth 100000 description link-to-leaf-01 ip address 10.101.1.1/30 ip ospf area 0.0.0.0 @@ -264,6 +282,7 @@ interface eth1 isis network point-to-point ! interface eth2 + bandwidth 100000 description link-to-leaf-02 ip address 10.101.2.1/30 ip ospf area 0.0.0.0 @@ -273,6 +292,7 @@ interface eth2 isis network point-to-point ! interface eth3 + bandwidth 100000 description link-to-leaf-03 ip address 10.101.3.1/30 ip ospf area 0.0.0.0 @@ -303,7 +323,7 @@ ripd=no ospf6d=no watchfrr_enable=yes vtysh_enable=yes -zebra_options=" -A 127.0.0.1 -s 90000000 -M zebra_snmp" +zebra_options=" -A 127.0.0.1 -s 90000000" ospfd_options=" -A 127.0.0.1 -M ospfd_snmp" isisd_options=" -A 127.0.0.1 -M isisd_snmp" DAEMONS @@ -320,6 +340,7 @@ interface lo ip router isis FABRIC ! interface eth1 + bandwidth 100000 description link-to-leaf-01 ip address 10.101.4.1/30 ip ospf area 0.0.0.0 @@ -329,6 +350,7 @@ interface eth1 isis network point-to-point ! interface eth2 + bandwidth 100000 description link-to-leaf-02 ip address 10.101.5.1/30 ip ospf area 0.0.0.0 @@ -338,6 +360,7 @@ interface eth2 isis network point-to-point ! interface eth3 + bandwidth 100000 description link-to-leaf-03 ip address 10.101.6.1/30 ip ospf area 0.0.0.0 @@ -368,7 +391,7 @@ ripd=no ospf6d=no watchfrr_enable=yes vtysh_enable=yes -zebra_options=" -A 127.0.0.1 -s 90000000 -M zebra_snmp" +zebra_options=" -A 127.0.0.1 -s 90000000" ospfd_options=" -A 127.0.0.1 -M ospfd_snmp" isisd_options=" -A 127.0.0.1 -M isisd_snmp" DAEMONS @@ -385,6 +408,7 @@ interface lo ip router isis FABRIC ! interface eth1 + bandwidth 100000 description uplink-to-spine-01 ip address 10.101.1.2/30 ip ospf area 0.0.0.0 @@ -394,6 +418,7 @@ interface eth1 isis network point-to-point ! interface eth2 + bandwidth 100000 description uplink-to-spine-02 ip address 10.101.4.2/30 ip ospf area 0.0.0.0 @@ -424,7 +449,7 @@ ripd=no ospf6d=no watchfrr_enable=yes vtysh_enable=yes -zebra_options=" -A 127.0.0.1 -s 90000000 -M zebra_snmp" +zebra_options=" -A 127.0.0.1 -s 90000000" ospfd_options=" -A 127.0.0.1 -M ospfd_snmp" isisd_options=" -A 127.0.0.1 -M isisd_snmp" DAEMONS @@ -441,6 +466,7 @@ interface lo ip router isis FABRIC ! interface eth1 + bandwidth 100000 description uplink-to-spine-01 ip address 10.101.2.2/30 ip ospf area 0.0.0.0 @@ -450,6 +476,7 @@ interface eth1 isis network point-to-point ! interface eth2 + bandwidth 100000 description uplink-to-spine-02 ip address 10.101.5.2/30 ip ospf area 0.0.0.0 @@ -480,7 +507,7 @@ ripd=no ospf6d=no watchfrr_enable=yes vtysh_enable=yes -zebra_options=" -A 127.0.0.1 -s 90000000 -M zebra_snmp" +zebra_options=" -A 127.0.0.1 -s 90000000" ospfd_options=" -A 127.0.0.1 -M ospfd_snmp" isisd_options=" -A 127.0.0.1 -M isisd_snmp" DAEMONS @@ -497,6 +524,7 @@ interface lo ip router isis FABRIC ! interface eth1 + bandwidth 100000 description uplink-to-spine-01 ip address 10.101.3.2/30 ip ospf area 0.0.0.0 @@ -506,6 +534,7 @@ interface eth1 isis network point-to-point ! interface eth2 + bandwidth 100000 description uplink-to-spine-02 ip address 10.101.6.2/30 ip ospf area 0.0.0.0 @@ -567,6 +596,30 @@ for node_def in "${NODES[@]}"; do echo " started: ${name} (mgmt: ${mgmt_ip}, role: ${role})" done +# --------------------------------------------------------------------------- +# Phase 4b: Re-apply bridge LLDP forwarding AFTER containers are started +# --------------------------------------------------------------------------- +# Linux resets group_fwd_mask to 0x0 whenever a new port is added to a bridge. +# Containers were just started above, so we must re-apply here. +echo "" +echo "==> [4b] Re-applying bridge LLDP forwarding after container start..." + +BRIDGE_CMDS2="" +for net in "${ALL_NETS[@]}"; do + br=$(podman network inspect "$net" 2>/dev/null \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['network_interface'])" 2>/dev/null || true) + if [[ -n "$br" ]]; then + BRIDGE_CMDS2+="echo 0 > /sys/class/net/${br}/bridge/multicast_snooping 2>/dev/null;" + BRIDGE_CMDS2+="echo 0x4000 > /sys/class/net/${br}/bridge/group_fwd_mask 2>/dev/null;" + echo " re-applied: ${net} -> ${br}" + fi +done + +if [[ -n "$BRIDGE_CMDS2" ]]; then + podman machine ssh -- "bash -c '${BRIDGE_CMDS2}exit 0'" 2>/dev/null || \ + echo " WARNING: could not re-apply bridge multicast settings" +fi + # --------------------------------------------------------------------------- # Phase 5: Attach test-opennms to management network # --------------------------------------------------------------------------- @@ -681,6 +734,23 @@ if [[ "${CONVERGED}" == "false" ]]; then exit 1 fi +# --------------------------------------------------------------------------- +# Phase 8: Start traffic generator +# --------------------------------------------------------------------------- +echo "" +echo "==> [+] Starting traffic generator..." + +LOADGEN="${SCRIPT_DIR}/.topology-lab/load-gen.py" +if [[ -f "${LOADGEN}" ]]; then + nohup python3 -u "${LOADGEN}" --max-mbps 100 --interval 30 \ + > "${SCRIPT_DIR}/.topology-lab/load-gen.log" 2>&1 & + echo $! > "${SCRIPT_DIR}/.topology-lab/load-gen.pid" + echo " load-gen started (PID $(cat "${SCRIPT_DIR}/.topology-lab/load-gen.pid"))" + echo " log: .topology-lab/load-gen.log" +else + echo " WARNING: load-gen.py not found at ${LOADGEN} — skipping traffic generation" +fi + # --------------------------------------------------------------------------- # Summary # --------------------------------------------------------------------------- @@ -705,4 +775,5 @@ echo " Wait ~30-60s for EnLinkd to collect, then open:" echo " http://localhost:8980/opennms/ → navigate to /#/topology" echo "" echo " Teardown: ./start-topology-lab.sh --teardown" +echo " Load gen: tail -f .topology-lab/load-gen.log" echo "============================================================" diff --git a/ui/src/components/Topology/TopologyEdgeGraphs.vue b/ui/src/components/Topology/TopologyEdgeGraphs.vue index 1f6e829fe3c4..c8487e01ec2b 100644 --- a/ui/src/components/Topology/TopologyEdgeGraphs.vue +++ b/ui/src/components/Topology/TopologyEdgeGraphs.vue @@ -15,20 +15,20 @@ diff --git a/ui/src/components/Topology/protocolColors.ts b/ui/src/components/Topology/protocolColors.ts index 04bec4a3c83d..79307e7f0375 100644 --- a/ui/src/components/Topology/protocolColors.ts +++ b/ui/src/components/Topology/protocolColors.ts @@ -108,10 +108,11 @@ export const parallelOffsets = (n: number): number[] => { * Maps a utilization percentage (0-100) to a traffic-light color. */ export const utilizationColor = (pct: number): string => { - if (pct < 50) return '#48BB78' // green - if (pct < 75) return '#ECC94B' // yellow - if (pct < 90) return '#ED8936' // orange - return '#FC8181' // red + if (pct === 0) return '#4FD1C5' // teal — up, no load + if (pct < 50) return '#48BB78' // green — low utilization + if (pct < 75) return '#ECC94B' // yellow + if (pct < 90) return '#ED8936' // orange + return '#FC8181' // red — near/over capacity } /** diff --git a/ui/src/composables/useTopology.ts b/ui/src/composables/useTopology.ts index ed48b3772500..5343f6a0ee6b 100644 --- a/ui/src/composables/useTopology.ts +++ b/ui/src/composables/useTopology.ts @@ -26,7 +26,7 @@ import { Ref } from 'vue' import { useTopologyStore } from '@/stores/topologyStore' import { TopologyVertex } from '@/types/topology' import { getProtocolColor, prettifyProtocol, utilizationColor, throughputWidth, formatBitsPerSec } from '@/components/Topology/protocolColors' -import { useWeathermapStore, EdgeLabelData } from '@/stores/weathermapStore' +import { useWeathermapStore, EdgeLabelData, EdgeUtil } from '@/stores/weathermapStore' import { useTopologyViewStore } from '@/stores/topologyViewStore' cytoscape.use(cxtmenu) @@ -185,7 +185,7 @@ const useTopology = (containerRef: Ref) => { protocols: string[] srcLabel: string tgtLabel: string - util?: { utilPct: number; inBps: number; outBps: number } | null + util?: EdgeUtil | null labelData?: EdgeLabelData | null } const edgeTooltip = ref(null) @@ -487,7 +487,7 @@ const useTopology = (containerRef: Ref) => { return } edge.style('line-color', utilizationColor(util.utilPct)) - edge.style('width', throughputWidth(util.inBps + util.outBps)) + edge.style('width', throughputWidth(util.totalBps)) }) }) } @@ -507,7 +507,8 @@ const useTopology = (containerRef: Ref) => { // Utilization line (from weathermap data) const util = wmStore.edgeUtilMap[key] if (viewStore.edgeLabels.showUtilization && util) { - parts.push(`${Math.round(util.utilPct)}% · ↑${formatBitsPerSec(util.inBps)} ↓${formatBitsPerSec(util.outBps)}`) + const src = util.src + parts.push(`${Math.round(util.utilPct)}% · ↑${formatBitsPerSec(src?.inBps ?? 0)} ↓${formatBitsPerSec(src?.outBps ?? 0)}`) } // MAC / speed lines (from edgeLabelData); port and IP now go to endpoint labels diff --git a/ui/src/stores/weathermapStore.ts b/ui/src/stores/weathermapStore.ts index 61b69f4c7a1e..f3074a5e2ff0 100644 --- a/ui/src/stores/weathermapStore.ts +++ b/ui/src/stores/weathermapStore.ts @@ -9,13 +9,22 @@ import { } from '@/services/measurementsService' import { getNodeEnlinkd, cleanName, NodeEnlinkdData } from '@/services/enlinkdService' -export interface EdgeUtil { +export interface EdgeEndpointUtil { inBps: number outBps: number utilPct: number ifSpeed: number } +export interface EdgeUtil { + src: EdgeEndpointUtil | null + tgt: EdgeEndpointUtil | null + /** max(src.utilPct, tgt.utilPct) — drives edge color on canvas */ + utilPct: number + /** combined bps across both endpoints — drives edge width on canvas */ + totalBps: number +} + export interface EdgeLabelData { localIfName?: string // ifName/ifDescr of local connecting interface remotePortId?: string // remote port from LLDP (ldpRemPort after cleanName) @@ -54,6 +63,18 @@ export const useWeathermapStore = defineStore('weathermapStore', () => { const lastUpdated = ref(null) const selectedTime = ref(null) // null = live + // When set, overrides ifSpeed as the utilization denominator. + // Useful for assessing utilization against a committed/contracted rate + // rather than physical line speed (e.g., in a lab with virtual 10 Gbps NICs). + const _REFBPS_KEY = 'opennms-topology-reference-bps' + const referenceBps = ref( + (() => { const v = localStorage.getItem(_REFBPS_KEY); return v ? Number(v) : null })() + ) + watch(referenceBps, v => { + if (v === null) localStorage.removeItem(_REFBPS_KEY) + else localStorage.setItem(_REFBPS_KEY, String(v)) + }) + let _timer: ReturnType | null = null let _activeVertices: TopologyVertex[] = [] let _activeEdges: TopologyEdge[] = [] @@ -68,14 +89,14 @@ export const useWeathermapStore = defineStore('weathermapStore', () => { const edges = _activeEdges // snapshot to prevent mid-flight mutation on rapid start() calls if (edges.length === 0) return - // Collect unique node IDs from all edges + // Step 1 — Collect unique node IDs from all edges const nodeIds = new Set() for (const e of edges) { nodeIds.add(e.source.id) nodeIds.add(e.target.id) } - // Fetch SNMP interfaces, node type, IP interfaces, and LLDP enlinkd data for each node + // Step 2 — Fetch SNMP interfaces, node type, IP interfaces, and LLDP enlinkd data for each node const nodeResults = await Promise.allSettled( Array.from(nodeIds).map(async (nodeId) => { const [ifaces, type, ipIfaces, enlinkd] = await Promise.all([ @@ -104,51 +125,23 @@ export const useWeathermapStore = defineStore('weathermapStore', () => { nodeEnlinkdMap[nodeId] = enlinkd } - // Fetch utilization for each edge in parallel - const edgeResults = await Promise.allSettled( - edges.map(async (e) => { - const key = edgeKey(e.source.id, e.target.id) - const srcIface = nodeSnmpMap[e.source.id] - const tgtIface = nodeSnmpMap[e.target.id] - - // Use the source endpoint's interface (prefer src; fall back to tgt) - const iface = srcIface ?? tgtIface - if (!iface) return { key, util: null } - - const nodeId = srcIface ? e.source.id : e.target.id - const util: InterfaceUtil | null = await fetchInterfaceUtilization(nodeId, iface, selectedTime.value ?? undefined) - return { key, util } - }) - ) - - const utilMap: Record = {} - for (const result of edgeResults) { - if (result.status !== 'fulfilled') continue - const { key, util } = result.value - if (!util) continue - utilMap[key] = { - inBps: util.inBps, - outBps: util.outBps, - ifSpeed: util.ifSpeed, - utilPct: computeUtilPct(util.inBps, util.outBps, util.ifSpeed) - } - } - - // Build edgeLabelData — IP + LLDP port correlation per edge + // Step 3 — Build edgeLabelData via LLDP correlation BEFORE the utilization fetch so that + // srcIface/tgtIface (the specific connecting interfaces identified via LLDP) are available + // for the per-endpoint utilization queries in Step 4. const vertexLabelById = new Map(_activeVertices.map(v => [v.id, v.label])) const labelMap: Record = {} for (const e of edges) { - const key = edgeKey(e.source.id, e.target.id) - const srcId = e.source.id - const tgtId = e.target.id + const key = edgeKey(e.source.id, e.target.id) + const srcId = e.source.id + const tgtId = e.target.id const data: EdgeLabelData = {} data.srcNodeId = srcId data.tgtNodeId = tgtId + data.localIp = nodeIpMap[srcId] + data.remoteIp = nodeIpMap[tgtId] - data.localIp = nodeIpMap[srcId] - data.remoteIp = nodeIpMap[tgtId] - + // Forward LLDP — source node's view of the link const enlinkd = nodeEnlinkdMap[srcId] const targetLabel = vertexLabelById.get(String(tgtId)) if (enlinkd && targetLabel) { @@ -166,15 +159,14 @@ export const useWeathermapStore = defineStore('weathermapStore', () => { data.srcIface = localIface } } else { - const cleaned = cleanName(lldpLink.lldpLocalPort) - data.localIfName = cleaned || undefined + data.localIfName = cleanName(lldpLink.lldpLocalPort) || undefined } data.remotePortId = cleanName(lldpLink.ldpRemPort) || undefined data.remoteMac = lldpLink.lldpRemChassisId || undefined } } - // Reverse LLDP lookup — try from target's perspective to fill in missing fields + // Reverse LLDP — target node's view of the link; identifies tgtIface and fills gaps const tgtEnlinkd = nodeEnlinkdMap[tgtId] const srcLabel = vertexLabelById.get(String(srcId)) if (tgtEnlinkd && srcLabel) { @@ -182,41 +174,87 @@ export const useWeathermapStore = defineStore('weathermapStore', () => { cleanName(l.lldpRemInfo).toLowerCase() === srcLabel.toLowerCase() ) if (reverseLldp) { - // target's local port → remotePortId (target's interface) - if (!data.remotePortId) { - const ifIndexMatch = reverseLldp.lldpLocalPort.match(/ifindex:(\d+)/i) - if (ifIndexMatch) { - const remoteIface = nodeIfIndexMap[tgtId]?.get(Number(ifIndexMatch[1])) - if (remoteIface) { + const ifIndexMatch = reverseLldp.lldpLocalPort.match(/ifindex:(\d+)/i) + if (ifIndexMatch) { + const remoteIface = nodeIfIndexMap[tgtId]?.get(Number(ifIndexMatch[1])) + if (remoteIface) { + // Always capture tgtIface when reverse LLDP identifies the interface + data.tgtIface = remoteIface + data.remoteMac = data.remoteMac ?? remoteIface.physAddr ?? undefined + if (!data.remotePortId) { data.remotePortId = remoteIface.ifName ?? remoteIface.ifDescr ?? undefined - data.remoteMac = data.remoteMac ?? remoteIface.physAddr ?? undefined - data.tgtIface = remoteIface } - } else { - data.remotePortId = cleanName(reverseLldp.lldpLocalPort) || undefined } + } else if (!data.remotePortId) { + data.remotePortId = cleanName(reverseLldp.lldpLocalPort) || undefined } - // target's view of src's port → localIfName fallback if (!data.localIfName) { data.localIfName = cleanName(reverseLldp.ldpRemPort) || undefined } } } - // Final fallback: SNMP best interface for source node - if (!data.localIfName) { + // Fallbacks — use best SNMP interface when LLDP didn't identify the connecting interface + if (!data.srcIface) { const best = nodeSnmpMap[srcId] if (best) { - data.localIfName = best.ifName ?? best.ifDescr ?? undefined - data.localMac = best.physAddr ?? undefined - data.ifSpeed = best.ifSpeed > 0 ? best.ifSpeed : undefined + data.localIfName = data.localIfName ?? best.ifName ?? best.ifDescr ?? undefined + data.localMac = data.localMac ?? best.physAddr ?? undefined + data.ifSpeed = data.ifSpeed ?? (best.ifSpeed > 0 ? best.ifSpeed : undefined) data.srcIface = best } } + if (!data.tgtIface) { + const best = nodeSnmpMap[tgtId] + if (best) data.tgtIface = best + } labelMap[key] = data } + // Step 4 — Fetch utilization from BOTH endpoints in parallel using LLDP-correlated interfaces + const toEndpointUtil = (u: InterfaceUtil | null): EdgeEndpointUtil | null => { + if (!u) return null + const effectiveSpeed = referenceBps.value ?? u.ifSpeed + return { + inBps: u.inBps, + outBps: u.outBps, + ifSpeed: u.ifSpeed, + utilPct: computeUtilPct(u.inBps, u.outBps, effectiveSpeed) + } + } + + const edgeResults = await Promise.allSettled( + edges.map(async (e) => { + const key = edgeKey(e.source.id, e.target.id) + const data = labelMap[key] + const [srcUtil, tgtUtil] = await Promise.all([ + data?.srcIface + ? fetchInterfaceUtilization(e.source.id, data.srcIface, selectedTime.value ?? undefined) + : Promise.resolve(null), + data?.tgtIface + ? fetchInterfaceUtilization(e.target.id, data.tgtIface, selectedTime.value ?? undefined) + : Promise.resolve(null) + ]) + return { key, srcUtil, tgtUtil } + }) + ) + + const utilMap: Record = {} + for (const result of edgeResults) { + if (result.status !== 'fulfilled') continue + const { key, srcUtil, tgtUtil } = result.value + const src = toEndpointUtil(srcUtil) + const tgt = toEndpointUtil(tgtUtil) + if (!src && !tgt) continue + utilMap[key] = { + src, + tgt, + utilPct: Math.max(src?.utilPct ?? 0, tgt?.utilPct ?? 0), + totalBps: (src ? src.inBps + src.outBps : 0) + (tgt ? tgt.inBps + tgt.outBps : 0) + } + } + edgeUtilMap.value = utilMap nodeDownMap.value = downMap edgeLabelData.value = labelMap @@ -275,9 +313,11 @@ export const useWeathermapStore = defineStore('weathermapStore', () => { } } + const setReferenceBps = (bps: number | null) => { referenceBps.value = bps } + return { edgeUtilMap, nodeDownMap, edgeLabelData, loading, error, pollInterval, lastUpdated, - selectedTime, - start, stop, refresh, setPollInterval, setTime + selectedTime, referenceBps, + start, stop, refresh, setPollInterval, setTime, setReferenceBps } }) From 42cdee5ea83565c0cd0bdee8b8ab79faf9fa6fb6 Mon Sep 17 00:00:00 2001 From: Chance Newkirk <40219357+cnewkirk@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:07:24 -0700 Subject: [PATCH 29/35] fix(topology): handle no-data state in edge sparklines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When collectd hasn't yet collected for an interface (e.g. after a container rebuild that changes MAC addresses), fetchInterfaceTimeSeries returns empty arrays and Chart.js renders a blank canvas. Show "awaiting data…" instead of a blank/0bps chart block when inBps.length === 0. Charts render as soon as data arrives. Also remove the duplicate label guard in the chart creation watcher (hasData check is now the single source of truth). --- .../Topology/TopologyEdgeGraphs.vue | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/ui/src/components/Topology/TopologyEdgeGraphs.vue b/ui/src/components/Topology/TopologyEdgeGraphs.vue index c8487e01ec2b..0b3876bf84e8 100644 --- a/ui/src/components/Topology/TopologyEdgeGraphs.vue +++ b/ui/src/components/Topology/TopologyEdgeGraphs.vue @@ -16,20 +16,26 @@