diff --git a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-alt.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-alt.json index 6b3630890695..95ec6acae136 100644 --- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-alt.json +++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-alt.json @@ -317,9 +317,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/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json index f2ded6f7779c..29ae84cb4645 100644 --- a/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json +++ b/opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.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/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 13e902167306..7c3cc7873211 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 2f138fca1806..ab4d93fde4d8 100644 --- a/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java +++ b/smoke-test/src/test/java/org/opennms/smoketest/MenuHeaderIT.java @@ -136,7 +136,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..316759289401 --- /dev/null +++ b/ui/src/components/ManageMinions/MinionEditorDialog.vue @@ -0,0 +1,226 @@ + + + + + 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..c5f16f591002 --- /dev/null +++ b/ui/src/components/ManageMinions/MinionsTable.vue @@ -0,0 +1,237 @@ + + + + + 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 e73a8df6caf0..99d4b74d69b0 100644 --- a/ui/src/main/router/index.ts +++ b/ui/src/main/router/index.ts @@ -126,6 +126,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 081e47de0cec..27d1a8fd2017 100644 --- a/ui/src/services/index.ts +++ b/ui/src/services/index.ts @@ -75,6 +75,7 @@ import { setUsageStatisticsStatus } from './usageStatisticsService' import { addZenithRegistration, getZenithRegistrations } from './zenithConnectService' +import { deleteMinion, getMinionNodeIds, listMinions, updateMinion } from './minionAdminService' import { getSystemReportPlugins, getSystemReportFormatters, generateSystemReport } from './systemReportService' import { createManagedGroup, @@ -86,6 +87,10 @@ import { } from './groupAdminService' export default { + getMinionNodeIds, + listMinions, + updateMinion, + deleteMinion, search, getSystemReportPlugins, getSystemReportFormatters, diff --git a/ui/src/services/minionAdminService.ts b/ui/src/services/minionAdminService.ts new file mode 100644 index 000000000000..e06a3e6062f5 --- /dev/null +++ b/ui/src/services/minionAdminService.ts @@ -0,0 +1,153 @@ +/// +/// 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 { Minion } from '@/types/minionAdmin' +import { v2 } from './axiosInstances' + +// PrimeVue Manage Minions (NMS-20128). Reuses the existing v2 +// AbstractDaoRestService CRUD at /api/v2/minions — no backend change; the v1 +// REST and the JSON contract are untouched. + +const { showSnackBar } = useSnackbar() +const { startSpinner, stopSpinner } = useSpinner() +const endpoint = '/minions' + +// 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 +} + +// null on failure (not []) so callers can keep showing the previous list +// Bound the fetch rather than limit=0 (unbounded); minions are few in practice. +const LIST_CAP = 2000 + +const listMinions = async (): Promise<{ minions: Minion[]; totalCount: number } | null> => { + try { + startSpinner() + const resp = await v2.get(`${endpoint}?limit=${LIST_CAP}&orderBy=label`) + if (resp.status === 204) { + return { minions: [], totalCount: 0 } + } + const raw = resp.data?.minion ?? [] + const minions = Array.isArray(raw) ? raw : [raw] + return { minions, totalCount: resp.data?.totalCount ?? minions.length } + } catch (_err) { + showSnackBar({ msg: 'Failed to load minions.' }) + return null + } finally { + stopSpinner() + } +} + +// Each minion auto-registers a requisition node whose foreignId is the minion id. +// Resolve them in one query and key by id+location (ids repeat across locations), +// mirroring the legacy page so a minion's ID can link to its node. Best-effort: +// the link is a convenience, so a failure just yields no links. +const minionNodeKey = (id: string, location: string | null) => `${id}\u0000${location ?? ''}` + +const getMinionNodeIds = async (minions: Minion[]): Promise> => { + if (!minions.length) { + return {} + } + try { + const fiql = '(' + minions.map(m => `foreignId==${m.id}`).join(',') + ')' + const resp = await v2.get(`/nodes?limit=${LIST_CAP}&_s=${encodeURIComponent(fiql)}`) + if (resp.status === 204) { + return {} + } + const raw = resp.data?.node ?? [] + const nodes = Array.isArray(raw) ? raw : [raw] + const map: Record = {} + for (const n of nodes) { + if (n.foreignId != null) { + map[minionNodeKey(String(n.foreignId), n.location ?? null)] = Number(n.id) + } + } + return map + } catch (_err) { + return {} + } +} + +export interface MinionEdit { + id: string + label: string | null + location: string + properties: Record +} + +// Read-before-write: the v2 PUT is a whole-object saveOrUpdate, so we must send +// the CURRENT server row with only label/location/properties changed — spreading +// a stale list snapshot would revert the server-maintained status/version/date. +const updateMinion = async (edit: MinionEdit): Promise => { + try { + startSpinner() + const current = await v2.get(`${endpoint}/${encodeURIComponent(edit.id)}`) + const fresh = (current.data ?? {}) as Minion + const payload: Minion = { + ...fresh, + label: edit.label, + location: edit.location, + properties: edit.properties + } + await v2.put(`${endpoint}/${encodeURIComponent(edit.id)}`, payload) + showSnackBar({ msg: `Minion '${edit.label ?? edit.id}' updated.` }) + return null + } catch (err: any) { + const msg = errorMessage(err, `Failed to update minion '${edit.label ?? edit.id}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +const deleteMinion = async (id: string): Promise => { + try { + startSpinner() + await v2.delete(`${endpoint}/${encodeURIComponent(id)}`) + showSnackBar({ msg: `Minion '${id}' deleted.` }) + return null + } catch (err: any) { + // already gone (another admin deleted it): treat as success so the row clears + if (err?.response?.status === 404) { + return null + } + const msg = errorMessage(err, `Failed to delete minion '${id}'.`) + showSnackBar({ msg, error: true }) + return msg + } finally { + stopSpinner() + } +} + +export { deleteMinion, getMinionNodeIds, listMinions, minionNodeKey, updateMinion } diff --git a/ui/src/stores/minionAdminStore.ts b/ui/src/stores/minionAdminStore.ts new file mode 100644 index 000000000000..3e138583d56f --- /dev/null +++ b/ui/src/stores/minionAdminStore.ts @@ -0,0 +1,75 @@ +/// +/// 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 { Minion } from '@/types/minionAdmin' +import { MinionEdit, minionNodeKey } from '@/services/minionAdminService' +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +export const useMinionAdminStore = defineStore('minionAdminStore', () => { + 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..b7ac41a523f7 --- /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..ccc785fd578e --- /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..b4c012307a6a --- /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..b2c95670732b --- /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..ae9c64bf63a4 --- /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([]) + }) +})