| 1 | <template> |
| 2 | <div class="healthcheck-list"> |
| 3 | <div ref="header" class="header flex items-center justify-end gap-2"> |
| 4 | <div class="info flex grow gap-2"> |
| 5 | <n-popover overlap placement="bottom-start"> |
| 6 | <template #trigger> |
| 7 | <div class="bg-default rounded-lg"> |
| 8 | <n-button size="small" class="cursor-help!"> |
| 9 | <template #icon> |
| 10 | <Icon :name="InfoIcon" /> |
| 11 | </template> |
| 12 | </n-button> |
| 13 | </div> |
| 14 | </template> |
| 15 | <div class="flex flex-col gap-2"> |
| 16 | <div class="box"> |
| 17 | Total : |
| 18 | <code>{{ stats?.total_count || 0 }}</code> |
| 19 | </div> |
| 20 | <div class="box text-error-500"> |
| 21 | Active : |
| 22 | <code>{{ stats?.active_alerts_count || 0 }}</code> |
| 23 | </div> |
| 24 | <div class="box text-warning-500"> |
| 25 | Critical : |
| 26 | <code>{{ criticalTotal }}</code> |
| 27 | </div> |
| 28 | <div class="box text-success-500"> |
| 29 | Cleared : |
| 30 | <code>{{ stats?.cleared_alerts_count || 0 }}</code> |
| 31 | </div> |
| 32 | </div> |
| 33 | </n-popover> |
| 34 | </div> |
| 35 | <div class="flex items-center gap-2"> |
| 36 | <n-select |
| 37 | v-model:value="checkNameFilter" |
| 38 | :options="checkNameOptions" |
| 39 | size="small" |
| 40 | class="w-48!" |
| 41 | placeholder="Filter by check name" |
| 42 | clearable |
| 43 | filterable |
| 44 | @update:value="getData" |
| 45 | /> |
| 46 | <n-select |
| 47 | v-model:value="statusFilter" |
| 48 | :options="statusOptions" |
| 49 | size="small" |
| 50 | class="w-32!" |
| 51 | @update:value="getData" |
| 52 | /> |
| 53 | <n-checkbox v-model:checked="excludeOk" size="small" @update:checked="getData">Exclude OK</n-checkbox> |
| 54 | </div> |
| 55 | <n-pagination |
| 56 | v-model:page="currentPage" |
| 57 | v-model:page-size="pageSize" |
| 58 | :page-slot |
| 59 | :show-size-picker |
| 60 | :page-sizes |
| 61 | :item-count="total" |
| 62 | :simple="simpleMode" |
| 63 | /> |
| 64 | </div> |
| 65 | <n-spin :show="loading"> |
| 66 | <div class="my-3 flex min-h-52 flex-col gap-2"> |
| 67 | <template v-if="healthcheckList.length"> |
| 68 | <HealthcheckItem |
| 69 | v-for="alert of itemsPaginated" |
| 70 | :key="(alert.check_id || '') + alert.time" |
| 71 | :alert |
| 72 | class="item-appear item-appear-bottom item-appear-005" |
| 73 | /> |
| 74 | </template> |
| 75 | <template v-else> |
| 76 | <n-empty v-if="!loading" description="No items found" class="h-48 justify-center" /> |
| 77 | </template> |
| 78 | </div> |
| 79 | </n-spin> |
| 80 | <div class="footer flex justify-end"> |
| 81 | <n-pagination |
| 82 | v-if="itemsPaginated.length > 3" |
| 83 | v-model:page="currentPage" |
| 84 | :page-size |
| 85 | :item-count="total" |
| 86 | :page-slot="6" |
| 87 | /> |
| 88 | </div> |
| 89 | </div> |
| 90 | </template> |
| 91 | |
| 92 | <script setup lang="ts"> |
| 93 | // TODO-FE: refactor |
| 94 | import type { InfluxDBAlert, InfluxDBAlertResponse } from "@/types/healthchecks.d" |
| 95 | import { useResizeObserver } from "@vueuse/core" |
| 96 | import _orderBy from "lodash/orderBy" |
| 97 | import { NButton, NCheckbox, NEmpty, NPagination, NPopover, NSelect, NSpin, useMessage } from "naive-ui" |
| 98 | import { computed, onBeforeMount, ref } from "vue" |
| 99 | import Api from "@/api" |
| 100 | import Icon from "@/components/common/Icon.vue" |
| 101 | import { InfluxDBAlertSeverity } from "@/types/healthchecks.d" |
| 102 | import HealthcheckItem from "./HealthcheckItem.vue" |
| 103 | |
| 104 | const message = useMessage() |
| 105 | const loading = ref(false) |
| 106 | const healthcheckList = ref<InfluxDBAlert[]>([]) |
| 107 | const stats = ref<InfluxDBAlertResponse | null>(null) |
| 108 | const checkNames = ref<string[]>([]) |
| 109 | |
| 110 | const pageSize = ref(25) |
| 111 | const currentPage = ref(1) |
| 112 | const simpleMode = ref(false) |
| 113 | const showSizePicker = ref(true) |
| 114 | const pageSizes = [10, 25, 50, 100] |
| 115 | const header = ref() |
| 116 | const pageSlot = ref(8) |
| 117 | |
| 118 | // Filters |
| 119 | const statusFilter = ref<"all" | "active" | "cleared">("all") |
| 120 | const excludeOk = ref(false) |
| 121 | const checkNameFilter = ref<string | null>(null) |
| 122 | |
| 123 | const statusOptions = [ |
| 124 | { label: "All", value: "all" }, |
| 125 | { label: "Active", value: "active" }, |
| 126 | { label: "Cleared", value: "cleared" } |
| 127 | ] |
| 128 | |
| 129 | const checkNameOptions = computed(() => [ |
| 130 | { label: "All Checks" }, |
| 131 | ...checkNames.value.map(name => ({ label: name, value: name })) |
| 132 | ]) |
| 133 | |
| 134 | const itemsPaginated = computed(() => { |
| 135 | const from = (currentPage.value - 1) * pageSize.value |
| 136 | const to = currentPage.value * pageSize.value |
| 137 | |
| 138 | const list = _orderBy( |
| 139 | healthcheckList.value, |
| 140 | [ |
| 141 | // Sort by severity priority |
| 142 | item => { |
| 143 | const severityOrder = { |
| 144 | [InfluxDBAlertSeverity.Critical]: 0, |
| 145 | [InfluxDBAlertSeverity.Warning]: 1, |
| 146 | [InfluxDBAlertSeverity.Info]: 2, |
| 147 | [InfluxDBAlertSeverity.Ok]: 3 |
| 148 | } |
| 149 | return severityOrder[item.severity as InfluxDBAlertSeverity] |
| 150 | }, |
| 151 | "time" |
| 152 | ], |
| 153 | ["asc", "desc"] |
| 154 | ) |
| 155 | |
| 156 | return list.slice(from, to) |
| 157 | }) |
| 158 | |
| 159 | const InfoIcon = "carbon:information" |
| 160 | |
| 161 | const total = computed<number>(() => { |
| 162 | return healthcheckList.value.length || 0 |
| 163 | }) |
| 164 | |
| 165 | const criticalTotal = computed<number>(() => { |
| 166 | return healthcheckList.value.filter(o => o.severity === InfluxDBAlertSeverity.Critical).length || 0 |
| 167 | }) |
| 168 | |
| 169 | function getCheckNames() { |
| 170 | Api.healthchecks |
| 171 | .getCheckNames() |
| 172 | .then(res => { |
| 173 | if (res.data.success) { |
| 174 | checkNames.value = res.data.check_names || [] |
| 175 | } |
| 176 | }) |
| 177 | .catch(() => { |
| 178 | checkNames.value = [] |
| 179 | }) |
| 180 | } |
| 181 | |
| 182 | function getData() { |
| 183 | loading.value = true |
| 184 | |
| 185 | Api.healthchecks |
| 186 | .getHealthchecks({ |
| 187 | days: 7, |
| 188 | status: statusFilter.value, |
| 189 | exclude_ok: excludeOk.value, |
| 190 | check_name: checkNameFilter.value || undefined |
| 191 | }) |
| 192 | .then(res => { |
| 193 | if (res.data.success) { |
| 194 | healthcheckList.value = res.data.alerts || [] |
| 195 | stats.value = res.data |
| 196 | } else { |
| 197 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 198 | } |
| 199 | }) |
| 200 | .catch(err => { |
| 201 | healthcheckList.value = [] |
| 202 | stats.value = null |
| 203 | |
| 204 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 205 | }) |
| 206 | .finally(() => { |
| 207 | loading.value = false |
| 208 | }) |
| 209 | } |
| 210 | |
| 211 | useResizeObserver(header, entries => { |
| 212 | const entry = entries[0] |
| 213 | if (!entry) return |
| 214 | |
| 215 | const { width } = entry.contentRect |
| 216 | |
| 217 | pageSlot.value = width < 650 ? 5 : 8 |
| 218 | simpleMode.value = width < 450 |
| 219 | }) |
| 220 | |
| 221 | onBeforeMount(() => { |
| 222 | getCheckNames() |
| 223 | getData() |
| 224 | }) |
| 225 | </script> |