Wazuh agent frontend (#512)
* Add Wazuh groups functionality with API integration and UI components * Refactor update Wazuh group configuration endpoint to accept raw XML content and improve parameter handling
taylor_socfortress committed
Sep 12, 2025 at 15:50 UTC
dd77abc9dd7084f6af75d164a128a4b5dd836170
7 files changed
+488
-9
backend/app/connectors/wazuh_manager/routes/groups.py
+10
-9
@@ -2,15 +2,12 @@ from typing import List
2
from typing import Optional
3
4
from fastapi import APIRouter
5
-from fastapi import Body
5
from fastapi import Path
6
from fastapi import Query
7
+from fastapi import Request
8
from fastapi import Security
9
10
from app.auth.routes.auth import AuthHandler
11
-from app.connectors.wazuh_manager.schema.groups import (
12
- WazuhGroupConfigurationUpdateRequest,
13
-)
11
from app.connectors.wazuh_manager.schema.groups import (
12
WazuhGroupConfigurationUpdateResponse,
13
)
@@ -181,8 +178,8 @@ async def get_wazuh_group_file_endpoint(
178
dependencies=[Security(AuthHandler().get_current_user, scopes=["admin"])],
179
)
180
async def update_wazuh_group_configuration_endpoint(
181
+ request: Request,
182
group_id: str = Path(..., description="Group ID (name of the group)"),
185
- request: WazuhGroupConfigurationUpdateRequest = Body(..., description="Configuration update request"),
183
pretty: Optional[bool] = Query(False, description="Show results in human-readable format"),
184
wait_for_complete: Optional[bool] = Query(False, description="Disable timeout response"),
185
) -> WazuhGroupConfigurationUpdateResponse:
@@ -194,12 +191,12 @@ async def update_wazuh_group_configuration_endpoint(
191
192
Parameters:
193
- group_id: The ID (name) of the group to update (required)
197
- - request: The configuration update request containing XML content (required)
194
+ - request: Raw XML configuration content in the request body
195
- pretty: Format results for human readability
196
- wait_for_complete: Disable request timeout
197
198
Request Body:
202
- - configuration: Full valid XML configuration content
199
+ - Raw XML configuration content (Content-Type: application/xml)
200
201
Returns:
202
- WazuhGroupConfigurationUpdateResponse: Confirmation of successful update
@@ -209,6 +206,10 @@ async def update_wazuh_group_configuration_endpoint(
206
- 404: If the specified group is not found
207
- 500: If there's an error updating the configuration
208
"""
209
+ # Read the raw XML content from the request body
210
+ configuration_content = await request.body()
211
+ configuration_xml = configuration_content.decode("utf-8")
212
+
213
# Use locals() to capture all parameters, excluding path and body parameters
213
- params = {k: v for k, v in locals().items() if k not in ["group_id", "request"]}
214
- return await update_wazuh_group_configuration(group_id, request.configuration, **params)
214
+ params = {k: v for k, v in locals().items() if k not in ["group_id", "request", "configuration_content", "configuration_xml"]}
215
+ return await update_wazuh_group_configuration(group_id, configuration_xml, **params)
frontend/src/api/endpoints/wazuh/groups.ts
new
+110
@@ -0,0 +1,110 @@
1
+import type { FlaskBaseResponse } from "@/types/flask.d"
2
+import type { WazuhGroup, WazuhGroupConfigurationUpdate, WazuhGroupFile, WazuhGroupFileDetails } from "@/types/wazuh/groups.d"
3
+import { HttpClient } from "../../httpClient"
4
+
5
+// Interface for groups query parameters
6
+export interface GroupsQueryParams {
7
+ /** Show results in human-readable format */
8
+ pretty?: boolean
9
+ /** Disable timeout response */
10
+ wait_for_complete?: boolean
11
+ /** List of group IDs (separated by comma) */
12
+ groups_list?: string[]
13
+ /** First element to return in the collection */
14
+ offset?: number
15
+ /** Maximum number of elements to return */
16
+ limit?: number
17
+ /** Sort the collection by a field or fields */
18
+ sort?: string | null
19
+ /** Look for elements containing the specified string */
20
+ search?: string | null
21
+ /** Select algorithm to generate the returned checksums */
22
+ hash?: string | null
23
+ /** Query to filter results by */
24
+ q?: string | null
25
+ /** Select which fields to return */
26
+ select?: string[] | null
27
+ /** Look for distinct values */
28
+ distinct?: boolean
29
+}
30
+
31
+// Interface for group files query parameters
32
+export interface GroupFilesQueryParams {
33
+ /** Show results in human-readable format */
34
+ pretty?: boolean
35
+ /** Disable timeout response */
36
+ wait_for_complete?: boolean
37
+ /** First element to return in the collection */
38
+ offset?: number
39
+ /** Maximum number of elements to return */
40
+ limit?: number
41
+ /** Sort the collection by a field or fields */
42
+ sort?: string | null
43
+ /** Look for elements containing the specified string */
44
+ search?: string | null
45
+ /** Select algorithm to generate the returned checksums */
46
+ hash?: string | null
47
+ /** Query to filter results by */
48
+ q?: string | null
49
+ /** Select which fields to return */
50
+ select?: string[] | null
51
+ /** Look for distinct values */
52
+ distinct?: boolean
53
+}
54
+
55
+// Interface for group file query parameters
56
+export interface GroupFileQueryParams {
57
+ /** Show results in human-readable format */
58
+ pretty?: boolean
59
+ /** Disable timeout response */
60
+ wait_for_complete?: boolean
61
+ /** Type of file */
62
+ type?: string[] | null
63
+ /** Format response in plain text */
64
+ raw?: boolean
65
+}
66
+
67
+export default {
68
+ getGroups(query: GroupsQueryParams, signal?: AbortSignal) {
69
+ return HttpClient.get<FlaskBaseResponse & { results: WazuhGroup[]; total_items: number }>(
70
+ `/wazuh_manager/groups`,
71
+ {
72
+ params: query,
73
+ signal
74
+ }
75
+ )
76
+ },
77
+ getGroupFiles(groupId: string, query: GroupFilesQueryParams, signal?: AbortSignal) {
78
+ return HttpClient.get<FlaskBaseResponse & { results: WazuhGroupFile[]; total_items: number }>(
79
+ `/wazuh_manager/groups/${groupId}/files`,
80
+ {
81
+ params: query,
82
+ signal
83
+ }
84
+ )
85
+ },
86
+ getGroupFile(groupId: string, filename: string, query?: GroupFileQueryParams) {
87
+ return HttpClient.get<FlaskBaseResponse & WazuhGroupFileDetails>(
88
+ `/wazuh_manager/groups/${groupId}/files/${filename}`,
89
+ {
90
+ params: {
91
+ raw: true,
92
+ pretty: false,
93
+ wait_for_complete: false,
94
+ ...query
95
+ }
96
+ }
97
+ )
98
+ },
99
+ updateGroupConfiguration(groupId: string, configContent: string) {
100
+ return HttpClient.put<FlaskBaseResponse & WazuhGroupConfigurationUpdate>(
101
+ `/wazuh_manager/groups/${groupId}/configuration`,
102
+ configContent,
103
+ {
104
+ headers: {
105
+ 'Content-Type': 'application/xml'
106
+ }
107
+ }
108
+ )
109
+ }
110
+}
frontend/src/api/endpoints/wazuh/index.ts
+2
@@ -1,8 +1,10 @@
1
+import groups from "./groups"
2
import indices from "./indices"
3
import mitre from "./mitre"
4
import rules from "./rules"
5
6
export default {
7
+ groups,
8
mitre,
9
rules,
10
indices
frontend/src/app-layouts/common/Navbar/items.tsx
+13
@@ -67,6 +67,19 @@ export default function getItems(): MenuMixedOption[] {
67
),
68
key: "Agents"
69
},
70
+ {
71
+ label: () =>
72
+ h(
73
+ RouterLink,
74
+ {
75
+ to: {
76
+ name: "Groups"
77
+ }
78
+ },
79
+ { default: () => "Groups" }
80
+ ),
81
+ key: "Groups"
82
+ },
83
{
84
label: () =>
85
h(
frontend/src/router/index.ts
+6
@@ -62,6 +62,12 @@ const router = createRouter({
62
component: () => import("@/views/agents/DetectionRules.vue"),
63
meta: { title: "Detection Rules" }
64
},
65
+ {
66
+ path: "groups",
67
+ name: "Groups",
68
+ component: () => import("@/views/agents/Groups.vue"),
69
+ meta: { title: "Groups" }
70
+ },
71
{
72
path: "copilot-actions",
73
name: "CopilotActions",
frontend/src/types/wazuh/groups.d.ts
new
+25
@@ -0,0 +1,25 @@
1
+export interface WazuhGroup {
2
+ name: string
3
+ count: number
4
+ mergedSum: string
5
+ configSum: string
6
+}
7
+
8
+export interface WazuhGroupFile {
9
+ filename: string
10
+ hash: string
11
+}
12
+
13
+export interface WazuhGroupFileDetails {
14
+ group_id: string
15
+ filename: string
16
+ content: string
17
+ is_raw: boolean
18
+}
19
+
20
+export interface WazuhGroupConfigurationUpdate {
21
+ success: boolean
22
+ message: string
23
+ group_id: string
24
+ filename: string
25
+}
frontend/src/views/agents/Groups.vue
new
+322
@@ -0,0 +1,322 @@
1
+<template>
2
+ <div class="page page-wrapped page-mobile-full page-without-footer flex flex-col">
3
+ <SegmentedPage
4
+ main-content-class="!p-0 overflow-hidden grow flex flex-col h-full"
5
+ :use-main-scroll="false"
6
+ padding="18px"
7
+ enable-resize
8
+ toolbar-height="54px"
9
+ sidebar-content-class="p-0!"
10
+ >
11
+ <template #sidebar-header>
12
+ <div class="flex w-full items-center justify-between gap-3">
13
+ <n-input
14
+ v-model:value="filters.search"
15
+ size="small"
16
+ class="max-w-full grow"
17
+ clearable
18
+ placeholder="Search groups..."
19
+ >
20
+ <template #prefix>
21
+ <Icon :name="SearchIcon" :size="16" />
22
+ </template>
23
+ </n-input>
24
+
25
+ <n-tooltip>
26
+ <template #trigger>
27
+ <n-button secondary :loading="loadingRefresh" size="small" @click="refreshGroups()">
28
+ <template #icon>
29
+ <Icon :name="RefreshIcon"></Icon>
30
+ </template>
31
+ </n-button>
32
+ </template>
33
+ <div>Refresh Groups</div>
34
+ </n-tooltip>
35
+ </div>
36
+ </template>
37
+ <template #sidebar-content>
38
+ <n-spin :show="loadingGroups">
39
+ <template v-if="groupsList.length">
40
+ <div class="divide-border divide-y-1 flex flex-col">
41
+ <div
42
+ v-for="group of groupsList"
43
+ :key="group.name"
44
+ class="hover:text-warning cursor-pointer break-all px-4.5 py-2.5 text-sm"
45
+ :class="{ 'bg-warning/10': group.name === currentGroup?.name }"
46
+ @click.stop="loadGroup(group)"
47
+ >
48
+ <div class="font-mono">{{ group.name }}</div>
49
+ <div class="text-secondary text-xs">{{ group.count }} agents</div>
50
+ </div>
51
+ </div>
52
+ </template>
53
+ <template v-else>
54
+ <n-empty v-if="!loadingGroups" description="No groups found" class="h-48 justify-center" />
55
+ </template>
56
+ </n-spin>
57
+ </template>
58
+ <template v-if="pagination.total" #sidebar-footer>
59
+ <div class="flex w-full items-center justify-center">
60
+ <n-pagination
61
+ v-model:page="pagination.current"
62
+ :page-size="pagination.size"
63
+ :page-slot="5"
64
+ :item-count="pagination.total"
65
+ simple
66
+ />
67
+ </div>
68
+ </template>
69
+ <template v-if="currentGroup && currentFile" #main-toolbar>
70
+ <div class="@container flex items-center justify-between">
71
+ <div class="flex items-center gap-2 md:gap-3">
72
+ <n-button
73
+ v-if="xmlEditorCTX"
74
+ size="small"
75
+ :disabled="!xmlEditorCTX.canUndo()"
76
+ @click="xmlEditorCTX.undo"
77
+ >
78
+ <div class="flex items-center gap-2">
79
+ <Icon :name="UndoIcon" />
80
+ <span class="@sm:flex hidden">Undo</span>
81
+ </div>
82
+ </n-button>
83
+ <n-button
84
+ v-if="xmlEditorCTX"
85
+ size="small"
86
+ :disabled="!xmlEditorCTX.canRedo()"
87
+ @click="xmlEditorCTX.redo"
88
+ >
89
+ <div class="flex items-center gap-2">
90
+ <span class="@sm:flex hidden">Redo</span>
91
+ <Icon :name="RedoIcon" />
92
+ </div>
93
+ </n-button>
94
+ </div>
95
+ <div class="flex items-center gap-2 md:gap-3">
96
+ <n-popover v-if="xmlErrors.length && xmlEditorCTX" class="p-1!">
97
+ <template #trigger>
98
+ <div class="flex items-center justify-end gap-2">
99
+ <Icon
100
+ name="carbon:warning-alt"
101
+ :size="20"
102
+ class="text-warning animate-fade cursor-help"
103
+ />
104
+ <span class="text-warning @lg:flex hidden font-mono text-xs">Errors detected</span>
105
+ </div>
106
+ </template>
107
+
108
+ <n-scrollbar class="max-h-100">
109
+ <div class="flex max-w-80 flex-col gap-1">
110
+ <div
111
+ v-for="item of xmlErrors"
112
+ :key="JSON.stringify(item)"
113
+ class="bg-secondary hover:bg-body flex cursor-pointer flex-col gap-0.5 rounded-sm p-1 font-mono"
114
+ @click="xmlEditorCTX.scrollToLine(item.line)"
115
+ >
116
+ <div class="text-secondary text-[8px]">line: {{ item.line }}</div>
117
+ <div class="text-xs">{{ item.message }}</div>
118
+ </div>
119
+ </div>
120
+ </n-scrollbar>
121
+ </n-popover>
122
+
123
+ <n-button
124
+ :loading="uploadingConfig"
125
+ size="small"
126
+ type="primary"
127
+ :disabled="!isDirty"
128
+ @click="updateGroupConfiguration()"
129
+ >
130
+ <div class="flex items-center gap-2">
131
+ <Icon :name="UploadIcon" />
132
+ <span class="@xs:flex hidden">Update</span>
133
+ </div>
134
+ </n-button>
135
+ </div>
136
+ </div>
137
+ </template>
138
+ <template #main-content>
139
+ <div v-if="currentGroup && currentFile" class="px-4.5 break-all py-2.5 text-sm">
140
+ <div class="font-mono">
141
+ Group: {{ currentGroup?.name }} | File: {{ currentFile?.filename }}
142
+ </div>
143
+ </div>
144
+ <n-spin
145
+ :show="loadingFile || uploadingConfig"
146
+ class="flex h-full w-full overflow-hidden"
147
+ content-class="flex h-full grow flex-col justify-center overflow-hidden"
148
+ >
149
+ <template v-if="currentGroup && currentFile">
150
+ <XMLEditor
151
+ v-model="currentFile.content"
152
+ class="scrollbar-styled text-sm"
153
+ @errors="xmlErrors = $event"
154
+ @mounted="xmlEditorCTX = $event"
155
+ />
156
+ </template>
157
+ <template v-else>
158
+ <n-empty v-if="!loadingFile" description="Select a group" class="h-48 justify-center" />
159
+ </template>
160
+ </n-spin>
161
+ </template>
162
+ </SegmentedPage>
163
+ </div>
164
+</template>
165
+
166
+<script setup lang="ts">
167
+import type { XMLEditorCtx, XMLError } from "@/components/common/XMLEditor.vue"
168
+import type { WazuhGroup, WazuhGroupFileDetails } from "@/types/wazuh/groups.d"
169
+import { watchDebounced } from "@vueuse/core"
170
+import axios from "axios"
171
+import _clone from "lodash/cloneDeep"
172
+import { NButton, NEmpty, NInput, NPagination, NPopover, NScrollbar, NSpin, NTooltip, useMessage } from "naive-ui"
173
+import { computed, ref, watch } from "vue"
174
+import Api from "@/api"
175
+import Icon from "@/components/common/Icon.vue"
176
+import SegmentedPage from "@/components/common/SegmentedPage.vue"
177
+import XMLEditor from "@/components/common/XMLEditor.vue"
178
+
179
+const message = useMessage()
180
+const loadingRefresh = ref(false)
181
+const loadingGroups = ref(false)
182
+const loadingFile = ref(false)
183
+const uploadingConfig = ref(false)
184
+const groupsList = ref<WazuhGroup[]>([])
185
+const currentGroup = ref<WazuhGroup | null>(null)
186
+const currentFile = ref<WazuhGroupFileDetails | null>(null)
187
+const backupFile = ref<WazuhGroupFileDetails | null>(null)
188
+const xmlEditorCTX = ref<XMLEditorCtx | null>(null)
189
+const UndoIcon = "carbon:undo"
190
+const RedoIcon = "carbon:redo"
191
+const SearchIcon = "ion:search-outline"
192
+const RefreshIcon = "carbon:renew"
193
+const UploadIcon = "carbon:cloud-upload"
194
+
195
+const filters = ref({
196
+ search: null
197
+})
198
+const pagination = ref({
199
+ current: 1,
200
+ size: 30,
201
+ total: 0
202
+})
203
+
204
+const isDirty = computed(() => currentFile.value?.content !== backupFile.value?.content)
205
+const xmlErrors = ref<XMLError[]>([])
206
+
207
+let abortController: AbortController | null = null
208
+
209
+function loadGroup(group: WazuhGroup) {
210
+ if (group.name !== currentGroup.value?.name) {
211
+ currentGroup.value = group
212
+ // Auto-load agent.conf file when a group is selected
213
+ loadGroupFile(group.name, "agent.conf")
214
+ }
215
+}
216
+
217
+function refreshGroups() {
218
+ abortController?.abort()
219
+ loadingRefresh.value = true
220
+ getGroups().finally(() => {
221
+ loadingRefresh.value = false
222
+ })
223
+}
224
+
225
+function getGroups() {
226
+ abortController?.abort()
227
+ abortController = new AbortController()
228
+
229
+ loadingGroups.value = true
230
+
231
+ return Api.wazuh.groups
232
+ .getGroups(
233
+ {
234
+ search: filters.value.search || undefined,
235
+ pretty: false,
236
+ wait_for_complete: false,
237
+ distinct: false,
238
+ offset: (pagination.value.current - 1) * pagination.value.size,
239
+ limit: pagination.value.size
240
+ },
241
+ abortController.signal
242
+ )
243
+ .then(res => {
244
+ if (res.data.success) {
245
+ groupsList.value = res.data.results || []
246
+ pagination.value.total = res.data.total_items
247
+ } else {
248
+ pagination.value.total = 0
249
+ message.error(res.data?.message || "An error occurred. Please try again later.")
250
+ }
251
+ loadingGroups.value = false
252
+ })
253
+ .catch(err => {
254
+ if (!axios.isCancel(err)) {
255
+ groupsList.value = []
256
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
257
+ loadingGroups.value = false
258
+ }
259
+ })
260
+}
261
+
262
+function loadGroupFile(groupId: string, filename: string) {
263
+ loadingFile.value = true
264
+
265
+ Api.wazuh.groups
266
+ .getGroupFile(groupId, filename)
267
+ .then(res => {
268
+ if (res.data.success) {
269
+ currentFile.value = _clone(res.data)
270
+ backupFile.value = _clone(res.data)
271
+ } else {
272
+ message.error(res.data?.message || "An error occurred. Please try again later.")
273
+ }
274
+ })
275
+ .catch(err => {
276
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
277
+ })
278
+ .finally(() => {
279
+ loadingFile.value = false
280
+ })
281
+}
282
+
283
+function updateGroupConfiguration() {
284
+ if (currentGroup.value && currentFile.value) {
285
+ uploadingConfig.value = true
286
+
287
+ Api.wazuh.groups
288
+ .updateGroupConfiguration(currentGroup.value.name, currentFile.value.content)
289
+ .then(res => {
290
+ if (res.data.success) {
291
+ currentFile.value = _clone(currentFile.value)
292
+ backupFile.value = _clone(currentFile.value)
293
+ message.success("Group configuration updated successfully")
294
+ } else {
295
+ message.error("An error occurred. Please try again later.")
296
+ }
297
+ })
298
+ .catch(err => {
299
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
300
+ })
301
+ .finally(() => {
302
+ uploadingConfig.value = false
303
+ })
304
+ }
305
+}
306
+
307
+watch(
308
+ [filters],
309
+ () => {
310
+ pagination.value.current = 1
311
+ },
312
+ { deep: true }
313
+)
314
+
315
+watchDebounced(
316
+ [filters, () => pagination.value.current],
317
+ () => {
318
+ getGroups()
319
+ },
320
+ { debounce: 250, immediate: true, deep: true }
321
+)
322
+</script>