| 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> |