main
vue 143 lines 3.36 KB
Raw
1 <template>
2 <div class="@container flex flex-col gap-4">
3 <div class="flex items-center justify-end gap-2">
4 <span v-if="stats" class="text-tertiary text-xs">
5 Last refreshed
6 <span v-if="stats.last_refresh">{{ formatRelativeTime(stats.last_refresh) }}</span>
7 <span v-else>just now</span>
8 </span>
9 <n-button :loading="loadingStats" size="tiny" secondary @click="loadStats">
10 <template #icon><Icon :name="RefreshIcon" :size="15" /></template>
11 Refresh
12 </n-button>
13 </div>
14
15 <n-spin :show="loadingStats">
16 <div class="grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3 @7xl:grid-cols-6">
17 <CardLink
18 v-for="tile in headerStatTiles"
19 :key="tile.id"
20 :title="tile.label"
21 :value="tile.value"
22 :icon="tile.icon"
23 :subtitle="tile.sub"
24 />
25 </div>
26 </n-spin>
27 </div>
28 </template>
29
30 <script setup lang="ts">
31 import type { CatalogStatsResponse } from "@/types/detectionCatalog.d"
32 import { NButton, NSpin } from "naive-ui"
33 import { computed, onBeforeMount, ref } from "vue"
34 import Api from "@/api"
35 import CardLink from "@/components/common/cards/CardLink.vue"
36 import Icon from "@/components/common/Icon.vue"
37 import dayjs from "@/utils/dayjs"
38
39 interface HeaderStatTileDef {
40 id: string
41 label: string
42 icon?: string
43 sub?: string
44 value: (stats: CatalogStatsResponse | null) => number
45 show?: (stats: CatalogStatsResponse | null) => boolean
46 }
47
48 interface HeaderStatTile {
49 id: string
50 label: string
51 sub?: string
52 icon?: string
53 value: string
54 }
55
56 const stats = ref<CatalogStatsResponse | null>(null)
57 const loadingStats = ref(false)
58
59 const DetectionIcon = "carbon:radar"
60 const StoryIcon = "carbon:book"
61 const WazuhIcon = "carbon:document-security"
62 const TacticIcon = "carbon:flag"
63 const DataSourceIcon = "carbon:data-base"
64 const ProductIcon = "carbon:cube"
65 const RefreshIcon = "carbon:renew"
66
67 const HEADER_STAT_TILE_DEFS: HeaderStatTileDef[] = [
68 {
69 id: "detections",
70 label: "Detections",
71 icon: DetectionIcon,
72 sub: "CoPilot Searches",
73 value: s => s?.detection_count ?? 0
74 },
75 {
76 id: "stories",
77 label: "Stories",
78 icon: StoryIcon,
79 sub: "Analytic stories",
80 value: s => s?.story_count ?? 0
81 },
82 {
83 id: "wazuh-rules",
84 label: "Wazuh rules",
85 icon: WazuhIcon,
86 sub: "From Wazuh Manager",
87 show: s => !!s?.wazuh_available,
88 value: s => s?.wazuh_rule_count ?? 0
89 },
90 {
91 id: "mitre-tactics",
92 label: "MITRE tactics",
93 icon: TacticIcon,
94 sub: "Covered",
95 value: s => s?.tactic_count ?? 0
96 },
97 {
98 id: "data-sources",
99 label: "Data sources",
100 icon: DataSourceIcon,
101 sub: "Distinct sources",
102 value: s => s?.data_source_count ?? 0
103 },
104 {
105 id: "products",
106 label: "Products",
107 icon: ProductIcon,
108 sub: "Distinct products",
109 value: s => s?.product_count ?? 0
110 }
111 ]
112
113 const headerStatTiles = computed<HeaderStatTile[]>(() =>
114 HEADER_STAT_TILE_DEFS.filter(tile => !tile.show || tile.show(stats.value)).map(tile => ({
115 id: tile.id,
116 label: tile.label,
117 sub: tile.sub,
118 icon: tile.icon,
119 value: tile.value(stats.value).toLocaleString()
120 }))
121 )
122
123 function loadStats() {
124 loadingStats.value = true
125 Api.detectionCatalog
126 .getStats()
127 .then(res => {
128 if (res.data?.success) stats.value = res.data
129 })
130 .catch(() => {
131 /* Stats are best-effort cosmetic; don't toast an error. */
132 })
133 .finally(() => {
134 loadingStats.value = false
135 })
136 }
137
138 function formatRelativeTime(iso: string): string {
139 return dayjs(iso).fromNow()
140 }
141
142 onBeforeMount(loadStats)
143 </script>