ITPub博客

首页 > 大数据 > 可视化 > 技术解读:鸿蒙平台 Kibana 数据可视化适配实战:基于 Electron 壳方案的本地数据驱动仪表盘开发

技术解读:鸿蒙平台 Kibana 数据可视化适配实战:基于 Electron 壳方案的本地数据驱动仪表盘开发

可视化 作者:KaelByte 时间:2026-10-13 11:19:25 0 删除 编辑

一、技术架构分析

1.1 整体架构

本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程 的分层架构:

  • Electron Web 层:运行于 ArkWeb 引擎,提供完整的数据可视化能力(仪表盘、数据发现、可视化配置、开发工具)

  • 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、屏幕自适应等系统能力

这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。

1.2 技术方案

| 对比项 | Kibana 原版 | 鸿蒙适配版 |
| --- | --- | --- |
| 前端框架 | React + Redux | HTML/CSS/Vanilla JavaScript |
| 数据源 | Elasticsearch 后端 | 本地 JSON 文件持久化 |
| 图表引擎 | Vega / D3.js | Chart.js 4.4.0 |
| 查询语言 | KQL / Lucene | 自研类 KQL 条件解析 |
| 部署方式 | Docker / npm | HAP 包(鸿蒙应用) |
| 运行环境 | 浏览器 | 鸿蒙 PC(ArkWeb 引擎) |

1.3 核心功能清单

| 功能模块 | 具体能力 |
| --- | --- |
| 仪表盘 | 4 个指标卡片 + 4 种图表(折线/环形/柱状/极地),全部从真实数据计算 |
| 数据发现 | 结构化数据表格 + 索引模式切换 + 全文检索 + 分页 + 单条删除 |
| 可视化 | 4 种图表类型 + 3 种 X 轴 + 3 种 Y 轴 + 4 种聚合方式,三维组合 |
| 开发工具 | 类 KQL 查询控制台,返回 Elasticsearch 格式 JSON 响应 |
| 数据管理 | 新增记录(自定义对话框表单)、删除记录、导出 JSON 文件 |
| 种子数据 | 首次启动自动生成 200 条模拟微服务日志 |

二、环境准备

2.1 开发环境要求

| 项目 | 版本/信息 |
| --- | --- |
| 操作系统 | Windows 10/11 |
| 核心框架 | Electron (Node.js + Chromium) |
| 技术栈 | HTML5/CSS3/Vanilla JavaScript |
| 图表库 | Chart.js 4.4.0 |
| 数据存储 | 本地 JSON 文件(fs 读写) |
| 目标设备 | 鸿蒙 PC |
| 目标架构 | arm64-v8a |
| 开发工具 | DevEco Studio(鸿蒙官方 IDE) |

2.2 项目结构

ohos_hap/
├── electron-apps/
│   └── Kibana/
│       ├── main.js
│       ├── renderer.js
│       ├── index.html
│       ├── package.json
│       └── styles/
│           └── kibana.css
├── web_engine/
│   └── src/main/resources/
│       └── resfile/resources/app/
│           ├── main.js
│           ├── renderer.js
│           ├── index.html
│           ├── package.json
│           └── styles/kibana.css
└── electron/
└── libs/arm64-v8a/
        ├── libelectron.so
├── libadapter.so
└── libffmpeg.so

开发流程:在 electron-apps/Kibana/ 中开发,每次修改后同步到 web_engine/src/main/resources/resfile/resources/app/ 部署目录。

三、核心适配流程

3.1 第一步:创建 Electron 主进程

文件:electron-apps/Kibana/main.js

主进程负责窗口创建、本地数据持久化和 IPC 通信。与 Notepadqq 相比,Kibana 增加了数据 CRUD 通道和种子数据初始化。

const
{
app
,
BrowserWindow
,
ipcMain
,
dialog
,
screen
}
=
require
(
'electron'
)
;
const
fs
=
require
(
'fs'
)
;
const
path
=
require
(
'path'
)
;
app
.
disableHardwareAcceleration
(
)
;
let
mainWindow
=
null
;
function
getDataFilePath
(
)
{
const
userDataPath
=
app
.
getPath
(
'userData'
)
;
return
path
.
join
(
userDataPath
,
'kibana-data.json'
)
;
}
function
loadData
(
)
{
try
{
const
filePath
=
getDataFilePath
(
)
;
if
(
fs
.
existsSync
(
filePath
)
)
{
const
raw
=
fs
.
readFileSync
(
filePath
,
'utf-8'
)
;
return
JSON
.
parse
(
raw
)
;
}
}
catch
(
e
)
{
console
.
warn
(
'[Kibana] 读取数据失败:'
,
e
.
message
)
;
}
return
[
]
;
}
function
saveData
(
data
)
{
try
{
const
filePath
=
getDataFilePath
(
)
;
const
dir
=
path
.
dirname
(
filePath
)
;
if
(
!
fs
.
existsSync
(
dir
)
)
{
fs
.
mkdirSync
(
dir
,
{
recursive
:
true
}
)
;
}
fs
.
writeFileSync
(
filePath
,
JSON
.
stringify
(
data
,
null
,
2
)
,
'utf-8'
)
;
return
true
;
}
catch
(
e
)
{
console
.
warn
(
'[Kibana] 写入数据失败:'
,
e
.
message
)
;
return
false
;
}
}
function
createWindow
(
)
{
try
{
const
display
=
screen
.
getPrimaryDisplay
(
)
;
const
{
width
,
height
}
=
display
.
workAreaSize
;
mainWindow
=
new
BrowserWindow
(
{
width
:
Math
.
floor
(
width
*
0.9
)
,
height
:
Math
.
floor
(
height
*
0.85
)
,
frame
:
true
,
transparent
:
false
,
resizable
:
true
,
webPreferences
:
{
nodeIntegration
:
true
,
contextIsolation
:
false
}
}
)
;
mainWindow
.
loadFile
(
'index.html'
)
;
}
catch
(
e
)
{
console
.
warn
(
'[Kibana] 创建窗口失败:'
,
e
.
message
)
;
}
}
app
.
whenReady
(
)
.
then
(
(
)
=>
{
initSeedData
(
)
;
createWindow
(
)
;
}
)
;
app
.
on
(
'window-all-closed'
,
(
)
=>
{
app
.
quit
(
)
;
}
)
;
| 防护目标 | 配置项 | 作用 |
| --- | --- | --- |
| 防 XComponent 崩溃 | frame: true, transparent: false, resizable: true | 使用标准窗口模式,避免触发鸿蒙适配层缺陷 |
| 防 GPU 白屏 | disableHardwareAcceleration() | 禁用硬件加速,规避 GPU 渲染异常 |
| 防单点故障 | try-catch 包裹窗口创建 | 即使窗口创建失败也不会导致进程崩溃 |

