参考文档 https://grafana.com/tutorials/build-a-panel-plugin/

Grafana是一个跨平台、开源的数据可视化网络应用程序平台。用户配置连接的数据源之后,Grafana可以在网络浏览器里显示数据图表和警告。该软件的企业版本提供更多的扩展功能。扩展功能通过插件的形式提供,终端用户可以自定义自己的数据面板界面以及数据请求方式。

Grafana

我们最近需要使用Grafana展示一些数据多且非时序化的数据,这不是它擅长的(Tableau比较擅长,强大的缓存能力),这会导致每次展示数据,都会对服务器产生巨大的压力,因为Grafana会加载所有的数据,然后在界面上进行分页。

没有办法,只能自己写插件,这篇文章记录了插件开发过程,记录了关键的几段代码。

环境准备

操作系统:

linux、mac或者wsl

依赖软件:

yarn,NodeJS >=14,docker,docker compose

目标

创建一个可以在服务端分页的Table插件

创建项目

1
npx @grafana/create-plugin@latest

create project

填写项目名等信息

完成创建后,进入项目目录,看到下面的内容

dir content

安装依赖

1
yarn install

如果依赖安装失败,需要检查node和yarn的版本

启动插件

可以在vscode里面打开项目,进行编辑

在vscode控制台启动插件

1
yarn dev

in vscode

后面的修改可以自动反应到grafana的界面

启动Grafana

在目录下启动docker

1
docker compose up

浏览器打开 http://localhost:3000/plugins,使用My Table搜索,可以看到我们创建的插件

Plugins

点击右上角,创建一个dashborad

Dashborad

然后创建一个visualization,选择我们创建的Panel visualization

我们的Grafana插件项目就创建成功,并且可以进行预览和查看了。

开始改代码

完成上面的工作后,我们可以得到如下的界面,区域1是Panel的显示界面,区域2是Panel的配置界面,区域3是数据源: Ui

重要文件介绍

文件 描述
package.json 项目信息和依赖,我们可以修改项目信息,依赖交给yarn(yarn add / yarn remove)来管理
src/plugin.json 插件信息,可以修改名称、描述、图标等内容
src/module.ts 编写Options代码,控制Panel配置界面
src/components/SimplePanel.tsx Panel显示界面代码,可也修改成其他名字,注意修改引用
src/types.ts 主要是Options的类型定义

分析src/components/SimplePanel.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import React from 'react';
import { PanelProps } from '@grafana/data';
import { SimpleOptions } from 'types';
import { css, cx } from '@emotion/css';
import { useStyles2, useTheme2 } from '@grafana/ui';

interface Props extends PanelProps<SimpleOptions> {}

const getStyles = () => {
  return {
    wrapper: css`
      font-family: Open Sans;
      position: relative;
    `,
    svg: css`
      position: absolute;
      top: 0;
      left: 0;
    `,
    textBox: css`
      position: absolute;
      bottom: 0;
      left: 0;
      padding: 10px;
    `,
  };
};

export const SimplePanel: React.FC<Props> = ({ options, data, width, height }) => {
  const theme = useTheme2();
  const styles = useStyles2(getStyles);
  return (
    <div
      className={cx(
        styles.wrapper,
        css`
          width: ${width}px;
          height: ${height}px;
        `
      )}
    >
      <svg
        className={styles.svg}
        width={width}
        height={height}
        xmlns="http://www.w3.org/2000/svg"
        xmlnsXlink="http://www.w3.org/1999/xlink"
        viewBox={`-${width / 2} -${height / 2} ${width} ${height}`}
      >
        <g>
          <circle style={{ fill: theme.colors.primary.main }} r={100} />
        </g>
      </svg>

      <div className={styles.textBox}>
        {options.showSeriesCount && <div>Number of series: {data.series.length}</div>}
        <div>Text option value: {options.text}</div>
      </div>
    </div>
  );
};

这个文件比较简单,就是在界面上画了一个SVG,然后根据options的配置来显示底部数据。

PanelProps是Grafana给我们所有数据,我们要根据这个数据来显示界面

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

