鸿蒙平台 Kibana 数据可视化适配实战:基于 Electron 壳方案的本地数据驱动仪表盘开发
Kibana 是 Elastic 公司开源的数据可视化平台,作为 Elastic Stack 的核心组件,广泛应用于日志分析、应用性能监控和运维可视化场景。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个本地数据驱动的 Kibana 仪表盘应用的完整过程——涵盖本地 JSON 数据持久化、Chart.js 多维度图表渲染、数据 CRUD 操作、聚合引擎、自定义下拉框防崩溃等核心功能的实现细节与踩坑经验。
欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_kibana
一、技术架构分析
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/ # Kibana 应用源码(开发目录)
│ ├── main.js # Electron 主进程(窗口管理 + 数据持久化 + IPC)
│ ├── renderer.js # 渲染进程(数据驱动渲染 + CRUD + 聚合引擎)
│ ├── index.html # HTML 页面布局(侧栏导航 + 4 个视图)
│ ├── package.json # 项目配置
│ └── styles/
│ └── kibana.css # 深色主题样式
├── web_engine/ # 鸿蒙 web_engine 模块
│ └── src/main/resources/
│ └── resfile/resources/app/ # 部署目录(构建时打包进 HAP)
│ ├── main.js
│ ├── renderer.js
│ ├── index.html
│ ├── package.json
│ └── styles/kibana.css
└── electron/ # Electron 原生库
└── libs/arm64-v8a/
├── libelectron.so # Electron 核心库
├── 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 通道和种子数据初始化。
// Kibana - 主进程
const { app, BrowserWindow, ipcMain, dialog, screen } = require('electron');
const fs = require('fs');
const path = require('path');
// 防 GPU 白屏:禁用硬件加速,降级为软件渲染
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;
// 防 XComponent 崩溃:frame: true + transparent: false + resizable: true
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);
}
// ========== 数据 CRUD IPC ==========
// 获取所有数据
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 };
}
});
IPC 通道设计:
| 通道名 | 功能 | 参数 |
|---|---|---|
| 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
{
"query": {
"match_all": {}
}
}"></textarea>
<button class="run-btn" id="runQueryBtn">▶ 执行</button>
</div>
<div class="devtools-output">
<div class="output-header">响应结果</div>
<pre class="output-content" id="queryOutput">// 执行查询后显示结果</pre>
</div>
</div>
</div>
</div>
</div>
<!-- 应用逻辑 -->
<script src="renderer.js"></script>
</body>
</html>
布局设计要点:
- 整体采用 Flex 横向布局(sidebar + main-content),主内容区纵向 Flex 分层
- 4 个视图通过 hidden 类切换显示,避免重复创建 DOM
- 所有下拉框均使用自定义 div 实现(.custom-select),避免鸿蒙平台原生 select 崩溃
- 指标卡片初始值为 0,由 renderer.js 启动后从真实数据计算填充
3.4 第四步:实现数据驱动渲染
文件:electron-apps/Kibana/renderer.js
渲染进程的核心逻辑是:从主进程加载数据到内存,所有指标和图表均从真实数据实时计算。
// Kibana - 渲染进程
const { ipcRenderer } = require('electron');
// ========== 全局状态 ==========
let currentView = 'dashboard';
let currentPage = 1;
const pageSize = 20;
let charts = {};
let vizChart = null;
let allData = []; // 从本地 JSON 存储加载的真实数据
// ========== 数据加载 ==========
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; // count fallback
}
X 轴分组逻辑支持三种维度:
| X 轴选项 | 分组方式 | 示例 |
|---|---|---|
| 时间戳 | 按星期几分组 | 周日/周一/…/周六 |
| 分类 | 按服务名分组 | 网关服务/用户服务/… |
| 状态码 | 按状态码段分组 | 2xx/3xx/4xx/5xx |
Y 轴与聚合方式组合:
| 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 {
// 支持 status:xxx 过滤
const statusMatch = queryText.match(/status:(\d+)/);
if (statusMatch) {
filtered = filtered.filter(d => d.status === parseInt(statusMatch[1]));
}
// 支持 method:xxx 过滤
const methodMatch = queryText.match(/method:(\w+)/);
if (methodMatch) {
filtered = filtered.filter(d => d.method === methodMatch[1].toUpperCase());
}
// 支持 service:xxx 过滤
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(); }
});
});
}
应用中共有 5 处使用了自定义下拉框:
| 下拉框 | 位置 | 联动效果 |
|---|---|---|
| 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 =
'<div class="dialog-box">' +
'<div class="dialog-message">' + message + '</div>' +
'<div class="dialog-buttons">' +
'<button class="dialog-btn dialog-btn-ok">确定</button>' +
'</div>' +
'</div>';
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 =
'<div class="dialog-box dialog-box-wide">' +
'<div class="dialog-title">新增记录</div>' +
'<div class="dialog-form">' +
'<div class="form-row"><label>级别</label>' +
'<div class="custom-select" id="addLevel">' +
'<div class="select-display" data-value="INFO">INFO</div>' +
'<div class="select-options">' +
'<div class="select-option" data-value="INFO">INFO</div>' +
'<div class="select-option" data-value="WARN">WARN</div>' +
'<div class="select-option" data-value="ERROR">ERROR</div>' +
'<div class="select-option" data-value="DEBUG">DEBUG</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="form-row"><label>服务</label><input type="text" class="form-input" id="addService" value="网关服务"></div>' +
'<div class="form-row"><label>方法</label>' +
'<div class="custom-select" id="addMethod">' +
'<div class="select-display" data-value="GET">GET</div>' +
'<div class="select-options">' +
'<div class="select-option" data-value="GET">GET</div>' +
'<div class="select-option" data-value="POST">POST</div>' +
'<div class="select-option" data-value="PUT">PUT</div>' +
'<div class="select-option" data-value="DELETE">DELETE</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="form-row"><label>路径</label><input type="text" class="form-input" id="addPath" value="/api/health"></div>' +
'<div class="form-row"><label>状态码</label><input type="number" class="form-input" id="addStatus" value="200"></div>' +
'<div class="form-row"><label>耗时(ms)</label><input type="number" class="form-input" id="addLatency" value="100"></div>' +
'</div>' +
'<div class="dialog-buttons">' +
'<button class="dialog-btn dialog-btn-cancel" id="addCancel">取消</button>' +
'<button class="dialog-btn dialog-btn-ok" id="addConfirm">确定</button>' +
'</div>' +
'</div>';
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:
/* 通用 tooltip(防鸿蒙原生 title 崩溃) */
[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 技术栈和数据可视化功能实现,无需关心平台差异。
更多推荐




所有评论(0)