3.2 第二步:实现数据持久化与种子数据

Kibana 的核心区别于其他应用在于数据驱动——所有图表和指标都从真实数据计算。主进程通过 IPC 通道暴露数据 CRUD 接口:

function
initSeedData
(
)
{
const
existing
=
loadData
(
)
;
if
(
existing
.
length
>
0
)
return
;
const
levels
=
[
'INFO'
,
'INFO'
,
'INFO'
,
'WARN'
,
'ERROR'
,
'DEBUG'
]
;
const
services
=
[
'网关服务'
,
'用户服务'
,
'订单服务'
,
'支付服务'
,
'认证服务'
]
;
const
methods
=
[
'GET'
,
'POST'
,
'PUT'
,
'DELETE'
]
;
const
paths
=
[
'/api/users'
,
'/api/orders'
,
'/api/products'
,
'/api/auth/login'
,
'/api/payments'
,
'/api/health'
]
;
const
statusCodes
=
[
200
,
200
,
200
,
200
,
201
,
301
,
400
,
404
,
500
,
502
]
;
const
seed
=
[
]
;
for
(
let
i
=
0
;
i
<
200
;
i
++
)
{
const
now
=
new
Date
(
)
;
now
.
setMinutes
(
now
.
getMinutes
(
)
-
Math
.
floor
(
Math
.
random
(
)
*
1440
)
)
;
seed
.
push
(
{
id
:
'rec_'
+
Date
.
now
(
)
+
'_'
+
i
,
timestamp
:
now
.
toISOString
(
)
,
level
:
levels
[
Math
.
floor
(
Math
.
random
(
)
*
levels
.
length
)
]
,
service
:
services
[
Math
.
floor
(
Math
.
random
(
)
*
services
.
length
)
]
,
method
:
methods
[
Math
.
floor
(
Math
.
random
(
)
*
methods
.
length
)
]
,
path
:
paths
[
Math
.
floor
(
Math
.
random
(
)
*
paths
.
length
)
]
,
status
:
statusCodes
[
Math
.
floor
(
Math
.
random
(
)
*
statusCodes
.
length
)
]
,
latency
:
Math
.
floor
(
Math
.
random
(
)
*
500
)
+
10
}
)
;
}
saveData
(
seed
)
;
}
ipcMain
.
handle
(
'data:getAll'
,
async
(
)
=>
{
return
loadData
(
)
;
}
)
;
ipcMain
.
handle
(
'data:add'
,
async
(
event
,
record
)
=>
{
const
data
=
loadData
(
)
;
record
.
id
=
'rec_'
+
Date
.
now
(
)
+
'_'
+
Math
.
floor
(
Math
.
random
(
)
*
1000
)
;
record
.
timestamp
=
new
Date
(
)
.
toISOString
(
)
;
data
.
unshift
(
record
)
;
saveData
(
data
)
;
return
{
success
:
true
,
record
:
record
}
;
}
)
;
ipcMain
.
handle
(
'data:delete'
,
async
(
event
,
recordId
)
=>
{
const
data
=
loadData
(
)
;
const
filtered
=
data
.
filter
(
r
=>
r
.
id
!==
recordId
)
;
if
(
filtered
.
length
===
data
.
length
)
{
return
{
success
:
false
,
error
:
'记录不存在'
}
;
}
saveData
(
filtered
)
;
return
{
success
:
true
}
;
}
)
;
ipcMain
.
handle
(
'dialog:exportData'
,
async
(
)
=>
{
try
{
const
result
=
await
dialog
.
showSaveDialog
(
mainWindow
,
{
filters
:
[
{
name
:
'JSON 文件'
,
extensions
:
[
'json'
]
}
,
{
name
:
'所有文件'
,
extensions
:
[
'*'
]
}
]
}
)
;
return
result
;
}
catch
(
e
)
{
return
{
canceled
:
true
,
filePath
:
''
}
;
}
}
)
;
ipcMain
.
handle
(
'file:write'
,
async
(
event
,
filePath
,
content
)
=>
{
try
{
fs
.
writeFileSync
(
filePath
,
content
,
'utf-8'
)
;
return
{
success
:
true
}
;
}
catch
(
err
)
{
return
{
success
:
false
,
error
:
err
.
message
}
;
}
}
)
;
| 通道名 | 功能 | 参数 |
| --- | --- | --- |
| data:getAll | 获取全部数据 | 无 |
| data:add | 新增一条记录 | record(对象) |
| data:delete | 删除指定记录 | recordId(字符串) |
| dialog:exportData | 导出文件对话框 | 无 |
| file:write | 写入文件内容 | filePath, content |

3.3 第三步:构建页面布局

文件:electron-apps/Kibana/index.html

