| 1 | <template> |
| 2 | <div class="flex flex-col gap-1"> |
| 3 | <div class="flex items-end justify-between gap-3"> |
| 4 | <p class="py-1 text-sm"> |
| 5 | Showing |
| 6 | <span class="font-semibold">{{ events.length }}</span> |
| 7 | of |
| 8 | <span class="font-semibold">{{ totalEvents }}</span> |
| 9 | events |
| 10 | </p> |
| 11 | |
| 12 | <div class="flex flex-wrap items-center gap-3"> |
| 13 | <n-tooltip class="px-2! py-1! text-xs!"> |
| 14 | <template #trigger> |
| 15 | <Icon :name="InfoIcon" :size="15" class="cursor-help" /> |
| 16 | </template> |
| 17 | Click table row to view event details |
| 18 | </n-tooltip> |
| 19 | <n-button |
| 20 | text |
| 21 | :disabled="!eventSource" |
| 22 | title="Configure which columns to display for this event source" |
| 23 | @click="emit('configure-columns')" |
| 24 | > |
| 25 | <template #icon> |
| 26 | <Icon :name="SettingsIcon" :size="15" /> |
| 27 | </template> |
| 28 | Columns |
| 29 | </n-button> |
| 30 | </div> |
| 31 | </div> |
| 32 | |
| 33 | <n-data-table |
| 34 | :columns |
| 35 | :loading="loadingEvents" |
| 36 | :data="events" |
| 37 | size="small" |
| 38 | :scroll-x |
| 39 | :row-key="(row: EventSearchResult) => row._id || JSON.stringify(row)" |
| 40 | :row-props |
| 41 | class="[&_.n-data-table-th\_\_title]:whitespace-nowrap" |
| 42 | > |
| 43 | <template #empty> |
| 44 | <n-empty description="No events found"> |
| 45 | <template #extra>Try adjusting your query or expanding the time range</template> |
| 46 | </n-empty> |
| 47 | </template> |
| 48 | </n-data-table> |
| 49 | |
| 50 | <div v-if="scrollId && events.length < totalEvents" class="mt-3 flex justify-center"> |
| 51 | <n-button :loading="loadingMore" @click="emit('load-more')">Load More</n-button> |
| 52 | </div> |
| 53 | </div> |
| 54 | </template> |
| 55 | |
| 56 | <script setup lang="ts"> |
| 57 | import type { DataTableColumns } from "naive-ui" |
| 58 | import type { EventSearchResult } from "@/types/events.d" |
| 59 | import type { DisplayColumn, EventSource } from "@/types/eventSources.d" |
| 60 | import { NButton, NDataTable, NEmpty, NTooltip } from "naive-ui" |
| 61 | import { computed, h } from "vue" |
| 62 | import Icon from "@/components/common/Icon.vue" |
| 63 | |
| 64 | const props = defineProps<{ |
| 65 | events: EventSearchResult[] |
| 66 | totalEvents: number |
| 67 | loadingEvents: boolean |
| 68 | loadingMore: boolean |
| 69 | hasSearched: boolean |
| 70 | scrollId: string | null |
| 71 | eventSource: EventSource | null |
| 72 | }>() |
| 73 | |
| 74 | const emit = defineEmits<{ |
| 75 | "load-more": [] |
| 76 | "configure-columns": [] |
| 77 | "row-select": [event: EventSearchResult] |
| 78 | }>() |
| 79 | |
| 80 | const MIN_COLUMN_WIDTH = 120 |
| 81 | const SettingsIcon = "carbon:settings" |
| 82 | const InfoIcon = "carbon:information" |
| 83 | |
| 84 | function resolveColumnWidth(width?: number | null): number { |
| 85 | if (width == null || width < MIN_COLUMN_WIDTH) return MIN_COLUMN_WIDTH |
| 86 | return width |
| 87 | } |
| 88 | |
| 89 | function normalizeColumns(cols: DataTableColumns<EventSearchResult>): DataTableColumns<EventSearchResult> { |
| 90 | return cols.map(col => ({ |
| 91 | ...col, |
| 92 | width: resolveColumnWidth(typeof col.width === "number" ? col.width : undefined) |
| 93 | })) |
| 94 | } |
| 95 | |
| 96 | const defaultColumns: DataTableColumns<EventSearchResult> = [ |
| 97 | { |
| 98 | title: "Timestamp", |
| 99 | key: "timestamp", |
| 100 | width: 180, |
| 101 | sorter: (a, b) => { |
| 102 | const timeA = a.timestamp || a["@timestamp"] || "" |
| 103 | const timeB = b.timestamp || b["@timestamp"] || "" |
| 104 | return new Date(timeA).getTime() - new Date(timeB).getTime() |
| 105 | }, |
| 106 | render(row) { |
| 107 | const ts = row.timestamp || row["@timestamp"] |
| 108 | if (!ts) return "-" |
| 109 | return new Date(ts).toLocaleString() |
| 110 | } |
| 111 | }, |
| 112 | { |
| 113 | title: "Source", |
| 114 | key: "agent_name", |
| 115 | width: 140, |
| 116 | ellipsis: { tooltip: true }, |
| 117 | render(row) { |
| 118 | return row.agent_name || row.source || "-" |
| 119 | } |
| 120 | }, |
| 121 | { |
| 122 | title: "Rule", |
| 123 | key: "rule_description", |
| 124 | width: 200, |
| 125 | ellipsis: { tooltip: true }, |
| 126 | render(row) { |
| 127 | return row.rule_description || row.rule_id || "-" |
| 128 | } |
| 129 | }, |
| 130 | { |
| 131 | title: "Level", |
| 132 | key: "rule_level", |
| 133 | width: 80, |
| 134 | sorter: (a, b) => (Number(a.rule_level) || 0) - (Number(b.rule_level) || 0), |
| 135 | render(row) { |
| 136 | if (row.rule_level === undefined || row.rule_level === null) return "-" |
| 137 | const level = Number(row.rule_level) |
| 138 | let type: "default" | "warning" | "error" | "success" | "info" = "default" |
| 139 | if (level >= 12) type = "error" |
| 140 | else if (level >= 8) type = "warning" |
| 141 | else if (level >= 4) type = "info" |
| 142 | return h("span", { class: `level-${type}` }, String(row.rule_level)) |
| 143 | } |
| 144 | }, |
| 145 | { |
| 146 | title: "Summary", |
| 147 | key: "full_log", |
| 148 | width: 320, |
| 149 | ellipsis: { tooltip: true }, |
| 150 | render(row) { |
| 151 | return row.full_log || row.data || row.message || "-" |
| 152 | } |
| 153 | } |
| 154 | ] |
| 155 | |
| 156 | function getNestedValue(obj: EventSearchResult, path: string): unknown { |
| 157 | return path.split(".").reduce<unknown>((acc, segment) => { |
| 158 | if (acc && typeof acc === "object") { |
| 159 | return (acc as Record<string, unknown>)[segment] |
| 160 | } |
| 161 | return undefined |
| 162 | }, obj) |
| 163 | } |
| 164 | |
| 165 | function formatCellValue(val: unknown): string { |
| 166 | if (val === undefined || val === null || val === "") return "-" |
| 167 | if (Array.isArray(val)) return val.map(v => (v === null || v === undefined ? "" : String(v))).join(", ") |
| 168 | if (typeof val === "object") return JSON.stringify(val) |
| 169 | return String(val) |
| 170 | } |
| 171 | |
| 172 | function buildColumnFromConfig(col: DisplayColumn): DataTableColumns<EventSearchResult>[number] { |
| 173 | return { |
| 174 | title: col.label || col.key, |
| 175 | key: col.key, |
| 176 | width: resolveColumnWidth(col.width), |
| 177 | ellipsis: { tooltip: true }, |
| 178 | render(row: EventSearchResult) { |
| 179 | return formatCellValue(getNestedValue(row, col.key)) |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | const columns = computed<DataTableColumns<EventSearchResult>>(() => { |
| 185 | const configured = props.eventSource?.displayed_columns |
| 186 | if (configured && configured.length > 0) { |
| 187 | return normalizeColumns(configured.map(buildColumnFromConfig)) |
| 188 | } |
| 189 | return normalizeColumns(defaultColumns) |
| 190 | }) |
| 191 | |
| 192 | const scrollX = computed(() => |
| 193 | columns.value.reduce( |
| 194 | (sum, col) => sum + resolveColumnWidth(typeof col.width === "number" ? col.width : undefined), |
| 195 | 0 |
| 196 | ) |
| 197 | ) |
| 198 | |
| 199 | function rowProps(row: EventSearchResult) { |
| 200 | return { |
| 201 | style: "cursor: pointer", |
| 202 | onClick: () => { |
| 203 | emit("row-select", row) |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | </script> |