main
vue 397 lines 11.7 KB
Raw
1 <template>
2 <div class="flex flex-col gap-4">
3 <n-alert v-if="showNoSourcesWarning" title="No Event Sources Configured" type="warning" closable>
4 No event sources are configured for this customer. Contact your administrator to set up event sources.
5 </n-alert>
6
7 <div class="@container flex w-full flex-col gap-3">
8 <div class="flex flex-col gap-2">
9 <div class="flex w-full flex-wrap items-center justify-between gap-2">
10 <div class="flex items-center gap-2">
11 Lucene Query
12 <p class="text-secondary text-xs">type # to autocomplete</p>
13 </div>
14 <div class="flex items-center gap-2">
15 <n-select
16 v-model:value="selectedCustomerCode"
17 placeholder="Select Customer"
18 filterable
19 size="tiny"
20 :options="customersOptions"
21 :consistent-menu-width="false"
22 @update:value="onCustomerChange"
23 />
24
25 <n-select
26 v-model:value="selectedSourceName"
27 placeholder="Select Source"
28 filterable
29 size="tiny"
30 :options="eventSourceOptions"
31 :disabled="!selectedCustomerCode"
32 :loading="loadingEventSources"
33 :consistent-menu-width="false"
34 />
35 </div>
36 </div>
37
38 <n-mention
39 v-model:value="query"
40 type="textarea"
41 separator=" "
42 :autosize="{ minRows: 3, maxRows: 8 }"
43 placeholder="e.g. agent_name:web-server AND rule_level:>=10"
44 :options="suggestionOptions"
45 :prefix="['#']"
46 :loading="loadingFieldMappings"
47 :render-label
48 @select="onMentionSelect"
49 >
50 <template #empty>
51 <n-spin :show="loadingFieldMappings">
52 <div class="text-secondary text-xs">No field mappings found</div>
53 </n-spin>
54 </template>
55 </n-mention>
56 </div>
57
58 <div class="flex flex-wrap justify-between gap-3">
59 <div class="text-secondary text-xs">
60 {{
61 !selectedCustomerCode
62 ? "Select a customer to search"
63 : !selectedSourceName
64 ? "Select a source to search"
65 : ""
66 }}
67 </div>
68
69 <div class="flex flex-wrap items-center justify-end gap-3">
70 <n-input-group class="flex flex-1 items-center justify-end">
71 <n-input-number
72 v-if="timerangeMode === 'relative'"
73 v-model:value="filterTimeRange.time"
74 :show-button="false"
75 :min="1"
76 size="small"
77 placeholder="Time"
78 class="max-w-15! min-w-10! text-center"
79 />
80 <n-select
81 v-if="timerangeMode === 'relative'"
82 v-model:value="filterTimeRange.unit"
83 :options="unitOptions"
84 class="max-w-25!"
85 placeholder="Time unit"
86 :consistent-menu-width="false"
87 size="small"
88 />
89 <n-date-picker
90 v-if="timerangeMode === 'absolute'"
91 v-model:value="daterange"
92 type="datetimerange"
93 class="min-w-70"
94 clearable
95 size="small"
96 />
97 <n-button
98 v-if="timerangeMode === 'relative'"
99 secondary
100 size="small"
101 @click="timerangeMode = 'absolute'"
102 >
103 <template #icon>
104 <Icon name="carbon:calendar" />
105 </template>
106 </n-button>
107 <n-button
108 v-if="timerangeMode === 'absolute'"
109 secondary
110 size="small"
111 @click="timerangeMode = 'relative'"
112 >
113 <template #icon>
114 <Icon name="carbon:reset" />
115 </template>
116 </n-button>
117 </n-input-group>
118
119 <n-select v-model:value="pageSize" :options="pageSizeOptions" size="small" class="w-33!" />
120
121 <n-button
122 secondary
123 size="small"
124 type="primary"
125 :loading="loadingEvents"
126 :disabled="!selectedCustomerCode || !selectedSourceName"
127 @click="searchEvents"
128 >
129 <template #icon>
130 <Icon name="carbon:search" />
131 </template>
132 Search
133 </n-button>
134 </div>
135 </div>
136 </div>
137 </div>
138 </template>
139
140 <script setup lang="ts">
141 import type { MentionOption } from "naive-ui"
142 import type { VNodeChild } from "vue"
143 import type { ApiError } from "@/types/common"
144 import type { EventSearchQueryTimerange, EventSourceItem, FieldMapping } from "@/types/siem"
145 import { NAlert, NButton, NDatePicker, NInputGroup, NInputNumber, NMention, NSelect, NSpin, useMessage } from "naive-ui"
146 import { computed, h, onBeforeMount, ref, watch } from "vue"
147 import { useRoute } from "vue-router"
148 import Api from "@/api"
149 import Icon from "@/components/common/Icon.vue"
150 import { useAuthStore } from "@/stores/auth"
151 import { useCustomerFilterStore } from "@/stores/customerFilter"
152 import { getApiErrorMessage } from "@/utils"
153 import dayjs from "@/utils/dayjs"
154
155 export interface SearchFormParams {
156 customerCode: string
157 sourceName: string | null
158 query: string
159 timerange: EventSearchQueryTimerange
160 timeFrom: number | null
161 timeTo: number | null
162 timeMode: "relative" | "absolute"
163 pageSize: number
164 }
165
166 export interface SearchFormLoad {
167 customerCode: string
168 eventSources: EventSourceItem[]
169 }
170
171 defineProps<{
172 loadingEvents: boolean
173 }>()
174
175 const emit = defineEmits<{
176 (e: "search", value: SearchFormParams): void
177 (e: "loaded", value: SearchFormLoad): void
178 }>()
179
180 const query = defineModel<string>("query")
181
182 const TRAILING_WHITESPACE_RE = /\s$/
183
184 const route = useRoute()
185 const authStore = useAuthStore()
186 const customerFilterStore = useCustomerFilterStore()
187 const message = useMessage()
188
189 const customerCode = computed(() => authStore.userCustomerCode)
190 const customersOptions = computed(() => authStore.accessibleCustomerCodes.map(code => ({ label: code, value: code })))
191 // Seed the searched customer from the global filter when it resolves to a single
192 // customer, otherwise the user's primary / first accessible customer.
193 const selectedCustomerCode = ref(
194 customerFilterStore.selectedCustomerCodes.length === 1
195 ? customerFilterStore.selectedCustomerCodes[0]
196 : customerCode.value || authStore.accessibleCustomerCodes[0] || ""
197 )
198
199 const eventSources = ref<EventSourceItem[]>([])
200 const loadingEventSources = ref(false)
201 const selectedSourceName = ref<string | null>(null)
202 const enabledSources = computed(() => eventSources.value.filter(s => s.enabled))
203 const eventSourceOptions = computed(() =>
204 enabledSources.value.map(s => ({ label: `${s.name} (${s.event_type})`, value: s.name }))
205 )
206
207 const showNoSourcesWarning = computed(
208 () => selectedCustomerCode.value && !loadingEventSources.value && eventSources.value.length === 0
209 )
210
211 const filterTimeRange = ref<{ unit: "h" | "d" | "w"; time: number }>({
212 unit: "h",
213 time: 24
214 })
215 const unitOptions: { label: string; value: "h" | "d" | "w" }[] = [
216 { label: "Hours", value: "h" },
217 { label: "Days", value: "d" },
218 { label: "Weeks", value: "w" }
219 ]
220 const timerange = computed<EventSearchQueryTimerange>(
221 () => `${filterTimeRange.value.time}${filterTimeRange.value.unit}`
222 )
223 const daterange = ref<[number, number]>([dayjs().subtract(1, "day").valueOf(), Date.now()])
224 const timerangeMode = ref<"relative" | "absolute">("relative")
225
226 const pageSizeOptions = [
227 { label: "25 per page", value: 25 },
228 { label: "50 per page", value: 50 },
229 { label: "100 per page", value: 100 },
230 { label: "250 per page", value: 250 }
231 ]
232 const pageSize = ref(pageSizeOptions[1].value)
233
234 const loadingFieldMappings = ref(false)
235 const fieldMappings = ref<FieldMapping[]>([])
236 const suggestionOptions = computed(() => {
237 return fieldMappings.value.map(f => ({ label: f.field, type: f.type, value: f.field }))
238 })
239
240 function renderLabel(option: MentionOption): VNodeChild {
241 const label = String(option.label ?? "")
242 const type = String(option.type ?? "")
243
244 return h("div", { class: "flex items-center gap-2 justify-between w-full" }, [
245 h("div", { class: "text-sm font-medium" }, label),
246 h("div", { class: "text-xs text-secondary" }, type)
247 ])
248 }
249
250 async function loadEventSources(customerCode: string) {
251 loadingEventSources.value = true
252 eventSources.value = []
253 selectedSourceName.value = null
254 fieldMappings.value = []
255
256 try {
257 const response = await Api.siem.getEventSources(customerCode)
258 eventSources.value = response.data.event_sources
259 } catch (err) {
260 message.error(getApiErrorMessage(err as ApiError) || "Failed to load event sources")
261 } finally {
262 loadingEventSources.value = false
263
264 emit("loaded", {
265 customerCode,
266 eventSources: eventSources.value
267 })
268 }
269 }
270
271 function onCustomerChange(code: string) {
272 // Reload event sources for the newly selected customer (resets source + fields).
273 if (code) {
274 loadEventSources(code)
275 }
276 }
277
278 async function loadFieldMappings() {
279 if (!selectedCustomerCode.value || !selectedSourceName.value) return
280
281 loadingFieldMappings.value = true
282 fieldMappings.value = []
283 try {
284 const response = await Api.siem.getFieldMappings(selectedCustomerCode.value, selectedSourceName.value)
285 fieldMappings.value = response.data.fields
286 } catch {
287 fieldMappings.value = []
288 } finally {
289 loadingFieldMappings.value = false
290 }
291 }
292
293 async function searchEvents() {
294 if (!selectedCustomerCode.value || !selectedSourceName.value) return
295
296 emit("search", {
297 customerCode: selectedCustomerCode.value,
298 sourceName: selectedSourceName.value,
299 query: query.value || "",
300 timerange: timerange.value,
301 timeFrom: timerangeMode.value === "absolute" ? daterange.value[0] : null,
302 timeTo: timerangeMode.value === "absolute" ? daterange.value[1] : null,
303 timeMode: timerangeMode.value,
304 pageSize: pageSize.value
305 })
306 }
307
308 function onMentionSelect(option: MentionOption, prefix: string) {
309 // Current query text and the raw token as typed in the mention (e.g. "#field")
310 const currentQuery = query.value || ""
311 const target = `${prefix}${option.value}`
312
313 // Find the last occurrence of the typed token to replace it
314 const lastIndex = currentQuery.lastIndexOf(target)
315
316 if (lastIndex === -1) {
317 // If only the trigger is present (e.g. "... #"), replace the last trigger with "field:"
318 const lastPrefixIndex = currentQuery.lastIndexOf(prefix)
319 if (lastPrefixIndex !== -1) {
320 const before = currentQuery.slice(0, lastPrefixIndex)
321 const after = currentQuery.slice(lastPrefixIndex + prefix.length)
322 const needsSpace = before.length > 0 && !TRAILING_WHITESPACE_RE.test(before)
323 const replacement = `${needsSpace ? " " : ""}${option.value}:`
324 query.value = `${before}${replacement}${after}`
325 return
326 }
327
328 // If neither token nor trigger is found, append the field followed by ":" at the end of the query
329 const needsSpace = currentQuery.length > 0 && !TRAILING_WHITESPACE_RE.test(currentQuery)
330 query.value = `${currentQuery}${needsSpace ? " " : ""}${option.value}:`
331 return
332 }
333
334 // Split query around the matched token
335 const before = currentQuery.slice(0, lastIndex)
336 const after = currentQuery.slice(lastIndex + target.length)
337
338 // Rebuild query replacing the token with the plain field name followed by ":"
339 const needsSpace = before.length > 0 && !TRAILING_WHITESPACE_RE.test(before)
340 const replacement = `${needsSpace ? " " : ""}${option.value}:`
341
342 query.value = `${before}${replacement}${after}`
343 }
344
345 // -- Lifecycle --
346 async function applyRouteParams() {
347 const qCustomer = (route.query.customer_code || selectedCustomerCode.value) as string
348 const qSource = route.query.source_name as string | undefined
349 const qQuery = route.query.query as string | undefined
350
351 if (!qCustomer) return
352
353 selectedCustomerCode.value = qCustomer
354 await loadEventSources(qCustomer)
355
356 if (qSource) {
357 const match = enabledSources.value.find(s => s.name === qSource)
358 if (match) {
359 selectedSourceName.value = match.name
360 }
361 }
362
363 if (qQuery) {
364 query.value = qQuery
365 }
366
367 if (selectedCustomerCode.value && selectedSourceName.value) {
368 searchEvents()
369 }
370 }
371
372 watch(
373 selectedSourceName,
374 val => {
375 if (val) {
376 loadFieldMappings()
377 }
378 },
379 { immediate: true }
380 )
381
382 // Follow the global customer filter when it narrows to a single customer.
383 watch(
384 () => customerFilterStore.selectedCustomerCodes,
385 codes => {
386 if (codes.length === 1 && codes[0] !== selectedCustomerCode.value) {
387 selectedCustomerCode.value = codes[0]
388 loadEventSources(codes[0])
389 }
390 },
391 { deep: true }
392 )
393
394 onBeforeMount(() => {
395 applyRouteParams()
396 })
397 </script>