diff --git a/opennms-webapp-rest/127.0.0.1.tm4.epoch b/opennms-webapp-rest/127.0.0.1.tm4.epoch new file mode 100644 index 000000000000..62f3e6fff7af Binary files /dev/null and b/opennms-webapp-rest/127.0.0.1.tm4.epoch differ 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..927356682aa9 --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java @@ -0,0 +1,121 @@ +/* + * 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.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +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.netmgt.dao.api.ServiceTypeDao; +import org.opennms.netmgt.model.OnmsServiceType; +import org.opennms.web.rest.v2.api.DashboardRestApi; +import org.slf4j.Logger; +import org.opennms.web.api.Authentication; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 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; + + @Autowired + private ServiceTypeDao serviceTypeDao; + + 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 javax.ws.rs.core.SecurityContext securityContext, final Map layout) { + // the layout is system-wide: every user reads it, only admins write it + if (securityContext == null || !securityContext.isUserInRole(Authentication.ROLE_ADMIN)) { + return Response.status(Status.FORBIDDEN).entity("Saving the system dashboard requires the admin role.").build(); + } + 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(); + } + } + + @Override + @Transactional(readOnly = true) + public Response getServiceTypes() { + try { + final List> types = serviceTypeDao.findAll().stream() + .sorted(Comparator.comparing(OnmsServiceType::getName, String.CASE_INSENSITIVE_ORDER)) + .map(t -> { + final Map m = new LinkedHashMap<>(); + m.put("id", t.getId()); + m.put("name", t.getName()); + return m; + }) + .collect(Collectors.toList()); + return Response.ok(types).build(); + } catch (final Exception e) { + LOG.error("Failed to list service types", 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..15b671c6e5cb --- /dev/null +++ b/opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/api/DashboardRestApi.java @@ -0,0 +1,79 @@ +/* + * 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.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; + +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(@Context SecurityContext securityContext, Map layout); + + @GET + @Path("service-types") + @Produces(MediaType.APPLICATION_JSON) + @Operation( + summary = "List monitored service types (id + name).", + description = "Used by the dashboard Quick Search 'Providing service' control.", + operationId = "getDashboardServiceTypes" + ) + Response getServiceTypes(); +} diff --git a/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java new file mode 100644 index 000000000000..c167b759b8b7 --- /dev/null +++ b/opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java @@ -0,0 +1,149 @@ +/* + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + * either express or implied. See the License for the specific + * language governing permissions and limitations under the + * License. + */ +package org.opennms.web.rest.v2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import javax.ws.rs.core.MediaType; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.test.MockLogAppender; +import org.opennms.core.test.OpenNMSJUnit4ClassRunner; +import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; +import org.opennms.core.test.rest.AbstractSpringJerseyRestTestCase; +import org.opennms.netmgt.dao.DatabasePopulator; +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:/META-INF/opennms/applicationContext-postgresJsonStore.xml", + "classpath:/applicationContext-rest-test.xml" +}) +@JUnitConfigurationEnvironment(systemProperties = "org.opennms.timeseries.strategy=integration") +@JUnitTemporaryDatabase +public class DashboardRestServiceIT extends AbstractSpringJerseyRestTestCase { + + @Autowired + private DatabasePopulator m_databasePopulator; + + public DashboardRestServiceIT() { + super(CXF_REST_V2_CONTEXT_PATH); + } + + private static boolean s_populated = false; + + @Override + protected void afterServletStart() { + MockLogAppender.setupLogging(); + // the temp database is shared across the methods of this class; + // repopulating trips unique constraints + if (!s_populated) { + m_databasePopulator.populateDatabase(); + s_populated = true; + } + } + + @Test + public void testLayoutLifecycle() throws Exception { + // the document round-trips as nested JSON, not an escaped string + final String layout = "{\"scope\":\"SYSTEM\",\"version\":1," + + "\"panels\":[{\"id\":\"a\",\"type\":\"notes\",\"x\":0,\"y\":0,\"w\":6,\"h\":200," + + "\"options\":{\"text\":\"hello\"}}]," + + "\"refresh\":{\"seconds\":120,\"paused\":false}}"; + sendData(PUT, MediaType.APPLICATION_JSON, "/dashboard/system", layout, 204); + + final JSONObject read = new JSONObject(getJson("/dashboard/system")); + assertEquals("SYSTEM", read.getString("scope")); + assertEquals(1, read.getJSONArray("panels").length()); + assertEquals("hello", read.getJSONArray("panels").getJSONObject(0).getJSONObject("options").getString("text")); + + // saving again replaces the document + sendData(PUT, MediaType.APPLICATION_JSON, "/dashboard/system", + "{\"scope\":\"SYSTEM\",\"version\":1,\"panels\":[],\"refresh\":{\"seconds\":60,\"paused\":true}}", 204); + final JSONObject replaced = new JSONObject(getJson("/dashboard/system")); + assertEquals(0, replaced.getJSONArray("panels").length()); + assertEquals(60, replaced.getJSONObject("refresh").getInt("seconds")); + } + + @Test + public void testNullBodyRejected() throws Exception { + sendData(PUT, MediaType.APPLICATION_JSON, "/dashboard/system", "null", 400); + } + + @Test + public void testServiceTypesSortedCaseInsensitively() throws Exception { + final JSONArray types = new JSONArray(getJson("/dashboard/service-types")); + assertTrue(types.length() > 0); + String previous = null; + for (int i = 0; i < types.length(); i++) { + final JSONObject type = types.getJSONObject(i); + assertTrue(type.has("id")); + final String name = type.getString("name"); + if (previous != null) { + assertTrue(previous + " <= " + name, previous.compareToIgnoreCase(name) <= 0); + } + previous = name; + } + } + + @Test + public void testLayoutWriteRequiresAdmin() throws Exception { + // ensure a layout exists so the read below is a definite 200 + sendData(PUT, MediaType.APPLICATION_JSON, "/dashboard/system", + "{\"scope\":\"SYSTEM\",\"panels\":[]}", 204); + setUser("nobody", new String[]{ "ROLE_USER" }); + try { + // every user may read the layout, only admins may write it + sendRequest(GET, "/dashboard/system", 200); + sendData(PUT, MediaType.APPLICATION_JSON, "/dashboard/system", + "{\"scope\":\"SYSTEM\",\"panels\":[]}", 403); + } finally { + setUser("admin", new String[]{ "ROLE_ADMIN" }); + } + } + + private String getJson(final String url) throws Exception { + final MockHttpServletRequest request = createRequest(GET, url); + request.addHeader("Accept", MediaType.APPLICATION_JSON); + return sendRequest(request, 200); + } +} diff --git a/opennms-webapp-rest/tmlog4.log b/opennms-webapp-rest/tmlog4.log new file mode 100644 index 000000000000..711006c3d3b5 Binary files /dev/null and b/opennms-webapp-rest/tmlog4.log differ diff --git a/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml b/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml index 4829fe6357f7..a655d110c404 100644 --- a/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml +++ b/opennms-webapp/src/main/webapp/WEB-INF/applicationContext-spring-security.xml @@ -190,6 +190,11 @@ + + + diff --git a/opennms-webapp/src/main/webapp/includes/quicksearch-box.jsp b/opennms-webapp/src/main/webapp/includes/quicksearch-box.jsp index a0efa097b016..e653bc07b3bc 100644 --- a/opennms-webapp/src/main/webapp/includes/quicksearch-box.jsp +++ b/opennms-webapp/src/main/webapp/includes/quicksearch-box.jsp @@ -101,3 +101,8 @@ function submitNodeSearch(params) { + +<%-- Standalone call-to-action below the Quick Search box, not part of it --%> + diff --git a/ui/package.json b/ui/package.json index c3628933034e..82feeb784be1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -32,6 +32,7 @@ "@vueuse/core": "^9.13.0", "ace-builds": "^1.32.6", "axios": "^1.15.0", + "grid-layout-plus": "^1.1.1", "chart.js": "^3.9.1", "chartjs-plugin-zoom": "^2.0.1", "cronstrue": "^3.9.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 806f4d2e209f..1f3c4cbbf80d 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -77,6 +77,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 @@ -505,6 +508,15 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@fortawesome/fontawesome-common-types@6.7.2': resolution: {integrity: sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==} engines: {node: '>=6'} @@ -543,6 +555,9 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@interactjs/types@1.10.28': + resolution: {integrity: sha512-vPmu4HWmsg0Fub3BPKGk7DuozkhgVK84TVkziY47v8Uh0A14gph55/vVRaQN4oZov0lGNJNK6Jyddim2qd/4vw==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -550,6 +565,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==} @@ -603,42 +621,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==} @@ -743,79 +755,66 @@ packages: resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.2': resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.2': resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.2': resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.2': resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.2': resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.2': resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.2': resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.2': resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.2': resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.2': resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.2': resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.2': resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.62.2': resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} @@ -1198,6 +1197,14 @@ packages: resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.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} @@ -2003,6 +2010,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + 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'} @@ -2059,6 +2071,9 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + interactjs@1.10.28: + resolution: {integrity: sha512-QCa4ksTPd2p/FZ4I6hrH6fogjLqru1TxYywRiKYNuLZtrlAxTIYUa/MYsE7PnUgWZJU3SrGVDjiz065BeaXYng==} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -2730,6 +2745,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true vite-plugin-externals@0.6.2: @@ -3104,6 +3120,17 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + '@fortawesome/fontawesome-common-types@6.7.2': {} '@fortawesome/fontawesome-svg-core@6.7.2': @@ -3134,6 +3161,8 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@interactjs/types@1.10.28': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3145,6 +3174,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': @@ -4071,6 +4102,15 @@ snapshots: '@typescript-eslint/types': 8.61.0 eslint-visitor-keys: 5.0.1 + '@vexip-ui/hooks@2.9.4(vue@3.5.33(typescript@5.4.5))': + dependencies: + '@floating-ui/dom': 1.8.0 + '@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.3(@types/node@25.6.0)(sass@1.99.0))(vue@3.5.33(typescript@5.4.5))': dependencies: vite: 6.4.3(@types/node@25.6.0)(sass@1.99.0) @@ -4988,6 +5028,13 @@ snapshots: graceful-fs@4.2.11: {} + 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.28 + vue: 3.5.33(typescript@5.4.5) + hammerjs@2.0.8: {} happy-dom@9.20.3: @@ -5031,6 +5078,10 @@ snapshots: ini@1.3.8: {} + interactjs@1.10.28: + dependencies: + '@interactjs/types': 1.10.28 + 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..ebd7d9d9010d --- /dev/null +++ b/ui/src/components/Dashboard/DashboardFilterControl.vue @@ -0,0 +1,142 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/DashboardGrid.vue b/ui/src/components/Dashboard/DashboardGrid.vue new file mode 100644 index 000000000000..11797d6e41f7 --- /dev/null +++ b/ui/src/components/Dashboard/DashboardGrid.vue @@ -0,0 +1,277 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/DashboardToolbar.vue b/ui/src/components/Dashboard/DashboardToolbar.vue new file mode 100644 index 000000000000..d3577c6a65a3 --- /dev/null +++ b/ui/src/components/Dashboard/DashboardToolbar.vue @@ -0,0 +1,312 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/PanelFrame.vue b/ui/src/components/Dashboard/PanelFrame.vue new file mode 100644 index 000000000000..5deec0c28618 --- /dev/null +++ b/ui/src/components/Dashboard/PanelFrame.vue @@ -0,0 +1,298 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/PanelOptionsDialog.vue b/ui/src/components/Dashboard/PanelOptionsDialog.vue new file mode 100644 index 000000000000..05857a5f6b07 --- /dev/null +++ b/ui/src/components/Dashboard/PanelOptionsDialog.vue @@ -0,0 +1,261 @@ + + + + + + + + diff --git a/ui/src/components/Dashboard/defaultLayout.ts b/ui/src/components/Dashboard/defaultLayout.ts new file mode 100644 index 000000000000..f50e8ccae3a1 --- /dev/null +++ b/ui/src/components/Dashboard/defaultLayout.ts @@ -0,0 +1,38 @@ +/// +/// 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 }, + autoCompact: true, + // The base framework ships an empty dashboard; panels arrive with the + // parity (NMS-20126) and new-panel (NMS-20127) groups. + panels: [] +}) diff --git a/ui/src/components/Dashboard/filter.ts b/ui/src/components/Dashboard/filter.ts new file mode 100644 index 000000000000..3b84f2c081af --- /dev/null +++ b/ui/src/components/Dashboard/filter.ts @@ -0,0 +1,95 @@ +/// +/// 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 '@/services/axiosInstances' +import type { DashboardFilter } from '@/types/dashboard' + +// Shared dashboard filter (NMS-10507): surveillance categories + IP match, +// translated into FIQL for the node-scoped panels (alarms / situations / +// outages). node.id and ipInterface.ipAddress both resolve on those entities; +// node.categories.name does NOT, so categories are resolved to node ids first. + +export const isFilterActive = (filter: DashboardFilter): boolean => + filter.surveillanceCategories.length > 0 || !!filter.ipMatch?.trim() + +// Cache per sorted category set for the life of the page; the membership of a +// surveillance category rarely changes within a dashboard session. +const nodeIdCache = new Map>() + +// Resolve the selected categories to the union of their member node ids. +// Returns null when no category is selected (no node constraint). +export const resolveFilterNodeIds = async (filter: DashboardFilter): Promise => { + const categories = [...filter.surveillanceCategories].sort() + if (!categories.length) { + return null + } + const key = categories.join('|') + if (!nodeIdCache.has(key)) { + nodeIdCache.set(key, (async () => { + const ids = new Set() + let ok = true + for (const category of categories) { + try { + const resp = await v2.get(`/nodes?_s=${encodeURIComponent(`category.name==${category}`)}&limit=0`) + for (const node of resp.data?.node ?? []) { + if (node?.id != null) { + ids.add(Number(node.id)) + } + } + } catch { + // a transient failure must not cache an empty set for the session; evict + // so the next refresh retries instead of leaving the panels permanently empty + ok = false + } + } + if (!ok) { + nodeIdCache.delete(key) + } + return [...ids] + })()) + } + return nodeIdCache.get(key)! +} + +// URLs have length limits, so cap the node-id OR-group; the panels are homepage +// summaries, not exhaustive reports. +const MAX_NODE_IDS = 300 + +// FIQL fragments (already grouped) that constrain a node-scoped query to the +// active filter. AND these into the panel's own `_s` with ';'. +export const filterFiqlClauses = (filter: DashboardFilter, nodeIds: number[] | null): string[] => { + const clauses: string[] = [] + const ip = filter.ipMatch?.trim() + if (ip) { + clauses.push(`ipInterface.ipAddress==${ip}`) + } + if (nodeIds) { + // a category selected but matching no nodes must return nothing, not everything + const capped = nodeIds.slice(0, MAX_NODE_IDS) + clauses.push(capped.length ? `(${capped.map(id => `node.id==${id}`).join(',')})` : 'node.id==-1') + } + return clauses +} + +// Convenience: resolve + build the clauses in one call. +export const buildFilterClauses = async (filter: DashboardFilter): Promise => + filterFiqlClauses(filter, await resolveFilterNodeIds(filter)) diff --git a/ui/src/components/Dashboard/panels/HtmlContentPanel.vue b/ui/src/components/Dashboard/panels/HtmlContentPanel.vue new file mode 100644 index 000000000000..5f224230d657 --- /dev/null +++ b/ui/src/components/Dashboard/panels/HtmlContentPanel.vue @@ -0,0 +1,96 @@ + + + +