main
vue 263 lines 7.45 KB
Raw
1 <template>
2 <n-modal
3 v-model:show="showLocal"
4 preset="card"
5 :style="{ maxWidth: 'min(560px, 92vw)' }"
6 title="Bulk Provision Graylog Alerts"
7 :bordered="false"
8 segmented
9 >
10 <div v-if="!result" class="flex flex-col gap-3">
11 <n-alert type="info" :show-icon="false">
12 This will create one Graylog event definition per rule, using the shared configuration below. Rules
13 whose alert title already exists in Graylog are skipped automatically. Rules without a Graylog query are
14 also skipped.
15 </n-alert>
16
17 <div class="grid grid-cols-2 gap-3">
18 <div class="flex flex-col gap-1">
19 <label class="text-secondary text-xs">Search within (seconds)</label>
20 <n-input-number v-model:value="config.search_within_seconds" :min="60" :max="86400" size="small" />
21 </div>
22 <div class="flex flex-col gap-1">
23 <label class="text-secondary text-xs">Execute every (seconds)</label>
24 <n-input-number v-model:value="config.execute_every_seconds" :min="60" :max="86400" size="small" />
25 </div>
26 <div class="flex flex-col gap-1">
27 <label class="text-secondary text-xs">Priority</label>
28 <n-select v-model:value="config.priority" :options="priorityOptions" size="small" />
29 </div>
30 <div class="flex flex-col gap-1">
31 <label class="text-secondary text-xs">Event limit</label>
32 <n-input-number v-model:value="config.event_limit" :min="1" :max="10000" size="small" />
33 </div>
34 </div>
35
36 <div class="text-secondary text-xs">
37 About to provision
38 <strong>{{ provisionableCount }}</strong>
39 rule{{ provisionableCount === 1 ? "" : "s" }}.
40 </div>
41
42 <div class="flex justify-end gap-2">
43 <n-button size="small" quaternary :disabled="submitting" @click="showLocal = false">Cancel</n-button>
44 <n-button
45 size="small"
46 type="primary"
47 :loading="submitting"
48 :disabled="!provisionableCount"
49 @click="submit"
50 >
51 Provision {{ provisionableCount }} rule{{ provisionableCount === 1 ? "" : "s" }}
52 </n-button>
53 </div>
54 </div>
55
56 <div v-else class="flex flex-col gap-3">
57 <div class="grid grid-cols-3 gap-2">
58 <div class="result-stat" :class="{ 'is-active result-provisioned': result.provisioned_count > 0 }">
59 <div class="result-num">{{ result.provisioned_count }}</div>
60 <div class="result-label">Provisioned</div>
61 </div>
62 <div class="result-stat" :class="{ 'is-active': result.skipped_count > 0 }">
63 <div class="result-num">{{ result.skipped_count }}</div>
64 <div class="result-label">Skipped</div>
65 </div>
66 <div class="result-stat" :class="{ 'is-active result-failed': result.failed_count > 0 }">
67 <div class="result-num">{{ result.failed_count }}</div>
68 <div class="result-label">Failed</div>
69 </div>
70 </div>
71
72 <div class="bulk-results-list">
73 <div v-for="r of result.results" :key="r.rule_id" class="bulk-result-row">
74 <div class="flex min-w-0 flex-col">
75 <div class="text-default truncate text-sm">{{ r.rule_name || r.rule_id }}</div>
76 <div v-if="r.reason" class="text-tertiary truncate text-xs">{{ r.reason }}</div>
77 </div>
78 <Badge :color="statusBadgeColor(r.status)" size="small">
79 <template #value>{{ r.status }}</template>
80 </Badge>
81 </div>
82 </div>
83
84 <div class="flex justify-end">
85 <n-button size="small" type="primary" @click="close">Done</n-button>
86 </div>
87 </div>
88 </n-modal>
89 </template>
90
91 <script setup lang="ts">
92 import type { BadgeColor } from "@/components/common/Badge.vue"
93 import type { BulkProvisionGraylogAlertResponse, BulkProvisionRuleStatus } from "@/types/copilotSearches.d"
94 import { NAlert, NButton, NInputNumber, NModal, NSelect, useMessage } from "naive-ui"
95 import { computed, reactive, ref, watch } from "vue"
96 import Api from "@/api"
97 import Badge from "@/components/common/Badge.vue"
98
99 const props = defineProps<{
100 show: boolean
101 /** Rule IDs to provision. Caller should pass only IDs that have a Graylog query if known. */
102 ruleIds: string[]
103 /** Optional override for the displayed count. Defaults to ruleIds.length. */
104 provisionableCount?: number
105 }>()
106
107 const emit = defineEmits<{
108 (e: "update:show", value: boolean): void
109 (e: "success", result: BulkProvisionGraylogAlertResponse): void
110 }>()
111
112 const message = useMessage()
113
114 const showLocal = computed({
115 get: () => props.show,
116 set: v => emit("update:show", v)
117 })
118
119 const submitting = ref(false)
120 const result = ref<BulkProvisionGraylogAlertResponse | null>(null)
121
122 const config = reactive({
123 search_within_seconds: 300,
124 execute_every_seconds: 300,
125 priority: 2 as 1 | 2 | 3,
126 event_limit: 1000
127 })
128
129 const priorityOptions = [
130 { label: "Low", value: 1 },
131 { label: "Normal", value: 2 },
132 { label: "High", value: 3 }
133 ]
134
135 const provisionableCount = computed(() => props.provisionableCount ?? props.ruleIds.length)
136
137 async function submit() {
138 if (!props.ruleIds.length) return
139 submitting.value = true
140 try {
141 const res = await Api.copilotSearches.bulkProvisionGraylogAlerts({
142 rule_ids: props.ruleIds,
143 search_within_seconds: config.search_within_seconds,
144 execute_every_seconds: config.execute_every_seconds,
145 priority: config.priority,
146 event_limit: config.event_limit
147 })
148 result.value = res.data
149 emit("success", res.data)
150 if (res.data.failed_count === 0) message.success(res.data.message)
151 else message.warning(res.data.message)
152 } catch (err: any) {
153 message.error(err.response?.data?.message || "Bulk provision failed")
154 } finally {
155 submitting.value = false
156 }
157 }
158
159 function close() {
160 showLocal.value = false
161 setTimeout(() => {
162 result.value = null
163 }, 250)
164 }
165
166 // Reset result when the modal opens fresh OR the rule set changes
167 watch(
168 () => props.show,
169 open => {
170 if (open) result.value = null
171 }
172 )
173 watch(
174 () => props.ruleIds,
175 () => {
176 result.value = null
177 },
178 { deep: true }
179 )
180
181 function statusBadgeColor(status: BulkProvisionRuleStatus): BadgeColor | undefined {
182 switch (status) {
183 case "provisioned":
184 return "success"
185 case "failed":
186 return "danger"
187 case "skipped":
188 return undefined
189 }
190 }
191 </script>
192
193 <style scoped lang="scss">
194 .result-stat {
195 border: 1px solid var(--border-color);
196 border-radius: var(--border-radius);
197 background: var(--bg-default-color);
198 padding: 10px;
199 text-align: center;
200 color: var(--fg-secondary-color);
201 }
202 .result-stat.is-active {
203 color: var(--fg-default-color);
204 }
205 .result-stat.result-provisioned.is-active {
206 border-color: rgba(var(--success-color-rgb) / 0.45);
207 background: rgba(var(--success-color-rgb) / 0.06);
208 }
209 .result-stat.result-failed.is-active {
210 border-color: rgba(var(--error-color-rgb) / 0.45);
211 background: rgba(var(--error-color-rgb) / 0.06);
212 }
213 .result-num {
214 font-size: 1.4rem;
215 font-weight: 700;
216 line-height: 1;
217 }
218 .result-stat.is-active .result-num {
219 color: inherit;
220 }
221 .result-stat.result-provisioned.is-active .result-num {
222 color: var(--success-color);
223 }
224 .result-stat.result-failed.is-active .result-num {
225 color: var(--error-color);
226 }
227 .result-label {
228 font-size: 0.7rem;
229 color: var(--fg-tertiary-color);
230 text-transform: uppercase;
231 letter-spacing: 0.04em;
232 margin-top: 4px;
233 }
234
235 .bulk-results-list {
236 max-height: 320px;
237 overflow-y: auto;
238 display: flex;
239 flex-direction: column;
240 border: 1px solid var(--border-color);
241 border-radius: var(--border-radius);
242 background: var(--bg-default-color);
243 }
244
245 .bulk-result-row {
246 display: flex;
247 align-items: center;
248 justify-content: space-between;
249 gap: 12px;
250 padding: 8px 12px;
251 }
252 .bulk-result-row + .bulk-result-row {
253 border-top: 1px solid var(--border-color);
254 }
255 .bulk-result-row:hover {
256 background: rgba(var(--primary-color-rgb) / 0.04);
257 }
258
259 .bulk-result-row :deep(.badge) {
260 flex-shrink: 0;
261 white-space: nowrap;
262 }
263 </style>