main
vue 491 lines 13.9 KB
Raw
1 <template>
2 <n-form ref="formRef" :model="form" :rules="formRules" label-placement="top" :disabled="saving">
3 <n-form-item label="Name" path="name">
4 <n-input v-model:value="form.name" placeholder="e.g., Wazuh — Default" />
5 </n-form-item>
6
7 <n-form-item label="Description" path="description">
8 <n-input
9 v-model:value="form.description"
10 type="textarea"
11 placeholder="What this template is for"
12 :autosize="{ minRows: 2, maxRows: 4 }"
13 />
14 </n-form-item>
15
16 <div class="grid grid-cols-2 gap-4">
17 <n-form-item label="Customer code" path="customer_code" :show-feedback="false">
18 <n-select
19 v-model:value="form.customer_code"
20 :options="customersOptions"
21 placeholder="Leave empty for global"
22 :loading="loadingCustomers"
23 filterable
24 clearable
25 :consistent-menu-width="false"
26 />
27 </n-form-item>
28 <n-form-item label="Alert source" path="source" :show-feedback="false">
29 <n-select
30 v-model:value="form.source"
31 :options="sourcesOptions"
32 :consistent-menu-width="false"
33 placeholder="e.g., wazuh (leave empty for any)"
34 filterable
35 clearable
36 :loading="loadingConfiguredSources"
37 />
38 </n-form-item>
39 </div>
40
41 <n-form-item path="is_default">
42 <n-checkbox v-model:checked="form.is_default">Default for this (customer, source) scope</n-checkbox>
43 </n-form-item>
44
45 <!--
46 Conditional auto-apply. Both inputs must be filled (or both empty) — the
47 backend rejects half-set pairs because a partial condition would silently
48 never trigger. Example: field "data_win_system_eventID", value "1" applies
49 this template only to Sysmon Event ID 1 events.
50 -->
51 <n-card size="small" class="my-4" title="Conditional auto-apply (optional)">
52 <template #header-extra>
53 <div v-if="matchHalfSet" class="text-warning text-xs">Both field and value are required</div>
54 </template>
55 <p class="text-secondary mb-2 text-xs">
56 When set, auto-apply only fires if the originating Wazuh document has
57 <code>{{ form.match_field || "[field]" }}</code>
58 equal to
59 <code>{{ form.match_value || "[value]" }}</code>
60 . Leave blank for an unconditional template.
61 </p>
62 <div class="grid grid-cols-1 gap-3 @md:grid-cols-2">
63 <n-form-item label="Match field" path="match_field" :show-feedback="false">
64 <n-input v-model:value="form.match_field" placeholder="e.g., data_win_system_eventID" />
65 </n-form-item>
66 <n-form-item label="Match value" path="match_value" :show-feedback="false">
67 <n-input v-model:value="form.match_value" placeholder="e.g., 1" />
68 </n-form-item>
69 </div>
70 </n-card>
71
72 <n-card size="small" class="my-4" title="Tasks" content-class="flex flex-col gap-3">
73 <template #header-extra>
74 <div
75 v-if="taskSaving"
76 class="text-secondary text-xs opacity-0 transition-opacity duration-300"
77 :class="{ 'animate-pulse opacity-100': taskSaving }"
78 >
79 saving...
80 </div>
81 </template>
82 <p v-if="props.template == null" class="text-xs">
83 Add at least one task. You can edit / reorder tasks after the template is created.
84 </p>
85 <p v-else class="text-xs">
86 Tasks below are saved immediately on add / edit / delete. Editing the template does NOT mutate task
87 snapshots already attached to real cases.
88 </p>
89
90 <div class="flex flex-col gap-2">
91 <CardEntity v-for="(task, idx) in tasks" :key="task._key" embedded size="small">
92 <div class="flex flex-col gap-2">
93 <div class="flex items-center gap-2">
94 <n-input
95 v-model:value="task.title"
96 size="small"
97 placeholder="Task title"
98 class="flex-1"
99 @blur="saveTask(idx)"
100 />
101 <n-checkbox v-model:checked="task.mandatory" @update:checked="saveTask(idx)">
102 mandatory
103 </n-checkbox>
104 <n-button-group v-if="tasks.length > 1" size="tiny">
105 <n-button :disabled="idx === 0" @click="moveTask(idx, -1)">
106 <template #icon><Icon name="carbon:arrow-up" /></template>
107 </n-button>
108 <n-button :disabled="idx === tasks.length - 1" @click="moveTask(idx, 1)">
109 <template #icon><Icon name="carbon:arrow-down" /></template>
110 </n-button>
111 </n-button-group>
112 <n-button
113 v-if="tasks.length > 1"
114 size="tiny"
115 type="error"
116 quaternary
117 @click="deleteTask(idx)"
118 >
119 <template #icon><Icon name="carbon:trash-can" /></template>
120 </n-button>
121 </div>
122 <n-input
123 v-model:value="task.description"
124 size="small"
125 placeholder="Description (optional)"
126 :autosize="{ minRows: 1, maxRows: 3 }"
127 type="textarea"
128 clearable
129 @blur="saveTask(idx)"
130 />
131 <n-input
132 v-model:value="task.guidelines"
133 size="small"
134 placeholder="Guidelines / best practices (optional)"
135 :autosize="{ minRows: 1, maxRows: 5 }"
136 type="textarea"
137 clearable
138 @blur="saveTask(idx)"
139 />
140 </div>
141 </CardEntity>
142
143 <n-button size="small" dashed @click="addTask">
144 <template #icon><Icon name="carbon:add" /></template>
145 Add task
146 </n-button>
147 </div>
148 </n-card>
149
150 <div class="flex justify-end gap-2">
151 <n-button @click="emit('cancel')">Cancel</n-button>
152 <n-button type="primary" :loading="saving" @click="handleSave">
153 {{ props.template ? "Save changes" : "Create template" }}
154 </n-button>
155 </div>
156 </n-form>
157 </template>
158
159 <script setup lang="ts">
160 import type { FormInst, FormRules } from "naive-ui"
161 import type { ApiError } from "@/types/common"
162 import type { Customer } from "@/types/customers"
163 import type { CaseTemplate } from "@/types/incidentManagement/caseTemplates.d"
164 import type { SourceName } from "@/types/incidentManagement/sources"
165 import { NButton, NButtonGroup, NCard, NCheckbox, NForm, NFormItem, NInput, NSelect, useMessage } from "naive-ui"
166 import { computed, onBeforeMount, ref, watch } from "vue"
167 import Api from "@/api"
168 import CardEntity from "@/components/common/cards/CardEntity.vue"
169 import Icon from "@/components/common/Icon.vue"
170 import { getApiErrorMessage } from "@/utils"
171
172 interface DraftTask {
173 _key: string // stable client-side key for v-for
174 id?: number // present when persisted (template_task_id from backend)
175 title: string | null
176 description: string | null
177 guidelines: string | null
178 mandatory: boolean
179 order_index: number
180 }
181
182 interface FormModel {
183 name: string | null
184 description: string | null
185 customer_code: string | null
186 source: string | null
187 is_default: boolean
188 match_field: string | null
189 match_value: string | null
190 }
191
192 const props = defineProps<{
193 template?: CaseTemplate | null
194 }>()
195
196 const emit = defineEmits<{
197 (e: "saved", template: CaseTemplate): void
198 (e: "cancel"): void
199 }>()
200
201 const message = useMessage()
202 const formRef = ref<FormInst | null>(null)
203 const saving = ref(false)
204 const taskSaving = ref(false)
205
206 const loadingCustomers = ref(false)
207 const customersList = ref<Customer[]>([])
208 const customersOptions = computed(() =>
209 customersList.value.map(o => ({ label: `#${o.customer_code} - ${o.customer_name}`, value: o.customer_code }))
210 )
211
212 const loadingConfiguredSources = ref(false)
213 const configuredSourcesList = ref<SourceName[]>([])
214 const sourcesOptions = computed(() => configuredSourcesList.value.map(o => ({ label: o, value: o })))
215
216 const form = ref<FormModel>({
217 name: null,
218 description: null,
219 customer_code: null,
220 source: null,
221 is_default: false,
222 match_field: null,
223 match_value: null
224 })
225
226 const matchHalfSet = computed(() => {
227 const hasField = !!form.value.match_field?.trim()
228 const hasValue = !!form.value.match_value?.trim()
229 return hasField !== hasValue
230 })
231 const formRules: FormRules = {
232 name: { required: true, message: "Name is required", trigger: "blur" }
233 }
234
235 const tasks = ref<DraftTask[]>([])
236
237 let keyCounter = 0
238 function nextKey() {
239 keyCounter += 1
240 return `t${Date.now()}-${keyCounter}`
241 }
242
243 function loadFromTemplate(t?: CaseTemplate | null) {
244 if (t) {
245 form.value = {
246 name: t.name ?? null,
247 description: t.description ?? null,
248 customer_code: t.customer_code ?? null,
249 source: t.source ?? null,
250 is_default: t.is_default,
251 match_field: t.match_field ?? null,
252 match_value: t.match_value ?? null
253 }
254 tasks.value = (t.tasks ?? []).map(task => ({
255 _key: nextKey(),
256 id: task.id ?? null,
257 title: task.title ?? null,
258 description: task.description ?? null,
259 guidelines: task.guidelines ?? null,
260 mandatory: task.mandatory ?? false,
261 order_index: task.order_index ?? 0
262 }))
263 } else {
264 form.value = {
265 name: null,
266 description: null,
267 customer_code: null,
268 source: null,
269 is_default: false,
270 match_field: null,
271 match_value: null
272 }
273 tasks.value = [
274 {
275 _key: nextKey(),
276 title: null,
277 description: null,
278 guidelines: null,
279 mandatory: false,
280 order_index: 0
281 }
282 ]
283 }
284 }
285
286 function addTask() {
287 tasks.value.push({
288 _key: nextKey(),
289 title: "",
290 description: "",
291 guidelines: "",
292 mandatory: false,
293 order_index: tasks.value.length
294 })
295 }
296
297 async function deleteTask(idx: number) {
298 const task = tasks.value[idx]
299 // If the template hasn't been created yet, just drop the row.
300 if (!props.template || task.id == null) {
301 tasks.value.splice(idx, 1)
302 return
303 }
304
305 taskSaving.value = true
306 try {
307 const res = await Api.incidentManagement.caseTemplates.deleteTemplateTask(task.id)
308 if (res.data.success) {
309 tasks.value.splice(idx, 1)
310 } else {
311 message.warning(res.data.message)
312 }
313 } catch (err) {
314 message.error(getApiErrorMessage(err as ApiError) || "Failed to delete task")
315 } finally {
316 taskSaving.value = false
317 }
318 }
319
320 async function moveTask(idx: number, delta: number) {
321 const newIdx = idx + delta
322
323 if (newIdx < 0 || newIdx >= tasks.value.length) return
324
325 const moved = tasks.value.splice(idx, 1)[0]
326 tasks.value.splice(newIdx, 0, moved)
327 tasks.value.forEach((t, i) => (t.order_index = i))
328
329 // If the template is persisted, push the reorder up to the backend.
330 if (props.template) {
331 const orderedIds = tasks.value.filter(t => t.id != null).map(t => t.id as number)
332 if (orderedIds.length === tasks.value.length) {
333 taskSaving.value = true
334 try {
335 await Api.incidentManagement.caseTemplates.reorderTemplateTasks(props.template.id, orderedIds)
336 } catch (err) {
337 message.error(getApiErrorMessage(err as ApiError) || "Failed to reorder tasks")
338 } finally {
339 taskSaving.value = false
340 }
341 }
342 }
343 }
344
345 async function saveTask(idx: number) {
346 if (!props.template) return // creation flow batches at submit time
347
348 const draft = tasks.value[idx]
349
350 if (!draft.title?.trim()) return // skip empty drafts; user is still typing
351
352 taskSaving.value = true
353
354 const payload = {
355 title: draft.title ?? "",
356 description: draft.description || "",
357 guidelines: draft.guidelines || "",
358 mandatory: draft.mandatory,
359 order_index: draft.order_index
360 }
361
362 try {
363 if (draft.id == null) {
364 const res = await Api.incidentManagement.caseTemplates.addTemplateTask(props.template.id, payload)
365 if (res.data.success && res.data.task) {
366 draft.id = res.data.task.id
367 } else {
368 message.warning(res.data.message)
369 }
370 } else {
371 const res = await Api.incidentManagement.caseTemplates.updateTemplateTask(draft.id, payload)
372 if (!res.data.success) message.warning(res.data.message)
373 }
374 } catch (err) {
375 message.error(getApiErrorMessage(err as ApiError) || "Failed to save task")
376 } finally {
377 taskSaving.value = false
378 }
379 }
380
381 async function handleSave() {
382 try {
383 await formRef.value?.validate()
384 } catch {
385 return
386 }
387
388 if (matchHalfSet.value) {
389 message.warning("Match field and match value must both be set or both be empty.")
390 return
391 }
392
393 saving.value = true
394
395 const payload = {
396 name: form.value.name || "",
397 description: form.value.description || null,
398 customer_code: form.value.customer_code || null,
399 source: form.value.source || null,
400 is_default: form.value.is_default,
401 match_field: form.value.match_field?.trim() || null,
402 match_value: form.value.match_value?.trim() || null
403 }
404
405 try {
406 if (props.template) {
407 // Update flow — metadata only; task edits already streamed via saveTask.
408 const res = await Api.incidentManagement.caseTemplates.updateTemplate(props.template.id, payload)
409 if (res.data.success && res.data.template) {
410 emit("saved", res.data.template)
411 } else {
412 message.warning(res.data.message)
413 }
414 } else {
415 const cleanTasks = tasks.value
416 .filter(t => (t.title?.trim() || "").length > 0)
417 .map(t => ({
418 title: t.title ?? "",
419 description: t.description || null,
420 guidelines: t.guidelines || null,
421 mandatory: t.mandatory,
422 order_index: t.order_index
423 }))
424 const res = await Api.incidentManagement.caseTemplates.createTemplate({
425 ...payload,
426 tasks: cleanTasks
427 })
428 if (res.data.success && res.data.template) {
429 emit("saved", res.data.template)
430 } else {
431 message.warning(res.data.message)
432 }
433 }
434 } catch (err) {
435 message.error(getApiErrorMessage(err as ApiError) || "Failed to save template")
436 } finally {
437 saving.value = false
438 }
439 }
440
441 function getCustomers() {
442 loadingCustomers.value = true
443
444 Api.customers
445 .getCustomers()
446 .then(res => {
447 if (res.data.success) {
448 customersList.value = res.data?.customers || []
449 } else {
450 message.warning(res.data?.message || "An error occurred. Please try again later.")
451 }
452 })
453 .catch(err => {
454 message.error(getApiErrorMessage(err as ApiError) || "An error occurred. Please try again later.")
455 })
456 .finally(() => {
457 loadingCustomers.value = false
458 })
459 }
460
461 function getConfiguredSources() {
462 loadingConfiguredSources.value = true
463
464 Api.incidentManagement.sources
465 .getConfiguredSources()
466 .then(res => {
467 if (res.data.success) {
468 configuredSourcesList.value = res.data?.sources || []
469 } else {
470 message.warning(res.data?.message || "An error occurred. Please try again later.")
471 }
472 })
473 .catch(err => {
474 message.error(getApiErrorMessage(err as ApiError) || "An error occurred. Please try again later.")
475 })
476 .finally(() => {
477 loadingConfiguredSources.value = false
478 })
479 }
480
481 watch(() => props.template, loadFromTemplate, { immediate: true })
482
483 onBeforeMount(() => {
484 getCustomers()
485 getConfiguredSources()
486 })
487
488 defineExpose({
489 validate: () => formRef.value?.restoreValidation()
490 })
491 </script>