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.opennms org.opennms.features.topology-views ${project.version} test + + org.opennms.features + org.opennms.features.system-report + ${project.version} + test + org.opennms.features.distributed org.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.opennms org.opennms.features.topology-views ${project.version} test + + org.opennms.features + org.opennms.features.system-report + ${project.version} + test + org.opennms.features.distributed org.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.opennms opennms-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 @@ + + + + + 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() + }) +})