64
</template>
65
Search
66
</n-button>
67
+ <n-button
68
+ quaternary
69
+ :disabled="!selectedEventSource"
70
+ title="Configure which columns to display for this event source"
71
+ @click="showColumnConfig = true"
72
+ >
73
+ <template #icon>
74
+ <Icon :name="SettingsIcon" :size="16" />
75
+ </template>
76
+ Columns
77
+ </n-button>
78
</div>
79
80
<!-- Query Bar with Autocomplete -->
149
@filter-add="addFilterFromDetail"
150
@filter-exclude="excludeFilterFromDetail"
151
/>
152
+
153
+ <!-- Column Config Modal -->
154
+ <ColumnConfigModal
155
+ v-model:show="showColumnConfig"
156
+ :event-source="selectedEventSource"
157
+ :field-mappings
158
+ @saved="onColumnsSaved"
159
+ />
160
</div>
161
</template>
162
164
import type { DataTableColumns } from "naive-ui"
165
import type { Customer } from "@/types/customers.d"
166
import type { EventSearchResult, FieldMapping } from "@/types/events.d"
148
-import type { EventSource } from "@/types/eventSources.d"
167
+import type { DisplayColumn, EventSource } from "@/types/eventSources.d"
168
import { NAlert, NButton, NCard, NDataTable, NDatePicker, NEmpty, NInput, NSelect, NSpin, useMessage } from "naive-ui"
169
import { computed, h, nextTick, onBeforeMount, ref } from "vue"
170
import { useRoute } from "vue-router"
171
import Api from "@/api"
172
import Icon from "@/components/common/Icon.vue"
173
+import ColumnConfigModal from "./ColumnConfigModal.vue"
174
import EventDetailDrawer from "./EventDetailDrawer.vue"
175
176
const route = useRoute()
177
178
const SearchIcon = "carbon:search"
179
const CodeIcon = "carbon:code"
180
+const SettingsIcon = "carbon:settings"
181
const FIELD_TOKEN_REGEX = /(?:^|[\s(])([a-z_][\w.]*)$/i
182
183
const message = useMessage()
223
() => selectedCustomerCode.value && !loadingEventSources.value && eventSourcesList.value.length === 0
224
)
225
226
+const selectedEventSource = computed<EventSource | null>(
227
+ () => eventSourcesList.value.find(s => s.name === selectedSourceName.value) ?? null
228
+)
229
+
230
// -- Field mappings / autocomplete --
231
const fieldMappings = ref<FieldMapping[]>([])
232
422
}
423
424
// -- Table columns --
400
-const columns = computed<DataTableColumns<EventSearchResult>>(() => {
401
- const baseColumns: DataTableColumns<EventSearchResult> = [
402
- {
403
- title: "Timestamp",
404
- key: "timestamp",
405
- width: 180,
406
- sorter: (a, b) => {
407
- const timeA = a.timestamp || a["@timestamp"] || ""
408
- const timeB = b.timestamp || b["@timestamp"] || ""
409
- return new Date(timeA).getTime() - new Date(timeB).getTime()
410
- },
411
- render(row) {
412
- const ts = row.timestamp || row["@timestamp"]
413
- if (!ts) return "-"
414
- return new Date(ts).toLocaleString()
415
- }
416
- },
417
- {
418
- title: "Source",
419
- key: "agent_name",
420
- width: 140,
421
- ellipsis: { tooltip: true },
422
- render(row) {
423
- return row.agent_name || row.source || "-"
424
- }
425
- },
426
- {
427
- title: "Rule",
428
- key: "rule_description",
429
- ellipsis: { tooltip: true },
430
- render(row) {
431
- return row.rule_description || row.rule_id || "-"
432
- }
425
+// Default columns we fall back to when an event source has no displayed_columns
426
+// configured. These keep the prior behaviour for un-customised sources.
427
+const defaultColumns: DataTableColumns<EventSearchResult> = [
428
+ {
429
+ title: "Timestamp",
430
+ key: "timestamp",
431
+ width: 180,
432
+ sorter: (a, b) => {
433
+ const timeA = a.timestamp || a["@timestamp"] || ""
434
+ const timeB = b.timestamp || b["@timestamp"] || ""
435
+ return new Date(timeA).getTime() - new Date(timeB).getTime()
436
},
434
- {
435
- title: "Level",
436
- key: "rule_level",
437
- width: 80,
438
- sorter: (a, b) => (Number(a.rule_level) || 0) - (Number(b.rule_level) || 0),
439
- render(row) {
440
- if (row.rule_level === undefined || row.rule_level === null) return "-"
441
- const level = Number(row.rule_level)
442
- let type: "default" | "warning" | "error" | "success" | "info" = "default"
443
- if (level >= 12) type = "error"
444
- else if (level >= 8) type = "warning"
445
- else if (level >= 4) type = "info"
446
- return h("span", { class: `level-${type}` }, String(row.rule_level))
447
- }
448
- },
449
- {
450
- title: "Summary",
451
- key: "full_log",
452
- ellipsis: { tooltip: true },
453
- render(row) {
454
- return row.full_log || row.data || row.message || "-"
455
- }
437
+ render(row) {
438
+ const ts = row.timestamp || row["@timestamp"]
439
+ if (!ts) return "-"
440
+ return new Date(ts).toLocaleString()
441
+ }
442
+ },
443
+ {
444
+ title: "Source",
445
+ key: "agent_name",
446
+ width: 140,
447
+ ellipsis: { tooltip: true },
448
+ render(row) {
449
+ return row.agent_name || row.source || "-"
450
+ }
451
+ },
452
+ {
453
+ title: "Rule",
454
+ key: "rule_description",
455
+ ellipsis: { tooltip: true },
456
+ render(row) {
457
+ return row.rule_description || row.rule_id || "-"
458
}
457
- ]
459
+ },
460
+ {
461
+ title: "Level",
462
+ key: "rule_level",
463
+ width: 80,
464
+ sorter: (a, b) => (Number(a.rule_level) || 0) - (Number(b.rule_level) || 0),
465
+ render(row) {
466
+ if (row.rule_level === undefined || row.rule_level === null) return "-"
467
+ const level = Number(row.rule_level)
468
+ let type: "default" | "warning" | "error" | "success" | "info" = "default"
469
+ if (level >= 12) type = "error"
470
+ else if (level >= 8) type = "warning"
471
+ else if (level >= 4) type = "info"
472
+ return h("span", { class: `level-${type}` }, String(row.rule_level))
473
+ }
474
+ },
475
+ {
476
+ title: "Summary",
477
+ key: "full_log",
478
+ ellipsis: { tooltip: true },
479
+ render(row) {
480
+ return row.full_log || row.data || row.message || "-"
481
+ }
482
+ }
483
+]
484
+
485
+/** Walk a dotted path (e.g. "agent.name") through a nested object. */
486
+function getNestedValue(obj: EventSearchResult, path: string): unknown {
487
+ return path.split(".").reduce<unknown>((acc, segment) => {
488
+ if (acc && typeof acc === "object") {
489
+ return (acc as Record<string, unknown>)[segment]
490
+ }
491
+ return undefined
492
+ }, obj)
493
+}
494
+
495
+function formatCellValue(val: unknown): string {
496
+ if (val === undefined || val === null || val === "") return "-"
497
+ if (Array.isArray(val)) return val.map(v => (v === null || v === undefined ? "" : String(v))).join(", ")
498
+ if (typeof val === "object") return JSON.stringify(val)
499
+ return String(val)
500
+}
501
+
502
+function buildColumnFromConfig(col: DisplayColumn): DataTableColumns<EventSearchResult>[number] {
503
+ return {
504
+ title: col.label || col.key,
505
+ key: col.key,
506
+ width: col.width || undefined,
507
+ ellipsis: { tooltip: true },
508
+ render(row: EventSearchResult) {
509
+ return formatCellValue(getNestedValue(row, col.key))
510
+ }
511
+ }
512
+}
513
459
- return baseColumns
514
+const columns = computed<DataTableColumns<EventSearchResult>>(() => {
515
+ const configured = selectedEventSource.value?.displayed_columns
516
+ if (configured && configured.length > 0) {
517
+ return configured.map(buildColumnFromConfig)
518
+ }
519
+ return defaultColumns
520
})
521
522
+// -- Configure columns modal --
523
+const showColumnConfig = ref(false)
524
+
525
+function onColumnsSaved(saved: DisplayColumn[] | null) {
526
+ // Patch the local list so the table re-renders without a network round-trip.
527
+ if (selectedEventSource.value) {
528
+ selectedEventSource.value.displayed_columns = saved
529
+ }
530
+}
531
+
532
// -- Event detail --
533
const showDetailDrawer = ref(false)
534
const selectedEvent = ref<EventSearchResult | null>(null)