页面采用经典的 Kibana 风格布局:左侧导航栏 + 顶部工具栏 + 右侧视图区域(4 个视图切换)。

<
!
DOCTYPE
html
><
html lang
=
"zh-CN"
><
head
><
meta charset
=
"UTF-8"
><
title
>
Kibana
-
数据可视化平台
<
/
title
><
link rel
=
"stylesheet"
href
=
"styles/kibana.css"
><
!
--
Chart
.
js 图表库
--
><
script src
=
"https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"
><
/
script
><
/
head
><
body
><
div
class
=
"app-container"
><
!
--
左侧导航栏
--
><
nav
class
=
"sidebar"
><
div
class
=
"sidebar-header"
><
span
class
=
"logo"
>
K
<
/
span
><
span
class
=
"logo-text"
>
Kibana
<
/
span
><
/
div
><
ul
class
=
"nav-menu"
><
li
class
=
"nav-item active"
data
-
view
=
"dashboard"
><
span
class
=
"nav-icon"
>
📊
<
/
span
><
span
class
=
"nav-label"
>
仪表盘
<
/
span
><
/
li
><
li
class
=
"nav-item"
data
-
view
=
"discover"
><
span
class
=
"nav-icon"
>
🔍
<
/
span
><
span
class
=
"nav-label"
>
数据发现
<
/
span
><
/
li
><
li
class
=
"nav-item"
data
-
view
=
"visualize"
><
span
class
=
"nav-icon"
>
📈
<
/
span
><
span
class
=
"nav-label"
>
可视化
<
/
span
><
/
li
><
li
class
=
"nav-item"
data
-
view
=
"devtools"
><
span
class
=
"nav-icon"
>
⚙️
<
/
span
><
span
class
=
"nav-label"
>
开发工具
<
/
span
><
/
li
><
/
ul
><
div
class
=
"sidebar-footer"
><
button
class
=
"export-btn"
data
-
action
=
"export"
>
导出
<
/
button
><
/
div
><
/
nav
><
!
--
右侧主内容区
--
><
div
class
=
"main-content"
><
!
--
顶部工具栏
--
><
div
class
=
"topbar"
><
div
class
=
"topbar-left"
><
h2
class
=
"view-title"
>
仪表盘
<
/
h2
><
span
class
=
"time-range"
>
最近
15
分钟
<
/
span
><
/
div
><
div
class
=
"topbar-right"
><
input type
=
"text"
class
=
"search-input"
id
=
"searchInput"
placeholder
=
"搜索..."
><
button
class
=
"add-record-btn"
id
=
"addRecordBtn"
>
+ 新增
<
/
button
><
button
class
=
"refresh-btn"
id
=
"refreshBtn"
>
🔄 刷新
<
/
button
><
/
div
><
/
div
><
!
--
Dashboard 视图
--
><
div
class
=
"view-container"
id
=
"view-dashboard"
><
div
class
=
"metrics-row"
><
div
class
=
"metric-card"
><
div
class
=
"metric-label"
>
总请求数
<
/
div
><
div
class
=
"metric-value"
id
=
"metricRequests"
>
0
<
/
div
><
div
class
=
"metric-trend up"
>
↑
12.5
%
<
/
div
><
/
div
><
div
class
=
"metric-card"
><
div
class
=
"metric-label"
>
错误率
<
/
div
><
div
class
=
"metric-value"
id
=
"metricErrors"
>
0.0
%
<
/
div
><
div
class
=
"metric-trend down"
>
↓
0.8
%
<
/
div
><
/
div
><
div
class
=
"metric-card"
><
div
class
=
"metric-label"
>
平均响应时间
<
/
div
><
div
class
=
"metric-value"
id
=
"metricLatency"
>
0ms
<
/
div
><
div
class
=
"metric-trend down"
>
↓
5.2
%
<
/
div
><
/
div
><
div
class
=
"metric-card"
><
div
class
=
"metric-label"
>
服务数量
<
/
div
><
div
class
=
"metric-value"
id
=
"metricUsers"
>
0
<
/
div
><
div
class
=
"metric-trend up"
>
个活跃服务
<
/
div
><
/
div
><
/
div
><
div
class
=
"charts-row"
><
div
class
=
"chart-panel"
><
div
class
=
"chart-header"
><
span
class
=
"chart-title"
>
请求趋势(
24
小时)
<
/
span
><
/
div
><
div
class
=
"chart-body"
><
canvas id
=
"lineChart"
><
/
canvas
><
/
div
><
/
div
><
div
class
=
"chart-panel"
><
div
class
=
"chart-header"
><
span
class
=
"chart-title"
>
状态码分布
<
/
span
><
/
div
><
div
class
=
"chart-body"
><
canvas id
=
"doughnutChart"
><
/
canvas
><
/
div
><
/
div
><
/
div
><
div
class
=
"charts-row"
><
div
class
=
"chart-panel"
><
div
class
=
"chart-header"
><
span
class
=
"chart-title"
>
各服务响应时间(ms)
<
/
span
><
/
div
><
div
class
=
"chart-body"
><
canvas id
=
"barChart"
><
/
canvas
><
/
div
><
/
div
><
div
class
=
"chart-panel"
><
div
class
=
"chart-header"
><
span
class
=
"chart-title"
>
流量来源分布
<
/
span
><
/
div
><
div
class
=
"chart-body"
><
canvas id
=
"polarChart"
><
/
canvas
><
/
div
><
/
div
><
/
div
><
/
div
><
!
--
Discover 视图
--
><
div
class
=
"view-container hidden"
id
=
"view-discover"
><
div
class
=
"discover-toolbar"
><
input type
=
"text"
class
=
"filter-input"
id
=
"filterInput"
placeholder
=
"过滤条件: status:200 AND method:GET"
><
div
class
=
"custom-select"
id
=
"indexSelect"
><
div
class
=
"select-display"
data
-
value
=
"logs"
>
logs
-
*
<
/
div
><
div
class
=
"select-options"
><
div
class
=
"select-option"
data
-
value
=
"logs"
>
logs
-
*
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"metrics"
>
metrics
-
*
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"traces"
>
traces
-
*
<
/
div
><
/
div
><
/
div
><
/
div
><
div
class
=
"data-table-container"
><
table
class
=
"data-table"
id
=
"dataTable"
><
thead
><
tr
><
th
>
时间
<
/
th
><
th
>
级别
<
/
th
><
th
>
服务
<
/
th
><
th
>
方法
<
/
th
><
th
>
路径
<
/
th
><
th
>
状态码
<
/
th
><
th
>
耗时
<
/
th
><
th
>
操作
<
/
th
><
/
tr
><
/
thead
><
tbody id
=
"dataTableBody"
><
/
tbody
><
/
table
><
/
div
><
div
class
=
"pagination"
><
button
class
=
"page-btn"
id
=
"prevPage"
>
上一页
<
/
button
><
span
class
=
"page-info"
id
=
"pageInfo"
>
第
1
页
/
共
10
页
<
/
span
><
button
class
=
"page-btn"
id
=
"nextPage"
>
下一页
<
/
button
><
/
div
><
/
div
><
!
--
Visualize 视图
--
><
div
class
=
"view-container hidden"
id
=
"view-visualize"
><
div
class
=
"viz-toolbar"
><
div
class
=
"custom-select"
id
=
"chartTypeSelect"
><
div
class
=
"select-display"
data
-
value
=
"line"
>
折线图
<
/
div
><
div
class
=
"select-options"
><
div
class
=
"select-option"
data
-
value
=
"line"
>
折线图
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"bar"
>
柱状图
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"pie"
>
饼图
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"area"
>
面积图
<
/
div
><
/
div
><
/
div
><
button
class
=
"create-viz-btn"
id
=
"createVizBtn"
>
创建可视化
<
/
button
><
/
div
><
div
class
=
"viz-editor"
><
div
class
=
"viz-config"
><
h3
>
数据配置
<
/
h3
><
div
class
=
"config-group"
><
label
>
X
轴
<
/
label
><
div
class
=
"custom-select"
id
=
"xAxisField"
><
div
class
=
"select-display"
data
-
value
=
"timestamp"
>
时间戳
<
/
div
><
div
class
=
"select-options"
><
div
class
=
"select-option"
data
-
value
=
"timestamp"
>
时间戳
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"category"
>
分类
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"status"
>
状态码
<
/
div
><
/
div
><
/
div
><
/
div
><
div
class
=
"config-group"
><
label
>
Y
轴
<
/
label
><
div
class
=
"custom-select"
id
=
"yAxisField"
><
div
class
=
"select-display"
data
-
value
=
"count"
>
计数
<
/
div
><
div
class
=
"select-options"
><
div
class
=
"select-option"
data
-
value
=
"count"
>
计数
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"avg_latency"
>
平均延迟
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"error_rate"
>
错误率
<
/
div
><
/
div
><
/
div
><
/
div
><
div
class
=
"config-group"
><
label
>
聚合方式
<
/
label
><
div
class
=
"custom-select"
id
=
"aggType"
><
div
class
=
"select-display"
data
-
value
=
"count"
>
计数
<
/
div
><
div
class
=
"select-options"
><
div
class
=
"select-option"
data
-
value
=
"count"
>
计数
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"avg"
>
平均值
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"sum"
>
求和
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"max"
>
最大值
<
/
div
><
/
div
><
/
div
><
/
div
><
/
div
><
div
class
=
"viz-preview"
><
canvas id
=
"vizChart"
><
/
canvas
><
/
div
><
/
div
><
/
div
><
!
--
Dev Tools 视图
--
><
div
class
=
"view-container hidden"
id
=
"view-devtools"
><
div
class
=
"devtools-container"
><
div
class
=
"devtools-editor"
><
textarea
class
=
"query-editor"
id
=
"queryEditor"
placeholder
=
"
GET
/
_search
{
&
quot
;
query
&
quot
;
:
{
&
quot
;
match_all
&
quot
;
:
{
}
}
}
"
><
/
textarea
><
button
class
=
"run-btn"
id
=
"runQueryBtn"
>
▶ 执行
<
/
button
><
/
div
><
div
class
=
"devtools-output"
><
div
class
=
"output-header"
>
响应结果
<
/
div
><
pre
class
=
"output-content"
id
=
"queryOutput"
><
/
div
><
/
div
><
/
div
><
/
div
><
/
div
><
!
--
应用逻辑
--
><
script src
=
"renderer.js"
><
/
script
><
/
body
><
/
html
>
  • 整体采用 Flex 横向布局(sidebar + main-content),主内容区纵向 Flex 分层

  • 个视图通过 hidden 类切换显示,避免重复创建 DOM

  • 所有下拉框均使用自定义 div 实现(.custom-select),避免鸿蒙平台原生 select 崩溃

  • 指标卡片初始值为 0,由 renderer.js 启动后从真实数据计算填充