interface PanelProps<T = any> {
    /** ID of the panel within the current dashboard */
    id: number;
    /** Result set of panel queries */
    data: PanelData;
    /** Time range of the current dashboard */
    timeRange: TimeRange;
    /** Time zone of the current dashboard */
    timeZone: TimeZone;
    /** Panel options */
    options: T;
    /** Indicates whether or not panel should be rendered transparent */
    transparent: boolean;
    /** Current width of the panel */
    width: number;
    /** Current height of the panel */
    height: number;
    /** Field options configuration */
    fieldConfig: FieldConfigSource;
    /** @internal */
    renderCounter: number;
    /** Panel title */
    title: string;
    /** EventBus  */
    eventBus: EventBus;
    /** Panel options change handler */
    onOptionsChange: (options: T) => void;
    /** Field config change handler */
    onFieldConfigChange: (config: FieldConfigSource) => void;
    /** Template variables interpolation function */
    replaceVariables: InterpolateFunction;
    /** Time range change handler */
    onChangeTimeRange: (timeRange: AbsoluteTimeRange) => void;
}
属性 解释
id panel实例的Id,唯一标识
data 从数据源里query出来的数据
timeRange dashborad右上角时间范围数据
timeZone 时区,用户可以设置
options 区域2——Panel的配置界面,配置的内容
transparent 是否背景透明,在公共Panel配置界面配置(区域2的上方Panel options
width 面板当前宽度,用户拖拽调整
height 面板当前高度,用户拖拽调整
fieldConfig 数据字段配置,比如指定一列设置为图片等
renderCounter 渲染次数
title 面板标题,在公共Panel配置界面配置(区域2的上方Panel options
eventBus 事件总线,用于和外界通讯,比如Panel之间
onOptionsChange Options变化时回调
onFieldConfigChange 数据字段配置变化时回调
replaceVariables 模板变量插值函数,参数:模板字符串(列如date>'$__timeFrom()'),返回:填充数据后字符串(例如date>'2017-04-21 05:01:17')
onChangeTimeRange dashborad右上角时间范围数据变化时回调

如果我们想要在服务端SQL分页,好像没有办法使用到data里面的数据,因为在Panel运行前,数据已经被Query出来,没有给Panel这个权限。

我的思路是,抛弃Grafana数据源,另起炉灶。

配置项

首先修改src/module.ts,添加一些配置项。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
return builder
.addTextInput({
    path: 'countSql',
    name: '行数SQL',
    description: '用于查询数据行数,计算分页',
    settings: {
        rows: 2,
        useTextarea: true,
        placeholder: '请输入SQL'
    },
    defaultValue: `select count(1) cnt from product;`
})
.addTextInput({
    path: 'sql',
    name: '数据SQL',
    description: '用于查询数据内容',
    settings: {
        rows: 7,
        useTextarea: true,
        placeholder: '请输入SQL'
    },
    defaultValue: `select id, code, image from product order by id desc limit #{limit} offset #{offset};`
})
.addTextInput({
    path: 'jsonConfig',
    name: 'Json 配置',
    description: '其他配置,为了省事,直接用Json',
    defaultValue: `{
"headers": [
    {
        "label": "商品ID",
        "col": "id",
        "type": "text",
        "width": 100
    },
    {
        "label": "商品代号",
        "col": "code",
        "type": "text",
        "width": 100
    },
    {
        "label": "商品图片",
        "col": "image",
        "type": "image",
        "width": 100,
        "image": {
            "width": 80
        }
    }
]
}`,
    settings: {
        rows: 20,
        useTextarea: true,
        placeholder: '请输入Json配置'
    }
});

修改src/types.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export interface SimpleOptions {
  countSql: string;
  sql: string;
  jsonConfig: string;
}

export interface OptionsJsonConfig {
  headers: OptionsJsonConfigHeader[]
}

export interface OptionsJsonConfigHeader {
  label: string
  col: string
  type: string
  valueType: string
  width: number
  image?: OptionsJsonConfigImage
}

export interface OptionsJsonConfigImage {
  width: number
}

上面代码生效后的效果

Options

表格

表格我们使用antd。加载数据使用axios

1
2
yarn add antd
yarn add axios

表格代码src/components/SimplePanel.tsx,解释请看注释

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143

import { css, cx } from '@emotion/css';
import { PanelProps } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { Button, ConfigProvider, Spin, Table, theme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import axios from 'axios';
import React, { useEffect } from 'react';
import { OptionsJsonConfig, SimpleOptions } from 'types';

interface Props extends PanelProps<SimpleOptions> {} // PanelProps, Grafana给Panel的环境数据

const getStyles = () => { // Panel框,样式
  return {
    wrapper: css`
      font-family: Open Sans;
      position: relative;
    `,
    svg: css`
      position: absolute;
      top: 0;
      left: 0;
    `,
    textBox: css`
      position: absolute;
      bottom: 0;
      left: 0;
      padding: 10px;
    `,
  };
};

export const SimplePanel: React.FC<Props> = ({ options, data, width, height, replaceVariables }) => {
  const styles = useStyles2(getStyles);
  const { darkAlgorithm } = theme;  // 默认使用antd dark主题
  const { countSql, sql, jsonConfig } = options; // 读取配置项

  const [pageNo, setPageNo] = React.useState(1); // 分页,当前页
  const [pageSize, setPageSize] = React.useState(10); // 分页,每页行数
  const [spinning, setSpinning] = React.useState(false); // 加载中

  const [tdata, setTdata] = React.useState<any[]>([]); // 表格数据
  const [total, setTotal] = React.useState(0); // 总行数

  const sqls = { countSql: replaceVariables(countSql), sql: replaceVariables(sql) } // 配置的SQL,这里最好进行加密处理,防止前端sql注入

  const jsonConfigObj: OptionsJsonConfig = JSON.parse(jsonConfig); // 配置的jsonConfig, 主要用于表头配置

  const handleValueType = (text: string, valueType: string) => { // 处理数据类型, 1.0000 -> 1
    if (valueType === 'number') {
      return parseFloat(text).toString();
    }
    return text;
  }

  const columns: ColumnsType<any> = jsonConfigObj.headers.map((header) => { // 根据配置,生成表头
    return {
      title: header.label,
      dataIndex: header.col,
      key: header.col,
      width: header.width,
      render: (text) => <div>{header.type === 'image' ? <img src={text} style={{ width: header.image?.width ?? '40px' }} /> : <span>{handleValueType(text, header.valueType)}</span>}</div>,
    }
  })


  useEffect(() => { // 加载数据
    setSpinning(true);
    axios.post(`load_data_url`, { // 服务端实现查询数据接口
      pageNo,
      pageSize,
      sqls: sqls
    }).then((res) => {
      setSpinning(false);
      console.log('res', res);
      const d = res?.data?.Data ?? { columns: [], datas: [], total: 0 };
      const { columns, datas, total } = d;
      const tdata: any[] = (datas || []).map((row: string[]) => {
        const obj: any = {};
        (columns || []).forEach((col: string, index: number) => {
          obj[col] = row[index];
        })
        return obj;
      });

      setTdata(tdata);
      setTotal(total);

      if (pageNo > 1 && (pageNo - 1) * pageSize >= total) {
        setPageNo(1)
      }
    })
  }, [pageNo, sqls, pageSize]) // hook监控, 当pageNo, sqls, pageSize变化时,重新加载数据

  const downloadExcel = () => { // 下载excel
    setSpinning(true);
    axios.post(`gen_download_url`, { // 服务端实现查询数据并生成excel链接接口
      headers: jsonConfigObj.headers,
      sqls: sqls
    }).then((res) => {
      setSpinning(false);
      console.log('res', res);
      const d = res?.data?.data ?? { fileName: '' };
      const { fileName } = d;
      if (fileName) {
        window.location.href = ('download_前缀' + fileName); // 服务端实现下载接口
      }
    })
  }

  return ( // 渲染表格
    <div className={cx(
      styles.wrapper,
      css`
          width: ${width}px;
          height: ${height}px;
        `
    )}>

      <ConfigProvider theme={{ algorithm: darkAlgorithm }}> {/* antd主题 */}
        <Spin tip="Loading..." spinning={spinning}> {/* 加载中 */}
          <Table columns={columns} dataSource={tdata} scroll={{ y: height - 110 }} bordered={true} size='small'
            pagination={{
              position: ['topLeft'],
              pageSizeOptions: [10, 20, 50, 100],
              defaultPageSize: 10,
              total: total,
              showTotal: (total, range) => `${range[0]}-${range[1]} of ${total} items`,
              onChange: (page, pageSize) => {
                setPageNo(page);
                setPageSize(pageSize ?? 10);
              },
              showSizeChanger: true,
              size: 'small',
              showQuickJumper: true,
            }} /> {/* 表格 */}
        </Spin>
        <Button type="primary" onClick={downloadExcel} style={{ position: 'absolute', right: 20, top: 10 }} size='small'>Excel (5W行)</Button> {/* excel按钮 */}
      </ConfigProvider>
    </div>
  );
};

最终效果

完美