| 1 | <template> |
| 2 | <div class="soc-alerts-list"> |
| 3 | <div ref="header" class="flex h-14 items-center justify-end gap-2"> |
| 4 | <slot name="header"></slot> |
| 5 | <div class="grow"> |
| 6 | <n-input v-model:value="alertTitle" size="small" placeholder="Search by title..." clearable /> |
| 7 | </div> |
| 8 | <div class="delete-box"> |
| 9 | <n-popover v-if="checkedCount" :width="200" placement="bottom" style="max-height: 240px" scrollable> |
| 10 | <template #trigger> |
| 11 | <n-button |
| 12 | size="small" |
| 13 | type="error" |
| 14 | ghost |
| 15 | :loading="loadingPurge" |
| 16 | :disabled="loadingAlerts" |
| 17 | @click="handleDelete()" |
| 18 | > |
| 19 | <div class="flex items-center gap-2"> |
| 20 | <Icon :name="TrashIcon" :size="16" /> |
| 21 | <span class="flex items-center gap-2"> |
| 22 | <span v-if="!compactMode">Delete Alerts:</span> |
| 23 | <span class="font-mono"> |
| 24 | {{ checkedCount }} |
| 25 | </span> |
| 26 | </span> |
| 27 | </div> |
| 28 | </n-button> |
| 29 | </template> |
| 30 | <template #header>Selected Alerts</template> |
| 31 | <template #footer> |
| 32 | <div class="flex justify-end"> |
| 33 | <n-button size="tiny" @click="clearChecked()">Clear selection</n-button> |
| 34 | </div> |
| 35 | </template> |
| 36 | <div class="checked-list flex flex-col gap-2"> |
| 37 | <div v-for="alertId of checkedList" :key="alertId" class="w-full"> |
| 38 | <n-button size="small" class="w-full! justify-start!" @click="clearChecked(alertId)"> |
| 39 | <template #icon> |
| 40 | <Icon :name="CloseIcon" :size="18" /> |
| 41 | </template> |
| 42 | <span class="font-mono">#{{ alertId }}</span> |
| 43 | </n-button> |
| 44 | </div> |
| 45 | </div> |
| 46 | </n-popover> |
| 47 | <n-button v-else size="small" type="error" ghost :loading="loadingPurge" @click="handlePurge()"> |
| 48 | <div class="flex items-center gap-2"> |
| 49 | <Icon :name="TrashIcon" :size="16" /> |
| 50 | <span class="xs:block hidden">Purge</span> |
| 51 | </div> |
| 52 | </n-button> |
| 53 | </div> |
| 54 | <PaginationIndeterminate |
| 55 | v-model:page="page" |
| 56 | v-model:page-size="pageSize" |
| 57 | v-model:sort="sort" |
| 58 | :page-sizes |
| 59 | :show-page-sizes="!compactMode" |
| 60 | :show-sort="!smallDeviceMode" |
| 61 | /> |
| 62 | </div> |
| 63 | |
| 64 | <n-spin :show="loadingAlerts || loadingPurge"> |
| 65 | <div class="min-h-52"> |
| 66 | <template v-if="alertsList.length"> |
| 67 | <SocAlertItem |
| 68 | v-for="alert of alertsList" |
| 69 | :key="alert.id" |
| 70 | v-model:checked="alert.checked" |
| 71 | :alert-data="alert.data" |
| 72 | class="item-appear item-appear-bottom item-appear-005 mb-2" |
| 73 | :is-bookmark="isBookmarked(alert.data)" |
| 74 | :users="usersList" |
| 75 | :highlight="alert.id === highlight" |
| 76 | show-badges-toggle |
| 77 | show-checkbox |
| 78 | @check="toggleCheckedList(alert.id, $event)" |
| 79 | @bookmark="bookmark()" |
| 80 | @deleted="itemDeleted(alert.id)" |
| 81 | /> |
| 82 | </template> |
| 83 | <template v-else> |
| 84 | <n-empty v-if="!loadingAlerts" description="No items found" class="h-48 justify-center" /> |
| 85 | </template> |
| 86 | </div> |
| 87 | </n-spin> |
| 88 | </div> |
| 89 | </template> |
| 90 | |
| 91 | <script setup lang="ts"> |
| 92 | import type { AlertsFilter } from "@/api/endpoints/soc" |
| 93 | import type { SocAlert } from "@/types/soc/alert.d" |
| 94 | import type { SocUser } from "@/types/soc/user.d" |
| 95 | import { useResizeObserver, watchDebounced } from "@vueuse/core" |
| 96 | import axios from "axios" |
| 97 | import { NButton, NEmpty, NInput, NPopover, NSpin, useDialog, useMessage } from "naive-ui" |
| 98 | import { computed, nextTick, onBeforeMount, onBeforeUnmount, onMounted, ref, toRefs, watch } from "vue" |
| 99 | import Api from "@/api" |
| 100 | import Icon from "@/components/common/Icon.vue" |
| 101 | import PaginationIndeterminate from "@/components/common/PaginationIndeterminate.vue" |
| 102 | import SocAlertItem from "./SocAlertItem/SocAlertItem.vue" |
| 103 | // MOCK |
| 104 | // import { alerts as alertsMock } from "./mock" |
| 105 | |
| 106 | const props = defineProps<{ |
| 107 | highlight: string | null | undefined |
| 108 | bookmarksList?: SocAlert[] |
| 109 | usersList?: SocUser[] |
| 110 | }>() |
| 111 | const emit = defineEmits<{ |
| 112 | (e: "bookmark"): void |
| 113 | (e: "deleted", value?: string): void |
| 114 | ( |
| 115 | e: "mounted", |
| 116 | value: { |
| 117 | reload: () => void |
| 118 | itemDeleted: (alertId: string, noEmit?: boolean) => void |
| 119 | } |
| 120 | ): void |
| 121 | }>() |
| 122 | |
| 123 | const { highlight, bookmarksList, usersList } = toRefs(props) |
| 124 | |
| 125 | const TrashIcon = "carbon:trash-can" |
| 126 | const CloseIcon = "carbon:close" |
| 127 | |
| 128 | let reloadTimeout: NodeJS.Timeout | null = null |
| 129 | const dialog = useDialog() |
| 130 | const message = useMessage() |
| 131 | const loadingPurge = ref(false) |
| 132 | const loadingAlerts = ref(false) |
| 133 | const alertsList = ref<{ checked: boolean; id: string; data: SocAlert }[]>([]) |
| 134 | const checkedList = ref<string[]>([]) |
| 135 | |
| 136 | const pageSize = ref(50) |
| 137 | const pageSizes = [25, 50, 100, 150, 200] |
| 138 | const page = ref(1) |
| 139 | const sort = ref<"desc" | "asc">("desc") |
| 140 | const alertTitle = ref("") |
| 141 | const header = ref() |
| 142 | const compactMode = ref(false) |
| 143 | const smallDeviceMode = ref(false) |
| 144 | |
| 145 | let abortController: AbortController | null = null |
| 146 | |
| 147 | const checkedCount = computed(() => checkedList.value.length) |
| 148 | |
| 149 | function isBookmarked(alert: SocAlert): boolean { |
| 150 | return !!(bookmarksList.value || []).filter(o => o.alert_id === alert.alert_id).length |
| 151 | } |
| 152 | |
| 153 | function bookmark() { |
| 154 | emit("bookmark") |
| 155 | safeReload() |
| 156 | } |
| 157 | |
| 158 | function getAlerts() { |
| 159 | loadingAlerts.value = true |
| 160 | |
| 161 | abortController = new AbortController() |
| 162 | |
| 163 | const filter: Partial<AlertsFilter> = {} |
| 164 | if (pageSize.value) { |
| 165 | filter.pageSize = pageSize.value |
| 166 | } |
| 167 | if (page.value) { |
| 168 | filter.page = page.value |
| 169 | } |
| 170 | if (sort.value) { |
| 171 | filter.sort = sort.value |
| 172 | } |
| 173 | if (alertTitle.value) { |
| 174 | filter.alertTitle = alertTitle.value |
| 175 | } |
| 176 | |
| 177 | Api.soc |
| 178 | .getAlerts(filter, abortController.signal) |
| 179 | .then(res => { |
| 180 | if (res.data.success) { |
| 181 | alertsList.value = (res.data?.alerts || []).map(o => ({ |
| 182 | checked: isChecked(o.alert_id.toString()), |
| 183 | data: o, |
| 184 | id: o.alert_id.toString() |
| 185 | })) |
| 186 | } else { |
| 187 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 188 | } |
| 189 | }) |
| 190 | .catch(err => { |
| 191 | if (!axios.isCancel(err)) { |
| 192 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 193 | } |
| 194 | }) |
| 195 | .finally(() => { |
| 196 | loadingAlerts.value = false |
| 197 | }) |
| 198 | } |
| 199 | |
| 200 | function scrollToItem(id: string) { |
| 201 | const element = document.getElementById(`alert-${id}`) |
| 202 | const scrollContent = document.querySelector("#main > .n-scrollbar > .n-scrollbar-container") as HTMLElement |
| 203 | |
| 204 | if (element && scrollContent) { |
| 205 | const wrap: HTMLElement = scrollContent |
| 206 | const middle = element.offsetTop - wrap.offsetHeight / 2 |
| 207 | scrollContent?.scrollTo({ top: middle, behavior: "smooth" }) |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | function safeReload() { |
| 212 | abortController?.abort() |
| 213 | |
| 214 | if (reloadTimeout) { |
| 215 | clearTimeout(reloadTimeout) |
| 216 | } |
| 217 | |
| 218 | reloadTimeout = setTimeout(() => { |
| 219 | getAlerts() |
| 220 | }, 200) |
| 221 | } |
| 222 | |
| 223 | function handleDelete() { |
| 224 | dialog.warning({ |
| 225 | title: "Confirm", |
| 226 | content: "This will remove latest 1000 Soc Alerts, are you sure you want to proceed?", |
| 227 | positiveText: "Yes I'm sure", |
| 228 | negativeText: "Cancel", |
| 229 | onPositiveClick: () => { |
| 230 | deleteMultipleAlerts() |
| 231 | }, |
| 232 | onNegativeClick: () => { |
| 233 | message.info("Purge canceled") |
| 234 | } |
| 235 | }) |
| 236 | } |
| 237 | |
| 238 | function handlePurge() { |
| 239 | dialog.warning({ |
| 240 | title: "Confirm", |
| 241 | content: "This will remove 1000 Soc Alerts, are you sure you want to proceed?", |
| 242 | positiveText: "Yes I'm sure", |
| 243 | negativeText: "Cancel", |
| 244 | onPositiveClick: () => { |
| 245 | purge() |
| 246 | }, |
| 247 | onNegativeClick: () => { |
| 248 | message.info("Purge canceled") |
| 249 | } |
| 250 | }) |
| 251 | } |
| 252 | |
| 253 | function deleteMultipleAlerts() { |
| 254 | loadingPurge.value = true |
| 255 | |
| 256 | Api.soc |
| 257 | .deleteMultipleAlerts(checkedList.value) |
| 258 | .then(res => { |
| 259 | if (res.data.success) { |
| 260 | clearChecked() |
| 261 | getAlerts() |
| 262 | emit("deleted") |
| 263 | message.success(res.data?.message || "SOC Alerts purged successfully") |
| 264 | } else { |
| 265 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 266 | } |
| 267 | }) |
| 268 | .catch(err => { |
| 269 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 270 | }) |
| 271 | .finally(() => { |
| 272 | loadingPurge.value = false |
| 273 | }) |
| 274 | } |
| 275 | |
| 276 | function purge() { |
| 277 | loadingPurge.value = true |
| 278 | |
| 279 | Api.soc |
| 280 | .purgeAlerts() |
| 281 | .then(res => { |
| 282 | if (res.data.success) { |
| 283 | getAlerts() |
| 284 | emit("deleted") |
| 285 | message.success(res.data?.message || "SOC Alerts purged successfully") |
| 286 | } else { |
| 287 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 288 | } |
| 289 | }) |
| 290 | .catch(err => { |
| 291 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 292 | }) |
| 293 | .finally(() => { |
| 294 | loadingPurge.value = false |
| 295 | }) |
| 296 | } |
| 297 | |
| 298 | function itemDeleted(alertId: string, noEmit = false) { |
| 299 | clearChecked(alertId) |
| 300 | getAlerts() |
| 301 | if (!noEmit) { |
| 302 | emit("deleted", alertId) |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | function toggleCheckedList(alertId: string, value: boolean) { |
| 307 | if (value) { |
| 308 | checkedList.value.push(alertId) |
| 309 | } else { |
| 310 | checkedList.value = checkedList.value.filter(o => o !== alertId) |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | function isChecked(alertId: string) { |
| 315 | return checkedList.value.filter(o => o === alertId).length !== 0 |
| 316 | } |
| 317 | |
| 318 | function clearChecked(alertId?: string) { |
| 319 | if (alertId) { |
| 320 | const alert = alertsList.value.find(o => o.id === alertId) |
| 321 | if (alert) { |
| 322 | alert.checked = false |
| 323 | } |
| 324 | checkedList.value = checkedList.value.filter(o => o !== alertId) |
| 325 | } else { |
| 326 | for (const alert of alertsList.value) { |
| 327 | alert.checked = false |
| 328 | } |
| 329 | checkedList.value = [] |
| 330 | } |
| 331 | } |
| 332 | |
| 333 | watch(loadingAlerts, val => { |
| 334 | if (!val) { |
| 335 | nextTick(() => { |
| 336 | setTimeout(() => { |
| 337 | if (highlight.value) { |
| 338 | scrollToItem(highlight.value) |
| 339 | } |
| 340 | }, 300) |
| 341 | }) |
| 342 | } |
| 343 | }) |
| 344 | |
| 345 | watch(highlight, val => { |
| 346 | if (val) { |
| 347 | nextTick(() => { |
| 348 | setTimeout(() => { |
| 349 | scrollToItem(val) |
| 350 | }) |
| 351 | }) |
| 352 | } |
| 353 | }) |
| 354 | |
| 355 | watchDebounced( |
| 356 | [page, pageSize, sort, alertTitle], |
| 357 | () => { |
| 358 | safeReload() |
| 359 | }, |
| 360 | { debounce: 300 } |
| 361 | ) |
| 362 | |
| 363 | useResizeObserver(header, entries => { |
| 364 | const entry = entries[0] |
| 365 | if (!entry) return |
| 366 | |
| 367 | const { width } = entry.contentRect |
| 368 | |
| 369 | if (width < 600) { |
| 370 | compactMode.value = true |
| 371 | pageSize.value = pageSizes[0] ?? 50 |
| 372 | } else { |
| 373 | compactMode.value = false |
| 374 | } |
| 375 | |
| 376 | smallDeviceMode.value = width < 450 |
| 377 | }) |
| 378 | |
| 379 | onBeforeMount(() => { |
| 380 | // MOCK |
| 381 | // alertsList.value = alertsMock as unknown as SocAlert[] |
| 382 | getAlerts() |
| 383 | }) |
| 384 | |
| 385 | onMounted(() => { |
| 386 | emit("mounted", { |
| 387 | reload: () => { |
| 388 | safeReload() |
| 389 | }, |
| 390 | itemDeleted |
| 391 | }) |
| 392 | }) |
| 393 | |
| 394 | onBeforeUnmount(() => { |
| 395 | abortController?.abort() |
| 396 | }) |
| 397 | </script> |