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