概述
Grafana插件系统是其强大功能的核心,允许开发者扩展Grafana的功能,创建自定义数据源、面板、应用程序等。本教程将深入介绍Grafana插件开发的各个方面。
学习目标
- 理解Grafana插件架构和类型
- 掌握插件开发环境搭建
- 学会开发数据源插件
- 学会开发面板插件
- 学会开发应用插件
- 了解插件发布和分发
插件类型与架构
1. 插件类型概览
from enum import Enum
from typing import Dict, List, Any, Optional
from dataclasses import dataclass
import json
import uuid
from datetime import datetime
class PluginType(Enum):
"""插件类型枚举"""
DATASOURCE = "datasource"
PANEL = "panel"
APP = "app"
RENDERER = "renderer"
SECRETSMANAGER = "secretsmanager"
class PluginState(Enum):
"""插件状态枚举"""
ALPHA = "alpha"
BETA = "beta"
STABLE = "stable"
DEPRECATED = "deprecated"
class PluginSignatureStatus(Enum):
"""插件签名状态枚举"""
INTERNAL = "internal"
VALID = "valid"
INVALID = "invalid"
MODIFIED = "modified"
UNSIGNED = "unsigned"
@dataclass
class PluginInfo:
"""插件信息数据类"""
id: str
name: str
type: PluginType
version: str
description: str
author: str
keywords: List[str]
screenshots: List[str]
logos: Dict[str, str]
links: List[Dict[str, str]]
dependencies: Dict[str, str]
state: PluginState
signature: PluginSignatureStatus
updated: str
class PluginManager:
"""插件管理器"""
def __init__(self):
self.plugins = {}
self.plugin_configs = {}
self.plugin_templates = {}
def get_plugin_types(self) -> Dict[str, Dict]:
"""获取支持的插件类型"""
return {
"datasource": {
"description": "数据源插件,用于连接和查询外部数据源",
"capabilities": [
"query", "annotations", "metrics", "logs", "streaming"
],
"examples": [
"prometheus", "mysql", "elasticsearch", "influxdb"
]
},
"panel": {
"description": "面板插件,用于创建自定义可视化组件",
"capabilities": [
"visualization", "editor", "field_options", "transformations"
],
"examples": [
"graph", "table", "stat", "gauge", "heatmap"
]
},
"app": {
"description": "应用插件,提供完整的应用程序功能",
"capabilities": [
"pages", "config", "includes", "dependencies"
],
"examples": [
"kubernetes-app", "worldmap-panel", "clock-panel"
]
},
"renderer": {
"description": "渲染器插件,用于图像渲染和导出",
"capabilities": [
"png_export", "pdf_export", "custom_rendering"
],
"examples": [
"image-renderer", "phantomjs-renderer"
]
}
}
def create_plugin_scaffold(self, plugin_type: PluginType,
plugin_name: str, plugin_id: str) -> Dict:
"""创建插件脚手架"""
scaffold = {
"plugin_json": self._generate_plugin_json(plugin_type, plugin_name, plugin_id),
"package_json": self._generate_package_json(plugin_name, plugin_id),
"webpack_config": self._generate_webpack_config(),
"tsconfig": self._generate_tsconfig(),
"source_structure": self._generate_source_structure(plugin_type),
"build_scripts": self._generate_build_scripts(),
"documentation": self._generate_documentation_template(plugin_type)
}
return scaffold
def _generate_plugin_json(self, plugin_type: PluginType,
plugin_name: str, plugin_id: str) -> Dict:
"""生成plugin.json配置"""
base_config = {
"type": plugin_type.value,
"name": plugin_name,
"id": plugin_id,
"info": {
"description": f"Custom {plugin_type.value} plugin",
"author": {
"name": "Your Name",
"url": "https://your-website.com"
},
"keywords": [plugin_type.value, "grafana", "plugin"],
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"links": [
{"name": "Website", "url": "https://github.com/your-org/your-plugin"},
{"name": "License", "url": "https://github.com/your-org/your-plugin/blob/master/LICENSE"}
],
"screenshots": [],
"version": "1.0.0",
"updated": datetime.now().strftime("%Y-%m-%d")
},
"dependencies": {
"grafanaDependency": ">=8.0.0",
"plugins": []
}
}
# 根据插件类型添加特定配置
if plugin_type == PluginType.DATASOURCE:
base_config.update({
"backend": True,
"executable": f"{plugin_id}",
"queryOptions": {
"maxDataPoints": True,
"minInterval": True
},
"alerting": True,
"annotations": True,
"metrics": True,
"logs": False,
"streaming": False
})
elif plugin_type == PluginType.PANEL:
base_config.update({
"skipDataQuery": False
})
elif plugin_type == PluginType.APP:
base_config.update({
"includes": [
{
"type": "page",
"name": "Main",
"path": "/plugins/" + plugin_id,
"role": "Viewer",
"addToNav": True,
"defaultNav": True
}
]
})
return base_config
def _generate_package_json(self, plugin_name: str, plugin_id: str) -> Dict:
"""生成package.json配置"""
return {
"name": plugin_id,
"version": "1.0.0",
"description": f"Grafana {plugin_name} plugin",
"scripts": {
"build": "webpack -c ./.config/webpack/webpack.config.ts --env production",
"dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development",
"test": "jest --watch --onlyChanged",
"test:ci": "jest --passWithNoTests --maxWorkers 4",
"typecheck": "tsc --noEmit",
"lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .",
"lint:fix": "npm run lint -- --fix",
"e2e": "npm exec playwright test",
"e2e:update": "npm exec playwright test --update-snapshots",
"server": "docker-compose up --build",
"sign": "npx --yes @grafana/sign-plugin@latest"
},
"author": "Your Name",
"license": "Apache-2.0",
"devDependencies": {
"@grafana/data": "latest",
"@grafana/runtime": "latest",
"@grafana/ui": "latest",
"@grafana/toolkit": "latest",
"@types/lodash": "latest",
"typescript": "latest",
"webpack": "latest",
"@swc/core": "latest",
"@swc/jest": "latest",
"jest": "latest",
"eslint": "latest"
},
"engines": {
"node": ">=16"
}
}
def _generate_webpack_config(self) -> str:
"""生成Webpack配置"""
return """
import type { Configuration } from 'webpack';
import { merge } from 'webpack-merge';
import grafanaConfig from './.config/webpack/webpack.config';
const config = async (env): Promise<Configuration> => {
const baseConfig = await grafanaConfig(env);
return merge(baseConfig, {
// Add custom webpack config here
});
};
export default config;
"""
def _generate_tsconfig(self) -> Dict:
"""生成TypeScript配置"""
return {
"compilerOptions": {
"target": "ES2015",
"lib": ["ES2015", "DOM"],
"allowJs": False,
"skipLibCheck": True,
"esModuleInterop": True,
"allowSyntheticDefaultImports": True,
"strict": True,
"forceConsistentCasingInFileNames": True,
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": True,
"isolatedModules": True,
"noEmit": True,
"jsx": "react-jsx",
"declaration": True,
"types": ["jest", "node"]
},
"include": ["src", "types"],
"exclude": ["dist", "node_modules"]
}
def _generate_source_structure(self, plugin_type: PluginType) -> Dict:
"""生成源码结构"""
base_structure = {
"src/": {
"module.ts": "插件主模块",
"plugin.json": "插件配置文件",
"README.md": "插件说明文档",
"img/": {
"logo.svg": "插件图标"
},
"types.ts": "TypeScript类型定义"
},
".config/": {
"webpack/": {
"webpack.config.ts": "Webpack配置"
}
},
"tests/": {
"__mocks__/": "测试模拟文件",
"datasource.test.ts": "数据源测试"
}
}
if plugin_type == PluginType.DATASOURCE:
base_structure["src/"].update({
"datasource.ts": "数据源实现",
"ConfigEditor.tsx": "配置编辑器组件",
"QueryEditor.tsx": "查询编辑器组件",
"VariableQueryEditor.tsx": "变量查询编辑器",
"AnnotationQueryEditor.tsx": "注释查询编辑器"
})
elif plugin_type == PluginType.PANEL:
base_structure["src/"].update({
"SimplePanel.tsx": "面板组件",
"SimpleEditor.tsx": "面板编辑器",
"FieldDisplay.tsx": "字段显示组件"
})
elif plugin_type == PluginType.APP:
base_structure["src/"].update({
"components/": {
"App.tsx": "应用主组件",
"ConfigEditor.tsx": "配置编辑器",
"ExamplePage.tsx": "示例页面"
},
"pages/": {
"PageOne.tsx": "页面一",
"PageTwo.tsx": "页面二"
}
})
return base_structure
def _generate_build_scripts(self) -> Dict:
"""生成构建脚本"""
return {
"docker-compose.yml": """
version: '3.8'
services:
grafana:
container_name: 'grafana-dev'
build:
context: ./.config
args:
grafana_image: grafana/grafana:8.5.0
grafana_version: 8.5.0
ports:
- 3000:3000/tcp
volumes:
- ./dist:/var/lib/grafana/plugins/your-plugin
- ./provisioning:/etc/grafana/provisioning
environment:
- TERM=linux
- GF_LOG_LEVEL=debug
- GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=your-plugin-id
""",
"Makefile": """
.PHONY: build clean install test
build:
npm run build
dev:
npm run dev
clean:
rm -rf dist/
install:
npm install
test:
npm run test:ci
lint:
npm run lint
sign:
npm run sign
server:
npm run server
"""
}
def _generate_documentation_template(self, plugin_type: PluginType) -> str:
"""生成文档模板"""
return f"""
# {plugin_type.value.title()} Plugin
## Overview
This is a custom Grafana {plugin_type.value} plugin.
## Installation
### From Grafana UI
1. Go to Configuration > Plugins
2. Search for your plugin
3. Click Install
### Manual Installation
1. Download the plugin
2. Extract to Grafana plugins directory
3. Restart Grafana
## Configuration
### Plugin Configuration
Describe how to configure the plugin...
### Data Source Configuration (if applicable)
Describe data source specific configuration...
## Usage
Describe how to use the plugin...
## Development
### Prerequisites
- Node.js 16+
- npm or yarn
- Docker (optional)
### Setup
```bash
npm install
npm run dev
Building
npm run build
Testing
npm run test
Contributing
Contributions are welcome! Please read the contributing guidelines.
License
Apache 2.0 License “””
def validate_plugin_structure(self, plugin_path: str) -> Dict:
"""验证插件结构"""
validation_result = {
"valid": True,
"errors": [],
"warnings": [],
"suggestions": []
}
required_files = [
"plugin.json",
"module.ts",
"package.json"
]
recommended_files = [
"README.md",
"CHANGELOG.md",
"LICENSE",
"img/logo.svg"
]
# 检查必需文件
for file in required_files:
# 这里应该检查文件是否存在
# if not os.path.exists(os.path.join(plugin_path, file)):
# validation_result["errors"].append(f"Missing required file: {file}")
# validation_result["valid"] = False
pass
# 检查推荐文件
for file in recommended_files:
# if not os.path.exists(os.path.join(plugin_path, file)):
# validation_result["warnings"].append(f"Missing recommended file: {file}")
pass
# 添加建议
validation_result["suggestions"].extend([
"Add comprehensive unit tests",
"Include integration tests",
"Add proper error handling",
"Implement proper logging",
"Add performance monitoring"
])
return validation_result
def generate_plugin_manifest(self, plugin_info: PluginInfo) -> Dict:
"""生成插件清单"""
manifest = {
"plugin": {
"id": plugin_info.id,
"name": plugin_info.name,
"type": plugin_info.type.value,
"version": plugin_info.version,
"description": plugin_info.description,
"author": plugin_info.author,
"keywords": plugin_info.keywords,
"state": plugin_info.state.value,
"signature": plugin_info.signature.value,
"updated": plugin_info.updated
},
"assets": {
"screenshots": plugin_info.screenshots,
"logos": plugin_info.logos
},
"links": plugin_info.links,
"dependencies": plugin_info.dependencies,
"build_info": {
"build_timestamp": datetime.now().isoformat(),
"build_hash": f"build_{uuid.uuid4().hex[:8]}",
"grafana_version": ">=8.0.0"
}
}
return manifest
使用示例
plugin_manager = PluginManager()
获取插件类型信息
plugin_types = plugin_manager.get_plugin_types() print(“支持的插件类型:”) for ptype, info in plugin_types.items(): print(f”- {ptype}: {info[‘description’]}“)
创建数据源插件脚手架
scaffold = plugin_manager.create_plugin_scaffold( PluginType.DATASOURCE, “My Custom DataSource”, “mycustom-datasource” )
print(“\n数据源插件脚手架已创建”) print(f”Plugin JSON: {json.dumps(scaffold[‘plugin_json’], indent=2)[:200]}…“)
创建插件信息
plugin_info = PluginInfo( id=“mycustom-datasource”, name=“My Custom DataSource”, type=PluginType.DATASOURCE, version=“1.0.0”, description=“A custom data source plugin for Grafana”, author=“Your Name”, keywords=[“datasource”, “custom”, “api”], screenshots=[“img/screenshot1.png”, “img/screenshot2.png”], logos={“small”: “img/logo.svg”, “large”: “img/logo.svg”}, links=[ {“name”: “GitHub”, “url”: “https://github.com/your-org/your-plugin”}, {“name”: “Documentation”, “url”: “https://your-plugin-docs.com”} ], dependencies={“grafanaDependency”: “>=8.0.0”}, state=PluginState.BETA, signature=PluginSignatureStatus.UNSIGNED, updated=datetime.now().strftime(“%Y-%m-%d”) )
生成插件清单
manifest = plugin_manager.generate_plugin_manifest(plugin_info) print(f”\n插件清单已生成: {manifest[‘plugin’][‘name’]} v{manifest[‘plugin’][‘version’]}“)
## 数据源插件开发
### 1. 数据源插件架构
```python
class DataSourcePlugin:
"""数据源插件基类"""
def __init__(self, plugin_id: str, name: str):
self.plugin_id = plugin_id
self.name = name
self.config = {}
self.query_types = []
self.capabilities = set()
def create_datasource_implementation(self) -> str:
"""创建数据源实现代码"""
return f"""
import {{
DataQueryRequest,
DataQueryResponse,
DataSourceApi,
DataSourceInstanceSettings,
MutableDataFrame,
FieldType,
}} from '@grafana/data';
import {{ MyQuery, MyDataSourceOptions, DEFAULT_QUERY }} from './types';
export class DataSource extends DataSourceApi<MyQuery, MyDataSourceOptions> {{
url?: string;
constructor(instanceSettings: DataSourceInstanceSettings<MyDataSourceOptions>) {{
super(instanceSettings);
this.url = instanceSettings.url;
}}
async query(options: DataQueryRequest<MyQuery>): Promise<DataQueryResponse> {{
const {{ range }} = options;
const from = range!.from.valueOf();
const to = range!.to.valueOf();
const promises = options.targets.map(async (target) => {{
const query = {{ ...DEFAULT_QUERY, ...target }};
if (query.hide) {{
return new MutableDataFrame();
}}
// 实现查询逻辑
const data = await this.doRequest(query, from, to);
return new MutableDataFrame({{
refId: query.refId,
fields: [
{{ name: 'Time', type: FieldType.time, values: data.timestamps }},
{{ name: 'Value', type: FieldType.number, values: data.values }},
],
}});
}});
return Promise.all(promises).then((data) => ({{ data }}));
}}
async doRequest(query: MyQuery, from: number, to: number) {{
// 实现具体的数据请求逻辑
const response = await fetch(`${{this.url}}/api/query`, {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
}},
body: JSON.stringify({{
query: query.queryText,
from,
to,
}}),
}});
return response.json();
}}
async testDatasource() {{
// 实现数据源连接测试
try {{
const response = await fetch(`${{this.url}}/api/health`);
if (response.ok) {{
return {{
status: 'success',
message: 'Data source is working',
}};
}}
throw new Error('Health check failed');
}} catch (error) {{
return {{
status: 'error',
message: `Cannot connect to API: ${{error}}`,
}};
}}
}}
async metricFindQuery(query: string): Promise<any[]> {{
// 实现变量查询
const response = await fetch(`${{this.url}}/api/search?q=${{encodeURIComponent(query)}}`);
const data = await response.json();
return data.map((item: any) => ({{ text: item.name, value: item.id }}));
}}
getDefaultQuery(): Partial<MyQuery> {{
return DEFAULT_QUERY;
}}
}}
"""
def create_config_editor(self) -> str:
"""创建配置编辑器组件"""
return f"""
import React, {{ ChangeEvent, PureComponent }} from 'react';
import {{ LegacyForms }} from '@grafana/ui';
import {{ DataSourcePluginOptionsEditorProps }} from '@grafana/data';
import {{ MyDataSourceOptions, MySecureJsonData }} from '../types';
const {{ SecretFormField, FormField }} = LegacyForms;
interface Props extends DataSourcePluginOptionsEditorProps<MyDataSourceOptions> {{}}
interface State {{}}
export class ConfigEditor extends PureComponent<Props, State> {{
onURLChange = (event: ChangeEvent<HTMLInputElement>) => {{
const {{ onOptionsChange, options }} = this.props;
const jsonData = {{
...options.jsonData,
url: event.target.value,
}};
onOptionsChange({{ ...options, jsonData }});
}};
onAPIKeyChange = (event: ChangeEvent<HTMLInputElement>) => {{
const {{ onOptionsChange, options }} = this.props;
onOptionsChange({{
...options,
secureJsonData: {{
apiKey: event.target.value,
}},
}});
}};
onResetAPIKey = () => {{
const {{ onOptionsChange, options }} = this.props;
onOptionsChange({{
...options,
secureJsonFields: {{
...options.secureJsonFields,
apiKey: false,
}},
secureJsonData: {{
...options.secureJsonData,
apiKey: '',
}},
}});
}};
render() {{
const {{ options }} = this.props;
const {{ jsonData, secureJsonFields }} = options;
const secureJsonData = (options.secureJsonData || {{}}) as MySecureJsonData;
return (
<div className="gf-form-group">
<div className="gf-form">
<FormField
label="URL"
labelWidth={{6}}
inputWidth={{20}}
onChange={{this.onURLChange}}
value={{jsonData.url || ''}}
placeholder="http://localhost:8080"
/>
</div>
<div className="gf-form-inline">
<div className="gf-form">
<SecretFormField
isConfigured={{(secureJsonFields && secureJsonFields.apiKey) as boolean}}
value={{secureJsonData.apiKey || ''}}
label="API Key"
placeholder="Enter API key"
labelWidth={{6}}
inputWidth={{20}}
onReset={{this.onResetAPIKey}}
onChange={{this.onAPIKeyChange}}
/>
</div>
</div>
</div>
);
}}
}}
"""
def create_query_editor(self) -> str:
"""创建查询编辑器组件"""
return f"""
import React, {{ ChangeEvent, PureComponent }} from 'react';
import {{ LegacyForms }} from '@grafana/ui';
import {{ QueryEditorProps }} from '@grafana/data';
import {{ DataSource }} from '../datasource';
import {{ MyDataSourceOptions, MyQuery }} from '../types';
const {{ FormField }} = LegacyForms;
type Props = QueryEditorProps<DataSource, MyQuery, MyDataSourceOptions>;
export class QueryEditor extends PureComponent<Props> {{
onQueryTextChange = (event: ChangeEvent<HTMLInputElement>) => {{
const {{ onChange, query }} = this.props;
onChange({{ ...query, queryText: event.target.value }});
}};
onConstantChange = (event: ChangeEvent<HTMLInputElement>) => {{
const {{ onChange, query, onRunQuery }} = this.props;
onChange({{ ...query, constant: parseFloat(event.target.value) }});
onRunQuery();
}};
render() {{
const {{ query }} = this.props;
const {{ queryText, constant }} = query;
return (
<div className="gf-form">
<FormField
width={{4}}
value={{constant}}
onChange={{this.onConstantChange}}
label="Constant"
type="number"
step="0.1"
/>
<FormField
labelWidth={{8}}
value={{queryText || ''}}
onChange={{this.onQueryTextChange}}
label="Query Text"
tooltip="Enter your query here"
/>
</div>
);
}}
}}
"""
def create_types_definition(self) -> str:
"""创建类型定义"""
return f"""
import {{ DataQuery, DataSourceJsonData }} from '@grafana/data';
export interface MyQuery extends DataQuery {{
queryText?: string;
constant: number;
}}
export const DEFAULT_QUERY: Partial<MyQuery> = {{
constant: 6.5,
}};
export interface MyDataSourceOptions extends DataSourceJsonData {{
url?: string;
timeout?: number;
}}
export interface MySecureJsonData {{
apiKey?: string;
}}
export interface MyVariableQuery {{
namespace: string;
rawQuery: string;
}}
"""
def create_module_file(self) -> str:
"""创建模块文件"""
return f"""
import {{ DataSourcePlugin }} from '@grafana/data';
import {{ DataSource }} from './datasource';
import {{ ConfigEditor }} from './components/ConfigEditor';
import {{ QueryEditor }} from './components/QueryEditor';
import {{ MyQuery, MyDataSourceOptions }} from './types';
export const plugin = new DataSourcePlugin<DataSource, MyQuery, MyDataSourceOptions>(DataSource)
.setConfigEditor(ConfigEditor)
.setQueryEditor(QueryEditor);
"""
def create_backend_implementation(self) -> str:
"""创建后端实现(Go语言)"""
return f"""
package main
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
// Make sure Datasource implements required interfaces.
var (
_ backend.QueryDataHandler = (*Datasource)(nil)
_ backend.CheckHealthHandler = (*Datasource)(nil)
_ instancemgmt.InstanceDisposer = (*Datasource)(nil)
)
// NewDatasource creates a new datasource instance.
func NewDatasource(_ backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {{
return &Datasource{{}}, nil
}}
// Datasource is an example datasource which can respond to data queries.
type Datasource struct {{}}
// Dispose here tells plugin SDK that plugin wants to clean up resources when a new instance
// created. As soon as datasource settings change detected by SDK old datasource instance will
// be disposed and a new one will be created using NewSampleDatasource factory function.
func (d *Datasource) Dispose() {{
// Clean up datasource instance resources.
}}
// QueryData handles multiple queries and returns multiple responses.
func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {{
log.DefaultLogger.Info("QueryData called", "request", req)
// create response struct
response := backend.NewQueryDataResponse()
// loop over queries and execute them individually.
for _, q := range req.Queries {{
res := d.query(ctx, req.PluginContext, q)
// save the response in a hashmap
// based on with RefID as identifier
response.Responses[q.RefID] = res
}}
return response, nil
}}
type queryModel struct {{
QueryText string `json:"queryText"`
Constant float64 `json:"constant"`
}}
func (d *Datasource) query(_ context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse {{
response := backend.DataResponse{{}}
// Unmarshal the JSON into our queryModel.
var qm queryModel
response.Error = json.Unmarshal(query.JSON, &qm)
if response.Error != nil {{
return response
}}
// create data frame response.
frame := data.NewFrame("response")
// add fields.
frame.Fields = append(frame.Fields,
data.NewField("time", nil, []time.Time{{query.TimeRange.From, query.TimeRange.To}}),
data.NewField("values", nil, []float64{{qm.Constant, qm.Constant * 2}}),
)
// add the frames to the response.
response.Frames = append(response.Frames, frame)
return response
}}
// CheckHealth handles health checks sent from Grafana to the plugin.
func (d *Datasource) CheckHealth(_ context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {{
log.DefaultLogger.Info("CheckHealth called", "request", req)
var status = backend.HealthStatusOk
var message = "Data source is working"
// Perform health check logic here
// You can test connectivity to your data source
return &backend.CheckHealthResult{{
Status: status,
Message: message,
}}, nil
}}
func main() {{
// Start listening to requests sent from Grafana. This call is blocking so
// it won't finish until Grafana shuts down the process or the plugin choose
// to exit by itself using os.Exit. Manage automatically manages life cycle
// of datasource instances. It accepts datasource instance factory as first
// argument. This factory will be called when new datasource instance requested
// by Grafana.
if err := backend.Manage("my-datasource-plugin", NewDatasource, backend.ManageOpts{{}}); err != nil {{
log.DefaultLogger.Error(err.Error())
}}
}}
"""
def generate_test_suite(self) -> str:
"""生成测试套件"""
return f"""
import {{ DataSource }} from '../datasource';
import {{ createMockInstanceSettings }} from './__mocks__/datasource';
import {{ of }} from 'rxjs';
const mockInstanceSettings = createMockInstanceSettings();
describe('DataSource', () => {{
let datasource: DataSource;
beforeEach(() => {{
datasource = new DataSource(mockInstanceSettings);
}});
describe('query', () => {{
it('should return data frames', async () => {{
const query = {{
targets: [
{{
refId: 'A',
queryText: 'test query',
constant: 10,
}},
],
range: {{
from: new Date('2021-01-01'),
to: new Date('2021-01-02'),
}},
}} as any;
const result = await datasource.query(query);
expect(result.data).toHaveLength(1);
expect(result.data[0].fields).toHaveLength(2);
expect(result.data[0].fields[0].name).toBe('Time');
expect(result.data[0].fields[1].name).toBe('Value');
}});
}});
describe('testDatasource', () => {{
it('should return success status', async () => {{
// Mock successful API response
global.fetch = jest.fn(() =>
Promise.resolve({{
ok: true,
json: () => Promise.resolve({{ status: 'ok' }}),
}})
) as jest.Mock;
const result = await datasource.testDatasource();
expect(result.status).toBe('success');
expect(result.message).toBe('Data source is working');
}});
it('should return error status on failure', async () => {{
// Mock failed API response
global.fetch = jest.fn(() =>
Promise.reject(new Error('Network error'))
) as jest.Mock;
const result = await datasource.testDatasource();
expect(result.status).toBe('error');
expect(result.message).toContain('Cannot connect to API');
}});
}});
describe('metricFindQuery', () => {{
it('should return metric options', async () => {{
const mockResponse = [
{{ name: 'metric1', id: 'metric1' }},
{{ name: 'metric2', id: 'metric2' }},
];
global.fetch = jest.fn(() =>
Promise.resolve({{
json: () => Promise.resolve(mockResponse),
}})
) as jest.Mock;
const result = await datasource.metricFindQuery('test');
expect(result).toHaveLength(2);
expect(result[0]).toEqual({{ text: 'metric1', value: 'metric1' }});
expect(result[1]).toEqual({{ text: 'metric2', value: 'metric2' }});
}});
}});
}});
"""
# 使用示例
datasource_plugin = DataSourcePlugin("mycustom-datasource", "My Custom DataSource")
# 生成数据源实现代码
datasource_code = datasource_plugin.create_datasource_implementation()
print("数据源实现代码已生成")
# 生成配置编辑器
config_editor_code = datasource_plugin.create_config_editor()
print("配置编辑器代码已生成")
# 生成查询编辑器
query_editor_code = datasource_plugin.create_query_editor()
print("查询编辑器代码已生成")
# 生成类型定义
types_code = datasource_plugin.create_types_definition()
print("类型定义代码已生成")
# 生成模块文件
module_code = datasource_plugin.create_module_file()
print("模块文件代码已生成")
# 生成后端实现
backend_code = datasource_plugin.create_backend_implementation()
print("后端实现代码已生成")
# 生成测试套件
test_code = datasource_plugin.generate_test_suite()
print("测试套件代码已生成")
面板插件开发
1. 面板插件架构
class PanelPlugin:
"""面板插件基类"""
def __init__(self, plugin_id: str, name: str):
self.plugin_id = plugin_id
self.name = name
self.panel_options = {}
self.field_options = {}
self.supported_data_formats = []
def create_panel_component(self) -> str:
"""创建面板组件"""
return f"""
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);
// 处理数据
const frame = data.series[0];
const values = frame?.fields.find((field) => field.type === 'number')?.values.toArray() || [];
const lastValue = values[values.length - 1] || 0;
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={{`0 0 ${{width}} ${{height}}`}}
>
<g>
{/* 绘制可视化内容 */}
<circle
cx={{width / 2}}
cy={{height / 2}}
r={{Math.min(width, height) / 4}}
fill={{options.color}}
stroke={{theme.colors.border.medium}}
strokeWidth={{2}}
/>
<text
x={{width / 2}}
y={{height / 2}}
textAnchor="middle"
dominantBaseline="middle"
fontSize={{options.fontSize}}
fill={{theme.colors.text.primary}}
>
{{lastValue.toFixed(2)}}
</text>
</g>
</svg>
{{options.showSeriesCount && (
<div className={{styles.textBox}}>
<span>Series count: {{data.series.length}}</span>
</div>
)}}
</div>
);
}};
"""
def create_panel_editor(self) -> str:
"""创建面板编辑器"""
return f"""
import React from 'react';
import {{ PanelEditorProps }} from '@grafana/data';
import {{ SimpleOptions }} from '../types';
import {{ Field, Input, Switch, ColorPicker, Slider }} from '@grafana/ui';
export const SimpleEditor: React.FC<PanelEditorProps<SimpleOptions>> = ({{ options, onOptionsChange }}) => {{
const onColorChange = (color: string) => {{
onOptionsChange({{
...options,
color,
}});
}};
const onFontSizeChange = (fontSize: number) => {{
onOptionsChange({{
...options,
fontSize,
}});
}};
const onShowSeriesCountChange = (showSeriesCount: boolean) => {{
onOptionsChange({{
...options,
showSeriesCount,
}});
}};
const onTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {{
onOptionsChange({{
...options,
text: event.target.value,
}});
}};
return (
<>
<Field label="Display text">
<Input
width={{40}}
value={{options.text}}
onChange={{onTextChange}}
placeholder="Enter display text"
/>
</Field>
<Field label="Color">
<ColorPicker
color={{options.color}}
onChange={{onColorChange}}
/>
</Field>
<Field label="Font size">
<Slider
min={{10}}
max={{100}}
value={{options.fontSize}}
onChange={{onFontSizeChange}}
/>
</Field>
<Field label="Show series count">
<Switch
value={{options.showSeriesCount}}
onChange={{onShowSeriesCountChange}}
/>
</Field>
</>
);
}};
"""
def create_panel_types(self) -> str:
"""创建面板类型定义"""
return f"""
export interface SimpleOptions {{
text: string;
color: string;
fontSize: number;
showSeriesCount: boolean;
}}
export const defaults: SimpleOptions = {{
text: 'Default text',
color: 'green',
fontSize: 16,
showSeriesCount: false,
}};
export interface FieldDisplayOptions {{
displayName?: string;
unit?: string;
decimals?: number;
min?: number;
max?: number;
color?: {{
mode: string;
value?: string;
}};
}}
export interface PanelData {{
values: number[];
timestamps: number[];
labels?: string[];
}}
"""
def create_panel_module(self) -> str:
"""创建面板模块"""
return f"""
import {{ PanelPlugin }} from '@grafana/data';
import {{ SimpleOptions, defaults }} from './types';
import {{ SimplePanel }} from './SimplePanel';
import {{ SimpleEditor }} from './SimpleEditor';
export const plugin = new PanelPlugin<SimpleOptions>(SimplePanel)
.setPanelOptions((builder) => {{
return builder
.addTextInput({{
path: 'text',
name: 'Display text',
description: 'Text to display in the panel',
defaultValue: defaults.text,
}})
.addColorPicker({{
path: 'color',
name: 'Color',
description: 'Color of the visualization',
defaultValue: defaults.color,
}})
.addSliderInput({{
path: 'fontSize',
name: 'Font size',
description: 'Font size for the text',
defaultValue: defaults.fontSize,
settings: {{
min: 10,
max: 100,
step: 1,
}},
}})
.addBooleanSwitch({{
path: 'showSeriesCount',
name: 'Show series count',
description: 'Show the number of series in the panel',
defaultValue: defaults.showSeriesCount,
}});
}})
.setPanelChangeHandler((panel, prevPluginId, prevOptions) => {{
// 处理面板迁移逻辑
if (prevPluginId === 'old-panel-id') {{
return {{
...panel,
options: {{
...defaults,
text: prevOptions.oldText || defaults.text,
}},
}};
}}
return panel;
}})
.setNoPadding();
"""
def create_advanced_panel_features(self) -> str:
"""创建高级面板功能"""
return f"""
import React, {{ useMemo }} from 'react';
import {{
PanelProps,
Field,
FieldType,
getFieldDisplayName,
formattedValueToString,
getDisplayProcessor,
}} from '@grafana/data';
import {{ useTheme2 }} from '@grafana/ui';
import {{ AdvancedOptions }} from '../types';
interface Props extends PanelProps<AdvancedOptions> {{}}
export const AdvancedPanel: React.FC<Props> = ({{ options, data, width, height, fieldConfig, timeZone }}) => {{
const theme = useTheme2();
// 处理字段配置
const processedData = useMemo(() => {{
if (!data.series.length) {{
return [];
}}
return data.series.map((series) => {{
return series.fields.map((field) => {{
const displayName = getFieldDisplayName(field, series, data.series);
const processor = getDisplayProcessor({{
field,
theme,
timeZone,
}});
return {{
name: displayName,
values: field.values.toArray().map((value) => {{
const processed = processor(value);
return {{
raw: value,
formatted: formattedValueToString(processed),
color: processed.color,
}};
}}),
config: field.config,
}};
}});
}}).flat();
}}, [data, theme, timeZone]);
// 数据转换
const transformedData = useMemo(() => {{
if (!processedData.length) return [];
switch (options.transformType) {{
case 'aggregate':
return processedData.map((field) => {{
const values = field.values.map(v => v.raw).filter(v => typeof v === 'number');
const sum = values.reduce((a, b) => a + b, 0);
const avg = sum / values.length;
const min = Math.min(...values);
const max = Math.max(...values);
return {{
...field,
aggregated: {{ sum, avg, min, max, count: values.length }},
}};
}});
case 'filter':
return processedData.map((field) => {{
const filtered = field.values.filter((value) => {{
const numValue = typeof value.raw === 'number' ? value.raw : 0;
return numValue >= (options.filterMin || 0) && numValue <= (options.filterMax || 100);
}});
return {{ ...field, values: filtered }};
}});
default:
return processedData;
}}
}}, [processedData, options]);
// 渲染逻辑
const renderVisualization = () => {{
switch (options.visualizationType) {{
case 'bar':
return renderBarChart();
case 'line':
return renderLineChart();
case 'scatter':
return renderScatterPlot();
default:
return renderTable();
}}
}};
const renderBarChart = () => {{
const barWidth = width / Math.max(transformedData.length, 1);
const maxValue = Math.max(...transformedData.flatMap(f => f.values.map(v => v.raw as number)));
return (
<svg width={{width}} height={{height}}>
{{transformedData.map((field, index) => {{
const value = field.values[field.values.length - 1]?.raw as number || 0;
const barHeight = (value / maxValue) * (height - 40);
return (
<g key={{index}}>
<rect
x={{index * barWidth + 10}}
y={{height - barHeight - 20}}
width={{barWidth - 20}}
height={{barHeight}}
fill={{field.values[field.values.length - 1]?.color || theme.colors.primary.main}}
stroke={{theme.colors.border.medium}}
/>
<text
x={{index * barWidth + barWidth / 2}}
y={{height - 5}}
textAnchor="middle"
fontSize={{12}}
fill={{theme.colors.text.primary}}
>
{{field.name}}
</text>
</g>
);
}})}}
</svg>
);
}};
const renderLineChart = () => {{
if (!transformedData.length) return null;
const points = transformedData[0].values.map((value, index) => {{
const x = (index / (transformedData[0].values.length - 1)) * (width - 40) + 20;
const y = height - ((value.raw as number) / 100) * (height - 40) - 20;
return `${{x}},${{y}}`;
}}).join(' ');
return (
<svg width={{width}} height={{height}}>
<polyline
points={{points}}
fill="none"
stroke={{theme.colors.primary.main}}
strokeWidth={{2}}
/>
{{transformedData[0].values.map((value, index) => {{
const x = (index / (transformedData[0].values.length - 1)) * (width - 40) + 20;
const y = height - ((value.raw as number) / 100) * (height - 40) - 20;
return (
<circle
key={{index}}
cx={{x}}
cy={{y}}
r={{3}}
fill={{value.color || theme.colors.primary.main}}
/>
);
}})}}
</svg>
);
}};
const renderScatterPlot = () => {{
if (transformedData.length < 2) return null;
const xField = transformedData[0];
const yField = transformedData[1];
const minLength = Math.min(xField.values.length, yField.values.length);
return (
<svg width={{width}} height={{height}}>
{{Array.from({{ length: minLength }}).map((_, index) => {{
const x = ((xField.values[index].raw as number) / 100) * (width - 40) + 20;
const y = height - ((yField.values[index].raw as number) / 100) * (height - 40) - 20;
return (
<circle
key={{index}}
cx={{x}}
cy={{y}}
r={{5}}
fill={{yField.values[index].color || theme.colors.primary.main}}
opacity={{0.7}}
/>
);
}})}}
</svg>
);
}};
const renderTable = () => {{
return (
<div style={{{{ width, height, overflow: 'auto' }}}}>
<table style={{{{ width: '100%', borderCollapse: 'collapse' }}}}>
<thead>
<tr>
{{transformedData.map((field, index) => (
<th
key={{index}}
style={{{{
padding: '8px',
borderBottom: `1px solid ${{theme.colors.border.medium}}`,
color: theme.colors.text.primary,
}}}}
>
{{field.name}}
</th>
))}}
</tr>
</thead>
<tbody>
{{transformedData[0]?.values.map((_, rowIndex) => (
<tr key={{rowIndex}}>
{{transformedData.map((field, colIndex) => (
<td
key={{colIndex}}
style={{{{
padding: '8px',
borderBottom: `1px solid ${{theme.colors.border.weak}}`,
color: field.values[rowIndex]?.color || theme.colors.text.primary,
}}}}
>
{{field.values[rowIndex]?.formatted || 'N/A'}}
</td>
))}}
</tr>
))}}
</tbody>
</table>
</div>
);
}};
return (
<div style={{{{ width, height, position: 'relative' }}}}>
{{renderVisualization()}}
{{options.showLegend && (
<div
style={{{{
position: 'absolute',
top: 10,
right: 10,
background: theme.colors.background.secondary,
padding: '8px',
borderRadius: '4px',
border: `1px solid ${{theme.colors.border.medium}}`,
}}}}
>
{{transformedData.map((field, index) => (
<div key={{index}} style={{{{ display: 'flex', alignItems: 'center', marginBottom: '4px' }}}}>
<div
style={{{{
width: '12px',
height: '12px',
backgroundColor: field.values[0]?.color || theme.colors.primary.main,
marginRight: '8px',
borderRadius: '2px',
}}}}
/>
<span style={{{{ color: theme.colors.text.primary, fontSize: '12px' }}}}>
{{field.name}}
</span>
</div>
))}}
</div>
)}}
</div>
);
}};
"""
# 使用示例
panel_plugin = PanelPlugin("mycustom-panel", "My Custom Panel")
# 生成面板组件
panel_component = panel_plugin.create_panel_component()
print("面板组件代码已生成")
# 生成面板编辑器
panel_editor = panel_plugin.create_panel_editor()
print("面板编辑器代码已生成")
# 生成面板类型定义
panel_types = panel_plugin.create_panel_types()
print("面板类型定义已生成")
# 生成面板模块
panel_module = panel_plugin.create_panel_module()
print("面板模块代码已生成")
# 生成高级面板功能
advanced_features = panel_plugin.create_advanced_panel_features()
print("高级面板功能代码已生成")
应用插件开发
1. 应用插件架构
class AppPlugin:
"""应用插件基类"""
def __init__(self, plugin_id: str, name: str):
self.plugin_id = plugin_id
self.name = name
self.pages = []
self.includes = []
self.config_pages = []
def create_app_component(self) -> str:
"""创建应用主组件"""
return f"""
import React from 'react';
import {{ AppRootProps }} from '@grafana/data';
import {{ Route, Switch }} from 'react-router-dom';
import {{ prefixRoute }} from 'utils/utils';
import {{ ExamplePage }} from './pages/ExamplePage';
import {{ ConfigPage }} from './pages/ConfigPage';
import {{ DashboardPage }} from './pages/DashboardPage';
export class App extends React.PureComponent<AppRootProps> {{
render() {{
const {{ path, onNavChanged, meta }} = this.props;
return (
<Switch>
<Route
path={{prefixRoute(`${{path}}/config`, meta.baseUrl)}}
component={{() => (
<ConfigPage
plugin={{meta}}
onNavChanged={{onNavChanged}}
/>
)}}
/>
<Route
path={{prefixRoute(`${{path}}/dashboard`, meta.baseUrl)}}
component={{() => (
<DashboardPage
plugin={{meta}}
onNavChanged={{onNavChanged}}
/>
)}}
/>
<Route
path={{prefixRoute(`${{path}}`, meta.baseUrl)}}
component={{() => (
<ExamplePage
plugin={{meta}}
onNavChanged={{onNavChanged}}
/>
)}}
/>
</Switch>
);
}}
}}
"""
def create_config_page(self) -> str:
"""创建配置页面"""
return f"""
import React, {{ PureComponent }} from 'react';
import {{ AppPluginMeta, PluginConfigPageProps }} from '@grafana/data';
import {{ Button, Field, Input, Switch, Alert, VerticalGroup }} from '@grafana/ui';
import {{ getBackendSrv }} from '@grafana/runtime';
export interface AppSettings {{
apiUrl?: string;
apiKey?: string;
enableFeatureX?: boolean;
refreshInterval?: number;
}}
interface Props extends PluginConfigPageProps<AppPluginMeta<AppSettings>> {{}}
interface State {{
settings: AppSettings;
isLoading: boolean;
testResult?: {{
status: 'success' | 'error';
message: string;
}};
}}
export class ConfigPage extends PureComponent<Props, State> {{
constructor(props: Props) {{
super(props);
this.state = {{
settings: {{
apiUrl: props.plugin.jsonData?.apiUrl || '',
apiKey: props.plugin.secureJsonData?.apiKey || '',
enableFeatureX: props.plugin.jsonData?.enableFeatureX || false,
refreshInterval: props.plugin.jsonData?.refreshInterval || 30,
}},
isLoading: false,
}};
}}
onApiUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {{
this.setState({{
settings: {{
...this.state.settings,
apiUrl: event.target.value,
}},
}});
}};
onApiKeyChange = (event: React.ChangeEvent<HTMLInputElement>) => {{
this.setState({{
settings: {{
...this.state.settings,
apiKey: event.target.value,
}},
}});
}};
onFeatureToggle = (enableFeatureX: boolean) => {{
this.setState({{
settings: {{
...this.state.settings,
enableFeatureX,
}},
}});
}};
onRefreshIntervalChange = (event: React.ChangeEvent<HTMLInputElement>) => {{
this.setState({{
settings: {{
...this.state.settings,
refreshInterval: parseInt(event.target.value, 10),
}},
}});
}};
onSave = async () => {{
this.setState({{ isLoading: true }});
try {{
const {{ settings }} = this.state;
await getBackendSrv().post(`/api/plugins/${{this.props.plugin.id}}/settings`, {{
enabled: this.props.plugin.enabled,
pinned: this.props.plugin.pinned,
jsonData: {{
apiUrl: settings.apiUrl,
enableFeatureX: settings.enableFeatureX,
refreshInterval: settings.refreshInterval,
}},
secureJsonData: {{
apiKey: settings.apiKey,
}},
}});
// 重新加载页面以应用新设置
window.location.reload();
}} catch (error) {{
console.error('Failed to save settings:', error);
}} finally {{
this.setState({{ isLoading: false }});
}}
}};
onTest = async () => {{
this.setState({{ isLoading: true, testResult: undefined }});
try {{
const {{ settings }} = this.state;
const response = await fetch(`${{settings.apiUrl}}/health`, {{
headers: {{
'Authorization': `Bearer ${{settings.apiKey}}`,
}},
}});
if (response.ok) {{
this.setState({{
testResult: {{
status: 'success',
message: 'Connection successful!',
}},
}});
}} else {{
throw new Error(`HTTP ${{response.status}}: ${{response.statusText}}`);
}}
}} catch (error) {{
this.setState({{
testResult: {{
status: 'error',
message: `Connection failed: ${{error.message}}`,
}},
}});
}} finally {{
this.setState({{ isLoading: false }});
}}
}};
render() {{
const {{ settings, isLoading, testResult }} = this.state;
return (
<div>
<h2>App Configuration</h2>
<VerticalGroup spacing="lg">
<Field label="API URL" description="The base URL for the external API">
<Input
width={{40}}
value={{settings.apiUrl}}
onChange={{this.onApiUrlChange}}
placeholder="https://api.example.com"
/>
</Field>
<Field label="API Key" description="Authentication key for the API">
<Input
width={{40}}
type="password"
value={{settings.apiKey}}
onChange={{this.onApiKeyChange}}
placeholder="Enter API key"
/>
</Field>
<Field label="Enable Feature X" description="Enable experimental feature X">
<Switch
value={{settings.enableFeatureX}}
onChange={{this.onFeatureToggle}}
/>
</Field>
<Field label="Refresh Interval (seconds)" description="How often to refresh data">
<Input
width={{20}}
type="number"
value={{settings.refreshInterval}}
onChange={{this.onRefreshIntervalChange}}
min={{5}}
max={{3600}}
/>
</Field>
{{testResult && (
<Alert
title={{testResult.status === 'success' ? 'Test Successful' : 'Test Failed'}}
severity={{testResult.status === 'success' ? 'success' : 'error'}}
>
{{testResult.message}}
</Alert>
)}}
<div>
<Button
variant="secondary"
onClick={{this.onTest}}
disabled={{isLoading || !settings.apiUrl || !settings.apiKey}}
>
Test Connection
</Button>
<Button
variant="primary"
onClick={{this.onSave}}
disabled={{isLoading}}
style={{{{ marginLeft: '8px' }}}}
>
Save
</Button>
</div>
</VerticalGroup>
</div>
);
}}
}}
"""
def create_example_page(self) -> str:
"""创建示例页面"""
return f"""
import React, {{ PureComponent }} from 'react';
import {{ AppPluginMeta, NavModel }} from '@grafana/data';
import {{ Page, Alert, Button, Card, VerticalGroup, HorizontalGroup }} from '@grafana/ui';
import {{ getBackendSrv }} from '@grafana/runtime';
import {{ AppSettings }} from './ConfigPage';
interface Props {{
plugin: AppPluginMeta<AppSettings>;
onNavChanged: (nav: NavModel) => void;
}}
interface State {{
data: any[];
isLoading: boolean;
error?: string;
}}
export class ExamplePage extends PureComponent<Props, State> {{
constructor(props: Props) {{
super(props);
this.state = {{
data: [],
isLoading: false,
}};
}}
componentDidMount() {{
this.props.onNavChanged({{
text: 'Example App',
subTitle: 'Grafana plugin example application',
url: '',
children: [
{{
text: 'Home',
url: '',
active: true,
}},
{{
text: 'Dashboard',
url: '/dashboard',
}},
{{
text: 'Configuration',
url: '/config',
}},
],
}});
this.loadData();
}}
loadData = async () => {{
this.setState({{ isLoading: true, error: undefined }});
try {{
const {{ plugin }} = this.props;
const apiUrl = plugin.jsonData?.apiUrl;
const apiKey = plugin.secureJsonData?.apiKey;
if (!apiUrl || !apiKey) {{
throw new Error('API URL and API Key must be configured');
}}
const response = await fetch(`${{apiUrl}}/data`, {{
headers: {{
'Authorization': `Bearer ${{apiKey}}`,
'Content-Type': 'application/json',
}},
}});
if (!response.ok) {{
throw new Error(`HTTP ${{response.status}}: ${{response.statusText}}`);
}}
const data = await response.json();
this.setState({{ data }});
}} catch (error) {{
this.setState({{ error: error.message }});
}} finally {{
this.setState({{ isLoading: false }});
}}
}};
render() {{
const {{ data, isLoading, error }} = this.state;
const {{ plugin }} = this.props;
return (
<Page navModel={{{{ text: 'Example App' }}}}>
<Page.Contents>
<VerticalGroup spacing="lg">
<div>
<h1>Welcome to {{plugin.name}}</h1>
<p>This is an example Grafana app plugin demonstrating various features.</p>
</div>
{{error && (
<Alert title="Error" severity="error">
{{error}}
<br />
<Button variant="secondary" size="sm" onClick={{this.loadData}}>
Retry
</Button>
</Alert>
)}}
<Card>
<Card.Heading>Data Overview</Card.Heading>
<Card.Actions>
<Button
variant="secondary"
onClick={{this.loadData}}
disabled={{isLoading}}
>
Refresh
</Button>
</Card.Actions>
{{isLoading ? (
<div>Loading...</div>
) : (
<div>
<p>Total records: {{data.length}}</p>
{{data.length > 0 && (
<div>
<h4>Sample Data:</h4>
<pre style={{{{ background: '#f5f5f5', padding: '10px', borderRadius: '4px' }}}}>
{{JSON.stringify(data.slice(0, 3), null, 2)}}
</pre>
</div>
)}}
</div>
)}}
</Card>
<Card>
<Card.Heading>Quick Actions</Card.Heading>
<HorizontalGroup spacing="md">
<Button variant="primary" onClick={{() => window.open('/dashboard/new', '_blank')}}>
Create Dashboard
</Button>
<Button variant="secondary" onClick={{() => window.open('/datasources/new', '_blank')}}>
Add Data Source
</Button>
<Button variant="secondary" onClick={{() => window.open('/alerting', '_blank')}}>
Manage Alerts
</Button>
</HorizontalGroup>
</Card>
<Card>
<Card.Heading>Plugin Information</Card.Heading>
<div>
<p><strong>Version:</strong> {{plugin.info.version}}</p>
<p><strong>Author:</strong> {{plugin.info.author.name}}</p>
<p><strong>Description:</strong> {{plugin.info.description}}</p>
<p><strong>Updated:</strong> {{plugin.info.updated}}</p>
</div>
</Card>
</VerticalGroup>
</Page.Contents>
</Page>
);
}}
}}
"""
def create_app_module(self) -> str:
"""创建应用模块"""
return f"""
import {{ AppPlugin }} from '@grafana/data';
import {{ App }} from './components/App';
import {{ ConfigPage }} from './components/ConfigPage';
import {{ AppSettings }} from './components/ConfigPage';
export const plugin = new AppPlugin<AppSettings>()
.setRootPage(App)
.addConfigPage({{
title: 'Configuration',
icon: 'cog',
body: ConfigPage,
id: 'configuration',
}})
.configureExtensionLink({{
title: 'Example App',
description: 'Open the example application',
extensionPointId: 'grafana/dashboard/panel/menu',
configure: () => {{
return {{
title: 'Open in Example App',
description: 'View this data in the example application',
path: '/a/example-app',
}};
}},
}});
"""
def create_app_types(self) -> str:
"""创建应用类型定义"""
return f"""
import {{ DataSourceJsonData }} from '@grafana/data';
export interface AppSettings extends DataSourceJsonData {{
apiUrl?: string;
enableFeatureX?: boolean;
refreshInterval?: number;
}}
export interface AppSecureSettings {{
apiKey?: string;
}}
export interface AppState {{
isConfigured: boolean;
lastSync?: string;
errorCount: number;
}}
export interface NavigationItem {{
text: string;
url: string;
icon?: string;
active?: boolean;
children?: NavigationItem[];
}}
export interface PageProps {{
plugin: any;
onNavChanged: (nav: any) => void;
}}
"""
# 使用示例
app_plugin = AppPlugin("example-app", "Example App")
# 生成应用组件
app_component = app_plugin.create_app_component()
print("应用组件代码已生成")
# 生成配置页面
config_page = app_plugin.create_config_page()
print("配置页面代码已生成")
# 生成示例页面
example_page = app_plugin.create_example_page()
print("示例页面代码已生成")
# 生成应用模块
app_module = app_plugin.create_app_module()
print("应用模块代码已生成")
# 生成应用类型定义
app_types = app_plugin.create_app_types()
print("应用类型定义已生成")
插件发布与分发
1. 插件签名与发布
class PluginPublisher:
"""插件发布管理器"""
def __init__(self):
self.signing_config = {}
self.distribution_channels = []
self.release_process = []
def create_signing_workflow(self) -> Dict:
"""创建签名工作流"""
return {
"github_actions": """
name: Sign and Release Plugin
on:
push:
tags:
- 'v*'
jobs:
sign-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npm run build
- name: Test plugin
run: npm run test:ci
- name: Sign plugin
run: npx --yes @grafana/sign-plugin@latest
env:
GRAFANA_API_KEY: ${{ secrets.GRAFANA_API_KEY }}
- name: Package plugin
run: |
mv dist ${{ github.event.repository.name }}
zip -r ${{ github.event.repository.name }}-${{ github.ref_name }}.zip ${{ github.event.repository.name }}
- name: Create Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
draft: false
prerelease: false
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./${{ github.event.repository.name }}-${{ github.ref_name }}.zip
asset_name: ${{ github.event.repository.name }}-${{ github.ref_name }}.zip
asset_content_type: application/zip
""",
"manual_signing": """
# 手动签名步骤
1. 构建插件
npm run build
2. 测试插件
npm run test:ci
3. 签名插件
npx @grafana/sign-plugin@latest
4. 验证签名
npx @grafana/sign-plugin@latest --verify
5. 打包插件
zip -r my-plugin.zip dist/
""",
"docker_signing": """
# Dockerfile for plugin signing
FROM node:16-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm run test:ci
RUN npx @grafana/sign-plugin@latest
CMD ["npm", "run", "package"]
"""
}
def create_distribution_config(self) -> Dict:
"""创建分发配置"""
return {
"grafana_catalog": {
"description": "官方Grafana插件目录",
"requirements": [
"插件必须经过签名",
"通过安全审查",
"符合质量标准",
"提供完整文档"
],
"submission_process": [
"在GitHub上创建插件仓库",
"添加plugin.json配置文件",
"提交到Grafana插件目录",
"等待审查和批准"
]
},
"private_registry": {
"description": "私有插件注册表",
"setup_steps": [
"配置私有npm注册表",
"设置访问权限",
"配置Grafana插件目录",
"部署插件包"
],
"nginx_config": """
server {
listen 80;
server_name plugins.company.com;
location /plugins/ {
alias /var/www/plugins/;
autoindex on;
autoindex_format json;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type";
}
location /api/plugins {
proxy_pass http://plugin-registry:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
"""
},
"docker_registry": {
"description": "Docker容器分发",
"dockerfile": """
FROM grafana/grafana:latest
USER root
COPY dist/ /var/lib/grafana/plugins/my-plugin/
RUN chown -R grafana:grafana /var/lib/grafana/plugins/
USER grafana
ENV GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=my-plugin
""",
"docker_compose": """
version: '3.8'
services:
grafana:
build: .
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=my-plugin
volumes:
- grafana-data:/var/lib/grafana
volumes:
grafana-data:
"""
}
}
def create_release_checklist(self) -> List[str]:
"""创建发布检查清单"""
return [
"✅ 代码审查完成",
"✅ 所有测试通过",
"✅ 文档更新完成",
"✅ 版本号更新",
"✅ CHANGELOG.md更新",
"✅ 插件签名完成",
"✅ 安全扫描通过",
"✅ 性能测试通过",
"✅ 兼容性测试完成",
"✅ 用户验收测试通过",
"✅ 发布说明准备",
"✅ 回滚计划制定",
"✅ 监控告警配置",
"✅ 用户通知发送"
]
def generate_plugin_metadata(self, plugin_info: PluginInfo) -> Dict:
"""生成插件元数据"""
return {
"id": plugin_info.id,
"name": plugin_info.name,
"type": plugin_info.type.value,
"version": plugin_info.version,
"description": plugin_info.description,
"author": plugin_info.author,
"keywords": plugin_info.keywords,
"repository": {
"type": "git",
"url": f"https://github.com/your-org/{plugin_info.id}"
},
"bugs": {
"url": f"https://github.com/your-org/{plugin_info.id}/issues"
},
"homepage": f"https://github.com/your-org/{plugin_info.id}#readme",
"license": "Apache-2.0",
"grafana": {
"dependency": ">=8.0.0",
"type": plugin_info.type.value,
"id": plugin_info.id
},
"dist": {
"tarball": f"https://github.com/your-org/{plugin_info.id}/releases/download/v{plugin_info.version}/{plugin_info.id}-{plugin_info.version}.zip"
},
"engines": {
"grafana": ">=8.0.0",
"node": ">=16.0.0"
}
}
def create_update_mechanism(self) -> Dict:
"""创建更新机制"""
return {
"auto_update_check": """
// 自动更新检查
const checkForUpdates = async () => {
try {
const response = await fetch('/api/plugins/my-plugin/updates');
const updateInfo = await response.json();
if (updateInfo.hasUpdate) {
showUpdateNotification(updateInfo);
}
} catch (error) {
console.error('Failed to check for updates:', error);
}
};
// 每24小时检查一次更新
setInterval(checkForUpdates, 24 * 60 * 60 * 1000);
""",
"migration_script": """
// 数据迁移脚本
const migratePluginData = (oldVersion, newVersion) => {
const migrations = {
'1.0.0': (data) => {
// 从0.x版本迁移到1.0.0
return {
...data,
newField: 'defaultValue'
};
},
'2.0.0': (data) => {
// 从1.x版本迁移到2.0.0
return {
...data,
settings: {
...data.config,
newSetting: true
}
};
}
};
let currentData = data;
const versionsToMigrate = getVersionsBetween(oldVersion, newVersion);
for (const version of versionsToMigrate) {
if (migrations[version]) {
currentData = migrations[version](currentData);
}
}
return currentData;
};
""",
"rollback_strategy": """
// 回滚策略
const rollbackPlugin = async (targetVersion) => {
try {
// 1. 备份当前配置
const currentConfig = await getPluginConfig();
await backupConfig(currentConfig);
// 2. 下载目标版本
const targetPackage = await downloadPluginVersion(targetVersion);
// 3. 停止当前插件
await stopPlugin();
// 4. 安装目标版本
await installPlugin(targetPackage);
// 5. 迁移配置(如果需要)
const migratedConfig = await migrateConfigDown(currentConfig, targetVersion);
await setPluginConfig(migratedConfig);
// 6. 启动插件
await startPlugin();
// 7. 验证回滚
const health = await checkPluginHealth();
if (!health.ok) {
throw new Error('Plugin health check failed after rollback');
}
return { success: true, version: targetVersion };
} catch (error) {
console.error('Rollback failed:', error);
return { success: false, error: error.message };
}
};
"""
}
# 使用示例
publisher = PluginPublisher()
# 创建签名工作流
signing_workflow = publisher.create_signing_workflow()
print("签名工作流已创建")
# 创建分发配置
distribution_config = publisher.create_distribution_config()
print("分发配置已创建")
# 生成发布检查清单
release_checklist = publisher.create_release_checklist()
print(f"发布检查清单包含 {len(release_checklist)} 项")
# 生成插件元数据
plugin_info = PluginInfo(
id="example-plugin",
name="Example Plugin",
type=PluginType.PANEL,
version="1.0.0",
description="An example Grafana plugin",
author="Your Name",
keywords=["panel", "visualization"],
screenshots=[],
logos={"small": "img/logo.svg", "large": "img/logo.svg"},
links=[],
dependencies={"grafanaDependency": ">=8.0.0"},
state=PluginState.STABLE,
signature=PluginSignatureStatus.VALID,
updated=datetime.now().strftime("%Y-%m-%d")
)
metadata = publisher.generate_plugin_metadata(plugin_info)
print("插件元数据已生成")
# 创建更新机制
update_mechanism = publisher.create_update_mechanism()
print("更新机制已创建")
总结
关键要点
插件类型理解
- 数据源插件:连接外部数据源
- 面板插件:创建自定义可视化
- 应用插件:提供完整应用功能
- 渲染器插件:处理图像渲染
开发环境搭建
- 使用官方脚手架工具
- 配置TypeScript和Webpack
- 设置测试环境
- 配置开发服务器
插件架构设计
- 模块化组件结构
- 类型安全的TypeScript
- 响应式UI设计
- 性能优化考虑
最佳实践
代码质量
- 遵循TypeScript最佳实践
- 编写全面的单元测试
- 使用ESLint和Prettier
- 实施代码审查流程
用户体验
- 提供直观的配置界面
- 实现响应式设计
- 添加加载状态和错误处理
- 支持主题切换
性能优化
- 实现数据缓存机制
- 优化渲染性能
- 减少不必要的重新渲染
- 使用虚拟化技术
安全考虑
- 验证用户输入
- 安全存储敏感信息
- 实施访问控制
- 定期安全审计
下一步学习建议
深入学习Grafana API
- 研究Grafana数据模型
- 掌握插件生命周期
- 学习高级配置选项
扩展功能开发
- 集成第三方库
- 开发复杂可视化
- 实现实时数据流
社区参与
- 贡献开源插件
- 参与Grafana社区讨论
- 分享开发经验
持续改进
- 收集用户反馈
- 监控插件性能
- 定期更新依赖
- 跟进Grafana新特性
通过本教程的学习,你应该能够: - 理解Grafana插件开发的核心概念 - 搭建完整的插件开发环境 - 开发各种类型的Grafana插件 - 实施插件发布和维护流程 - 遵循插件开发的最佳实践
继续实践和探索,你将能够创建出功能强大、用户友好的Grafana插件!