main
vue 237 lines 6.76 KB
Raw
1 <template>
2 <div class="flex flex-col gap-6">
3 <!-- Active columns -->
4 <section class="flex min-w-0 flex-col gap-3">
5 <div class="flex items-start justify-between gap-3">
6 <div>
7 <p class="text-secondary text-[10px] font-medium tracking-widest uppercase">Active columns</p>
8 <p class="text-secondary mt-1 text-xs">Order matches table left-to-right</p>
9 </div>
10 <span class="text-default shrink-0 font-mono text-xs tabular-nums">{{ localColumns.length }}</span>
11 </div>
12
13 <n-empty
14 v-if="!localColumns.length"
15 size="small"
16 description="No custom columns"
17 class="border-border rounded-lg border border-dashed py-6"
18 >
19 <template #extra>
20 <p class="text-secondary max-w-56 text-center text-xs">
21 Default table columns will be shown until you add fields below.
22 </p>
23 </template>
24 </n-empty>
25
26 <div v-else class="flex flex-col gap-1.5">
27 <div
28 v-for="(col, idx) in localColumns"
29 :key="col.key"
30 class="bg-default hover:bg-hover-005 group flex items-center gap-2 rounded-md py-2 transition-colors"
31 >
32 <span class="text-secondary w-5 shrink-0 text-center font-mono text-[10px] tabular-nums">
33 {{ idx + 1 }}
34 </span>
35 <n-input v-model:value="col.label" size="small" placeholder="Header label" />
36 <span class="text-secondary min-w-0 flex-1 truncate font-mono text-xs" :title="col.key">
37 {{ col.key }}
38 </span>
39 <div class="flex shrink-0 gap-0.5 opacity-60 transition-opacity group-hover:opacity-100">
40 <n-button
41 size="tiny"
42 quaternary
43 :disabled="idx === 0"
44 title="Move up"
45 @click="moveColumn(idx, -1)"
46 >
47 <template #icon>
48 <Icon :name="ArrowUpIcon" :size="14" />
49 </template>
50 </n-button>
51 <n-button
52 size="tiny"
53 quaternary
54 :disabled="idx === localColumns.length - 1"
55 title="Move down"
56 @click="moveColumn(idx, 1)"
57 >
58 <template #icon>
59 <Icon :name="ArrowDownIcon" :size="14" />
60 </template>
61 </n-button>
62 <n-button size="tiny" quaternary type="error" title="Remove column" @click="removeColumn(idx)">
63 <template #icon>
64 <Icon :name="CloseIcon" :size="14" />
65 </template>
66 </n-button>
67 </div>
68 </div>
69 </div>
70 </section>
71
72 <!-- Add column from available fields -->
73 <section class="flex min-w-0 flex-col gap-3">
74 <div>
75 <p class="text-secondary text-[10px] font-medium tracking-widest uppercase">Add column</p>
76 <p class="text-secondary mt-1 text-xs">Pick a mapped field not already in the table</p>
77 </div>
78
79 <n-input v-model:value="fieldFilter" placeholder="Filter fields…" clearable size="small">
80 <template #prefix>
81 <Icon :name="SearchIcon" :size="14" class="text-secondary" />
82 </template>
83 </n-input>
84
85 <n-card embedded content-class="p-0!">
86 <n-scrollbar
87 v-if="filteredFields.length"
88 class="max-h-72"
89 trigger="none"
90 content-class="flex flex-col gap-0 py-2"
91 >
92 <n-button
93 v-for="field in filteredFields"
94 :key="field.field"
95 quaternary
96 class="[&_.n-button\_\_content]:w-full"
97 @click="addColumn(field.field)"
98 >
99 <div class="flex w-full items-center justify-between gap-3">
100 <span class="text-default min-w-0 truncate font-mono text-xs">{{ field.field }}</span>
101 <span class="text-secondary shrink-0 text-[10px] tracking-wider uppercase">
102 {{ field.type }}
103 </span>
104 </div>
105 </n-button>
106 </n-scrollbar>
107
108 <div v-else class="text-secondary px-3 py-6 text-center text-xs">
109 {{ emptyFieldsMessage }}
110 </div>
111 </n-card>
112 </section>
113 </div>
114 </template>
115
116 <script setup lang="ts">
117 import type { FieldMapping } from "@/types/events.d"
118 import type { DisplayColumn, EventSource } from "@/types/eventSources.d"
119 import { NButton, NCard, NEmpty, NInput, NScrollbar, useMessage } from "naive-ui"
120 import { computed, ref, watch } from "vue"
121 import Api from "@/api"
122 import Icon from "@/components/common/Icon.vue"
123
124 const props = defineProps<{
125 open: boolean
126 eventSource: EventSource | null
127 fieldMappings: FieldMapping[]
128 loadingFieldMappings?: boolean
129 }>()
130
131 const emit = defineEmits<{
132 close: []
133 saved: [columns: DisplayColumn[] | null]
134 }>()
135
136 const SearchIcon = "carbon:search"
137 const CloseIcon = "carbon:close"
138 const ArrowUpIcon = "carbon:arrow-up"
139 const ArrowDownIcon = "carbon:arrow-down"
140
141 const message = useMessage()
142
143 const localColumns = ref<DisplayColumn[]>([])
144 const fieldFilter = ref("")
145 const saving = ref(false)
146
147 watch(
148 () => [props.open, props.eventSource?.id] as const,
149 ([open]) => {
150 if (open && props.eventSource) {
151 localColumns.value = (props.eventSource.displayed_columns ?? []).map(c => ({
152 key: c.key,
153 label: c.label,
154 width: c.width ?? null
155 }))
156 fieldFilter.value = ""
157 }
158 },
159 { immediate: true }
160 )
161
162 const usedKeys = computed(() => new Set(localColumns.value.map(c => c.key)))
163
164 const filteredFields = computed(() => {
165 const q = fieldFilter.value.trim().toLowerCase()
166 const all = props.fieldMappings.filter(f => !usedKeys.value.has(f.field))
167 if (!q) return all
168 return all.filter(f => f.field.toLowerCase().includes(q))
169 })
170
171 const emptyFieldsMessage = computed(() => {
172 if (props.loadingFieldMappings) return "Loading available fields…"
173 if (!props.fieldMappings.length) return "No field mappings available for this source."
174 if (fieldFilter.value.trim()) return "No fields match your filter."
175 return "All mapped fields are already in the table."
176 })
177
178 function defaultLabelFor(fieldPath: string): string {
179 const tail = fieldPath.split(".").pop() ?? fieldPath
180 return tail.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase())
181 }
182
183 function addColumn(fieldPath: string) {
184 if (usedKeys.value.has(fieldPath)) return
185 localColumns.value.push({
186 key: fieldPath,
187 label: defaultLabelFor(fieldPath),
188 width: null
189 })
190 }
191
192 function removeColumn(idx: number) {
193 localColumns.value.splice(idx, 1)
194 }
195
196 function moveColumn(idx: number, delta: number) {
197 const target = idx + delta
198 if (target < 0 || target >= localColumns.value.length) return
199 const [item] = localColumns.value.splice(idx, 1)
200 localColumns.value.splice(target, 0, item)
201 }
202
203 function resetToDefaults() {
204 localColumns.value = []
205 }
206
207 function onSave() {
208 if (!props.eventSource) return
209 saving.value = true
210
211 const payload = localColumns.value.length ? localColumns.value : null
212
213 Api.siem
214 .updateEventSource(props.eventSource.id, { displayed_columns: payload })
215 .then(res => {
216 if (res.data.success) {
217 message.success("Columns saved")
218 emit("saved", payload)
219 emit("close")
220 } else {
221 message.warning(res.data?.message || "Failed to save columns")
222 }
223 })
224 .catch(err => {
225 message.error(err.response?.data?.message || "Failed to save columns")
226 })
227 .finally(() => {
228 saving.value = false
229 })
230 }
231
232 defineExpose({
233 saving,
234 resetToDefaults,
235 onSave
236 })
237 </script>