3.4 第四步:实现数据驱动渲染

文件:electron-apps/Kibana/renderer.js

渲染进程的核心逻辑是:从主进程加载数据到内存,所有指标和图表均从真实数据实时计算。

const
{
ipcRenderer
}
=
require
(
'electron'
)
;
let
currentView
=
'dashboard'
;
let
currentPage
=
1
;
const
pageSize
=
20
;
let
charts
=
{
}
;
let
vizChart
=
null
;
let
allData
=
[
]
;
async
function
loadAllData
(
)
{
allData
=
await
ipcRenderer
.
invoke
(
'data:getAll'
)
;
allData
.
sort
(
(
a
,
b
)
=>
new
Date
(
b
.
timestamp
)
-
new
Date
(
a
.
timestamp
)
)
;
}
function
switchView
(
viewName
)
{
currentView
=
viewName
;
document
.
querySelectorAll
(
'.nav-item'
)
.
forEach
(
item
=>
{
item
.
classList
.
toggle
(
'active'
,
item
.
dataset
.
view
===
viewName
)
;
}
)
;
document
.
querySelectorAll
(
'.view-container'
)
.
forEach
(
container
=>
{
container
.
classList
.
add
(
'hidden'
)
;
}
)
;
const
targetView
=
document
.
getElementById
(
'view-'
+
viewName
)
;
if
(
targetView
)
{
targetView
.
classList
.
remove
(
'hidden'
)
;
}
const
titles
=
{
dashboard
:
'仪表盘'
,
discover
:
'数据发现'
,
visualize
:
'可视化'
,
devtools
:
'开发工具'
}
;
document
.
querySelector
(
'.view-title'
)
.
textContent
=
titles
[
viewName
]
||
viewName
;
if
(
viewName
===
'dashboard'
)
{
renderDashboard
(
)
;
}
else
if
(
viewName
===
'discover'
)
{
currentPage
=
1
;
renderDataTable
(
)
;
}
else
if
(
viewName
===
'visualize'
)
{
renderVizChart
(
)
;
}
}

