| 1 | <template> |
| 2 | <n-spin :show="loading"> |
| 3 | <n-form ref="formRef" :label-width="80" :model="form" :rules> |
| 4 | <div class="flex flex-col gap-2"> |
| 5 | <div class="flex gap-4"> |
| 6 | <n-form-item label="Priority" path="alert_priority" class="w-28"> |
| 7 | <n-select |
| 8 | v-model:value="form.alert_priority" |
| 9 | :options="alertPriorityOptions" |
| 10 | placeholder="Select..." |
| 11 | clearable |
| 12 | /> |
| 13 | </n-form-item> |
| 14 | <n-form-item label="Name" path="alert_name" class="grow"> |
| 15 | <n-input |
| 16 | v-model:value.trim="form.alert_name" |
| 17 | placeholder="Please insert Alert Name" |
| 18 | clearable |
| 19 | /> |
| 20 | </n-form-item> |
| 21 | </div> |
| 22 | <div class="flex flex-col gap-2"> |
| 23 | <n-form-item label="Description" path="alert_description"> |
| 24 | <n-input |
| 25 | v-model:value.trim="form.alert_description" |
| 26 | placeholder="Please insert Alert Description" |
| 27 | clearable |
| 28 | type="textarea" |
| 29 | :autosize="{ |
| 30 | minRows: 3, |
| 31 | maxRows: 10 |
| 32 | }" |
| 33 | /> |
| 34 | </n-form-item> |
| 35 | <n-form-item label="Streams" path="streams"> |
| 36 | <n-select |
| 37 | v-model:value="form.streams" |
| 38 | :options="availableStreamsOptions" |
| 39 | :loading="loadingStreams" |
| 40 | :placeholder="loadingStreams ? 'Loading Streams...' : 'Select Streams'" |
| 41 | multiple |
| 42 | clearable |
| 43 | to="body" |
| 44 | class="grow" |
| 45 | /> |
| 46 | </n-form-item> |
| 47 | <n-form-item label="Search Query" path="search_query"> |
| 48 | <n-input |
| 49 | v-model:value.trim="form.search_query" |
| 50 | placeholder="Please insert Search Query" |
| 51 | clearable |
| 52 | /> |
| 53 | </n-form-item> |
| 54 | </div> |
| 55 | <div class="w-full"> |
| 56 | <n-form-item label="Custom fields" path="custom_fields"> |
| 57 | <div class="flex w-full flex-col gap-1"> |
| 58 | <n-card v-for="(cf, index) of form.custom_fields" :key="cf.key" size="small" embedded> |
| 59 | <div class="flex w-full gap-2"> |
| 60 | <n-form-item |
| 61 | label="Name" |
| 62 | class="grow" |
| 63 | size="small" |
| 64 | :path="`custom_fields[${index}].name`" |
| 65 | :rule="{ |
| 66 | required: true, |
| 67 | message: `Field Name required`, |
| 68 | trigger: ['input', 'blur'] |
| 69 | }" |
| 70 | > |
| 71 | <n-input |
| 72 | v-model:value.trim="cf.name" |
| 73 | placeholder="Custom field Name" |
| 74 | clearable |
| 75 | @update:value="validate()" |
| 76 | /> |
| 77 | </n-form-item> |
| 78 | <n-form-item |
| 79 | label="Value" |
| 80 | class="grow" |
| 81 | size="small" |
| 82 | :path="`custom_fields[${index}].value`" |
| 83 | :rule="{ |
| 84 | required: true, |
| 85 | message: `Field Value required`, |
| 86 | trigger: ['input', 'blur'] |
| 87 | }" |
| 88 | > |
| 89 | <n-input |
| 90 | v-model:value.trim="cf.value" |
| 91 | placeholder="Custom field Value" |
| 92 | clearable |
| 93 | @update:value="validate()" |
| 94 | /> |
| 95 | </n-form-item> |
| 96 | <n-form-item size="small"> |
| 97 | <n-button type="error" secondary @click="removeCustomFiled(cf.key)"> |
| 98 | <template #icon> |
| 99 | <Icon :name="RemoveIcon" :size="16" /> |
| 100 | </template> |
| 101 | </n-button> |
| 102 | </n-form-item> |
| 103 | </div> |
| 104 | </n-card> |
| 105 | <div class="mt-3"> |
| 106 | <n-button @click="addCustomFiled()"> |
| 107 | <template #icon> |
| 108 | <Icon :name="AddIcon" /> |
| 109 | </template> |
| 110 | Add Custom Field |
| 111 | </n-button> |
| 112 | </div> |
| 113 | </div> |
| 114 | </n-form-item> |
| 115 | </div> |
| 116 | <div class="flex gap-4"> |
| 117 | <n-form-item label="Search within (seconds)" path="search_within_seconds" class="grow"> |
| 118 | <n-input-number |
| 119 | v-model:value="form.search_within_seconds" |
| 120 | :min="1" |
| 121 | placeholder="Input time in seconds" |
| 122 | clearable |
| 123 | class="w-full" |
| 124 | /> |
| 125 | </n-form-item> |
| 126 | <n-form-item label="Execute every (seconds)" path="execute_every_seconds" class="grow"> |
| 127 | <n-input-number |
| 128 | v-model:value="form.execute_every_seconds" |
| 129 | :min="1" |
| 130 | placeholder="Input time in seconds" |
| 131 | clearable |
| 132 | class="w-full" |
| 133 | /> |
| 134 | </n-form-item> |
| 135 | </div> |
| 136 | <div class="flex justify-between gap-4"> |
| 137 | <div class="flex gap-4"> |
| 138 | <slot name="additionalActions"></slot> |
| 139 | </div> |
| 140 | <div class="flex gap-4"> |
| 141 | <n-button :disabled="loading" @click="reset()">Reset</n-button> |
| 142 | <n-button |
| 143 | type="primary" |
| 144 | :disabled="!isValid" |
| 145 | :loading="submittingCustomAlert" |
| 146 | @click="validate(() => submit())" |
| 147 | > |
| 148 | Submit |
| 149 | </n-button> |
| 150 | </div> |
| 151 | </div> |
| 152 | </div> |
| 153 | </n-form> |
| 154 | </n-spin> |
| 155 | </template> |
| 156 | |
| 157 | <script setup lang="ts"> |
| 158 | import type { FormInst, FormItemRule, FormRules, FormValidationError, MessageReactive } from "naive-ui" |
| 159 | import type { CustomProvisionPayload } from "@/api/endpoints/monitoringAlerts" |
| 160 | import type { Stream } from "@/types/graylog/stream.d" |
| 161 | import _get from "lodash/get" |
| 162 | import _toSafeInteger from "lodash/toSafeInteger" |
| 163 | import _trim from "lodash/trim" |
| 164 | import { NButton, NCard, NForm, NFormItem, NInput, NInputNumber, NSelect, NSpin, useMessage } from "naive-ui" |
| 165 | import { computed, onBeforeMount, onMounted, ref, watch } from "vue" |
| 166 | import Api from "@/api" |
| 167 | import Icon from "@/components/common/Icon.vue" |
| 168 | import { CustomProvisionPriority } from "@/types/monitoringAlerts.d" |
| 169 | |
| 170 | interface CustomProvisionForm { |
| 171 | alert_name: string |
| 172 | alert_description: string |
| 173 | alert_priority: null | CustomProvisionPriority |
| 174 | search_query: string |
| 175 | custom_fields: { |
| 176 | name: string |
| 177 | value: string |
| 178 | key: number |
| 179 | }[] |
| 180 | search_within_seconds: number |
| 181 | execute_every_seconds: number |
| 182 | streams: string[] |
| 183 | } |
| 184 | |
| 185 | const emit = defineEmits<{ |
| 186 | (e: "update:loading", value: boolean): void |
| 187 | ( |
| 188 | e: "mounted", |
| 189 | value: { |
| 190 | reset: () => void |
| 191 | } |
| 192 | ): void |
| 193 | }>() |
| 194 | |
| 195 | const RemoveIcon = "ph:trash" |
| 196 | const AddIcon = "carbon:add-alt" |
| 197 | const submittingCustomAlert = ref(false) |
| 198 | const loading = computed(() => submittingCustomAlert.value) |
| 199 | const loadingStreams = ref(false) |
| 200 | const message = useMessage() |
| 201 | const availableStreams = ref<Stream[]>([]) |
| 202 | const form = ref<CustomProvisionForm>(getClearForm()) |
| 203 | const formRef = ref<FormInst | null>(null) |
| 204 | |
| 205 | const availableStreamsOptions = computed(() => availableStreams.value.map(o => ({ label: o.title, value: o.id }))) |
| 206 | |
| 207 | const areAllCustomerFieldsFilled = computed(() => { |
| 208 | const fieldsFilled = form.value.custom_fields.filter(o => !!o.name && !!o.value) |
| 209 | |
| 210 | return fieldsFilled.length === form.value.custom_fields.length |
| 211 | }) |
| 212 | |
| 213 | const areAllCustomerFieldsUniques = computed(() => { |
| 214 | const fieldsFilled = form.value.custom_fields.filter(o => !!o.name).map(o => o.name) |
| 215 | |
| 216 | const uniques: string[] = fieldsFilled.filter((value, index, self) => self.indexOf(value) === index) |
| 217 | |
| 218 | return uniques.length === form.value.custom_fields.length |
| 219 | }) |
| 220 | |
| 221 | /** @deprecated */ |
| 222 | /* |
| 223 | const isCustomerCodePresent = computed(() => { |
| 224 | const field = form.value.custom_fields.filter(o => o.name === "CUSTOMER_CODE" && !!o.value) |
| 225 | |
| 226 | return !!field.length |
| 227 | }) |
| 228 | */ |
| 229 | |
| 230 | const alertPriorityOptions: { label: string; value: CustomProvisionPriority }[] = [ |
| 231 | { label: "Low", value: CustomProvisionPriority.LOW }, |
| 232 | { label: "Medium", value: CustomProvisionPriority.MEDIUM }, |
| 233 | { label: "High", value: CustomProvisionPriority.HIGH } |
| 234 | ] |
| 235 | |
| 236 | const rules: FormRules = { |
| 237 | alert_priority: { |
| 238 | required: true, |
| 239 | validator: validatorNumber("Alert Priority", "Required"), |
| 240 | trigger: ["input", "blur"] |
| 241 | }, |
| 242 | alert_name: { |
| 243 | required: true, |
| 244 | message: "Please input the Alert Name", |
| 245 | trigger: ["input", "blur"] |
| 246 | }, |
| 247 | alert_description: { |
| 248 | required: true, |
| 249 | message: "Please input the Alert Description", |
| 250 | trigger: ["input", "blur"] |
| 251 | }, |
| 252 | search_query: { |
| 253 | required: true, |
| 254 | message: "Please input the Search Query", |
| 255 | trigger: ["input", "blur"] |
| 256 | }, |
| 257 | search_within_seconds: { |
| 258 | required: true, |
| 259 | // message: "Please input Search within", |
| 260 | validator: validatorNumber("Search within"), |
| 261 | trigger: ["input", "blur"] |
| 262 | }, |
| 263 | execute_every_seconds: { |
| 264 | required: true, |
| 265 | // message: "Please input Execute every", |
| 266 | validator: validatorNumber("Execute every"), |
| 267 | trigger: ["input", "blur"] |
| 268 | }, |
| 269 | custom_fields: { |
| 270 | required: false, |
| 271 | |
| 272 | validator(_rule: FormItemRule, _value: string) { |
| 273 | if (!areAllCustomerFieldsFilled.value) { |
| 274 | return new Error(`Please fill all customer fields`) |
| 275 | } |
| 276 | |
| 277 | if (!areAllCustomerFieldsUniques.value) { |
| 278 | return new Error(`There are duplicated fields`) |
| 279 | } |
| 280 | |
| 281 | /** @deprecated */ |
| 282 | /* |
| 283 | if (!value.length || !isCustomerCodePresent.value) { |
| 284 | return new Error(`At least one custom field with name CUSTOMER_CODE is required`) |
| 285 | } |
| 286 | */ |
| 287 | |
| 288 | return true |
| 289 | }, |
| 290 | trigger: ["input", "blur"] |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | const isValid = computed(() => { |
| 295 | if (!areAllCustomerFieldsFilled.value) { |
| 296 | return false |
| 297 | } |
| 298 | |
| 299 | /** @deprecated */ |
| 300 | /* |
| 301 | if (!isCustomerCodePresent.value) { |
| 302 | return false |
| 303 | } |
| 304 | */ |
| 305 | |
| 306 | if (!areAllCustomerFieldsUniques.value) { |
| 307 | return false |
| 308 | } |
| 309 | |
| 310 | let valid = true |
| 311 | |
| 312 | for (const key in rules) { |
| 313 | const rule = rules[key] as FormRules |
| 314 | |
| 315 | if (rule.required && !_trim(_get(form.value, key))) { |
| 316 | valid = false |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | return valid |
| 321 | }) |
| 322 | |
| 323 | const INTEGER_STRING_REGEX = /^\d*$/ |
| 324 | |
| 325 | function validatorNumber(fieldName: string, defaultMessage?: string) { |
| 326 | return (_rule: FormItemRule, value: string) => { |
| 327 | if (!value) { |
| 328 | return new Error(defaultMessage || `${fieldName} is required`) |
| 329 | } else if (!INTEGER_STRING_REGEX.test(value)) { |
| 330 | return new Error(`${fieldName} should be an integer`) |
| 331 | } else if (Number(value) < 1) { |
| 332 | return new Error(`${fieldName} should be above 1`) |
| 333 | } |
| 334 | return true |
| 335 | } |
| 336 | } |
| 337 | |
| 338 | let validationMessage: MessageReactive | null = null |
| 339 | |
| 340 | function validate(cb?: () => void) { |
| 341 | if (!formRef.value) return |
| 342 | |
| 343 | formRef.value.validate((errors?: Array<FormValidationError>) => { |
| 344 | if (!errors) { |
| 345 | validationMessage?.destroy() |
| 346 | validationMessage = null |
| 347 | if (cb) cb() |
| 348 | } else { |
| 349 | if (!validationMessage) { |
| 350 | validationMessage = message.warning("You must fill in the required fields correctly.") |
| 351 | } |
| 352 | return false |
| 353 | } |
| 354 | }) |
| 355 | } |
| 356 | |
| 357 | function addCustomFiled() { |
| 358 | form.value.custom_fields.push({ |
| 359 | name: "", |
| 360 | value: "", |
| 361 | key: Date.now() |
| 362 | }) |
| 363 | } |
| 364 | |
| 365 | function removeCustomFiled(key: number) { |
| 366 | form.value.custom_fields = form.value.custom_fields.filter(o => o.key !== key) |
| 367 | validate() |
| 368 | } |
| 369 | |
| 370 | function getClearForm(): CustomProvisionForm { |
| 371 | return { |
| 372 | alert_name: "", |
| 373 | alert_description: "", |
| 374 | alert_priority: null, |
| 375 | search_query: "", |
| 376 | custom_fields: [], |
| 377 | search_within_seconds: 1, |
| 378 | execute_every_seconds: 1, |
| 379 | streams: [] |
| 380 | } |
| 381 | } |
| 382 | |
| 383 | function reset() { |
| 384 | if (!loading.value) { |
| 385 | resetForm() |
| 386 | formRef.value?.restoreValidation() |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | function resetForm() { |
| 391 | form.value = getClearForm() |
| 392 | } |
| 393 | |
| 394 | function submit() { |
| 395 | submittingCustomAlert.value = true |
| 396 | |
| 397 | const payload: CustomProvisionPayload = { |
| 398 | alert_name: _trim(form.value.alert_name), |
| 399 | alert_description: _trim(form.value.alert_description), |
| 400 | alert_priority: form.value.alert_priority as CustomProvisionPriority, |
| 401 | search_query: _trim(form.value.search_query), |
| 402 | custom_fields: form.value.custom_fields, |
| 403 | search_within_ms: _toSafeInteger(form.value.search_within_seconds) * 1000, |
| 404 | execute_every_ms: _toSafeInteger(form.value.execute_every_seconds) * 1000, |
| 405 | streams: form.value.streams || [] |
| 406 | } |
| 407 | |
| 408 | Api.monitoringAlerts |
| 409 | .customProvision(payload) |
| 410 | .then(res => { |
| 411 | if (res.data.success) { |
| 412 | message.success( |
| 413 | res.data?.message || `Monitoring alert "${payload.alert_name}" provisioned successfully` |
| 414 | ) |
| 415 | resetForm() |
| 416 | } else { |
| 417 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 418 | } |
| 419 | }) |
| 420 | .catch(err => { |
| 421 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 422 | }) |
| 423 | .finally(() => { |
| 424 | submittingCustomAlert.value = false |
| 425 | }) |
| 426 | } |
| 427 | |
| 428 | function getStreams() { |
| 429 | if (availableStreams.value.length) { |
| 430 | return |
| 431 | } |
| 432 | |
| 433 | loadingStreams.value = true |
| 434 | |
| 435 | Api.graylog |
| 436 | .getStreams() |
| 437 | .then(res => { |
| 438 | if (res.data.success) { |
| 439 | availableStreams.value = res.data.streams || [] |
| 440 | } else { |
| 441 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 442 | } |
| 443 | }) |
| 444 | .catch(err => { |
| 445 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 446 | }) |
| 447 | .finally(() => { |
| 448 | loadingStreams.value = false |
| 449 | }) |
| 450 | } |
| 451 | |
| 452 | watch(loading, val => { |
| 453 | emit("update:loading", val) |
| 454 | }) |
| 455 | |
| 456 | onBeforeMount(() => { |
| 457 | getStreams() |
| 458 | }) |
| 459 | |
| 460 | onMounted(() => { |
| 461 | emit("mounted", { |
| 462 | reset |
| 463 | }) |
| 464 | }) |
| 465 | </script> |