diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json index d5a81390467e..5a9f7577cbcb 100644 --- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json +++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template.json @@ -241,7 +241,7 @@ { "id": "surveillanceCategories", "name": "Surveillance Categories", - "url": "admin/categories.htm", + "url": "ui/index.html#/admin/surveillance-categories", "locationMatch": "categories", "roles": null }, diff --git a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java index 5949936dcf44..6cb60e28260b 100644 --- a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java +++ b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java @@ -196,7 +196,8 @@ public void testMenuEntries() throws Exception { // Administration Menu clickMenuItem("Administration", "Surveillance Categories"); - wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//div[@class='card-header']/span[text()='Surveillance Categories']"))); + // now a /ui (Vue) page rather than the legacy JSP card header + wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//div[@id='app']//h1[@class='page-title' and normalize-space(text())='Surveillance Categories']"))); clickMenuItem("Administration", "Configure Thresholds"); wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//div[@class='card-header']/span[text()='Threshold Configuration']"))); diff --git a/ui/src/components/ManageCategories/CategoriesHelpPanel.vue b/ui/src/components/ManageCategories/CategoriesHelpPanel.vue new file mode 100644 index 000000000000..381082e5fd16 --- /dev/null +++ b/ui/src/components/ManageCategories/CategoriesHelpPanel.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/ui/src/components/ManageCategories/CategoriesTable.vue b/ui/src/components/ManageCategories/CategoriesTable.vue new file mode 100644 index 000000000000..3c5e3b699786 --- /dev/null +++ b/ui/src/components/ManageCategories/CategoriesTable.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/ui/src/components/ManageCategories/CategoryEditorDialog.vue b/ui/src/components/ManageCategories/CategoryEditorDialog.vue new file mode 100644 index 000000000000..5b557848322b --- /dev/null +++ b/ui/src/components/ManageCategories/CategoryEditorDialog.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/ui/src/components/ManageCategories/CategoryNodesDialog.vue b/ui/src/components/ManageCategories/CategoryNodesDialog.vue new file mode 100644 index 000000000000..f3de31bfcc6d --- /dev/null +++ b/ui/src/components/ManageCategories/CategoryNodesDialog.vue @@ -0,0 +1,269 @@ + + + + + diff --git a/ui/src/containers/ManageCategories.vue b/ui/src/containers/ManageCategories.vue new file mode 100644 index 000000000000..bd3b80f3723c --- /dev/null +++ b/ui/src/containers/ManageCategories.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/ui/src/main/router/index.ts b/ui/src/main/router/index.ts index 52a1b1b07ba4..4294b4ee0650 100644 --- a/ui/src/main/router/index.ts +++ b/ui/src/main/router/index.ts @@ -120,6 +120,24 @@ const router = createRouter({ } } }, + { + path: '/admin/surveillance-categories', + name: 'Surveillance Categories', + component: () => import('@/containers/ManageCategories.vue'), + beforeEnter: (to, from) => { + const checkRoles = () => { + if (!adminRole.value) { + showSnackBar({ msg: 'Must be admin to manage surveillance categories.' }) + router.push(from.path) + } + } + if (rolesAreLoaded.value) { + checkRoles() + } else { + whenever(rolesAreLoaded, () => checkRoles()) + } + } + }, { path: '/configuration', name: 'Configuration', diff --git a/ui/src/services/categoryAdminService.ts b/ui/src/services/categoryAdminService.ts new file mode 100644 index 000000000000..71a2f5b9aed6 --- /dev/null +++ b/ui/src/services/categoryAdminService.ts @@ -0,0 +1,156 @@ +/// +/// Licensed to The OpenNMS Group, Inc (TOG) under one or more +/// contributor license agreements. See the LICENSE.md file +/// distributed with this work for additional information +/// regarding copyright ownership. +/// +/// TOG licenses this file to You under the GNU Affero General +/// Public License Version 3 (the "License") or (at your option) +/// any later version. You may not use this file except in +/// compliance with the License. You may obtain a copy of the +/// License at: +/// +/// https://www.gnu.org/licenses/agpl-3.0.txt +/// +/// Unless required by applicable law or agreed to in writing, +/// software distributed under the License is distributed on an +/// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, +/// either express or implied. See the License for the specific +/// language governing permissions and limitations under the +/// License. +/// + +import useSnackbar from '@/composables/useSnackbar' +import useSpinner from '@/composables/useSpinner' +import { rest } from './axiosInstances' + +// PrimeVue Manage Surveillance Categories (NMS-20131). Reuses existing REST — +// no backend change: category CRUD via the v1 /rest/categories service, and +// node membership via its granular /rest/categories/{name}/nodes/{nodeId} +// sub-resource. Member nodes are read from the v2 node search (the only place +// that resolves category membership: `_s=category.name==NAME`; the bare +// ?category= param does NOT filter). + +export interface AdminCategory { + id?: number + name: string + description?: string | null + authorizedGroups?: string[] +} + +const { showSnackBar } = useSnackbar() +const { startSpinner, stopSpinner } = useSpinner() +const endpoint = '/categories' + +// Only surface a server detail if it looks like a short, plain message — a 500 +// often returns a servlet HTML error page, which must not be shown verbatim. +const errorMessage = (err: any, fallback: string): string => { + const detail = err?.response?.data + if (typeof detail === 'string') { + const trimmed = detail.trim() + if (trimmed && trimmed.length <= 200 && !/[<>]/.test(trimmed)) return trimmed + } + return fallback +} + +const listCategories = async (): Promise => { + try { + startSpinner() + const resp = await rest.get(endpoint) + if (resp.status === 204) { + return [] + } + const raw = resp.data?.category ?? [] + return Array.isArray(raw) ? raw : [raw] + } catch (_err) { + showSnackBar({ msg: 'Failed to load surveillance categories.' }) + return null + } finally { + stopSpinner() + } +} + +const createCategory = async (category: AdminCategory): Promise => { + try { + startSpinner() + await rest.post(endpoint, category) + showSnackBar({ msg: `Category '${category.name}' created.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to create category '${category.name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +// v1 category update is form-urlencoded bean-property update (used for the +// description; name is the immutable id). +const updateCategoryDescription = async (name: string, description: string): Promise => { + try { + startSpinner() + const body = new URLSearchParams() + body.set('description', description) + await rest.put(`${endpoint}/${encodeURIComponent(name)}`, body, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }) + showSnackBar({ msg: `Category '${name}' updated.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to update category '${name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const deleteCategory = async (name: string): Promise => { + try { + startSpinner() + await rest.delete(`${endpoint}/${encodeURIComponent(name)}`) + showSnackBar({ msg: `Category '${name}' deleted.` }) + return null + } catch (err: any) { + // already gone — the desired end-state holds, so treat it as success + if (err?.response?.status === 404) { + showSnackBar({ msg: `Category '${name}' deleted.` }) + return null + } + const msg = errorMessage(err, `Failed to delete category '${name}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const addNodeToCategory = async (name: string, nodeId: number): Promise => { + try { + await rest.put(`${endpoint}/${encodeURIComponent(name)}/nodes/${nodeId}`) + return true + } catch (_err) { + showSnackBar({ msg: `Failed to add node to '${name}'.`, error: true }) + return false + } +} + +const removeNodeFromCategory = async (name: string, nodeId: number): Promise => { + try { + await rest.delete(`${endpoint}/${encodeURIComponent(name)}/nodes/${nodeId}`) + return true + } catch (_err) { + showSnackBar({ msg: `Failed to remove node from '${name}'.`, error: true }) + return false + } +} + +export { + addNodeToCategory, + createCategory, + deleteCategory, + listCategories, + removeNodeFromCategory, + updateCategoryDescription +} diff --git a/ui/src/services/index.ts b/ui/src/services/index.ts index 8b8c60f273ea..20f53eeb4c3e 100644 --- a/ui/src/services/index.ts +++ b/ui/src/services/index.ts @@ -29,6 +29,14 @@ import { getNodeAvailabilityPercentage } from './nodeService' import { getCategories } from './categoryService' +import { + addNodeToCategory, + createCategory, + deleteCategory, + listCategories, + removeNodeFromCategory, + updateCategoryDescription +} from './categoryAdminService' import { getMonitoringLocations } from './monitoringLocationService' import { getServiceTypes } from './serviceTypes' import { getProvisionDService, putProvisionDService } from './configurationService' @@ -92,6 +100,12 @@ export default { getNodeSnmpInterfaces, getNodeAvailabilityPercentage, getCategories, + addNodeToCategory, + createCategory, + deleteCategory, + listCategories, + removeNodeFromCategory, + updateCategoryDescription, getMonitoringLocations, getLog, getLogs, diff --git a/ui/src/stores/categoryAdminStore.ts b/ui/src/stores/categoryAdminStore.ts new file mode 100644 index 000000000000..cec28a631388 --- /dev/null +++ b/ui/src/stores/categoryAdminStore.ts @@ -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. +/// + +import API from '@/services' +import { AdminCategory } from '@/services/categoryAdminService' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useCategoryAdminStore = defineStore('categoryAdminStore', () => { + const categories = ref([] as AdminCategory[]) + const loadError = ref(false) + + const getCategories = async () => { + const result = await API.listCategories() + if (result !== null) { + categories.value = result + loadError.value = false + } else { + loadError.value = true + } + } + + const createCategory = async (category: AdminCategory) => { + const error = await API.createCategory(category) + if (error === null) { + await getCategories() + } + return error + } + + const updateCategoryDescription = async (name: string, description: string) => { + const error = await API.updateCategoryDescription(name, description) + if (error === null) { + await getCategories() + } + return error + } + + const deleteCategory = async (name: string) => { + const error = await API.deleteCategory(name) + if (error === null) { + await getCategories() + } + return error + } + + return { categories, loadError, getCategories, createCategory, updateCategoryDescription, deleteCategory } +}) diff --git a/ui/tests/components/ManageCategories/CategoriesHelpPanel.test.ts b/ui/tests/components/ManageCategories/CategoriesHelpPanel.test.ts new file mode 100644 index 000000000000..0361e0b52087 --- /dev/null +++ b/ui/tests/components/ManageCategories/CategoriesHelpPanel.test.ts @@ -0,0 +1,16 @@ +import CategoriesHelpPanel from '@/components/ManageCategories/CategoriesHelpPanel.vue' +import { mount } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { describe, expect, it } from 'vitest' + +const TogglePanelStub = { name: 'TogglePanel', template: '
' } + +describe('CategoriesHelpPanel.vue', () => { + it('renders the help content', () => { + const wrapper = mount(CategoriesHelpPanel, { + global: { plugins: [PrimeVue], stubs: { TogglePanel: TogglePanelStub } } + }) + expect(wrapper.text()).toContain('About Surveillance Categories') + expect(wrapper.text()).toContain('surveillance category') + }) +}) diff --git a/ui/tests/components/ManageCategories/CategoriesTable.test.ts b/ui/tests/components/ManageCategories/CategoriesTable.test.ts new file mode 100644 index 000000000000..1eacff905380 --- /dev/null +++ b/ui/tests/components/ManageCategories/CategoriesTable.test.ts @@ -0,0 +1,44 @@ +import CategoriesTable from '@/components/ManageCategories/CategoriesTable.vue' +import { useCategoryAdminStore } from '@/stores/categoryAdminStore' +import { createTestingPinia } from '@pinia/testing' +import { mount } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mountTable = () => { + const wrapper = mount(CategoriesTable, { + global: { + plugins: [PrimeVue, createTestingPinia({ createSpy: vi.fn, stubActions: true })], + stubs: { + CategoryEditorDialog: true, CategoryNodesDialog: true, OnmsConfirmationDialog: true, + TableCard: { template: '
' } + } + } + }) + return { wrapper, store: useCategoryAdminStore() } +} + +describe('CategoriesTable.vue', () => { + let ctx: ReturnType + + beforeEach(() => { + ctx = mountTable() + }) + + it('renders column headers even when there are no categories', async () => { + ctx.store.categories = [] + await ctx.wrapper.vm.$nextTick() + const headers = ctx.wrapper.findAll('th').map((th) => th.text().trim()).filter(Boolean) + expect(headers).toContain('Name') + expect(headers).toContain('Description') + expect(ctx.wrapper.find('[data-test="empty-list"]').exists()).toBe(true) + }) + + it('renders a row per category with the action buttons', async () => { + ctx.store.categories = [{ name: 'Routers', description: 'core' }] as any + await ctx.wrapper.vm.$nextTick() + expect(ctx.wrapper.find('[data-test="manage-nodes-button"]').exists()).toBe(true) + expect(ctx.wrapper.find('[data-test="edit-category-button"]').exists()).toBe(true) + expect(ctx.wrapper.find('[data-test="delete-category-button"]').exists()).toBe(true) + }) +}) diff --git a/ui/tests/components/ManageCategories/CategoryEditorDialog.test.ts b/ui/tests/components/ManageCategories/CategoryEditorDialog.test.ts new file mode 100644 index 000000000000..7305d07ca474 --- /dev/null +++ b/ui/tests/components/ManageCategories/CategoryEditorDialog.test.ts @@ -0,0 +1,79 @@ +import CategoryEditorDialog from '@/components/ManageCategories/CategoryEditorDialog.vue' +import { useCategoryAdminStore } from '@/stores/categoryAdminStore' +import { createTestingPinia } from '@pinia/testing' +import { flushPromises, mount, VueWrapper } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const DialogStub = { + name: 'Dialog', + props: ['visible', 'header', 'modal'], + template: '
' +} + +const mountDialog = async (category: any = null) => { + const wrapper = mount(CategoryEditorDialog, { + props: { visible: false, category }, + global: { + plugins: [PrimeVue, createTestingPinia({ createSpy: vi.fn, stubActions: true })], + stubs: { Dialog: DialogStub } + } + }) + const store = useCategoryAdminStore() + vi.mocked(store.createCategory).mockResolvedValue(null) + vi.mocked(store.updateCategoryDescription).mockResolvedValue(null) + await wrapper.setProps({ visible: true }) + await flushPromises() + return { wrapper, store } +} + +describe('CategoryEditorDialog.vue', () => { + let ctx: { wrapper: VueWrapper, store: ReturnType } + + describe('create mode', () => { + beforeEach(async () => { ctx = await mountDialog(null) }) + + it('shows the name field and disables Save until a name is entered', async () => { + expect(ctx.wrapper.find('[data-test="category-name-input"]').exists()).toBe(true) + expect(ctx.wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + await ctx.wrapper.find('[data-test="category-name-input"]').setValue('Routers') + expect(ctx.wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeUndefined() + }) + + it('rejects a name with FIQL/markup characters', async () => { + await ctx.wrapper.find('[data-test="category-name-input"]').setValue('net,core') + expect(ctx.wrapper.find('[data-test="name-error"]').exists()).toBe(true) + expect(ctx.wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + }) + + it('creates the category and closes on success', async () => { + await ctx.wrapper.find('[data-test="category-name-input"]').setValue('Routers') + await ctx.wrapper.find('[data-test="category-description-input"]').setValue('core routers') + await ctx.wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + expect(ctx.store.createCategory).toHaveBeenCalledWith({ name: 'Routers', description: 'core routers' }) + expect(ctx.wrapper.emitted('update:visible')?.at(-1)).toEqual([false]) + }) + + it('keeps the dialog open and shows a server error', async () => { + vi.mocked(ctx.store.createCategory).mockResolvedValue('Category already exists.') + await ctx.wrapper.find('[data-test="category-name-input"]').setValue('Routers') + await ctx.wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + expect(ctx.wrapper.find('[data-test="dialog-error"]').text()).toContain('already exists') + expect(ctx.wrapper.emitted('update:visible')).toBeFalsy() + }) + }) + + describe('edit mode', () => { + beforeEach(async () => { ctx = await mountDialog({ name: 'Routers', description: 'old' }) }) + + it('hides the immutable name field and updates only the description', async () => { + expect(ctx.wrapper.find('[data-test="category-name-input"]').exists()).toBe(false) + await ctx.wrapper.find('[data-test="category-description-input"]').setValue('new desc') + await ctx.wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + expect(ctx.store.updateCategoryDescription).toHaveBeenCalledWith('Routers', 'new desc') + }) + }) +}) diff --git a/ui/tests/components/ManageCategories/CategoryNodesDialog.test.ts b/ui/tests/components/ManageCategories/CategoryNodesDialog.test.ts new file mode 100644 index 000000000000..559e04788b25 --- /dev/null +++ b/ui/tests/components/ManageCategories/CategoryNodesDialog.test.ts @@ -0,0 +1,127 @@ +import CategoryNodesDialog from '@/components/ManageCategories/CategoryNodesDialog.vue' +import API from '@/services' +import { flushPromises, mount, VueWrapper } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/services', () => ({ + default: { + getNodes: vi.fn(), + addNodeToCategory: vi.fn(), + removeNodeFromCategory: vi.fn() + } +})) + +const DialogStub = { + name: 'Dialog', + props: ['visible', 'header', 'modal'], + template: '
' +} +const ToggleStub = { + name: 'ToggleSwitch', + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: '