仪表盘指标计算 ——从 allData 实时统计,不存在硬编码假数据:

function
computeMetrics
(
)
{
const
total
=
allData
.
length
;
const
errorCount
=
allData
.
filter
(
d
=>
d
.
status
>=
500
)
.
length
;
const
errorRate
=
total
>
0
?
(
errorCount
/
total
*
100
)
.
toFixed
(
1
)
:
'0.0'
;
const
avgLatency
=
total
>
0
?
Math
.
round
(
allData
.
reduce
(
(
sum
,
d
)
=>
sum
+
d
.
latency
,
0
)
/
total
)
:
0
;
const
uniqueServices
=
new
Set
(
allData
.
map
(
d
=>
d
.
service
)
)
.
size
;
return
{
total
,
errorRate
,
avgLatency
,
uniqueServices
}
;
}
function
computeHourlyTrend
(
)
{
const
hours
=
Array
.
from
(
{
length
:
24
}
,
(
)
=>
0
)
;
allData
.
forEach
(
d
=>
{
const
h
=
new
Date
(
d
.
timestamp
)
.
getHours
(
)
;
hours
[
h
]
++
;
}
)
;
return
hours
;
}
function
computeStatusDistribution
(
)
{
let
s2xx
=
0
,
s3xx
=
0
,
s4xx
=
0
,
s5xx
=
0
;
allData
.
forEach
(
d
=>
{
if
(
d
.
status
>=
200
&&
d
.
status
<
300
)
s2xx
++
;
else
if
(
d
.
status
>=
300
&&
d
.
status
<
400
)
s3xx
++
;
else
if
(
d
.
status
>=
400
&&
d
.
status
<
500
)
s4xx
++
;
else
if
(
d
.
status
>=
500
)
s5xx
++
;
}
)
;
return
[
s2xx
,
s3xx
,
s4xx
,
s5xx
]
;
}
function
computeServiceLatency
(
)
{
const
serviceMap
=
{
}
;
allData
.
forEach
(
d
=>
{
if
(
!
serviceMap
[
d
.
service
]
)
serviceMap
[
d
.
service
]
=
[
]
;
serviceMap
[
d
.
service
]
.
push
(
d
.
latency
)
;
}
)
;
const
labels
=
Object
.
keys
(
serviceMap
)
;
const
avgs
=
labels
.
map
(
s
=>
{
const
arr
=
serviceMap
[
s
]
;
return
Math
.
round
(
arr
.
reduce
(
(
a
,
b
)
=>
a
+
b
,
0
)
/
arr
.
length
)
;
}
)
;
return
{
labels
,
avgs
}
;
}
function
computeSourceDistribution
(
)
{
const
methodCount
=
{
}
;
allData
.
forEach
(
d
=>
{
methodCount
[
d
.
method
]
=
(
methodCount
[
d
.
method
]
||
0
)
+
1
;
}
)
;
return
{
labels
:
Object
.
keys
(
methodCount
)
,
values
:
Object
.
values
(
methodCount
)
}
;
}
| 计算函数 | 输入 | 输出 | 对应图表 |
| --- | --- | --- | --- |
| computeMetrics | allData | 总请求数/错误率/延迟/服务数 | 4 个指标卡片 |
| computeHourlyTrend | allData | 24 小时计数数组 | 折线图 |
| computeStatusDistribution | allData | 2xx/3xx/4xx/5xx 计数 | 环形图 |
| computeServiceLatency | allData | 服务名 + 平均延迟 | 柱状图 |
| computeSourceDistribution | allData | 方法名 + 计数 | 极地图 |

3.5 第五步:实现多维度聚合引擎

可视化页面支持 X 轴 × Y 轴 × 聚合方式的三维组合配置,核心聚合函数:

