main
vue 282 lines 7.2 KB
Raw
1 <template>
2 <div class="flex flex-col gap-6">
3 <Filters v-model:value="filters" :status="statusesList" :os="osList" />
4
5 <div class="flex flex-col gap-2">
6 <div ref="headerRef" class="flex items-center justify-between">
7 <Chip size="small" :value="loading ? 'Loading...' : paginatedTotal" label="items" />
8
9 <div class="flex items-center gap-2 whitespace-nowrap">
10 <n-pagination
11 v-model:page="pagination.page"
12 v-model:page-size="pagination.pageSize"
13 :page-slot
14 :show-size-picker
15 :page-sizes
16 :item-count="paginatedTotal"
17 :simple="simpleMode"
18 size="small"
19 />
20 </div>
21 </div>
22
23 <div class="grow overflow-hidden">
24 <n-data-table
25 bordered
26 :loading
27 size="small"
28 :data="dataPaginated"
29 :columns
30 :scroll-x="1400"
31 class="[&_.n-data-table-th\_\_title]:whitespace-nowrap"
32 >
33 <template #empty>
34 <n-empty description="No agents found">
35 <template #extra>try changing the filters</template>
36 </n-empty>
37 </template>
38 </n-data-table>
39 </div>
40
41 <div class="flex justify-end">
42 <n-pagination
43 v-if="paginatedTotal > pagination.pageSize"
44 v-model:page="pagination.page"
45 :page-size="pagination.pageSize"
46 :item-count="paginatedTotal"
47 :page-slot="6"
48 size="small"
49 :simple="simpleMode"
50 />
51 </div>
52 </div>
53 </div>
54 </template>
55
56 <script setup lang="tsx">
57 import type { DataTableColumns } from "naive-ui"
58 import type { AgentCriticalUpdateSuccessPayload } from "./AgentCriticalSelect.vue"
59 import type { AgentsFilters } from "@/components/agents/Filters.vue"
60 import type { Agent } from "@/types/agents"
61 import type { ApiError } from "@/types/common"
62 import { useDebounceFn, useElementSize } from "@vueuse/core"
63 import axios from "axios"
64 import { NDataTable, NEmpty, NPagination, NTag, useMessage } from "naive-ui"
65 import { computed, onBeforeMount, ref, useTemplateRef, watch } from "vue"
66 import Api from "@/api"
67 import Filters from "@/components/agents/Filters.vue"
68 import Chip from "@/components/common/Chip.vue"
69 import Icon from "@/components/common/Icon.vue"
70 import { useCustomerFilterStore } from "@/stores/customerFilter"
71 import { useSettingsStore } from "@/stores/settings"
72 import { getApiErrorMessage, getStatusColor } from "@/utils"
73 import { formatDate } from "@/utils/format"
74 import AgentCriticalSelect from "./AgentCriticalSelect.vue"
75 import AgentDetailsButton from "./AgentDetailsButton.vue"
76
77 const emit = defineEmits<{
78 (e: "loaded", value: Agent[]): void
79 (e: "loading", value: boolean): void
80 }>()
81
82 const message = useMessage()
83 const loading = ref(false)
84 const dFormats = useSettingsStore().dateFormat
85
86 const { width: headerWidthRef } = useElementSize(useTemplateRef("headerRef"))
87 const pageSizes = [10, 25, 50, 100]
88 const pageSlot = computed(() => (headerWidthRef.value < 800 ? 5 : 8))
89 const simpleMode = computed(() => headerWidthRef.value < 600)
90 const showSizePicker = ref(true)
91
92 const pagination = ref({
93 page: 1,
94 pageSize: pageSizes[1]
95 })
96
97 const filters = ref<AgentsFilters>({
98 status: null,
99 os: null,
100 critical: false,
101 search: null
102 })
103
104 const data = ref<Agent[]>([])
105
106 const dataFiltered = computed(() => {
107 return data.value.filter(agent => {
108 if (filters.value.status && agent.wazuh_agent_status !== filters.value.status) {
109 return false
110 }
111 if (filters.value.critical && agent.critical_asset !== filters.value.critical) {
112 return false
113 }
114 if (filters.value.os && agent.os !== filters.value.os) {
115 return false
116 }
117 if (
118 filters.value.search &&
119 !agent.hostname.toLowerCase().includes(filters.value.search.toLowerCase()) &&
120 !agent.ip_address.toLowerCase().includes(filters.value.search.toLowerCase()) &&
121 !agent.agent_id.toLowerCase().includes(filters.value.search.toLowerCase())
122 ) {
123 return false
124 }
125 return true
126 })
127 })
128
129 const dataPaginated = computed(() => {
130 const from = (pagination.value.page - 1) * pagination.value.pageSize
131 const to = pagination.value.page * pagination.value.pageSize
132
133 return dataFiltered.value.slice(from, to)
134 })
135
136 const paginatedTotal = computed(() => dataFiltered.value.length)
137
138 const statusesList = computed(() => [...new Set(data.value.map(agent => agent.wazuh_agent_status))])
139 const osList = computed(() => [...new Set(data.value.map(agent => agent.os))])
140
141 const columns = computed<DataTableColumns<Agent>>(() => [
142 {
143 title: "Agent",
144 key: "agent_id",
145 fixed: simpleMode.value ? undefined : "left",
146 width: 280,
147 render: row => (
148 <div class="flex items-center gap-2">
149 <NTag
150 type={getStatusColor(row.wazuh_agent_status)}
151 round
152 class="p-1! [&_.n-tag\_\_icon]:m-0!"
153 v-slots={{
154 icon: () => <Icon name="carbon:circle-solid" />
155 }}
156 />
157 <div class="flex flex-col gap-0.5">
158 <div class="font-medium">{row.hostname}</div>
159 <div class="text-secondary text-xs">
160 ID:
161 {row.agent_id}
162 </div>
163 </div>
164 </div>
165 )
166 },
167 {
168 title: "IP Address",
169 key: "ip_address",
170 width: 160,
171 render: row => <div class="font-mono">{row.ip_address}</div>
172 },
173 {
174 title: "Operating System",
175 key: "os",
176 render: row => <div>{row.os}</div>
177 },
178 {
179 title: "Last Seen",
180 key: "wazuh_last_seen",
181 width: 180,
182 render: row => <div class="font-mono">{formatDate(row.wazuh_last_seen, dFormats.datetime)}</div>
183 },
184 {
185 title: "Status",
186 key: "wazuh_agent_status",
187 width: 180,
188 render: row => {
189 return (
190 <div class="flex items-center gap-2">
191 <Chip type={getStatusColor(row.wazuh_agent_status)} value={row.wazuh_agent_status.toUpperCase()} />
192 </div>
193 )
194 }
195 },
196 {
197 title: "Critical Asset",
198 key: "critical_asset",
199 width: 200,
200 render: row => {
201 return (
202 <AgentCriticalSelect
203 agentId={row.agent_id}
204 critical={row.critical_asset}
205 onSuccess={handleCriticalAssetUpdated}
206 />
207 )
208 }
209 },
210 {
211 title: "Actions",
212 key: "actions",
213 width: 194,
214 render: row => {
215 return <AgentDetailsButton agentId={row.agent_id} onCriticalAssetUpdated={handleCriticalAssetUpdated} />
216 }
217 }
218 ])
219
220 let abortController = new AbortController()
221
222 const customerFilterStore = useCustomerFilterStore()
223
224 const loadAgents = useDebounceFn(async () => {
225 loading.value = true
226
227 abortController?.abort()
228 abortController = new AbortController()
229
230 try {
231 const response = await Api.agents.getAgents(customerFilterStore.queryCustomerCodes)
232
233 data.value = response.data.agents || []
234 emit("loaded", data.value)
235 loading.value = false
236 } catch (err) {
237 if (!axios.isCancel(err)) {
238 message.error(getApiErrorMessage(err as ApiError))
239 loading.value = false
240 }
241 }
242 }, 400)
243
244 function handleCriticalAssetUpdated(payload: AgentCriticalUpdateSuccessPayload) {
245 const agent = data.value.find(a => a.agent_id === payload.agentId)
246 if (agent) {
247 agent.critical_asset = payload.critical
248 }
249 }
250
251 function resetPage() {
252 pagination.value.page = 1
253 }
254
255 watch(
256 loading,
257 value => {
258 emit("loading", value)
259 },
260 { immediate: true }
261 )
262
263 watch([() => pagination.value.pageSize, filters], resetPage, {
264 deep: true,
265 immediate: true
266 })
267
268 // The agents list is fetched once and filtered client-side, so a change to the
269 // global customer filter must re-fetch (the backend scopes by customer_codes).
270 watch(
271 () => customerFilterStore.selectedCustomerCodes,
272 () => {
273 pagination.value.page = 1
274 loadAgents()
275 },
276 { deep: true }
277 )
278
279 onBeforeMount(() => {
280 loadAgents()
281 })
282 </script>