main
vue 278 lines 8.04 KB
Raw
1 <template>
2 <div class="flex flex-col gap-6">
3 <SearchForm
4 v-model:query="query"
5 :loading-events="loading"
6 @search="handleSearchFormSearch"
7 @loaded="handleSearchFormLoaded"
8 />
9
10 <!-- Results -->
11 <div v-if="hasSearched">
12 <!-- Results Summary -->
13 <div ref="headerRef" class="mb-1 flex w-full items-center">
14 <p class="text-sm">
15 Showing
16 <span class="font-semibold">{{ events.length }}</span>
17 of
18 <span class="font-semibold">{{ totalEvents }}</span>
19 events
20 </p>
21 </div>
22
23 <n-data-table
24 bordered
25 size="small"
26 :data="events"
27 :columns
28 :scroll-x="1200"
29 class="[&_.n-data-table-th\_\_title]:whitespace-nowrap"
30 >
31 <template #empty>
32 <n-empty description="No events found">
33 <template #extra>Try adjusting your query or expanding the time range</template>
34 </n-empty>
35 </template>
36 </n-data-table>
37
38 <!-- Load More -->
39 <div v-if="scrollId && events.length < totalEvents" class="mt-4 text-center">
40 <n-button :loading @click="loadMoreEvents">
41 {{ loading ? "Loading..." : `Showing ${events.length} of ${totalEvents} events • Load More` }}
42 </n-button>
43 </div>
44 </div>
45
46 <EventDetails
47 :event="selectedEvent"
48 @filter-add="addFilter"
49 @filter-exclude="excludeFilter"
50 @close="selectedEvent = null"
51 />
52 </div>
53 </template>
54
55 <script setup lang="tsx">
56 import type { DataTableColumn, DataTableColumns, TagProps } from "naive-ui"
57 import type { SearchFormLoad, SearchFormParams } from "@/components/eventSearch/SearchForm.vue"
58 import type { ApiError } from "@/types/common"
59 import type { DisplayColumn, EventSearchQueryParams, EventSearchResult, EventSourceItem } from "@/types/siem"
60 import { useElementSize } from "@vueuse/core"
61 import { NButton, NDataTable, NEmpty, useMessage } from "naive-ui"
62 import { computed, ref, useTemplateRef } from "vue"
63 import Api from "@/api"
64 import Chip from "@/components/common/Chip.vue"
65 import Icon from "@/components/common/Icon.vue"
66 import EventDetails from "@/components/eventSearch/EventDetails.vue"
67 import SearchForm from "@/components/eventSearch/SearchForm.vue"
68 import { useSettingsStore } from "@/stores/settings"
69 import { getApiErrorMessage } from "@/utils"
70 import { formatDate } from "@/utils/format"
71
72 const message = useMessage()
73 const dFormats = useSettingsStore().dateFormat
74
75 const searchFormParams = ref<SearchFormParams | null>(null)
76 const searchFormLoad = ref<SearchFormLoad | null>(null)
77 const query = ref<string | undefined>(undefined)
78
79 const events = ref<EventSearchResult[]>([])
80 const totalEvents = ref(0)
81 const scrollId = ref<string | null>(null)
82 const loading = ref(false)
83 const hasSearched = ref(false)
84
85 const selectedEvent = ref<EventSearchResult | null>(null)
86
87 function selectEvent(event: EventSearchResult) {
88 selectedEvent.value = event
89 }
90
91 const { width: headerWidthRef } = useElementSize(useTemplateRef("headerRef"))
92 const simpleMode = computed(() => headerWidthRef.value < 600)
93
94 const selectedEventSource = computed<EventSourceItem | null>(() => {
95 const sourceName = searchFormParams.value?.sourceName
96 const sources = searchFormLoad.value?.eventSources
97 if (!sourceName || !sources) return null
98 return sources.find(s => s.name === sourceName) ?? null
99 })
100
101 /** Walk a dotted path (e.g. "agent.name") through a nested object. */
102 function getNestedValue(obj: EventSearchResult, path: string): unknown {
103 return path.split(".").reduce<unknown>((acc, segment) => {
104 if (acc && typeof acc === "object") {
105 return (acc as Record<string, unknown>)[segment]
106 }
107 return undefined
108 }, obj)
109 }
110
111 function formatCellValue(val: unknown): string {
112 if (val === undefined || val === null || val === "") return "-"
113 if (Array.isArray(val)) return val.map(v => (v === null || v === undefined ? "" : String(v))).join(", ")
114 if (typeof val === "object") return JSON.stringify(val)
115 return String(val)
116 }
117
118 function buildColumnFromConfig(col: DisplayColumn): DataTableColumn<EventSearchResult> {
119 return {
120 title: col.label || col.key,
121 key: col.key,
122 width: col.width || undefined,
123 ellipsis: { tooltip: true },
124 render: row => <div>{formatCellValue(getNestedValue(row, col.key))}</div>
125 }
126 }
127
128 // Defaults preserved from the original hardcoded layout so behaviour is unchanged
129 // for event sources that haven't been configured yet.
130 const defaultColumns = computed<DataTableColumn<EventSearchResult>[]>(() => [
131 {
132 title: "Timestamp",
133 key: "Timestamp",
134 fixed: simpleMode.value ? undefined : "left",
135 width: 160,
136 render: row => <div class="font-mono">{formatDate(row.timestamp || row["@timestamp"], dFormats.datetime)}</div>
137 },
138 {
139 title: "Level",
140 key: "Level",
141 width: 60,
142 render: row => (
143 <Chip
144 type={levelClass(row.rule_level ?? row.rule?.level)}
145 value={row.rule_level ?? row.rule?.level ?? "-"}
146 round
147 />
148 )
149 },
150 {
151 title: "Source",
152 key: "Source",
153 render: row => <div>{row.agent_name || row.agent?.name || "-"}</div>
154 },
155 {
156 title: "Rule",
157 key: "Rule",
158 render: row => <div>{row.rule_description || row.rule?.description || "-"}</div>
159 }
160 ])
161
162 const actionsColumn = computed<DataTableColumn<EventSearchResult>>(() => ({
163 title: "Actions",
164 key: "actions",
165 width: 150,
166 fixed: simpleMode.value ? undefined : "right",
167 render: row => (
168 <NButton
169 onClick={() => selectEvent(row)}
170 v-slots={{
171 icon: () => <Icon name="carbon:view" />
172 }}
173 >
174 View Details
175 </NButton>
176 )
177 }))
178
179 const columns = computed<DataTableColumns<EventSearchResult>>(() => {
180 const configured = selectedEventSource.value?.displayed_columns
181 const dataColumns =
182 configured && configured.length > 0 ? configured.map(buildColumnFromConfig) : defaultColumns.value
183 // Always keep the View Details action at the right edge — it's the only way
184 // to open the event drawer from the table.
185 return [...dataColumns, actionsColumn.value]
186 })
187
188 function handleSearchFormSearch(params: SearchFormParams) {
189 searchFormParams.value = params
190 searchEvents()
191 }
192
193 function handleSearchFormLoaded(load: SearchFormLoad) {
194 searchFormLoad.value = load
195 }
196
197 function resetResults() {
198 events.value = []
199 totalEvents.value = 0
200 scrollId.value = null
201 hasSearched.value = false
202 }
203
204 async function searchEvents() {
205 if (!searchFormParams.value) return
206 const payload = searchFormParams.value
207
208 if (!payload.customerCode || !payload.sourceName) return
209
210 loading.value = true
211 resetResults()
212
213 try {
214 const params: EventSearchQueryParams = {
215 page_size: payload.pageSize,
216 query: payload.query || undefined
217 }
218 if (payload.timeMode === "absolute" && payload.timeFrom && payload.timeTo) {
219 params.time_from = new Date(payload.timeFrom).toISOString()
220 params.time_to = new Date(payload.timeTo).toISOString()
221 } else {
222 params.timerange = payload.timerange
223 }
224
225 const response = await Api.siem.queryEvents(payload.customerCode, payload.sourceName, params)
226
227 events.value = response.data.events
228 totalEvents.value = response.data.total
229 scrollId.value = response.data.scroll_id
230 hasSearched.value = true
231 } catch (err) {
232 message.error(getApiErrorMessage(err as ApiError) || "Failed to search events")
233 } finally {
234 loading.value = false
235 }
236 }
237
238 async function loadMoreEvents() {
239 if (!scrollId.value) return
240
241 loading.value = true
242
243 try {
244 const response = await Api.siem.queryEvents(
245 searchFormParams.value?.customerCode || "",
246 searchFormParams.value?.sourceName || "",
247 {
248 scroll_id: scrollId.value
249 }
250 )
251
252 events.value.push(...response.data.events)
253 scrollId.value = response.data.scroll_id
254 } catch (err) {
255 message.error(getApiErrorMessage(err as ApiError) || "Failed to load more events")
256 } finally {
257 loading.value = false
258 }
259 }
260
261 function addFilter(field: string, value: string) {
262 const clause = `${field}:"${value}"`
263 query.value = query.value ? `${query.value} AND ${clause}` : clause
264 }
265
266 function excludeFilter(field: string, value: string) {
267 const clause = `NOT ${field}:"${value}"`
268 query.value = query.value ? `${query.value} AND ${clause}` : clause
269 }
270
271 function levelClass(level: number | undefined): TagProps["type"] | undefined {
272 if (level === undefined || level === null) return undefined
273 if (level >= 12) return "error"
274 if (level >= 8) return "warning"
275 if (level >= 4) return "info"
276 return "default"
277 }
278 </script>