@cryptotaxi247 / CoPilot / commits / 2be12aca

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>