function
aggregateBucket
(
records
,
yAxis
,
aggType
)
{
let
vals
;
if
(
yAxis
===
'count'
)
{
return
records
.
length
;
}
else
if
(
yAxis
===
'avg_latency'
)
{
vals
=
records
.
map
(
r
=>
r
.
latency
)
;
}
else
if
(
yAxis
===
'error_rate'
)
{
if
(
records
.
length
===
0
)
return
0
;
const
errors
=
records
.
filter
(
r
=>
r
.
status
>=
400
)
.
length
;
return
Math
.
round
(
errors
/
records
.
length
*
100
)
;
}
else
{
return
records
.
length
;
}
if
(
vals
.
length
===
0
)
return
0
;
if
(
aggType
===
'avg'
)
return
Math
.
round
(
vals
.
reduce
(
(
a
,
b
)
=>
a
+
b
,
0
)
/
vals
.
length
)
;
if
(
aggType
===
'sum'
)
return
vals
.
reduce
(
(
a
,
b
)
=>
a
+
b
,
0
)
;
if
(
aggType
===
'max'
)
return
Math
.
max
(
...
vals
)
;
return
vals
.
length
;
}
| X 轴选项 | 分组方式 | 示例 |
| --- | --- | --- |
| 时间戳 | 按星期几分组 | 周日/周一/…/周六 |
| 分类 | 按服务名分组 | 网关服务/用户服务/… |
| 状态码 | 按状态码段分组 | 2xx/3xx/4xx/5xx |
| Y 轴 | 聚合=计数 | 聚合=平均 | 聚合=求和 | 聚合=最大 |
| --- | --- | --- | --- | --- |
| 计数 | 记录条数 | 记录条数 | 记录条数 | 记录条数 |
| 平均延迟 | latency 均值 | latency 均值 | latency 总和 | latency 最大值 |
| 错误率 | 错误占比% | 错误占比% | 错误占比% | 错误占比% |

3.6 第六步:实现数据发现与索引模式

数据发现页面支持三种索引模式,类似 Kibana 的 Index Pattern 概念:

function
getFilteredData
(
)
{
const
filterText
=
document
.
getElementById
(
'filterInput'
)
.
value
.
toLowerCase
(
)
;
const
indexPattern
=
getSelectValue
(
'indexSelect'
)
;
let
filtered
=
allData
;
if
(
indexPattern
===
'metrics'
)
{
filtered
=
filtered
.
filter
(
d
=>
d
.
method
===
'GET'
)
;
}
else
if
(
indexPattern
===
'traces'
)
{
filtered
=
filtered
.
filter
(
d
=>
d
.
status
>=
400
)
;
}
if
(
filterText
)
{
filtered
=
filtered
.
filter
(
log
=>
{
const
logStr
=
JSON
.
stringify
(
log
)
.
toLowerCase
(
)
;
return
logStr
.
includes
(
filterText
)
;
}
)
;
}
return
filtered
;
}
| 索引模式 | 过滤规则 | 适用场景 |
| --- | --- | --- |
| logs-* | 显示全部数据 | 全量日志浏览 |
| metrics-* | 仅显示 GET 请求 | 指标采集分析 |
| traces-* | 仅显示错误请求(status >= 400) | 错误链路追踪 |

3.7 第七步:实现开发工具查询

开发工具提供类 KQL 查询语法,支持多条件组合过滤,返回 Elasticsearch 格式的 JSON 响应:

function
executeQuery
(
)
{
const
queryText
=
document
.
getElementById
(
'queryEditor'
)
.
value
.
trim
(
)
;
const
output
=
document
.
getElementById
(
'queryOutput'
)
;
if
(
!
queryText
)
{
output
.
textContent
=
'// 请输入查询语句'
;
return
;
}
let
filtered
=
allData
;
try
{
const
statusMatch
=
queryText
.
match
(
/
status:(\d+)
/
)
;
if
(
statusMatch
)
{
filtered
=
filtered
.
filter
(
d
=>
d
.
status
===
parseInt
(
statusMatch
[
1
]
)
)
;
}
const
methodMatch
=
queryText
.
match
(
/
method:(\w+)
/
)
;
if
(
methodMatch
)
{
filtered
=
filtered
.
filter
(
d
=>
d
.
method
===
methodMatch
[
1
]
.
toUpperCase
(
)
)
;
}
const
serviceMatch
=
queryText
.
match
(
/
service:(\S+)
/
)
;
if
(
serviceMatch
)
{
filtered
=
filtered
.
filter
(
d
=>
d
.
service
.
includes
(
serviceMatch
[
1
]
)
)
;
}
}
catch
(
e
)
{
}
const
mockResponse
=
{
took
:
Math
.
floor
(
Math
.
random
(
)
*
50
)
+
5
,
timed_out
:
false
,
_shards
:
{
total
:
5
,
successful
:
5
,
skipped
:
0
,
failed
:
0
}
,
hits
:
{
total
:
{
value
:
filtered
.
length
,
relation
:
'eq'
}
,
max_score
:
1.0
,
hits
:
filtered
.
slice
(
0
,
10
)
.
map
(
(
log
,
i
)
=>
(
{
_index
:
'kibana-local-data'
,
_id
:
log
.
id
,
_score
:
(
1.0
-
i
*
0.05
)
.
toFixed
(
4
)
,
_source
:
{
timestamp
:
log
.
timestamp
,
level
:
log
.
level
,
service
:
log
.
service
,
method
:
log
.
method
,
path
:
log
.
path
,
status
:
log
.
status
,
latency
:
log
.
latency
}
}
)
)
}
}
;
output
.
textContent
=
JSON
.
stringify
(
mockResponse
,
null
,
2
)
;
}
| 条件 | 示例 | 说明 |
| --- | --- | --- |
| status:N | status:500 | 精确匹配状态码 |
| method:X | method:GET | 精确匹配 HTTP 方法 |
| service:X | service:网关 | 模糊匹配服务名 |
| 组合 | status:200 method:GET | 多条件交集 |

四、鸿蒙稳定性适配

4.1 自定义下拉框替代原生 select

鸿蒙 Electron 适配层对原生 select 下拉框支持存在限制,select 元素会触发 SubWindow 创建,导致 XComponent 创建超时 SIGABRT 崩溃。解决方案是用纯 HTML/CSS/JS 实现的自定义 div 下拉框替代所有原生 select。

