From 0adc4931aee79b4be62b62f02218c72baa42a4c9 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Fri, 31 Jul 2026 19:07:05 -0400 Subject: [PATCH 1/5] NMS-20128: PrimeVue Manage Minions page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the legacy AngularJS minions page to a PrimeVue /ui page over the existing v2 /api/v2/minions CRUD — no backend or JSON contract change, and the v1 REST stays. Table of minions (id, label, location, type, status, version, last updated, property count) with edit and delete; the editor changes label, location and the key/value properties (with duplicate-key validation) while the server-maintained fields round-trip via spread, and delete confirms and warns about re-registration. The menu entry points at the new page. --- .../webapp/WEB-INF/menu/menu-template.json | 4 +- .../org/opennms/smoketest/MenuHeaderIT.java | 3 +- .../ManageMinions/MinionEditorDialog.vue | 228 +++++++++++++++++ .../ManageMinions/MinionsHelpPanel.vue | 83 ++++++ .../components/ManageMinions/MinionsTable.vue | 242 ++++++++++++++++++ ui/src/containers/ManageMinions.vue | 54 ++++ ui/src/main/router/index.ts | 18 ++ ui/src/services/index.ts | 5 + ui/src/services/minionAdminService.ts | Bin 0 -> 5432 bytes ui/src/stores/minionAdminStore.ts | 75 ++++++ ui/src/types/minionAdmin.ts | 42 +++ .../ManageMinions/MinionEditorDialog.test.ts | 84 ++++++ .../ManageMinions/MinionsHelpPanel.test.ts | 15 ++ .../ManageMinions/MinionsTable.test.ts | 91 +++++++ ui/tests/containers/ManageMinions.test.ts | 21 ++ ui/tests/services/minionAdminService.test.ts | 76 ++++++ ui/tests/stores/minionAdminStore.test.ts | 69 +++++ 17 files changed, 1107 insertions(+), 3 deletions(-) create mode 100644 ui/src/components/ManageMinions/MinionEditorDialog.vue create mode 100644 ui/src/components/ManageMinions/MinionsHelpPanel.vue create mode 100644 ui/src/components/ManageMinions/MinionsTable.vue create mode 100644 ui/src/containers/ManageMinions.vue create mode 100644 ui/src/services/minionAdminService.ts create mode 100644 ui/src/stores/minionAdminStore.ts create mode 100644 ui/src/types/minionAdmin.ts create mode 100644 ui/tests/components/ManageMinions/MinionEditorDialog.test.ts create mode 100644 ui/tests/components/ManageMinions/MinionsHelpPanel.test.ts create mode 100644 ui/tests/components/ManageMinions/MinionsTable.test.ts create mode 100644 ui/tests/containers/ManageMinions.test.ts create mode 100644 ui/tests/services/minionAdminService.test.ts create mode 100644 ui/tests/stores/minionAdminStore.test.ts 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..92af3568f458 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 @@ -321,9 +321,9 @@ { "id": "manageMinions", "name": "Manage Minions", - "url": "minion/index.jsp", + "url": "ui/index.html#/admin/minions", "locationMatch": "", - "roles": null + "roles": ["ROLE_ADMIN"] }, { "id": "manageApplications", 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..517ed2ceb32b 100644 --- a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java +++ b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java @@ -130,7 +130,8 @@ public void testMenuEntries() throws Exception { // Distributed Monitoring clickMenuItem("Distributed Monitoring", "Manage Minions"); - wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//ol[@class='breadcrumb']/li[contains(text()[normalize-space()], 'Manage Minions')]"))); + // now a /ui (Vue) page rather than the legacy JSP breadcrumb + wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//div[@id='app']//h1[@class='page-title' and normalize-space(text())='Manage Minions']"))); clickMenuItem("Distributed Monitoring", "Manage Applications"); wait.until(ExpectedConditions.visibilityOfElementLocated(By.xpath("//ol[@class='breadcrumb']/li[contains(text()[normalize-space()], 'Applications')]"))); diff --git a/ui/src/components/ManageMinions/MinionEditorDialog.vue b/ui/src/components/ManageMinions/MinionEditorDialog.vue new file mode 100644 index 000000000000..41b66a488f28 --- /dev/null +++ b/ui/src/components/ManageMinions/MinionEditorDialog.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/ui/src/components/ManageMinions/MinionsHelpPanel.vue b/ui/src/components/ManageMinions/MinionsHelpPanel.vue new file mode 100644 index 000000000000..2abfdb71f601 --- /dev/null +++ b/ui/src/components/ManageMinions/MinionsHelpPanel.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/ui/src/components/ManageMinions/MinionsTable.vue b/ui/src/components/ManageMinions/MinionsTable.vue new file mode 100644 index 000000000000..bcd8217dcfb8 --- /dev/null +++ b/ui/src/components/ManageMinions/MinionsTable.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/ui/src/containers/ManageMinions.vue b/ui/src/containers/ManageMinions.vue new file mode 100644 index 000000000000..7df9f238cb5f --- /dev/null +++ b/ui/src/containers/ManageMinions.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/ui/src/main/router/index.ts b/ui/src/main/router/index.ts index 52a1b1b07ba4..19d75747e324 100644 --- a/ui/src/main/router/index.ts +++ b/ui/src/main/router/index.ts @@ -120,6 +120,24 @@ const router = createRouter({ } } }, + { + path: '/admin/minions', + name: 'Manage Minions', + component: () => import('@/containers/ManageMinions.vue'), + beforeEnter: (to, from) => { + const checkRoles = () => { + if (!adminRole.value) { + showSnackBar({ msg: 'Must be admin to manage minions.' }) + router.push(from.path) + } + } + if (rolesAreLoaded.value) { + checkRoles() + } else { + whenever(rolesAreLoaded, () => checkRoles()) + } + } + }, { path: '/configuration', name: 'Configuration', diff --git a/ui/src/services/index.ts b/ui/src/services/index.ts index 8b8c60f273ea..fe916d18c257 100644 --- a/ui/src/services/index.ts +++ b/ui/src/services/index.ts @@ -74,8 +74,13 @@ import { setUsageStatisticsStatus } from './usageStatisticsService' import { addZenithRegistration, getZenithRegistrations } from './zenithConnectService' +import { deleteMinion, getMinionNodeIds, listMinions, updateMinion } from './minionAdminService' export default { + getMinionNodeIds, + listMinions, + updateMinion, + deleteMinion, search, getInfo, getNodes, diff --git a/ui/src/services/minionAdminService.ts b/ui/src/services/minionAdminService.ts new file mode 100644 index 0000000000000000000000000000000000000000..451b262d5efff2dc0969b4b76b34e58ec407a968 GIT binary patch literal 5432 zcmcIoe{UPd5$)gl6l22(k1%zoC@D}drd3&193~cJkWzv`FsvnSNv?IgJ>Tw$CR76b z5dDPxBz?0#NXn6Gw?z#Y^xf^w&b)c^X4YAj?PXa;uhm4BM&@KSUCbq&SF#+Ojp$Xa zo2o? zYE`S*+>&0FvNm&7rM$!~gX7UFaG-JdR zA);Sl9uClVl<=m<0lLR+MkEoIR=NQ~6?@rs21L1{LRjE->-Pc>K6xQlRBB6&VQ>($ zb4z}l$jTyCw|Al!RiQ*Vkr5k!=n>wrqQ_RKl7z#7^pvr7+rv^4w#(1%9r2&JwUy~+ z*>br|XJwP>dX^zdCV|PstSa`uOAk`}(QdX~mW4EiYWbm2HBvQRk*MI?M2rDuA(kA! zOS)qb+*#I0TsiBwQ;nY5rKlzCrrWd05hJh$DC{l(&cXxn_;^J9(PMgXJnD}+^jrVp zr}N7T`t5i)JRV&1Pe*h3Hxv{n8)2^bSZ+3n`EvtD570vx=dW z`7S+!7tA~M%2aZqrfT9MC`37H#7xpmBabE1u9Eda8K#4QpE+2zP}cF&Yt@+D|WnW$8Ddl;NE^v}Ql z14Tk(z$Ah2WR5JzADrRaZ`t1Pbadgs=d*quoexk1P$Qf`Ot+@Ax|z(8=Dj_9FqZDf z%=OX%`vSnPsmB!uSwQ57+hgfhn90SnbV>{RF4uocY*7w zLsL-{jDHsx)JSAI{)2kY>28l0a99t~^!W6n6h&6ywLD65VFeZ;%7l3bb&4x{z{#6z zwVk183dbxK(3oCWn(_<4%*y5Lgzw*cLt$xJ$a03>@vKLO2M2@?kFu+0&);UL1<8`I zs@)EV#ci;-#$ONPCeXdLBYN2s1zH_mtD*sw62|Z9ZJUgy2{3_}ZL|$?1R4&mCU=)-_ zrNn%_?vF0sogBXbNQn3#vYU^L71<+UR@}Ogu{Y2cW#qHFXuBH<_v$UGNn25z;B?!i zvRRDb+nWA{e-_2_m}Fb8LXMyYo4N=|@eJUc!yT~{;PdTaI+HfJesUMfVtwReeR3Cl zUw@-(%o8tGy+Vv-alOqjn@Vwz1&TfJ?iSy^|P8G%{C=eGb z?JAi|-#YO9RIpI^ULi2c?PI58*ZEpPf+Ep zER{sRgXXivSpM%>LRU%_@DLq|x0qOrKI#E*`2{gSJyjk>va<;wz6q7p=E_2OsC@mW zCwDPQ=0>ZvzK->b8ai_+VdH%qy(wycrrFl6&Skno&Y*Z{Cbqi)bZ8C#Q4rvrW<_eQO_y#b^+l^QC;2RVlqPO zD`&~hzBc%f?$V8AqWuVsdRryI{!c*llz$#1Z74;)KbDNyK3_*<*OfbF z+&8#9xPpg{6>?vX-%Cu4M%>Et`m#cU@a4aRqH*0DG%;2mmbjD4;qY{D5vRCXFa6z2 z^N{C)$fD2L*3&S$=5CBL7!d^DT-hduH?&{ep&+(oN>M@cEiS-KkwXeGr&)Kb9~$=; z7zz2d2mSZ`F~bVJf@JJ;6r{$5;L%2A!yF)id(;cg!H!~h-2RKEKWXY3CCG<-uJ30L z)+{^2Op9e3^aYhWhp{Bsb-kKJ@owEl7-47u2Vz^V#EP3>3=3O*mm^BkbjsE`ZuN#= z?p&9q&4&%c{I~EJKW}r`CAVPv!ddp!v5CHF?oT~miXLOW55d>mLk<5rB(en{88zoU zgW{ebu`Tim&(rJA%>ZGxh+}dF5`?M*WMG54m}hx55ZAFnEW#GIocrrjYk;he8a+|D7+P2i*m5JY_oc>0pI9ZF_a< S1Sfgu!bH~z { + const minions = ref([] as Minion[]) + const loadError = ref(false) + const isLoading = ref(false) + const totalCount = ref(0) + const truncated = computed(() => minions.value.length < totalCount.value) + // minion id+location -> its requisition node id, for the ID -> node link + const nodeIdByMinion = ref>({}) + + const getMinions = async () => { + isLoading.value = true + try { + const result = await API.listMinions() + if (result !== null) { + minions.value = result.minions + totalCount.value = result.totalCount + loadError.value = false + nodeIdByMinion.value = await API.getMinionNodeIds(result.minions) + } else { + loadError.value = true + } + } finally { + isLoading.value = false + } + } + + const nodeIdFor = (minion: Minion): number | undefined => + nodeIdByMinion.value[minionNodeKey(minion.id, minion.location)] + + const updateMinion = async (edit: MinionEdit) => { + const error = await API.updateMinion(edit) + if (error === null) { + await getMinions() + } + return error + } + + const deleteMinion = async (id: string) => { + const error = await API.deleteMinion(id) + if (error === null) { + await getMinions() + } + return error + } + + return { minions, loadError, isLoading, totalCount, truncated, nodeIdFor, getMinions, updateMinion, deleteMinion } +}) diff --git a/ui/src/types/minionAdmin.ts b/ui/src/types/minionAdmin.ts new file mode 100644 index 000000000000..b9284f0cf3b9 --- /dev/null +++ b/ui/src/types/minionAdmin.ts @@ -0,0 +1,42 @@ +/// +/// 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. +/// + +// Wire shape of an OnmsMinion from /api/v2/minions (OnmsMonitoringSystem + +// status/version). type/status/date are server-maintained (read-only); only +// label, location and properties are editable, matching the legacy page. +export interface Minion { + id: string + label: string | null + location: string | null + type?: string | null + status?: string | null + version?: string | null + date?: string | number | null // last updated + properties?: Record +} + +export interface MinionApiResponse { + minion: Minion[] + totalCount: number + count: number + offset: number +} diff --git a/ui/tests/components/ManageMinions/MinionEditorDialog.test.ts b/ui/tests/components/ManageMinions/MinionEditorDialog.test.ts new file mode 100644 index 000000000000..7a0a9d57eae7 --- /dev/null +++ b/ui/tests/components/ManageMinions/MinionEditorDialog.test.ts @@ -0,0 +1,84 @@ +import MinionEditorDialog from '@/components/ManageMinions/MinionEditorDialog.vue' +import { useMinionAdminStore } from '@/stores/minionAdminStore' +import { flushPromises, mount, VueWrapper } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/stores/minionAdminStore') + +const DialogStub = { + name: 'Dialog', + props: ['visible', 'header', 'modal'], + template: '
' +} + +describe('MinionEditorDialog.vue', () => { + let wrapper: VueWrapper + let store: any + + const minion = { id: 'm1', label: 'Minion One', location: 'Default', type: 'Minion', status: 'UP', version: '1.0', properties: { region: 'us' } } + + const mountDialog = async (m: any = minion) => { + wrapper = mount(MinionEditorDialog, { + props: { visible: false, minion: m }, + global: { plugins: [PrimeVue], stubs: { Dialog: DialogStub } } + }) + await wrapper.setProps({ visible: true }) + await flushPromises() + } + + beforeEach(() => { + vi.clearAllMocks() + store = { updateMinion: vi.fn().mockResolvedValue(null) } + vi.mocked(useMinionAdminStore).mockReturnValue(store) + }) + + it('prefills label, location and properties from the minion', async () => { + await mountDialog() + expect((wrapper.find('[data-test="label-input"]').element as HTMLInputElement).value).toBe('Minion One') + expect((wrapper.find('[data-test="prop-key-0"]').element as HTMLInputElement).value).toBe('region') + }) + + it('saves an edit payload of id/label/location/properties only (server fields preserved by the service)', async () => { + await mountDialog() + await wrapper.find('[data-test="label-input"]').setValue('Renamed') + await wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + const arg = store.updateMinion.mock.calls[0][0] + expect(arg).toEqual({ id: 'm1', label: 'Renamed', location: 'Default', properties: { region: 'us' } }) + // the editor deliberately does NOT send server-maintained fields + expect(arg.status).toBeUndefined() + expect(wrapper.emitted('update:visible')?.at(-1)).toEqual([false]) + }) + + it('preserves a property key verbatim (does not trim keys)', async () => { + await mountDialog({ ...minion, properties: { ' region': 'us' } }) + await wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + expect(store.updateMinion.mock.calls[0][0].properties).toEqual({ ' region': 'us' }) + }) + + it('requires a location', async () => { + await mountDialog() + await wrapper.find('[data-test="location-input"]').setValue('') + expect(wrapper.find('[data-test="location-error"]').text()).toContain('required') + expect(wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + }) + + it('blocks saving on duplicate property keys', async () => { + await mountDialog() + await wrapper.find('[data-test="add-property-button"]').trigger('click') + await wrapper.find('[data-test="prop-key-1"]').setValue('region') + expect(wrapper.find('[data-test="prop-error"]').text()).toContain('unique') + expect(wrapper.find('[data-test="save-button"]').attributes('disabled')).toBeDefined() + }) + + it('shows a server rejection inside the dialog and stays open', async () => { + store.updateMinion.mockResolvedValue('Update failed on the server.') + await mountDialog() + await wrapper.find('[data-test="save-button"]').trigger('click') + await flushPromises() + expect(wrapper.find('[data-test="dialog-error"]').text()).toContain('Update failed') + expect(wrapper.emitted('update:visible') ?? []).toEqual([]) + }) +}) diff --git a/ui/tests/components/ManageMinions/MinionsHelpPanel.test.ts b/ui/tests/components/ManageMinions/MinionsHelpPanel.test.ts new file mode 100644 index 000000000000..650afbc2348e --- /dev/null +++ b/ui/tests/components/ManageMinions/MinionsHelpPanel.test.ts @@ -0,0 +1,15 @@ +import MinionsHelpPanel from '@/components/ManageMinions/MinionsHelpPanel.vue' +import { mount } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { describe, expect, it } from 'vitest' + +const TogglePanelStub = { name: 'TogglePanel', template: '
' } + +describe('MinionsHelpPanel.vue', () => { + it('renders the help content', () => { + const wrapper = mount(MinionsHelpPanel, { + global: { plugins: [PrimeVue], stubs: { TogglePanel: TogglePanelStub } } + }) + expect(wrapper.text()).toContain('About Minions') + }) +}) diff --git a/ui/tests/components/ManageMinions/MinionsTable.test.ts b/ui/tests/components/ManageMinions/MinionsTable.test.ts new file mode 100644 index 000000000000..639e9a38d91e --- /dev/null +++ b/ui/tests/components/ManageMinions/MinionsTable.test.ts @@ -0,0 +1,91 @@ +import MinionsTable from '@/components/ManageMinions/MinionsTable.vue' +import { useMinionAdminStore } from '@/stores/minionAdminStore' +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 minion = (id: string, over: Record = {}) => ({ + id, label: id, location: 'Default', type: 'Minion', status: 'up', version: '1', date: 0, properties: {}, ...over +}) + +const mountTable = () => { + const wrapper = mount(MinionsTable, { + global: { + plugins: [PrimeVue, createTestingPinia({ createSpy: vi.fn, stubActions: true })], + stubs: { MinionEditorDialog: true, OnmsConfirmationDialog: true, TableCard: { template: '
' } } + } + }) + return { wrapper, store: useMinionAdminStore() } +} + +describe('MinionsTable.vue', () => { + let ctx: ReturnType + + beforeEach(() => { + ctx = mountTable() + }) + + it('renders all column headers even when there are no minions', async () => { + ctx.store.minions = [] + ctx.store.isLoading = false + await ctx.wrapper.vm.$nextTick() + const headers = ctx.wrapper.findAll('th').map((th) => th.text().trim()).filter(Boolean) + for (const h of ['ID', 'Label', 'Location', 'Type', 'Status', 'Version', 'Last Updated', 'Properties']) { + expect(headers).toContain(h) + } + expect(ctx.wrapper.find('[data-test="empty-list"]').exists()).toBe(true) + }) + + it('does not show the empty message while loading', async () => { + ctx.store.minions = [] + ctx.store.isLoading = true + await ctx.wrapper.vm.$nextTick() + expect(ctx.wrapper.find('[data-test="empty-list"]').exists()).toBe(false) + }) + + it('shows the error copy when a load failed', async () => { + ctx.store.minions = [] + ctx.store.isLoading = false + ctx.store.loadError = true + await ctx.wrapper.vm.$nextTick() + expect(ctx.wrapper.find('[data-test="empty-list"]').text()).toContain('Failed to load minions') + }) + + it('refresh button re-fetches minions', async () => { + ctx.store.minions = [minion('m1')] as any + await ctx.wrapper.vm.$nextTick() + await ctx.wrapper.find('[data-test="refresh-button"]').trigger('click') + expect(ctx.store.getMinions).toHaveBeenCalled() + }) + + it('links the ID to its node when a node id is known', async () => { + vi.mocked(ctx.store.nodeIdFor).mockReturnValue(42) + ctx.store.minions = [minion('m1')] as any + await ctx.wrapper.vm.$nextTick() + const link = ctx.wrapper.find('[data-test="minion-node-link"]') + expect(link.exists()).toBe(true) + expect(link.attributes('href')).toContain('element/node.jsp?node=42') + }) + + it('renders the ID as plain text when no node id is known', async () => { + // default testing-pinia spy returns undefined + ctx.store.minions = [minion('m1')] as any + await ctx.wrapper.vm.$nextTick() + expect(ctx.wrapper.find('[data-test="minion-node-link"]').exists()).toBe(false) + }) + + it('shows a truncation note when the safety cap was hit', async () => { + ctx.store.minions = [minion('m1')] as any + ctx.store.totalCount = 9 + await ctx.wrapper.vm.$nextTick() + expect(ctx.wrapper.find('[data-test="truncation-note"]').exists()).toBe(true) + }) + + it('shows a stale-data note when a reload failed but rows remain', async () => { + ctx.store.minions = [minion('m1')] as any + ctx.store.loadError = true + await ctx.wrapper.vm.$nextTick() + expect(ctx.wrapper.find('[data-test="stale-note"]').exists()).toBe(true) + }) +}) diff --git a/ui/tests/containers/ManageMinions.test.ts b/ui/tests/containers/ManageMinions.test.ts new file mode 100644 index 000000000000..2722f71c0fd2 --- /dev/null +++ b/ui/tests/containers/ManageMinions.test.ts @@ -0,0 +1,21 @@ +import ManageMinions from '@/containers/ManageMinions.vue' +import { useMinionAdminStore } from '@/stores/minionAdminStore' +import { createTestingPinia } from '@pinia/testing' +import { flushPromises, mount } from '@vue/test-utils' +import PrimeVue from 'primevue/config' +import { describe, expect, it, vi } from 'vitest' + +describe('ManageMinions.vue (container)', () => { + it('loads minions on mount and renders the page title', async () => { + const wrapper = mount(ManageMinions, { + global: { + plugins: [PrimeVue, createTestingPinia({ createSpy: vi.fn, stubActions: true })], + stubs: { MinionsTable: true, MinionsHelpPanel: true, BreadCrumbs: true } + } + }) + const store = useMinionAdminStore() + await flushPromises() + expect(store.getMinions).toHaveBeenCalled() + expect(wrapper.find('.page-title').text()).toBe('Manage Minions') + }) +}) diff --git a/ui/tests/services/minionAdminService.test.ts b/ui/tests/services/minionAdminService.test.ts new file mode 100644 index 000000000000..c0ac27630bc2 --- /dev/null +++ b/ui/tests/services/minionAdminService.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AxiosError, AxiosHeaders } from 'axios' +import { deleteMinion, getMinionNodeIds, listMinions, updateMinion } from '@/services/minionAdminService' +import { v2 } from '@/services/axiosInstances' + +vi.mock('@/services/axiosInstances', () => ({ v2: { get: vi.fn(), put: vi.fn(), delete: vi.fn() } })) +vi.mock('@/composables/useSnackbar', () => ({ default: () => ({ showSnackBar: vi.fn() }) })) +vi.mock('@/composables/useSpinner', () => ({ default: () => ({ startSpinner: vi.fn(), stopSpinner: vi.fn() }) })) + +const http = (status: number) => { + const e = new AxiosError('x') + e.response = { status, data: '', statusText: '', headers: {}, config: { headers: new AxiosHeaders() } } + return e +} + +const minion = (id: string, location = 'Default') => ({ id, label: id, location, type: 'Minion', status: 'up', version: '1', properties: {} }) as any + +describe('minionAdminService', () => { + beforeEach(() => vi.clearAllMocks()) + afterEach(() => vi.restoreAllMocks()) + + it('listMinions fetches a bounded page (not limit=0), reports the total, and maps 204 to empty', async () => { + vi.mocked(v2.get).mockResolvedValueOnce({ status: 200, data: { minion: [{ id: 'm1' }], totalCount: 7 } } as any) + expect(await listMinions()).toEqual({ minions: [{ id: 'm1' }], totalCount: 7 }) + expect(vi.mocked(v2.get).mock.calls[0][0]).not.toContain('limit=0') + + vi.mocked(v2.get).mockResolvedValueOnce({ status: 204 } as any) + expect(await listMinions()).toEqual({ minions: [], totalCount: 0 }) + }) + + it('listMinions returns null on failure', async () => { + vi.mocked(v2.get).mockRejectedValue(http(500)) + expect(await listMinions()).toBeNull() + }) + + it('getMinionNodeIds ORs foreignId== per minion and maps node ids by id+location', async () => { + vi.mocked(v2.get).mockResolvedValue({ status: 200, data: { node: [ + { id: '100', foreignId: 'm1', location: 'Default' }, + { id: '101', foreignId: 'm2', location: 'RemoteA' } + ] } } as any) + const map = await getMinionNodeIds([minion('m1'), minion('m2', 'RemoteA')]) + const url = vi.mocked(v2.get).mock.calls[0][0] as string + expect(decodeURIComponent(url)).toContain('(foreignId==m1,foreignId==m2)') + expect(map).toEqual({ 'm1\u0000Default': 100, 'm2\u0000RemoteA': 101 }) + }) + + it('getMinionNodeIds is best-effort — no minions or a failure yields an empty map', async () => { + expect(await getMinionNodeIds([])).toEqual({}) + vi.mocked(v2.get).mockRejectedValue(http(500)) + expect(await getMinionNodeIds([minion('m1')])).toEqual({}) + }) + + it('updateMinion reads the current row and changes only label/location/properties', async () => { + // fresh server row has a NEWER status than any client snapshot + vi.mocked(v2.get).mockResolvedValue({ data: { id: 'm1', label: 'old', location: 'Default', type: 'Minion', status: 'DOWN', version: '2.0', date: 999, properties: {} } }) + vi.mocked(v2.put).mockResolvedValue({}) + + await updateMinion({ id: 'm1', label: 'new label', location: 'RemoteA', properties: { k: 'v' } }) + + const [, body] = vi.mocked(v2.put).mock.calls[0] + expect(body).toMatchObject({ + id: 'm1', label: 'new label', location: 'RemoteA', properties: { k: 'v' }, + status: 'DOWN', version: '2.0', date: 999 // server-maintained fields from the FRESH read, not clobbered + }) + }) + + it('deleteMinion treats a 404 (already deleted) as success', async () => { + vi.mocked(v2.delete).mockRejectedValue(http(404)) + expect(await deleteMinion('gone')).toBeNull() + }) + + it('deleteMinion returns the error message on a real failure', async () => { + vi.mocked(v2.delete).mockRejectedValue(http(500)) + expect(await deleteMinion('m1')).toBeTruthy() + }) +}) diff --git a/ui/tests/stores/minionAdminStore.test.ts b/ui/tests/stores/minionAdminStore.test.ts new file mode 100644 index 000000000000..8f03b204f491 --- /dev/null +++ b/ui/tests/stores/minionAdminStore.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useMinionAdminStore } from '@/stores/minionAdminStore' +import API from '@/services' +import { Minion } from '@/types/minionAdmin' + +vi.mock('@/services', () => ({ + default: { listMinions: vi.fn(), updateMinion: vi.fn(), deleteMinion: vi.fn(), getMinionNodeIds: vi.fn() } +})) + +const minion = (id: string, location = 'Default'): Minion => ({ id, label: id, location, type: 'Minion', status: 'UP', version: '1.0', properties: {} }) +const listResult = (minions: Minion[], totalCount = minions.length) => ({ minions, totalCount }) + +describe('useMinionAdminStore', () => { + let store: ReturnType + beforeEach(() => { + setActivePinia(createPinia()) + store = useMinionAdminStore() + vi.clearAllMocks() + vi.mocked(API.getMinionNodeIds).mockResolvedValue({}) + }) + + it('starts empty', () => { + expect(store.minions).toEqual([]) + }) + + it('getMinions loads on success and preserves on failure', async () => { + vi.mocked(API.listMinions).mockResolvedValue(listResult([minion('m1')])) + await store.getMinions() + expect(store.minions).toEqual([minion('m1')]) + vi.mocked(API.listMinions).mockResolvedValue(null) + await store.getMinions() + expect(store.minions).toEqual([minion('m1')]) + expect(store.loadError).toBe(true) + }) + + it('flags truncation when the server had more rows than fetched', async () => { + vi.mocked(API.listMinions).mockResolvedValue(listResult([minion('m1')], 9)) + await store.getMinions() + expect(store.truncated).toBe(true) + }) + + it('maps a minion to its node id by id+location for the ID link', async () => { + vi.mocked(API.listMinions).mockResolvedValue(listResult([minion('m1', 'RemoteA')])) + vi.mocked(API.getMinionNodeIds).mockResolvedValue({ 'm1\u0000RemoteA': 42 }) + await store.getMinions() + expect(store.nodeIdFor(minion('m1', 'RemoteA'))).toBe(42) + expect(store.nodeIdFor(minion('m1', 'Default'))).toBeUndefined() + }) + + it('updateMinion refreshes on success, not on failure', async () => { + const edit = { id: 'm1', label: 'm1', location: 'Default', properties: {} } + vi.mocked(API.updateMinion).mockResolvedValue(null) + vi.mocked(API.listMinions).mockResolvedValue(listResult([minion('m1')])) + expect(await store.updateMinion(edit)).toBeNull() + expect(API.listMinions).toHaveBeenCalledTimes(1) + vi.clearAllMocks() + vi.mocked(API.updateMinion).mockResolvedValue('boom') + expect(await store.updateMinion(edit)).toBe('boom') + expect(API.listMinions).not.toHaveBeenCalled() + }) + + it('deleteMinion refreshes on success', async () => { + vi.mocked(API.deleteMinion).mockResolvedValue(null) + vi.mocked(API.listMinions).mockResolvedValue(listResult([])) + await store.deleteMinion('m1') + expect(store.minions).toEqual([]) + }) +}) From 82081780cff88e90405953f3d0b852827d6abe49 Mon Sep 17 00:00:00 2001 From: Jose Anes Date: Tue, 4 Aug 2026 08:54:58 -0400 Subject: [PATCH 2/5] NMS-20128: use @opennms/onms-ui wrappers on the Manage Minions page Swap direct PrimeVue for the Onms-XXX seam wrappers across the minions table and editor dialog: Button->OnmsButton (text/outlined mapped to variant), Dialog->OnmsDialog, InputText->OnmsInputText, DataTable->OnmsTable, Column->OnmsColumn, Tag->OnmsTag, and the IconField/InputIcon/InputText search box collapses into OnmsSearchInput. Message and IftaLabel have no wrapper yet and stay on PrimeVue. No behaviour change. --- .../ManageMinions/MinionEditorDialog.vue | 32 ++++---- .../components/ManageMinions/MinionsTable.vue | 73 +++++++++---------- 2 files changed, 50 insertions(+), 55 deletions(-) diff --git a/ui/src/components/ManageMinions/MinionEditorDialog.vue b/ui/src/components/ManageMinions/MinionEditorDialog.vue index 41b66a488f28..a526663b21ba 100644 --- a/ui/src/components/ManageMinions/MinionEditorDialog.vue +++ b/ui/src/components/ManageMinions/MinionEditorDialog.vue @@ -1,5 +1,5 @@