Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')]")));
Expand Down
226 changes: 226 additions & 0 deletions ui/src/components/ManageMinions/MinionEditorDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
<template>
<OnmsDialog
:visible="visible"
modal
:header="`Edit Minion: ${minion?.id ?? ''}`"
class="minion-editor-dialog"
width="min(620px, 95vw)"
data-test="minion-editor-dialog"
@update:visible="(value: boolean) => emit('update:visible', value)"
>
<div class="form-column">
<div v-if="errorText" class="dialog-error" role="alert" data-test="dialog-error">{{ errorText }}</div>

<FormField label="Label" for="minion-label">
<OnmsInputText id="minion-label" v-model="label" fluid data-test="label-input" />
</FormField>

<FormField label="Location" for="minion-location" required>
<OnmsInputText id="minion-location" v-model="location" :invalid="!!locationProblem" fluid data-test="location-input" />
<small v-if="locationProblem" class="field-error" data-test="location-error">{{ locationProblem }}</small>
<small v-else class="hint">The monitoring location this minion belongs to (required).</small>
</FormField>

<div class="props-section">
<div class="props-header">
<span class="props-title">Properties</span>
<OnmsButton
variant="outlined"
size="small"
icon="pi pi-plus"
label="Add"
aria-label="Add property"
data-test="add-property-button"
@click="addProperty"
/>
</div>
<ul v-if="properties.length" class="props-list" data-test="property-list">
<li v-for="(prop, index) in properties" :key="index" class="prop-row">
<OnmsInputText v-model="prop.key" placeholder="Key" :data-test="`prop-key-${index}`" class="prop-key" />
<OnmsInputText v-model="prop.value" placeholder="Value" :data-test="`prop-value-${index}`" class="prop-value" />
<OnmsIconButton
severity="danger"
:icon="Cancel"
:aria-label="`Remove property ${prop.key}`"
:data-test="`remove-prop-${index}`"
@click="properties.splice(index, 1)"
/>
</li>
</ul>
<p v-else class="no-props">No properties.</p>
<small v-if="dupKeyProblem" class="field-error" data-test="prop-error">{{ dupKeyProblem }}</small>
</div>
</div>

<template #footer>
<OnmsButton variant="text" label="Cancel" data-test="cancel-button" @click="emit('update:visible', false)" />
<OnmsButton label="Save Minion" :disabled="!!dupKeyProblem || !!locationProblem || saving" data-test="save-button" @click="save" />
</template>
</OnmsDialog>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue'

import { OnmsButton, OnmsDialog, OnmsIconButton, OnmsInputText } from '@opennms/onms-ui'

import FormField from '@/components/Common/FormField.vue'
import Cancel from '@opennms/onms-ui/icons/navigation/Cancel.vue'
import { useMinionAdminStore } from '@/stores/minionAdminStore'
import { Minion } from '@/types/minionAdmin'
import { MinionEdit } from '@/services/minionAdminService'

const props = defineProps<{
visible: boolean
minion: Minion | null
}>()

const emit = defineEmits(['update:visible'])

const store = useMinionAdminStore()

const label = ref('')
const location = ref('')
const properties = ref<{ key: string; value: string }[]>([])
const saving = ref(false)
const errorText = ref('')

const locationProblem = computed(() => (location.value.trim() ? null : 'A location is required.'))

const dupKeyProblem = computed(() => {
const keys = properties.value.map(p => p.key.trim()).filter(Boolean)
if (new Set(keys).size !== keys.length) {
return 'Property keys must be unique.'
}
if (properties.value.some(p => !p.key.trim() && p.value.trim())) {
return 'A property value needs a key.'
}
return null
})

watch(
() => props.visible,
(isVisible) => {
if (!isVisible) {
return
}
errorText.value = ''
label.value = props.minion?.label ?? ''
location.value = props.minion?.location ?? ''
properties.value = Object.entries(props.minion?.properties ?? {}).map(([key, value]) => ({ key, value: String(value) }))
}
)

const addProperty = () => {
properties.value.push({ key: '', value: '' })
}

const save = async () => {
if (!props.minion || dupKeyProblem.value || locationProblem.value) {
return
}
saving.value = true
try {
const propertyMap: Record<string, string> = {}
for (const { key, value } of properties.value) {
// preserve keys verbatim (do not trim) so a key the admin never touched
// is not silently renamed; drop only fully-empty rows
if (key || value) {
if (key) {
propertyMap[key] = value
}
}
}
// the service reads the current server row and changes only these three
// fields, so server-maintained status/version/date are never clobbered
const edit: MinionEdit = {
id: props.minion.id,
label: label.value.trim() || null,
location: location.value.trim(),
properties: propertyMap
}
const error = await store.updateMinion(edit)
if (error === null) {
emit('update:visible', false)
} else {
errorText.value = error
}
} finally {
saving.value = false
}
}
</script>

<style lang="scss" scoped>
.form-column {
display: flex;
flex-direction: column;
gap: 1rem;
padding-top: 0.5rem;

:deep(input) {
width: 100%;
}
}

.props-section {
.props-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;

.props-title {
font-weight: 600;
}
}

.props-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;

.prop-row {
display: flex;
gap: 0.5rem;
align-items: center;

.prop-key {
flex: 0 0 40%;
}
.prop-value {
flex: 1;
}
}
}

.no-props {
margin: 0;
color: var(--p-text-muted-color);
}
}

.dialog-error {
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--p-red-200, #fecaca);
background: var(--p-red-50, #fef2f2);
color: var(--p-red-700, #b91c1c);
font-size: 0.9rem;
}

.field-error {
display: block;
margin-top: 0.5rem;
color: var(--p-red-500, #e24c4c);
}

.hint {
display: block;
margin-top: 0.25rem;
color: var(--p-text-muted-color);
}
</style>
83 changes: 83 additions & 0 deletions ui/src/components/ManageMinions/MinionsHelpPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<template>
<TogglePanel
:collapsed="collapsed"
class="minions-help-panel"
data-test="minions-help-panel"
@update:collapsed="(value: boolean) => (collapsed = value)"
>
<template #header>
<span class="panel-header">
<i class="pi pi-question-circle" aria-hidden="true" />
About Minions
</span>
</template>
<div class="help-columns">
<div class="help-section">
<div class="section-title">What Minions are</div>
<p>
A Minion is a lightweight remote process that performs polling, data
collection and flow/trap reception on behalf of the main OpenNMS
system from a monitoring location. Each Minion registers itself and
reports its <strong>status</strong>, <strong>version</strong> and last
check-in time; those, along with its type, are maintained by the
system and shown here read-only.
</p>
</div>
<div class="help-section">
<div class="section-title">How to use this page</div>
<p>
<strong>Edit</strong> changes a Minion's label, its
<strong>location</strong>, and its key/value <strong>properties</strong>.
<strong>Delete</strong> removes a Minion and its auto-created
requisition node; a Minion whose process is still running will
re-register on its next check-in. The same operations are available to
tooling through the <code>/api/v2/minions</code> REST API.
</p>
</div>
</div>
</TogglePanel>
</template>

<script setup lang="ts">
import { ref } from 'vue'

import TogglePanel from '@/components/Common/TogglePanel.vue'

const collapsed = ref(true)
</script>

<style lang="scss" scoped>
.panel-header {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;

.pi-question-circle {
color: var(--p-primary-color);
}
}

.help-columns {
display: flex;
gap: 2.5rem;
flex-wrap: wrap;

.help-section {
flex: 1;
min-width: 320px;
}
}

.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}

p {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
line-height: 1.5;
}
</style>
Loading
Loading