<
!
--
自定义下拉框结构
--
><
div
class
=
"custom-select"
id
=
"chartTypeSelect"
><
div
class
=
"select-display"
data
-
value
=
"line"
>
折线图
<
/
div
><
div
class
=
"select-options"
><
div
class
=
"select-option"
data
-
value
=
"line"
>
折线图
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"bar"
>
柱状图
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"pie"
>
饼图
<
/
div
><
div
class
=
"select-option"
data
-
value
=
"area"
>
面积图
<
/
div
><
/
div
><
/
div
>

通过 JavaScript 控制 .select-options 的显示/隐藏,选中后更新 .select-display 的文本和数据值:

function
initCustomSelect
(
select
)
{
const
display
=
select
.
querySelector
(
'.select-display'
)
;
display
.
addEventListener
(
'click'
,
(
e
)
=>
{
e
.
stopPropagation
(
)
;
document
.
querySelectorAll
(
'.custom-select.open'
)
.
forEach
(
s
=>
{
if
(
s
!==
select
)
s
.
classList
.
remove
(
'open'
)
;
}
)
;
select
.
classList
.
toggle
(
'open'
)
;
}
)
;
select
.
querySelectorAll
(
'.select-option'
)
.
forEach
(
option
=>
{
option
.
addEventListener
(
'click'
,
(
e
)
=>
{
e
.
stopPropagation
(
)
;
display
.
textContent
=
option
.
textContent
;
display
.
dataset
.
value
=
option
.
dataset
.
value
;
select
.
classList
.
remove
(
'open'
)
;
if
(
select
.
id
===
'chartTypeSelect'
||
select
.
id
===
'xAxisField'
||
select
.
id
===
'yAxisField'
||
select
.
id
===
'aggType'
)
renderVizChart
(
)
;
if
(
select
.
id
===
'indexSelect'
)
{
currentPage
=
1
;
renderDataTable
(
)
;
}
}
)
;
}
)
;
}
| 下拉框 | 位置 | 联动效果 |
| --- | --- | --- |
| indexSelect | 数据发现 | 切换索引模式后刷新数据表 |
| chartTypeSelect | 可视化 | 切换图表类型后重绘图表 |
| xAxisField | 可视化 | 切换 X 轴后重绘图表 |
| yAxisField | 可视化 | 切换 Y 轴后重绘图表 |
| aggType | 可视化 | 切换聚合方式后重绘图表 |

4.2 自定义对话框替代原生弹窗

与 Notepadqq 一致,鸿蒙平台原生 alert/confirm 会触发 SubWindow 崩溃,Kibana 同样使用自定义 div 对话框:

function
showAlert
(
message
)
{
return
new
Promise
(
(
resolve
)
=>
{
const
overlay
=
document
.
createElement
(
'div'
)
;
overlay
.
className
=
'dialog-overlay'
;
overlay
.
innerHTML
=
''
+
''
+
message
+
''
+
''
+
'确定'
+
''
+
''
;
document
.
body
.
appendChild
(
overlay
)
;
const
okBtn
=
overlay
.
querySelector
(
'.dialog-btn-ok'
)
;
okBtn
.
addEventListener
(
'click'
,
(
)
=>
{
document
.
body
.
removeChild
(
overlay
)
;
resolve
(
)
;
}
)
;
okBtn
.
focus
(
)
;
}
)
;
}

新增记录对话框同样使用自定义 div 实现,内部包含自定义下拉框(级别、方法)和文本输入框(服务、路径、状态码、耗时),通过 Promise 返回用户输入:

async
function
showAddRecordDialog
(
)
{
const
overlay
=
document
.
createElement
(
'div'
)
;
overlay
.
className
=
'dialog-overlay'
;
overlay
.
innerHTML
=
''
+
'新增记录'
+
''
+
'级别'
+
''
+
'INFO'
+
''
+
'INFO'
+
'WARN'
+
'ERROR'
+
'DEBUG'
+
''
+
''
+
''
+
'服务'
+
'方法'
+
''
+
'GET'
+
''
+
'GET'
+
'POST'
+
'PUT'
+
'DELETE'
+
''
+
''
+
''
+
'路径'
+
'状态码'
+
'耗时(ms)'
+
''
+
''
+
'取消'
+
'确定'
+
''
+
''
;
document
.
body
.
appendChild
(
overlay
)
;
overlay
.
querySelectorAll
(
'.custom-select'
)
.
forEach
(
select
=>
{
const
display
=
select
.
querySelector
(
'.select-display'
)
;
display
.
addEventListener
(
'click'
,
(
e
)
=>
{
e
.
stopPropagation
(
)
;
overlay
.
querySelectorAll
(
'.custom-select.open'
)
.
forEach
(
s
=>
{
if
(
s
!==
select
)
s
.
classList
.
remove
(
'open'
)
;
}
)
;
select
.
classList
.
toggle
(
'open'
)
;
}
)
;
select
.
querySelectorAll
(
'.select-option'
)
.
forEach
(
option
=>
{
option
.
addEventListener
(
'click'
,
(
e
)
=>
{
e
.
stopPropagation
(
)
;
display
.
textContent
=
option
.
textContent
;
display
.
dataset
.
value
=
option
.
dataset
.
value
;
select
.
classList
.
remove
(
'open'
)
;
}
)
;
}
)
;
}
)
;
return
new
Promise
(
(
resolve
)
=>
{
overlay
.
querySelector
(
'#addCancel'
)
.
addEventListener
(
'click'
,
(
)
=>
{
document
.
body
.
removeChild
(
overlay
)
;
resolve
(
null
)
;
}
)
;
overlay
.
querySelector
(
'#addConfirm'
)
.
addEventListener
(
'click'
,
async
(
)
=>
{
const
record
=
{
level
:
getSelectValue
(
'addLevel'
)
,
service
:
overlay
.
querySelector
(
'#addService'
)
.
value
,
method
:
getSelectValue
(
'addMethod'
)
,
path
:
overlay
.
querySelector
(
'#addPath'
)
.
value
,
status
:
parseInt
(
overlay
.
querySelector
(
'#addStatus'
)
.
value
)
||
200
,
latency
:
parseInt
(
overlay
.
querySelector
(
'#addLatency'
)
.
value
)
||
100
}
;
document
.
body
.
removeChild
(
overlay
)
;
resolve
(
record
)
;
}
)
;
}
)
;
}

