diff --git a/features/bsm/rest/impl/pom.xml b/features/bsm/rest/impl/pom.xml
index e951aef338d7..d754fe9b1d89 100644
--- a/features/bsm/rest/impl/pom.xml
+++ b/features/bsm/rest/impl/pom.xml
@@ -65,14 +65,21 @@
provided
+ provide the topology-views module (view + asset models), the jsonStore
+ bean, and the system-report bean, which webapp-rest declares as
+ provided (non-transitive). -->
org.opennmsorg.opennms.features.topology-views${project.version}test
+
+ org.opennms.features
+ org.opennms.features.system-report
+ ${project.version}
+ test
+ org.opennms.features.distributedorg.opennms.features.distributed.kv-store.json.postgres
diff --git a/features/status/rest/pom.xml b/features/status/rest/pom.xml
index 89e40958974d..3d6f67072b31 100644
--- a/features/status/rest/pom.xml
+++ b/features/status/rest/pom.xml
@@ -56,14 +56,21 @@
test
+ provide the topology-views module (view + asset models), the
+ jsonStore bean, and the system-report bean, which webapp-rest
+ declares as provided (non-transitive). -->
org.opennmsorg.opennms.features.topology-views${project.version}test
+
+ org.opennms.features
+ org.opennms.features.system-report
+ ${project.version}
+ test
+ org.opennms.features.distributedorg.opennms.features.distributed.kv-store.json.postgres
diff --git a/opennms-webapp-rest/pom.xml b/opennms-webapp-rest/pom.xml
index 34b8ea248102..dc4691c1270b 100644
--- a/opennms-webapp-rest/pom.xml
+++ b/opennms-webapp-rest/pom.xml
@@ -154,6 +154,12 @@
${project.version}${onmsLibScope}
+
+ org.opennms.features
+ org.opennms.features.system-report
+ ${project.version}
+ ${onmsLibScope}
+ org.opennmsopennms-web-dependencies
diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/SystemReportRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/SystemReportRestService.java
new file mode 100644
index 000000000000..c6b8ba2706d9
--- /dev/null
+++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/SystemReportRestService.java
@@ -0,0 +1,214 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package org.opennms.web.rest.v2;
+
+import java.io.File;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import javax.ws.rs.Consumes;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.Produces;
+import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.Response.Status;
+import javax.ws.rs.core.StreamingOutput;
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+import org.opennms.systemreport.SystemReport;
+import org.opennms.systemreport.SystemReportFormatter;
+import org.opennms.systemreport.SystemReportPlugin;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+/**
+ * Backs the Generate System Report page: the set of report plugins (data
+ * sources) and formatters (output types), and generation of the report itself,
+ * streamed back as a file attachment. Replaces the legacy SystemReportController
+ * / FormatterView path.
+ */
+@Component
+@Path("system-report")
+@Produces(MediaType.APPLICATION_JSON)
+public class SystemReportRestService {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SystemReportRestService.class);
+
+ @Autowired
+ private SystemReport m_systemReport;
+
+ // SystemReport already filters by isVisible internally; the filter here is a
+ // deliberate boundary guard so this public endpoint never leaks a hidden plugin
+ // even if that internal contract changes.
+ @GET
+ @Path("plugins")
+ public List getPlugins() {
+ return m_systemReport.getPlugins().stream()
+ .filter(SystemReportPlugin::isVisible)
+ .map(PluginDTO::new)
+ .collect(Collectors.toList());
+ }
+
+ @GET
+ @Path("formatters")
+ public List getFormatters() {
+ return m_systemReport.getFormatters().stream()
+ .filter(SystemReportFormatter::isVisible)
+ .map(FormatterDTO::new)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Generate the report and stream it back as a file attachment. Mirrors the
+ * legacy FormatterView: resolve the formatter, run the selected plugins in
+ * order, and write to the response. Only stream-producing formatters are
+ * downloadable; a non-streaming one (e.g. FTP upload) is rejected here.
+ */
+ @POST
+ @Path("generate")
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_OCTET_STREAM)
+ public Response generate(final GenerateRequest request) {
+ if (request == null || request.getFormatter() == null || request.getFormatter().isBlank()) {
+ throw badRequest("A formatter is required.");
+ }
+ final SystemReportFormatter formatter = m_systemReport.getFormatters().stream()
+ .filter(SystemReportFormatter::isVisible)
+ .filter(f -> f.getName().equals(request.getFormatter()))
+ .findFirst()
+ .orElseThrow(() -> badRequest("Unknown formatter '" + request.getFormatter() + "'."));
+ if (!formatter.needsOutputStream() || formatter.getContentType() == null) {
+ throw badRequest("Formatter '" + request.getFormatter() + "' does not produce a downloadable report.");
+ }
+
+ final List selected = request.getPlugins() == null ? List.of() : request.getPlugins();
+ final List plugins = m_systemReport.getPlugins().stream()
+ .filter(SystemReportPlugin::isVisible)
+ .filter(p -> selected.contains(p.getName()))
+ .collect(Collectors.toList());
+ if (plugins.isEmpty()) {
+ throw badRequest("Select at least one report plugin.");
+ }
+
+ final String fileName = fileName(request.getOutput(), formatter.getExtension());
+ final StreamingOutput body = output -> {
+ try {
+ formatter.setOutputStream(output);
+ formatter.begin();
+ for (final SystemReportPlugin plugin : plugins) {
+ formatter.write(plugin);
+ output.flush();
+ }
+ formatter.end();
+ } catch (final Exception e) {
+ // The status line is already committed once streaming starts, so
+ // this can only truncate the download; log it for the operator.
+ LOG.warn("Error generating system report with formatter '{}'", formatter.getName(), e);
+ throw new WebApplicationException(e);
+ }
+ };
+
+ return Response.ok(body, formatter.getContentType())
+ .header("Content-Disposition", "attachment; filename=\"" + fileName + "\"")
+ .build();
+ }
+
+ // Matches FormatterView.getFileName: use the sanitized basename of the
+ // requested name, else a default derived from the formatter's extension.
+ private static String fileName(final String output, final String extension) {
+ if (output != null && !output.matches("^\\s*$")) {
+ return new File(output).getName().replaceAll("[^\\w\\.]", "");
+ }
+ return "opennms-system-report." + extension;
+ }
+
+ private static WebApplicationException badRequest(final String message) {
+ return new WebApplicationException(
+ Response.status(Status.BAD_REQUEST).type(MediaType.TEXT_PLAIN).entity(message).build());
+ }
+
+ @XmlRootElement(name = "plugin")
+ @XmlAccessorType(XmlAccessType.NONE)
+ public static class PluginDTO {
+ @XmlElement private String name;
+ @XmlElement private String description;
+
+ public PluginDTO() { }
+
+ public PluginDTO(final SystemReportPlugin plugin) {
+ this.name = plugin.getName();
+ this.description = plugin.getDescription();
+ }
+
+ public String getName() { return name; }
+ public String getDescription() { return description; }
+ }
+
+ @XmlRootElement(name = "formatter")
+ @XmlAccessorType(XmlAccessType.NONE)
+ public static class FormatterDTO {
+ @XmlElement private String name;
+ @XmlElement private String description;
+ @XmlElement private String extension;
+
+ public FormatterDTO() { }
+
+ public FormatterDTO(final SystemReportFormatter formatter) {
+ this.name = formatter.getName();
+ this.description = formatter.getDescription();
+ this.extension = formatter.getExtension();
+ }
+
+ public String getName() { return name; }
+ public String getDescription() { return description; }
+ public String getExtension() { return extension; }
+ }
+
+ @XmlRootElement(name = "generate")
+ @XmlAccessorType(XmlAccessType.NONE)
+ public static class GenerateRequest {
+ @XmlElement private String formatter;
+ @XmlElement private List plugins;
+ @XmlElement private String output;
+
+ public String getFormatter() { return formatter; }
+ public void setFormatter(final String formatter) { this.formatter = formatter; }
+
+ public List getPlugins() { return plugins; }
+ public void setPlugins(final List plugins) { this.plugins = plugins; }
+
+ public String getOutput() { return output; }
+ public void setOutput(final String output) { this.output = output; }
+ }
+
+ void setSystemReport(final SystemReport systemReport) {
+ m_systemReport = systemReport;
+ }
+}
diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/applicationContext-cxf-rest-v2.xml b/opennms-webapp-rest/src/main/webapp/WEB-INF/applicationContext-cxf-rest-v2.xml
index 17e22ab488d0..012aa631a123 100644
--- a/opennms-webapp-rest/src/main/webapp/WEB-INF/applicationContext-cxf-rest-v2.xml
+++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/applicationContext-cxf-rest-v2.xml
@@ -22,6 +22,13 @@
+
+
+
+
+
diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json
index d5a81390467e..ab8719a9eba0 100644
--- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json
+++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json
@@ -686,7 +686,7 @@
{
"id": "generateSystemReport",
"name": "Generate System Report",
- "url": "admin/support/systemReportList.htm",
+ "url": "ui/index.html#/system-report",
"locationMatch": null,
"roles": null
}
diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json
index d5a81390467e..ab8719a9eba0 100644
--- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json
+++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json
@@ -686,7 +686,7 @@
{
"id": "generateSystemReport",
"name": "Generate System Report",
- "url": "admin/support/systemReportList.htm",
+ "url": "ui/index.html#/system-report",
"locationMatch": null,
"roles": null
}
diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/SystemReportRestServiceTest.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/SystemReportRestServiceTest.java
new file mode 100644
index 000000000000..417f1f6bf30e
--- /dev/null
+++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/SystemReportRestServiceTest.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to The OpenNMS Group, Inc (TOG) under one or more
+ * contributor license agreements. See the LICENSE.md file
+ * distributed with this work for additional information
+ * regarding copyright ownership.
+ *
+ * TOG licenses this file to You under the GNU Affero General
+ * Public License Version 3 (the "License") or (at your option)
+ * any later version. You may not use this file except in
+ * compliance with the License. You may obtain a copy of the
+ * License at:
+ *
+ * https://www.gnu.org/licenses/agpl-3.0.txt
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific
+ * language governing permissions and limitations under the
+ * License.
+ */
+package org.opennms.web.rest.v2;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import javax.ws.rs.WebApplicationException;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.StreamingOutput;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.InOrder;
+import org.opennms.systemreport.SystemReport;
+import org.opennms.systemreport.SystemReportFormatter;
+import org.opennms.systemreport.SystemReportPlugin;
+
+public class SystemReportRestServiceTest {
+
+ private SystemReport m_systemReport;
+ private SystemReportRestService m_service;
+
+ private SystemReportPlugin plugin(final String name, final String description, final boolean visible) {
+ final SystemReportPlugin p = mock(SystemReportPlugin.class);
+ when(p.getName()).thenReturn(name);
+ when(p.getDescription()).thenReturn(description);
+ when(p.isVisible()).thenReturn(visible);
+ return p;
+ }
+
+ private SystemReportFormatter formatter(final String name, final String description, final String ext, final boolean visible) {
+ final SystemReportFormatter f = mock(SystemReportFormatter.class);
+ when(f.getName()).thenReturn(name);
+ when(f.getDescription()).thenReturn(description);
+ when(f.getExtension()).thenReturn(ext);
+ when(f.isVisible()).thenReturn(visible);
+ return f;
+ }
+
+ @Before
+ public void setUp() {
+ m_systemReport = mock(SystemReport.class);
+ m_service = new SystemReportRestService();
+ m_service.setSystemReport(m_systemReport);
+ }
+
+ @Test
+ public void returnsOnlyVisiblePluginsMappedToDtos() {
+ // build the mocks first; stubbing them inline inside the outer when() confuses Mockito
+ final SystemReportPlugin java = plugin("Java", "Java and JVM information", true);
+ final SystemReportPlugin hidden = plugin("Hidden", "should be filtered out", false);
+ final SystemReportPlugin os = plugin("OS", "Kernel, OS, and Distribution", true);
+ when(m_systemReport.getPlugins()).thenReturn(Arrays.asList(java, hidden, os));
+
+ final List result = m_service.getPlugins();
+
+ assertEquals(2, result.size());
+ assertEquals("Java", result.get(0).getName());
+ assertEquals("Java and JVM information", result.get(0).getDescription());
+ assertEquals("OS", result.get(1).getName());
+ }
+
+ @Test
+ public void returnsOnlyVisibleFormattersWithExtension() {
+ final SystemReportFormatter zip = formatter("zip", "Compressed file of all resources", "zip", true);
+ final SystemReportFormatter ftp = formatter("ftp", "internal only", "ftp", false);
+ final SystemReportFormatter text = formatter("text", "Human-readable text", "txt", true);
+ when(m_systemReport.getFormatters()).thenReturn(Arrays.asList(zip, ftp, text));
+
+ final List result = m_service.getFormatters();
+
+ assertEquals(2, result.size());
+ assertEquals("zip", result.get(0).getName());
+ assertEquals("zip", result.get(0).getExtension());
+ assertEquals("text", result.get(1).getName());
+ assertEquals("txt", result.get(1).getExtension());
+ }
+
+ private SystemReportFormatter streamingFormatter(final String name, final String ext, final String contentType) {
+ final SystemReportFormatter f = formatter(name, name, ext, true);
+ when(f.needsOutputStream()).thenReturn(true);
+ when(f.getContentType()).thenReturn(contentType);
+ return f;
+ }
+
+ private SystemReportRestService.GenerateRequest request(final String formatter, final List plugins, final String output) {
+ final SystemReportRestService.GenerateRequest r = new SystemReportRestService.GenerateRequest();
+ r.setFormatter(formatter);
+ r.setPlugins(plugins);
+ r.setOutput(output);
+ return r;
+ }
+
+ private int statusOf(final Runnable r) {
+ try {
+ r.run();
+ fail("expected a WebApplicationException");
+ return -1;
+ } catch (final WebApplicationException e) {
+ return e.getResponse().getStatus();
+ }
+ }
+
+ @Test
+ public void generateStreamsOnlySelectedPluginsInOrderWithAttachmentHeader() throws Exception {
+ final SystemReportPlugin java = plugin("Java", "d", true);
+ final SystemReportPlugin os = plugin("OS", "d", true);
+ when(m_systemReport.getPlugins()).thenReturn(Arrays.asList(java, os));
+ final SystemReportFormatter text = streamingFormatter("text", "txt", "text/plain");
+ when(m_systemReport.getFormatters()).thenReturn(Collections.singletonList(text));
+
+ final Response resp = m_service.generate(request("text", Collections.singletonList("Java"), null));
+
+ assertEquals(200, resp.getStatus());
+ assertEquals("attachment; filename=\"opennms-system-report.txt\"", resp.getHeaderString("Content-Disposition"));
+
+ // the entity is a StreamingOutput; running it drives the formatter
+ ((StreamingOutput) resp.getEntity()).write(new ByteArrayOutputStream());
+ final InOrder order = inOrder(text);
+ order.verify(text).begin();
+ order.verify(text).write(java);
+ order.verify(text).end();
+ verify(text, never()).write(os);
+ }
+
+ @Test
+ public void generateSanitizesTheRequestedFilenameToItsBasename() throws Exception {
+ final SystemReportPlugin java = plugin("Java", "d", true);
+ when(m_systemReport.getPlugins()).thenReturn(Collections.singletonList(java));
+ final SystemReportFormatter text = streamingFormatter("text", "txt", "text/plain");
+ when(m_systemReport.getFormatters()).thenReturn(Collections.singletonList(text));
+
+ final Response resp = m_service.generate(request("text", Collections.singletonList("Java"), "/etc/my report.txt"));
+
+ assertEquals("attachment; filename=\"myreport.txt\"", resp.getHeaderString("Content-Disposition"));
+ }
+
+ @Test
+ public void generateRejectsUnknownFormatter() {
+ when(m_systemReport.getFormatters()).thenReturn(Collections.emptyList());
+ assertEquals(400, statusOf(() -> m_service.generate(request("nope", Collections.singletonList("Java"), null))));
+ }
+
+ @Test
+ public void generateRejectsWhenNoPluginsSelected() {
+ final SystemReportPlugin java = plugin("Java", "d", true);
+ when(m_systemReport.getPlugins()).thenReturn(Collections.singletonList(java));
+ final SystemReportFormatter text = streamingFormatter("text", "txt", "text/plain");
+ when(m_systemReport.getFormatters()).thenReturn(Collections.singletonList(text));
+
+ assertEquals(400, statusOf(() -> m_service.generate(request("text", Collections.emptyList(), null))));
+ }
+
+ @Test
+ public void generateRejectsANonStreamingFormatter() {
+ final SystemReportPlugin java = plugin("Java", "d", true);
+ when(m_systemReport.getPlugins()).thenReturn(Collections.singletonList(java));
+ // visible but does not stream (e.g. FTP upload): not downloadable
+ final SystemReportFormatter ftp = formatter("ftp", "ftp", "ftp", true);
+ when(ftp.needsOutputStream()).thenReturn(false);
+ when(m_systemReport.getFormatters()).thenReturn(Collections.singletonList(ftp));
+
+ assertEquals(400, statusOf(() -> m_service.generate(request("ftp", Collections.singletonList("Java"), null))));
+ }
+}
diff --git a/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml b/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml
index 4829fe6357f7..7adf8f2c631c 100644
--- a/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml
+++ b/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml
@@ -193,6 +193,12 @@
+
+
+
+
diff --git a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java
index 5949936dcf44..f10f0f1cc076 100644
--- a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java
+++ b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java
@@ -270,8 +270,11 @@ public void testMenuEntries() throws Exception {
assertNotNull("supportMenu / Public Issue Tracker", foundElement);
clickMenuItem("Support", "Generate System Report");
- wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//ol[@class='breadcrumb']/li[contains(text()[normalize-space()], 'System Reports')]")));
- wait.until(ExpectedConditions.presenceOfElementLocated(By.xpath("//div[@class='card-body']//div[@class='form-group']/input[@type='submit' and @value='Generate System Report']")));
+ // NMS-20154 repointed this entry to the new PrimeVue page (ui/index.html#/system-report),
+ // which renders the /ui breadcrumb (div.breadcrumbs) and an OnmsButton rather than the
+ // legacy ol.breadcrumb list and a Bootstrap submit input.
+ wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//div[contains(@class, 'breadcrumbs')]//a[contains(text()[normalize-space()], 'Generate System Report')]")));
+ wait.until(ExpectedConditions.presenceOfElementLocated(By.xpath("//button[contains(normalize-space(.), 'Generate System Report')]")));
// Omitting for now - need to fix!
// Vaadin Topology page
diff --git a/ui/src/containers/SystemReport.vue b/ui/src/containers/SystemReport.vue
new file mode 100644
index 000000000000..0b18c9e093c3
--- /dev/null
+++ b/ui/src/containers/SystemReport.vue
@@ -0,0 +1,221 @@
+
+
+
+
+
+
+
+
+
+
{{ loadError }}
+
+
+
+
+ Plugins
+
+
+
+
+
+
+
+
Choose which plugins to enable:
+
+ togglePlugin(plugin.name, checked)"
+ />
+
+
+
No report plugins are available.
+
+
+
+
+ Report Type
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Generate System Report
+
+
+
+
+
+
+
+
diff --git a/ui/src/main/router/index.ts b/ui/src/main/router/index.ts
index 52a1b1b07ba4..3d1b54dd6be3 100644
--- a/ui/src/main/router/index.ts
+++ b/ui/src/main/router/index.ts
@@ -253,6 +253,25 @@ const router = createRouter({
}
}
},
+ {
+ path: '/system-report',
+ name: 'Generate System Report',
+ component: () => import('@/containers/SystemReport.vue'),
+ beforeEnter: (to, from) => {
+ const checkRoles = () => {
+ if (!adminRole.value) {
+ showSnackBar({ msg: 'Must be admin to generate a system report.' })
+ router.push(from.path)
+ }
+ }
+
+ if (rolesAreLoaded.value) {
+ checkRoles()
+ } else {
+ whenever(rolesAreLoaded, () => checkRoles())
+ }
+ }
+ },
{
path: '/snmp-config',
name: 'SNMP Config',
diff --git a/ui/src/services/index.ts b/ui/src/services/index.ts
index 8b8c60f273ea..22829979c3ff 100644
--- a/ui/src/services/index.ts
+++ b/ui/src/services/index.ts
@@ -74,9 +74,13 @@ import {
setUsageStatisticsStatus
} from './usageStatisticsService'
import { addZenithRegistration, getZenithRegistrations } from './zenithConnectService'
+import { getSystemReportPlugins, getSystemReportFormatters, generateSystemReport } from './systemReportService'
export default {
search,
+ getSystemReportPlugins,
+ getSystemReportFormatters,
+ generateSystemReport,
getInfo,
getNodes,
getAlarms,
diff --git a/ui/src/services/systemReportService.ts b/ui/src/services/systemReportService.ts
new file mode 100644
index 000000000000..3a47e5fde9c5
--- /dev/null
+++ b/ui/src/services/systemReportService.ts
@@ -0,0 +1,53 @@
+///
+/// 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 { GenerateSystemReportRequest, SystemReportFormatter, SystemReportPlugin } from '@/types/systemReport'
+
+// null (not []) on failure so the page can distinguish an error from an empty set.
+export const getSystemReportPlugins = async (): Promise => {
+ try {
+ const resp = await v2.get('/system-report/plugins')
+ return Array.isArray(resp.data) ? resp.data : []
+ } catch (_err) {
+ return null
+ }
+}
+
+export const getSystemReportFormatters = async (): Promise => {
+ try {
+ const resp = await v2.get('/system-report/formatters')
+ return Array.isArray(resp.data) ? resp.data : []
+ } catch (_err) {
+ return null
+ }
+}
+
+// Returns the raw AxiosResponse (blob) so useDownload can pull the filename from
+// the Content-Disposition header; false on failure so the page can warn the user.
+export const generateSystemReport = async (request: GenerateSystemReportRequest) => {
+ try {
+ return await v2.post('/system-report/generate', request, { responseType: 'blob' })
+ } catch (_err) {
+ return false
+ }
+}
diff --git a/ui/src/types/systemReport.ts b/ui/src/types/systemReport.ts
new file mode 100644
index 000000000000..eb54be69e25e
--- /dev/null
+++ b/ui/src/types/systemReport.ts
@@ -0,0 +1,39 @@
+///
+/// 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.
+///
+
+export interface SystemReportPlugin {
+ name: string
+ description: string
+}
+
+export interface SystemReportFormatter {
+ name: string
+ description: string
+ extension: string
+}
+
+export interface GenerateSystemReportRequest {
+ formatter: string
+ plugins: string[]
+ // sanitized basename; omit to let the server pick a default name
+ output?: string
+}
diff --git a/ui/tests/systemReport.test.ts b/ui/tests/systemReport.test.ts
new file mode 100644
index 000000000000..1de1124e38a8
--- /dev/null
+++ b/ui/tests/systemReport.test.ts
@@ -0,0 +1,173 @@
+///
+/// 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 { mount, flushPromises } from '@vue/test-utils'
+import { createTestingPinia } from '@pinia/testing'
+import PrimeVue from 'primevue/config'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import SystemReport from '@/containers/SystemReport.vue'
+
+const plugins = [
+ { name: 'Java', description: 'Java and JVM information' },
+ { name: 'OS', description: 'Kernel, OS, and Distribution' }
+]
+const formatters = [
+ { name: 'text', description: 'Human-readable text', extension: 'txt' },
+ { name: 'zip', description: 'Compressed file of all resources', extension: 'zip' }
+]
+
+const getSystemReportPlugins = vi.fn()
+const getSystemReportFormatters = vi.fn()
+const generateSystemReport = vi.fn()
+vi.mock('@/services', () => ({
+ default: {
+ getSystemReportPlugins: (...a: unknown[]) => getSystemReportPlugins(...a),
+ getSystemReportFormatters: (...a: unknown[]) => getSystemReportFormatters(...a),
+ generateSystemReport: (...a: unknown[]) => generateSystemReport(...a)
+ }
+}))
+
+const downloadFile = vi.fn()
+vi.mock('@/composables/useDownload', () => ({ default: () => ({ downloadFile }) }))
+
+const showSnackBar = vi.fn()
+vi.mock('@/composables/useSnackbar', () => ({ default: () => ({ showSnackBar }) }))
+
+// the page is admin-only; tests exercise it as an admin
+vi.mock('@/composables/useRole', () => ({ default: () => ({ adminRole: { value: true }}) }))
+
+const okResponse = {
+ data: new Blob(['report']),
+ headers: { 'content-disposition': 'attachment; filename="opennms-system-report.txt"' }
+}
+
+const mountPage = () =>
+ mount(SystemReport, {
+ global: {
+ plugins: [createTestingPinia({ stubActions: false }), PrimeVue],
+ stubs: ['router-link', 'BreadCrumbs']
+ }
+ })
+
+describe('SystemReport', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ getSystemReportPlugins.mockResolvedValue([...plugins])
+ getSystemReportFormatters.mockResolvedValue([...formatters])
+ generateSystemReport.mockResolvedValue({ ...okResponse })
+ })
+
+ it('generates with every plugin enabled and the text formatter by default, then downloads', async () => {
+ const wrapper = mountPage()
+ await flushPromises()
+
+ await wrapper.get('[data-test=generate-btn]').trigger('click')
+ await flushPromises()
+
+ // default matches the legacy form's pre-selected 'text', every plugin enabled
+ expect(generateSystemReport).toHaveBeenCalledWith({
+ formatter: 'text',
+ plugins: ['Java', 'OS'],
+ output: undefined
+ })
+ // the response is handed to useDownload as-is (force-blob)
+ expect(downloadFile).toHaveBeenCalledWith(expect.objectContaining({ data: expect.any(Blob) }), true)
+ // the user gets told generation is under way
+ expect(showSnackBar).toHaveBeenCalledWith(expect.objectContaining({ msg: expect.stringMatching(/generating/i) }))
+ })
+
+ it('sanitizes the filename to word characters, mirroring the server', async () => {
+ const wrapper = mountPage()
+ await flushPromises()
+
+ await wrapper.get('[data-test=filename]').setValue('my report.txt')
+ await wrapper.get('[data-test=generate-btn]').trigger('click')
+ await flushPromises()
+
+ expect(generateSystemReport).toHaveBeenCalledWith(expect.objectContaining({ output: 'myreport.txt' }))
+ })
+
+ it('omits output when the filename sanitizes to empty (no empty download name)', async () => {
+ const wrapper = mountPage()
+ await flushPromises()
+
+ await wrapper.get('[data-test=filename]').setValue('///')
+ await wrapper.get('[data-test=generate-btn]').trigger('click')
+ await flushPromises()
+
+ expect(generateSystemReport).toHaveBeenCalledWith(expect.objectContaining({ output: undefined }))
+ })
+
+ it('reports an error and does not download when generation fails', async () => {
+ generateSystemReport.mockResolvedValue(false)
+ const wrapper = mountPage()
+ await flushPromises()
+
+ await wrapper.get('[data-test=generate-btn]').trigger('click')
+ await flushPromises()
+
+ expect(downloadFile).not.toHaveBeenCalled()
+ expect(showSnackBar).toHaveBeenCalledWith(expect.objectContaining({ error: true, msg: expect.stringMatching(/could not be generated/i) }))
+ })
+
+ it('the All toggle clears every plugin and reselects them', async () => {
+ const wrapper = mountPage()
+ await flushPromises()
+ const vm = wrapper.vm as unknown as { selectedPlugins: string[]; toggleAll: () => void; allSelected: boolean }
+
+ expect(vm.selectedPlugins).toEqual(['Java', 'OS'])
+ expect(vm.allSelected).toBe(true)
+
+ vm.toggleAll()
+ expect(vm.selectedPlugins).toEqual([])
+
+ vm.toggleAll()
+ expect(vm.selectedPlugins).toEqual(['Java', 'OS'])
+ })
+
+ it('per-plugin toggle adds and removes a single plugin', async () => {
+ const wrapper = mountPage()
+ await flushPromises()
+ const vm = wrapper.vm as unknown as { selectedPlugins: string[]; togglePlugin: (n: string, c: boolean) => void }
+
+ vm.togglePlugin('Java', false)
+ expect(vm.selectedPlugins).toEqual(['OS'])
+
+ vm.togglePlugin('Java', true)
+ expect(vm.selectedPlugins).toEqual(['OS', 'Java'])
+
+ // toggling on an already-selected plugin does not duplicate it
+ vm.togglePlugin('Java', true)
+ expect(vm.selectedPlugins).toEqual(['OS', 'Java'])
+ })
+
+ it('surfaces a load error and does not generate when the API fails', async () => {
+ getSystemReportPlugins.mockResolvedValue(null)
+ const wrapper = mountPage()
+ await flushPromises()
+
+ expect(wrapper.get('[data-test=load-error]').text()).toContain('Failed to load')
+ await wrapper.get('[data-test=generate-btn]').trigger('click')
+ await flushPromises()
+ expect(generateSystemReport).not.toHaveBeenCalled()
+ })
+})