main
vue 197 lines 6.01 KB
Raw
1 <template>
2 <div class="@container flex flex-col gap-8">
3 <!-- Header Bar -->
4 <div class="flex flex-wrap items-end justify-between gap-6">
5 <div class="flex gap-3">
6 <n-button quaternary size="small" @click="routeDashboardsList().navigate()">
7 <template #icon>
8 <Icon :name="ArrowBackIcon" :size="22" />
9 </template>
10 </n-button>
11 <div class="flex flex-col">
12 <span class="text-lg font-semibold">{{ dashboardTitle }}</span>
13 <span class="text-xs opacity-60">{{ dashboardDescription }}</span>
14 </div>
15 </div>
16 <div class="flex grow items-center justify-end gap-2">
17 <n-radio-group v-model:value="selectedTimerange" size="small">
18 <n-radio-button v-for="preset in timePresets" :key="preset" :value="preset" :label="preset" />
19 </n-radio-group>
20
21 <n-button size="small" :loading @click="fetchPanelData">
22 <template #icon>
23 <Icon :name="RefreshIcon" :size="16" />
24 </template>
25 </n-button>
26 </div>
27 </div>
28
29 <!-- Panels Grid -->
30 <n-spin :show="loading" content-class="grid grid-cols-12 gap-4">
31 <CardStats
32 v-for="item in panels"
33 :key="item.panel.id"
34 :title="item.panel.title"
35 class="h-full"
36 :class="[panelColSpanClass(item.panel.w)]"
37 :clickable="['stat'].includes(item.panel.type)"
38 @click="['stat'].includes(item.panel.type) ? openEventSearch(item.panel.lucene || '*') : undefined"
39 >
40 <template v-if="['pie', 'bar_h'].includes(item.panel.type)" #header-extra>
41 <n-tooltip class="py1! px2!">
42 <template #trigger>
43 <Icon :name="InfoIcon" :size="16" class="text-secondary cursor-help" />
44 </template>
45 <div class="text-sm">Click on a segment to go to the event search page.</div>
46 </n-tooltip>
47 </template>
48
49 <div v-if="item.panel.type === 'stat'" class="font-mono text-2xl font-semibold">
50 {{ formatCompactNumber(item.data?.value) }}
51 </div>
52
53 <component
54 :is="chartByType[item.panel.type]"
55 v-if="item.data && chartByType[item.panel.type]"
56 :labels="item.data.labels"
57 :data="item.data.data"
58 :monochrome="item.panel.type === 'histogram'"
59 :labels-datetime="item.panel.type === 'histogram'"
60 :height="`${item.panel.type === 'histogram' ? `${item.panel.h + 100}px` : `${item.panel.h}px`}`"
61 @item-click="onChartItemClick(item.panel, $event.name)"
62 />
63
64 <span v-if="item.data?.error" class="text-error text-xs">
65 {{ item.data.error }}
66 </span>
67 </CardStats>
68 </n-spin>
69
70 <n-empty v-if="!loading && !hasData && errorMsg" :description="errorMsg" />
71 </div>
72 </template>
73
74 <script setup lang="ts">
75 import type { Component } from "vue"
76 import type { ApiError } from "@/types/common"
77 import type { DashboardPanel, DashboardPanelType, PanelResult } from "@/types/siem"
78 import axios from "axios"
79 import { NButton, NEmpty, NRadioButton, NRadioGroup, NSpin, NTooltip, useMessage } from "naive-ui"
80 import { computed, ref, watch } from "vue"
81 import Api from "@/api"
82 import CardStats from "@/components/common/cards/CardStats.vue"
83 import ChartBar from "@/components/common/charts/ChartBar.vue"
84 import ChartColumn from "@/components/common/charts/ChartColumn.vue"
85 import ChartPie from "@/components/common/charts/ChartPie.vue"
86 import Icon from "@/components/common/Icon.vue"
87 import { useNavigation } from "@/composables/common/useNavigation"
88 import { formatCompactNumber, getApiErrorMessage } from "@/utils"
89 import { panelColSpanClass } from "./utils"
90
91 const { dashboardId } = defineProps<{
92 dashboardId: number
93 }>()
94
95 const ArrowBackIcon = "carbon:arrow-left"
96 const RefreshIcon = "carbon:renew"
97
98 interface DashboardPanelEntry {
99 panel: DashboardPanel
100 data: PanelResult | undefined
101 }
102
103 const chartByType: Record<DashboardPanelType, Component | undefined> = {
104 stat: undefined,
105 pie: ChartPie,
106 bar_h: ChartBar,
107 histogram: ChartColumn
108 }
109
110 const { routeDashboardsList, routeEventSearch } = useNavigation()
111 const InfoIcon = "carbon:information"
112 const message = useMessage()
113
114 const timePresets = ["1h", "6h", "24h", "7d", "30d"]
115
116 const dashboardTitle = ref("")
117 const dashboardDescription = ref("")
118 const customerCode = ref("")
119 const sourceName = ref("")
120 const panels = ref<DashboardPanelEntry[]>([])
121 const loading = ref(false)
122 const errorMsg = ref("")
123 const selectedTimerange = ref(timePresets[2])
124
125 const hasData = computed(() => panels.value.some(row => row.data != null))
126
127 let abortController = new AbortController()
128
129 async function fetchPanelData() {
130 if (abortController) {
131 abortController.abort()
132 }
133
134 abortController = new AbortController()
135
136 loading.value = true
137 errorMsg.value = ""
138
139 try {
140 const res = await Api.siem.getPanelData(dashboardId, selectedTimerange.value, abortController.signal)
141
142 if (res.data.success) {
143 dashboardTitle.value = res.data.template.title
144 dashboardDescription.value = res.data.template.description
145
146 panels.value = res.data.template.panels.map(p => ({
147 panel: p,
148 data: res.data.panels[p.id]
149 }))
150
151 customerCode.value = res.data.customer_code
152 sourceName.value = res.data.source_name
153 } else {
154 errorMsg.value = res.data.message || "Failed to fetch panel data"
155 message.error(errorMsg.value)
156 }
157
158 loading.value = false
159 } catch (error) {
160 if (!axios.isCancel(error)) {
161 loading.value = false
162 errorMsg.value = getApiErrorMessage(error as ApiError) || "Failed to fetch panel data"
163 message.error(errorMsg.value)
164 }
165 }
166 }
167
168 function openEventSearch(luceneQuery: string) {
169 routeEventSearch({
170 customer_code: customerCode.value,
171 source_name: sourceName.value,
172 query: luceneQuery
173 }).navigate()
174 }
175
176 function buildDrillDownQuery(panel: DashboardPanel, clickedValue: string): string {
177 const baseLucene = panel.lucene && panel.lucene !== "*" ? `(${panel.lucene})` : ""
178 const fieldFilter = panel.field ? `${panel.field}:"${clickedValue}"` : ""
179 return [baseLucene, fieldFilter].filter(Boolean).join(" AND ")
180 }
181
182 function onChartItemClick(panel: DashboardPanel, name: string) {
183 if (panel.type === "histogram") {
184 return
185 }
186 const query = buildDrillDownQuery(panel, name)
187 if (query) openEventSearch(query)
188 }
189
190 watch(
191 selectedTimerange,
192 () => {
193 fetchPanelData()
194 },
195 { immediate: true }
196 )
197 </script>