70
</div>
71
</header>
72
73
- <!-- Main Content -->
73
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
75
- <!-- Dashboard Header Bar -->
76
- <div class="mb-6 rounded-lg bg-white shadow">
77
- <div class="px-4 py-4 sm:px-6">
78
- <div class="flex flex-wrap items-center justify-between gap-4">
79
- <div class="flex items-center gap-3">
80
- <button
81
- @click="router.push('/dashboards')"
82
- class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
83
- >
84
- <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
85
- <path
86
- stroke-linecap="round"
87
- stroke-linejoin="round"
88
- stroke-width="2"
89
- d="M10 19l-7-7m0 0l7-7m-7 7h18"
90
- />
91
- </svg>
92
- </button>
93
- <div>
94
- <h2 class="text-lg font-semibold text-gray-900">
95
- {{ dashboardTitle || "Loading..." }}
96
- </h2>
97
- <p class="text-sm text-gray-500">{{ dashboardDescription }}</p>
98
- </div>
99
- </div>
100
- <div class="flex items-center gap-2">
101
- <button
102
- v-for="preset in timePresets"
103
- :key="preset.value"
104
- @click="selectTimerange(preset.value)"
105
- class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
106
- :class="
107
- selectedTimerange === preset.value
108
- ? 'bg-indigo-600 text-white'
109
- : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
110
- "
111
- >
112
- {{ preset.label }}
113
- </button>
114
- <button
115
- @click="fetchPanelData"
116
- :disabled="loading"
117
- class="ml-2 rounded-md bg-gray-100 p-1.5 text-gray-600 hover:bg-gray-200 disabled:opacity-50"
118
- >
119
- <svg
120
- class="h-4 w-4"
121
- :class="{ 'animate-spin': loading }"
122
- fill="none"
123
- stroke="currentColor"
124
- viewBox="0 0 24 24"
125
- >
126
- <path
127
- stroke-linecap="round"
128
- stroke-linejoin="round"
129
- stroke-width="2"
130
- d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
131
- />
132
- </svg>
133
- </button>
134
- </div>
135
- </div>
136
- </div>
137
- </div>
138
-
139
- <!-- Error -->
140
- <div v-if="errorMsg" class="mb-6 rounded-md border border-red-300 bg-red-50 p-4">
141
- <p class="text-sm text-red-700">{{ errorMsg }}</p>
142
- </div>
143
-
144
- <!-- Loading -->
145
- <div v-if="loading && !hasData" class="rounded-lg bg-white px-4 py-12 text-center shadow">
146
- <div class="mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-indigo-600"></div>
147
- <p class="mt-2 text-sm text-gray-500">Loading dashboard panels...</p>
148
- </div>
149
-
150
- <!-- Panels Grid -->
151
- <div v-if="hasData || (!loading && panels.length > 0)" class="panel-grid">
152
- <div v-for="panel in panels" :key="panel.id" :style="{ gridColumn: `span ${panel.w}` }">
153
- <!-- Stat Panel -->
154
- <div
155
- v-if="panel.type === 'stat'"
156
- class="cursor-pointer rounded-lg bg-white p-6 text-center shadow transition-shadow hover:shadow-md"
157
- @click="openEventSearch(panel.lucene || '*')"
158
- >
159
- <p class="text-xs font-medium tracking-wide text-gray-500 uppercase">{{ panel.title }}</p>
160
- <p class="mt-2 text-3xl font-bold" :style="{ color: accentColor }">
161
- {{ formatStatValue(panelResults[panel.id]?.value) }}
162
- </p>
163
- <p v-if="panelResults[panel.id]?.error" class="mt-1 text-xs text-red-500">
164
- {{ panelResults[panel.id].error }}
165
- </p>
166
- </div>
167
-
168
- <!-- Chart Panel -->
169
- <div v-else class="rounded-lg bg-white shadow">
170
- <div class="border-b border-gray-100 px-4 py-3">
171
- <h3 class="text-sm font-medium text-gray-700">{{ panel.title }}</h3>
172
- </div>
173
- <div class="p-2">
174
- <div :id="`chart-${panel.id}`" :style="{ height: `${panel.h}px`, width: '100%' }"></div>
175
- </div>
176
- <p v-if="panelResults[panel.id]?.error" class="px-4 pb-2 text-xs text-red-500">
177
- {{ panelResults[panel.id].error }}
178
- </p>
179
- </div>
180
- </div>
181
- </div>
74
+ <DashboardViewer :dashboard-id />
75
</div>
76
</div>
77
</template>
78
79
<script setup lang="ts">
187
-import type { ECharts } from "echarts/core"
188
-import type { DashboardPanel, PanelResult } from "@/api/siem"
189
-import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from "vue"
80
+import { computed, ref } from "vue"
81
import { useRoute, useRouter } from "vue-router"
82
+import DashboardViewer from "@/components/dashboards/DashboardViewer.vue"
83
import { usePortalSettingsStore } from "@/stores/portalSettings"
192
-import { SiemAPI } from "@/api/siem"
193
-import { BarChart, LineChart, PieChart } from "echarts/charts"
194
-import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components"
195
-import { init as echartsInit, use as echartsUse } from "echarts/core"
196
-import { CanvasRenderer } from "echarts/renderers"
197
-
198
-echartsUse([TooltipComponent, LegendComponent, GridComponent, PieChart, BarChart, LineChart, CanvasRenderer])
84
85
const route = useRoute()
86
const router = useRouter()
105
router.push("/login")
106
}
107
223
-// -- Dashboard state --
108
const dashboardId = computed(() => Number(route.params.id))
225
-
226
-const COLORS = [
227
- "#6366f1",
228
- "#818cf8",
229
- "#34d399",
230
- "#fbbf24",
231
- "#f87171",
232
- "#a78bfa",
233
- "#fb923c",
234
- "#2dd4bf",
235
- "#e879f9",
236
- "#94a3b8"
237
-]
238
-
239
-const accentColor = ref("#6366f1")
240
-const customerCode = ref("")
241
-const sourceName = ref("")
242
-const dashboardTitle = ref("")
243
-const dashboardDescription = ref("")
244
-const panels = ref<DashboardPanel[]>([])
245
-const panelResults = ref<Record<string, PanelResult>>({})
246
-const loading = ref(false)
247
-const errorMsg = ref("")
248
-const selectedTimerange = ref("24h")
249
-
250
-const timePresets = [
251
- { label: "1h", value: "1h" },
252
- { label: "6h", value: "6h" },
253
- { label: "24h", value: "24h" },
254
- { label: "7d", value: "7d" },
255
- { label: "30d", value: "30d" }
256
-]
257
-
258
-const hasData = computed(() => Object.keys(panelResults.value).length > 0)
259
-
260
-const chartInstances = ref<Map<string, ECharts>>(new Map())
261
-let resizeObservers: ResizeObserver[] = []
262
-
263
-function formatStatValue(value: number | null | undefined): string {
264
- if (value == null) return "—"
265
- if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
266
- if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
267
- return value.toLocaleString()
268
-}
269
-
270
-function selectTimerange(value: string) {
271
- selectedTimerange.value = value
272
- fetchPanelData()
273
-}
274
-
275
-// -- Drill-down to Event Search --
276
-function openEventSearch(luceneQuery: string) {
277
- const routeData = router.resolve({
278
- path: "/event-search",
279
- query: {
280
- customer_code: customerCode.value,
281
- source_name: sourceName.value,
282
- query: luceneQuery
283
- }
284
- })
285
- window.open(routeData.href, "_blank")
286
-}
287
-
288
-function buildDrilldownQuery(panel: DashboardPanel, clickedValue: string): string {
289
- const baseLucene = panel.lucene && panel.lucene !== "*" ? `(${panel.lucene})` : ""
290
- const fieldFilter = panel.field ? `${panel.field}:"${clickedValue}"` : ""
291
- return [baseLucene, fieldFilter].filter(Boolean).join(" AND ")
292
-}
293
-
294
-// -- Fetch panel data --
295
-async function fetchPanelData() {
296
- loading.value = true
297
- errorMsg.value = ""
298
- try {
299
- const res = await SiemAPI.getPanelData(dashboardId.value, selectedTimerange.value)
300
- if (res.success) {
301
- const tpl = res.template
302
- dashboardTitle.value = tpl.title
303
- dashboardDescription.value = tpl.description
304
- panels.value = tpl.panels
305
- accentColor.value = res.accent_color || "#6366f1"
306
- customerCode.value = res.customer_code
307
- sourceName.value = res.source_name
308
- panelResults.value = res.panels
309
- await nextTick()
310
- renderAllCharts()
311
- } else {
312
- errorMsg.value = res.message || "Failed to fetch panel data"
313
- }
314
- } catch (err: any) {
315
- errorMsg.value = err.response?.data?.detail || err.message || "Failed to fetch panel data"
316
- } finally {
317
- loading.value = false
318
- }
319
-}
320
-
321
-// -- ECharts options builders --
322
-function getHistogramOptions(result: PanelResult) {
323
- return {
324
- tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
325
- grid: { left: 50, right: 20, top: 10, bottom: 30 },
326
- xAxis: {
327
- type: "category",
328
- data: result.labels,
329
- axisLabel: { color: "#6b7280", fontSize: 10, rotate: result.labels.length > 20 ? 45 : 0 },
330
- axisLine: { lineStyle: { color: "#e5e7eb" } }
331
- },
332
- yAxis: {
333
- type: "value",
334
- axisLabel: { color: "#6b7280", fontSize: 10 },
335
- splitLine: { lineStyle: { color: "#f3f4f6" } }
336
- },
337
- series: [
338
- {
339
- type: "bar",
340
- data: result.data,
341
- itemStyle: { color: accentColor.value, borderRadius: [2, 2, 0, 0] }
342
- }
343
- ]
344
- }
345
-}
346
-
347
-function getPieOptions(result: PanelResult) {
348
- const data = result.labels.map((label, i) => ({ value: result.data[i], name: label }))
349
- return {
350
- tooltip: { trigger: "item", formatter: "{b}: <strong>{c}</strong> ({d}%)" },
351
- legend: {
352
- type: "scroll",
353
- orient: "vertical",
354
- right: 10,
355
- top: 20,
356
- bottom: 20,
357
- textStyle: { color: "#374151", fontSize: 11 }
358
- },
359
- series: [
360
- {
361
- type: "pie",
362
- radius: ["40%", "70%"],
363
- center: ["35%", "50%"],
364
- avoidLabelOverlap: true,
365
- itemStyle: { borderColor: "#ffffff", borderWidth: 2, borderRadius: 4 },
366
- label: { show: false },
367
- color: COLORS,
368
- data
369
- }
370
- ]
371
- }
372
-}
373
-
374
-function getBarHOptions(result: PanelResult) {
375
- return {
376
- tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
377
- grid: { left: 10, right: 30, top: 10, bottom: 10, containLabel: true },
378
- xAxis: {
379
- type: "value",
380
- axisLabel: { color: "#6b7280", fontSize: 10 },
381
- splitLine: { lineStyle: { color: "#f3f4f6" } }
382
- },
383
- yAxis: {
384
- type: "category",
385
- data: [...result.labels].reverse(),
386
- axisLabel: { color: "#6b7280", fontSize: 10, width: 180, overflow: "truncate" },
387
- axisLine: { lineStyle: { color: "#e5e7eb" } }
388
- },
389
- series: [
390
- {
391
- type: "bar",
392
- data: [...result.data].reverse(),
393
- itemStyle: { color: accentColor.value, borderRadius: [0, 2, 2, 0] }
394
- }
395
- ]
396
- }
397
-}
398
-
399
-function renderChart(panel: DashboardPanel) {
400
- const result = panelResults.value[panel.id]
401
- if (!result || panel.type === "stat") return
402
-
403
- const dom = document.getElementById(`chart-${panel.id}`)
404
- if (!dom) return
405
-
406
- let chart = chartInstances.value.get(panel.id)
407
- if (!chart) {
408
- chart = echartsInit(dom)
409
- chartInstances.value.set(panel.id, chart)
410
-
411
- // Click handler for drill-down to Event Search
412
- chart.on("click", (params: { name?: string }) => {
413
- if (!params.name) return
414
- const query = buildDrilldownQuery(panel, params.name)
415
- if (query) openEventSearch(query)
416
- })
417
-
418
- const observer = new ResizeObserver(() => chart?.resize())
419
- observer.observe(dom)
420
- resizeObservers.push(observer)
421
- }
422
-
423
- let options
424
- if (panel.type === "histogram") options = getHistogramOptions(result)
425
- else if (panel.type === "pie") options = getPieOptions(result)
426
- else if (panel.type === "bar_h") options = getBarHOptions(result)
427
-
428
- if (options) {
429
- chart.setOption(options, true)
430
- }
431
-}
432
-
433
-function renderAllCharts() {
434
- for (const panel of panels.value) {
435
- if (panel.type !== "stat") {
436
- renderChart(panel)
437
- }
438
- }
439
-}
440
-
441
-// -- Lifecycle --
442
-onMounted(() => {
443
- fetchPanelData()
444
-})
445
-
446
-onBeforeUnmount(() => {
447
- for (const chart of chartInstances.value.values()) {
448
- chart.dispose()
449
- }
450
- for (const obs of resizeObservers) {
451
- obs.disconnect()
452
- }
453
-})
109
</script>
455
-
456
-<style scoped>
457
-.panel-grid {
458
- display: grid;
459
- grid-template-columns: repeat(12, 1fr);
460
- gap: 16px;
461
-}
462
-</style>