main
vue 313 lines 8.03 KB
Raw
1 <template>
2 <div class="@container flex flex-col gap-4">
3 <div class="flex flex-col gap-1">
4 <h3 class="text-lg font-semibold">Compliance Coverage</h3>
5 <p class="text-secondary text-sm">
6 Wazuh rules grouped by control ID for the selected compliance framework. Each row shows how many rules
7 cover that control and how active they've been — useful for auditor questions like "what coverage do we
8 have for PCI DSS 10.2.4?".
9 </p>
10 </div>
11
12 <!-- HERO STATS for the selected framework -->
13 <div v-if="!loading && pivot" class="grid grid-cols-1 gap-4 @2xl:grid-cols-3">
14 <CardLink
15 v-for="tile in complianceStatTiles"
16 :key="tile.id"
17 :title="tile.label"
18 :value="tile.value"
19 :icon-left="tile.icon"
20 :color="tile.color"
21 :subtitle="tile.sub"
22 size="small"
23 />
24 </div>
25
26 <!-- Framework selector + control search -->
27 <div class="flex flex-wrap items-center gap-2">
28 <n-select
29 v-model:value="selectedFramework"
30 :options="frameworkOptions"
31 :loading="loadingFrameworks"
32 size="small"
33 class="min-w-80 flex-1"
34 @update:value="load"
35 />
36 <n-input
37 v-model:value="filter"
38 size="small"
39 placeholder="Filter by control ID…"
40 clearable
41 class="min-w-80 flex-1"
42 >
43 <template #prefix><Icon name="carbon:search" /></template>
44 </n-input>
45 <Badge v-if="pivot" type="splitted" color="primary" class="shrink-0">
46 <template #label>Showing</template>
47 <template #value>{{ filteredGroups.length }} / {{ pivot.groups.length }}</template>
48 </Badge>
49 </div>
50
51 <n-data-table
52 :columns
53 :data="filteredGroups"
54 :loading
55 size="small"
56 :pagination
57 :row-props
58 :scroll-x="1000"
59 class="catalog-table"
60 />
61
62 <!-- Control drill-down modal -->
63 <n-modal
64 v-model:show="showGroupModal"
65 preset="card"
66 :style="{ maxWidth: 'min(720px, 92vw)' }"
67 :title="modalTitle"
68 :bordered="false"
69 segmented
70 >
71 <ComplianceDetail v-if="modalGroup" :group="modalGroup" />
72 </n-modal>
73 </div>
74 </template>
75
76 <script setup lang="tsx">
77 import type { DataTableColumns, SelectOption } from "naive-ui"
78 import type {
79 CatalogComplianceFramework,
80 CatalogComplianceGroupRow,
81 CatalogComplianceResponse
82 } from "@/types/detectionCatalog.d"
83 import { NDataTable, NInput, NModal, NSelect, NTag, useMessage } from "naive-ui"
84 import { computed, onBeforeMount, ref } from "vue"
85 import Api from "@/api"
86 import Badge from "@/components/common/Badge.vue"
87 import CardLink from "@/components/common/cards/CardLink.vue"
88 import Icon from "@/components/common/Icon.vue"
89 import ComplianceDetail from "./ComplianceDetail.vue"
90
91 interface ComplianceStatTile {
92 id: string
93 label: string
94 value: string
95 icon: string
96 sub: string
97 color?: "warning" | "success" | "danger" | "primary"
98 }
99
100 const message = useMessage()
101
102 const frameworks = ref<CatalogComplianceFramework[]>([])
103 const selectedFramework = ref<string>("pci_dss")
104 const pivot = ref<CatalogComplianceResponse | null>(null)
105 const loadingFrameworks = ref(false)
106 const loading = ref(false)
107 const filter = ref("")
108
109 const showGroupModal = ref(false)
110 const modalGroup = ref<CatalogComplianceGroupRow | null>(null)
111 const modalTitle = ref("Compliance Control")
112
113 const ControlIcon = "carbon:certificate-check"
114 const RulesIcon = "carbon:document-security"
115 const UntaggedIcon = "carbon:document-blank"
116
117 const pagination = {
118 pageSize: 25,
119 pageSizes: [10, 25, 50, 100],
120 showSizePicker: true
121 }
122
123 const frameworkOptions = computed<SelectOption[]>(() => frameworks.value.map(f => ({ label: f.label, value: f.key })))
124
125 const filteredGroups = computed<CatalogComplianceGroupRow[]>(() => {
126 const groups = pivot.value?.groups || []
127 const q = filter.value.trim().toLowerCase()
128 if (!q) return groups
129 return groups.filter(g => g.control.toLowerCase().includes(q))
130 })
131
132 const complianceStatTiles = computed<ComplianceStatTile[]>(() => {
133 if (!pivot.value) return []
134
135 return [
136 {
137 id: "controls",
138 label: `${pivot.value.framework_label} controls`,
139 value: pivot.value.control_count.toLocaleString(),
140 icon: ControlIcon,
141 sub: "Distinct control IDs",
142 color: "primary"
143 },
144 {
145 id: "rules-tagged",
146 label: "Rules tagged",
147 value: pivot.value.rules_with_compliance.toLocaleString(),
148 icon: RulesIcon,
149 sub: `of ${pivot.value.total_rules.toLocaleString()} total Wazuh rules`
150 },
151 {
152 id: "rules-without-tag",
153 label: "Rules without tag",
154 value: (pivot.value.total_rules - pivot.value.rules_with_compliance).toLocaleString(),
155 icon: UntaggedIcon,
156 sub: "Not classified for this framework",
157 color: "warning"
158 }
159 ]
160 })
161
162 function openGroup(group: CatalogComplianceGroupRow) {
163 modalGroup.value = group
164 modalTitle.value = `${pivot.value?.framework_label ?? ""} ${group.control}`
165 showGroupModal.value = true
166 }
167
168 function rowProps(row: CatalogComplianceGroupRow) {
169 return {
170 style: "cursor: pointer;",
171 onClick: () => openGroup(row)
172 }
173 }
174
175 const columns: DataTableColumns<CatalogComplianceGroupRow> = [
176 {
177 title: "Activity",
178 key: "total_hits_30d",
179 width: 150,
180 fixed: "left",
181 defaultSortOrder: "descend",
182 sorter: (a, b) => a.total_hits_30d - b.total_hits_30d,
183 render: row => {
184 if (row.total_hits_30d === 0) {
185 return (
186 <div class="flex items-center gap-1.5">
187 <span class="dot dot-muted"></span>
188 <span class="text-secondary text-xs">No hits 30d</span>
189 </div>
190 )
191 }
192 const dotClass =
193 row.total_hits_30d >= 10000
194 ? "dot-danger"
195 : row.total_hits_30d >= 1000
196 ? "dot-warning"
197 : row.total_hits_30d >= 100
198 ? "dot-info"
199 : "dot-success"
200 return (
201 <div class="flex items-center gap-2">
202 <span class={`dot ${dotClass}`}></span>
203 <div class="flex flex-col leading-tight">
204 <span class="font-mono text-xs font-medium">{row.total_hits_30d.toLocaleString()}</span>
205 <span class="text-secondary text-xs">{`${row.total_hits_7d.toLocaleString()} in 7d`}</span>
206 </div>
207 </div>
208 )
209 }
210 },
211 {
212 title: "Control",
213 key: "control",
214 width: 200,
215 sorter: (a, b) => a.control.localeCompare(b.control),
216 render: row => <span class="font-mono text-sm font-medium">{row.control}</span>
217 },
218 {
219 title: "Rules",
220 key: "rule_count",
221 width: 100,
222 sorter: (a, b) => a.rule_count - b.rule_count,
223 render: row => (
224 <NTag size="small" type="primary">
225 {row.rule_count}
226 </NTag>
227 )
228 },
229 {
230 title: "Rule IDs (preview)",
231 key: "rule_ids",
232 render: row => (
233 <div class="flex flex-wrap gap-1">
234 {row.rule_ids.slice(0, 10).map(rid => (
235 <NTag key={rid} size="small">
236 {rid}
237 </NTag>
238 ))}
239 {row.rule_ids.length > 10 && (
240 <NTag size="small" type="default" bordered={false}>{`+${row.rule_ids.length - 10}`}</NTag>
241 )}
242 </div>
243 )
244 }
245 ]
246
247 function loadFrameworks() {
248 loadingFrameworks.value = true
249 Api.detectionCatalog
250 .listComplianceFrameworks()
251 .then(res => {
252 if (res.data?.success) {
253 frameworks.value = res.data.frameworks || []
254 if (frameworks.value.length && !frameworks.value.some(f => f.key === selectedFramework.value)) {
255 selectedFramework.value = frameworks.value[0].key
256 }
257 }
258 })
259 .catch(() => {
260 /* Non-fatal — analyst can retry. */
261 })
262 .finally(() => {
263 loadingFrameworks.value = false
264 load()
265 })
266 }
267
268 function load() {
269 if (!selectedFramework.value) return
270 loading.value = true
271 Api.detectionCatalog
272 .getCompliancePivot(selectedFramework.value)
273 .then(res => {
274 if (res.data?.success) pivot.value = res.data
275 else message.warning(res.data?.message || "Failed to load compliance pivot")
276 })
277 .catch(err => {
278 message.error(
279 err.response?.data?.detail || err.response?.data?.message || "Failed to load compliance pivot"
280 )
281 })
282 .finally(() => {
283 loading.value = false
284 })
285 }
286
287 onBeforeMount(loadFrameworks)
288 </script>
289
290 <style scoped lang="scss">
291 :deep(.dot) {
292 display: inline-block;
293 width: 8px;
294 height: 8px;
295 border-radius: 50%;
296 flex-shrink: 0;
297 }
298 :deep(.dot-muted) {
299 background-color: var(--border-color);
300 }
301 :deep(.dot-success) {
302 background-color: var(--success-color);
303 }
304 :deep(.dot-info) {
305 background-color: var(--primary-color);
306 }
307 :deep(.dot-warning) {
308 background-color: var(--warning-color);
309 }
310 :deep(.dot-danger) {
311 background-color: var(--error-color);
312 }
313 </style>