main
vue 418 lines 11.7 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 <div class="grid-auto-fit-250 grid gap-4">
18 <n-card
19 v-for="item of statisticsCards"
20 :key="item.level"
21 class="ring-primary cursor-pointer transition-all duration-300 hover:ring-1"
22 content-class="flex flex-col gap-2"
23 size="small"
24 @click="selectComplianceLevel(item.level)"
25 >
26 <div class="flex items-center justify-between gap-2 whitespace-nowrap">
27 <div class="flex items-center gap-2">
28 <ScaLevelIcon :level="item.level" :size="24" :class="item.iconClass" />
29 <span class="text-xl">{{ item.label }}</span>
30 </div>
31 <div class="font-mono text-xl font-bold">{{ stats[item.key].toLocaleString() }}</div>
32 </div>
33 <n-progress
34 type="line"
35 indicator-placement="inside"
36 :percentage="getPercentage(stats[item.key])"
37 :color="item.barColor"
38 class="custom-progress"
39 />
40 </n-card>
41 </div>
42 </div>
43
44 <!-- Coverage Cards -->
45 <div class="mt-8">
46 <div class="mb-5 flex flex-wrap items-center justify-between gap-3">
47 <h3 class="flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
48 <Icon name="carbon:double-axis-chart-column" :size="20" class="text-primary" />
49 Coverage
50 </h3>
51
52 <p class="flex items-center gap-2 text-sm">
53 Coverage based on the 100 policies with the lowest score.
54 <Icon :name="InfoIcon" :size="14" />
55 </p>
56 </div>
57
58 <n-card class="bg-secondary! overflow-hidden">
59 <div class="flex flex-wrap justify-between gap-8">
60 <n-statistic
61 label="Agents"
62 :value="overviewData?.total_agents?.toLocaleString() || 0"
63 tabular-nums
64 />
65 <n-statistic
66 label="Policies"
67 :value="overviewData?.total_policies?.toLocaleString() || 0"
68 tabular-nums
69 />
70 <n-statistic label="Avg Score" :value="overviewData?.average_score?.toFixed(1) || 0" tabular-nums />
71 </div>
72 </n-card>
73 </div>
74
75 <!-- Top 5 Policies by Compliance Score -->
76 <div v-if="topPoliciesByScore.length > 0" class="mt-8">
77 <div class="mb-5 flex flex-wrap items-center justify-between gap-3">
78 <h3 class="flex items-center gap-2 text-lg font-semibold">
79 <Icon :name="TopPoliciesIcon" :size="20" class="text-primary" />
80 Top 5 Policies by Compliance Score
81 </h3>
82
83 <p class="flex items-center gap-2 text-sm">
84 Ranking based on the 100 policies with the lowest score.
85 <Icon :name="InfoIcon" :size="14" />
86 </p>
87 </div>
88 <div class="grid-auto-fit-250 grid gap-10">
89 <div
90 v-for="(policy, index) in topPoliciesByScore"
91 :key="policy.policy_id"
92 class="@container flex items-stretch gap-2"
93 >
94 <div class="flex items-end">
95 <div
96 class="w-4 rounded-t-2xl rounded-b-sm"
97 :class="{
98 'h-full bg-yellow-500': index === 0,
99 'h-8/12 bg-gray-400': index === 1,
100 'h-4/12 bg-amber-600': index === 2
101 }"
102 ></div>
103 </div>
104
105 <div
106 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"
107 @click="selectPolicyID(policy.policy_id)"
108 >
109 <div class="flex items-center justify-between gap-4">
110 <div class="flex items-center gap-2 text-xl">
111 <Icon
112 :name="index < 3 ? 'carbon:trophy' : 'carbon:warning-alt'"
113 :size="20"
114 :class="
115 index === 0
116 ? 'text-yellow-500'
117 : index === 1
118 ? 'text-gray-400'
119 : index === 2
120 ? 'text-amber-600'
121 : 'text-info'
122 "
123 />
124 <span>#{{ index + 1 }}</span>
125 </div>
126 <ScaLevelBadge :score="policy.score" class="mt-1" />
127 </div>
128
129 <div class="text-lg leading-snug font-semibold">{{ policy.policy_name }}</div>
130
131 <div class="text-secondary grid grid-cols-1 gap-1 text-xs break-all @md:grid-cols-2">
132 <div class="flex items-center gap-2">
133 <span>Policy ID:</span>
134 <span class="font-mono font-semibold">{{ policy.policy_id }}</span>
135 </div>
136 <div class="flex items-center gap-2">
137 <span>Agents:</span>
138 <span class="font-mono font-semibold">{{ policy.agentCount.toLocaleString() }}</span>
139 </div>
140 <div class="flex items-center gap-2">
141 <span>Total Checks:</span>
142 <span class="font-mono font-semibold">
143 {{ policy.total_checks.toLocaleString() }}
144 </span>
145 </div>
146 <div class="flex items-center gap-2">
147 <span class="text-success">Pass:</span>
148 <span class="font-mono font-semibold">
149 {{ policy.pass }}
150 </span>
151 </div>
152 <div class="flex items-center gap-2">
153 <span class="text-error">Fail:</span>
154 <span class="font-mono font-semibold">{{ policy.fail }}</span>
155 </div>
156 </div>
157 </div>
158 </div>
159 </div>
160 </div>
161 </n-spin>
162 </template>
163
164 <script setup lang="ts">
165 import type { ScaOverviewFilter, ScaOverviewFilterTypes } from "./types"
166 import type { AgentScaOverviewItem, ScaOverviewQuery, ScaOverviewResponse } from "@/types/sca.d"
167 import { watchDebounced } from "@vueuse/core"
168 import axios from "axios"
169 import _set from "lodash/set"
170 import _toNumber from "lodash/toNumber"
171 import { NCard, NProgress, NSpin, NStatistic, useMessage } from "naive-ui"
172 import { computed, ref, toRefs } from "vue"
173 import Api from "@/api"
174 import Icon from "@/components/common/Icon.vue"
175 import { ScaComplianceLevel } from "@/types/sca.d"
176 import ScaLevelBadge from "./ScaLevelBadge.vue"
177 import ScaLevelIcon from "./ScaLevelIcon.vue"
178 import { getComplianceLevel } from "./utils"
179
180 const props = defineProps<{ filters: ScaOverviewFilter[] }>()
181
182 const emit = defineEmits<{
183 (e: "update:min_score", value: number): void
184 (e: "update:max_score", value: number): void
185 (e: "update:policy_id", value: string): void
186 }>()
187
188 const { filters } = toRefs(props)
189
190 const loading = ref(false)
191 const message = useMessage()
192 const list = ref<AgentScaOverviewItem[]>([])
193
194 const totalCount = ref(0)
195
196 // Overview data from API response
197 const overviewData = ref<ScaOverviewResponse | null>(null)
198
199 const InfoIcon = "carbon:information"
200 const TopPoliciesIcon = "carbon:trophy"
201
202 // Define the allowed keys for statistics
203 type LevelKey = "excellent" | "good" | "average" | "poor" | "critical"
204
205 const statisticsCards: {
206 label: string
207 level: ScaComplianceLevel
208 iconClass: string
209 barColor: string
210 key: LevelKey
211 }[] = [
212 {
213 label: "Excellent",
214 level: ScaComplianceLevel.Excellent,
215 iconClass: "text-success",
216 barColor: "var(--success-color)",
217 key: "excellent"
218 },
219 {
220 label: "Good",
221 level: ScaComplianceLevel.Good,
222 iconClass: "text-info",
223 barColor: "var(--info-color)",
224 key: "good"
225 },
226 {
227 label: "Average",
228 level: ScaComplianceLevel.Average,
229 iconClass: "text-warning",
230 barColor: "var(--warning-color)",
231 key: "average"
232 },
233 {
234 label: "Poor",
235 level: ScaComplianceLevel.Poor,
236 iconClass: "text-orange-500",
237 barColor: "var(--color-orange-500)",
238 key: "poor"
239 },
240 {
241 label: "Critical",
242 level: ScaComplianceLevel.Critical,
243 iconClass: "text-error",
244 barColor: "var(--error-color)",
245 key: "critical"
246 }
247 ]
248
249 // Calculate statistics from current data
250 const stats = computed((): Record<LevelKey, number> => {
251 // Calculate compliance level distribution from current data
252 const excellent = list.value.filter(item => getComplianceLevel(item.score) === ScaComplianceLevel.Excellent).length
253 const good = list.value.filter(item => getComplianceLevel(item.score) === ScaComplianceLevel.Good).length
254 const average = list.value.filter(item => getComplianceLevel(item.score) === ScaComplianceLevel.Average).length
255 const poor = list.value.filter(item => getComplianceLevel(item.score) === ScaComplianceLevel.Poor).length
256 const critical = list.value.filter(item => getComplianceLevel(item.score) === ScaComplianceLevel.Critical).length
257
258 return {
259 excellent,
260 good,
261 average,
262 poor,
263 critical
264 }
265 })
266
267 // Calculate top policies by compliance score
268 interface PolicyAggregate {
269 policy_id: string
270 policy_name: string
271 score: number
272 agentCount: number
273 total_checks: number
274 pass: number
275 fail: number
276 }
277
278 const topPoliciesByScore = computed(() => {
279 // Group policies and calculate averages
280 const policyMap = new Map<string, PolicyAggregate>()
281
282 list.value.forEach(item => {
283 const key = item.policy_id
284 const existing = policyMap.get(key)
285
286 if (existing) {
287 // Update averages and counts
288 existing.agentCount++
289 existing.score = Math.max(existing.score, item.score) // Use highest score for ranking
290 existing.total_checks += item.total_checks
291 existing.pass += item.pass
292 existing.fail += item.fail
293 } else {
294 policyMap.set(key, {
295 policy_id: item.policy_id,
296 policy_name: item.policy_name,
297 score: item.score,
298 agentCount: 1,
299 total_checks: item.total_checks,
300 pass: item.pass,
301 fail: item.fail
302 })
303 }
304 })
305
306 // Convert to array and sort by score
307
308 return [...policyMap.values()].sort((a: PolicyAggregate, b: PolicyAggregate) => b.score - a.score).slice(0, 5)
309 })
310
311 function getPercentage(count: number): number {
312 if (totalCount.value === 0) return 0
313 return _toNumber(((count / totalCount.value) * 100).toFixed(1))
314 }
315
316 let abortController: AbortController | null = null
317
318 function getList() {
319 abortController?.abort()
320 abortController = new AbortController()
321
322 loading.value = true
323
324 const query: ScaOverviewQuery = {
325 page: 1,
326 page_size: 100
327 }
328
329 for (const key of ["customer_code", "policy_id", "policy_name", "agent_name"] as ScaOverviewFilterTypes[]) {
330 if (filters.value.find(o => o.type === key)?.value) {
331 _set(query, key, `${filters.value.find(o => o.type === key)?.value}`)
332 }
333 }
334 for (const key of ["min_score", "max_score"] as ScaOverviewFilterTypes[]) {
335 if (filters.value.find(o => o.type === key)?.value) {
336 _set(query, key, _toNumber(filters.value.find(o => o.type === key)?.value))
337 }
338 }
339
340 Api.sca
341 .searchScaOverview(query, abortController.signal)
342 .then(res => {
343 loading.value = false
344
345 if (res.data.success) {
346 list.value = res.data?.sca_results || []
347 totalCount.value = res.data?.total_count || 0
348 overviewData.value = res.data
349 } else {
350 message.warning(res.data?.message || "An error occurred. Please try again later.")
351 }
352 })
353 .catch(err => {
354 if (!axios.isCancel(err)) {
355 message.error(err.response?.data?.message || "An error occurred. Please try again later.")
356 loading.value = false
357 }
358 })
359 }
360
361 function selectPolicyID(value: string) {
362 emit("update:policy_id", value)
363 }
364
365 function selectComplianceLevel(level: ScaComplianceLevel) {
366 let min_score = 0
367 let max_score = 0
368
369 switch (level) {
370 case ScaComplianceLevel.Excellent:
371 min_score = 90
372 max_score = 100
373 break
374 case ScaComplianceLevel.Good:
375 min_score = 80
376 max_score = 89
377 break
378 case ScaComplianceLevel.Average:
379 min_score = 70
380 max_score = 79
381 break
382 case ScaComplianceLevel.Poor:
383 min_score = 60
384 max_score = 69
385 break
386 case ScaComplianceLevel.Critical:
387 min_score = 0
388 max_score = 59
389 break
390 }
391
392 emit("update:min_score", min_score)
393 emit("update:max_score", max_score)
394 }
395
396 watchDebounced(
397 filters,
398 () => {
399 getList()
400 },
401 { deep: true, debounce: 300, immediate: true }
402 )
403 </script>
404
405 <style lang="scss" scoped>
406 .custom-progress {
407 :deep() {
408 .n-progress-graph-line-indicator {
409 text-shadow:
410 0px 0px 1px black,
411 0px 0px 2px black,
412 0px 0px 3px black;
413 font-weight: bold;
414 color: white !important;
415 }
416 }
417 }
418 </style>