main
vue 320 lines 9.28 KB
Raw
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" />
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 flex flex-col divide-y">
41 <div
42 v-for="group of groupsList"
43 :key="group.name"
44 class="hover:text-warning cursor-pointer px-4.5 py-2.5 text-sm break-all"
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="hidden @sm:flex">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="hidden @sm:flex">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 hidden font-mono text-xs @lg:flex">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="hidden @xs:flex">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 py-2.5 text-sm break-all">
140 <div class="font-mono">Group: {{ currentGroup?.name }} | File: {{ currentFile?.filename }}</div>
141 </div>
142 <n-spin
143 :show="loadingFile || uploadingConfig"
144 class="flex h-full w-full overflow-hidden"
145 content-class="flex h-full grow flex-col justify-center overflow-hidden"
146 >
147 <template v-if="currentGroup && currentFile">
148 <XMLEditor
149 v-model="currentFile.content"
150 class="scrollbar-styled text-sm"
151 @errors="xmlErrors = $event"
152 @mounted="xmlEditorCTX = $event"
153 />
154 </template>
155 <template v-else>
156 <n-empty v-if="!loadingFile" description="Select a group" class="h-48 justify-center" />
157 </template>
158 </n-spin>
159 </template>
160 </SegmentedPage>
161 </div>
162 </template>
163
164 <script setup lang="ts">
165 import type { XMLEditorCtx, XMLError } from "@/components/common/XMLEditor.vue"
166 import type { WazuhGroup, WazuhGroupFileDetails } from "@/types/wazuh/groups.d"
167 import { watchDebounced } from "@vueuse/core"
168 import axios from "axios"
169 import _clone from "lodash/cloneDeep"
170 import { NButton, NEmpty, NInput, NPagination, NPopover, NScrollbar, NSpin, NTooltip, useMessage } from "naive-ui"
171 import { computed, ref, watch } from "vue"
172 import Api from "@/api"
173 import Icon from "@/components/common/Icon.vue"
174 import SegmentedPage from "@/components/common/SegmentedPage.vue"
175 import XMLEditor from "@/components/common/XMLEditor.vue"
176
177 const message = useMessage()
178 const loadingRefresh = ref(false)
179 const loadingGroups = ref(false)
180 const loadingFile = ref(false)
181 const uploadingConfig = ref(false)
182 const groupsList = ref<WazuhGroup[]>([])
183 const currentGroup = ref<WazuhGroup | null>(null)
184 const currentFile = ref<WazuhGroupFileDetails | null>(null)
185 const backupFile = ref<WazuhGroupFileDetails | null>(null)
186 const xmlEditorCTX = ref<XMLEditorCtx | null>(null)
187 const UndoIcon = "carbon:undo"
188 const RedoIcon = "carbon:redo"
189 const SearchIcon = "ion:search-outline"
190 const RefreshIcon = "carbon:renew"
191 const UploadIcon = "carbon:cloud-upload"
192
193 const filters = ref({
194 search: null
195 })
196 const pagination = ref({
197 current: 1,
198 size: 30,
199 total: 0
200 })
201
202 const isDirty = computed(() => currentFile.value?.content !== backupFile.value?.content)
203 const xmlErrors = ref<XMLError[]>([])
204
205 let abortController: AbortController | null = null
206
207 function loadGroup(group: WazuhGroup) {
208 if (group.name !== currentGroup.value?.name) {
209 currentGroup.value = group
210 // Auto-load agent.conf file when a group is selected
211 loadGroupFile(group.name, "agent.conf")
212 }
213 }
214
215 function refreshGroups() {
216 abortController?.abort()
217 loadingRefresh.value = true
218 getGroups().finally(() => {
219 loadingRefresh.value = false
220 })
221 }
222
223 function getGroups() {
224 abortController?.abort()
225 abortController = new AbortController()
226
227 loadingGroups.value = true
228
229 return Api.wazuh.groups
230 .getGroups(
231 {
232 search: filters.value.search || undefined,
233 pretty: false,
234 wait_for_complete: false,
235 distinct: false,
236 offset: (pagination.value.current - 1) * pagination.value.size,
237 limit: pagination.value.size
238 },
239 abortController.signal
240 )
241 .then(res => {
242 if (res.data.success) {
243 groupsList.value = res.data.results || []
244 pagination.value.total = res.data.total_items
245 } else {
246 pagination.value.total = 0
247 message.error(res.data?.message || "An error occurred. Please try again later.")
248 }
249 loadingGroups.value = false
250 })
251 .catch(err => {
252 if (!axios.isCancel(err)) {
253 groupsList.value = []
254 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
255 loadingGroups.value = false
256 }
257 })
258 }
259
260 function loadGroupFile(groupId: string, filename: string) {
261 loadingFile.value = true
262
263 Api.wazuh.groups
264 .getGroupFile(groupId, filename)
265 .then(res => {
266 if (res.data.success) {
267 currentFile.value = _clone(res.data)
268 backupFile.value = _clone(res.data)
269 } else {
270 message.error(res.data?.message || "An error occurred. Please try again later.")
271 }
272 })
273 .catch(err => {
274 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
275 })
276 .finally(() => {
277 loadingFile.value = false
278 })
279 }
280
281 function updateGroupConfiguration() {
282 if (currentGroup.value && currentFile.value) {
283 uploadingConfig.value = true
284
285 Api.wazuh.groups
286 .updateGroupConfiguration(currentGroup.value.name, currentFile.value.content)
287 .then(res => {
288 if (res.data.success) {
289 currentFile.value = _clone(currentFile.value)
290 backupFile.value = _clone(currentFile.value)
291 message.success("Group configuration updated successfully")
292 } else {
293 message.error("An error occurred. Please try again later.")
294 }
295 })
296 .catch(err => {
297 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
298 })
299 .finally(() => {
300 uploadingConfig.value = false
301 })
302 }
303 }
304
305 watch(
306 [filters],
307 () => {
308 pagination.value.current = 1
309 },
310 { deep: true }
311 )
312
313 watchDebounced(
314 [filters, () => pagination.value.current],
315 () => {
316 getGroups()
317 },
318 { debounce: 250, immediate: true, deep: true }
319 )
320 </script>