From b6c2512c49836cf2a6a2eda1fd6e343b48cd69b4 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Thu, 30 Jul 2026 15:51:47 -0400 Subject: [PATCH 1/2] NMS-20100: PrimeVue Notifications page with a General configuration tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the Notifications page (notice queries, outstanding/acknowledged notice lists with CSV export and printing, collapsible explanations) as a PrimeVue page backed by REST, and adds a Configure Notifications dialog holding a General tab with the system-wide notification on/off switch. Adds the admin-only /rest/notification-config endpoints for the notifd status and the read side of destination paths (the base API surface for the dependent tab PRs), backed by the same file-backed factories the legacy wizards use — the XML files stay the system of record. Browser delivery of notices for /ui pages ships here as well. The Event Notifications, Destination Paths and Path Outages tabs follow in dependent PRs. --- .../v1/NotificationConfigRestService.java | 215 ++++++++++++ .../webapp/WEB-INF/menu/menu-template.json | 2 +- .../v1/NotificationConfigRestServiceIT.java | 178 ++++++++++ .../ConfigureNotificationsDialog.vue | 106 ++++++ .../AdminNotifications/NoticesTable.vue | 308 ++++++++++++++++++ .../NotificationExplanationsCard.vue | 115 +++++++ .../NotificationQueriesCard.vue | 150 +++++++++ ui/src/composables/useBrowserNotifications.ts | 93 ++++++ ui/src/containers/AdminNotifications.vue | 102 ++++++ ui/src/main/App.vue | 8 +- ui/src/main/router/index.ts | 5 + ui/src/services/index.ts | 13 +- ui/src/services/noticesService.ts | 87 +++++ ui/src/services/notificationConfigService.ts | 77 +++++ ui/src/stores/noticesStore.ts | 120 +++++++ ui/src/stores/notificationConfigStore.ts | 60 ++++ ui/src/types/notices.ts | 72 ++++ ui/src/types/notificationConfig.ts | 48 +++ ui/tests/stores/noticesStore.test.ts | 155 +++++++++ .../stores/notificationConfigStore.test.ts | 81 +++++ 20 files changed, 1992 insertions(+), 3 deletions(-) create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/NotificationConfigRestService.java create mode 100644 opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/NotificationConfigRestServiceIT.java create mode 100644 ui/src/components/AdminNotifications/ConfigureNotificationsDialog.vue create mode 100644 ui/src/components/AdminNotifications/NoticesTable.vue create mode 100644 ui/src/components/AdminNotifications/NotificationExplanationsCard.vue create mode 100644 ui/src/components/AdminNotifications/NotificationQueriesCard.vue create mode 100644 ui/src/composables/useBrowserNotifications.ts create mode 100644 ui/src/containers/AdminNotifications.vue create mode 100644 ui/src/services/noticesService.ts create mode 100644 ui/src/services/notificationConfigService.ts create mode 100644 ui/src/stores/noticesStore.ts create mode 100644 ui/src/stores/notificationConfigStore.ts create mode 100644 ui/src/types/notices.ts create mode 100644 ui/src/types/notificationConfig.ts create mode 100644 ui/tests/stores/noticesStore.test.ts create mode 100644 ui/tests/stores/notificationConfigStore.test.ts diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/NotificationConfigRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/NotificationConfigRestService.java new file mode 100644 index 000000000000..27dc8a8af566 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/NotificationConfigRestService.java @@ -0,0 +1,215 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v1; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +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.Response.Status; +import javax.ws.rs.core.SecurityContext; +import javax.xml.bind.annotation.XmlAttribute; +import javax.xml.bind.annotation.XmlRootElement; + +import org.opennms.netmgt.config.DestinationPathFactory; +import org.opennms.netmgt.config.NotifdConfigFactory; +import org.opennms.netmgt.config.destinationPaths.DestinationPaths; +import org.opennms.netmgt.events.api.EventProxy; +import org.opennms.netmgt.model.events.EventBuilder; +import org.opennms.web.api.Authentication; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * REST access to the notification configuration that has historically only been + * reachable through the admin JSP wizards. All operations delegate to the same + * file-backed config factories the legacy servlets use, so the XML files + * (notifications.xml, destinationPaths.xml, notificationCommands.xml, + * notifd-configuration.xml) remain the system of record and stay fully + * editable by hand. + * + * + */ +@Component("notificationConfigRestService") +@Path("notification-config") +@Tag(name = "Notification-config", description = "Notification Configuration API") +public class NotificationConfigRestService extends OnmsRestService { + + private static final Logger LOG = LoggerFactory.getLogger(NotificationConfigRestService.class); + + @Autowired + @Qualifier("eventProxy") + protected EventProxy m_eventProxy; + + @XmlRootElement(name = "notification-status") + public static class NotificationStatus { + private String m_status; + + public NotificationStatus() { + } + + public NotificationStatus(final String status) { + m_status = status; + } + + @XmlAttribute(name = "status") + public String getStatus() { + return m_status; + } + + public void setStatus(final String status) { + m_status = status; + } + } + + @GET + @Path("status") + @Produces(MediaType.APPLICATION_JSON) + public NotificationStatus getStatus(@Context final SecurityContext securityContext) { + assertAdmin(securityContext, "read the notification status"); + try { + return new NotificationStatus(getNotifdConfigFactory().getNotificationStatus()); + } catch (final Exception e) { + throw getException(Status.INTERNAL_SERVER_ERROR, "Can't read notifd status: {}", e.getMessage()); + } + } + + @PUT + @Path("status") + @Consumes(MediaType.APPLICATION_JSON) + public Response setStatus(@Context final SecurityContext securityContext, final NotificationStatus status) { + assertAdmin(securityContext, "change the notification status"); + if (status == null || !("on".equals(status.getStatus()) || "off".equals(status.getStatus()))) { + throw getException(Status.BAD_REQUEST, "Status must be 'on' or 'off'"); + } + writeLock(); + try { + LOG.info("Setting notifd status to {} for user {}", status.getStatus(), securityContext.getUserPrincipal().getName()); + if ("on".equals(status.getStatus())) { + getNotifdConfigFactory().turnNotifdOn(); + sendStatusEvent("uei.opennms.org/internal/notificationsTurnedOn", securityContext); + } else { + getNotifdConfigFactory().turnNotifdOff(); + sendStatusEvent("uei.opennms.org/internal/notificationsTurnedOff", securityContext); + } + return Response.noContent().build(); + } catch (final Exception e) { + throw getException(Status.INTERNAL_SERVER_ERROR, "Can't update notifd status: {}", e.getMessage()); + } finally { + writeUnlock(); + } + } + + @GET + @Path("destination-paths") + @Produces(MediaType.APPLICATION_JSON) + public DestinationPaths getDestinationPaths(@Context final SecurityContext securityContext) { + assertAdmin(securityContext, "read destination paths"); + readLock(); + try { + final DestinationPaths paths = new DestinationPaths(); + final List list = new ArrayList<>(getDestinationPathFactory().getPaths().values()); + list.sort(Comparator.comparing(org.opennms.netmgt.config.destinationPaths.Path::getName, String.CASE_INSENSITIVE_ORDER)); + paths.setPaths(list); + return paths; + } catch (final Exception e) { + throw getException(Status.INTERNAL_SERVER_ERROR, "Can't read destination paths: {}", e.getMessage()); + } finally { + readUnlock(); + } + } + + @GET + @Path("destination-paths/{name}") + @Produces(MediaType.APPLICATION_JSON) + public org.opennms.netmgt.config.destinationPaths.Path getDestinationPath(@Context final SecurityContext securityContext, @PathParam("name") final String name) { + assertAdmin(securityContext, "read destination paths"); + readLock(); + try { + final org.opennms.netmgt.config.destinationPaths.Path path = getDestinationPathFactory().getPath(name); + if (path == null) { + throw getException(Status.NOT_FOUND, "Destination path {} was not found.", name); + } + return path; + } catch (final javax.ws.rs.WebApplicationException e) { + throw e; + } catch (final Exception e) { + throw getException(Status.INTERNAL_SERVER_ERROR, "Can't read destination path {}: {}", name, e.getMessage()); + } finally { + readUnlock(); + } + } + + private static void assertAdmin(final SecurityContext securityContext, final String operation) { + if (!securityContext.isUserInRole(Authentication.ROLE_ADMIN)) { + throw getException(Status.FORBIDDEN, "User {} does not have access to {}.", securityContext.getUserPrincipal().getName(), operation); + } + } + + private void sendStatusEvent(final String uei, final SecurityContext securityContext) { + final EventBuilder bldr = new EventBuilder(uei, "ReST"); + bldr.addParam("remoteUser", securityContext.getUserPrincipal().getName()); + try { + m_eventProxy.send(bldr.getEvent()); + } catch (final Exception e) { + LOG.warn("Can't send event {}", uei, e); + } + } + + private static NotifdConfigFactory getNotifdConfigFactory() throws Exception { + NotifdConfigFactory.init(); + return NotifdConfigFactory.getInstance(); + } + + private static boolean isBlank(final String value) { + return value == null || value.isBlank(); + } + + private static DestinationPathFactory getDestinationPathFactory() throws Exception { + DestinationPathFactory.init(); + return DestinationPathFactory.getInstance(); + } + + + + +} 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..0f358eb68766 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 @@ -262,7 +262,7 @@ { "id": "notifications", "name": "Notifications", - "url": "notification/index.jsp", + "url": "ui/index.html#/admin/notifications", "locationMatch": "notification", "roles": null }, diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/NotificationConfigRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/NotificationConfigRestServiceIT.java new file mode 100644 index 000000000000..2283e3166585 --- /dev/null +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/NotificationConfigRestServiceIT.java @@ -0,0 +1,178 @@ +/* + * 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.v1; + + +import java.io.File; +import java.nio.charset.Charset; + +import javax.ws.rs.core.MediaType; + +import org.apache.commons.io.FileUtils; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.test.ConfigurationTestUtils; +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.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +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/component-service.xml", + "classpath*:/META-INF/opennms/component-dao.xml", + "classpath:/META-INF/opennms/applicationContext-databasePopulator.xml", + "classpath:/META-INF/opennms/mockEventIpcManager.xml", + "file:src/main/webapp/WEB-INF/applicationContext-svclayer.xml", + "file:src/main/webapp/WEB-INF/applicationContext-cxf-common.xml", + "classpath:/applicationContext-rest-test.xml" +}) +@JUnitConfigurationEnvironment(systemProperties="org.opennms.timeseries.strategy=integration") +@JUnitTemporaryDatabase +public class NotificationConfigRestServiceIT extends AbstractSpringJerseyRestTestCase { + + + private String m_onmsHome; + + + @Override + protected void beforeServletStart() throws Exception { + MockLogAppender.setupLogging(); + File etc = new File("target/test-work-dir/etc"); + etc.mkdirs(); + m_onmsHome = etc.getParent(); + System.setProperty("opennms.home", m_onmsHome); + ConfigurationTestUtils.setRelativeHomeDirectory(m_onmsHome); + + FileUtils.writeStringToFile(new File(etc, "notifd-configuration.xml"), "" + + "" + + "default20s" + + "org.opennms.netmgt.notifd.DefaultQueueHandler" + + "" + + "", Charset.defaultCharset()); + // deliberately NOT calling NotifdConfigFactory.init() here: the service + // must initialize it itself (cold-start ordering regression guard) + + FileUtils.writeStringToFile(new File(etc, "notifications.xml"), "" + + "" + + "
1.2Wednesday, February 6, 2002 10:10:00 AM ESTlocalhost
" + + "" + + "uei.opennms.org/nodes/nodeDown" + + "IPADDR != '0.0.0.0'" + + "Email-Admin" + + "node down" + + "" + + "
", Charset.defaultCharset()); + + FileUtils.writeStringToFile(new File(etc, "destinationPaths.xml"), "" + + "" + + "
1.2Wednesday, February 6, 2002 10:10:00 AM ESTlocalhost
" + + "" + + "AdminjavaEmail" + + "" + + "
", Charset.defaultCharset()); + + FileUtils.writeStringToFile(new File(etc, "notificationCommands.xml"), "" + + "" + + "
.9Wednesday, February 6, 2002 10:10:00 AM ESTlocalhost
" + + "" + + "javaEmail" + + "org.opennms.netmgt.notifd.JavaMailNotificationStrategy" + + "send an email" + + "" + + "
", Charset.defaultCharset()); + + FileUtils.writeStringToFile(new File(etc, "groups.xml"), "" + + "" + + "
1.3Wednesday, February 6, 2002 10:10:00 AM ESTlocalhost
" + + "Adminadmin" + + "" + + "
", Charset.defaultCharset()); + + } + + // Required so context initialization can't repoint opennms.home at + // opennms-base-assembly between servlet start and the first request. + @Override + public void afterServletStart() throws Exception { + System.setProperty("opennms.home", m_onmsHome); + ConfigurationTestUtils.setRelativeHomeDirectory(m_onmsHome); + } + + @Test + public void testNotifdStatus() throws Exception { + JSONObject status = new JSONObject(getJson("/notification-config/status")); + Assert.assertEquals("off", status.getString("status")); + + sendData(PUT, MediaType.APPLICATION_JSON, "/notification-config/status", "{\"status\":\"on\"}", 204); + status = new JSONObject(getJson("/notification-config/status")); + Assert.assertEquals("on", status.getString("status")); + + // only on/off are valid + sendData(PUT, MediaType.APPLICATION_JSON, "/notification-config/status", "{\"status\":\"maybe\"}", 400); + } + + @Test + public void testDestinationPathsReadable() throws Exception { + // the read side ships in the base PR: the event-notification editor + // needs the path list for its destination picker + // order-independent: sibling tests may add or rename paths + JSONObject list = new JSONObject(getJson("/notification-config/destination-paths")); + Assert.assertTrue(list.getJSONArray("path").length() >= 1); + final String firstName = list.getJSONArray("path").getJSONObject(0).getString("name"); + JSONObject path = new JSONObject(getJson("/notification-config/destination-paths/" + + java.net.URLEncoder.encode(firstName, java.nio.charset.StandardCharsets.UTF_8))); + Assert.assertEquals(firstName, path.getString("name")); + } + + @Test + public void testForbiddenForNonAdmin() throws Exception { + setUser("nobody", new String[]{ "ROLE_USER" }); + try { + sendRequest(GET, "/notification-config/status", 403); + sendData(PUT, MediaType.APPLICATION_JSON, "/notification-config/status", "{\"status\":\"on\"}", 403); + } finally { + setUser("admin", new String[]{ "ROLE_ADMIN" }); + } + } + + private String getJson(final String url) throws Exception { + // the instance createRequest carries the setUser() user AND roles + final MockHttpServletRequest request = createRequest(GET, url); + request.addHeader("Accept", MediaType.APPLICATION_JSON); + return sendRequest(request, 200); + } +} diff --git a/ui/src/components/AdminNotifications/ConfigureNotificationsDialog.vue b/ui/src/components/AdminNotifications/ConfigureNotificationsDialog.vue new file mode 100644 index 000000000000..ea07c0144abe --- /dev/null +++ b/ui/src/components/AdminNotifications/ConfigureNotificationsDialog.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/ui/src/components/AdminNotifications/NoticesTable.vue b/ui/src/components/AdminNotifications/NoticesTable.vue new file mode 100644 index 000000000000..883389d73438 --- /dev/null +++ b/ui/src/components/AdminNotifications/NoticesTable.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/ui/src/components/AdminNotifications/NotificationExplanationsCard.vue b/ui/src/components/AdminNotifications/NotificationExplanationsCard.vue new file mode 100644 index 000000000000..b32acdef45b3 --- /dev/null +++ b/ui/src/components/AdminNotifications/NotificationExplanationsCard.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/ui/src/components/AdminNotifications/NotificationQueriesCard.vue b/ui/src/components/AdminNotifications/NotificationQueriesCard.vue new file mode 100644 index 000000000000..8ccf29938ed8 --- /dev/null +++ b/ui/src/components/AdminNotifications/NotificationQueriesCard.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/ui/src/composables/useBrowserNotifications.ts b/ui/src/composables/useBrowserNotifications.ts new file mode 100644 index 000000000000..f6a19a0e1f3a --- /dev/null +++ b/ui/src/composables/useBrowserNotifications.ts @@ -0,0 +1,93 @@ +/// +/// 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 useSnackbar from '@/composables/useSnackbar' + +// Consumes the notifd 'browser' notification method for the Vue app: the +// BrowserNotificationStrategy publishes {id, head, body} messages per user on +// the /notification/stream WebSocket. Legacy JSP pages have their own consumer +// (core/web-assets notifications app); this one covers all /ui pages. Desktop +// notifications are used when the user has granted permission, with an +// in-page snackbar as the fallback so the message is never silently dropped. + +interface BrowserNotificationMessage { + id?: string | string[] + head?: string | string[] + body?: string | string[] +} + +const { showSnackBar } = useSnackbar() + +let socket: WebSocket | null = null +let started = false + +// the stream serializes each field as a single-element array +const unwrap = (value?: string | string[]): string | undefined => { + if (Array.isArray(value)) { + return value.length ? String(value[0]) : undefined + } + return value ?? undefined +} + +const display = (message: BrowserNotificationMessage) => { + const head = unwrap(message.head) ?? 'OpenNMS Notification' + const body = unwrap(message.body) + if ('Notification' in window && Notification.permission === 'granted') { + new Notification(head, { + body: body ?? '', + tag: `opennms:notification:${unwrap(message.id) ?? head}` + }) + } else { + showSnackBar({ msg: body ? `${head} — ${body}` : head, timeout: 8000 }) + } +} + +const connect = (baseHref: string) => { + socket = new WebSocket(`${baseHref}notification/stream`.replace(/^http/, 'ws')) + + socket.onmessage = (event: MessageEvent) => { + try { + display(JSON.parse(event.data)) + } catch { + // not a notification payload; ignore + } + } + + socket.onclose = () => { + socket = null + setTimeout(() => connect(baseHref), 5000) + } +} + +const startBrowserNotifications = (baseHref: string) => { + if (started || !baseHref) { + return + } + started = true + if ('Notification' in window && Notification.permission === 'default') { + // fire-and-forget; the snackbar fallback covers an undecided/denied state + Notification.requestPermission().catch(() => undefined) + } + connect(baseHref) +} + +export default startBrowserNotifications diff --git a/ui/src/containers/AdminNotifications.vue b/ui/src/containers/AdminNotifications.vue new file mode 100644 index 000000000000..46ead5f11c6b --- /dev/null +++ b/ui/src/containers/AdminNotifications.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/ui/src/main/App.vue b/ui/src/main/App.vue index c81991b0e069..497caf8ec51a 100644 --- a/ui/src/main/App.vue +++ b/ui/src/main/App.vue @@ -26,9 +26,11 @@ setup lang="ts" > -import { onMounted } from 'vue' +import { computed, onMounted } from 'vue' +import { whenever } from '@vueuse/core' import { OnmsToastHost } from '@opennms/onms-ui' +import startBrowserNotifications from '@/composables/useBrowserNotifications' import OnmsAppLayout from '@/components/Layout/OnmsAppLayout.vue' import Footer from '@/components/Layout/Footer.vue' import Menubar from '@/components/Menu/Menubar.vue' @@ -48,6 +50,10 @@ const monitoringSystemStore = useMonitoringSystemStore() const nodeStructureStore = useNodeStructureStore() const pluginStore = usePluginStore() +// notifd 'browser' method delivery for /ui pages; needs baseHref from the menu +const baseHref = computed(() => menuStore.mainMenu?.baseHref ?? '') +whenever(() => !!baseHref.value, () => startBrowserNotifications(baseHref.value), { once: true }) + onMounted(() => { authStore.getWhoAmI() infoStore.getInfo() diff --git a/ui/src/main/router/index.ts b/ui/src/main/router/index.ts index 52a1b1b07ba4..a3a2b02cc0ac 100644 --- a/ui/src/main/router/index.ts +++ b/ui/src/main/router/index.ts @@ -158,6 +158,11 @@ const router = createRouter({ } } }, + { + path: '/admin/notifications', + name: 'Notifications', + component: () => import('@/containers/AdminNotifications.vue') + }, { path: '/map', name: 'Map', diff --git a/ui/src/services/index.ts b/ui/src/services/index.ts index 8b8c60f273ea..e063fb77e409 100644 --- a/ui/src/services/index.ts +++ b/ui/src/services/index.ts @@ -74,6 +74,12 @@ import { setUsageStatisticsStatus } from './usageStatisticsService' import { addZenithRegistration, getZenithRegistrations } from './zenithConnectService' +import { + getDestinationPaths, + getNotificationConfigStatus, + setNotificationConfigStatus +} from './notificationConfigService' +import { acknowledgeNotice, browseNotices } from './noticesService' export default { search, @@ -135,5 +141,10 @@ export default { setUsageStatisticsStatus, addZenithRegistration, getZenithRegistrations, - performLogout + performLogout, + acknowledgeNotice, + browseNotices, + getDestinationPaths, + getNotificationConfigStatus, + setNotificationConfigStatus } diff --git a/ui/src/services/noticesService.ts b/ui/src/services/noticesService.ts new file mode 100644 index 000000000000..48709a24d3f3 --- /dev/null +++ b/ui/src/services/noticesService.ts @@ -0,0 +1,87 @@ +/// +/// 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 useSnackbar from '@/composables/useSnackbar' +import useSpinner from '@/composables/useSpinner' +import { NoticeAckType, NoticeBrowseResult, OnmsNotice } from '@/types/notices' +import { rest } from './axiosInstances' + +const { showSnackBar } = useSnackbar() +const { startSpinner, stopSpinner } = useSpinner() +const endpoint = '/notifications' + +interface BrowseNoticesParams { + acktype: NoticeAckType + user?: string | null + limit: number + offset: number +} + +const browseNotices = async (params: BrowseNoticesParams): Promise => { + try { + startSpinner() + const query = new URLSearchParams() + query.set('limit', String(params.limit)) + query.set('offset', String(params.offset)) + query.set('orderBy', 'pageTime') + query.set('order', 'desc') + if (params.acktype === 'unack') { + query.set('answeredBy', 'null') + } else if (params.acktype === 'ack') { + query.set('answeredBy', 'notnull') + } + if (params.user) { + query.set('usersNotified.userId', params.user) + } + const resp = await rest.get(`${endpoint}?${query.toString()}`) + // 204 No Content when nothing matches + if (!resp.data || typeof resp.data !== 'object') { + return { notices: [], totalCount: 0 } + } + const raw = resp.data.notification ?? [] + const notices: OnmsNotice[] = Array.isArray(raw) ? raw : [raw] + return { notices, totalCount: Number(resp.data.totalCount ?? notices.length) } + } catch (_err) { + showSnackBar({ msg: 'Failed to load notices.' }) + return { notices: [], totalCount: 0 } + } finally { + stopSpinner() + } +} + +const acknowledgeNotice = async (noticeId: number, ack: boolean): Promise => { + try { + startSpinner() + await rest.put(`${endpoint}/${noticeId}`, new URLSearchParams({ ack: String(ack) }), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }) + showSnackBar({ msg: `Notice ${noticeId} ${ack ? 'acknowledged' : 'unacknowledged'}.` }) + return true + } catch (_err) { + showSnackBar({ msg: `Failed to ${ack ? 'acknowledge' : 'unacknowledge'} notice ${noticeId}.` }) + return false + } finally { + stopSpinner() + } +} + +export { acknowledgeNotice, browseNotices } diff --git a/ui/src/services/notificationConfigService.ts b/ui/src/services/notificationConfigService.ts new file mode 100644 index 000000000000..714896140754 --- /dev/null +++ b/ui/src/services/notificationConfigService.ts @@ -0,0 +1,77 @@ +/// +/// 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 useSnackbar from '@/composables/useSnackbar' +import useSpinner from '@/composables/useSpinner' +import { DestinationPath, NotifdStatus } from '@/types/notificationConfig' +import { rest } from './axiosInstances' + +const { showSnackBar } = useSnackbar() +const { startSpinner, stopSpinner } = useSpinner() +const endpoint = '/notification-config' + +const getNotificationConfigStatus = async (): Promise => { + try { + startSpinner() + const resp = await rest.get(`${endpoint}/status`) + return resp.data?.status ?? null + } catch (_err) { + showSnackBar({ msg: 'Failed to load notification status.' }) + return null + } finally { + stopSpinner() + } +} + +const setNotificationConfigStatus = async (status: NotifdStatus): Promise => { + try { + startSpinner() + await rest.put(`${endpoint}/status`, { status }) + showSnackBar({ msg: `Notifications turned ${status}.` }) + return true + } catch (_err) { + showSnackBar({ msg: 'Failed to update notification status.' }) + return false + } finally { + stopSpinner() + } +} + +const getDestinationPaths = async (): Promise => { + try { + startSpinner() + const resp = await rest.get(`${endpoint}/destination-paths`) + return resp.data?.path ?? [] + } catch (_err) { + showSnackBar({ msg: 'Failed to load destination paths.' }) + return [] + } finally { + stopSpinner() + } +} + + +export { + getDestinationPaths, + getNotificationConfigStatus, + setNotificationConfigStatus +} diff --git a/ui/src/stores/noticesStore.ts b/ui/src/stores/noticesStore.ts new file mode 100644 index 000000000000..786f6e2bbbc3 --- /dev/null +++ b/ui/src/stores/noticesStore.ts @@ -0,0 +1,120 @@ +/// +/// 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 API from '@/services' +import { useAuthStore } from '@/stores/authStore' +import { NoticeQueryPreset, OnmsNotice } from '@/types/notices' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +export const useNoticesStore = defineStore('noticesStore', () => { + const authStore = useAuthStore() + + const preset = ref('yourOutstanding') + const userFilter = ref(null) + const notices = ref([] as OnmsNotice[]) + const totalCount = ref(0) + const rows = ref(10) + const first = ref(0) + const loading = ref(false) + + const currentUser = computed(() => authStore.whoAmI.id) + + const acktype = computed<'unack' | 'ack'>(() => (preset.value === 'allAcknowledged' ? 'ack' : 'unack')) + + const effectiveUser = computed(() => { + if (preset.value === 'yourOutstanding') { + return currentUser.value || null + } + if (preset.value === 'userSearch') { + return userFilter.value + } + return null + }) + + const title = computed(() => { + switch (preset.value) { + case 'yourOutstanding': + return 'Your Outstanding Notices' + case 'allOutstanding': + return 'All Outstanding Notices' + case 'allAcknowledged': + return 'All Acknowledged Notices' + case 'userSearch': + return `Outstanding Notices for '${userFilter.value}'` + } + return 'Notices' + }) + + const load = async () => { + loading.value = true + try { + const result = await API.browseNotices({ + acktype: acktype.value, + user: effectiveUser.value, + limit: rows.value, + offset: first.value + }) + notices.value = result.notices + totalCount.value = result.totalCount + } finally { + loading.value = false + } + } + + const applyPreset = async (newPreset: NoticeQueryPreset, user?: string) => { + preset.value = newPreset + userFilter.value = newPreset === 'userSearch' ? (user ?? null) : null + first.value = 0 + await load() + } + + const onPage = async (newFirst: number, newRows: number) => { + first.value = newFirst + rows.value = newRows + await load() + } + + const acknowledge = async (notice: OnmsNotice) => { + const ok = await API.acknowledgeNotice(notice.id, true) + if (ok) { + await load() + } + return ok + } + + return { + preset, + userFilter, + notices, + totalCount, + rows, + first, + loading, + currentUser, + title, + load, + applyPreset, + onPage, + acknowledge + } +}) diff --git a/ui/src/stores/notificationConfigStore.ts b/ui/src/stores/notificationConfigStore.ts new file mode 100644 index 000000000000..82595aefc723 --- /dev/null +++ b/ui/src/stores/notificationConfigStore.ts @@ -0,0 +1,60 @@ +/// +/// 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 API from '@/services' +import { DestinationPath, NotifdStatus } from '@/types/notificationConfig' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useNotificationConfigStore = defineStore('notificationConfigStore', () => { + const notifdStatus = ref(null) + const destinationPaths = ref([] as DestinationPath[]) + + const getStatus = async () => { + notifdStatus.value = await API.getNotificationConfigStatus() + } + + const setStatus = async (status: NotifdStatus) => { + const ok = await API.setNotificationConfigStatus(status) + if (ok) { + notifdStatus.value = status + } + return ok + } + + const getDestinationPaths = async () => { + destinationPaths.value = await API.getDestinationPaths() + } + + const populate = async () => { + await Promise.all([getStatus(), getDestinationPaths()]) + } + + return { + notifdStatus, + destinationPaths, + getStatus, + setStatus, + getDestinationPaths, + populate + } +}) diff --git a/ui/src/types/notices.ts b/ui/src/types/notices.ts new file mode 100644 index 000000000000..5549569e76dd --- /dev/null +++ b/ui/src/types/notices.ts @@ -0,0 +1,72 @@ +/// +/// 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. +/// + +// Notices (sent notifications) served by /rest/notifications — DB-backed, +// distinct from the notification *configuration* in types/notificationConfig.ts. +// Field names follow the wire format of the v1 REST serializer, which differs +// from the entity/DB names (id, textMessage, ackUser/ackTime, destinations). + +export type NoticeAckType = 'unack' | 'ack' | 'all' + +export type NoticeQueryPreset = 'yourOutstanding' | 'allOutstanding' | 'allAcknowledged' | 'userSearch' + +export interface OnmsNoticeServiceType { + id?: number + name?: string +} + +export interface OnmsNoticeDestination { + id?: number + userId?: string + media?: string | null + contactInfo?: string | null + notifyTime?: number | string | null + autoNotify?: string | null +} + +export interface OnmsNotice { + id: number + subject?: string | null + textMessage?: string | null + uei?: string | null + severity?: string | null + pageTime?: number | string | null + ackTime?: number | string | null + ackUser?: string | null + eventId?: number | null + nodeId?: number | null + nodeLabel?: string | null + ipAddress?: string | null + serviceType?: OnmsNoticeServiceType | null + queueId?: string | null + destinations?: OnmsNoticeDestination[] +} + +export interface NoticeBrowseFilter { + acktype: NoticeAckType + user: string | null +} + +export interface NoticeBrowseResult { + notices: OnmsNotice[] + totalCount: number +} diff --git a/ui/src/types/notificationConfig.ts b/ui/src/types/notificationConfig.ts new file mode 100644 index 000000000000..123eeb20c7cd --- /dev/null +++ b/ui/src/types/notificationConfig.ts @@ -0,0 +1,48 @@ +/// +/// 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. +/// + +// JSON mirrors of the JAXB config models behind /rest/notification-config. +// Field names follow the XML element/attribute names in notifications.xml, +// destinationPaths.xml and notificationCommands.xml — the files stay the +// system of record, this API is a 1:1 view of them. + +export type NotifdStatus = 'on' | 'off' + +export interface DestinationPathTarget { + interval?: string + name: string + autoNotify?: string + command: string[] +} + +export interface DestinationPathEscalate { + delay: string + target: DestinationPathTarget[] +} + +export interface DestinationPath { + name: string + 'initial-delay'?: string + target: DestinationPathTarget[] + escalate?: DestinationPathEscalate[] +} + diff --git a/ui/tests/stores/noticesStore.test.ts b/ui/tests/stores/noticesStore.test.ts new file mode 100644 index 000000000000..8d55e878013d --- /dev/null +++ b/ui/tests/stores/noticesStore.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useNoticesStore } from '@/stores/noticesStore' +import { useAuthStore } from '@/stores/authStore' +import API from '@/services' +import { OnmsNotice } from '@/types/notices' + +vi.mock('@/services', () => ({ + default: { + browseNotices: vi.fn(), + acknowledgeNotice: vi.fn() + } +})) + +describe('useNoticesStore', () => { + let store: ReturnType + + const mockNotices: OnmsNotice[] = [ + { + id: 1, + subject: 'Notice #1: node down', + severity: 'MAJOR', + pageTime: 1785327487410, + nodeId: 16, + nodeLabel: 'Core-Router-01' + }, + { + id: 2, + subject: 'Notice #2: interface down', + severity: 'MAJOR', + pageTime: 1785327487000, + ipAddress: '10.0.0.5' + } + ] + + const mockResult = { notices: mockNotices, totalCount: 5 } + + beforeEach(() => { + setActivePinia(createPinia()) + const authStore = useAuthStore() + authStore.whoAmI = { id: 'admin', fullName: 'Administrator', internal: true, roles: ['ROLE_ADMIN'] } + store = useNoticesStore() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('Initial State', () => { + it('should default to the current user outstanding notices', () => { + expect(store.preset).toBe('yourOutstanding') + expect(store.notices).toEqual([]) + expect(store.totalCount).toBe(0) + expect(store.first).toBe(0) + expect(store.rows).toBe(10) + expect(store.title).toBe('Your Outstanding Notices') + }) + }) + + describe('load', () => { + it('should query outstanding notices for the current user', async () => { + vi.mocked(API.browseNotices).mockResolvedValue(mockResult) + + await store.load() + + expect(API.browseNotices).toHaveBeenCalledWith({ + acktype: 'unack', + user: 'admin', + limit: 10, + offset: 0 + }) + expect(store.notices).toEqual(mockNotices) + expect(store.totalCount).toBe(5) + }) + }) + + describe('applyPreset', () => { + it('allOutstanding should drop the user filter and reset paging', async () => { + vi.mocked(API.browseNotices).mockResolvedValue(mockResult) + store.first = 30 + + await store.applyPreset('allOutstanding') + + expect(store.preset).toBe('allOutstanding') + expect(store.first).toBe(0) + expect(API.browseNotices).toHaveBeenCalledWith({ + acktype: 'unack', + user: null, + limit: 10, + offset: 0 + }) + expect(store.title).toBe('All Outstanding Notices') + }) + + it('allAcknowledged should query acknowledged notices', async () => { + vi.mocked(API.browseNotices).mockResolvedValue(mockResult) + + await store.applyPreset('allAcknowledged') + + expect(API.browseNotices).toHaveBeenCalledWith( + expect.objectContaining({ acktype: 'ack', user: null }) + ) + expect(store.title).toBe('All Acknowledged Notices') + }) + + it('userSearch should filter outstanding notices by the given user', async () => { + vi.mocked(API.browseNotices).mockResolvedValue(mockResult) + + await store.applyPreset('userSearch', 'operator') + + expect(store.userFilter).toBe('operator') + expect(API.browseNotices).toHaveBeenCalledWith( + expect.objectContaining({ acktype: 'unack', user: 'operator' }) + ) + expect(store.title).toBe("Outstanding Notices for 'operator'") + }) + }) + + describe('onPage', () => { + it('should reload with the new offset and page size', async () => { + vi.mocked(API.browseNotices).mockResolvedValue(mockResult) + + await store.onPage(20, 20) + + expect(store.first).toBe(20) + expect(store.rows).toBe(20) + expect(API.browseNotices).toHaveBeenCalledWith( + expect.objectContaining({ limit: 20, offset: 20 }) + ) + }) + }) + + describe('acknowledge', () => { + it('should acknowledge and reload on success', async () => { + vi.mocked(API.acknowledgeNotice).mockResolvedValue(true) + vi.mocked(API.browseNotices).mockResolvedValue(mockResult) + + const ok = await store.acknowledge(mockNotices[0]) + + expect(ok).toBe(true) + expect(API.acknowledgeNotice).toHaveBeenCalledWith(1, true) + expect(API.browseNotices).toHaveBeenCalledTimes(1) + }) + + it('should not reload when acknowledging fails', async () => { + vi.mocked(API.acknowledgeNotice).mockResolvedValue(false) + + const ok = await store.acknowledge(mockNotices[0]) + + expect(ok).toBe(false) + expect(API.browseNotices).not.toHaveBeenCalled() + }) + }) +}) diff --git a/ui/tests/stores/notificationConfigStore.test.ts b/ui/tests/stores/notificationConfigStore.test.ts new file mode 100644 index 000000000000..e1d2e12d21fc --- /dev/null +++ b/ui/tests/stores/notificationConfigStore.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useNotificationConfigStore } from '@/stores/notificationConfigStore' +import API from '@/services' +import { DestinationPath } from '@/types/notificationConfig' + +vi.mock('@/services', () => ({ + default: { + getNotificationConfigStatus: vi.fn(), + setNotificationConfigStatus: vi.fn(), + getDestinationPaths: vi.fn() + } +})) + +describe('useNotificationConfigStore', () => { + let store: ReturnType + + const mockPath: DestinationPath = { + name: 'Email-Admin', + 'initial-delay': '0s', + target: [{ name: 'Admin', command: ['javaEmail'] }] + } + + beforeEach(() => { + setActivePinia(createPinia()) + store = useNotificationConfigStore() + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe('Initial State', () => { + it('should start empty with unknown notifd status', () => { + expect(store.notifdStatus).toBeNull() + expect(store.destinationPaths).toEqual([]) + }) + }) + + describe('notifd status', () => { + it('should load the status', async () => { + vi.mocked(API.getNotificationConfigStatus).mockResolvedValue('on') + + await store.getStatus() + + expect(store.notifdStatus).toBe('on') + }) + + it('should update the status on success', async () => { + vi.mocked(API.setNotificationConfigStatus).mockResolvedValue(true) + + const ok = await store.setStatus('on') + + expect(ok).toBe(true) + expect(store.notifdStatus).toBe('on') + }) + + it('should keep the old status on failure', async () => { + store.notifdStatus = 'off' + vi.mocked(API.setNotificationConfigStatus).mockResolvedValue(false) + + const ok = await store.setStatus('on') + + expect(ok).toBe(false) + expect(store.notifdStatus).toBe('off') + }) + }) + + describe('populate', () => { + it('should load everything the dialog needs', async () => { + vi.mocked(API.getNotificationConfigStatus).mockResolvedValue('off') + vi.mocked(API.getDestinationPaths).mockResolvedValue([mockPath]) + + await store.populate() + + expect(store.notifdStatus).toBe('off') + expect(store.destinationPaths).toEqual([mockPath]) + }) + }) +}) From a9e9fe2f1f19a1da97746c12e0a84a32487c672e Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Thu, 30 Jul 2026 15:55:42 -0400 Subject: [PATCH 2/2] NMS-20100: Path Outages tab Adds the Path Outages tab: the list of configured critical paths and the apply/clear flow driven by a filter rule with a node-count preview, in place of the legacy notification wizard pages. Storage is and stays the pathoutage table; the endpoints mirror the legacy NotificationWizardServlet semantics, including clearing the path when the critical IP is blank. Depends on the Notifications page base PR. --- .../v1/NotificationConfigRestService.java | 290 ++++++++++++++ .../v1/NotificationConfigRestServiceIT.java | 69 ++++ .../ConfigureNotificationsDialog.vue | 5 + .../AdminNotifications/PathOutagesTab.vue | 374 ++++++++++++++++++ ui/src/services/index.ts | 8 + ui/src/services/notificationConfigService.ts | 62 ++- ui/src/stores/notificationConfigStore.ts | 34 +- ui/src/types/notificationConfig.ts | 20 + .../stores/notificationConfigStore.test.ts | 53 ++- 9 files changed, 910 insertions(+), 5 deletions(-) create mode 100644 ui/src/components/AdminNotifications/PathOutagesTab.vue diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/NotificationConfigRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/NotificationConfigRestService.java index 27dc8a8af566..4205b930442f 100644 --- a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/NotificationConfigRestService.java +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v1/NotificationConfigRestService.java @@ -21,9 +21,15 @@ */ package org.opennms.web.rest.v1; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.SortedMap; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; @@ -41,10 +47,15 @@ import javax.xml.bind.annotation.XmlAttribute; import javax.xml.bind.annotation.XmlRootElement; +import org.opennms.core.db.DataSourceFactory; +import org.opennms.core.utils.DBUtils; +import org.opennms.core.utils.InetAddressUtils; import org.opennms.netmgt.config.DestinationPathFactory; import org.opennms.netmgt.config.NotifdConfigFactory; import org.opennms.netmgt.config.destinationPaths.DestinationPaths; import org.opennms.netmgt.events.api.EventProxy; +import org.opennms.netmgt.filter.FilterDaoFactory; +import org.opennms.netmgt.filter.api.FilterParseException; import org.opennms.netmgt.model.events.EventBuilder; import org.opennms.web.api.Authentication; import org.slf4j.Logger; @@ -209,7 +220,286 @@ private static DestinationPathFactory getDestinationPathFactory() throws Excepti return DestinationPathFactory.getInstance(); } + // ------------------------------------------------------------------ + // Path outages (critical paths). Storage is and stays the pathoutage + // DB table; the logic below mirrors the legacy NotificationWizardServlet + // (filter rule -> node set; per node delete + optional insert; blank + // critical IP clears the path for the matching nodes). + // ------------------------------------------------------------------ + private static final String SQL_LIST_PATH_OUTAGES = "SELECT p.nodeid, n.nodelabel, p.criticalpathip, p.criticalpathservicename FROM pathoutage p LEFT JOIN node n ON n.nodeid = p.nodeid ORDER BY n.nodelabel, p.nodeid"; + private static final String SQL_SET_CRITICAL_PATH = "INSERT INTO pathoutage (nodeid, criticalpathip, criticalpathservicename) VALUES (?, ?, ?)"; + private static final String SQL_DELETE_CRITICAL_PATH = "DELETE FROM pathoutage WHERE nodeid=?"; + + private static final int PREVIEW_NODE_LIMIT = 200; + + @XmlRootElement(name = "path-outage") + public static class PathOutageDTO { + private Integer m_nodeId; + private String m_nodeLabel; + private String m_criticalPathIp; + private String m_criticalPathServiceName; + + public Integer getNodeId() { + return m_nodeId; + } + + public void setNodeId(final Integer nodeId) { + m_nodeId = nodeId; + } + + public String getNodeLabel() { + return m_nodeLabel; + } + + public void setNodeLabel(final String nodeLabel) { + m_nodeLabel = nodeLabel; + } + + public String getCriticalPathIp() { + return m_criticalPathIp; + } + + public void setCriticalPathIp(final String criticalPathIp) { + m_criticalPathIp = criticalPathIp; + } + + public String getCriticalPathServiceName() { + return m_criticalPathServiceName; + } + + public void setCriticalPathServiceName(final String criticalPathServiceName) { + m_criticalPathServiceName = criticalPathServiceName; + } + } + + @XmlRootElement(name = "path-outage-preview") + public static class PathOutagePreviewDTO { + private int m_totalCount; + private List m_nodes = new ArrayList<>(); + + public int getTotalCount() { + return m_totalCount; + } + + public void setTotalCount(final int totalCount) { + m_totalCount = totalCount; + } + + public List getNodes() { + return m_nodes; + } + + public void setNodes(final List nodes) { + m_nodes = nodes; + } + } + + @XmlRootElement(name = "path-outage-request") + public static class PathOutageRequestDTO { + private String m_rule; + private String m_criticalIp; + private String m_criticalSvc; + + public String getRule() { + return m_rule; + } + + public void setRule(final String rule) { + m_rule = rule; + } + + public String getCriticalIp() { + return m_criticalIp; + } + + public void setCriticalIp(final String criticalIp) { + m_criticalIp = criticalIp; + } + + public String getCriticalSvc() { + return m_criticalSvc; + } + + public void setCriticalSvc(final String criticalSvc) { + m_criticalSvc = criticalSvc; + } + } + + @GET + @Path("path-outages") + @Produces(MediaType.APPLICATION_JSON) + public List getPathOutages(@Context final SecurityContext securityContext) { + assertAdmin(securityContext, "read path outages"); + readLock(); + try { + final List result = new ArrayList<>(); + final Connection conn = DataSourceFactory.getInstance().getConnection(); + final DBUtils d = new DBUtils(getClass(), conn); + try { + final PreparedStatement stmt = conn.prepareStatement(SQL_LIST_PATH_OUTAGES); + d.watch(stmt); + final ResultSet rs = stmt.executeQuery(); + d.watch(rs); + while (rs.next()) { + final PathOutageDTO dto = new PathOutageDTO(); + dto.setNodeId(rs.getInt(1)); + dto.setNodeLabel(rs.getString(2)); + dto.setCriticalPathIp(rs.getString(3)); + dto.setCriticalPathServiceName(rs.getString(4)); + result.add(dto); + } + } finally { + d.cleanUp(); + } + return result; + } catch (final SQLException e) { + throw getException(Status.INTERNAL_SERVER_ERROR, "Can't read path outages: {}", e.getMessage()); + } finally { + readUnlock(); + } + } + + @GET + @Path("path-outages/preview") + @Produces(MediaType.APPLICATION_JSON) + public PathOutagePreviewDTO previewPathOutageRule(@Context final SecurityContext securityContext, @javax.ws.rs.QueryParam("rule") final String rule) { + assertAdmin(securityContext, "preview path outage rules"); + if (rule == null || rule.isBlank()) { + throw getException(Status.BAD_REQUEST, "A filter rule is required"); + } + readLock(); + try { + final SortedMap nodes = getMatchingNodes(rule); + final PathOutagePreviewDTO preview = new PathOutagePreviewDTO(); + preview.setTotalCount(nodes.size()); + for (final Map.Entry entry : nodes.entrySet()) { + if (preview.getNodes().size() >= PREVIEW_NODE_LIMIT) { + break; + } + final PathOutageDTO dto = new PathOutageDTO(); + dto.setNodeId(entry.getKey()); + dto.setNodeLabel(entry.getValue()); + preview.getNodes().add(dto); + } + return preview; + } finally { + readUnlock(); + } + } + + @POST + @Path("path-outages") + @Consumes(MediaType.APPLICATION_JSON) + public Response applyPathOutage(@Context final SecurityContext securityContext, final PathOutageRequestDTO request) { + assertAdmin(securityContext, "configure path outages"); + if (request == null || request.getRule() == null || request.getRule().isBlank()) { + throw getException(Status.BAD_REQUEST, "A filter rule is required"); + } + String criticalIp = request.getCriticalIp() == null ? "" : request.getCriticalIp().trim(); + final String criticalSvc = request.getCriticalSvc() == null || request.getCriticalSvc().isBlank() ? "ICMP" : request.getCriticalSvc().trim(); + if (!criticalIp.isEmpty()) { + try { + FilterDaoFactory.getInstance().validateRule("IPADDR IPLIKE " + criticalIp); + // store the canonical form, matching the legacy wizard + criticalIp = InetAddressUtils.normalize(criticalIp); + } catch (final FilterParseException | IllegalArgumentException e) { + throw getException(Status.BAD_REQUEST, "Invalid critical path IP address: {}", criticalIp); + } + } + writeLock(); + try { + final SortedMap nodes = getMatchingNodes(request.getRule()); + final Connection conn = DataSourceFactory.getInstance().getConnection(); + final DBUtils d = new DBUtils(getClass(), conn); + try { + // one transaction so a mid-loop failure can't leave some nodes + // stripped of their old critical path with no new one written + conn.setAutoCommit(false); + try (PreparedStatement delete = conn.prepareStatement(SQL_DELETE_CRITICAL_PATH); + PreparedStatement insert = conn.prepareStatement(SQL_SET_CRITICAL_PATH)) { + for (final Integer nodeId : nodes.keySet()) { + delete.setInt(1, nodeId); + delete.addBatch(); + if (!criticalIp.isEmpty()) { + insert.setInt(1, nodeId); + insert.setString(2, criticalIp); + insert.setString(3, criticalSvc); + insert.addBatch(); + } + } + delete.executeBatch(); + if (!criticalIp.isEmpty()) { + insert.executeBatch(); + } + conn.commit(); + } catch (final SQLException e) { + conn.rollback(); + throw e; + } finally { + conn.setAutoCommit(true); + } + } finally { + d.cleanUp(); + } + LOG.info("Path outage {} applied to {} nodes (rule '{}') by user {}", + criticalIp.isEmpty() ? "cleared" : "critical path " + criticalIp + "/" + criticalSvc, + nodes.size(), request.getRule(), securityContext.getUserPrincipal().getName()); + return Response.noContent().build(); + } catch (final javax.ws.rs.WebApplicationException e) { + throw e; + } catch (final Exception e) { + throw getException(Status.INTERNAL_SERVER_ERROR, "Can't apply path outage: {}", e.getMessage()); + } finally { + writeUnlock(); + } + } + + @DELETE + @Path("path-outages/{nodeId}") + public Response deletePathOutage(@Context final SecurityContext securityContext, @PathParam("nodeId") final Integer nodeId) { + assertAdmin(securityContext, "remove path outages"); + writeLock(); + try { + final Connection conn = DataSourceFactory.getInstance().getConnection(); + final DBUtils d = new DBUtils(getClass(), conn); + try { + deleteCriticalPath(nodeId, conn); + } finally { + d.cleanUp(); + } + return Response.noContent().build(); + } catch (final SQLException e) { + throw getException(Status.INTERNAL_SERVER_ERROR, "Can't remove path outage for node {}: {}", String.valueOf(nodeId), e.getMessage()); + } finally { + writeUnlock(); + } + } + + private static SortedMap getMatchingNodes(final String rule) { + try { + FilterDaoFactory.getInstance().validateRule(rule); + } catch (final FilterParseException e) { + throw getException(Status.BAD_REQUEST, "Invalid filter rule: {}", e.getMessage()); + } + try { + return FilterDaoFactory.getInstance().getNodeMap(rule); + } catch (final FilterParseException e) { + throw getException(Status.BAD_REQUEST, "Invalid filter rule: {}", e.getMessage()); + } + } + + private static void deleteCriticalPath(final int nodeId, final Connection conn) throws SQLException { + final DBUtils d = new DBUtils(NotificationConfigRestService.class); + try { + final PreparedStatement stmt = conn.prepareStatement(SQL_DELETE_CRITICAL_PATH); + d.watch(stmt); + stmt.setInt(1, nodeId); + stmt.execute(); + } finally { + d.cleanUp(); + } + } } diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/NotificationConfigRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/NotificationConfigRestServiceIT.java index 2283e3166585..29d7fd50d141 100644 --- a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/NotificationConfigRestServiceIT.java +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v1/NotificationConfigRestServiceIT.java @@ -21,9 +21,16 @@ */ package org.opennms.web.rest.v1; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.io.File; +import java.sql.Connection; +import java.sql.Statement; import java.nio.charset.Charset; +import java.util.TreeMap; +import java.util.SortedMap; import javax.ws.rs.core.MediaType; @@ -38,6 +45,10 @@ import org.opennms.core.test.OpenNMSJUnit4ClassRunner; import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; import org.opennms.core.test.rest.AbstractSpringJerseyRestTestCase; +import org.opennms.core.db.DataSourceFactory; +import org.opennms.netmgt.filter.FilterDaoFactory; +import org.opennms.netmgt.filter.api.FilterDao; +import org.opennms.netmgt.filter.api.FilterParseException; import org.opennms.test.JUnitConfigurationEnvironment; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpServletRequest; @@ -64,9 +75,11 @@ @JUnitTemporaryDatabase public class NotificationConfigRestServiceIT extends AbstractSpringJerseyRestTestCase { + private static final String INVALID_RULE = "this is not a rule"; private String m_onmsHome; + private FilterDao m_filterDao; @Override protected void beforeServletStart() throws Exception { @@ -122,6 +135,12 @@ protected void beforeServletStart() throws Exception { + "" + "", Charset.defaultCharset()); + m_filterDao = mock(FilterDao.class); + final SortedMap nodeMap = new TreeMap<>(); + nodeMap.put(1, "node1"); + when(m_filterDao.getNodeMap("IPADDR IPLIKE *.*.*.*")).thenReturn(nodeMap); + doThrow(new FilterParseException("invalid rule")).when(m_filterDao).validateRule(INVALID_RULE); + FilterDaoFactory.setInstance(m_filterDao); } // Required so context initialization can't repoint opennms.home at @@ -130,6 +149,14 @@ protected void beforeServletStart() throws Exception { public void afterServletStart() throws Exception { System.setProperty("opennms.home", m_onmsHome); ConfigurationTestUtils.setRelativeHomeDirectory(m_onmsHome); + // Seed the node row for the pathoutage foreign key through the same + // DataSource the service uses, so it is committed and visible to the + // service's own connections regardless of test transactions. + try (Connection conn = DataSourceFactory.getInstance().getConnection(); + Statement st = conn.createStatement()) { + st.execute("INSERT INTO monitoringlocations (id, monitoringarea) VALUES ('Default', 'Default') ON CONFLICT (id) DO NOTHING"); + st.execute("INSERT INTO node (nodeid, nodecreatetime, nodelabel, location) VALUES (1, now(), 'node1', 'Default') ON CONFLICT (nodeid) DO NOTHING"); + } } @Test @@ -145,6 +172,46 @@ public void testNotifdStatus() throws Exception { sendData(PUT, MediaType.APPLICATION_JSON, "/notification-config/status", "{\"status\":\"maybe\"}", 400); } + @Test + public void testPathOutageLifecycle() throws Exception { + JSONArray outages = new JSONArray(getJson("/notification-config/path-outages")); + Assert.assertEquals(0, outages.length()); + + JSONObject preview = new JSONObject(getJson("/notification-config/path-outages/preview?rule=IPADDR%20IPLIKE%20*.*.*.*")); + Assert.assertEquals(1, preview.getInt("totalCount")); + Assert.assertEquals("node1", preview.getJSONArray("nodes").getJSONObject(0).getString("nodeLabel")); + + sendData(POST, MediaType.APPLICATION_JSON, "/notification-config/path-outages", + "{\"rule\":\"IPADDR IPLIKE *.*.*.*\",\"criticalIp\":\"192.168.1.1\",\"criticalSvc\":\"ICMP\"}", 204); + outages = new JSONArray(getJson("/notification-config/path-outages")); + Assert.assertEquals(1, outages.length()); + Assert.assertEquals("192.168.1.1", outages.getJSONObject(0).getString("criticalPathIp")); + + sendRequest(DELETE, "/notification-config/path-outages/1", 204); + outages = new JSONArray(getJson("/notification-config/path-outages")); + Assert.assertEquals(0, outages.length()); + } + + @Test + public void testPathOutageClearsWithBlankIp() throws Exception { + sendData(POST, MediaType.APPLICATION_JSON, "/notification-config/path-outages", + "{\"rule\":\"IPADDR IPLIKE *.*.*.*\",\"criticalIp\":\"192.168.1.1\"}", 204); + Assert.assertEquals(1, new JSONArray(getJson("/notification-config/path-outages")).length()); + + // blank critical IP clears the path for the matching nodes + sendData(POST, MediaType.APPLICATION_JSON, "/notification-config/path-outages", + "{\"rule\":\"IPADDR IPLIKE *.*.*.*\"}", 204); + Assert.assertEquals(0, new JSONArray(getJson("/notification-config/path-outages")).length()); + } + + @Test + public void testPathOutageValidation() throws Exception { + sendData(POST, MediaType.APPLICATION_JSON, "/notification-config/path-outages", + "{\"rule\":\"" + INVALID_RULE + "\",\"criticalIp\":\"192.168.1.1\"}", 400); + sendData(POST, MediaType.APPLICATION_JSON, "/notification-config/path-outages", "{}", 400); + sendRequest(GET, "/notification-config/path-outages/preview", 400); + } + @Test public void testDestinationPathsReadable() throws Exception { // the read side ships in the base PR: the event-notification editor @@ -164,6 +231,8 @@ public void testForbiddenForNonAdmin() throws Exception { try { sendRequest(GET, "/notification-config/status", 403); sendData(PUT, MediaType.APPLICATION_JSON, "/notification-config/status", "{\"status\":\"on\"}", 403); + sendData(POST, MediaType.APPLICATION_JSON, "/notification-config/path-outages", + "{\"rule\":\"IPADDR IPLIKE *.*.*.*\"}", 403); } finally { setUser("admin", new String[]{ "ROLE_ADMIN" }); } diff --git a/ui/src/components/AdminNotifications/ConfigureNotificationsDialog.vue b/ui/src/components/AdminNotifications/ConfigureNotificationsDialog.vue index ea07c0144abe..93c0748e0940 100644 --- a/ui/src/components/AdminNotifications/ConfigureNotificationsDialog.vue +++ b/ui/src/components/AdminNotifications/ConfigureNotificationsDialog.vue @@ -11,9 +11,13 @@ > + Path Outages General + + +
@@ -48,6 +52,7 @@ import TabPanels from 'primevue/tabpanels' import Tabs from 'primevue/tabs' import ToggleSwitch from 'primevue/toggleswitch' +import PathOutagesTab from '@/components/AdminNotifications/PathOutagesTab.vue' import { useNotificationConfigStore } from '@/stores/notificationConfigStore' import { NotifdStatus } from '@/types/notificationConfig' diff --git a/ui/src/components/AdminNotifications/PathOutagesTab.vue b/ui/src/components/AdminNotifications/PathOutagesTab.vue new file mode 100644 index 000000000000..5b6a8f42f334 --- /dev/null +++ b/ui/src/components/AdminNotifications/PathOutagesTab.vue @@ -0,0 +1,374 @@ + + + + + diff --git a/ui/src/services/index.ts b/ui/src/services/index.ts index e063fb77e409..0e0d5ec197bd 100644 --- a/ui/src/services/index.ts +++ b/ui/src/services/index.ts @@ -75,8 +75,12 @@ import { } from './usageStatisticsService' import { addZenithRegistration, getZenithRegistrations } from './zenithConnectService' import { + applyPathOutage, + deletePathOutage, getDestinationPaths, getNotificationConfigStatus, + getPathOutages, + previewPathOutageRule, setNotificationConfigStatus } from './notificationConfigService' import { acknowledgeNotice, browseNotices } from './noticesService' @@ -143,7 +147,11 @@ export default { getZenithRegistrations, performLogout, acknowledgeNotice, + applyPathOutage, browseNotices, + deletePathOutage, + getPathOutages, + previewPathOutageRule, getDestinationPaths, getNotificationConfigStatus, setNotificationConfigStatus diff --git a/ui/src/services/notificationConfigService.ts b/ui/src/services/notificationConfigService.ts index 714896140754..d8cfc695222d 100644 --- a/ui/src/services/notificationConfigService.ts +++ b/ui/src/services/notificationConfigService.ts @@ -22,7 +22,7 @@ import useSnackbar from '@/composables/useSnackbar' import useSpinner from '@/composables/useSpinner' -import { DestinationPath, NotifdStatus } from '@/types/notificationConfig' +import { DestinationPath, NotifdStatus, PathOutage, PathOutagePreview, PathOutageRequest } from '@/types/notificationConfig' import { rest } from './axiosInstances' const { showSnackBar } = useSnackbar() @@ -69,9 +69,69 @@ const getDestinationPaths = async (): Promise => { } } +const getPathOutages = async (): Promise => { + try { + startSpinner() + const resp = await rest.get(`${endpoint}/path-outages`) + return resp.data ?? [] + } catch (_err) { + showSnackBar({ msg: 'Failed to load path outages.' }) + return [] + } finally { + stopSpinner() + } +} + +const previewPathOutageRule = async (rule: string): Promise => { + try { + startSpinner() + const resp = await rest.get(`${endpoint}/path-outages/preview?rule=${encodeURIComponent(rule)}`) + return resp.data ?? null + } catch (err: any) { + const detail = err?.response?.data + showSnackBar({ msg: typeof detail === 'string' && detail ? detail : 'Failed to validate the filter rule.' }) + return null + } finally { + stopSpinner() + } +} + +const applyPathOutage = async (request: PathOutageRequest): Promise => { + try { + startSpinner() + await rest.post(`${endpoint}/path-outages`, request) + showSnackBar({ msg: request.criticalIp ? 'Critical path applied.' : 'Critical path cleared.' }) + return true + } catch (err: any) { + const detail = err?.response?.data + showSnackBar({ msg: typeof detail === 'string' && detail ? detail : 'Failed to apply the critical path.' }) + return false + } finally { + stopSpinner() + } +} + +const deletePathOutage = async (nodeId: number): Promise => { + try { + startSpinner() + await rest.delete(`${endpoint}/path-outages/${nodeId}`) + showSnackBar({ msg: 'Critical path removed.' }) + return true + } catch (_err) { + showSnackBar({ msg: 'Failed to remove the critical path.' }) + return false + } finally { + stopSpinner() + } +} + export { + applyPathOutage, + deletePathOutage, getDestinationPaths, getNotificationConfigStatus, + getPathOutages, + previewPathOutageRule, setNotificationConfigStatus } diff --git a/ui/src/stores/notificationConfigStore.ts b/ui/src/stores/notificationConfigStore.ts index 82595aefc723..577a3358a817 100644 --- a/ui/src/stores/notificationConfigStore.ts +++ b/ui/src/stores/notificationConfigStore.ts @@ -21,13 +21,14 @@ /// import API from '@/services' -import { DestinationPath, NotifdStatus } from '@/types/notificationConfig' +import { DestinationPath, NotifdStatus, PathOutage, PathOutagePreview, PathOutageRequest } from '@/types/notificationConfig' import { defineStore } from 'pinia' import { ref } from 'vue' export const useNotificationConfigStore = defineStore('notificationConfigStore', () => { const notifdStatus = ref(null) const destinationPaths = ref([] as DestinationPath[]) + const pathOutages = ref([] as PathOutage[]) const getStatus = async () => { notifdStatus.value = await API.getNotificationConfigStatus() @@ -45,8 +46,32 @@ export const useNotificationConfigStore = defineStore('notificationConfigStore', destinationPaths.value = await API.getDestinationPaths() } + const getPathOutages = async () => { + pathOutages.value = await API.getPathOutages() + } + + const previewPathOutageRule = async (rule: string): Promise => { + return await API.previewPathOutageRule(rule) + } + + const applyPathOutage = async (request: PathOutageRequest) => { + const ok = await API.applyPathOutage(request) + if (ok) { + await getPathOutages() + } + return ok + } + + const deletePathOutage = async (nodeId: number) => { + const ok = await API.deletePathOutage(nodeId) + if (ok) { + await getPathOutages() + } + return ok + } + const populate = async () => { - await Promise.all([getStatus(), getDestinationPaths()]) + await Promise.all([getStatus(), getDestinationPaths(), getPathOutages()]) } return { @@ -55,6 +80,11 @@ export const useNotificationConfigStore = defineStore('notificationConfigStore', getStatus, setStatus, getDestinationPaths, + pathOutages, + getPathOutages, + previewPathOutageRule, + applyPathOutage, + deletePathOutage, populate } }) diff --git a/ui/src/types/notificationConfig.ts b/ui/src/types/notificationConfig.ts index 123eeb20c7cd..fa2254d9d5a4 100644 --- a/ui/src/types/notificationConfig.ts +++ b/ui/src/types/notificationConfig.ts @@ -46,3 +46,23 @@ export interface DestinationPath { escalate?: DestinationPathEscalate[] } +// Path outages (critical paths) — DB-backed (pathoutage table), unlike the +// XML-backed config above. Wire shapes of /rest/notification-config/path-outages. +export interface PathOutage { + nodeId: number + nodeLabel?: string | null + criticalPathIp?: string | null + criticalPathServiceName?: string | null +} + +export interface PathOutagePreview { + totalCount: number + nodes: PathOutage[] +} + +export interface PathOutageRequest { + rule: string + criticalIp?: string + criticalSvc?: string +} + diff --git a/ui/tests/stores/notificationConfigStore.test.ts b/ui/tests/stores/notificationConfigStore.test.ts index e1d2e12d21fc..b636bda5b7f0 100644 --- a/ui/tests/stores/notificationConfigStore.test.ts +++ b/ui/tests/stores/notificationConfigStore.test.ts @@ -2,13 +2,17 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useNotificationConfigStore } from '@/stores/notificationConfigStore' import API from '@/services' -import { DestinationPath } from '@/types/notificationConfig' +import { DestinationPath, PathOutage } from '@/types/notificationConfig' vi.mock('@/services', () => ({ default: { getNotificationConfigStatus: vi.fn(), setNotificationConfigStatus: vi.fn(), - getDestinationPaths: vi.fn() + getDestinationPaths: vi.fn(), + getPathOutages: vi.fn(), + previewPathOutageRule: vi.fn(), + applyPathOutage: vi.fn(), + deletePathOutage: vi.fn() } })) @@ -21,6 +25,10 @@ describe('useNotificationConfigStore', () => { target: [{ name: 'Admin', command: ['javaEmail'] }] } + const mockPathOutages: PathOutage[] = [ + { nodeId: 1, nodeLabel: 'localhost', criticalPathIp: '192.168.1.1', criticalPathServiceName: 'ICMP' } + ] + beforeEach(() => { setActivePinia(createPinia()) store = useNotificationConfigStore() @@ -35,6 +43,7 @@ describe('useNotificationConfigStore', () => { it('should start empty with unknown notifd status', () => { expect(store.notifdStatus).toBeNull() expect(store.destinationPaths).toEqual([]) + expect(store.pathOutages).toEqual([]) }) }) @@ -67,15 +76,55 @@ describe('useNotificationConfigStore', () => { }) }) + describe('path outages', () => { + it('should load path outages', async () => { + vi.mocked(API.getPathOutages).mockResolvedValue(mockPathOutages) + + await store.getPathOutages() + + expect(store.pathOutages).toEqual(mockPathOutages) + }) + + it('should refresh after apply and delete', async () => { + vi.mocked(API.applyPathOutage).mockResolvedValue(true) + vi.mocked(API.deletePathOutage).mockResolvedValue(true) + vi.mocked(API.getPathOutages).mockResolvedValue(mockPathOutages) + + await store.applyPathOutage({ rule: 'IPADDR IPLIKE *.*.*.*', criticalIp: '192.168.1.1' }) + await store.deletePathOutage(1) + + expect(API.getPathOutages).toHaveBeenCalledTimes(2) + }) + + it('should not refresh after a failed apply', async () => { + vi.mocked(API.applyPathOutage).mockResolvedValue(false) + + await store.applyPathOutage({ rule: 'bogus rule' }) + + expect(API.getPathOutages).not.toHaveBeenCalled() + }) + + it('should pass the preview through', async () => { + const preview = { totalCount: 3, nodes: mockPathOutages } + vi.mocked(API.previewPathOutageRule).mockResolvedValue(preview) + + const result = await store.previewPathOutageRule('IPADDR IPLIKE *.*.*.*') + + expect(result).toEqual(preview) + }) + }) + describe('populate', () => { it('should load everything the dialog needs', async () => { vi.mocked(API.getNotificationConfigStatus).mockResolvedValue('off') vi.mocked(API.getDestinationPaths).mockResolvedValue([mockPath]) + vi.mocked(API.getPathOutages).mockResolvedValue(mockPathOutages) await store.populate() expect(store.notifdStatus).toBe('off') expect(store.destinationPaths).toEqual([mockPath]) + expect(store.pathOutages).toEqual(mockPathOutages) }) }) })