main
vue 245 lines 5.27 KB
Raw
1 <template>
2 <n-card
3 size="small"
4 :bordered="false"
5 hoverable
6 class="patch-tuesday-card"
7 :class="[`priority-${item.prioritization.priority.toLowerCase()}`]"
8 >
9 <!-- Header -->
10 <div class="card-header">
11 <div class="cve-info">
12 <span class="cve-id">{{ item.cve }}</span>
13 <PatchTuesdayPriorityBadge :priority="item.prioritization.priority" />
14 </div>
15 <div class="badges">
16 <n-tag v-if="item.kev.in_kev" type="error" size="small" round>
17 <template #icon>
18 <Icon :name="AlertIcon" />
19 </template>
20 KEV
21 </n-tag>
22 <n-tag v-if="item.severity" :type="getSeverityType(item.severity)" size="small">
23 {{ item.severity }}
24 </n-tag>
25 </div>
26 </div>
27
28 <!-- Title -->
29 <p class="card-title">{{ item.title || "No title available" }}</p>
30
31 <!-- Product Info -->
32 <div class="product-info">
33 <n-tag size="small" :bordered="false">
34 {{ item.affected.family }}
35 </n-tag>
36 <span class="product-name">{{ truncateProduct(item.affected.product) }}</span>
37 </div>
38
39 <!-- Scores Row -->
40 <div class="scores-row">
41 <div v-if="item.cvss.base !== null" class="score-item">
42 <span class="score-label">CVSS</span>
43 <span class="score-value" :class="getCvssClass(item.cvss.base)">
44 {{ item.cvss.base.toFixed(1) }}
45 </span>
46 </div>
47 <div v-if="item.epss.score !== null" class="score-item">
48 <span class="score-label">EPSS</span>
49 <span class="score-value">{{ (item.epss.score * 100).toFixed(1) }}%</span>
50 </div>
51 <div v-if="item.epss.percentile !== null" class="score-item">
52 <span class="score-label">Percentile</span>
53 <span class="score-value">{{ (item.epss.percentile * 100).toFixed(0) }}%</span>
54 </div>
55 </div>
56
57 <!-- KB Articles -->
58 <div v-if="item.remediation.kbs.length > 0" class="kb-row">
59 <Icon :name="LinkIcon" :size="14" />
60 <span class="kb-list">{{ item.remediation.kbs.slice(0, 3).join(", ") }}</span>
61 <span v-if="item.remediation.kbs.length > 3" class="kb-more">
62 +{{ item.remediation.kbs.length - 3 }} more
63 </span>
64 </div>
65
66 <!-- SLA Hint -->
67 <div class="sla-hint">
68 <Icon :name="ClockIcon" :size="14" />
69 <span>{{ getSlaHint(item.prioritization.suggested_sla) }}</span>
70 </div>
71 </n-card>
72 </template>
73
74 <script setup lang="ts">
75 // TODO-FE: refactor
76 import type { PatchTuesdayItem } from "@/types/patchTuesday.d"
77 import { NCard, NTag } from "naive-ui"
78 import Icon from "@/components/common/Icon.vue"
79 import PatchTuesdayPriorityBadge from "./PatchTuesdayPriorityBadge.vue"
80
81 defineProps<{
82 item: PatchTuesdayItem
83 }>()
84 const AlertIcon = "carbon:warning"
85 const ClockIcon = "carbon:time"
86 const LinkIcon = "carbon:link"
87
88 function getSeverityType(severity: string): "error" | "warning" | "info" | "default" {
89 const s = severity.toLowerCase()
90 if (s === "critical") return "error"
91 if (s === "important") return "warning"
92 if (s === "moderate") return "info"
93 return "default"
94 }
95
96 function getCvssClass(score: number): string {
97 if (score >= 9.0) return "critical"
98 if (score >= 7.0) return "high"
99 if (score >= 4.0) return "medium"
100 return "low"
101 }
102
103 function truncateProduct(product: string): string {
104 if (product.length <= 40) return product
105 return `${product.substring(0, 40)}...`
106 }
107
108 function getSlaHint(sla: string): string {
109 if (sla.includes("immediately") || sla.includes("24h")) return "Patch immediately"
110 if (sla.includes("72h")) return "Patch within 72 hours"
111 if (sla.includes("7-14")) return "Patch within 7-14 days"
112 return "Patch within 30 days"
113 }
114 </script>
115
116 <style scoped lang="scss">
117 .patch-tuesday-card {
118 border-radius: 8px;
119 cursor: pointer;
120 transition: all 0.2s ease;
121 border-left: 4px solid transparent;
122 background: var(--bg-secondary-color);
123
124 &.priority-p0 {
125 border-left-color: #ef4444;
126 }
127
128 &.priority-p1 {
129 border-left-color: #f97316;
130 }
131
132 &.priority-p2 {
133 border-left-color: #eab308;
134 }
135
136 &.priority-p3 {
137 border-left-color: #22c55e;
138 }
139
140 .card-header {
141 display: flex;
142 justify-content: space-between;
143 align-items: flex-start;
144 margin-bottom: 8px;
145
146 .cve-info {
147 display: flex;
148 align-items: center;
149 gap: 8px;
150
151 .cve-id {
152 font-weight: 600;
153 font-size: 0.95rem;
154 font-family: monospace;
155 }
156 }
157
158 .badges {
159 display: flex;
160 gap: 4px;
161 }
162 }
163
164 .card-title {
165 font-size: 0.875rem;
166 line-height: 1.4;
167 margin-bottom: 12px;
168 opacity: 0.9;
169 display: -webkit-box;
170 -webkit-line-clamp: 2;
171 -webkit-box-orient: vertical;
172 overflow: hidden;
173 }
174
175 .product-info {
176 display: flex;
177 align-items: center;
178 gap: 8px;
179 margin-bottom: 12px;
180
181 .product-name {
182 font-size: 0.8rem;
183 opacity: 0.7;
184 }
185 }
186
187 .scores-row {
188 display: flex;
189 gap: 16px;
190 margin-bottom: 12px;
191
192 .score-item {
193 display: flex;
194 flex-direction: column;
195
196 .score-label {
197 font-size: 0.7rem;
198 text-transform: uppercase;
199 opacity: 0.6;
200 }
201
202 .score-value {
203 font-weight: 600;
204 font-size: 0.9rem;
205
206 &.critical {
207 color: #ef4444;
208 }
209 &.high {
210 color: #f97316;
211 }
212 &.medium {
213 color: #eab308;
214 }
215 &.low {
216 color: #22c55e;
217 }
218 }
219 }
220 }
221
222 .kb-row {
223 display: flex;
224 align-items: center;
225 gap: 6px;
226 font-size: 0.8rem;
227 opacity: 0.7;
228 margin-bottom: 8px;
229
230 .kb-more {
231 opacity: 0.6;
232 }
233 }
234
235 .sla-hint {
236 display: flex;
237 align-items: center;
238 gap: 6px;
239 font-size: 0.75rem;
240 opacity: 0.6;
241 padding-top: 8px;
242 border-top: 1px solid var(--border-color);
243 }
244 }
245 </style>