1
+<template>
2
+ <div class="flex flex-col gap-4">
3
+ <!-- Info Banner -->
4
+ <div class="info-banner p-3 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30">
5
+ <div class="flex items-start gap-3">
6
+ <Icon :name="InfoIcon" class="text-blue-600 dark:text-blue-400 mt-0.5" :size="16" />
7
+ <p class="text-sm text-blue-800 dark:text-blue-200 leading-relaxed">
8
+ Vulnerability Overview provides real-time vulnerability data from Wazuh Indexer with EPSS scoring and detailed package information.
9
+ </p>
10
+ </div>
11
+ </div>
12
+
13
+ <!-- Filters -->
14
+ <div class="flex flex-col">
15
+ <div ref="header" class="header flex items-center justify-end gap-2">
16
+ <div class="info flex grow gap-2">
17
+ <n-popover overlap placement="bottom-start">
18
+ <template #trigger>
19
+ <div class="bg-default rounded-lg">
20
+ <n-button size="small" class="!cursor-help">
21
+ <template #icon>
22
+ <Icon :name="InfoIcon"></Icon>
23
+ </template>
24
+ </n-button>
25
+ </div>
26
+ </template>
27
+ <div class="flex flex-col gap-3 p-2 max-w-sm">
28
+ <div class="font-medium text-sm mb-2">Vulnerability Overview</div>
29
+
30
+ <div class="grid grid-cols-2 gap-3 text-xs">
31
+ <div class="flex justify-between">
32
+ <span>Total Vulnerabilities:</span>
33
+ <code class="font-mono">{{ totalCount.toLocaleString() }}</code>
34
+ </div>
35
+ <div class="flex justify-between">
36
+ <span>Current Page:</span>
37
+ <code class="font-mono">{{ currentPage }} / {{ totalPages }}</code>
38
+ </div>
39
+ </div>
40
+
41
+ <div class="border-t pt-2">
42
+ <div class="text-xs font-medium mb-2">Severity Distribution</div>
43
+ <div class="grid grid-cols-2 gap-2 text-xs">
44
+ <div class="flex justify-between">
45
+ <span class="text-red-600">Critical:</span>
46
+ <span class="font-mono">{{ stats.critical.toLocaleString() }} ({{ getPercentage(stats.critical) }}%)</span>
47
+ </div>
48
+ <div class="flex justify-between">
49
+ <span class="text-orange-600">High:</span>
50
+ <span class="font-mono">{{ stats.high.toLocaleString() }} ({{ getPercentage(stats.high) }}%)</span>
51
+ </div>
52
+ <div class="flex justify-between">
53
+ <span class="text-yellow-600">Medium:</span>
54
+ <span class="font-mono">{{ stats.medium.toLocaleString() }} ({{ getPercentage(stats.medium) }}%)</span>
55
+ </div>
56
+ <div class="flex justify-between">
57
+ <span class="text-blue-600">Low:</span>
58
+ <span class="font-mono">{{ stats.low.toLocaleString() }} ({{ getPercentage(stats.low) }}%)</span>
59
+ </div>
60
+ </div>
61
+ </div>
62
+
63
+ <div class="border-t pt-2">
64
+ <div class="text-xs font-medium mb-2">Coverage</div>
65
+ <div class="space-y-1 text-xs">
66
+ <div class="flex justify-between">
67
+ <span>Affected Agents:</span>
68
+ <span class="font-mono">{{ stats.uniqueAgents.toLocaleString() }}</span>
69
+ </div>
70
+ <div class="flex justify-between">
71
+ <span>Unique Packages:</span>
72
+ <span class="font-mono">{{ stats.uniquePackages.toLocaleString() }}</span>
73
+ </div>
74
+ <div class="flex justify-between">
75
+ <span>Customer Codes:</span>
76
+ <span class="font-mono">{{ stats.uniqueCustomers.toLocaleString() }}</span>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+ </n-popover> <n-select
82
+ v-model:value="selectedCustomer"
83
+ :options="customerOptions"
84
+ clearable
85
+ size="small"
86
+ placeholder="Customer"
87
+ class="max-w-32"
88
+ :loading="loadingCustomers"
89
+ />
90
+
91
+ <n-select
92
+ v-model:value="selectedSeverity"
93
+ :options="severityOptions"
94
+ clearable
95
+ size="small"
96
+ placeholder="Severity"
97
+ class="max-w-32"
98
+ />
99
+
100
+ <n-input
101
+ v-model:value="searchCVE"
102
+ size="small"
103
+ placeholder="Search CVE..."
104
+ class="max-w-40"
105
+ clearable
106
+ >
107
+ <template #prefix>
108
+ <Icon :name="SearchIcon"></Icon>
109
+ </template>
110
+ </n-input>
111
+
112
+ <n-select
113
+ v-model:value="searchAgent"
114
+ :options="agentOptions"
115
+ size="small"
116
+ placeholder="Search agent..."
117
+ class="max-w-40"
118
+ clearable
119
+ filterable
120
+ :loading="loadingAgents"
121
+ />
122
+
123
+ <n-input
124
+ v-model:value="searchPackage"
125
+ size="small"
126
+ placeholder="Search package..."
127
+ class="max-w-40"
128
+ clearable
129
+ >
130
+ <template #prefix>
131
+ <Icon :name="PackageIcon"></Icon>
132
+ </template>
133
+ </n-input>
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ <!-- Statistics Cards -->
139
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4 mb-4">
140
+ <div class="stat-card">
141
+ <div class="stat-header">
142
+ <Icon :name="TotalIcon" :size="20" class="text-blue-600" />
143
+ <span class="stat-title">Total</span>
144
+ </div>
145
+ <div class="stat-value">{{ totalCount.toLocaleString() }}</div>
146
+ </div>
147
+
148
+ <div class="stat-card critical clickable" :class="{ selected: selectedSeverity === VulnerabilitySeverity.Critical }" @click="selectSeverity(VulnerabilitySeverity.Critical)">
149
+ <div class="stat-header">
150
+ <Icon :name="CriticalIcon" :size="20" class="text-red-600" />
151
+ <span class="stat-title">Critical</span>
152
+ </div>
153
+ <div class="stat-value">{{ stats.critical.toLocaleString() }}</div>
154
+ <div class="stat-percentage">{{ getPercentage(stats.critical) }}%</div>
155
+ </div>
156
+
157
+ <div class="stat-card high clickable" :class="{ selected: selectedSeverity === VulnerabilitySeverity.High }" @click="selectSeverity(VulnerabilitySeverity.High)">
158
+ <div class="stat-header">
159
+ <Icon :name="HighIcon" :size="20" class="text-orange-600" />
160
+ <span class="stat-title">High</span>
161
+ </div>
162
+ <div class="stat-value">{{ stats.high.toLocaleString() }}</div>
163
+ <div class="stat-percentage">{{ getPercentage(stats.high) }}%</div>
164
+ </div>
165
+
166
+ <div class="stat-card medium clickable" :class="{ selected: selectedSeverity === VulnerabilitySeverity.Medium }" @click="selectSeverity(VulnerabilitySeverity.Medium)">
167
+ <div class="stat-header">
168
+ <Icon :name="MediumIcon" :size="20" class="text-yellow-600" />
169
+ <span class="stat-title">Medium</span>
170
+ </div>
171
+ <div class="stat-value">{{ stats.medium.toLocaleString() }}</div>
172
+ <div class="stat-percentage">{{ getPercentage(stats.medium) }}%</div>
173
+ </div>
174
+
175
+ <div class="stat-card low clickable" :class="{ selected: selectedSeverity === VulnerabilitySeverity.Low }" @click="selectSeverity(VulnerabilitySeverity.Low)">
176
+ <div class="stat-header">
177
+ <Icon :name="LowIcon" :size="20" class="text-blue-600" />
178
+ <span class="stat-title">Low</span>
179
+ </div>
180
+ <div class="stat-value">{{ stats.low.toLocaleString() }}</div>
181
+ <div class="stat-percentage">{{ getPercentage(stats.low) }}%</div>
182
+ </div>
183
+ </div>
184
+
185
+ <!-- Top 5 Packages by EPSS Score -->
186
+ <div v-if="topEpssPackages.length > 0" class="mb-4">
187
+ <h3 class="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100 flex items-center gap-2">
188
+ <Icon :name="PackageIcon" :size="20" class="text-orange-600" />
189
+ Top 5 Packages by EPSS Score
190
+ </h3>
191
+ <div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
192
+ <div
193
+ v-for="(pkg, index) in topEpssPackages.slice(0, 5)"
194
+ :key="`${pkg.package_name}-${pkg.maxEpssScore}`"
195
+ class="epss-package-card clickable"
196
+ :class="{
197
+ 'rank-1': index === 0,
198
+ 'rank-2': index === 1,
199
+ 'rank-3': index === 2,
200
+ 'selected': searchPackage === pkg.package_name
201
+ }"
202
+ @click="selectPackage(pkg.package_name)"
203
+ >
204
+ <div class="epss-header">
205
+ <div class="epss-rank">
206
+ <Icon
207
+ :name="index < 3 ? 'carbon:trophy' : 'carbon:warning-alt'"
208
+ :size="16"
209
+ :class="index === 0 ? 'text-yellow-500' : index === 1 ? 'text-gray-400' : index === 2 ? 'text-amber-600' : 'text-orange-500'"
210
+ />
211
+ <span class="rank-number">#{{ index + 1 }}</span>
212
+ </div>
213
+ <Badge color="warning" type="splitted" size="small">
214
+ <template #label>EPSS</template>
215
+ <template #value>{{ pkg.maxEpssScore.toFixed(3) }}</template>
216
+ </Badge>
217
+ </div>
218
+
219
+ <div class="package-name">{{ pkg.package_name }}</div>
220
+
221
+ <div class="package-stats">
222
+ <div class="stat-row">
223
+ <span class="stat-label">Vulnerabilities:</span>
224
+ <span class="stat-value">{{ pkg.vulnCount.toLocaleString() }}</span>
225
+ </div>
226
+ <div class="stat-row">
227
+ <span class="stat-label">Affected Agents:</span>
228
+ <span class="stat-value">{{ pkg.affectedAgents.toLocaleString() }}</span>
229
+ </div>
230
+ <div class="stat-row">
231
+ <span class="stat-label">Max CVSS:</span>
232
+ <span class="stat-value">{{ pkg.maxCvssScore?.toFixed(1) || 'N/A' }}</span>
233
+ </div>
234
+ </div>
235
+
236
+ <!-- Critical/High severity indicator -->
237
+ <div v-if="pkg.criticalCount > 0 || pkg.highCount > 0" class="severity-indicator">
238
+ <Badge v-if="pkg.criticalCount > 0" color="danger" size="small">
239
+ <template #value>{{ pkg.criticalCount }} Critical</template>
240
+ </Badge>
241
+ <Badge v-if="pkg.highCount > 0" color="warning" size="small">
242
+ <template #value>{{ pkg.highCount }} High</template>
243
+ </Badge>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ </div>
248
+
249
+ <!-- Vulnerability List -->
250
+ <n-spin :show="loading">
251
+ <div class="my-3">
252
+ <template v-if="list.length">
253
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
254
+ <VulnerabilityCard v-for="item of list" :key="`${item.cve_id}-${item.agent_name}`" :vulnerability="item" />
255
+ </div>
256
+
257
+ <!-- Pagination -->
258
+ <div class="flex justify-center mt-6">
259
+ <n-pagination
260
+ v-model:page="currentPage"
261
+ :page-count="totalPages"
262
+ :page-size="pageSize"
263
+ :item-count="totalCount"
264
+ show-size-picker
265
+ :page-sizes="[25, 50, 100, 200]"
266
+ @update:page="updatePage"
267
+ @update:page-size="updatePageSize"
268
+ />
269
+ </div>
270
+ </template>
271
+ <template v-else>
272
+ <n-empty v-if="!loading" description="No vulnerabilities found" class="h-48 justify-center" />
273
+ </template>
274
+ </div>
275
+ </n-spin>
276
+ </div>
277
+</template>
278
+
279
+<script setup lang="ts">
280
+import type { Agent } from "@/types/agents.d"
281
+import type { Customer } from "@/types/customers.d"
282
+import type { VulnerabilitySearchItem, VulnerabilitySearchQuery } from "@/types/vulnerabilities.d"
283
+import { watchDebounced } from "@vueuse/core"
284
+import axios from "axios"
285
+import { NButton, NEmpty, NInput, NPagination, NPopover, NSelect, NSpin, useMessage } from "naive-ui"
286
+import { computed, onMounted, ref } from "vue"
287
+import Api from "@/api"
288
+import Icon from "@/components/common/Icon.vue"
289
+import { VulnerabilitySeverity } from "@/types/vulnerabilities.d"
290
+import VulnerabilityCard from "./VulnerabilityCard.vue"
291
+
292
+const loading = ref(false)
293
+const message = useMessage()
294
+const list = ref<VulnerabilitySearchItem[]>([])
295
+const header = ref()
296
+const totalCount = ref(0)
297
+const currentPage = ref(1)
298
+const pageSize = ref(50)
299
+const totalPages = ref(0)
300
+const selectedCustomer = ref<string | null>(null)
301
+const selectedSeverity = ref<VulnerabilitySeverity | null>(null)
302
+const searchCVE = ref<string>("")
303
+const searchAgent = ref<string>("")
304
+const searchPackage = ref<string>("")
305
+
306
+// Severity counts from API response
307
+const criticalCount = ref(0)
308
+const highCount = ref(0)
309
+const mediumCount = ref(0)
310
+const lowCount = ref(0)
311
+
312
+// Agents data for dropdown
313
+const agents = ref<Agent[]>([])
314
+const loadingAgents = ref(false)
315
+
316
+// Customers data for dropdown
317
+const customers = ref<Customer[]>([])
318
+const loadingCustomers = ref(false)
319
+
320
+const InfoIcon = "carbon:information"
321
+const SearchIcon = "carbon:search"
322
+const PackageIcon = "carbon:package"
323
+const TotalIcon = "carbon:result"
324
+const CriticalIcon = "carbon:warning-filled"
325
+const HighIcon = "carbon:warning"
326
+const MediumIcon = "carbon:warning-alt"
327
+const LowIcon = "carbon:information"
328
+
329
+const severityOptions = Object.values(VulnerabilitySeverity).map(severity => ({
330
+ label: severity,
331
+ value: severity
332
+}))
333
+
334
+// Agent options for dropdown
335
+const agentOptions = computed(() => {
336
+ return (agents.value || []).map(agent => ({
337
+ label: agent.hostname,
338
+ value: agent.hostname
339
+ }))
340
+})
341
+
342
+// Calculate statistics from current data
343
+const stats = computed(() => {
344
+ // Use API response counts for global statistics across all pages
345
+ const critical = criticalCount.value
346
+ const high = highCount.value
347
+ const medium = mediumCount.value
348
+ const low = lowCount.value
349
+
350
+ // Calculate unique values from current page data for context
351
+ const uniqueAgents = new Set(list.value.map(v => v.agent_name)).size
352
+ const uniquePackages = new Set(list.value.map(v => v.package_name).filter(Boolean)).size
353
+ const uniqueCustomers = new Set(list.value.map(v => v.customer_code).filter(Boolean)).size
354
+
355
+ return {
356
+ critical,
357
+ high,
358
+ medium,
359
+ low,
360
+ uniqueAgents,
361
+ uniquePackages,
362
+ uniqueCustomers
363
+ }
364
+})
365
+
366
+// Calculate top packages by EPSS score
367
+const topEpssPackages = computed(() => {
368
+ // Group vulnerabilities by package name
369
+ const packageMap = new Map<string, {
370
+ package_name: string
371
+ vulnCount: number
372
+ maxEpssScore: number
373
+ maxCvssScore: number | null
374
+ affectedAgents: Set<string>
375
+ criticalCount: number
376
+ highCount: number
377
+ }>()
378
+
379
+ list.value.forEach(vuln => {
380
+ if (!vuln.package_name || !vuln.epss_score) return
381
+
382
+ const epssScore = Number.parseFloat(vuln.epss_score)
383
+ if (Number.isNaN(epssScore)) return
384
+
385
+ const key = vuln.package_name
386
+ const existing = packageMap.get(key)
387
+
388
+ if (existing) {
389
+ existing.vulnCount++
390
+ existing.maxEpssScore = Math.max(existing.maxEpssScore, epssScore)
391
+ if (vuln.base_score) {
392
+ existing.maxCvssScore = Math.max(existing.maxCvssScore || 0, vuln.base_score)
393
+ }
394
+ existing.affectedAgents.add(vuln.agent_name)
395
+
396
+ if (vuln.severity === VulnerabilitySeverity.Critical) existing.criticalCount++
397
+ if (vuln.severity === VulnerabilitySeverity.High) existing.highCount++
398
+ } else {
399
+ packageMap.set(key, {
400
+ package_name: vuln.package_name,
401
+ vulnCount: 1,
402
+ maxEpssScore: epssScore,
403
+ maxCvssScore: vuln.base_score || null,
404
+ affectedAgents: new Set([vuln.agent_name]),
405
+ criticalCount: vuln.severity === VulnerabilitySeverity.Critical ? 1 : 0,
406
+ highCount: vuln.severity === VulnerabilitySeverity.High ? 1 : 0
407
+ })
408
+ }
409
+ })
410
+
411
+ // Convert to array and sort by max EPSS score
412
+ return Array.from(packageMap.values())
413
+ .map(pkg => ({
414
+ ...pkg,
415
+ affectedAgents: pkg.affectedAgents.size
416
+ }))
417
+ .sort((a, b) => b.maxEpssScore - a.maxEpssScore)
418
+ .slice(0, 5)
419
+})
420
+
421
+function getPercentage(count: number): string {
422
+ if (totalCount.value === 0) return "0"
423
+ return ((count / totalCount.value) * 100).toFixed(1)
424
+}
425
+
426
+// Customer options for dropdown
427
+const customerOptions = computed(() => {
428
+ return (customers.value || []).map(customer => ({
429
+ label: customer.customer_code,
430
+ value: customer.customer_code
431
+ }))
432
+})
433
+
434
+let abortController: AbortController | null = null
435
+
436
+function getList() {
437
+ abortController?.abort()
438
+ abortController = new AbortController()
439
+
440
+ loading.value = true
441
+
442
+ const query: VulnerabilitySearchQuery = {
443
+ page: currentPage.value,
444
+ page_size: pageSize.value,
445
+ customer_code: selectedCustomer.value || undefined,
446
+ severity: selectedSeverity.value || undefined,
447
+ cve_id: searchCVE.value || undefined,
448
+ agent_name: searchAgent.value || undefined,
449
+ package_name: searchPackage.value || undefined,
450
+ include_epss: true
451
+ }
452
+
453
+ Api.vulnerabilities
454
+ .searchVulnerabilities(query, abortController.signal)
455
+ .then(res => {
456
+ loading.value = false
457
+
458
+ if (res.data.success) {
459
+ list.value = res.data?.vulnerabilities || []
460
+ totalCount.value = res.data?.total_count || 0
461
+ totalPages.value = res.data?.total_pages || 0
462
+ currentPage.value = res.data?.page || 1
463
+
464
+ // Store severity counts from API response
465
+ criticalCount.value = res.data?.critical_count || 0
466
+ highCount.value = res.data?.high_count || 0
467
+ mediumCount.value = res.data?.medium_count || 0
468
+ lowCount.value = res.data?.low_count || 0
469
+ } else {
470
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
471
+ }
472
+ })
473
+ .catch(err => {
474
+ if (!axios.isCancel(err)) {
475
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
476
+ loading.value = false
477
+ }
478
+ })
479
+}
480
+
481
+function getAgents() {
482
+ loadingAgents.value = true
483
+
484
+ Api.agents
485
+ .getAgents()
486
+ .then(res => {
487
+ if (res.data.success) {
488
+ agents.value = res.data.agents || []
489
+ } else {
490
+ message.warning(res.data?.message || "Failed to load agents.")
491
+ }
492
+ })
493
+ .catch(err => {
494
+ message.error(err.response?.data?.message || "Failed to load agents.")
495
+ })
496
+ .finally(() => {
497
+ loadingAgents.value = false
498
+ })
499
+}
500
+
501
+function getCustomers() {
502
+ loadingCustomers.value = true
503
+
504
+ Api.customers
505
+ .getCustomers()
506
+ .then(res => {
507
+ if (res.data.success) {
508
+ customers.value = res.data.customers || []
509
+ } else {
510
+ message.warning(res.data?.message || "Failed to load customers.")
511
+ }
512
+ })
513
+ .catch(err => {
514
+ message.error(err.response?.data?.message || "Failed to load customers.")
515
+ })
516
+ .finally(() => {
517
+ loadingCustomers.value = false
518
+ })
519
+}
520
+
521
+function updatePage(page: number) {
522
+ currentPage.value = page
523
+ getList()
524
+}
525
+
526
+function updatePageSize(size: number) {
527
+ pageSize.value = size
528
+ currentPage.value = 1
529
+ getList()
530
+}
531
+
532
+function selectPackage(packageName: string) {
533
+ // If the same package is already selected, clear the filter
534
+ if (searchPackage.value === packageName) {
535
+ searchPackage.value = ""
536
+ } else {
537
+ // Set the package name in the search filter
538
+ searchPackage.value = packageName
539
+ }
540
+ // Reset to first page when filtering
541
+ currentPage.value = 1
542
+}
543
+
544
+function selectSeverity(severity: VulnerabilitySeverity) {
545
+ // If the same severity is already selected, clear the filter
546
+ if (selectedSeverity.value === severity) {
547
+ selectedSeverity.value = null
548
+ } else {
549
+ // Set the severity in the search filter
550
+ selectedSeverity.value = severity
551
+ }
552
+ // Reset to first page when filtering
553
+ currentPage.value = 1
554
+}
555
+
556
+// Load agents and customers when component mounts
557
+onMounted(() => {
558
+ getAgents()
559
+ getCustomers()
560
+})
561
+
562
+watchDebounced([selectedCustomer, selectedSeverity, searchCVE, searchAgent, searchPackage], () => {
563
+ currentPage.value = 1
564
+ getList()
565
+}, {
566
+ deep: true,
567
+ debounce: 300,
568
+ immediate: true
569
+})
570
+</script>
571
+
572
+<style scoped>
573
+.stat-card {
574
+ background-color: white;
575
+ border-radius: 0.5rem;
576
+ padding: 1rem;
577
+ border: 1px solid rgb(229 231 235);
578
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
579
+ transition: all 0.2s ease;
580
+}
581
+
582
+.stat-card.clickable {
583
+ cursor: pointer;
584
+}
585
+
586
+.stat-card:hover {
587
+ transform: translateY(-2px);
588
+ box-shadow: 0 4px 8px 0 rgb(0 0 0 / 0.1);
589
+}
590
+
591
+.stat-card.selected {
592
+ border-width: 2px;
593
+ box-shadow: 0 4px 12px 0 rgb(59 130 246 / 0.3);
594
+}
595
+
596
+.stat-card.critical {
597
+ border-color: rgb(254 202 202);
598
+ background-color: rgb(254 242 242);
599
+}
600
+
601
+.stat-card.critical.selected {
602
+ border-color: rgb(220 38 38);
603
+ background-color: rgb(254 226 226);
604
+}
605
+
606
+.stat-card.high {
607
+ border-color: rgb(254 215 170);
608
+ background-color: rgb(255 247 237);
609
+}
610
+
611
+.stat-card.high.selected {
612
+ border-color: rgb(234 88 12);
613
+ background-color: rgb(255 237 213);
614
+}
615
+
616
+.stat-card.medium {
617
+ border-color: rgb(254 240 138);
618
+ background-color: rgb(254 252 232);
619
+}
620
+
621
+.stat-card.medium.selected {
622
+ border-color: rgb(202 138 4);
623
+ background-color: rgb(254 249 195);
624
+}
625
+
626
+.stat-card.low {
627
+ border-color: rgb(191 219 254);
628
+ background-color: rgb(239 246 255);
629
+}
630
+
631
+.stat-card.low.selected {
632
+ border-color: rgb(59 130 246);
633
+ background-color: rgb(219 234 254);
634
+}
635
+
636
+.stat-header {
637
+ display: flex;
638
+ align-items: center;
639
+ gap: 0.5rem;
640
+ margin-bottom: 0.5rem;
641
+}
642
+
643
+.stat-title {
644
+ font-size: 0.875rem;
645
+ font-weight: 500;
646
+ color: rgb(75 85 99);
647
+}
648
+
649
+.stat-value {
650
+ font-size: 1.5rem;
651
+ font-weight: 700;
652
+ color: rgb(17 24 39);
653
+}
654
+
655
+.stat-percentage {
656
+ font-size: 0.75rem;
657
+ color: rgb(107 114 128);
658
+ margin-top: 0.25rem;
659
+}
660
+
661
+.quick-stat {
662
+ display: flex;
663
+ align-items: center;
664
+ gap: 0.5rem;
665
+ padding: 0.75rem;
666
+ background-color: rgb(249 250 251);
667
+ border-radius: 0.5rem;
668
+}
669
+
670
+/* EPSS Package Cards */
671
+.epss-package-card {
672
+ background-color: white;
673
+ border: 1px solid rgb(229 231 235);
674
+ border-radius: 0.5rem;
675
+ padding: 1rem;
676
+ transition: all 0.2s ease;
677
+}
678
+
679
+/* Light mode specific styles */
680
+:root .epss-package-card,
681
+html:not(.dark) .epss-package-card,
682
+[data-theme="light"] .epss-package-card {
683
+ background-color: white;
684
+ border-color: rgb(229 231 235);
685
+}
686
+
687
+.epss-package-card.clickable {
688
+ cursor: pointer;
689
+}
690
+
691
+.epss-package-card:hover {
692
+ border-color: rgb(156 163 175);
693
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
694
+ transform: translateY(-2px);
695
+}
696
+
697
+.epss-package-card.selected {
698
+ border-color: rgb(59 130 246);
699
+ background-color: rgb(239 246 255);
700
+ box-shadow: 0 4px 12px -1px rgb(59 130 246 / 0.2);
701
+}
702
+
703
+.epss-package-card.rank-1 {
704
+ border-color: rgb(234 179 8);
705
+ background-color: rgb(255 255 255);
706
+}
707
+
708
+.epss-package-card.rank-2 {
709
+ border-color: rgb(156 163 175);
710
+ background-color: rgb(255 255 255);
711
+}
712
+
713
+.epss-package-card.rank-3 {
714
+ border-color: rgb(217 119 6);
715
+ background-color: rgb(255 255 255);
716
+}
717
+
718
+/* Light mode ranked cards */
719
+:root .epss-package-card.rank-1,
720
+html:not(.dark) .epss-package-card.rank-1,
721
+[data-theme="light"] .epss-package-card.rank-1 {
722
+ border-color: rgb(234 179 8);
723
+ background-color: white;
724
+}
725
+
726
+:root .epss-package-card.rank-2,
727
+html:not(.dark) .epss-package-card.rank-2,
728
+[data-theme="light"] .epss-package-card.rank-2 {
729
+ border-color: rgb(156 163 175);
730
+ background-color: white;
731
+}
732
+
733
+:root .epss-package-card.rank-3,
734
+html:not(.dark) .epss-package-card.rank-3,
735
+[data-theme="light"] .epss-package-card.rank-3 {
736
+ border-color: rgb(217 119 6);
737
+ background-color: white;
738
+}
739
+
740
+.epss-header {
741
+ display: flex;
742
+ justify-content: space-between;
743
+ align-items: center;
744
+ margin-bottom: 0.75rem;
745
+}
746
+
747
+.epss-rank {
748
+ display: flex;
749
+ align-items: center;
750
+ gap: 0.5rem;
751
+}
752
+
753
+.rank-number {
754
+ font-weight: 600;
755
+ font-size: 0.875rem;
756
+ color: rgb(75 85 99);
757
+}
758
+
759
+.package-name {
760
+ font-weight: 600;
761
+ font-size: 1rem;
762
+ color: rgb(17 24 39);
763
+ margin-bottom: 0.75rem;
764
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
765
+}
766
+
767
+.package-stats {
768
+ display: flex;
769
+ flex-direction: column;
770
+ gap: 0.5rem;
771
+ margin-bottom: 0.75rem;
772
+}
773
+
774
+.stat-row {
775
+ display: flex;
776
+ justify-content: space-between;
777
+ align-items: center;
778
+}
779
+
780
+.stat-label {
781
+ font-size: 0.75rem;
782
+ color: rgb(107 114 128);
783
+ font-weight: 500;
784
+}
785
+
786
+.stat-value {
787
+ font-size: 1rem;
788
+ font-weight: 800;
789
+ color: rgb(255 255 255);
790
+ background-color: rgb(59 130 246);
791
+ padding: 0.375rem 0.75rem;
792
+ border-radius: 0.5rem;
793
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
794
+ text-align: center;
795
+ min-width: 3rem;
796
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
797
+}
798
+
799
+.severity-indicator {
800
+ display: flex;
801
+ gap: 0.5rem;
802
+ flex-wrap: wrap;
803
+}
804
+
805
+/* Dark mode styles */
806
+html.dark .stat-card {
807
+ background-color: rgb(31 41 55);
808
+ border-color: rgb(75 85 99);
809
+}
810
+
811
+html.dark .stat-card.critical {
812
+ border-color: rgb(220 38 38);
813
+ background-color: rgb(127 29 29);
814
+}
815
+
816
+html.dark .stat-card.high {
817
+ border-color: rgb(234 88 12);
818
+ background-color: rgb(154 52 18);
819
+}
820
+
821
+html.dark .stat-card.medium {
822
+ border-color: rgb(202 138 4);
823
+ background-color: rgb(161 98 7);
824
+}
825
+
826
+html.dark .stat-card.low {
827
+ border-color: rgb(59 130 246);
828
+ background-color: rgb(30 64 175);
829
+}
830
+
831
+html.dark .stat-title {
832
+ color: rgb(209 213 219);
833
+}
834
+
835
+html.dark .stat-value {
836
+ color: rgb(255 255 255);
837
+}
838
+
839
+html.dark .stat-percentage {
840
+ color: rgb(209 213 219);
841
+}
842
+
843
+html.dark .quick-stat {
844
+ background-color: rgb(31 41 55);
845
+ color: rgb(243 244 246);
846
+}
847
+
848
+/* Dark mode for EPSS Package Cards */
849
+html.dark .epss-package-card {
850
+ background-color: rgb(31 41 55) !important;
851
+ border-color: rgb(75 85 99);
852
+}
853
+
854
+html.dark .epss-package-card:hover {
855
+ border-color: rgb(156 163 175);
856
+}
857
+
858
+html.dark .epss-package-card.rank-1 {
859
+ border-color: rgb(234 179 8);
860
+ background-color: rgb(31 41 55) !important;
861
+}
862
+
863
+html.dark .epss-package-card.rank-2 {
864
+ border-color: rgb(156 163 175);
865
+ background-color: rgb(31 41 55) !important;
866
+}
867
+
868
+html.dark .epss-package-card.rank-3 {
869
+ border-color: rgb(217 119 6);
870
+ background-color: rgb(31 41 55) !important;
871
+}
872
+
873
+html.dark .rank-number {
874
+ color: rgb(209 213 219);
875
+}
876
+
877
+html.dark .package-name {
878
+ color: rgb(243 244 246);
879
+}
880
+
881
+html.dark .stat-label {
882
+ color: rgb(156 163 175);
883
+}
884
+
885
+html.dark .stat-value {
886
+ color: rgb(255 255 255);
887
+ background-color: rgb(79 70 229);
888
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
889
+}
890
+
891
+/* Alternative dark mode selectors for better compatibility */
892
+.dark .stat-card,
893
+[data-theme="dark"] .stat-card {
894
+ background-color: rgb(31 41 55);
895
+ border-color: rgb(75 85 99);
896
+}
897
+
898
+.dark .stat-card.critical,
899
+[data-theme="dark"] .stat-card.critical {
900
+ border-color: rgb(220 38 38);
901
+ background-color: rgb(127 29 29);
902
+}
903
+
904
+.dark .stat-card.critical.selected,
905
+[data-theme="dark"] .stat-card.critical.selected {
906
+ border-color: rgb(248 113 113);
907
+ background-color: rgb(153 27 27);
908
+ box-shadow: 0 4px 12px 0 rgb(248 113 113 / 0.3);
909
+}
910
+
911
+.dark .stat-card.high,
912
+[data-theme="dark"] .stat-card.high {
913
+ border-color: rgb(234 88 12);
914
+ background-color: rgb(154 52 18);
915
+}
916
+
917
+.dark .stat-card.high.selected,
918
+[data-theme="dark"] .stat-card.high.selected {
919
+ border-color: rgb(251 146 60);
920
+ background-color: rgb(194 65 14);
921
+ box-shadow: 0 4px 12px 0 rgb(251 146 60 / 0.3);
922
+}
923
+
924
+.dark .stat-card.medium,
925
+[data-theme="dark"] .stat-card.medium {
926
+ border-color: rgb(202 138 4);
927
+ background-color: rgb(161 98 7);
928
+}
929
+
930
+.dark .stat-card.medium.selected,
931
+[data-theme="dark"] .stat-card.medium.selected {
932
+ border-color: rgb(250 204 21);
933
+ background-color: rgb(180 83 9);
934
+ box-shadow: 0 4px 12px 0 rgb(250 204 21 / 0.3);
935
+}
936
+
937
+.dark .stat-card.low,
938
+[data-theme="dark"] .stat-card.low {
939
+ border-color: rgb(59 130 246);
940
+ background-color: rgb(30 64 175);
941
+}
942
+
943
+.dark .stat-card.low.selected,
944
+[data-theme="dark"] .stat-card.low.selected {
945
+ border-color: rgb(96 165 250);
946
+ background-color: rgb(37 99 235);
947
+ box-shadow: 0 4px 12px 0 rgb(96 165 250 / 0.3);
948
+}
949
+
950
+.dark .stat-title,
951
+[data-theme="dark"] .stat-title {
952
+ color: rgb(209 213 219);
953
+}
954
+
955
+.dark .stat-value,
956
+[data-theme="dark"] .stat-value {
957
+ color: rgb(255 255 255);
958
+}
959
+
960
+.dark .stat-percentage,
961
+[data-theme="dark"] .stat-percentage {
962
+ color: rgb(209 213 219);
963
+}
964
+
965
+.dark .quick-stat,
966
+[data-theme="dark"] .quick-stat {
967
+ background-color: rgb(31 41 55);
968
+ color: rgb(243 244 246);
969
+}
970
+
971
+/* EPSS Package Cards - Alternative dark mode selectors */
972
+.dark .epss-package-card,
973
+[data-theme="dark"] .epss-package-card {
974
+ background-color: rgb(31 41 55) !important;
975
+ border-color: rgb(75 85 99);
976
+}
977
+
978
+.dark .epss-package-card.rank-1,
979
+[data-theme="dark"] .epss-package-card.rank-1 {
980
+ border-color: rgb(234 179 8);
981
+ background-color: rgb(31 41 55) !important;
982
+}
983
+
984
+.dark .epss-package-card.rank-2,
985
+[data-theme="dark"] .epss-package-card.rank-2 {
986
+ border-color: rgb(156 163 175);
987
+ background-color: rgb(31 41 55) !important;
988
+}
989
+
990
+.dark .epss-package-card.rank-3,
991
+[data-theme="dark"] .epss-package-card.rank-3 {
992
+ border-color: rgb(217 119 6);
993
+ background-color: rgb(31 41 55) !important;
994
+}
995
+
996
+.dark .epss-package-card:hover,
997
+[data-theme="dark"] .epss-package-card:hover {
998
+ border-color: rgb(156 163 175);
999
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3);
1000
+}
1001
+
1002
+.dark .epss-package-card.selected,
1003
+[data-theme="dark"] .epss-package-card.selected {
1004
+ border-color: rgb(96 165 250);
1005
+ background-color: rgb(30 58 138);
1006
+ box-shadow: 0 4px 12px -1px rgb(96 165 250 / 0.3);
1007
+}
1008
+
1009
+.dark .package-name,
1010
+[data-theme="dark"] .package-name {
1011
+ color: rgb(243 244 246);
1012
+}
1013
+
1014
+.dark .stat-label,
1015
+[data-theme="dark"] .stat-label {
1016
+ color: rgb(156 163 175);
1017
+}
1018
+
1019
+.dark .stat-value,
1020
+[data-theme="dark"] .stat-value {
1021
+ color: rgb(255 255 255);
1022
+ background-color: rgb(79 70 229);
1023
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
1024
+}
1025
+
1026
+/* Media query for system dark mode preference */
1027
+@media (prefers-color-scheme: dark) {
1028
+ .stat-card {
1029
+ background-color: rgb(31 41 55);
1030
+ border-color: rgb(75 85 99);
1031
+ }
1032
+
1033
+ .stat-card.critical {
1034
+ border-color: rgb(220 38 38);
1035
+ background-color: rgb(127 29 29);
1036
+ }
1037
+
1038
+ .stat-card.high {
1039
+ border-color: rgb(234 88 12);
1040
+ background-color: rgb(154 52 18);
1041
+ }
1042
+
1043
+ .stat-card.medium {
1044
+ border-color: rgb(202 138 4);
1045
+ background-color: rgb(161 98 7);
1046
+ }
1047
+
1048
+ .stat-card.low {
1049
+ border-color: rgb(59 130 246);
1050
+ background-color: rgb(30 64 175);
1051
+ }
1052
+
1053
+ .stat-title {
1054
+ color: rgb(209 213 219);
1055
+ }
1056
+
1057
+ .stat-value {
1058
+ color: rgb(255 255 255);
1059
+ background-color: rgb(79 70 229);
1060
+ box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
1061
+ }
1062
+
1063
+ .stat-percentage {
1064
+ color: rgb(209 213 219);
1065
+ }
1066
+
1067
+ .quick-stat {
1068
+ background-color: rgb(31 41 55);
1069
+ color: rgb(243 244 246);
1070
+ }
1071
+
1072
+ .epss-package-card {
1073
+ background-color: rgb(31 41 55) !important;
1074
+ border-color: rgb(75 85 99);
1075
+ }
1076
+
1077
+ .epss-package-card:hover {
1078
+ border-color: rgb(156 163 175);
1079
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3);
1080
+ }
1081
+
1082
+ .epss-package-card.selected {
1083
+ border-color: rgb(96 165 250);
1084
+ background-color: rgb(30 58 138);
1085
+ box-shadow: 0 4px 12px -1px rgb(96 165 250 / 0.3);
1086
+ }
1087
+
1088
+ .package-name {
1089
+ color: rgb(243 244 246);
1090
+ }
1091
+
1092
+ .stat-label {
1093
+ color: rgb(156 163 175);
1094
+ }
1095
+}
1096
+</style>