4.3 通用 tooltip 替代原生 title

鸿蒙平台原生 title 属性也可能触发异常,因此使用 CSS 伪元素实现自定义 tooltip:

[
data
-
tip
]
:
hover
{
position
:
relative
;
}
[
data
-
tip
]
:
hover
:
:
after
{
content
:
attr
(
data
-
tip
)
;
position
:
absolute
;
top
:
100
%
;
left
:
50
%
;
transform
:
translateX
(
-
50
%
)
;
margin
-
top
:
4px
;
padding
:
3px 8px
;
background
:
#1e1e2e
;
color
:
#d1d5db
;
font
-
size
:
11px
;
border
:
1px solid #2d2d44
;
border
-
radius
:
3px
;
white
-
space
:
nowrap
;
pointer
-
events
:
none
;
z
-
index
:
9999
;
}

五、构建与部署

5.1 同步到 web_engine

开发完成后,将 electron-apps/Kibana/ 目录的全部内容同步到 web_engine 部署目录:

# 清空旧文件
Remove
-
Item
"web_engine\src\main\resources\resfile\resources\app\*"
-
Recurse
-
Force

# 复制最新文件
Copy
-
Item
"electron-apps\Kibana\*"
`
-Destination "web_engine\src\main\resources\resfile\resources\app\"
`
-
Recurse
-
Force

注意:每次修改代码后都必须同步,否则构建的 HAP 包不会包含最新代码。

5.2 构建 HAP 包

在 DevEco Studio 中打开项目,选择 Build → Build Hap(s)/APP(s) → Build Hap(s),即可生成可安装的 HAP 包。

六、常见问题与解决方案

Q1:启动后图表不显示

问题现象:仪表盘指标卡片有数值,但图表区域空白

根本原因:Chart.js CDN 资源加载失败(网络问题或 CDN 不可达)

解决方案:检查网络连接,确保能访问 cdn.jsdelivr.net。如需离线使用,可将 chart.js 文件下载到本地并修改 HTML 中的引用路径。

Q2:点击下拉框无反应

问题现象:点击自定义下拉框的 .select-display 没有弹出选项列表

根本原因:事件冒泡导致 document 的 click 监听器立即关闭了下拉框

解决方案:在 .select-display 的 click 事件中调用 e.stopPropagation(),阻止事件冒泡到 document

Q3:新增记录后图表不更新

问题现象:通过新增对话框添加记录后,仪表盘数据没有变化

根本原因:addRecord 函数只调用了 IPC 写入,没有重新加载 allData 并刷新当前视图

解决方案:在 addRecord 中先 await loadAllData() 重新加载数据,再根据 currentView 调用对应的渲染函数

Q4:切换视图后数据丢失

问题现象:从数据发现切换到仪表盘再切回来,分页回到第 1 页

根本原因:switchView 中强制将 currentPage 重置为 1

解决方案:这是设计行为——每次进入数据发现视图都从第 1 页开始,避免用户看到过期数据。如需保留分页状态,可移除 currentPage = 1 的重置逻辑。

七、总结

本文完整记录了 Kibana 数据可视化平台在鸿蒙 PC 平台的适配过程。核心技术要点总结如下:

| 技术点 | 方案 |
| --- | --- |
| 数据源 | 本地 JSON 文件持久化(userData 目录) |
| 种子数据 | 首次启动自动生成 200 条微服务日志 |
| 图表引擎 | Chart.js 4.4.0(折线/环形/柱状/极地) |
| 聚合引擎 | X轴×Y轴×聚合方式三维组合计算 |
| 索引模式 | logs- /metrics- /traces-* 三种过滤 |
| 查询语法 | 类 KQL 条件解析 + Elasticsearch 格式响应 |
| 鸿蒙稳定性 | 三防策略 + 自定义 div 替代原生弹窗/下拉框 |
| 主题配色 | Kibana 风格深色主题 |

核心经验:鸿蒙 Electron 适配层目前对原生弹窗(confirm、alert)和原生下拉框(select)的支持存在限制,解决方案是从源头避免触发原生 SubWindow——用纯 HTML/CSS/JS 实现的自定义组件替代所有原生弹窗元素。这套"自定义组件替代"策略在 Subversion 客户端、Notepadqq 编辑器和 Kibana 仪表盘中均已验证有效。

整个适配过程遵循 Electron 壳方案标准化流程:在 electron-apps/ 开发目录中编写代码,通过同步命令部署到 web_engine/ 模块,最终由鸿蒙壳工程打包为 HAP 安装包。开发者可以专注于 Web 技术栈和数据可视化功能实现,无需关心平台差异。


转载声明:本文为转载整理,原文版权归原作者及原站点所有。

原文来源:CSDN HarmonyOS

原创地址:https://blog.csdn.net/COLLINSXU/article/details/165006731

原文作者:程序山海;如有侵权,请联系管理员删除。

上一篇: 没有了~
下一篇: 没有了~
请登录后发表评论 登录
全部评论

注册时间:2022-04-01

  • 博文量
    1
  • 访问量
    129