1
+<template>
2
+ <div class="flex flex-col gap-4">
3
+ <!-- No Event Sources Warning -->
4
+ <n-alert v-if="showNoSourcesWarning" title="No Event Sources Configured" type="warning" closable>
5
+ An Event Source needs to be defined for this customer before events can be searched. Go to the customer's
6
+ <strong>Event Sources</strong>
7
+ tab to configure one.
8
+ </n-alert>
9
+
10
+ <!-- Filters Bar -->
11
+ <n-card size="small">
12
+ <div class="flex flex-col gap-3">
13
+ <div class="flex flex-wrap items-end gap-3">
14
+ <div class="flex flex-col gap-1">
15
+ <span class="text-xs opacity-60">Customer</span>
16
+ <n-select
17
+ v-model:value="selectedCustomerCode"
18
+ :options="customersOptions"
19
+ placeholder="Select Customer"
20
+ filterable
21
+ :loading="loadingCustomers"
22
+ style="width: 260px"
23
+ @update:value="onCustomerChange"
24
+ />
25
+ </div>
26
+ <div class="flex flex-col gap-1">
27
+ <span class="text-xs opacity-60">Event Source</span>
28
+ <n-select
29
+ v-model:value="selectedSourceName"
30
+ :options="eventSourceOptions"
31
+ placeholder="Select Source"
32
+ filterable
33
+ :loading="loadingEventSources"
34
+ :disabled="!selectedCustomerCode"
35
+ style="width: 220px"
36
+ @update:value="onSourceChange"
37
+ />
38
+ </div>
39
+ <div class="flex flex-col gap-1">
40
+ <span class="text-xs opacity-60">Time Range</span>
41
+ <n-select v-model:value="timerange" :options="timerangeOptions" style="width: 140px" />
42
+ </div>
43
+ <div class="flex flex-col gap-1">
44
+ <span class="text-xs opacity-60">Page Size</span>
45
+ <n-select v-model:value="pageSize" :options="pageSizeOptions" style="width: 110px" />
46
+ </div>
47
+ <n-button
48
+ type="primary"
49
+ :disabled="!selectedCustomerCode || !selectedSourceName"
50
+ :loading="loadingEvents"
51
+ @click="searchEvents()"
52
+ >
53
+ <template #icon>
54
+ <Icon :name="SearchIcon" :size="16" />
55
+ </template>
56
+ Search
57
+ </n-button>
58
+ </div>
59
+
60
+ <!-- Query Bar with Autocomplete -->
61
+ <div class="relative">
62
+ <n-input
63
+ ref="queryInputRef"
64
+ v-model:value="query"
65
+ placeholder="Lucene query (e.g. agent_name:server01 AND rule_level:>=10)"
66
+ clearable
67
+ @keydown.enter="searchEvents()"
68
+ @keydown.tab.prevent="acceptSuggestion"
69
+ @keydown.escape="showSuggestions = false"
70
+ @input="onQueryInput"
71
+ >
72
+ <template #prefix>
73
+ <Icon :name="CodeIcon" :size="16" class="opacity-50" />
74
+ </template>
75
+ </n-input>
76
+ <!-- Autocomplete dropdown -->
77
+ <div
78
+ v-if="showSuggestions && filteredSuggestions.length"
79
+ class="suggestions-dropdown bg-default absolute top-full right-0 left-0 z-50 mt-1 max-h-48 overflow-y-auto rounded-lg border shadow-lg"
80
+ >
81
+ <div
82
+ v-for="(suggestion, index) in filteredSuggestions"
83
+ :key="suggestion.field"
84
+ class="suggestion-item flex cursor-pointer items-center justify-between px-3 py-1.5 text-sm hover:bg-[var(--hover-005-color)]"
85
+ :class="{ 'bg-[var(--hover-005-color)]': index === activeSuggestionIndex }"
86
+ @mousedown.prevent="applySuggestion(suggestion.field)"
87
+ >
88
+ <span class="font-mono">{{ suggestion.field }}</span>
89
+ <span class="text-xs opacity-50">{{ suggestion.type }}</span>
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </div>
94
+ </n-card>
95
+
96
+ <!-- Results -->
97
+ <n-spin :show="loadingEvents">
98
+ <n-card v-if="events.length || loadingEvents" size="small">
99
+ <div class="mb-2 flex items-center justify-between">
100
+ <span class="text-sm opacity-60">
101
+ {{ totalEvents }} event{{ totalEvents !== 1 ? "s" : "" }} found
102
+ </span>
103
+ </div>
104
+ <n-data-table
105
+ :columns="columns"
106
+ :data="events"
107
+ :bordered="false"
108
+ :single-line="false"
109
+ size="small"
110
+ :row-key="(row: EventSearchResult) => row._id || JSON.stringify(row)"
111
+ :row-props="rowProps"
112
+ max-height="calc(100vh - 360px)"
113
+ virtual-scroll
114
+ />
115
+ <div v-if="scrollId && events.length < totalEvents" class="mt-3 flex justify-center">
116
+ <n-button :loading="loadingMore" @click="loadMoreEvents">Load More</n-button>
117
+ </div>
118
+ </n-card>
119
+ <n-empty
120
+ v-else-if="!loadingEvents && hasSearched"
121
+ description="No events found"
122
+ class="h-48 justify-center"
123
+ />
124
+ </n-spin>
125
+
126
+ <!-- Event Detail Drawer -->
127
+ <EventDetailDrawer
128
+ v-model:show="showDetailDrawer"
129
+ :event="selectedEvent"
130
+ @filter-add="addFilterFromDetail"
131
+ @filter-exclude="excludeFilterFromDetail"
132
+ />
133
+ </div>
134
+</template>
135
+
136
+<script setup lang="ts">
137
+import type { DataTableColumns } from "naive-ui"
138
+import type { EventSearchResult, FieldMapping } from "@/types/events.d"
139
+import type { EventSource } from "@/types/eventSources.d"
140
+import type { Customer } from "@/types/customers.d"
141
+import { NAlert, NButton, NCard, NDataTable, NEmpty, NInput, NSelect, NSpin, useMessage } from "naive-ui"
142
+import { computed, h, nextTick, onBeforeMount, ref } from "vue"
143
+import { useRoute } from "vue-router"
144
+import Api from "@/api"
145
+import Icon from "@/components/common/Icon.vue"
146
+import EventDetailDrawer from "./EventDetailDrawer.vue"
147
+
148
+const route = useRoute()
149
+
150
+const SearchIcon = "carbon:search"
151
+const CodeIcon = "carbon:code"
152
+
153
+const message = useMessage()
154
+
155
+// -- Customer selection --
156
+const loadingCustomers = ref(false)
157
+const customersList = ref<Customer[]>([])
158
+const selectedCustomerCode = ref<string | null>(null)
159
+
160
+const customersOptions = computed(() =>
161
+ customersList.value.map(o => ({ label: `#${o.customer_code} - ${o.customer_name}`, value: o.customer_code }))
162
+)
163
+
164
+function getCustomers() {
165
+ loadingCustomers.value = true
166
+ return Api.customers
167
+ .getCustomers()
168
+ .then(res => {
169
+ if (res.data.success) {
170
+ customersList.value = res.data?.customers || []
171
+ } else {
172
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
173
+ }
174
+ })
175
+ .catch(err => {
176
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
177
+ })
178
+ .finally(() => {
179
+ loadingCustomers.value = false
180
+ })
181
+}
182
+
183
+// -- Event Source selection --
184
+const loadingEventSources = ref(false)
185
+const eventSourcesList = ref<EventSource[]>([])
186
+const selectedSourceName = ref<string | null>(null)
187
+
188
+const eventSourceOptions = computed(() =>
189
+ eventSourcesList.value.filter(s => s.enabled).map(s => ({ label: `${s.name} (${s.event_type})`, value: s.name }))
190
+)
191
+
192
+const showNoSourcesWarning = computed(
193
+ () => selectedCustomerCode.value && !loadingEventSources.value && eventSourcesList.value.length === 0
194
+)
195
+
196
+function getEventSources(customerCode: string) {
197
+ loadingEventSources.value = true
198
+ eventSourcesList.value = []
199
+ selectedSourceName.value = null
200
+ fieldMappings.value = []
201
+
202
+ Api.siem
203
+ .getEventSources(customerCode)
204
+ .then(res => {
205
+ if (res.data.success) {
206
+ eventSourcesList.value = res.data?.event_sources || []
207
+ } else {
208
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
209
+ }
210
+ })
211
+ .catch(err => {
212
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
213
+ })
214
+ .finally(() => {
215
+ loadingEventSources.value = false
216
+ })
217
+}
218
+
219
+function onCustomerChange(code: string) {
220
+ resetResults()
221
+ if (code) {
222
+ getEventSources(code)
223
+ }
224
+}
225
+
226
+function onSourceChange() {
227
+ resetResults()
228
+ if (selectedCustomerCode.value && selectedSourceName.value) {
229
+ loadFieldMappings()
230
+ }
231
+}
232
+
233
+// -- Search parameters --
234
+const timerange = ref("24h")
235
+const timerangeOptions = [
236
+ { label: "1 hour", value: "1h" },
237
+ { label: "6 hours", value: "6h" },
238
+ { label: "24 hours", value: "24h" },
239
+ { label: "3 days", value: "3d" },
240
+ { label: "7 days", value: "7d" },
241
+ { label: "14 days", value: "14d" },
242
+ { label: "30 days", value: "30d" }
243
+]
244
+
245
+const pageSize = ref(50)
246
+const pageSizeOptions = [
247
+ { label: "25", value: 25 },
248
+ { label: "50", value: 50 },
249
+ { label: "100", value: 100 },
250
+ { label: "250", value: 250 }
251
+]
252
+
253
+const query = ref("")
254
+
255
+// -- Field mappings / autocomplete --
256
+const fieldMappings = ref<FieldMapping[]>([])
257
+const showSuggestions = ref(false)
258
+const activeSuggestionIndex = ref(0)
259
+
260
+function loadFieldMappings() {
261
+ if (!selectedCustomerCode.value || !selectedSourceName.value) return
262
+
263
+ Api.siem
264
+ .getFieldMappings(selectedCustomerCode.value, selectedSourceName.value)
265
+ .then(res => {
266
+ if (res.data.success) {
267
+ fieldMappings.value = res.data.fields || []
268
+ }
269
+ })
270
+ .catch(() => {
271
+ // Silent fail - autocomplete is optional
272
+ })
273
+}
274
+
275
+const currentFieldToken = computed(() => {
276
+ if (!query.value) return ""
277
+ const cursorPos = query.value.length
278
+ const before = query.value.substring(0, cursorPos)
279
+ // Match the last word being typed (field name token before a colon or standalone)
280
+ const match = before.match(/(?:^|[\s(])([a-zA-Z_][\w.]*)$/)
281
+ return match ? match[1] : ""
282
+})
283
+
284
+const filteredSuggestions = computed(() => {
285
+ const token = currentFieldToken.value.toLowerCase()
286
+ if (!token || token.length < 2) return []
287
+ return fieldMappings.value.filter(f => f.field.toLowerCase().includes(token)).slice(0, 20)
288
+})
289
+
290
+function onQueryInput() {
291
+ showSuggestions.value = currentFieldToken.value.length >= 2 && filteredSuggestions.value.length > 0
292
+ activeSuggestionIndex.value = 0
293
+}
294
+
295
+function applySuggestion(fieldName: string) {
296
+ const token = currentFieldToken.value
297
+ if (token) {
298
+ const lastIndex = query.value.lastIndexOf(token)
299
+ query.value = query.value.substring(0, lastIndex) + fieldName + ":"
300
+ }
301
+ showSuggestions.value = false
302
+}
303
+
304
+function acceptSuggestion() {
305
+ if (showSuggestions.value && filteredSuggestions.value.length > 0) {
306
+ applySuggestion(filteredSuggestions.value[activeSuggestionIndex.value].field)
307
+ }
308
+}
309
+
310
+// -- Events data --
311
+const events = ref<EventSearchResult[]>([])
312
+const totalEvents = ref(0)
313
+const scrollId = ref<string | null>(null)
314
+const loadingEvents = ref(false)
315
+const loadingMore = ref(false)
316
+const hasSearched = ref(false)
317
+
318
+function resetResults() {
319
+ events.value = []
320
+ totalEvents.value = 0
321
+ scrollId.value = null
322
+ hasSearched.value = false
323
+}
324
+
325
+function searchEvents() {
326
+ if (!selectedCustomerCode.value || !selectedSourceName.value) return
327
+
328
+ loadingEvents.value = true
329
+ hasSearched.value = true
330
+ resetResults()
331
+
332
+ Api.siem
333
+ .queryEvents(selectedCustomerCode.value, selectedSourceName.value, {
334
+ timerange: timerange.value,
335
+ page_size: pageSize.value,
336
+ query: query.value || undefined
337
+ })
338
+ .then(res => {
339
+ if (res.data.success) {
340
+ events.value = res.data.events || []
341
+ totalEvents.value = res.data.total
342
+ scrollId.value = res.data.scroll_id
343
+ } else {
344
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
345
+ }
346
+ })
347
+ .catch(err => {
348
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
349
+ })
350
+ .finally(() => {
351
+ loadingEvents.value = false
352
+ })
353
+}
354
+
355
+function loadMoreEvents() {
356
+ if (!selectedCustomerCode.value || !selectedSourceName.value || !scrollId.value) return
357
+
358
+ loadingMore.value = true
359
+
360
+ Api.siem
361
+ .queryEvents(selectedCustomerCode.value, selectedSourceName.value, {
362
+ scroll_id: scrollId.value
363
+ })
364
+ .then(res => {
365
+ if (res.data.success) {
366
+ events.value.push(...(res.data.events || []))
367
+ scrollId.value = res.data.scroll_id
368
+ } else {
369
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
370
+ }
371
+ })
372
+ .catch(err => {
373
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
374
+ })
375
+ .finally(() => {
376
+ loadingMore.value = false
377
+ })
378
+}
379
+
380
+// -- Table columns --
381
+const columns = computed<DataTableColumns<EventSearchResult>>(() => {
382
+ const baseColumns: DataTableColumns<EventSearchResult> = [
383
+ {
384
+ title: "Timestamp",
385
+ key: "timestamp",
386
+ width: 180,
387
+ sorter: (a, b) => {
388
+ const timeA = a.timestamp || a["@timestamp"] || ""
389
+ const timeB = b.timestamp || b["@timestamp"] || ""
390
+ return new Date(timeA).getTime() - new Date(timeB).getTime()
391
+ },
392
+ render(row) {
393
+ const ts = row.timestamp || row["@timestamp"]
394
+ if (!ts) return "-"
395
+ return new Date(ts).toLocaleString()
396
+ }
397
+ },
398
+ {
399
+ title: "Source",
400
+ key: "agent_name",
401
+ width: 140,
402
+ ellipsis: { tooltip: true },
403
+ render(row) {
404
+ return row.agent_name || row.source || "-"
405
+ }
406
+ },
407
+ {
408
+ title: "Rule",
409
+ key: "rule_description",
410
+ ellipsis: { tooltip: true },
411
+ render(row) {
412
+ return row.rule_description || row.rule_id || "-"
413
+ }
414
+ },
415
+ {
416
+ title: "Level",
417
+ key: "rule_level",
418
+ width: 80,
419
+ sorter: (a, b) => (Number(a.rule_level) || 0) - (Number(b.rule_level) || 0),
420
+ render(row) {
421
+ if (row.rule_level === undefined || row.rule_level === null) return "-"
422
+ const level = Number(row.rule_level)
423
+ let type: "default" | "warning" | "error" | "success" | "info" = "default"
424
+ if (level >= 12) type = "error"
425
+ else if (level >= 8) type = "warning"
426
+ else if (level >= 4) type = "info"
427
+ return h("span", { class: `level-${type}` }, String(row.rule_level))
428
+ }
429
+ },
430
+ {
431
+ title: "Summary",
432
+ key: "full_log",
433
+ ellipsis: { tooltip: true },
434
+ render(row) {
435
+ return row.full_log || row.data || row.message || "-"
436
+ }
437
+ }
438
+ ]
439
+
440
+ return baseColumns
441
+})
442
+
443
+// -- Event detail --
444
+const showDetailDrawer = ref(false)
445
+const selectedEvent = ref<EventSearchResult | null>(null)
446
+
447
+function rowProps(row: EventSearchResult) {
448
+ return {
449
+ style: "cursor: pointer",
450
+ onClick: () => {
451
+ selectedEvent.value = row
452
+ showDetailDrawer.value = true
453
+ }
454
+ }
455
+}
456
+
457
+function addFilterFromDetail(field: string, value: string) {
458
+ const filterExpr = `${field}:"${value}"`
459
+ if (query.value) {
460
+ query.value += ` AND ${filterExpr}`
461
+ } else {
462
+ query.value = filterExpr
463
+ }
464
+ showDetailDrawer.value = false
465
+ searchEvents()
466
+}
467
+
468
+function excludeFilterFromDetail(field: string, value: string) {
469
+ const filterExpr = `NOT ${field}:"${value}"`
470
+ if (query.value) {
471
+ query.value += ` AND ${filterExpr}`
472
+ } else {
473
+ query.value = filterExpr
474
+ }
475
+ showDetailDrawer.value = false
476
+ searchEvents()
477
+}
478
+
479
+// -- Lifecycle --
480
+function applyRouteParams() {
481
+ const qp = route.query
482
+ if (qp.customer_code) {
483
+ const code = String(qp.customer_code)
484
+ selectedCustomerCode.value = code
485
+
486
+ if (qp.query) {
487
+ query.value = String(qp.query)
488
+ }
489
+
490
+ // Load event sources then auto-select source_name if provided
491
+ loadingEventSources.value = true
492
+ Api.siem
493
+ .getEventSources(code)
494
+ .then(res => {
495
+ if (res.data.success) {
496
+ eventSourcesList.value = res.data?.event_sources || []
497
+
498
+ const targetSource = qp.source_name ? String(qp.source_name) : null
499
+ if (targetSource) {
500
+ // Try exact match first
501
+ const match = eventSourcesList.value.find(s => s.name === targetSource && s.enabled)
502
+ if (match) {
503
+ selectedSourceName.value = match.name
504
+ }
505
+ } else {
506
+ // Default to first EDR source if no source_name specified
507
+ const edr = eventSourcesList.value.find(s => s.event_type === "EDR" && s.enabled)
508
+ if (edr) {
509
+ selectedSourceName.value = edr.name
510
+ }
511
+ }
512
+
513
+ if (selectedSourceName.value) {
514
+ loadFieldMappings()
515
+ nextTick(() => searchEvents())
516
+ }
517
+ }
518
+ })
519
+ .finally(() => {
520
+ loadingEventSources.value = false
521
+ })
522
+ }
523
+}
524
+
525
+onBeforeMount(() => {
526
+ getCustomers().then(() => {
527
+ applyRouteParams()
528
+ })
529
+})
530
+</script>
531
+
532
+<style scoped>
533
+.suggestions-dropdown {
534
+ border-color: var(--border-color);
535
+}
536
+
537
+.level-error {
538
+ color: var(--error-color, #e88080);
539
+ font-weight: 600;
540
+}
541
+
542
+.level-warning {
543
+ color: var(--warning-color, #f0a020);
544
+ font-weight: 600;
545
+}
546
+
547
+.level-info {
548
+ color: var(--info-color, #70c0e8);
549
+}
550
+</style>