main
vue 394 lines 11.8 KB
Raw
1 <template>
2 <n-spin :show="loading" content-class="flex flex-col gap-4">
3 <!-- Statistics Cards -->
4 <div>
5 <div class="mb-5 flex flex-wrap items-center justify-between gap-3">
6 <h3 class="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
7 <Icon name="carbon:chart-ring" :size="20" class="text-primary" />
8 Distribution
9 </h3>
10
11 <p class="flex items-center gap-2 text-sm">
12 Total:
13 <code>{{ totalCount.toLocaleString() }}</code>
14 <Icon :name="InfoIcon" :size="14" />
15 </p>
16 </div>
17
18 <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
19 <n-card
20 v-for="item of statisticsCards"
21 :key="item.severity"
22 class="ring-primary cursor-pointer transition-all duration-300 hover:ring-1"
23 content-class="flex flex-col gap-2"
24 size="small"
25 @click="selectSeverity(item.severity)"
26 >
27 <div class="flex items-center justify-between gap-2 whitespace-nowrap">
28 <div class="flex items-center gap-2">
29 <VulnerabilitySeverityIcon :severity="item.severity" :size="24" :class="item.iconClass" />
30 <span class="text-xl">{{ item.label }}</span>
31 </div>
32 <div class="font-mono text-xl font-bold">{{ stats[item.key].toLocaleString() }}</div>
33 </div>
34 <n-progress
35 type="line"
36 indicator-placement="inside"
37 :percentage="getPercentage(stats[item.key])"
38 :color="item.barColor"
39 class="custom-progress"
40 />
41 </n-card>
42 </div>
43 </div>
44
45 <!-- Coverage Cards -->
46 <div class="mt-8">
47 <div class="mb-5 flex flex-wrap items-center justify-between gap-3">
48 <h3 class="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
49 <Icon name="carbon:double-axis-chart-column" :size="20" class="text-primary" />
50 Coverage
51 </h3>
52
53 <p class="flex items-center gap-2 text-sm">
54 Coverage based on the 100 vulnerabilities with the highest EPSS score.
55 <Icon :name="InfoIcon" :size="14" />
56 </p>
57 </div>
58
59 <n-card class="bg-secondary! overflow-hidden">
60 <div class="flex flex-wrap justify-between gap-8">
61 <n-statistic label="Affected Agents" :value="stats.uniqueAgents.toLocaleString()" tabular-nums />
62 <n-statistic label="Unique Packages" :value="stats.uniquePackages.toLocaleString()" tabular-nums />
63 <n-statistic label="Customer Codes" :value="stats.uniqueCustomers.toLocaleString()" tabular-nums />
64 </div>
65 </n-card>
66 </div>
67
68 <!-- Top 5 Packages by EPSS Score -->
69 <div v-if="topEpssPackages.length > 0" class="mt-8">
70 <div class="mb-5 flex flex-wrap items-center justify-between gap-3">
71 <h3 class="flex items-center gap-2 text-lg font-semibold">
72 <Icon :name="PackageIcon" :size="20" class="text-primary" />
73 Top 5 Packages by EPSS Score
74 </h3>
75
76 <p class="flex items-center gap-2 text-sm">
77 Ranking based on the 100 vulnerabilities with the highest EPSS score.
78 <Icon :name="InfoIcon" :size="14" />
79 </p>
80 </div>
81 <div class="grid-auto-fit-250 grid gap-10">
82 <div
83 v-for="(pkg, index) in topEpssPackages"
84 :key="pkg.package_name"
85 class="@container flex items-stretch gap-2"
86 >
87 <div class="flex items-end">
88 <div
89 class="w-4 rounded-t-2xl rounded-b-sm"
90 :class="{
91 'h-full bg-yellow-500': index === 0,
92 'h-8/12 bg-gray-400': index === 1,
93 'h-4/12 bg-amber-600': index === 2
94 }"
95 ></div>
96 </div>
97 <div
98 class="bg-secondary ring-primary flex grow cursor-pointer flex-col gap-2 rounded-md px-3 py-2 transition-all duration-300 hover:ring-1"
99 @click="selectPackage(pkg.package_name)"
100 >
101 <div class="flex items-center gap-2 text-xl">
102 <Icon
103 :name="index < 3 ? 'carbon:trophy' : 'carbon:warning-alt'"
104 :size="20"
105 :class="
106 index === 0
107 ? 'text-yellow-500'
108 : index === 1
109 ? 'text-gray-400'
110 : index === 2
111 ? 'text-amber-600'
112 : 'text-info'
113 "
114 />
115 <span>#{{ index + 1 }}</span>
116 </div>
117
118 <div class="text-lg leading-snug font-semibold">{{ pkg.package_name }}</div>
119
120 <div class="text-secondary grid grid-cols-1 gap-1 text-xs break-all @md:grid-cols-2">
121 <div class="flex items-center gap-2">
122 <span>EPSS:</span>
123 <span class="font-mono font-semibold">{{ pkg.maxEpssScore.toLocaleString() }}</span>
124 </div>
125 <div class="flex items-center gap-2">
126 <span>Vulnerabilities:</span>
127 <span class="font-mono font-semibold">{{ pkg.vulnCount.toLocaleString() }}</span>
128 </div>
129 <div class="flex items-center gap-2">
130 <span>Affected Agents:</span>
131 <span class="font-mono font-semibold">
132 {{ pkg.affectedAgents.toLocaleString() }}
133 </span>
134 </div>
135 <div class="flex items-center gap-2">
136 <span>Max CVSS:</span>
137 <span class="font-mono font-semibold">
138 {{ pkg.maxCvssScore?.toFixed(1) || "N/A" }}
139 </span>
140 </div>
141 <div class="flex items-center gap-2">
142 <span>Critical count:</span>
143 <span class="font-mono font-semibold">{{ pkg.criticalCount }}</span>
144 </div>
145 <div class="flex items-center gap-2">
146 <span>High count:</span>
147 <span class="font-mono font-semibold">{{ pkg.highCount }}</span>
148 </div>
149 </div>
150 </div>
151 </div>
152 </div>
153 </div>
154 </n-spin>
155 </template>
156
157 <script setup lang="ts">
158 import type { VulnerabilitiesListFilter } from "./types.d"
159 import type { VulnerabilitySearchItem, VulnerabilitySearchQuery } from "@/types/vulnerabilities.d"
160 import { watchDebounced } from "@vueuse/core"
161 import axios from "axios"
162 import _toNumber from "lodash/toNumber"
163 import { NCard, NProgress, NSpin, NStatistic, useMessage } from "naive-ui"
164 import { computed, ref, toRefs } from "vue"
165 import Api from "@/api"
166 import Icon from "@/components/common/Icon.vue"
167 import { VulnerabilitySeverity } from "@/types/vulnerabilities.d"
168 import VulnerabilitySeverityIcon from "./VulnerabilitySeverityIcon.vue"
169
170 const props = defineProps<{ filters: VulnerabilitiesListFilter[] }>()
171
172 const emit = defineEmits<{
173 (e: "update:severity", value: VulnerabilitySeverity): void
174 (e: "update:package", value: string): void
175 }>()
176
177 const { filters } = toRefs(props)
178
179 const message = useMessage()
180 const loading = ref(false)
181 const list = ref<VulnerabilitySearchItem[]>([])
182
183 // Severity counts from API response
184 const totalCount = ref(0)
185 const criticalCount = ref(0)
186 const highCount = ref(0)
187 const mediumCount = ref(0)
188 const lowCount = ref(0)
189
190 const PackageIcon = "carbon:package"
191 const InfoIcon = "carbon:information"
192
193 // Define the allowed keys for statistics
194 type SeverityKey = "critical" | "high" | "medium" | "low"
195
196 const statisticsCards: {
197 label: string
198 severity: VulnerabilitySeverity
199 iconClass: string
200 barColor: string
201 key: SeverityKey
202 }[] = [
203 {
204 label: "Critical",
205 severity: VulnerabilitySeverity.Critical,
206 iconClass: "text-error",
207 barColor: "var(--error-color)",
208 key: "critical"
209 },
210 {
211 label: "High",
212 severity: VulnerabilitySeverity.High,
213 iconClass: "text-orange-500",
214 barColor: "var(--color-orange-500)",
215 key: "high"
216 },
217 {
218 label: "Medium",
219 severity: VulnerabilitySeverity.Medium,
220 iconClass: "text-warning",
221 barColor: "var(--warning-color)",
222 key: "medium"
223 },
224 {
225 label: "Low",
226 severity: VulnerabilitySeverity.Low,
227 iconClass: "text-info",
228 barColor: "var(--info-color)",
229 key: "low"
230 }
231 ]
232
233 // Calculate statistics from current data
234 const stats = computed((): Record<SeverityKey | "uniqueAgents" | "uniquePackages" | "uniqueCustomers", number> => {
235 // Use API response counts for global statistics across all pages
236 const critical = criticalCount.value
237 const high = highCount.value
238 const medium = mediumCount.value
239 const low = lowCount.value
240
241 // Calculate unique values from current page data for context
242 const uniqueAgents = new Set(list.value.map(v => v.agent_name)).size
243 const uniquePackages = new Set(list.value.map(v => v.package_name).filter(Boolean)).size
244 const uniqueCustomers = new Set(list.value.map(v => v.customer_code).filter(Boolean)).size
245
246 return {
247 critical,
248 high,
249 medium,
250 low,
251 uniqueAgents,
252 uniquePackages,
253 uniqueCustomers
254 }
255 })
256
257 // Calculate top packages by EPSS score
258 const topEpssPackages = computed(() => {
259 // Group vulnerabilities by package name
260 const packageMap = new Map<
261 string,
262 {
263 package_name: string
264 vulnCount: number
265 maxEpssScore: number
266 maxCvssScore: number | null
267 affectedAgents: Set<string>
268 criticalCount: number
269 highCount: number
270 }
271 >()
272
273 list.value.forEach(vuln => {
274 if (!vuln.package_name || !vuln.epss_score) return
275
276 const epssScore = Number.parseFloat(vuln.epss_score)
277 if (Number.isNaN(epssScore)) return
278
279 const key = vuln.package_name
280 const existing = packageMap.get(key)
281
282 if (existing) {
283 existing.vulnCount++
284 existing.maxEpssScore = Math.max(existing.maxEpssScore, epssScore)
285 if (vuln.base_score) {
286 existing.maxCvssScore = Math.max(existing.maxCvssScore || 0, vuln.base_score)
287 }
288 existing.affectedAgents.add(vuln.agent_name)
289
290 if (vuln.severity === VulnerabilitySeverity.Critical) existing.criticalCount++
291 if (vuln.severity === VulnerabilitySeverity.High) existing.highCount++
292 } else {
293 packageMap.set(key, {
294 package_name: vuln.package_name,
295 vulnCount: 1,
296 maxEpssScore: epssScore,
297 maxCvssScore: vuln.base_score || null,
298 affectedAgents: new Set([vuln.agent_name]),
299 criticalCount: vuln.severity === VulnerabilitySeverity.Critical ? 1 : 0,
300 highCount: vuln.severity === VulnerabilitySeverity.High ? 1 : 0
301 })
302 }
303 })
304
305 // Convert to array and sort by max EPSS score
306 return Array.from(packageMap.values(), pkg => ({
307 ...pkg,
308 affectedAgents: pkg.affectedAgents.size
309 }))
310 .sort((a, b) => b.maxEpssScore - a.maxEpssScore)
311 .slice(0, 5)
312 })
313
314 function getPercentage(count: number): number {
315 if (totalCount.value === 0) return 0
316 return _toNumber(((count / totalCount.value) * 100).toFixed(1))
317 }
318
319 let abortController: AbortController | null = null
320
321 function getList() {
322 abortController?.abort()
323 abortController = new AbortController()
324
325 loading.value = true
326
327 const query: VulnerabilitySearchQuery = {
328 page: 1,
329 page_size: 100,
330 customer_code: filters.value.find(o => o.type === "customer_code")?.value || undefined,
331 severity: (filters.value.find(o => o.type === "severity")?.value as VulnerabilitySeverity) || undefined,
332 cve_id: filters.value.find(o => o.type === "cve_id")?.value || undefined,
333 agent_name: filters.value.find(o => o.type === "agent_name")?.value || undefined,
334 package_name: filters.value.find(o => o.type === "package_name")?.value || undefined,
335 include_epss: true
336 }
337
338 Api.vulnerabilities
339 .searchVulnerabilities(query, abortController.signal)
340 .then(res => {
341 loading.value = false
342
343 if (res.data.success) {
344 list.value = res.data?.vulnerabilities || []
345
346 // Store severity counts from API response
347 totalCount.value = res.data?.total_count || 0
348 criticalCount.value = res.data?.critical_count || 0
349 highCount.value = res.data?.high_count || 0
350 mediumCount.value = res.data?.medium_count || 0
351 lowCount.value = res.data?.low_count || 0
352 } else {
353 message.warning(res.data?.message || "An error occurred. Please try again later.")
354 }
355 })
356 .catch(err => {
357 if (!axios.isCancel(err)) {
358 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
359 loading.value = false
360 }
361 })
362 }
363
364 function selectSeverity(value: VulnerabilitySeverity) {
365 emit("update:severity", value)
366 }
367
368 function selectPackage(value: string) {
369 emit("update:package", value)
370 }
371
372 watchDebounced(
373 filters,
374 () => {
375 getList()
376 },
377 { deep: true, debounce: 300, immediate: true }
378 )
379 </script>
380
381 <style lang="scss" scoped>
382 .custom-progress {
383 :deep() {
384 .n-progress-graph-line-indicator {
385 text-shadow:
386 0px 0px 1px black,
387 0px 0px 2px black,
388 0px 0px 3px black;
389 font-weight: bold;
390 color: white !important;
391 }
392 }
393 }
394 </style>