Sca overview frontend (#508)
* feat: add SCA card and content components with detailed compliance overview - Implemented ScaCard.vue to display SCA policy details including score, pass/fail counts, and compliance level. - Created ScaCardContent.vue for detailed view of SCA policy, including overview, description, compliance statistics, and scan information. - Defined types for SCA data in sca.d.ts, including interfaces for overview items and responses. - Added ScaOverview.vue view to integrate the new components into the agents overview page. * refactor: rename pass_count, fail_count, and invalid_count to pass, fail, and invalid in SCAs
taylor_socfortress committed
Sep 11, 2025 at 15:54 UTC
2be12aca42dddcc0b3ea500e7040ab2780059066
9 files changed
+1479
-1
frontend/src/api/endpoints/sca.ts
new
+39
@@ -0,0 +1,39 @@
1
+import type {
2
+ ScaOverviewQuery,
3
+ ScaOverviewResponse,
4
+ ScaStatsResponse
5
+} from "@/types/sca.d"
6
+import { HttpClient } from "../httpClient"
7
+
8
+export default {
9
+ /**
10
+ * Search SCA results across all agents with filtering and pagination
11
+ */
12
+ searchScaOverview(query?: ScaOverviewQuery, signal?: AbortSignal) {
13
+ return HttpClient.get<ScaOverviewResponse>(`/sca/overview`, {
14
+ params: {
15
+ customer_code: query?.customer_code,
16
+ agent_name: query?.agent_name,
17
+ policy_id: query?.policy_id,
18
+ policy_name: query?.policy_name,
19
+ min_score: query?.min_score,
20
+ max_score: query?.max_score,
21
+ page: query?.page || 1,
22
+ page_size: query?.page_size || 50
23
+ },
24
+ signal
25
+ })
26
+ },
27
+
28
+ /**
29
+ * Get SCA statistics across all agents or for a specific customer
30
+ */
31
+ getScaStats(customer_code?: string, signal?: AbortSignal) {
32
+ return HttpClient.get<ScaStatsResponse>(`/sca/stats`, {
33
+ params: {
34
+ customer_code
35
+ },
36
+ signal
37
+ })
38
+ }
39
+}
frontend/src/api/index.ts
+2
@@ -20,6 +20,7 @@ import monitoringAlerts from "./endpoints/monitoringAlerts"
20
import networkConnectors from "./endpoints/networkConnectors"
21
import portainer from "./endpoints/portainer"
22
import reporting from "./endpoints/reporting"
23
+import sca from "./endpoints/sca"
24
import scheduler from "./endpoints/scheduler"
25
import shuffle from "./endpoints/shuffle"
26
import sigma from "./endpoints/sigma"
@@ -64,6 +65,7 @@ export default {
65
users,
66
sysmonConfig,
67
vulnerabilities,
68
+ sca,
69
wazuh,
70
portainer,
71
shuffle,
frontend/src/app-layouts/common/Navbar/items.tsx
+14
-1
@@ -118,7 +118,20 @@ export default function getItems(): MenuMixedOption[] {
118
{ default: () => "Vulnerability Overview" }
119
),
120
key: "VulnerabilityOverview"
121
- }
121
+ },
122
+ {
123
+ label: () =>
124
+ h(
125
+ RouterLink,
126
+ {
127
+ to: {
128
+ name: "ScaOverview"
129
+ }
130
+ },
131
+ { default: () => "SCA Overview" }
132
+ ),
133
+ key: "ScaOverview"
134
+ }
135
]
136
},
137
{
frontend/src/components/sca/List.vue
new
+864
@@ -0,0 +1,864 @@
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
+ SCA Overview provides real-time Security Configuration Assessment results from Wazuh Manager across all agents with comprehensive compliance scoring.
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">SCA Overview</div>
29
+
30
+ <div class="grid grid-cols-2 gap-3 text-xs">
31
+ <div class="flex justify-between">
32
+ <span>Total Policies:</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">Compliance Distribution</div>
43
+ <div class="grid grid-cols-2 gap-2 text-xs">
44
+ <div class="flex justify-between">
45
+ <span class="text-green-600">Excellent:</span>
46
+ <span class="font-mono">{{ stats.excellent.toLocaleString() }} ({{ getPercentage(stats.excellent) }}%)</span>
47
+ </div>
48
+ <div class="flex justify-between">
49
+ <span class="text-blue-600">Good:</span>
50
+ <span class="font-mono">{{ stats.good.toLocaleString() }} ({{ getPercentage(stats.good) }}%)</span>
51
+ </div>
52
+ <div class="flex justify-between">
53
+ <span class="text-yellow-600">Average:</span>
54
+ <span class="font-mono">{{ stats.average.toLocaleString() }} ({{ getPercentage(stats.average) }}%)</span>
55
+ </div>
56
+ <div class="flex justify-between">
57
+ <span class="text-orange-600">Poor:</span>
58
+ <span class="font-mono">{{ stats.poor.toLocaleString() }} ({{ getPercentage(stats.poor) }}%)</span>
59
+ </div>
60
+ <div class="flex justify-between">
61
+ <span class="text-red-600">Critical:</span>
62
+ <span class="font-mono">{{ stats.critical.toLocaleString() }} ({{ getPercentage(stats.critical) }}%)</span>
63
+ </div>
64
+ </div>
65
+ </div>
66
+
67
+ <div class="border-t pt-2">
68
+ <div class="text-xs font-medium mb-2">Coverage</div>
69
+ <div class="space-y-1 text-xs">
70
+ <div class="flex justify-between">
71
+ <span>Agents:</span>
72
+ <span class="font-mono">{{ overviewData?.total_agents?.toLocaleString() || 0 }}</span>
73
+ </div>
74
+ <div class="flex justify-between">
75
+ <span>Policies:</span>
76
+ <span class="font-mono">{{ overviewData?.total_policies?.toLocaleString() || 0 }}</span>
77
+ </div>
78
+ <div class="flex justify-between">
79
+ <span>Avg Score:</span>
80
+ <span class="font-mono">{{ overviewData?.average_score?.toFixed(1) || 0 }}%</span>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ </n-popover>
86
+
87
+ <n-select
88
+ v-model:value="selectedCustomer"
89
+ :options="customerOptions"
90
+ clearable
91
+ size="small"
92
+ placeholder="Customer"
93
+ class="max-w-32"
94
+ :loading="loadingCustomers"
95
+ />
96
+
97
+ <n-input
98
+ v-model:value="searchPolicyId"
99
+ size="small"
100
+ placeholder="Policy ID..."
101
+ class="max-w-40"
102
+ clearable
103
+ >
104
+ <template #prefix>
105
+ <Icon :name="PolicyIcon"></Icon>
106
+ </template>
107
+ </n-input>
108
+
109
+ <n-input
110
+ v-model:value="searchPolicyName"
111
+ size="small"
112
+ placeholder="Policy name..."
113
+ class="max-w-40"
114
+ clearable
115
+ >
116
+ <template #prefix>
117
+ <Icon :name="SearchIcon"></Icon>
118
+ </template>
119
+ </n-input>
120
+
121
+ <n-select
122
+ v-model:value="searchAgent"
123
+ :options="agentOptions"
124
+ size="small"
125
+ placeholder="Search agent..."
126
+ class="max-w-40"
127
+ clearable
128
+ filterable
129
+ :loading="loadingAgents"
130
+ />
131
+
132
+ <n-input-number
133
+ v-model:value="minScore"
134
+ size="small"
135
+ placeholder="Min score"
136
+ class="max-w-32"
137
+ :min="0"
138
+ :max="100"
139
+ clearable
140
+ />
141
+
142
+ <n-input-number
143
+ v-model:value="maxScore"
144
+ size="small"
145
+ placeholder="Max score"
146
+ class="max-w-32"
147
+ :min="0"
148
+ :max="100"
149
+ clearable
150
+ />
151
+ </div>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- Statistics Cards -->
156
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4 mb-4">
157
+ <div class="stat-card">
158
+ <div class="stat-header">
159
+ <Icon :name="TotalIcon" :size="20" class="text-blue-600" />
160
+ <span class="stat-title">Total</span>
161
+ </div>
162
+ <div class="stat-value">{{ totalCount.toLocaleString() }}</div>
163
+ </div>
164
+
165
+ <div class="stat-card excellent clickable" :class="{ selected: selectedComplianceLevel === ScaComplianceLevel.Excellent }" @click="selectComplianceLevel(ScaComplianceLevel.Excellent)">
166
+ <div class="stat-header">
167
+ <Icon :name="ExcellentIcon" :size="20" class="text-green-600" />
168
+ <span class="stat-title">Excellent</span>
169
+ </div>
170
+ <div class="stat-value">{{ stats.excellent.toLocaleString() }}</div>
171
+ <div class="stat-percentage">{{ getPercentage(stats.excellent) }}%</div>
172
+ </div>
173
+
174
+ <div class="stat-card good clickable" :class="{ selected: selectedComplianceLevel === ScaComplianceLevel.Good }" @click="selectComplianceLevel(ScaComplianceLevel.Good)">
175
+ <div class="stat-header">
176
+ <Icon :name="GoodIcon" :size="20" class="text-blue-600" />
177
+ <span class="stat-title">Good</span>
178
+ </div>
179
+ <div class="stat-value">{{ stats.good.toLocaleString() }}</div>
180
+ <div class="stat-percentage">{{ getPercentage(stats.good) }}%</div>
181
+ </div>
182
+
183
+ <div class="stat-card average clickable" :class="{ selected: selectedComplianceLevel === ScaComplianceLevel.Average }" @click="selectComplianceLevel(ScaComplianceLevel.Average)">
184
+ <div class="stat-header">
185
+ <Icon :name="AverageIcon" :size="20" class="text-yellow-600" />
186
+ <span class="stat-title">Average</span>
187
+ </div>
188
+ <div class="stat-value">{{ stats.average.toLocaleString() }}</div>
189
+ <div class="stat-percentage">{{ getPercentage(stats.average) }}%</div>
190
+ </div>
191
+
192
+ <div class="stat-card poor clickable" :class="{ selected: selectedComplianceLevel === ScaComplianceLevel.Poor }" @click="selectComplianceLevel(ScaComplianceLevel.Poor)">
193
+ <div class="stat-header">
194
+ <Icon :name="PoorIcon" :size="20" class="text-orange-600" />
195
+ <span class="stat-title">Poor</span>
196
+ </div>
197
+ <div class="stat-value">{{ stats.poor.toLocaleString() }}</div>
198
+ <div class="stat-percentage">{{ getPercentage(stats.poor) }}%</div>
199
+ </div>
200
+
201
+ <div class="stat-card critical clickable" :class="{ selected: selectedComplianceLevel === ScaComplianceLevel.Critical }" @click="selectComplianceLevel(ScaComplianceLevel.Critical)">
202
+ <div class="stat-header">
203
+ <Icon :name="CriticalIcon" :size="20" class="text-red-600" />
204
+ <span class="stat-title">Critical</span>
205
+ </div>
206
+ <div class="stat-value">{{ stats.critical.toLocaleString() }}</div>
207
+ <div class="stat-percentage">{{ getPercentage(stats.critical) }}%</div>
208
+ </div>
209
+ </div>
210
+
211
+ <!-- Top 5 Policies by Compliance Score -->
212
+ <div v-if="topPoliciesByScore.length > 0" class="mb-4">
213
+ <h3 class="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100 flex items-center gap-2">
214
+ <Icon :name="TopPoliciesIcon" :size="20" class="text-green-600" />
215
+ Top 5 Policies by Compliance Score
216
+ </h3>
217
+ <div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
218
+ <div
219
+ v-for="(policy, index) in topPoliciesByScore.slice(0, 5)"
220
+ :key="`${policy.policy_id}-${policy.score}`"
221
+ class="policy-card clickable"
222
+ :class="{
223
+ 'rank-1': index === 0,
224
+ 'rank-2': index === 1,
225
+ 'rank-3': index === 2,
226
+ 'selected': searchPolicyId === policy.policy_id
227
+ }"
228
+ @click="selectPolicy(policy.policy_id)"
229
+ >
230
+ <div class="policy-header">
231
+ <div class="policy-rank">
232
+ <Icon
233
+ :name="index < 3 ? 'carbon:trophy' : 'carbon:security'"
234
+ :size="16"
235
+ :class="index === 0 ? 'text-yellow-500' : index === 1 ? 'text-gray-400' : index === 2 ? 'text-amber-600' : 'text-green-500'"
236
+ />
237
+ <span class="rank-number">#{{ index + 1 }}</span>
238
+ </div>
239
+ <Badge :color="getComplianceLevelColor(getComplianceLevel(policy.score))" type="splitted" size="small">
240
+ <template #label>Score</template>
241
+ <template #value>{{ policy.score }}%</template>
242
+ </Badge>
243
+ </div>
244
+
245
+ <div class="policy-name">{{ policy.policy_name }}</div>
246
+
247
+ <div class="policy-stats">
248
+ <div class="stat-row">
249
+ <span class="stat-label">Policy ID:</span>
250
+ <span class="stat-value-sm">{{ policy.policy_id }}</span>
251
+ </div>
252
+ <div class="stat-row">
253
+ <span class="stat-label">Agents:</span>
254
+ <span class="stat-value-sm">{{ policy.agentCount.toLocaleString() }}</span>
255
+ </div>
256
+ <div class="stat-row">
257
+ <span class="stat-label">Total Checks:</span>
258
+ <span class="stat-value-sm">{{ policy.total_checks.toLocaleString() }}</span>
259
+ </div>
260
+ </div>
261
+
262
+ <!-- Pass/Fail indicator -->
263
+ <div class="compliance-indicator">
264
+ <Badge color="success" size="small">
265
+ <template #value>{{ policy.pass }} Pass</template>
266
+ </Badge>
267
+ <Badge v-if="policy.fail > 0" color="danger" size="small">
268
+ <template #value>{{ policy.fail }} Fail</template>
269
+ </Badge>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ </div>
274
+
275
+ <!-- SCA Results List -->
276
+ <n-spin :show="loading">
277
+ <div class="my-3">
278
+ <template v-if="list.length">
279
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
280
+ <ScaCard v-for="item of list" :key="`${item.policy_id}-${item.agent_name}`" :sca="item" />
281
+ </div>
282
+
283
+ <!-- Pagination -->
284
+ <div class="flex justify-center mt-6">
285
+ <n-pagination
286
+ v-model:page="currentPage"
287
+ :page-count="totalPages"
288
+ :page-size="pageSize"
289
+ :item-count="totalCount"
290
+ show-size-picker
291
+ :page-sizes="[25, 50, 100, 200]"
292
+ @update:page="updatePage"
293
+ @update:page-size="updatePageSize"
294
+ />
295
+ </div>
296
+ </template>
297
+ <template v-else>
298
+ <n-empty v-if="!loading" description="No SCA results found" class="h-48 justify-center" />
299
+ </template>
300
+ </div>
301
+ </n-spin>
302
+ </div>
303
+</template>
304
+
305
+<script setup lang="ts">
306
+import type { Agent } from "@/types/agents.d"
307
+import type { Customer } from "@/types/customers.d"
308
+import type { AgentScaOverviewItem, ScaOverviewQuery, ScaOverviewResponse } from "@/types/sca.d"
309
+import { watchDebounced } from "@vueuse/core"
310
+import axios from "axios"
311
+import { NButton, NEmpty, NInput, NInputNumber, NPagination, NPopover, NSelect, NSpin, useMessage } from "naive-ui"
312
+import { computed, onMounted, ref } from "vue"
313
+import Api from "@/api"
314
+import Icon from "@/components/common/Icon.vue"
315
+import { getComplianceLevel, getComplianceLevelColor, ScaComplianceLevel } from "@/types/sca.d"
316
+import ScaCard from "./ScaCard.vue"
317
+
318
+const loading = ref(false)
319
+const message = useMessage()
320
+const list = ref<AgentScaOverviewItem[]>([])
321
+const header = ref()
322
+const totalCount = ref(0)
323
+const currentPage = ref(1)
324
+const pageSize = ref(50)
325
+const totalPages = ref(0)
326
+const selectedCustomer = ref<string | null>(null)
327
+const selectedComplianceLevel = ref<ScaComplianceLevel | null>(null)
328
+const searchPolicyId = ref<string>("")
329
+const searchPolicyName = ref<string>("")
330
+const searchAgent = ref<string>("")
331
+const minScore = ref<number | null>(null)
332
+const maxScore = ref<number | null>(null)
333
+
334
+// Overview data from API response
335
+const overviewData = ref<ScaOverviewResponse | null>(null)
336
+
337
+// Agents data for dropdown
338
+const agents = ref<Agent[]>([])
339
+const loadingAgents = ref(false)
340
+
341
+// Customers data for dropdown
342
+const customers = ref<Customer[]>([])
343
+const loadingCustomers = ref(false)
344
+
345
+const InfoIcon = "carbon:information"
346
+const SearchIcon = "carbon:search"
347
+const PolicyIcon = "carbon:security"
348
+const TotalIcon = "carbon:result"
349
+const ExcellentIcon = "carbon:checkmark-filled"
350
+const GoodIcon = "carbon:checkmark"
351
+const AverageIcon = "carbon:warning-alt"
352
+const PoorIcon = "carbon:warning"
353
+const CriticalIcon = "carbon:warning-filled"
354
+const TopPoliciesIcon = "carbon:trophy"
355
+
356
+// Agent options for dropdown
357
+const agentOptions = computed(() => {
358
+ return (agents.value || []).map(agent => ({
359
+ label: agent.hostname,
360
+ value: agent.hostname
361
+ }))
362
+})
363
+
364
+// Customer options for dropdown
365
+const customerOptions = computed(() => {
366
+ return (customers.value || []).map(customer => ({
367
+ label: customer.customer_code,
368
+ value: customer.customer_code
369
+ }))
370
+})
371
+
372
+// Calculate statistics from current data
373
+const stats = computed(() => {
374
+ // Calculate compliance level distribution from current data
375
+ const excellent = list.value.filter(item => getComplianceLevel(item.score) === ScaComplianceLevel.Excellent).length
376
+ const good = list.value.filter(item => getComplianceLevel(item.score) === ScaComplianceLevel.Good).length
377
+ const average = list.value.filter(item => getComplianceLevel(item.score) === ScaComplianceLevel.Average).length
378
+ const poor = list.value.filter(item => getComplianceLevel(item.score) === ScaComplianceLevel.Poor).length
379
+ const critical = list.value.filter(item => getComplianceLevel(item.score) === ScaComplianceLevel.Critical).length
380
+
381
+ return {
382
+ excellent,
383
+ good,
384
+ average,
385
+ poor,
386
+ critical
387
+ }
388
+})
389
+
390
+// Calculate top policies by compliance score
391
+const topPoliciesByScore = computed(() => {
392
+ // Group policies and calculate averages
393
+ const policyMap = new Map<string, {
394
+ policy_id: string
395
+ policy_name: string
396
+ score: number
397
+ agentCount: number
398
+ total_checks: number
399
+ pass: number
400
+ fail: number
401
+ }>()
402
+
403
+ list.value.forEach(item => {
404
+ const key = item.policy_id
405
+ const existing = policyMap.get(key)
406
+
407
+ if (existing) {
408
+ // Update averages and counts
409
+ existing.agentCount++
410
+ existing.score = Math.max(existing.score, item.score) // Use highest score for ranking
411
+ existing.total_checks += item.total_checks
412
+ existing.pass += item.pass
413
+ existing.fail += item.fail
414
+ } else {
415
+ policyMap.set(key, {
416
+ policy_id: item.policy_id,
417
+ policy_name: item.policy_name,
418
+ score: item.score,
419
+ agentCount: 1,
420
+ total_checks: item.total_checks,
421
+ pass: item.pass,
422
+ fail: item.fail
423
+ })
424
+ }
425
+ })
426
+
427
+ // Convert to array and sort by score
428
+ return Array.from(policyMap.values())
429
+ .sort((a, b) => b.score - a.score)
430
+ .slice(0, 5)
431
+})
432
+
433
+function getPercentage(count: number): string {
434
+ if (totalCount.value === 0) return "0"
435
+ return ((count / totalCount.value) * 100).toFixed(1)
436
+}
437
+
438
+let abortController: AbortController | null = null
439
+
440
+function getList() {
441
+ abortController?.abort()
442
+ abortController = new AbortController()
443
+
444
+ loading.value = true
445
+
446
+ const query: ScaOverviewQuery = {
447
+ page: currentPage.value,
448
+ page_size: pageSize.value,
449
+ customer_code: selectedCustomer.value || undefined,
450
+ policy_id: searchPolicyId.value || undefined,
451
+ policy_name: searchPolicyName.value || undefined,
452
+ agent_name: searchAgent.value || undefined,
453
+ min_score: minScore.value || undefined,
454
+ max_score: maxScore.value || undefined
455
+ }
456
+
457
+ // Apply compliance level filter by converting to score range
458
+ if (selectedComplianceLevel.value) {
459
+ switch (selectedComplianceLevel.value) {
460
+ case ScaComplianceLevel.Excellent:
461
+ query.min_score = 90
462
+ query.max_score = 100
463
+ break
464
+ case ScaComplianceLevel.Good:
465
+ query.min_score = 80
466
+ query.max_score = 89
467
+ break
468
+ case ScaComplianceLevel.Average:
469
+ query.min_score = 70
470
+ query.max_score = 79
471
+ break
472
+ case ScaComplianceLevel.Poor:
473
+ query.min_score = 60
474
+ query.max_score = 69
475
+ break
476
+ case ScaComplianceLevel.Critical:
477
+ query.min_score = 0
478
+ query.max_score = 59
479
+ break
480
+ }
481
+ }
482
+
483
+ Api.sca
484
+ .searchScaOverview(query, abortController.signal)
485
+ .then(res => {
486
+ loading.value = false
487
+
488
+ if (res.data.success) {
489
+ list.value = res.data?.sca_results || []
490
+ totalCount.value = res.data?.total_count || 0
491
+ totalPages.value = res.data?.total_pages || 0
492
+ currentPage.value = res.data?.page || 1
493
+ overviewData.value = res.data
494
+ } else {
495
+ message.warning(res.data?.message || "An error occurred. Please try again later.")
496
+ }
497
+ })
498
+ .catch(err => {
499
+ if (!axios.isCancel(err)) {
500
+ message.error(err.response?.data?.message || "An error occurred. Please try again later.")
501
+ loading.value = false
502
+ }
503
+ })
504
+}
505
+
506
+function getAgents() {
507
+ loadingAgents.value = true
508
+
509
+ Api.agents
510
+ .getAgents()
511
+ .then(res => {
512
+ if (res.data.success) {
513
+ agents.value = res.data.agents || []
514
+ } else {
515
+ message.warning(res.data?.message || "Failed to load agents.")
516
+ }
517
+ })
518
+ .catch(err => {
519
+ message.error(err.response?.data?.message || "Failed to load agents.")
520
+ })
521
+ .finally(() => {
522
+ loadingAgents.value = false
523
+ })
524
+}
525
+
526
+function getCustomers() {
527
+ loadingCustomers.value = true
528
+
529
+ Api.customers
530
+ .getCustomers()
531
+ .then(res => {
532
+ if (res.data.success) {
533
+ customers.value = res.data.customers || []
534
+ } else {
535
+ message.warning(res.data?.message || "Failed to load customers.")
536
+ }
537
+ })
538
+ .catch(err => {
539
+ message.error(err.response?.data?.message || "Failed to load customers.")
540
+ })
541
+ .finally(() => {
542
+ loadingCustomers.value = false
543
+ })
544
+}
545
+
546
+function updatePage(page: number) {
547
+ currentPage.value = page
548
+ getList()
549
+}
550
+
551
+function updatePageSize(size: number) {
552
+ pageSize.value = size
553
+ currentPage.value = 1
554
+ getList()
555
+}
556
+
557
+function selectPolicy(policyId: string) {
558
+ // If the same policy is already selected, clear the filter
559
+ if (searchPolicyId.value === policyId) {
560
+ searchPolicyId.value = ""
561
+ } else {
562
+ // Set the policy ID in the search filter
563
+ searchPolicyId.value = policyId
564
+ }
565
+ // Reset to first page when filtering
566
+ currentPage.value = 1
567
+}
568
+
569
+function selectComplianceLevel(level: ScaComplianceLevel) {
570
+ // If the same level is already selected, clear the filter
571
+ if (selectedComplianceLevel.value === level) {
572
+ selectedComplianceLevel.value = null
573
+ } else {
574
+ // Set the compliance level filter
575
+ selectedComplianceLevel.value = level
576
+ }
577
+ // Reset to first page when filtering
578
+ currentPage.value = 1
579
+}
580
+
581
+// Load agents and customers when component mounts
582
+onMounted(() => {
583
+ getAgents()
584
+ getCustomers()
585
+})
586
+
587
+watchDebounced([selectedCustomer, selectedComplianceLevel, searchPolicyId, searchPolicyName, searchAgent, minScore, maxScore], () => {
588
+ currentPage.value = 1
589
+ getList()
590
+}, {
591
+ deep: true,
592
+ debounce: 300,
593
+ immediate: true
594
+})
595
+</script>
596
+
597
+<style scoped>
598
+.stat-card {
599
+ background-color: white;
600
+ border-radius: 0.5rem;
601
+ padding: 1rem;
602
+ border: 1px solid rgb(229 231 235);
603
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
604
+ transition: all 0.2s ease;
605
+}
606
+
607
+.stat-card.clickable {
608
+ cursor: pointer;
609
+}
610
+
611
+.stat-card:hover {
612
+ transform: translateY(-2px);
613
+ box-shadow: 0 4px 8px 0 rgb(0 0 0 / 0.1);
614
+}
615
+
616
+.stat-card.selected {
617
+ border-width: 2px;
618
+ box-shadow: 0 4px 12px 0 rgb(59 130 246 / 0.3);
619
+}
620
+
621
+.stat-card.excellent {
622
+ border-color: rgb(34 197 94);
623
+ background-color: rgb(240 253 244);
624
+}
625
+
626
+.stat-card.excellent.selected {
627
+ border-color: rgb(34 197 94);
628
+ background-color: rgb(220 252 231);
629
+}
630
+
631
+.stat-card.good {
632
+ border-color: rgb(59 130 246);
633
+ background-color: rgb(239 246 255);
634
+}
635
+
636
+.stat-card.good.selected {
637
+ border-color: rgb(59 130 246);
638
+ background-color: rgb(219 234 254);
639
+}
640
+
641
+.stat-card.average {
642
+ border-color: rgb(234 179 8);
643
+ background-color: rgb(254 252 232);
644
+}
645
+
646
+.stat-card.average.selected {
647
+ border-color: rgb(234 179 8);
648
+ background-color: rgb(254 249 195);
649
+}
650
+
651
+.stat-card.poor {
652
+ border-color: rgb(249 115 22);
653
+ background-color: rgb(255 247 237);
654
+}
655
+
656
+.stat-card.poor.selected {
657
+ border-color: rgb(249 115 22);
658
+ background-color: rgb(255 237 213);
659
+}
660
+
661
+.stat-card.critical {
662
+ border-color: rgb(239 68 68);
663
+ background-color: rgb(254 242 242);
664
+}
665
+
666
+.stat-card.critical.selected {
667
+ border-color: rgb(239 68 68);
668
+ background-color: rgb(254 226 226);
669
+}
670
+
671
+.stat-header {
672
+ display: flex;
673
+ align-items: center;
674
+ gap: 0.5rem;
675
+ margin-bottom: 0.5rem;
676
+}
677
+
678
+.stat-title {
679
+ font-size: 0.875rem;
680
+ font-weight: 500;
681
+ color: rgb(75 85 99);
682
+}
683
+
684
+.stat-value {
685
+ font-size: 1.5rem;
686
+ font-weight: 700;
687
+ color: rgb(17 24 39);
688
+}
689
+
690
+.stat-percentage {
691
+ font-size: 0.75rem;
692
+ color: rgb(107 114 128);
693
+ margin-top: 0.25rem;
694
+}
695
+
696
+/* Policy Cards */
697
+.policy-card {
698
+ background-color: white;
699
+ border: 1px solid rgb(229 231 235);
700
+ border-radius: 0.5rem;
701
+ padding: 1rem;
702
+ transition: all 0.2s ease;
703
+}
704
+
705
+.policy-card.clickable {
706
+ cursor: pointer;
707
+}
708
+
709
+.policy-card:hover {
710
+ border-color: rgb(156 163 175);
711
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
712
+ transform: translateY(-2px);
713
+}
714
+
715
+.policy-card.selected {
716
+ border-color: rgb(59 130 246);
717
+ background-color: rgb(239 246 255);
718
+ box-shadow: 0 4px 12px -1px rgb(59 130 246 / 0.2);
719
+}
720
+
721
+.policy-card.rank-1 {
722
+ border-color: rgb(234 179 8);
723
+ background-color: rgb(255 255 255);
724
+}
725
+
726
+.policy-card.rank-2 {
727
+ border-color: rgb(156 163 175);
728
+ background-color: rgb(255 255 255);
729
+}
730
+
731
+.policy-card.rank-3 {
732
+ border-color: rgb(217 119 6);
733
+ background-color: rgb(255 255 255);
734
+}
735
+
736
+.policy-header {
737
+ display: flex;
738
+ justify-content: space-between;
739
+ align-items: center;
740
+ margin-bottom: 0.75rem;
741
+}
742
+
743
+.policy-rank {
744
+ display: flex;
745
+ align-items: center;
746
+ gap: 0.5rem;
747
+}
748
+
749
+.rank-number {
750
+ font-weight: 600;
751
+ font-size: 0.875rem;
752
+ color: rgb(75 85 99);
753
+}
754
+
755
+.policy-name {
756
+ font-weight: 600;
757
+ font-size: 1rem;
758
+ color: rgb(17 24 39);
759
+ margin-bottom: 0.75rem;
760
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
761
+}
762
+
763
+.policy-stats {
764
+ display: flex;
765
+ flex-direction: column;
766
+ gap: 0.5rem;
767
+ margin-bottom: 0.75rem;
768
+}
769
+
770
+.stat-row {
771
+ display: flex;
772
+ justify-content: space-between;
773
+ align-items: center;
774
+}
775
+
776
+.stat-label {
777
+ font-size: 0.75rem;
778
+ color: rgb(107 114 128);
779
+ font-weight: 500;
780
+}
781
+
782
+.stat-value-sm {
783
+ font-size: 0.875rem;
784
+ font-weight: 600;
785
+ color: rgb(17 24 39);
786
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
787
+}
788
+
789
+.compliance-indicator {
790
+ display: flex;
791
+ gap: 0.5rem;
792
+ flex-wrap: wrap;
793
+}
794
+
795
+/* Dark mode styles */
796
+html.dark .stat-card {
797
+ background-color: rgb(31 41 55);
798
+ border-color: rgb(75 85 99);
799
+}
800
+
801
+html.dark .stat-card.excellent {
802
+ border-color: rgb(34 197 94);
803
+ background-color: rgb(20 83 45);
804
+}
805
+
806
+html.dark .stat-card.good {
807
+ border-color: rgb(59 130 246);
808
+ background-color: rgb(30 64 175);
809
+}
810
+
811
+html.dark .stat-card.average {
812
+ border-color: rgb(234 179 8);
813
+ background-color: rgb(161 98 7);
814
+}
815
+
816
+html.dark .stat-card.poor {
817
+ border-color: rgb(249 115 22);
818
+ background-color: rgb(154 52 18);
819
+}
820
+
821
+html.dark .stat-card.critical {
822
+ border-color: rgb(239 68 68);
823
+ background-color: rgb(127 29 29);
824
+}
825
+
826
+html.dark .stat-title {
827
+ color: rgb(209 213 219);
828
+}
829
+
830
+html.dark .stat-value {
831
+ color: rgb(255 255 255);
832
+}
833
+
834
+html.dark .stat-percentage {
835
+ color: rgb(209 213 219);
836
+}
837
+
838
+html.dark .policy-card {
839
+ background-color: rgb(31 41 55);
840
+ border-color: rgb(75 85 99);
841
+}
842
+
843
+html.dark .policy-card:hover {
844
+ border-color: rgb(156 163 175);
845
+}
846
+
847
+html.dark .policy-card.selected {
848
+ border-color: rgb(96 165 250);
849
+ background-color: rgb(30 58 138);
850
+ box-shadow: 0 4px 12px -1px rgb(96 165 250 / 0.3);
851
+}
852
+
853
+html.dark .policy-name {
854
+ color: rgb(243 244 246);
855
+}
856
+
857
+html.dark .stat-label {
858
+ color: rgb(156 163 175);
859
+}
860
+
861
+html.dark .stat-value-sm {
862
+ color: rgb(243 244 246);
863
+}
864
+</style>
frontend/src/components/sca/ScaCard.vue
new
+125
@@ -0,0 +1,125 @@
1
+<template>
2
+ <div class="sca-card h-full">
3
+ <CardEntity hoverable clickable :embedded class="@container h-full flex flex-col" :class="getComplianceBorderClass(sca.score)" @click.stop="showDetails = true">
4
+ <template #headerMain>{{ sca.policy_name }}</template>
5
+ <template #headerExtra>
6
+ <Badge :color="getComplianceLevelColor(getComplianceLevel(sca.score))">
7
+ <template #iconLeft><Icon :name="getComplianceLevelIcon(getComplianceLevel(sca.score))" :size="14" /></template>
8
+ <template #value>{{ getComplianceLevel(sca.score) }}</template>
9
+ </Badge>
10
+ </template>
11
+ <template #default>
12
+ <div class="flex-1">
13
+ <p class="text-base font-medium opacity-90 leading-relaxed line-clamp-3">{{ sca.description }}</p>
14
+ <div class="mt-2 text-sm opacity-75">
15
+ <div class="flex items-center gap-2">
16
+ <Icon :name="HostIcon" :size="14" />
17
+ <span>{{ sca.agent_name }}</span>
18
+ </div>
19
+ <div class="flex items-center gap-2 mt-1">
20
+ <Icon :name="PolicyIcon" :size="14" />
21
+ <span>{{ sca.policy_id }}</span>
22
+ </div>
23
+ </div>
24
+ </div>
25
+ </template>
26
+ <template #footerMain>
27
+ <div class="flex flex-wrap items-center gap-2">
28
+ <Badge v-if="sca.customer_code" class="text-xs">
29
+ <template #value>{{ sca.customer_code }}</template>
30
+ </Badge>
31
+
32
+ <Badge color="primary" type="splitted" class="text-xs">
33
+ <template #label>Score</template>
34
+ <template #value>{{ sca.score }}%</template>
35
+ </Badge>
36
+
37
+ <Badge color="success" type="splitted" class="text-xs">
38
+ <template #label>Pass</template>
39
+ <template #value>{{ sca.pass }}</template>
40
+ </Badge>
41
+
42
+ <Badge color="danger" type="splitted" class="text-xs">
43
+ <template #label>Fail</template>
44
+ <template #value>{{ sca.fail }}</template>
45
+ </Badge>
46
+
47
+ <Badge v-if="sca.invalid > 0" color="warning" type="splitted" class="text-xs">
48
+ <template #label>Invalid</template>
49
+ <template #value>{{ sca.invalid }}</template>
50
+ </Badge>
51
+
52
+ <Badge type="splitted" class="text-xs">
53
+ <template #label>Total</template>
54
+ <template #value>{{ sca.total_checks }}</template>
55
+ </Badge>
56
+ </div>
57
+ </template>
58
+ <template #footerExtra>
59
+ <div class="text-xs opacity-60">
60
+ {{ formatDate(sca.end_scan) }}
61
+ </div>
62
+ </template>
63
+ </CardEntity>
64
+
65
+ <!-- SCA Details Modal -->
66
+ <n-modal
67
+ v-model:show="showDetails"
68
+ preset="card"
69
+ :style="{ maxWidth: 'min(800px, 90vw)', minHeight: 'min(600px, 90vh)' }"
70
+ :title="`SCA Policy: ${sca.policy_name}`"
71
+ :bordered="false"
72
+ segmented
73
+ >
74
+ <ScaCardContent :sca="sca" />
75
+ </n-modal>
76
+ </div>
77
+</template>
78
+
79
+<script setup lang="ts">
80
+import type { AgentScaOverviewItem } from "@/types/sca.d"
81
+import { NModal } from "naive-ui"
82
+import { ref } from "vue"
83
+import Badge from "@/components/common/Badge.vue"
84
+import CardEntity from "@/components/common/cards/CardEntity.vue"
85
+import Icon from "@/components/common/Icon.vue"
86
+import { getComplianceLevel, getComplianceLevelColor, getComplianceLevelIcon } from "@/types/sca.d"
87
+import ScaCardContent from "./ScaCardContent.vue"
88
+
89
+const { sca } = defineProps<{ sca: AgentScaOverviewItem; embedded?: boolean }>()
90
+
91
+const showDetails = ref(false)
92
+const HostIcon = "carbon:bare-metal-server"
93
+const PolicyIcon = "carbon:security"
94
+
95
+function getComplianceBorderClass(score: number): string {
96
+ const level = getComplianceLevel(score)
97
+ const borderMap: Record<string, string> = {
98
+ Excellent: "border-l-4 border-l-green-500 dark:border-l-green-400",
99
+ Good: "border-l-4 border-l-blue-500 dark:border-l-blue-400",
100
+ Average: "border-l-4 border-l-yellow-500 dark:border-l-yellow-400",
101
+ Poor: "border-l-4 border-l-orange-500 dark:border-l-orange-400",
102
+ Critical: "border-l-4 border-l-red-500 dark:border-l-red-400"
103
+ }
104
+ return borderMap[level] || ""
105
+}
106
+
107
+function formatDate(dateString: string): string {
108
+ return new Date(dateString).toLocaleDateString()
109
+}
110
+</script>
111
+
112
+<style scoped>
113
+.sca-card {
114
+ min-height: 280px;
115
+}
116
+
117
+.line-clamp-3 {
118
+ display: -webkit-box;
119
+ -webkit-line-clamp: 3;
120
+ line-clamp: 3;
121
+ -webkit-box-orient: vertical;
122
+ overflow: hidden;
123
+ text-overflow: ellipsis;
124
+}
125
+</style>
frontend/src/components/sca/ScaCardContent.vue
new
+301
@@ -0,0 +1,301 @@
1
+<template>
2
+ <div class="sca-content">
3
+ <!-- Overview Section -->
4
+ <div class="mb-6">
5
+ <h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
6
+ <Icon :name="OverviewIcon" :size="20" />
7
+ Policy Overview
8
+ </h3>
9
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
10
+ <div class="space-y-3">
11
+ <div class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded">
12
+ <span class="text-sm font-medium">Policy ID:</span>
13
+ <code class="text-sm">{{ sca.policy_id }}</code>
14
+ </div>
15
+ <div class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded">
16
+ <span class="text-sm font-medium">Agent:</span>
17
+ <span class="text-sm font-mono">{{ sca.agent_name }}</span>
18
+ </div>
19
+ <div v-if="sca.customer_code" class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded">
20
+ <span class="text-sm font-medium">Customer:</span>
21
+ <Badge class="text-xs">
22
+ <template #value>{{ sca.customer_code }}</template>
23
+ </Badge>
24
+ </div>
25
+ </div>
26
+ <div class="space-y-3">
27
+ <div class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded">
28
+ <span class="text-sm font-medium">Compliance Level:</span>
29
+ <Badge :color="getComplianceLevelColor(getComplianceLevel(sca.score))">
30
+ <template #iconLeft><Icon :name="getComplianceLevelIcon(getComplianceLevel(sca.score))" :size="14" /></template>
31
+ <template #value>{{ getComplianceLevel(sca.score) }}</template>
32
+ </Badge>
33
+ </div>
34
+ <div class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded">
35
+ <span class="text-sm font-medium">Score:</span>
36
+ <Badge color="primary" type="splitted">
37
+ <template #label>Score</template>
38
+ <template #value>{{ sca.score }}%</template>
39
+ </Badge>
40
+ </div>
41
+ <div class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded">
42
+ <span class="text-sm font-medium">Scan Date:</span>
43
+ <span class="text-sm">{{ formatDateTime(sca.end_scan) }}</span>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ </div>
48
+
49
+ <!-- Description Section -->
50
+ <div class="mb-6">
51
+ <h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
52
+ <Icon :name="DescriptionIcon" :size="20" />
53
+ Description
54
+ </h3>
55
+ <div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
56
+ <p class="text-sm leading-relaxed">{{ sca.description }}</p>
57
+ </div>
58
+ </div>
59
+
60
+ <!-- Compliance Statistics -->
61
+ <div class="mb-6">
62
+ <h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
63
+ <Icon :name="StatsIcon" :size="20" />
64
+ Compliance Statistics
65
+ </h3>
66
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
67
+ <div class="stat-card-small pass">
68
+ <div class="stat-header-small">
69
+ <Icon :name="PassIcon" :size="16" class="text-green-600" />
70
+ <span class="stat-title-small">Passed</span>
71
+ </div>
72
+ <div class="stat-value-small">{{ sca.pass }}</div>
73
+ <div class="stat-percentage-small">{{ getCheckPercentage(sca.pass) }}%</div>
74
+ </div>
75
+
76
+ <div class="stat-card-small fail">
77
+ <div class="stat-header-small">
78
+ <Icon :name="FailIcon" :size="16" class="text-red-600" />
79
+ <span class="stat-title-small">Failed</span>
80
+ </div>
81
+ <div class="stat-value-small">{{ sca.fail }}</div>
82
+ <div class="stat-percentage-small">{{ getCheckPercentage(sca.fail) }}%</div>
83
+ </div>
84
+
85
+ <div v-if="sca.invalid > 0" class="stat-card-small invalid">
86
+ <div class="stat-header-small">
87
+ <Icon :name="InvalidIcon" :size="16" class="text-yellow-600" />
88
+ <span class="stat-title-small">Invalid</span>
89
+ </div>
90
+ <div class="stat-value-small">{{ sca.invalid }}</div>
91
+ <div class="stat-percentage-small">{{ getCheckPercentage(sca.invalid) }}%</div>
92
+ </div>
93
+
94
+ <div class="stat-card-small total">
95
+ <div class="stat-header-small">
96
+ <Icon :name="TotalIcon" :size="16" class="text-blue-600" />
97
+ <span class="stat-title-small">Total</span>
98
+ </div>
99
+ <div class="stat-value-small">{{ sca.total_checks }}</div>
100
+ <div class="stat-percentage-small">100%</div>
101
+ </div>
102
+ </div>
103
+ </div>
104
+
105
+ <!-- Progress Bar -->
106
+ <div class="mb-6">
107
+ <h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
108
+ <Icon :name="ProgressIcon" :size="20" />
109
+ Compliance Progress
110
+ </h3>
111
+ <div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-6 relative overflow-hidden">
112
+ <div
113
+ class="bg-green-500 h-6 rounded-full transition-all duration-500 flex items-center justify-end pr-2"
114
+ :style="{ width: `${sca.score}%` }"
115
+ >
116
+ <span v-if="sca.score > 15" class="text-white text-xs font-semibold">{{ sca.score }}%</span>
117
+ </div>
118
+ <span v-if="sca.score <= 15" class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-700 dark:text-gray-300">
119
+ {{ sca.score }}%
120
+ </span>
121
+ </div>
122
+ <div class="flex justify-between text-xs text-gray-500 mt-1">
123
+ <span>0%</span>
124
+ <span>50%</span>
125
+ <span>100%</span>
126
+ </div>
127
+ </div>
128
+
129
+ <!-- Scan Information -->
130
+ <div class="mb-6">
131
+ <h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
132
+ <Icon :name="ScanIcon" :size="20" />
133
+ Scan Information
134
+ </h3>
135
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
136
+ <div class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded">
137
+ <span class="text-sm font-medium">Scan Started:</span>
138
+ <span class="text-sm">{{ formatDateTime(sca.start_scan) }}</span>
139
+ </div>
140
+ <div class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded">
141
+ <span class="text-sm font-medium">Scan Completed:</span>
142
+ <span class="text-sm">{{ formatDateTime(sca.end_scan) }}</span>
143
+ </div>
144
+ <div v-if="sca.hash_file" class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded">
145
+ <span class="text-sm font-medium">Hash File:</span>
146
+ <code class="text-xs">{{ sca.hash_file }}</code>
147
+ </div>
148
+ <div class="flex items-center justify-between py-2 px-3 bg-gray-50 dark:bg-gray-800 rounded">
149
+ <span class="text-sm font-medium">Scan Duration:</span>
150
+ <span class="text-sm">{{ getScanDuration() }}</span>
151
+ </div>
152
+ </div>
153
+ </div>
154
+
155
+ <!-- References -->
156
+ <div v-if="sca.references" class="mb-6">
157
+ <h3 class="text-lg font-semibold mb-3 flex items-center gap-2">
158
+ <Icon :name="ReferenceIcon" :size="20" />
159
+ References
160
+ </h3>
161
+ <div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
162
+ <p class="text-sm leading-relaxed break-all">{{ sca.references }}</p>
163
+ </div>
164
+ </div>
165
+ </div>
166
+</template>
167
+
168
+<script setup lang="ts">
169
+import type { AgentScaOverviewItem } from "@/types/sca.d"
170
+import Badge from "@/components/common/Badge.vue"
171
+import Icon from "@/components/common/Icon.vue"
172
+import { getComplianceLevel, getComplianceLevelColor, getComplianceLevelIcon } from "@/types/sca.d"
173
+
174
+const { sca } = defineProps<{ sca: AgentScaOverviewItem }>()
175
+
176
+const OverviewIcon = "carbon:overview"
177
+const DescriptionIcon = "carbon:document"
178
+const StatsIcon = "carbon:analytics"
179
+const ProgressIcon = "carbon:progress-bar"
180
+const ScanIcon = "carbon:scan"
181
+const ReferenceIcon = "carbon:link"
182
+const PassIcon = "carbon:checkmark-filled"
183
+const FailIcon = "carbon:close-filled"
184
+const InvalidIcon = "carbon:warning-alt"
185
+const TotalIcon = "carbon:result"
186
+
187
+function getCheckPercentage(count: number): string {
188
+ if (sca.total_checks === 0) return "0"
189
+ return ((count / sca.total_checks) * 100).toFixed(1)
190
+}
191
+
192
+function formatDateTime(dateString: string): string {
193
+ return new Date(dateString).toLocaleString()
194
+}
195
+
196
+function getScanDuration(): string {
197
+ const start = new Date(sca.start_scan)
198
+ const end = new Date(sca.end_scan)
199
+ const durationMs = end.getTime() - start.getTime()
200
+ const durationMinutes = Math.floor(durationMs / 60000)
201
+ const durationSeconds = Math.floor((durationMs % 60000) / 1000)
202
+
203
+ if (durationMinutes > 0) {
204
+ return `${durationMinutes}m ${durationSeconds}s`
205
+ }
206
+ return `${durationSeconds}s`
207
+}
208
+</script>
209
+
210
+<style scoped>
211
+.stat-card-small {
212
+ background-color: white;
213
+ border-radius: 0.5rem;
214
+ padding: 0.75rem;
215
+ border: 1px solid rgb(229 231 235);
216
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
217
+}
218
+
219
+.stat-card-small.pass {
220
+ border-color: rgb(34 197 94);
221
+ background-color: rgb(240 253 244);
222
+}
223
+
224
+.stat-card-small.fail {
225
+ border-color: rgb(239 68 68);
226
+ background-color: rgb(254 242 242);
227
+}
228
+
229
+.stat-card-small.invalid {
230
+ border-color: rgb(234 179 8);
231
+ background-color: rgb(254 252 232);
232
+}
233
+
234
+.stat-card-small.total {
235
+ border-color: rgb(59 130 246);
236
+ background-color: rgb(239 246 255);
237
+}
238
+
239
+.stat-header-small {
240
+ display: flex;
241
+ align-items: center;
242
+ gap: 0.5rem;
243
+ margin-bottom: 0.5rem;
244
+}
245
+
246
+.stat-title-small {
247
+ font-size: 0.75rem;
248
+ font-weight: 500;
249
+ color: rgb(75 85 99);
250
+}
251
+
252
+.stat-value-small {
253
+ font-size: 1.25rem;
254
+ font-weight: 700;
255
+ color: rgb(17 24 39);
256
+}
257
+
258
+.stat-percentage-small {
259
+ font-size: 0.7rem;
260
+ color: rgb(107 114 128);
261
+ margin-top: 0.25rem;
262
+}
263
+
264
+/* Dark mode styles */
265
+html.dark .stat-card-small {
266
+ background-color: rgb(31 41 55);
267
+ border-color: rgb(75 85 99);
268
+}
269
+
270
+html.dark .stat-card-small.pass {
271
+ border-color: rgb(34 197 94);
272
+ background-color: rgb(20 83 45);
273
+}
274
+
275
+html.dark .stat-card-small.fail {
276
+ border-color: rgb(239 68 68);
277
+ background-color: rgb(127 29 29);
278
+}
279
+
280
+html.dark .stat-card-small.invalid {
281
+ border-color: rgb(234 179 8);
282
+ background-color: rgb(161 98 7);
283
+}
284
+
285
+html.dark .stat-card-small.total {
286
+ border-color: rgb(59 130 246);
287
+ background-color: rgb(30 64 175);
288
+}
289
+
290
+html.dark .stat-title-small {
291
+ color: rgb(209 213 219);
292
+}
293
+
294
+html.dark .stat-value-small {
295
+ color: rgb(255 255 255);
296
+}
297
+
298
+html.dark .stat-percentage-small {
299
+ color: rgb(209 213 219);
300
+}
301
+</style>
frontend/src/router/index.ts
+6
@@ -73,6 +73,12 @@ const router = createRouter({
73
name: "VulnerabilityOverview",
74
component: () => import("@/views/agents/VulnerabilityOverview.vue"),
75
meta: { title: "Vulnerability Overview" }
76
+ },
77
+ {
78
+ path: "sca-overview",
79
+ name: "ScaOverview",
80
+ component: () => import("@/views/agents/ScaOverview.vue"),
81
+ meta: { title: "SCA Overview" }
82
}
83
]
84
},
frontend/src/types/sca.d.ts
new
+119
@@ -0,0 +1,119 @@
1
+export interface AgentScaOverviewItem {
2
+ agent_id: string
3
+ agent_name: string
4
+ customer_code?: string | null
5
+ policy_id: string
6
+ policy_name: string
7
+ description: string
8
+ total_checks: number
9
+ pass: number
10
+ fail: number
11
+ invalid: number
12
+ score: number
13
+ start_scan: string
14
+ end_scan: string
15
+ references?: string | null
16
+ hash_file?: string | null
17
+}
18
+
19
+export interface ScaOverviewResponse {
20
+ sca_results: AgentScaOverviewItem[]
21
+ total_count: number
22
+ total_agents: number
23
+ total_policies: number
24
+ average_score: number
25
+ total_checks_all_agents: number
26
+ total_passes_all_agents: number
27
+ total_fails_all_agents: number
28
+ total_invalid_all_agents: number
29
+ page: number
30
+ page_size: number
31
+ total_pages: number
32
+ has_next: boolean
33
+ has_previous: boolean
34
+ success: boolean
35
+ message: string
36
+ filters_applied: Record<string, any>
37
+}
38
+
39
+export interface ScaOverviewQuery {
40
+ customer_code?: string
41
+ agent_name?: string
42
+ policy_id?: string
43
+ policy_name?: string
44
+ min_score?: number
45
+ max_score?: number
46
+ page?: number
47
+ page_size?: number
48
+}
49
+
50
+export interface ScaStatsResponse {
51
+ total_agents_with_sca: number
52
+ total_policies: number
53
+ average_score_across_all: number
54
+ total_checks_all_agents: number
55
+ total_passes_all_agents: number
56
+ total_fails_all_agents: number
57
+ total_invalid_all_agents: number
58
+ by_customer: Record<string, {
59
+ total_agents: number
60
+ total_policies: number
61
+ average_score: number
62
+ total_checks: number
63
+ total_passes: number
64
+ total_fails: number
65
+ total_invalid: number
66
+ }>
67
+ success: boolean
68
+ message: string
69
+}
70
+
71
+export enum ScaComplianceLevel {
72
+ Excellent = "Excellent", // 90-100%
73
+ Good = "Good", // 80-89%
74
+ Average = "Average", // 70-79%
75
+ Poor = "Poor", // 60-69%
76
+ Critical = "Critical" // <60%
77
+}
78
+
79
+export function getComplianceLevel(score: number): ScaComplianceLevel {
80
+ if (score >= 90) return ScaComplianceLevel.Excellent
81
+ if (score >= 80) return ScaComplianceLevel.Good
82
+ if (score >= 70) return ScaComplianceLevel.Average
83
+ if (score >= 60) return ScaComplianceLevel.Poor
84
+ return ScaComplianceLevel.Critical
85
+}
86
+
87
+export function getComplianceLevelColor(level: ScaComplianceLevel): "primary" | "warning" | "success" | "danger" {
88
+ switch (level) {
89
+ case ScaComplianceLevel.Excellent:
90
+ return "success"
91
+ case ScaComplianceLevel.Good:
92
+ return "primary"
93
+ case ScaComplianceLevel.Average:
94
+ return "warning"
95
+ case ScaComplianceLevel.Poor:
96
+ return "warning"
97
+ case ScaComplianceLevel.Critical:
98
+ return "danger"
99
+ default:
100
+ return "primary"
101
+ }
102
+}
103
+
104
+export function getComplianceLevelIcon(level: ScaComplianceLevel): string {
105
+ switch (level) {
106
+ case ScaComplianceLevel.Excellent:
107
+ return "carbon:checkmark-filled"
108
+ case ScaComplianceLevel.Good:
109
+ return "carbon:checkmark"
110
+ case ScaComplianceLevel.Average:
111
+ return "carbon:warning-alt"
112
+ case ScaComplianceLevel.Poor:
113
+ return "carbon:warning"
114
+ case ScaComplianceLevel.Critical:
115
+ return "carbon:warning-filled"
116
+ default:
117
+ return "carbon:help"
118
+ }
119
+}
frontend/src/views/agents/ScaOverview.vue
new
+9
@@ -0,0 +1,9 @@
1
+<template>
2
+ <div class="page">
3
+ <List />
4
+ </div>
5
+</template>
6
+
7
+<script setup lang="ts">
8
+import List from "@/components/sca/List.vue"
9
+</script>