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 + ReduxHTML/CSS/Vanilla JavaScript
数据源Elasticsearch 后端本地 JSON 文件持久化
图表引擎Vega / D3.jsChart.js 4.4.0
查询语言KQL / Lucene自研类 KQL 条件解析
部署方式Docker / npmHAP 包(鸿蒙应用)
运行环境浏览器鸿蒙 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
{
  &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">// 执行查询后显示结果</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) };
}

数据驱动渲染模型

计算函数输入输出对应图表
computeMetricsallData总请求数/错误率/延迟/服务数4 个指标卡片
computeHourlyTrendallData24 小时计数数组折线图
computeStatusDistributionallData2xx/3xx/4xx/5xx 计数环形图
computeServiceLatencyallData服务名 + 平均延迟柱状图
computeSourceDistributionallData方法名 + 计数极地图

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:Nstatus:500精确匹配状态码
method:Xmethod:GET精确匹配 HTTP 方法
service:Xservice:网关模糊匹配服务名
组合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 技术栈和数据可视化功能实现,无需关心平台差异。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