From 0c5671063195579a8983e3e5e543a2498235abb3 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Tue, 2 Jun 2026 17:00:42 -0400 Subject: [PATCH 01/27] NMS-19851: Configurable system-wide dashboard (milestones 1-2 + persistence backend) Adds a configurable, PrimeVue-based Vue 3 dashboard under /ui (route /dashboard) to begin replacing the legacy JSP homepage, plus the REST endpoint to persist its layout. Frontend (ui/): - Panel framework: registry, Pinia store, default layout, PanelFrame chrome (PrimeVue Panel), and a sample panel. Panels receive resolved filter / timeframe / refresh contracts. - DashboardGrid uses grid-layout-plus for drag + resize (edit mode only); geometry flows grid -> store, add/remove/collapse flow store -> grid. - Toolbar: global timeframe, refresh interval + pause (NMS-4404), add panel, save/edit; per-panel collapse/rename/remove. - Global filter control (NMS-10507): surveillance categories + IP match. - dashboardService talks to /api/v2/dashboard/system (404 -> built-in default). Backend (opennms-webapp-rest): - DashboardRestApi / DashboardRestService: GET/PUT /api/v2/dashboard/system, persisting one system-wide JSON layout document via JsonStore. Addresses NMS-11946 (collapse/rearrange/hide) and lays groundwork for NMS-4433 (new panel types). Future: per-user / named dashboards reuse the same layout document by varying its scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../web/rest/v2/DashboardRestService.java | 86 +++++++++ .../web/rest/v2/api/DashboardRestApi.java | 67 +++++++ ui/package.json | 3 +- ui/pnpm-lock.yaml | 88 +++++++-- .../Dashboard/DashboardFilterControl.vue | 146 +++++++++++++++ ui/src/components/Dashboard/DashboardGrid.vue | 149 +++++++++++++++ .../components/Dashboard/DashboardToolbar.vue | 153 +++++++++++++++ ui/src/components/Dashboard/PanelFrame.vue | 151 +++++++++++++++ ui/src/components/Dashboard/defaultLayout.ts | 50 +++++ .../Dashboard/panels/SamplePanel.vue | 80 ++++++++ ui/src/components/Dashboard/registry.ts | 65 +++++++ ui/src/components/Dashboard/timeframe.ts | 44 +++++ ui/src/composables/useDashboardRefresh.ts | 52 ++++++ ui/src/containers/Dashboard.vue | 54 ++++++ ui/src/main/router/index.ts | 6 + ui/src/services/dashboardService.ts | 55 ++++++ ui/src/stores/dashboardStore.ts | 175 ++++++++++++++++++ ui/src/types/dashboard.ts | 97 ++++++++++ 18 files changed, 1501 insertions(+), 20 deletions(-) create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java create mode 100644 opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/DashboardRestApi.java create mode 100644 ui/src/components/Dashboard/DashboardFilterControl.vue create mode 100644 ui/src/components/Dashboard/DashboardGrid.vue create mode 100644 ui/src/components/Dashboard/DashboardToolbar.vue create mode 100644 ui/src/components/Dashboard/PanelFrame.vue create mode 100644 ui/src/components/Dashboard/defaultLayout.ts create mode 100644 ui/src/components/Dashboard/panels/SamplePanel.vue create mode 100644 ui/src/components/Dashboard/registry.ts create mode 100644 ui/src/components/Dashboard/timeframe.ts create mode 100644 ui/src/composables/useDashboardRefresh.ts create mode 100644 ui/src/containers/Dashboard.vue create mode 100644 ui/src/services/dashboardService.ts create mode 100644 ui/src/stores/dashboardStore.ts create mode 100644 ui/src/types/dashboard.ts diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java new file mode 100644 index 000000000000..45fc2875ac9f --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java @@ -0,0 +1,86 @@ +/* + * 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.util.Map; +import java.util.Optional; + +import javax.ws.rs.core.Response; +import javax.ws.rs.core.Response.Status; + +import org.codehaus.jackson.map.ObjectMapper; +import org.opennms.features.distributed.kvstore.api.JsonStore; +import org.opennms.web.rest.v2.api.DashboardRestApi; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +/** + * Implementation of {@link DashboardRestApi}. Persists the single system-wide + * dashboard layout JSON document in the {@link JsonStore} (the same key-value + * store used by e.g. ResourceRestService), keyed by a fixed context/key. + */ +@Service +public class DashboardRestService implements DashboardRestApi { + private static final Logger LOG = LoggerFactory.getLogger(DashboardRestService.class); + + // One document for the whole system. Future per-user / named dashboards would + // vary the key while keeping the same store. + private static final String CONTEXT = "dashboard"; + private static final String KEY = "system"; + + @Autowired + private JsonStore jsonStore; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public Response getSystemLayout() { + final Optional stored = jsonStore.get(KEY, CONTEXT); + if (stored.isEmpty()) { + return Response.status(Status.NOT_FOUND).build(); + } + try { + final Map layout = objectMapper.readValue(stored.get(), Map.class); + return Response.ok(layout).build(); + } catch (final Exception e) { + LOG.error("Failed to parse stored system dashboard layout", e); + return Response.serverError().build(); + } + } + + @Override + public Response updateSystemLayout(final Map layout) { + if (layout == null) { + return Response.status(Status.BAD_REQUEST).build(); + } + try { + final String json = objectMapper.writeValueAsString(layout); + jsonStore.put(KEY, json, CONTEXT); + return Response.noContent().build(); + } catch (final Exception e) { + LOG.error("Failed to store system dashboard layout", e); + return Response.serverError().build(); + } + } +} diff --git a/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/DashboardRestApi.java b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/DashboardRestApi.java new file mode 100644 index 000000000000..6887490841ef --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/DashboardRestApi.java @@ -0,0 +1,67 @@ +/* + * 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.api; + +import java.util.Map; + +import javax.ws.rs.Consumes; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +/** + * REST API for the configurable system-wide dashboard layout (NMS-19851). + * + * A single JSON layout document is stored for the whole system. The document is + * opaque to the backend (the UI owns its shape); it is persisted verbatim. + */ +@Path("dashboard") +@Tag(name = "Dashboard", description = "System-wide dashboard layout API V2") +public interface DashboardRestApi { + + @GET + @Path("system") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Get the system-wide dashboard layout.", + description = "Returns the stored system-wide dashboard layout document, or 404 if none has been saved yet.", + operationId = "getSystemDashboardLayout" + ) + Response getSystemLayout(); + + @PUT + @Path("system") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "Save the system-wide dashboard layout.", + description = "Replaces the stored system-wide dashboard layout document.", + operationId = "updateSystemDashboardLayout" + ) + Response updateSystemLayout(Map layout); +} diff --git a/ui/package.json b/ui/package.json index c7eb3ea5fa35..cd23198de4a7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -65,11 +65,11 @@ "@featherds/toggle-button": "^0.12.43", "@featherds/tooltip": "0.12.43", "@featherds/utils": "0.12.43", - "@primevue/themes": "^4.5.4", "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/vue-fontawesome": "^3.0.6", + "@primevue/themes": "^4.5.4", "@vueuse/core": "^9.13.0", "ace-builds": "^1.32.6", "axios": "^1.15.0", @@ -81,6 +81,7 @@ "date-fns-tz": "^3.2.0", "dotenv": "^17.2.3", "fast-xml-parser": "^5.5.11", + "grid-layout-plus": "^1.1.1", "ip-regex": "^5.0.0", "is-ip": "^5.0.1", "is-valid-domain": "^0.1.6", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index f9f3a75dc6ee..a8f9cbd3ab7b 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -197,6 +197,9 @@ importers: fast-xml-parser: specifier: ^5.5.11 version: 5.6.0 + grid-layout-plus: + specifier: ^1.1.1 + version: 1.1.1(vue@3.5.33(typescript@5.4.5)) ip-regex: specifier: ^5.0.0 version: 5.0.0 @@ -717,6 +720,15 @@ packages: '@featherds/utils@0.12.44': resolution: {integrity: sha512-ic6JDH7mL5fYkqDD7Vu/caE+CbshlBDyt5clGG/0GxtYFU462RRuiEAphuyDlfA8IggHMIyQK+XUAqSJX1kkYA==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fortawesome/fontawesome-common-types@6.7.2': resolution: {integrity: sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==} engines: {node: '>=6'} @@ -755,6 +767,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@interactjs/types@1.10.27': + resolution: {integrity: sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -762,6 +777,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@juggle/resize-observer@3.4.0': + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + '@lit-labs/ssr-dom-shim@1.5.1': resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} @@ -815,42 +833,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -955,79 +967,66 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -1400,6 +1399,14 @@ packages: resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} engines: {node: ^16.0.0 || >=18.0.0} + '@vexip-ui/hooks@2.9.4': + resolution: {integrity: sha512-dGUiBAeHIsnSVigGSPHcuHBVqrSGW8LV+zGohvOpBfXs8Ynn5ZcSmybIWJ3G826NsicPu9rqwcJG8uvSgG4k4Q==} + peerDependencies: + vue: ^3.2.25 + + '@vexip-ui/utils@2.16.4': + resolution: {integrity: sha512-KX+Q4EsuwDp6ZlRJ7OAkiYxu52D5CVM8zpqQz/FXYV+JUtzl9T3dvxgtA8gQ0wm5Sh/xT6jp8Wo4X7tLAzRh/A==} + '@vitejs/plugin-vue@5.2.4': resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2239,6 +2246,11 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + grid-layout-plus@1.1.1: + resolution: {integrity: sha512-7CWehJubrVC8Ps5QFUlnDsp0kiREvKfi3Pdjp21EyY8BNzSusqI3Utcxvu1Y9UUKe3YExvbhJzIxHK6rorbRaQ==} + peerDependencies: + vue: ^3.0.0 + hammerjs@2.0.8: resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==} engines: {node: '>=0.8.0'} @@ -2295,6 +2307,9 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + interactjs@1.10.27: + resolution: {integrity: sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -3765,6 +3780,17 @@ snapshots: '@featherds/utils@0.12.44': {} + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + '@fortawesome/fontawesome-common-types@6.7.2': {} '@fortawesome/fontawesome-svg-core@6.7.2': @@ -3795,6 +3821,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@interactjs/types@1.10.27': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3806,6 +3834,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@juggle/resize-observer@3.4.0': {} + '@lit-labs/ssr-dom-shim@1.5.1': {} '@lit/reactive-element@2.1.2': @@ -4715,6 +4745,15 @@ snapshots: '@typescript-eslint/types': 6.21.0 eslint-visitor-keys: 3.4.3 + '@vexip-ui/hooks@2.9.4(vue@3.5.33(typescript@5.4.5))': + dependencies: + '@floating-ui/dom': 1.7.6 + '@juggle/resize-observer': 3.4.0 + '@vexip-ui/utils': 2.16.4 + vue: 3.5.33(typescript@5.4.5) + + '@vexip-ui/utils@2.16.4': {} + '@vitejs/plugin-vue@5.2.4(vite@6.4.2(@types/node@25.6.0)(sass@1.99.0))(vue@3.5.33(typescript@5.4.5))': dependencies: vite: 6.4.2(@types/node@25.6.0)(sass@1.99.0) @@ -5669,6 +5708,13 @@ snapshots: graphemer@1.4.0: {} + grid-layout-plus@1.1.1(vue@3.5.33(typescript@5.4.5)): + dependencies: + '@vexip-ui/hooks': 2.9.4(vue@3.5.33(typescript@5.4.5)) + '@vexip-ui/utils': 2.16.4 + interactjs: 1.10.27 + vue: 3.5.33(typescript@5.4.5) + hammerjs@2.0.8: {} happy-dom@9.20.3: @@ -5715,6 +5761,10 @@ snapshots: ini@1.3.8: {} + interactjs@1.10.27: + dependencies: + '@interactjs/types': 1.10.27 + internmap@2.0.3: {} ip-regex@5.0.0: {} diff --git a/ui/src/components/Dashboard/DashboardFilterControl.vue b/ui/src/components/Dashboard/DashboardFilterControl.vue new file mode 100644 index 000000000000..2864382955d4 --- /dev/null +++ b/ui/src/components/Dashboard/DashboardFilterControl.vue @@ -0,0 +1,146 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/DashboardGrid.vue b/ui/src/components/Dashboard/DashboardGrid.vue new file mode 100644 index 000000000000..5e063ed615bf --- /dev/null +++ b/ui/src/components/Dashboard/DashboardGrid.vue @@ -0,0 +1,149 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/DashboardToolbar.vue b/ui/src/components/Dashboard/DashboardToolbar.vue new file mode 100644 index 000000000000..dd4763fc9f53 --- /dev/null +++ b/ui/src/components/Dashboard/DashboardToolbar.vue @@ -0,0 +1,153 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/PanelFrame.vue b/ui/src/components/Dashboard/PanelFrame.vue new file mode 100644 index 000000000000..350849a0e5ca --- /dev/null +++ b/ui/src/components/Dashboard/PanelFrame.vue @@ -0,0 +1,151 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/defaultLayout.ts b/ui/src/components/Dashboard/defaultLayout.ts new file mode 100644 index 000000000000..45dc99640e8c --- /dev/null +++ b/ui/src/components/Dashboard/defaultLayout.ts @@ -0,0 +1,50 @@ +/// +/// 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 { type DashboardLayout, TimeframePreset } from '@/types/dashboard' + +// Built-in default used until the backend (NMS-19851) persists a layout, and +// as the "reset" target. A factory (not a constant) so each load gets a fresh, +// independently-mutable object. +export const createDefaultLayout = (): DashboardLayout => ({ + scope: 'SYSTEM', + version: 1, + refresh: { seconds: 120, paused: false }, + globalFilter: { surveillanceCategories: [], ipMatch: null }, + globalTimeframe: { preset: TimeframePreset.Last24h, from: null, to: null }, + panels: [ + { + id: 'sample-1', + type: 'sample', + x: 0, + y: 0, + w: 4, + h: 4, + collapsed: false, + titleOverride: null, + filterOverride: null, + timeframeOverride: null, + refreshSeconds: null, + options: {} + } + ] +}) diff --git a/ui/src/components/Dashboard/panels/SamplePanel.vue b/ui/src/components/Dashboard/panels/SamplePanel.vue new file mode 100644 index 000000000000..b4960d5539ec --- /dev/null +++ b/ui/src/components/Dashboard/panels/SamplePanel.vue @@ -0,0 +1,80 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/registry.ts b/ui/src/components/Dashboard/registry.ts new file mode 100644 index 000000000000..e96cbec201d5 --- /dev/null +++ b/ui/src/components/Dashboard/registry.ts @@ -0,0 +1,65 @@ +/// +/// 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 { type Component, defineAsyncComponent } from 'vue' +import type { FilterKey } from '@/types/dashboard' + +export interface PanelGridSize { + w: number + h: number +} + +// A panel is self-contained: registering one entry here is all that's needed to +// make it available in the "Add panel" picker. The registry is the single place +// that grows as panels are added (NMS-4433 etc). +export interface PanelDefinition { + type: string + title: string + category: 'status' | 'inventory' | 'info' + component: Component + defaultSize: PanelGridSize + minSize?: PanelGridSize + supportsTimeframe?: boolean // show timeframe control for this panel (user-requested) + supportedFilters?: FilterKey[] // filters this panel honors (NMS-10507) + renamable?: boolean // default true + collapsible?: boolean // default true (NMS-11946) + roles?: string[] // future: restrict visibility via useRole() +} + +export const panelRegistry: Record = { + sample: { + type: 'sample', + title: 'Sample Panel', + category: 'info', + component: defineAsyncComponent(() => import('./panels/SamplePanel.vue')), + defaultSize: { w: 4, h: 4 }, + minSize: { w: 2, h: 2 }, + supportsTimeframe: true, + supportedFilters: ['surveillanceCategories', 'ipMatch'], + renamable: true, + collapsible: true + } +} + +export const getPanelDefinition = (type: string): PanelDefinition | undefined => panelRegistry[type] + +export const listPanelDefinitions = (): PanelDefinition[] => Object.values(panelRegistry) diff --git a/ui/src/components/Dashboard/timeframe.ts b/ui/src/components/Dashboard/timeframe.ts new file mode 100644 index 000000000000..93bcf9cef260 --- /dev/null +++ b/ui/src/components/Dashboard/timeframe.ts @@ -0,0 +1,44 @@ +/// +/// 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 { TimeframePreset } from '@/types/dashboard' + +export const timeframeOptions: { label: string; value: TimeframePreset }[] = [ + { label: 'Last 24 hours', value: TimeframePreset.Last24h }, + { label: 'Today', value: TimeframePreset.Today }, + { label: 'Yesterday', value: TimeframePreset.Yesterday }, + { label: 'This week', value: TimeframePreset.ThisWeek }, + { label: 'Last week', value: TimeframePreset.LastWeek }, + { label: 'Last 7 days', value: TimeframePreset.Last7Days }, + { label: 'Last 30 days', value: TimeframePreset.Last30Days }, + { label: 'This month', value: TimeframePreset.ThisMonth }, + { label: 'Last month', value: TimeframePreset.LastMonth }, + { label: 'Custom range', value: TimeframePreset.Custom } +] + +export const refreshOptions: { label: string; value: number }[] = [ + { label: 'Off', value: 0 }, + { label: 'Every 30 seconds', value: 30 }, + { label: 'Every minute', value: 60 }, + { label: 'Every 2 minutes', value: 120 }, + { label: 'Every 5 minutes', value: 300 } +] diff --git a/ui/src/composables/useDashboardRefresh.ts b/ui/src/composables/useDashboardRefresh.ts new file mode 100644 index 000000000000..b6acfafe6ca3 --- /dev/null +++ b/ui/src/composables/useDashboardRefresh.ts @@ -0,0 +1,52 @@ +/// +/// 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 { onUnmounted, watch } from 'vue' +import { storeToRefs } from 'pinia' +import { useDashboardStore } from '@/stores/dashboardStore' + +// Owns the single global refresh timer (NMS-4404). Panels react to +// store.refreshTick rather than each holding their own timer. Auto-refresh is +// suspended while paused, while in edit mode, or when the interval is "off". +export const useDashboardRefresh = () => { + const store = useDashboardStore() + const { refreshSeconds, isPaused, editMode } = storeToRefs(store) + let timer: ReturnType | null = null + + const clear = () => { + if (timer !== null) { + clearInterval(timer) + timer = null + } + } + + const restart = () => { + clear() + if (isPaused.value || editMode.value || refreshSeconds.value <= 0) { + return + } + timer = setInterval(() => store.tickRefresh(), refreshSeconds.value * 1000) + } + + watch([refreshSeconds, isPaused, editMode], restart, { immediate: true }) + onUnmounted(clear) +} diff --git a/ui/src/containers/Dashboard.vue b/ui/src/containers/Dashboard.vue new file mode 100644 index 000000000000..558031210af1 --- /dev/null +++ b/ui/src/containers/Dashboard.vue @@ -0,0 +1,54 @@ + + + + + + + + diff --git a/ui/src/main/router/index.ts b/ui/src/main/router/index.ts index 6284c44bc758..e38654eacad1 100644 --- a/ui/src/main/router/index.ts +++ b/ui/src/main/router/index.ts @@ -91,6 +91,12 @@ const router = createRouter({ name: 'home', component: Home }, + { + // configurable system-wide dashboard (NMS-19851); home route repoints here at cutover + path: '/dashboard', + name: 'Dashboard', + component: () => import('@/containers/Dashboard.vue') + }, { // for compatibility with legacy plugins // should be removed when all plugins have unique 'extensionId' and follow new pattern diff --git a/ui/src/services/dashboardService.ts b/ui/src/services/dashboardService.ts new file mode 100644 index 000000000000..9be3ecf33258 --- /dev/null +++ b/ui/src/services/dashboardService.ts @@ -0,0 +1,55 @@ +/// +/// 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 axios from 'axios' +import { v2 } from './axiosInstances' +import type { DashboardLayout } from '@/types/dashboard' +import { createDefaultLayout } from '@/components/Dashboard/defaultLayout' + +// Single system-wide dashboard document, served by DashboardRestService at +// /api/v2/dashboard/system. GET 404s until a layout has been saved, in which +// case we fall back to the built-in default. +const endpoint = '/dashboard/system' + +export const getSystemDashboard = async (): Promise => { + try { + const response = await v2.get(endpoint) + + if (response.status === 200 && Array.isArray(response.data?.panels)) { + return response.data + } + + return createDefaultLayout() + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + // no layout saved yet — use the built-in default + return createDefaultLayout() + } + + console.error('Failed to load system dashboard layout:', error) + return createDefaultLayout() + } +} + +export const saveSystemDashboard = async (layout: DashboardLayout): Promise => { + await v2.put(endpoint, layout) +} diff --git a/ui/src/stores/dashboardStore.ts b/ui/src/stores/dashboardStore.ts new file mode 100644 index 000000000000..489facf153ef --- /dev/null +++ b/ui/src/stores/dashboardStore.ts @@ -0,0 +1,175 @@ +/// +/// 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 { defineStore } from 'pinia' +import { v4 as uuidv4 } from 'uuid' +import type { DashboardFilter, DashboardLayout, DashboardPanel, Timeframe } from '@/types/dashboard' +import { createDefaultLayout } from '@/components/Dashboard/defaultLayout' +import { getPanelDefinition } from '@/components/Dashboard/registry' +import { getSystemDashboard, saveSystemDashboard } from '@/services/dashboardService' + +interface DashboardStoreState { + layout: DashboardLayout + isLoading: boolean + isSaving: boolean + isDirty: boolean + editMode: boolean + refreshTick: number +} + +export const useDashboardStore = defineStore('dashboardStore', { + state: (): DashboardStoreState => ({ + layout: createDefaultLayout(), + isLoading: false, + isSaving: false, + isDirty: false, + editMode: false, + refreshTick: 0 + }), + getters: { + panels: (state): DashboardPanel[] => state.layout.panels, + isPaused: (state): boolean => state.layout.refresh.paused, + refreshSeconds: (state): number => state.layout.refresh.seconds, + // Effective filter / timeframe / cadence for a panel: override wins, else global. + resolvedFilter: + (state) => + (panel: DashboardPanel): DashboardFilter => + panel.filterOverride ?? state.layout.globalFilter, + resolvedTimeframe: + (state) => + (panel: DashboardPanel): Timeframe => + panel.timeframeOverride ?? state.layout.globalTimeframe, + resolvedRefreshSeconds: + (state) => + (panel: DashboardPanel): number => + panel.refreshSeconds ?? state.layout.refresh.seconds + }, + actions: { + async load() { + this.isLoading = true + try { + this.layout = await getSystemDashboard() + this.isDirty = false + } finally { + this.isLoading = false + } + }, + async save(): Promise { + this.isSaving = true + try { + await saveSystemDashboard(this.layout) + this.isDirty = false + return true + } catch (error) { + // keep isDirty so the user can retry; surfacing UI comes with the snackbar wiring + console.error('Failed to save dashboard layout:', error) + return false + } finally { + this.isSaving = false + } + }, + // Reload from the server (or default), discarding unsaved edits. + async reset() { + await this.load() + }, + setEditMode(on: boolean) { + this.editMode = on + }, + togglePaused() { + this.layout.refresh.paused = !this.layout.refresh.paused + this.isDirty = true + }, + setRefreshSeconds(seconds: number) { + this.layout.refresh.seconds = seconds + this.isDirty = true + }, + setGlobalTimeframe(timeframe: Timeframe) { + this.layout.globalTimeframe = timeframe + this.isDirty = true + }, + setGlobalFilter(filter: DashboardFilter) { + this.layout.globalFilter = filter + this.isDirty = true + }, + // Persist grid geometry coming back from the layout engine. Collapsed panels + // report a header-only height, so we keep their stored (expanded) height. + syncGeometry(items: { i: string; x: number; y: number; w: number; h: number }[]) { + let changed = false + for (const item of items) { + const panel = this.layout.panels.find((p) => p.id === item.i) + if (!panel) continue + const nextH = panel.collapsed ? panel.h : item.h + if (panel.x !== item.x || panel.y !== item.y || panel.w !== item.w || panel.h !== nextH) { + panel.x = item.x + panel.y = item.y + panel.w = item.w + panel.h = nextH + changed = true + } + } + if (changed) this.isDirty = true + }, + addPanel(type: string) { + const def = getPanelDefinition(type) + if (!def) {return} + // place the new panel below everything currently laid out + const nextY = this.layout.panels.reduce((max, p) => Math.max(max, p.y + p.h), 0) + this.layout.panels.push({ + id: uuidv4(), + type, + x: 0, + y: nextY, + w: def.defaultSize.w, + h: def.defaultSize.h, + collapsed: false, + titleOverride: null, + filterOverride: null, + timeframeOverride: null, + refreshSeconds: null, + options: {} + }) + this.isDirty = true + }, + removePanel(id: string) { + this.layout.panels = this.layout.panels.filter((p) => p.id !== id) + this.isDirty = true + }, + setPanelCollapsed(id: string, collapsed: boolean) { + const panel = this.layout.panels.find((p) => p.id === id) + if (panel) { + panel.collapsed = collapsed + this.isDirty = true + } + }, + setPanelTitle(id: string, title: string | null) { + const panel = this.layout.panels.find((p) => p.id === id) + if (panel) { + const trimmed = title?.trim() + panel.titleOverride = trimmed ? trimmed : null + this.isDirty = true + } + }, + tickRefresh() { + this.refreshTick++ + } + } +}) diff --git a/ui/src/types/dashboard.ts b/ui/src/types/dashboard.ts new file mode 100644 index 000000000000..f7aee29ef94a --- /dev/null +++ b/ui/src/types/dashboard.ts @@ -0,0 +1,97 @@ +/// +/// 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. +/// + +// Type model for the configurable system-wide dashboard (NMS-19851). +// The persisted layout document is intentionally self-describing so that +// future per-user / multiple-named dashboards are additive (change `scope`), +// not a rewrite. + +export enum TimeframePreset { + Last24h = 'LAST_24H', + Today = 'TODAY', + Yesterday = 'YESTERDAY', + ThisWeek = 'THIS_WEEK', + LastWeek = 'LAST_WEEK', + Last7Days = 'LAST_7D', + Last30Days = 'LAST_30D', + ThisMonth = 'THIS_MONTH', + LastMonth = 'LAST_MONTH', + Custom = 'CUSTOM' +} + +export interface Timeframe { + preset: TimeframePreset + // ISO-8601 strings, only used when preset === Custom + from: string | null + to: string | null +} + +export type FilterKey = 'surveillanceCategories' | 'ipMatch' + +// Shared filter contract honored by every panel (NMS-10507). +export interface DashboardFilter { + surveillanceCategories: string[] + ipMatch: string | null +} + +// Global auto-refresh with pause (NMS-4404). seconds <= 0 means "off". +export interface DashboardRefresh { + seconds: number + paused: boolean +} + +export interface DashboardPanel { + id: string + type: string // matches a PanelDefinition.type in the registry + // grid geometry (12-column grid) + x: number + y: number + w: number + h: number + collapsed: boolean // NMS-11946 + titleOverride: string | null // user rename; null => registry default title + filterOverride: DashboardFilter | null // null => use globalFilter + timeframeOverride: Timeframe | null // null => use globalTimeframe + refreshSeconds: number | null // null => use refresh.seconds + options: Record // panel-specific content options +} + +// Future: 'USER' and named dashboards. Only 'SYSTEM' is used today. +export type DashboardScope = 'SYSTEM' + +export interface DashboardLayout { + scope: DashboardScope + version: number + refresh: DashboardRefresh + globalFilter: DashboardFilter + globalTimeframe: Timeframe + panels: DashboardPanel[] +} + +// Props every panel component receives from the PanelFrame. +export interface PanelComponentProps { + options: Record + filter: DashboardFilter + timeframe: Timeframe + // increments on each global refresh tick; panels watch this to refetch + refreshTick: number +} From 27c10815a01f84ee4bda69c7ec6e8252ad477daf Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Tue, 2 Jun 2026 18:15:04 -0400 Subject: [PATCH 02/27] NMS-19851: Add first real dashboard panels (notifications, status overview, nodes with alarms) Replaces the placeholder default layout with three panels matching the legacy homepage boxes: - Notifications: outstanding-notice counts from /rest/notifications/summary. - Status Overview: Chart.js doughnut of alarm counts by severity, read from /api/v2/alarms totalCount per severity (FIQL alarm.severity==). - Nodes with Pending Alarms: alarms grouped by node with count + max severity. Adds a shared severity util (ordering, labels, OpenNMS-standard colors) and a notificationService. Panels refetch on the global refresh tick. Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/src/components/Dashboard/defaultLayout.ts | 34 +++- .../Dashboard/panels/NodesWithAlarmsPanel.vue | 159 +++++++++++++++++ .../Dashboard/panels/NotificationsPanel.vue | 98 +++++++++++ .../Dashboard/panels/StatusOverviewPanel.vue | 163 ++++++++++++++++++ ui/src/components/Dashboard/registry.ts | 30 ++++ ui/src/components/Dashboard/severity.ts | 59 +++++++ ui/src/services/notificationService.ts | 51 ++++++ 7 files changed, 591 insertions(+), 3 deletions(-) create mode 100644 ui/src/components/Dashboard/panels/NodesWithAlarmsPanel.vue create mode 100644 ui/src/components/Dashboard/panels/NotificationsPanel.vue create mode 100644 ui/src/components/Dashboard/panels/StatusOverviewPanel.vue create mode 100644 ui/src/components/Dashboard/severity.ts create mode 100644 ui/src/services/notificationService.ts diff --git a/ui/src/components/Dashboard/defaultLayout.ts b/ui/src/components/Dashboard/defaultLayout.ts index 45dc99640e8c..92180e9b7f02 100644 --- a/ui/src/components/Dashboard/defaultLayout.ts +++ b/ui/src/components/Dashboard/defaultLayout.ts @@ -33,12 +33,40 @@ export const createDefaultLayout = (): DashboardLayout => ({ globalTimeframe: { preset: TimeframePreset.Last24h, from: null, to: null }, panels: [ { - id: 'sample-1', - type: 'sample', + id: 'nodes-1', + type: 'nodes-with-alarms', x: 0, y: 0, w: 4, - h: 4, + h: 6, + collapsed: false, + titleOverride: null, + filterOverride: null, + timeframeOverride: null, + refreshSeconds: null, + options: {} + }, + { + id: 'status-1', + type: 'status-overview', + x: 4, + y: 0, + w: 4, + h: 6, + collapsed: false, + titleOverride: null, + filterOverride: null, + timeframeOverride: null, + refreshSeconds: null, + options: {} + }, + { + id: 'notif-1', + type: 'notifications', + x: 8, + y: 0, + w: 4, + h: 3, collapsed: false, titleOverride: null, filterOverride: null, diff --git a/ui/src/components/Dashboard/panels/NodesWithAlarmsPanel.vue b/ui/src/components/Dashboard/panels/NodesWithAlarmsPanel.vue new file mode 100644 index 000000000000..34c44c0c0b9b --- /dev/null +++ b/ui/src/components/Dashboard/panels/NodesWithAlarmsPanel.vue @@ -0,0 +1,159 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/panels/NotificationsPanel.vue b/ui/src/components/Dashboard/panels/NotificationsPanel.vue new file mode 100644 index 000000000000..4ef0e8f8969c --- /dev/null +++ b/ui/src/components/Dashboard/panels/NotificationsPanel.vue @@ -0,0 +1,98 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/panels/StatusOverviewPanel.vue b/ui/src/components/Dashboard/panels/StatusOverviewPanel.vue new file mode 100644 index 000000000000..aa442611df71 --- /dev/null +++ b/ui/src/components/Dashboard/panels/StatusOverviewPanel.vue @@ -0,0 +1,163 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/registry.ts b/ui/src/components/Dashboard/registry.ts index e96cbec201d5..ee944eaf2908 100644 --- a/ui/src/components/Dashboard/registry.ts +++ b/ui/src/components/Dashboard/registry.ts @@ -46,6 +46,36 @@ export interface PanelDefinition { } export const panelRegistry: Record = { + 'nodes-with-alarms': { + type: 'nodes-with-alarms', + title: 'Nodes with Pending Alarms', + category: 'status', + component: defineAsyncComponent(() => import('./panels/NodesWithAlarmsPanel.vue')), + defaultSize: { w: 4, h: 5 }, + minSize: { w: 3, h: 3 }, + renamable: true, + collapsible: true + }, + 'status-overview': { + type: 'status-overview', + title: 'Status Overview', + category: 'status', + component: defineAsyncComponent(() => import('./panels/StatusOverviewPanel.vue')), + defaultSize: { w: 4, h: 6 }, + minSize: { w: 3, h: 4 }, + renamable: true, + collapsible: true + }, + notifications: { + type: 'notifications', + title: 'Notifications', + category: 'info', + component: defineAsyncComponent(() => import('./panels/NotificationsPanel.vue')), + defaultSize: { w: 4, h: 3 }, + minSize: { w: 2, h: 2 }, + renamable: true, + collapsible: true + }, sample: { type: 'sample', title: 'Sample Panel', diff --git a/ui/src/components/Dashboard/severity.ts b/ui/src/components/Dashboard/severity.ts new file mode 100644 index 000000000000..149508339778 --- /dev/null +++ b/ui/src/components/Dashboard/severity.ts @@ -0,0 +1,59 @@ +/// +/// 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. +/// + +// OnmsSeverity ordering + display metadata for dashboard panels. Hex colors +// approximate the OpenNMS standard severity palette (used directly by the +// canvas-based Status Overview chart, which can't read the SCSS .severity-* +// classes). Keep highest-severity-first. + +export interface SeverityMeta { + key: string + label: string + color: string + weight: number +} + +export const SEVERITIES: SeverityMeta[] = [ + { key: 'CRITICAL', label: 'Critical', color: '#a81d1d', weight: 7 }, + { key: 'MAJOR', label: 'Major', color: '#d4663a', weight: 6 }, + { key: 'MINOR', label: 'Minor', color: '#e89b3b', weight: 5 }, + { key: 'WARNING', label: 'Warning', color: '#f0c419', weight: 4 }, + { key: 'NORMAL', label: 'Normal', color: '#3a7d2c', weight: 3 }, + { key: 'INDETERMINATE', label: 'Indeterminate', color: '#808080', weight: 2 }, + { key: 'CLEARED', label: 'Cleared', color: '#9e9e9e', weight: 1 } +] + +// Severities shown in the alarm Status Overview chart (the actionable set). +export const ALARM_CHART_SEVERITIES = ['CRITICAL', 'MAJOR', 'MINOR', 'WARNING', 'INDETERMINATE'] + +const FALLBACK: SeverityMeta = { key: 'UNKNOWN', label: 'Unknown', color: '#999999', weight: 0 } + +export const severityMeta = (key: string): SeverityMeta => + SEVERITIES.find((s) => s.key === (key ?? '').toUpperCase()) ?? { ...FALLBACK, key, label: key || 'Unknown' } + +export const severityColor = (key: string): string => severityMeta(key).color + +export const severityLabel = (key: string): string => severityMeta(key).label + +// Highest severity among a set of severity keys (by weight). +export const maxSeverity = (keys: string[]): string => + keys.reduce((max, k) => (severityMeta(k).weight > severityMeta(max).weight ? k : max), 'CLEARED') diff --git a/ui/src/services/notificationService.ts b/ui/src/services/notificationService.ts new file mode 100644 index 000000000000..4becad310c8f --- /dev/null +++ b/ui/src/services/notificationService.ts @@ -0,0 +1,51 @@ +/// +/// 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 { rest } from './axiosInstances' + +// Mirrors NotificationSummary (v1 NotificationRestService /notifications/summary). +export interface NotificationSummary { + user?: string + totalCount: number + totalUnacknowledgedCount: number + userUnacknowledgedCount: number + teamUnacknowledgedCount: number +} + +const empty = (): NotificationSummary => ({ + totalCount: 0, + totalUnacknowledgedCount: 0, + userUnacknowledgedCount: 0, + teamUnacknowledgedCount: 0 +}) + +export const getNotificationSummary = async (): Promise => { + try { + const resp = await rest.get('/notifications/summary') + if (resp.status === 204) { + return empty() + } + return resp.data as NotificationSummary + } catch (err) { + return false + } +} From de07c9741e13571f4d0a983b81524fb7377675e2 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Tue, 2 Jun 2026 19:07:11 -0400 Subject: [PATCH 03/27] NMS-19851: Status Overview outages donut + News Feed panel - Status Overview now shows BOTH legacy doughnuts (alarms + outages), sourced from /api/v2/status/summary/nodes/{alarms,outages} (nodes grouped by unacked alarms / current outages), replacing the prior alarms-only per-severity count. - New News Feed panel from /api/v2/newsfeed (title/link/short description/tags). Adds statusService and newsfeedService; both panels added to the default layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/src/components/Dashboard/defaultLayout.ts | 14 ++ .../Dashboard/panels/NewsFeedPanel.vue | 129 +++++++++++++++ .../Dashboard/panels/StatusOverviewPanel.vue | 150 +++++++++++------- ui/src/components/Dashboard/registry.ts | 10 ++ ui/src/services/newsfeedService.ts | 45 ++++++ ui/src/services/statusService.ts | 51 ++++++ 6 files changed, 339 insertions(+), 60 deletions(-) create mode 100644 ui/src/components/Dashboard/panels/NewsFeedPanel.vue create mode 100644 ui/src/services/newsfeedService.ts create mode 100644 ui/src/services/statusService.ts diff --git a/ui/src/components/Dashboard/defaultLayout.ts b/ui/src/components/Dashboard/defaultLayout.ts index 92180e9b7f02..c4b325954cfe 100644 --- a/ui/src/components/Dashboard/defaultLayout.ts +++ b/ui/src/components/Dashboard/defaultLayout.ts @@ -73,6 +73,20 @@ export const createDefaultLayout = (): DashboardLayout => ({ timeframeOverride: null, refreshSeconds: null, options: {} + }, + { + id: 'news-1', + type: 'newsfeed', + x: 8, + y: 3, + w: 4, + h: 5, + collapsed: false, + titleOverride: null, + filterOverride: null, + timeframeOverride: null, + refreshSeconds: null, + options: {} } ] }) diff --git a/ui/src/components/Dashboard/panels/NewsFeedPanel.vue b/ui/src/components/Dashboard/panels/NewsFeedPanel.vue new file mode 100644 index 000000000000..501780dc5b2c --- /dev/null +++ b/ui/src/components/Dashboard/panels/NewsFeedPanel.vue @@ -0,0 +1,129 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/panels/StatusOverviewPanel.vue b/ui/src/components/Dashboard/panels/StatusOverviewPanel.vue index aa442611df71..73703a1a6b88 100644 --- a/ui/src/components/Dashboard/panels/StatusOverviewPanel.vue +++ b/ui/src/components/Dashboard/panels/StatusOverviewPanel.vue @@ -21,20 +21,26 @@ License. --> @@ -126,10 +149,17 @@ onBeforeUnmount(() => { display: flex; flex-direction: column; - &__chart { - position: relative; + &__donuts { flex: 1 1 auto; min-height: 0; + display: flex; + gap: 0.5rem; + } + + &__donut { + position: relative; + flex: 1 1 0; + min-width: 0; } &__center { @@ -145,13 +175,13 @@ onBeforeUnmount(() => { } &__total { - font-size: 1.75rem; + font-size: 1.5rem; font-weight: 700; line-height: 1; } - &__label { - font-size: 0.75rem; + &__cap { + font-size: 0.7rem; color: var(--feather-secondary-text-on-surface, #666); } diff --git a/ui/src/components/Dashboard/registry.ts b/ui/src/components/Dashboard/registry.ts index ee944eaf2908..2a6b68d1d579 100644 --- a/ui/src/components/Dashboard/registry.ts +++ b/ui/src/components/Dashboard/registry.ts @@ -76,6 +76,16 @@ export const panelRegistry: Record = { renamable: true, collapsible: true }, + newsfeed: { + type: 'newsfeed', + title: 'OpenNMS News Feed', + category: 'info', + component: defineAsyncComponent(() => import('./panels/NewsFeedPanel.vue')), + defaultSize: { w: 4, h: 5 }, + minSize: { w: 3, h: 3 }, + renamable: true, + collapsible: true + }, sample: { type: 'sample', title: 'Sample Panel', diff --git a/ui/src/services/newsfeedService.ts b/ui/src/services/newsfeedService.ts new file mode 100644 index 000000000000..5e25d99e8197 --- /dev/null +++ b/ui/src/services/newsfeedService.ts @@ -0,0 +1,45 @@ +/// +/// 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' + +// Mirrors NewsFeedItem from the v2 /newsfeed endpoint (response: { items: [...] }). +export interface NewsFeedItem { + title: string + link: string + description: string + shortDescription: string + categories: string[] + tags: string[] +} + +export const getNewsFeed = async (): Promise => { + try { + const resp = await v2.get('/newsfeed') + if (resp.status === 204) { + return [] + } + return (resp.data?.items ?? []) as NewsFeedItem[] + } catch (err) { + return [] + } +} diff --git a/ui/src/services/statusService.ts b/ui/src/services/statusService.ts new file mode 100644 index 000000000000..facd35bd0e59 --- /dev/null +++ b/ui/src/services/statusService.ts @@ -0,0 +1,51 @@ +/// +/// 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' + +// The status summary endpoints return c3-style [label, count] column pairs, +// e.g. [["Critical", 2], ["Minor", 1]]. Labels are title-case severity/status +// names (Normal/Warning/Minor/Major/Critical). +export interface StatusSummaryEntry { + label: string + count: number +} + +const fetchSummary = async (path: string): Promise => { + try { + const resp = await v2.get(path) + if (resp.status === 204 || !Array.isArray(resp.data)) { + return [] + } + return (resp.data as unknown[]) + .filter((row): row is [string, number] => Array.isArray(row) && row.length >= 2) + .map((row) => ({ label: String(row[0]), count: Number(row[1]) })) + } catch (err) { + return [] + } +} + +export const getNodesByAlarms = (): Promise => + fetchSummary('/status/summary/nodes/alarms') + +export const getNodesByOutages = (): Promise => + fetchSummary('/status/summary/nodes/outages') From b50ad4f8465a53302aafdbfe5f862ac256be3a83 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Tue, 2 Jun 2026 19:35:03 -0400 Subject: [PATCH 04/27] NMS-19851: Add Pending Situations and Service Outages panels - Pending Situations: alarms filtered isSituation==true (/api/v2/alarms), with severity, affected-node/alarm counts. - Nodes with Service Outages: currently-open outages from /api/v2/outages (FIQL ifRegainedService==epoch), node + service name. Adds outageService and situationService; refactors the default layout into a three-column arrangement (situations/alarms/outages, status overview, notifications/news) via a small panel() helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/src/components/Dashboard/defaultLayout.ts | 79 +++-------- .../Dashboard/panels/OutagesPanel.vue | 122 ++++++++++++++++ .../Dashboard/panels/SituationsPanel.vue | 131 ++++++++++++++++++ ui/src/components/Dashboard/registry.ts | 20 +++ ui/src/services/outageService.ts | 54 ++++++++ ui/src/services/situationService.ts | 47 +++++++ 6 files changed, 396 insertions(+), 57 deletions(-) create mode 100644 ui/src/components/Dashboard/panels/OutagesPanel.vue create mode 100644 ui/src/components/Dashboard/panels/SituationsPanel.vue create mode 100644 ui/src/services/outageService.ts create mode 100644 ui/src/services/situationService.ts diff --git a/ui/src/components/Dashboard/defaultLayout.ts b/ui/src/components/Dashboard/defaultLayout.ts index c4b325954cfe..00b7b8507373 100644 --- a/ui/src/components/Dashboard/defaultLayout.ts +++ b/ui/src/components/Dashboard/defaultLayout.ts @@ -20,7 +20,22 @@ /// License. /// -import { type DashboardLayout, TimeframePreset } from '@/types/dashboard' +import { type DashboardLayout, type DashboardPanel, TimeframePreset } from '@/types/dashboard' + +const panel = (id: string, type: string, x: number, y: number, w: number, h: number): DashboardPanel => ({ + id, + type, + x, + y, + w, + h, + collapsed: false, + titleOverride: null, + filterOverride: null, + timeframeOverride: null, + refreshSeconds: null, + options: {} +}) // Built-in default used until the backend (NMS-19851) persists a layout, and // as the "reset" target. A factory (not a constant) so each load gets a fresh, @@ -32,61 +47,11 @@ export const createDefaultLayout = (): DashboardLayout => ({ globalFilter: { surveillanceCategories: [], ipMatch: null }, globalTimeframe: { preset: TimeframePreset.Last24h, from: null, to: null }, panels: [ - { - id: 'nodes-1', - type: 'nodes-with-alarms', - x: 0, - y: 0, - w: 4, - h: 6, - collapsed: false, - titleOverride: null, - filterOverride: null, - timeframeOverride: null, - refreshSeconds: null, - options: {} - }, - { - id: 'status-1', - type: 'status-overview', - x: 4, - y: 0, - w: 4, - h: 6, - collapsed: false, - titleOverride: null, - filterOverride: null, - timeframeOverride: null, - refreshSeconds: null, - options: {} - }, - { - id: 'notif-1', - type: 'notifications', - x: 8, - y: 0, - w: 4, - h: 3, - collapsed: false, - titleOverride: null, - filterOverride: null, - timeframeOverride: null, - refreshSeconds: null, - options: {} - }, - { - id: 'news-1', - type: 'newsfeed', - x: 8, - y: 3, - w: 4, - h: 5, - collapsed: false, - titleOverride: null, - filterOverride: null, - timeframeOverride: null, - refreshSeconds: null, - options: {} - } + panel('situations-1', 'pending-situations', 0, 0, 4, 3), + panel('nodes-1', 'nodes-with-alarms', 0, 3, 4, 6), + panel('outages-1', 'service-outages', 0, 9, 4, 4), + panel('status-1', 'status-overview', 4, 0, 4, 6), + panel('notif-1', 'notifications', 8, 0, 4, 3), + panel('news-1', 'newsfeed', 8, 3, 4, 6) ] }) diff --git a/ui/src/components/Dashboard/panels/OutagesPanel.vue b/ui/src/components/Dashboard/panels/OutagesPanel.vue new file mode 100644 index 000000000000..917fb4a97250 --- /dev/null +++ b/ui/src/components/Dashboard/panels/OutagesPanel.vue @@ -0,0 +1,122 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/panels/SituationsPanel.vue b/ui/src/components/Dashboard/panels/SituationsPanel.vue new file mode 100644 index 000000000000..7df604f22151 --- /dev/null +++ b/ui/src/components/Dashboard/panels/SituationsPanel.vue @@ -0,0 +1,131 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/registry.ts b/ui/src/components/Dashboard/registry.ts index 2a6b68d1d579..fa1a976346e5 100644 --- a/ui/src/components/Dashboard/registry.ts +++ b/ui/src/components/Dashboard/registry.ts @@ -46,6 +46,16 @@ export interface PanelDefinition { } export const panelRegistry: Record = { + 'pending-situations': { + type: 'pending-situations', + title: 'Pending Situations', + category: 'status', + component: defineAsyncComponent(() => import('./panels/SituationsPanel.vue')), + defaultSize: { w: 4, h: 3 }, + minSize: { w: 3, h: 2 }, + renamable: true, + collapsible: true + }, 'nodes-with-alarms': { type: 'nodes-with-alarms', title: 'Nodes with Pending Alarms', @@ -56,6 +66,16 @@ export const panelRegistry: Record = { renamable: true, collapsible: true }, + 'service-outages': { + type: 'service-outages', + title: 'Nodes with Service Outages', + category: 'status', + component: defineAsyncComponent(() => import('./panels/OutagesPanel.vue')), + defaultSize: { w: 4, h: 4 }, + minSize: { w: 3, h: 3 }, + renamable: true, + collapsible: true + }, 'status-overview': { type: 'status-overview', title: 'Status Overview', diff --git a/ui/src/services/outageService.ts b/ui/src/services/outageService.ts new file mode 100644 index 000000000000..63eac30b5cdc --- /dev/null +++ b/ui/src/services/outageService.ts @@ -0,0 +1,54 @@ +/// +/// 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' + +// OnmsOutage exposes flat nodeId/nodeLabel/ipAddress/locationName; the service +// name is nested under monitoredService.serviceType.name. +export interface CurrentOutage { + id?: number + nodeId?: number + nodeLabel?: string + ipAddress?: string + locationName?: string + ifLostService?: number | string + serviceName?: string + monitoredService?: { serviceName?: string; serviceType?: { name?: string } } +} + +// Currently-open outages: ifRegainedService is the epoch sentinel (== null). +const OPEN_FIQL = 'outage.ifRegainedService==1970-01-01T00:00:00.000-0000' + +export const getCurrentOutages = async (limit = 12): Promise => { + try { + const resp = await v2.get(`/outages?_s=${encodeURIComponent(OPEN_FIQL)}&limit=${limit}`) + if (resp.status === 204) { + return [] + } + return (resp.data?.outage ?? []) as CurrentOutage[] + } catch (err) { + return [] + } +} + +export const outageServiceName = (o: CurrentOutage): string => + o.serviceName ?? o.monitoredService?.serviceName ?? o.monitoredService?.serviceType?.name ?? 'service' diff --git a/ui/src/services/situationService.ts b/ui/src/services/situationService.ts new file mode 100644 index 000000000000..cd8913d39a18 --- /dev/null +++ b/ui/src/services/situationService.ts @@ -0,0 +1,47 @@ +/// +/// 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' + +// Situations are alarms flagged isSituation; the relevant extra fields come +// straight from the alarm JSON. +export interface Situation { + id: number | string + severity: string + situationAlarmCount?: number + affectedNodeCount?: number + lastEventTime?: number + logMessage?: string +} + +export const getPendingSituations = async (limit = 12): Promise => { + try { + const fiql = encodeURIComponent('isSituation==true') + const resp = await v2.get(`/alarms?_s=${fiql}&limit=${limit}&orderBy=lastEventTime&order=DESC`) + if (resp.status === 204) { + return [] + } + return (resp.data?.alarm ?? []) as Situation[] + } catch (err) { + return [] + } +} From 521df8d9f0809c8fb1ad99acdc21e77fdc3c3e38 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Wed, 3 Jun 2026 09:17:17 -0400 Subject: [PATCH 05/27] NMS-19851: Add Regional Status map panel Leaflet/OSM map (vue-leaflet) replicating the legacy homepage map: geolocated nodes (/api/v2/nodes assetRecord) with markers colored by each node's highest alarm severity (derived from /api/v2/alarms). Handles grid resize via ResizeObserver -> invalidateSize. Added to the registry + default layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/src/components/Dashboard/defaultLayout.ts | 3 +- .../Dashboard/panels/RegionalMapPanel.vue | 202 ++++++++++++++++++ ui/src/components/Dashboard/registry.ts | 10 + 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/Dashboard/panels/RegionalMapPanel.vue diff --git a/ui/src/components/Dashboard/defaultLayout.ts b/ui/src/components/Dashboard/defaultLayout.ts index 00b7b8507373..11e2d860a157 100644 --- a/ui/src/components/Dashboard/defaultLayout.ts +++ b/ui/src/components/Dashboard/defaultLayout.ts @@ -52,6 +52,7 @@ export const createDefaultLayout = (): DashboardLayout => ({ panel('outages-1', 'service-outages', 0, 9, 4, 4), panel('status-1', 'status-overview', 4, 0, 4, 6), panel('notif-1', 'notifications', 8, 0, 4, 3), - panel('news-1', 'newsfeed', 8, 3, 4, 6) + panel('news-1', 'newsfeed', 8, 3, 4, 6), + panel('map-1', 'regional-map', 4, 6, 8, 6) ] }) diff --git a/ui/src/components/Dashboard/panels/RegionalMapPanel.vue b/ui/src/components/Dashboard/panels/RegionalMapPanel.vue new file mode 100644 index 000000000000..a74538dba808 --- /dev/null +++ b/ui/src/components/Dashboard/panels/RegionalMapPanel.vue @@ -0,0 +1,202 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/registry.ts b/ui/src/components/Dashboard/registry.ts index fa1a976346e5..2e6991b02561 100644 --- a/ui/src/components/Dashboard/registry.ts +++ b/ui/src/components/Dashboard/registry.ts @@ -86,6 +86,16 @@ export const panelRegistry: Record = { renamable: true, collapsible: true }, + 'regional-map': { + type: 'regional-map', + title: 'Regional Status', + category: 'status', + component: defineAsyncComponent(() => import('./panels/RegionalMapPanel.vue')), + defaultSize: { w: 8, h: 6 }, + minSize: { w: 4, h: 4 }, + renamable: true, + collapsible: true + }, notifications: { type: 'notifications', title: 'Notifications', From cfe412abeedb1d2eba524f864edabc22647fa801 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Wed, 3 Jun 2026 09:38:10 -0400 Subject: [PATCH 06/27] NMS-19851: Wider middle column in default dashboard layout Match the legacy homepage proportions: 3 / 6 / 3 of the 12-col grid (was even 4/4/4). Left = situations/alarms/outages, center (wide) = status overview + map, right = notifications/news. Individual panels remain resizable via the grid. Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/src/components/Dashboard/defaultLayout.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ui/src/components/Dashboard/defaultLayout.ts b/ui/src/components/Dashboard/defaultLayout.ts index 11e2d860a157..36cb7e99e11b 100644 --- a/ui/src/components/Dashboard/defaultLayout.ts +++ b/ui/src/components/Dashboard/defaultLayout.ts @@ -46,13 +46,16 @@ export const createDefaultLayout = (): DashboardLayout => ({ refresh: { seconds: 120, paused: false }, globalFilter: { surveillanceCategories: [], ipMatch: null }, globalTimeframe: { preset: TimeframePreset.Last24h, from: null, to: null }, + // Three columns with a wider middle (3 / 6 / 3 of 12), matching the legacy + // homepage. Left: situations/alarms/outages; center (wide): status + map; + // right: notifications/news. panels: [ - panel('situations-1', 'pending-situations', 0, 0, 4, 3), - panel('nodes-1', 'nodes-with-alarms', 0, 3, 4, 6), - panel('outages-1', 'service-outages', 0, 9, 4, 4), - panel('status-1', 'status-overview', 4, 0, 4, 6), - panel('notif-1', 'notifications', 8, 0, 4, 3), - panel('news-1', 'newsfeed', 8, 3, 4, 6), - panel('map-1', 'regional-map', 4, 6, 8, 6) + panel('situations-1', 'pending-situations', 0, 0, 3, 3), + panel('nodes-1', 'nodes-with-alarms', 0, 3, 3, 5), + panel('outages-1', 'service-outages', 0, 8, 3, 4), + panel('status-1', 'status-overview', 3, 0, 6, 5), + panel('map-1', 'regional-map', 3, 5, 6, 7), + panel('notif-1', 'notifications', 9, 0, 3, 3), + panel('news-1', 'newsfeed', 9, 3, 3, 5) ] }) From 15b6e133732d42effc7af2bd0765e1401d25ced6 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Wed, 3 Jun 2026 09:52:09 -0400 Subject: [PATCH 07/27] NMS-19851: Add Availability (24h) panel Replicates the legacy "Availability Over the Past 24 Hours" categories box from GET /rest/availability (RTC category data: outage-text, availability-text, availability-class). Table of category / outages / availability with an Overall Service Availability total row; availability colored by class. No new backend needed. Added to registry + default layout (center column, under Status Overview). Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/src/components/Dashboard/defaultLayout.ts | 3 +- .../Dashboard/panels/AvailabilityPanel.vue | 151 ++++++++++++++++++ ui/src/components/Dashboard/registry.ts | 10 ++ ui/src/services/availabilityService.ts | 64 ++++++++ 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/Dashboard/panels/AvailabilityPanel.vue create mode 100644 ui/src/services/availabilityService.ts diff --git a/ui/src/components/Dashboard/defaultLayout.ts b/ui/src/components/Dashboard/defaultLayout.ts index 36cb7e99e11b..4d6e92cae4a5 100644 --- a/ui/src/components/Dashboard/defaultLayout.ts +++ b/ui/src/components/Dashboard/defaultLayout.ts @@ -54,7 +54,8 @@ export const createDefaultLayout = (): DashboardLayout => ({ panel('nodes-1', 'nodes-with-alarms', 0, 3, 3, 5), panel('outages-1', 'service-outages', 0, 8, 3, 4), panel('status-1', 'status-overview', 3, 0, 6, 5), - panel('map-1', 'regional-map', 3, 5, 6, 7), + panel('availability-1', 'availability', 3, 5, 6, 4), + panel('map-1', 'regional-map', 3, 9, 6, 7), panel('notif-1', 'notifications', 9, 0, 3, 3), panel('news-1', 'newsfeed', 9, 3, 3, 5) ] diff --git a/ui/src/components/Dashboard/panels/AvailabilityPanel.vue b/ui/src/components/Dashboard/panels/AvailabilityPanel.vue new file mode 100644 index 000000000000..3f29065631a2 --- /dev/null +++ b/ui/src/components/Dashboard/panels/AvailabilityPanel.vue @@ -0,0 +1,151 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/registry.ts b/ui/src/components/Dashboard/registry.ts index 2e6991b02561..2b35e9116308 100644 --- a/ui/src/components/Dashboard/registry.ts +++ b/ui/src/components/Dashboard/registry.ts @@ -86,6 +86,16 @@ export const panelRegistry: Record = { renamable: true, collapsible: true }, + availability: { + type: 'availability', + title: 'Availability Over the Past 24 Hours', + category: 'status', + component: defineAsyncComponent(() => import('./panels/AvailabilityPanel.vue')), + defaultSize: { w: 6, h: 4 }, + minSize: { w: 4, h: 3 }, + renamable: true, + collapsible: true + }, 'regional-map': { type: 'regional-map', title: 'Regional Status', diff --git a/ui/src/services/availabilityService.ts b/ui/src/services/availabilityService.ts new file mode 100644 index 000000000000..2a068fa84ad8 --- /dev/null +++ b/ui/src/services/availabilityService.ts @@ -0,0 +1,64 @@ +/// +/// 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 { rest } from './axiosInstances' + +// Front-page RTC availability per category (legacy categories-box), from +// GET /rest/availability → { section: [{ name, categories: { category: [...] } }] }. +export interface AvailabilityCategory { + name: string + outageText: string + availabilityText: string + availability: number + availabilityClass: string + outageClass: string +} + +export interface AvailabilitySection { + name: string + categories: AvailabilityCategory[] +} + +// JAXB-style JSON collapses single-element arrays to objects; normalize. +const toArray = (v: T | T[] | undefined | null): T[] => (v == null ? [] : Array.isArray(v) ? v : [v]) + +export const getAvailability = async (): Promise => { + try { + const resp = await rest.get('/availability', { headers: { Accept: 'application/json' } }) + if (resp.status === 204 || !resp.data?.section) { + return [] + } + return toArray(resp.data.section).map((s) => ({ + name: s.name, + categories: toArray(s.categories?.category).map((c) => ({ + name: c.name, + outageText: c['outage-text'] ?? '', + availabilityText: c['availability-text'] ?? '', + availability: Number(c.availability ?? 0), + availabilityClass: c['availability-class'] ?? 'Normal', + outageClass: c['outage-class'] ?? 'Normal' + })) + })) + } catch (err) { + return [] + } +} From 9737c9009688e0cfa8937c52760f50c9114d0b13 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Wed, 3 Jun 2026 10:28:37 -0400 Subject: [PATCH 08/27] NMS-19851: Add Business Services and Applications (with pending alarms) panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both replicate the legacy summary boxes from the existing status REST (/api/v2/status/business-services and /status/applications) — no new backend needed; the list-with-severity endpoints already exist in StatusRestService. Shared StatusListPanel renders name + severity badge, filtered to problem severities (WARNING+). Added to registry + left column of the default layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- ui/src/components/Dashboard/defaultLayout.ts | 2 + .../Dashboard/panels/ApplicationsPanel.vue | 38 ++++++ .../panels/BusinessServicesPanel.vue | 38 ++++++ .../Dashboard/panels/StatusListPanel.vue | 122 ++++++++++++++++++ ui/src/components/Dashboard/registry.ts | 20 +++ ui/src/services/statusService.ts | 35 +++++ 6 files changed, 255 insertions(+) create mode 100644 ui/src/components/Dashboard/panels/ApplicationsPanel.vue create mode 100644 ui/src/components/Dashboard/panels/BusinessServicesPanel.vue create mode 100644 ui/src/components/Dashboard/panels/StatusListPanel.vue diff --git a/ui/src/components/Dashboard/defaultLayout.ts b/ui/src/components/Dashboard/defaultLayout.ts index 4d6e92cae4a5..6063510de23d 100644 --- a/ui/src/components/Dashboard/defaultLayout.ts +++ b/ui/src/components/Dashboard/defaultLayout.ts @@ -53,6 +53,8 @@ export const createDefaultLayout = (): DashboardLayout => ({ panel('situations-1', 'pending-situations', 0, 0, 3, 3), panel('nodes-1', 'nodes-with-alarms', 0, 3, 3, 5), panel('outages-1', 'service-outages', 0, 8, 3, 4), + panel('bsm-1', 'business-services', 0, 12, 3, 3), + panel('apps-1', 'applications', 0, 15, 3, 3), panel('status-1', 'status-overview', 3, 0, 6, 5), panel('availability-1', 'availability', 3, 5, 6, 4), panel('map-1', 'regional-map', 3, 9, 6, 7), diff --git a/ui/src/components/Dashboard/panels/ApplicationsPanel.vue b/ui/src/components/Dashboard/panels/ApplicationsPanel.vue new file mode 100644 index 000000000000..33fbea56bf77 --- /dev/null +++ b/ui/src/components/Dashboard/panels/ApplicationsPanel.vue @@ -0,0 +1,38 @@ + + + + + + diff --git a/ui/src/components/Dashboard/panels/BusinessServicesPanel.vue b/ui/src/components/Dashboard/panels/BusinessServicesPanel.vue new file mode 100644 index 000000000000..267a87335f95 --- /dev/null +++ b/ui/src/components/Dashboard/panels/BusinessServicesPanel.vue @@ -0,0 +1,38 @@ + + + + + + diff --git a/ui/src/components/Dashboard/panels/StatusListPanel.vue b/ui/src/components/Dashboard/panels/StatusListPanel.vue new file mode 100644 index 000000000000..6eb708cd99ae --- /dev/null +++ b/ui/src/components/Dashboard/panels/StatusListPanel.vue @@ -0,0 +1,122 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/registry.ts b/ui/src/components/Dashboard/registry.ts index 2b35e9116308..4b6d251fbdc3 100644 --- a/ui/src/components/Dashboard/registry.ts +++ b/ui/src/components/Dashboard/registry.ts @@ -86,6 +86,26 @@ export const panelRegistry: Record = { renamable: true, collapsible: true }, + 'business-services': { + type: 'business-services', + title: 'Business Services with Pending Alarms', + category: 'status', + component: defineAsyncComponent(() => import('./panels/BusinessServicesPanel.vue')), + defaultSize: { w: 4, h: 3 }, + minSize: { w: 3, h: 2 }, + renamable: true, + collapsible: true + }, + applications: { + type: 'applications', + title: 'Applications with Pending Alarms', + category: 'status', + component: defineAsyncComponent(() => import('./panels/ApplicationsPanel.vue')), + defaultSize: { w: 4, h: 3 }, + minSize: { w: 3, h: 2 }, + renamable: true, + collapsible: true + }, availability: { type: 'availability', title: 'Availability Over the Past 24 Hours', diff --git a/ui/src/services/statusService.ts b/ui/src/services/statusService.ts index facd35bd0e59..dcd8247c6f27 100644 --- a/ui/src/services/statusService.ts +++ b/ui/src/services/statusService.ts @@ -49,3 +49,38 @@ export const getNodesByAlarms = (): Promise => export const getNodesByOutages = (): Promise => fetchSummary('/status/summary/nodes/outages') + +// Named status lists (business services / applications) — only those with a +// pending problem (severity worse than NORMAL), matching the legacy boxes. +export interface StatusListItem { + id: number + name: string + severity: string +} + +const PROBLEM_SEVERITIES = new Set(['WARNING', 'MINOR', 'MAJOR', 'CRITICAL']) + +const severityString = (s: unknown): string => + (typeof s === 'string' ? s : ((s as any)?.label ?? (s as any)?.name ?? '')).toUpperCase() + +const fetchStatusList = async (path: string, key: string): Promise => { + try { + const resp = await v2.get(path, { headers: { Accept: 'application/json' } }) + if (resp.status === 204 || !resp.data) { + return [] + } + const raw = resp.data[key] + const arr = Array.isArray(raw) ? raw : raw ? [raw] : [] + return arr + .map((x: any) => ({ id: Number(x.id), name: x.name, severity: severityString(x.severity) })) + .filter((x: StatusListItem) => PROBLEM_SEVERITIES.has(x.severity)) + } catch (err) { + return [] + } +} + +export const getBusinessServicesStatus = (): Promise => + fetchStatusList('/status/business-services', 'businessservice') + +export const getApplicationsStatus = (): Promise => + fetchStatusList('/status/applications', 'application') From b3d70032d5be2bbfe5661568e505da7d410d5237 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Wed, 3 Jun 2026 10:39:29 -0400 Subject: [PATCH 09/27] NMS-19851: Search widgets, original-layout order, toolbar tooltips + fullscreen - Add Quick Search (node id/label/IP -> element/nodeList.htm), and Resource Graphs / KSC Reports launcher panels (link to legacy chooser pages; full typeahead is a follow-up). - Default layout now mirrors the legacy homepage column order/positions for easy comparison: left = situations/alarms/outages/BSM/apps/news; center = status/availability/map; right = notifications/resource-graphs/KSC/quick-search. - Toolbar: title tooltips on the timeframe + refresh selects (and pause), and a Full screen button (Fullscreen API on the dashboard container) for NOC displays. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/Dashboard/DashboardToolbar.vue | 72 +++++++++--- ui/src/components/Dashboard/defaultLayout.ts | 18 ++- .../Dashboard/panels/KscReportsPanel.vue | 90 +++++++++++++++ .../Dashboard/panels/QuickSearchPanel.vue | 107 ++++++++++++++++++ .../Dashboard/panels/ResourceGraphsPanel.vue | 96 ++++++++++++++++ ui/src/components/Dashboard/registry.ts | 30 +++++ ui/src/containers/Dashboard.vue | 11 +- 7 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 ui/src/components/Dashboard/panels/KscReportsPanel.vue create mode 100644 ui/src/components/Dashboard/panels/QuickSearchPanel.vue create mode 100644 ui/src/components/Dashboard/panels/ResourceGraphsPanel.vue diff --git a/ui/src/components/Dashboard/DashboardToolbar.vue b/ui/src/components/Dashboard/DashboardToolbar.vue index dd4763fc9f53..73bf42fb7862 100644 --- a/ui/src/components/Dashboard/DashboardToolbar.vue +++ b/ui/src/components/Dashboard/DashboardToolbar.vue @@ -32,29 +32,48 @@ License.
- + + + - + + + + +