602 alert incidents filtering bug (#626)
* feat: add endpoint to delete alerts by title filter with customer access control * feat: add bulk delete functionality for alerts by title filter * precommit fixes * fix: update current version to 0.1.28
taylor_socfortress committed
Jan 24, 2026 at 12:56 UTC
50161fd2054245cb0a489fa3229276fcb7b8d19b
4 files changed
+342
-174
backend/app/incidents/routes/db_operations.py
+68
@@ -910,6 +910,74 @@ async def delete_alerts_endpoint(request: DeleteAlertsRequest, db: AsyncSession
910
)
911
912
913
+@incidents_db_operations_router.delete("/alerts/by-title/{title_filter}", response_model=DeleteAlertsResponse)
914
+async def delete_alerts_by_title_endpoint(
915
+ title_filter: str,
916
+ current_user: User = Depends(AuthHandler().get_current_user),
917
+ db: AsyncSession = Depends(get_db),
918
+):
919
+ """
920
+ Delete alerts matching a title filter.
921
+
922
+ The title_filter will match any alert where the title contains the filter string.
923
+ For example, filtering by "File" will match "File added to the system."
924
+
925
+ Args:
926
+ title_filter: The string to filter alert titles by (case-insensitive partial match)
927
+ current_user: Current authenticated user
928
+ db: Database session
929
+
930
+ Returns:
931
+ DeleteAlertsResponse with lists of deleted and not deleted alert IDs
932
+ """
933
+ logger.info(f"Deleting alerts with title filter '{title_filter}' for user: {current_user.username}")
934
+
935
+ # Get customer access filtering
936
+ accessible_customers = await customer_access_handler.get_user_accessible_customers(current_user, db)
937
+
938
+ # Build query to find matching alerts
939
+ query = select(Alert).where(Alert.alert_name.ilike(f"%{title_filter}%"))
940
+
941
+ # Apply customer filtering if not admin/analyst
942
+ if "*" not in accessible_customers:
943
+ query = query.where(Alert.customer_code.in_(accessible_customers))
944
+
945
+ result = await db.execute(query)
946
+ matching_alerts = result.scalars().all()
947
+
948
+ if not matching_alerts:
949
+ return DeleteAlertsResponse(
950
+ message=f"No alerts found matching title filter: {title_filter}",
951
+ deleted_alert_ids=[],
952
+ not_deleted_alert_ids=[],
953
+ success=True,
954
+ )
955
+
956
+ deleted_alert_ids = []
957
+ not_deleted_alert_ids = []
958
+
959
+ for alert in matching_alerts:
960
+ try:
961
+ # Check if alert is linked to a case
962
+ await is_alert_linked_to_case(alert.id, db)
963
+ # Delete the alert
964
+ await delete_alert(alert.id, db)
965
+ deleted_alert_ids.append(alert.id)
966
+ except HTTPException as e:
967
+ if e.status_code == 400:
968
+ logger.info(f"Alert {alert.id} is linked to a case and cannot be deleted. Skipping.")
969
+ not_deleted_alert_ids.append(alert.id)
970
+ else:
971
+ raise e
972
+
973
+ return DeleteAlertsResponse(
974
+ message=f"Processed {len(matching_alerts)} alerts matching '{title_filter}'. Deleted: {len(deleted_alert_ids)}, Skipped: {len(not_deleted_alert_ids)}",
975
+ deleted_alert_ids=deleted_alert_ids,
976
+ not_deleted_alert_ids=not_deleted_alert_ids,
977
+ success=True,
978
+ )
979
+
980
+
981
@incidents_db_operations_router.get("/alerts/status/{status}", response_model=AlertOutResponse)
982
async def list_alerts_by_status_endpoint(
983
status: AlertStatus,
backend/app/version/services/version.py
+1
-1
@@ -7,7 +7,7 @@ from loguru import logger
7
from packaging.version import Version
8
9
# Current version - update this with each release
10
-CURRENT_VERSION = "0.1.27"
10
+CURRENT_VERSION = "0.1.28"
11
VERSION_CHECK_URL = "https://api.github.com/repos/socfortress/CoPilot/releases/latest"
12
13
frontend/src/api/endpoints/incidentManagement/alerts.ts
+9
@@ -177,6 +177,15 @@ export default {
177
}
178
)
179
},
180
+ deleteAlertsByTitle(titleFilter: string) {
181
+ return HttpClient.delete<
182
+ FlaskBaseResponse & {
183
+ deleted_alert_ids: number[]
184
+ not_deleted_alert_ids: number[]
185
+ message: string
186
+ }
187
+ >(`/incidents/db_operations/alerts/by-title/${encodeURIComponent(titleFilter)}`)
188
+ },
189
getAlertContext(alertContextId: number) {
190
return HttpClient.get<FlaskBaseResponse & { alert_context: AlertContext }>(
191
`/incidents/db_operations/alert/context/${alertContextId}`
frontend/src/components/incidentManagement/alerts/AlertsList.vue
+264
-173
@@ -104,6 +104,13 @@
104
</template>
105
</n-button>
106
</n-badge>
107
+
108
+ <n-button size="small" secondary @click="showDeleteByTitleModal = true">
109
+ <template #icon>
110
+ <Icon :name="TrashIcon" />
111
+ </template>
112
+ <span class="hidden sm:inline">Bulk Delete</span>
113
+ </n-button>
114
</div>
115
116
<CollapseKeepAlive v-if="showFilters" :show="showFiltersView" embedded arrow="top-right">
@@ -184,6 +191,37 @@
191
</n-card>
192
</n-collapse-transition>
193
194
+ <n-modal
195
+ v-model:show="showDeleteByTitleModal"
196
+ preset="dialog"
197
+ title="Delete Alerts by Title"
198
+ :positive-text="deletingByTitle ? 'Deleting...' : 'Delete'"
199
+ negative-text="Cancel"
200
+ :positive-button-props="{ disabled: !titleFilterInput || deletingByTitle }"
201
+ @positive-click="deleteAlertsByTitle"
202
+ >
203
+ <div class="flex flex-col gap-4">
204
+ <p class="text-sm opacity-70">
205
+ Delete all alerts matching a title filter. This will perform a partial, case-insensitive match.
206
+ </p>
207
+ <n-input
208
+ v-model:value="titleFilterInput"
209
+ placeholder="e.g., 'File' to match 'File added to the system.'"
210
+ :disabled="deletingByTitle"
211
+ @keyup.enter="titleFilterInput && deleteAlertsByTitle()"
212
+ >
213
+ <template #prefix>
214
+ <Icon :name="FilterIcon" />
215
+ </template>
216
+ </n-input>
217
+ <n-alert v-if="titleFilterInput" type="warning" :bordered="false">
218
+ This will delete all alerts with titles containing "{{ titleFilterInput }}".
219
+ <br />
220
+ <strong>This action cannot be undone.</strong>
221
+ </n-alert>
222
+ </div>
223
+ </n-modal>
224
+
225
<n-spin :show="loading">
226
<div class="my-3 flex min-h-52 flex-col gap-2">
227
<template v-if="alertsList.length">
@@ -228,18 +266,21 @@ import { useResizeObserver, useStorage } from "@vueuse/core"
266
import axios from "axios"
267
import _orderBy from "lodash/orderBy"
268
import {
231
- NBadge,
232
- NButton,
233
- NCard,
234
- NCollapseTransition,
235
- NEmpty,
236
- NPagination,
237
- NPopconfirm,
238
- NPopover,
239
- NScrollbar,
240
- NSelect,
241
- NSpin,
242
- useMessage
269
+ NAlert,
270
+ NBadge,
271
+ NButton,
272
+ NCard,
273
+ NCollapseTransition,
274
+ NEmpty,
275
+ NInput,
276
+ NModal,
277
+ NPagination,
278
+ NPopconfirm,
279
+ NPopover,
280
+ NScrollbar,
281
+ NSelect,
282
+ NSpin,
283
+ useMessage
284
} from "naive-ui"
285
import { computed, defineAsyncComponent, nextTick, onBeforeMount, provide, ref, watch } from "vue"
286
import Api from "@/api"
@@ -249,13 +290,13 @@ import AlertItem from "./AlertItem.vue"
290
import AlertsFilters from "./AlertsFilters.vue"
291
292
const {
252
- highlight,
253
- preset,
254
- showFilters = true
293
+ highlight,
294
+ preset,
295
+ showFilters = true
296
} = defineProps<{
256
- highlight?: string | null
257
- preset?: AlertsListFilter[]
258
- showFilters?: boolean
297
+ highlight?: string | null
298
+ preset?: AlertsListFilter[]
299
+ showFilters?: boolean
300
}>()
301
302
const AlertMergeCaseButton = defineAsyncComponent(() => import("./AlertMergeCaseButton.vue"))
@@ -269,6 +310,9 @@ const checkedNoLinkedAlerts = computed(() => checkedAlerts.value.filter(alert =>
310
const message = useMessage()
311
const loading = ref(false)
312
const deleting = ref(false)
313
+const deletingByTitle = ref(false)
314
+const showDeleteByTitleModal = ref(false)
315
+const titleFilterInput = ref("")
316
const showFiltersView = useStorage<boolean>("incident-management-alerts-list-filters-view-state", false, localStorage)
317
const alertsList = ref<Alert[]>([])
318
const availableUsers = ref<string[]>([])
@@ -284,8 +328,8 @@ const header = ref()
328
const pageSlot = ref(8)
329
const sort = defineModel<"asc" | "desc">("sort", { default: "desc" })
330
const sortOptions = [
287
- { label: "Desc", value: "desc" },
288
- { label: "Asc", value: "asc" }
331
+ { label: "Desc", value: "desc" },
332
+ { label: "Asc", value: "asc" }
333
]
334
335
const totalFiltered = ref(0)
@@ -298,43 +342,43 @@ const filtersCTX = ref<{ setFilter: (payload: AlertsListFilter[]) => void } | nu
342
const filters = ref<AlertsListFilter[]>([])
343
344
const filtered = computed<boolean>(() => {
301
- return !!filters.value.length
345
+ return !!filters.value.length
346
})
347
348
const highlightedItemFound = ref(!highlight)
349
const highlightedItemOpened = ref(!highlight)
350
351
watch([currentPage, sort], () => {
308
- getData()
352
+ getData()
353
})
354
355
watch(pageSize, () => {
312
- if (currentPage.value === 1) {
313
- getData()
314
- } else {
315
- currentPage.value = 1
316
- }
356
+ if (currentPage.value === 1) {
357
+ getData()
358
+ } else {
359
+ currentPage.value = 1
360
+ }
361
})
362
363
watch(
320
- alertsList,
321
- () => {
322
- if (
323
- alertsList.value.length &&
324
- !alertsList.value.find(o => o.id.toString() === highlight) &&
325
- currentPage.value < totalFiltered.value &&
326
- !highlightedItemFound.value
327
- ) {
328
- nextTick(() => {
329
- currentPage.value++
330
- })
331
- }
332
-
333
- if (alertsList.value.find(o => o.id.toString() === highlight)) {
334
- highlightedItemFound.value = true
335
- }
336
- },
337
- { immediate: true }
364
+ alertsList,
365
+ () => {
366
+ if (
367
+ alertsList.value.length &&
368
+ !alertsList.value.find(o => o.id.toString() === highlight) &&
369
+ currentPage.value < totalFiltered.value &&
370
+ !highlightedItemFound.value
371
+ ) {
372
+ nextTick(() => {
373
+ currentPage.value++
374
+ })
375
+ }
376
+
377
+ if (alertsList.value.find(o => o.id.toString() === highlight)) {
378
+ highlightedItemFound.value = true
379
+ }
380
+ },
381
+ { immediate: true }
382
)
383
384
provide("assignable-users", availableUsers)
@@ -342,176 +386,223 @@ provide("assignable-users", availableUsers)
386
provide("linkable-cases", linkableCases)
387
388
function applyFilters(newFilters: AlertsListFilter[]) {
345
- filters.value = newFilters
346
- getData()
389
+ filters.value = newFilters
390
+ getData()
391
}
392
393
function updateAlert(updatedAlert: Alert) {
350
- const alertIndex = alertsList.value.findIndex(o => o.id === updatedAlert.id)
351
- if (alertIndex !== -1) {
352
- alertsList.value[alertIndex] = updatedAlert
353
- }
394
+ const alertIndex = alertsList.value.findIndex(o => o.id === updatedAlert.id)
395
+ if (alertIndex !== -1) {
396
+ alertsList.value[alertIndex] = updatedAlert
397
+ }
398
}
399
400
function isChecked(alert: Alert) {
357
- return !!checkedAlerts.value.find(o => o.id === alert.id)
401
+ return !!checkedAlerts.value.find(o => o.id === alert.id)
402
}
403
404
function toggleCheck(alert: Alert) {
361
- const alertIndex = checkedAlerts.value.findIndex(o => o.id === alert.id)
405
+ const alertIndex = checkedAlerts.value.findIndex(o => o.id === alert.id)
406
363
- if (checkedAlerts.value.find(o => o.id === alert.id)) {
364
- checkedAlerts.value.splice(alertIndex, 1)
365
- } else {
366
- checkedAlerts.value.push(alert)
367
- }
407
+ if (checkedAlerts.value.find(o => o.id === alert.id)) {
408
+ checkedAlerts.value.splice(alertIndex, 1)
409
+ } else {
410
+ checkedAlerts.value.push(alert)
411
+ }
412
}
413
414
function resetChecked() {
371
- checkedAlerts.value = []
415
+ checkedAlerts.value = []
416
}
417
418
function setALlChecked() {
375
- for (const alert of alertsList.value) {
376
- if (!isChecked(alert)) {
377
- toggleCheck(alert)
378
- }
379
- }
419
+ for (const alert of alertsList.value) {
420
+ if (!isChecked(alert)) {
421
+ toggleCheck(alert)
422
+ }
423
+ }
424
}
425
426
function removeChecked(alert: Alert) {
383
- const alertIndex = checkedAlerts.value.findIndex(o => o.id === alert.id)
427
+ const alertIndex = checkedAlerts.value.findIndex(o => o.id === alert.id)
428
385
- if (checkedAlerts.value.find(o => o.id === alert.id)) {
386
- checkedAlerts.value.splice(alertIndex, 1)
387
- }
429
+ if (checkedAlerts.value.find(o => o.id === alert.id)) {
430
+ checkedAlerts.value.splice(alertIndex, 1)
431
+ }
432
}
433
434
function deleted(alert: Alert) {
391
- removeChecked(alert)
392
- getData()
435
+ removeChecked(alert)
436
+ getData()
437
}
438
439
function getData() {
396
- abortController?.abort()
397
- abortController = new AbortController()
398
-
399
- loading.value = true
400
-
401
- const query: Partial<AlertsQuery> = {
402
- page: currentPage.value,
403
- pageSize: pageSize.value,
404
- sort: sort.value
405
- }
406
-
407
- if (filtered.value) {
408
- query.filters = filters.value
409
- }
410
-
411
- Api.incidentManagement.alerts
412
- .getAlertsList(query, abortController.signal)
413
- .then(res => {
414
- if (res.data.success) {
415
- alertsList.value = res.data?.alerts || []
416
- total.value = res.data.total || 0
417
- totalFiltered.value = res.data.total_filtered ?? total.value ?? 0
418
- statusCloseTotal.value = res.data.closed || 0
419
- statusInProgressTotal.value = res.data.in_progress || 0
420
- statusOpenTotal.value = res.data.open || 0
421
- } else {
422
- message.warning(res.data?.message || "An error occurred. Please try again later.")
423
- }
424
- loading.value = false
425
- })
426
- .catch(err => {
427
- if (!axios.isCancel(err)) {
428
- alertsList.value = []
429
-
430
- message.error(err.response?.data?.message || "An error occurred. Please try again later.")
431
- loading.value = false
432
- }
433
- })
440
+ abortController?.abort()
441
+ abortController = new AbortController()
442
+
443
+ loading.value = true
444
+
445
+ const query: Partial<AlertsQuery> = {
446
+ page: currentPage.value,
447
+ pageSize: pageSize.value,
448
+ sort: sort.value
449
+ }
450
+
451
+ if (filtered.value) {
452
+ query.filters = filters.value
453
+ }
454
+
455
+ Api.incidentManagement.alerts
456
+ .getAlertsList(query, abortController.signal)
457
+ .then(res => {
458
+ if (res.data.success) {
459
+ alertsList.value = res.data?.alerts || []
460
+ total.value = res.data.total || 0
461
+ totalFiltered.value = res.data.total_filtered ?? total.value ?? 0
462
+ statusCloseTotal.value = res.data.closed || 0
463
+ statusInProgressTotal.value = res.data.in_progress || 0
464
+ statusOpenTotal.value = res.data.open || 0
465
+ } else {
466
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
467
+ }
468
+ loading.value = false
469
+ })
470
+ .catch(err => {
471
+ if (!axios.isCancel(err)) {
472
+ alertsList.value = []
473
+
474
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
475
+ loading.value = false
476
+ }
477
+ })
478
}
479
480
function getAvailableUsers() {
437
- Api.incidentManagement.alerts
438
- .getAvailableUsers()
439
- .then(res => {
440
- if (res.data.success) {
441
- availableUsers.value = res.data?.available_users || []
442
- } else {
443
- message.warning(res.data?.message || "An error occurred. Please try again later.")
444
- }
445
- })
446
- .catch(err => {
447
- message.error(err.response?.data?.message || "An error occurred. Please try again later.")
448
- })
481
+ Api.incidentManagement.alerts
482
+ .getAvailableUsers()
483
+ .then(res => {
484
+ if (res.data.success) {
485
+ availableUsers.value = res.data?.available_users || []
486
+ } else {
487
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
488
+ }
489
+ })
490
+ .catch(err => {
491
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
492
+ })
493
}
494
495
function getCases() {
452
- Api.incidentManagement.cases
453
- .getCasesList()
454
- .then(res => {
455
- if (res.data.success) {
456
- linkableCases.value = _orderBy(res.data?.cases || [], ["id"], ["desc"])
457
- } else {
458
- message.warning(res.data?.message || "An error occurred. Please try again later.")
459
- }
460
- })
461
- .catch(err => {
462
- message.error(err.response?.data?.message || "An error occurred. Please try again later.")
463
- })
496
+ Api.incidentManagement.cases
497
+ .getCasesList()
498
+ .then(res => {
499
+ if (res.data.success) {
500
+ linkableCases.value = _orderBy(res.data?.cases || [], ["id"], ["desc"])
501
+ } else {
502
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
503
+ }
504
+ })
505
+ .catch(err => {
506
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
507
+ })
508
}
509
510
function deleteAlerts() {
467
- deleting.value = true
468
-
469
- Api.incidentManagement.alerts
470
- .deleteAlerts(checkedAlerts.value.map(o => o.id))
471
- .then(res => {
472
- if (res.data.success) {
473
- if (res.data.deleted_alert_ids.length) {
474
- for (const id of res.data.deleted_alert_ids) {
475
- toggleCheck({ id } as Alert)
476
- }
477
-
478
- if (res.data.not_deleted_alert_ids.length) {
479
- message.warning("Some alerts could not be deleted.")
480
- } else {
481
- message.success(res.data?.message || "Alerts deleted successfully.")
482
- }
483
- } else {
484
- message.warning("The selected alerts could not be deleted")
485
- }
486
-
487
- getData()
488
- } else {
489
- message.warning(res.data?.message || "An error occurred. Please try again later.")
490
- }
491
- })
492
- .catch(err => {
493
- message.error(err.response?.data?.message || "An error occurred. Please try again later.")
494
- })
495
- .finally(() => {
496
- deleting.value = false
497
- })
511
+ deleting.value = true
512
+
513
+ Api.incidentManagement.alerts
514
+ .deleteAlerts(checkedAlerts.value.map(o => o.id))
515
+ .then(res => {
516
+ if (res.data.success) {
517
+ if (res.data.deleted_alert_ids.length) {
518
+ for (const id of res.data.deleted_alert_ids) {
519
+ toggleCheck({ id } as Alert)
520
+ }
521
+
522
+ if (res.data.not_deleted_alert_ids.length) {
523
+ message.warning("Some alerts could not be deleted.")
524
+ } else {
525
+ message.success(res.data?.message || "Alerts deleted successfully.")
526
+ }
527
+ } else {
528
+ message.warning("The selected alerts could not be deleted")
529
+ }
530
+
531
+ getData()
532
+ } else {
533
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
534
+ }
535
+ })
536
+ .catch(err => {
537
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
538
+ })
539
+ .finally(() => {
540
+ deleting.value = false
541
+ })
542
+}
543
+
544
+function deleteAlertsByTitle() {
545
+ if (!titleFilterInput.value) return
546
+
547
+ deletingByTitle.value = true
548
+
549
+ Api.incidentManagement.alerts
550
+ .deleteAlertsByTitle(titleFilterInput.value)
551
+ .then(res => {
552
+ if (res.data.success) {
553
+ const deletedCount = res.data.deleted_alert_ids.length
554
+ const skippedCount = res.data.not_deleted_alert_ids.length
555
+
556
+ if (deletedCount > 0) {
557
+ if (skippedCount > 0) {
558
+ message.warning(
559
+ `Deleted ${deletedCount} alert(s). ${skippedCount} alert(s) were skipped (linked to cases).`
560
+ )
561
+ } else {
562
+ message.success(`Successfully deleted ${deletedCount} alert(s).`)
563
+ }
564
+
565
+ // Clear any checked alerts that were deleted
566
+ checkedAlerts.value = checkedAlerts.value.filter(
567
+ alert => !res.data.deleted_alert_ids.includes(alert.id)
568
+ )
569
+
570
+ // Refresh the list
571
+ getData()
572
+ } else {
573
+ message.info("No alerts were deleted. They may be linked to cases or not found.")
574
+ }
575
+
576
+ // Close modal and reset input
577
+ showDeleteByTitleModal.value = false
578
+ titleFilterInput.value = ""
579
+ } else {
580
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
581
+ }
582
+ })
583
+ .catch(err => {
584
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
585
+ })
586
+ .finally(() => {
587
+ deletingByTitle.value = false
588
+ })
589
}
590
591
useResizeObserver(header, entries => {
501
- const entry = entries[0]
502
- const { width } = entry.contentRect
592
+ const entry = entries[0]
593
+ const { width } = entry.contentRect
594
504
- pageSlot.value = width < 700 ? 5 : 8
505
- simpleMode.value = width < 550
595
+ pageSlot.value = width < 700 ? 5 : 8
596
+ simpleMode.value = width < 550
597
})
598
599
onBeforeMount(() => {
509
- if (!showFilters && preset?.length) {
510
- filters.value = preset
511
- }
600
+ if (!showFilters && preset?.length) {
601
+ filters.value = preset
602
+ }
603
513
- getData()
514
- getAvailableUsers()
515
- getCases()
604
+ getData()
605
+ getAvailableUsers()
606
+ getCases()
607
})
608
</script>