├── .config ├── .cprc.json ├── .eslintrc ├── .prettierrc.js ├── Dockerfile ├── README.md ├── entrypoint.sh ├── jest-setup.js ├── jest.config.js ├── jest │ ├── mocks │ │ └── react-inlinesvg.tsx │ └── utils.js ├── supervisord │ └── supervisord.conf ├── tsconfig.json ├── types │ └── custom.d.ts └── webpack │ ├── constants.ts │ ├── utils.ts │ └── webpack.config.ts ├── .cprc.json ├── .eslintrc ├── .github └── workflows │ └── package.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── CHANGELOG.md ├── DEV.md ├── LICENSE ├── README.md ├── docker-compose.yaml ├── jest-setup.js ├── jest.config.js ├── package.json ├── package.sh ├── src ├── ErrorDisplay.tsx ├── PanelOptionCode.tsx ├── PlotlyChart.tsx ├── SimplePanel.tsx ├── dataUtils.ts ├── img │ ├── plotly-large.png │ └── plotly-small.png ├── migrations.ts ├── module.test.ts ├── module.ts ├── panel.css ├── plugin.json ├── screenshots │ ├── editor.png │ └── panel.png ├── types.ts ├── useChartConfig.ts ├── useScriptEvaluation.ts └── utils.js ├── tsconfig.json └── yarn.lock /.config/.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5.3.10" 3 | } 4 | -------------------------------------------------------------------------------- /.config/.eslintrc: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-eslint-config 6 | */ 7 | { 8 | "extends": ["@grafana/eslint-config"], 9 | "root": true, 10 | "rules": { 11 | "react/prop-types": "off" 12 | }, 13 | "overrides": [ 14 | { 15 | "plugins": ["deprecation"], 16 | "files": ["src/**/*.{ts,tsx}"], 17 | "rules": { 18 | "deprecation/deprecation": "warn" 19 | }, 20 | "parserOptions": { 21 | "project": "./tsconfig.json" 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.config/.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | module.exports = { 8 | endOfLine: 'auto', 9 | printWidth: 120, 10 | trailingComma: 'es5', 11 | semi: true, 12 | jsxSingleQuote: false, 13 | singleQuote: true, 14 | useTabs: false, 15 | tabWidth: 2, 16 | }; 17 | -------------------------------------------------------------------------------- /.config/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG grafana_version=latest 2 | ARG grafana_image=grafana-enterprise 3 | 4 | FROM grafana/${grafana_image}:${grafana_version} 5 | 6 | ARG development=false 7 | ARG TARGETARCH 8 | 9 | 10 | ENV DEV "${development}" 11 | 12 | # Make it as simple as possible to access the grafana instance for development purposes 13 | # Do NOT enable these settings in a public facing / production grafana instance 14 | ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin" 15 | ENV GF_AUTH_ANONYMOUS_ENABLED "true" 16 | ENV GF_AUTH_BASIC_ENABLED "false" 17 | # Set development mode so plugins can be loaded without the need to sign 18 | ENV GF_DEFAULT_APP_MODE "development" 19 | 20 | 21 | LABEL maintainer="Grafana Labs " 22 | 23 | ENV GF_PATHS_HOME="/usr/share/grafana" 24 | WORKDIR $GF_PATHS_HOME 25 | 26 | USER root 27 | 28 | # Installing supervisor and inotify-tools 29 | RUN if [ "${development}" = "true" ]; then \ 30 | if grep -i -q alpine /etc/issue; then \ 31 | apk add supervisor inotify-tools git; \ 32 | elif grep -i -q ubuntu /etc/issue; then \ 33 | DEBIAN_FRONTEND=noninteractive && \ 34 | apt-get update && \ 35 | apt-get install -y supervisor inotify-tools git && \ 36 | rm -rf /var/lib/apt/lists/*; \ 37 | else \ 38 | echo 'ERROR: Unsupported base image' && /bin/false; \ 39 | fi \ 40 | fi 41 | 42 | COPY supervisord/supervisord.conf /etc/supervisor.d/supervisord.ini 43 | COPY supervisord/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 44 | 45 | 46 | 47 | # Inject livereload script into grafana index.html 48 | RUN sed -i 's|||g' /usr/share/grafana/public/views/index.html 49 | 50 | 51 | COPY entrypoint.sh /entrypoint.sh 52 | RUN chmod +x /entrypoint.sh 53 | ENTRYPOINT ["/entrypoint.sh"] 54 | -------------------------------------------------------------------------------- /.config/README.md: -------------------------------------------------------------------------------- 1 | # Default build configuration by Grafana 2 | 3 | **This is an auto-generated directory and is not intended to be changed! ⚠️** 4 | 5 | The `.config/` directory holds basic configuration for the different tools 6 | that are used to develop, test and build the project. In order to make it updates easier we ask you to 7 | not edit files in this folder to extend configuration. 8 | 9 | ## How to extend the basic configs? 10 | 11 | Bear in mind that you are doing it at your own risk, and that extending any of the basic configuration can lead 12 | to issues around working with the project. 13 | 14 | ### Extending the ESLint config 15 | 16 | Edit the `.eslintrc` file in the project root in order to extend the ESLint configuration. 17 | 18 | **Example:** 19 | 20 | ```json 21 | { 22 | "extends": "./.config/.eslintrc", 23 | "rules": { 24 | "react/prop-types": "off" 25 | } 26 | } 27 | ``` 28 | 29 | --- 30 | 31 | ### Extending the Prettier config 32 | 33 | Edit the `.prettierrc.js` file in the project root in order to extend the Prettier configuration. 34 | 35 | **Example:** 36 | 37 | ```javascript 38 | module.exports = { 39 | // Prettier configuration provided by Grafana scaffolding 40 | ...require('./.config/.prettierrc.js'), 41 | 42 | semi: false, 43 | }; 44 | ``` 45 | 46 | --- 47 | 48 | ### Extending the Jest config 49 | 50 | There are two configuration in the project root that belong to Jest: `jest-setup.js` and `jest.config.js`. 51 | 52 | **`jest-setup.js`:** A file that is run before each test file in the suite is executed. We are using it to 53 | set up the Jest DOM for the testing library and to apply some polyfills. ([link to Jest docs](https://jestjs.io/docs/configuration#setupfilesafterenv-array)) 54 | 55 | **`jest.config.js`:** The main Jest configuration file that extends the Grafana recommended setup. ([link to Jest docs](https://jestjs.io/docs/configuration)) 56 | 57 | #### ESM errors with Jest 58 | 59 | A common issue with the current jest config involves importing an npm package that only offers an ESM build. These packages cause jest to error with `SyntaxError: Cannot use import statement outside a module`. To work around this, we provide a list of known packages to pass to the `[transformIgnorePatterns](https://jestjs.io/docs/configuration#transformignorepatterns-arraystring)` jest configuration property. If need be, this can be extended in the following way: 60 | 61 | ```javascript 62 | process.env.TZ = 'UTC'; 63 | const { grafanaESModules, nodeModulesToTransform } = require('./config/jest/utils'); 64 | 65 | module.exports = { 66 | // Jest configuration provided by Grafana 67 | ...require('./.config/jest.config'), 68 | // Inform jest to only transform specific node_module packages. 69 | transformIgnorePatterns: [nodeModulesToTransform([...grafanaESModules, 'packageName'])], 70 | }; 71 | ``` 72 | 73 | --- 74 | 75 | ### Extending the TypeScript config 76 | 77 | Edit the `tsconfig.json` file in the project root in order to extend the TypeScript configuration. 78 | 79 | **Example:** 80 | 81 | ```json 82 | { 83 | "extends": "./.config/tsconfig.json", 84 | "compilerOptions": { 85 | "preserveConstEnums": true 86 | } 87 | } 88 | ``` 89 | 90 | --- 91 | 92 | ### Extending the Webpack config 93 | 94 | Follow these steps to extend the basic Webpack configuration that lives under `.config/`: 95 | 96 | #### 1. Create a new Webpack configuration file 97 | 98 | Create a new config file that is going to extend the basic one provided by Grafana. 99 | It can live in the project root, e.g. `webpack.config.ts`. 100 | 101 | #### 2. Merge the basic config provided by Grafana and your custom setup 102 | 103 | We are going to use [`webpack-merge`](https://github.com/survivejs/webpack-merge) for this. 104 | 105 | ```typescript 106 | // webpack.config.ts 107 | import type { Configuration } from 'webpack'; 108 | import { merge } from 'webpack-merge'; 109 | import grafanaConfig from './.config/webpack/webpack.config'; 110 | 111 | const config = async (env): Promise => { 112 | const baseConfig = await grafanaConfig(env); 113 | 114 | return merge(baseConfig, { 115 | // Add custom config here... 116 | output: { 117 | asyncChunks: true, 118 | }, 119 | }); 120 | }; 121 | 122 | export default config; 123 | ``` 124 | 125 | #### 3. Update the `package.json` to use the new Webpack config 126 | 127 | We need to update the `scripts` in the `package.json` to use the extended Webpack configuration. 128 | 129 | **Update for `build`:** 130 | 131 | ```diff 132 | -"build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 133 | +"build": "webpack -c ./webpack.config.ts --env production", 134 | ``` 135 | 136 | **Update for `dev`:** 137 | 138 | ```diff 139 | -"dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 140 | +"dev": "webpack -w -c ./webpack.config.ts --env development", 141 | ``` 142 | 143 | ### Configure grafana image to use when running docker 144 | 145 | By default, `grafana-enterprise` will be used as the docker image for all docker related commands. If you want to override this behavior, simply alter the `docker-compose.yaml` by adding the following build arg `grafana_image`. 146 | 147 | **Example:** 148 | 149 | ```yaml 150 | version: '3.7' 151 | 152 | services: 153 | grafana: 154 | container_name: 'myorg-basic-app' 155 | build: 156 | context: ./.config 157 | args: 158 | grafana_version: ${GRAFANA_VERSION:-9.1.2} 159 | grafana_image: ${GRAFANA_IMAGE:-grafana} 160 | ``` 161 | 162 | In this example, we assign the environment variable `GRAFANA_IMAGE` to the build arg `grafana_image` with a default value of `grafana`. This will allow you to set the value while running the docker compose commands, which might be convenient in some scenarios. 163 | 164 | --- 165 | -------------------------------------------------------------------------------- /.config/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "${DEV}" = "false" ]; then 4 | echo "Starting test mode" 5 | exec /run.sh 6 | fi 7 | 8 | echo "Starting development mode" 9 | 10 | if grep -i -q alpine /etc/issue; then 11 | exec /usr/bin/supervisord -c /etc/supervisord.conf 12 | elif grep -i -q ubuntu /etc/issue; then 13 | exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf 14 | else 15 | echo 'ERROR: Unsupported base image' 16 | exit 1 17 | fi 18 | 19 | -------------------------------------------------------------------------------- /.config/jest-setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-jest-config 6 | */ 7 | 8 | import '@testing-library/jest-dom'; 9 | import { TextEncoder, TextDecoder } from 'util'; 10 | 11 | Object.assign(global, { TextDecoder, TextEncoder }); 12 | 13 | // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 14 | Object.defineProperty(global, 'matchMedia', { 15 | writable: true, 16 | value: (query) => ({ 17 | matches: false, 18 | media: query, 19 | onchange: null, 20 | addListener: jest.fn(), // deprecated 21 | removeListener: jest.fn(), // deprecated 22 | addEventListener: jest.fn(), 23 | removeEventListener: jest.fn(), 24 | dispatchEvent: jest.fn(), 25 | }), 26 | }); 27 | 28 | HTMLCanvasElement.prototype.getContext = () => {}; 29 | -------------------------------------------------------------------------------- /.config/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-jest-config 6 | */ 7 | 8 | const path = require('path'); 9 | const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils'); 10 | 11 | module.exports = { 12 | moduleNameMapper: { 13 | '\\.(css|scss|sass)$': 'identity-obj-proxy', 14 | 'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'), 15 | }, 16 | modulePaths: ['/src'], 17 | setupFilesAfterEnv: ['/jest-setup.js'], 18 | testEnvironment: 'jest-environment-jsdom', 19 | testMatch: [ 20 | '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', 21 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 22 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 23 | ], 24 | transform: { 25 | '^.+\\.(t|j)sx?$': [ 26 | '@swc/jest', 27 | { 28 | sourceMaps: 'inline', 29 | jsc: { 30 | parser: { 31 | syntax: 'typescript', 32 | tsx: true, 33 | decorators: false, 34 | dynamicImport: true, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | // Jest will throw `Cannot use import statement outside module` if it tries to load an 41 | // ES module without it being transformed first. ./config/README.md#esm-errors-with-jest 42 | transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)], 43 | }; 44 | -------------------------------------------------------------------------------- /.config/jest/mocks/react-inlinesvg.tsx: -------------------------------------------------------------------------------- 1 | // Due to the grafana/ui Icon component making fetch requests to 2 | // `/public/img/icon/.svg` we need to mock react-inlinesvg to prevent 3 | // the failed fetch requests from displaying errors in console. 4 | 5 | import React from 'react'; 6 | 7 | type Callback = (...args: any[]) => void; 8 | 9 | export interface StorageItem { 10 | content: string; 11 | queue: Callback[]; 12 | status: string; 13 | } 14 | 15 | export const cacheStore: { [key: string]: StorageItem } = Object.create(null); 16 | 17 | const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/; 18 | 19 | const InlineSVG = ({ src }: { src: string }) => { 20 | // testId will be the file name without extension (e.g. `public/img/icons/angle-double-down.svg` -> `angle-double-down`) 21 | const testId = src.replace(SVG_FILE_NAME_REGEX, '$2'); 22 | return ; 23 | }; 24 | 25 | export default InlineSVG; 26 | -------------------------------------------------------------------------------- /.config/jest/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | /* 8 | * This utility function is useful in combination with jest `transformIgnorePatterns` config 9 | * to transform specific packages (e.g.ES modules) in a projects node_modules folder. 10 | */ 11 | const nodeModulesToTransform = (moduleNames) => `node_modules\/(?!.*(${moduleNames.join('|')})\/.*)`; 12 | 13 | // Array of known nested grafana package dependencies that only bundle an ESM version 14 | const grafanaESModules = [ 15 | '.pnpm', // Support using pnpm symlinked packages 16 | '@grafana/schema', 17 | 'd3', 18 | 'd3-color', 19 | 'd3-force', 20 | 'd3-interpolate', 21 | 'd3-scale-chromatic', 22 | 'ol', 23 | 'react-colorful', 24 | 'rxjs', 25 | 'uuid', 26 | ]; 27 | 28 | module.exports = { 29 | nodeModulesToTransform, 30 | grafanaESModules, 31 | }; 32 | -------------------------------------------------------------------------------- /.config/supervisord/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | 5 | [program:grafana] 6 | user=root 7 | directory=/var/lib/grafana 8 | command=/run.sh 9 | stdout_logfile=/dev/fd/1 10 | stdout_logfile_maxbytes=0 11 | redirect_stderr=true 12 | killasgroup=true 13 | stopasgroup=true 14 | autostart=true 15 | 16 | -------------------------------------------------------------------------------- /.config/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-typescript-config 6 | */ 7 | { 8 | "compilerOptions": { 9 | "alwaysStrict": true, 10 | "declaration": false, 11 | "rootDir": "../src", 12 | "baseUrl": "../src", 13 | "typeRoots": ["../node_modules/@types"], 14 | "resolveJsonModule": true 15 | }, 16 | "ts-node": { 17 | "compilerOptions": { 18 | "module": "commonjs", 19 | "target": "es5", 20 | "esModuleInterop": true 21 | }, 22 | "transpileOnly": true 23 | }, 24 | "include": ["../src", "./types"], 25 | "extends": "@grafana/tsconfig" 26 | } 27 | -------------------------------------------------------------------------------- /.config/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | // Image declarations 2 | declare module '*.gif' { 3 | const src: string; 4 | export default src; 5 | } 6 | 7 | declare module '*.jpg' { 8 | const src: string; 9 | export default src; 10 | } 11 | 12 | declare module '*.jpeg' { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module '*.png' { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module '*.webp' { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module '*.svg' { 28 | const content: string; 29 | export default content; 30 | } 31 | 32 | // Font declarations 33 | declare module '*.woff'; 34 | declare module '*.woff2'; 35 | declare module '*.eot'; 36 | declare module '*.ttf'; 37 | declare module '*.otf'; 38 | -------------------------------------------------------------------------------- /.config/webpack/constants.ts: -------------------------------------------------------------------------------- 1 | export const SOURCE_DIR = 'src'; 2 | export const DIST_DIR = 'dist'; 3 | -------------------------------------------------------------------------------- /.config/webpack/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import process from 'process'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { glob } from 'glob'; 6 | import { SOURCE_DIR } from './constants'; 7 | 8 | export function isWSL() { 9 | if (process.platform !== 'linux') { 10 | return false; 11 | } 12 | 13 | if (os.release().toLowerCase().includes('microsoft')) { 14 | return true; 15 | } 16 | 17 | try { 18 | return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); 19 | } catch { 20 | return false; 21 | } 22 | } 23 | 24 | export function getPackageJson() { 25 | return require(path.resolve(process.cwd(), 'package.json')); 26 | } 27 | 28 | export function getPluginJson() { 29 | return require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`)); 30 | } 31 | 32 | export function getCPConfigVersion() { 33 | const cprcJson = path.resolve(__dirname, '../', '.cprc.json'); 34 | return fs.existsSync(cprcJson) ? require(cprcJson).version : { version: 'unknown' }; 35 | } 36 | 37 | export function hasReadme() { 38 | return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md')); 39 | } 40 | 41 | // Support bundling nested plugins by finding all plugin.json files in src directory 42 | // then checking for a sibling module.[jt]sx? file. 43 | export async function getEntries(): Promise> { 44 | const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true }); 45 | 46 | const plugins = await Promise.all( 47 | pluginsJson.map((pluginJson) => { 48 | const folder = path.dirname(pluginJson); 49 | return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true }); 50 | }) 51 | ); 52 | 53 | return plugins.reduce((result, modules) => { 54 | return modules.reduce((result, module) => { 55 | const pluginPath = path.dirname(module); 56 | const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, ''); 57 | const entryName = pluginName === '' ? 'module' : `${pluginName}/module`; 58 | 59 | result[entryName] = module; 60 | return result; 61 | }, result); 62 | }, {}); 63 | } 64 | -------------------------------------------------------------------------------- /.config/webpack/webpack.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-webpack-config 6 | */ 7 | 8 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 9 | import ESLintPlugin from 'eslint-webpack-plugin'; 10 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 11 | import path from 'path'; 12 | import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin'; 13 | import TerserPlugin from 'terser-webpack-plugin'; 14 | import { type Configuration, BannerPlugin } from 'webpack'; 15 | import LiveReloadPlugin from 'webpack-livereload-plugin'; 16 | import VirtualModulesPlugin from 'webpack-virtual-modules'; 17 | 18 | import { DIST_DIR, SOURCE_DIR } from './constants'; 19 | import { getCPConfigVersion, getEntries, getPackageJson, getPluginJson, hasReadme, isWSL } from './utils'; 20 | 21 | const pluginJson = getPluginJson(); 22 | const cpVersion = getCPConfigVersion(); 23 | 24 | const virtualPublicPath = new VirtualModulesPlugin({ 25 | 'node_modules/grafana-public-path.js': ` 26 | import amdMetaModule from 'amd-module'; 27 | 28 | __webpack_public_path__ = 29 | amdMetaModule && amdMetaModule.uri 30 | ? amdMetaModule.uri.slice(0, amdMetaModule.uri.lastIndexOf('/') + 1) 31 | : 'public/plugins/${pluginJson.id}/'; 32 | `, 33 | }); 34 | 35 | const config = async (env): Promise => { 36 | const baseConfig: Configuration = { 37 | cache: { 38 | type: 'filesystem', 39 | buildDependencies: { 40 | config: [__filename], 41 | }, 42 | }, 43 | 44 | context: path.join(process.cwd(), SOURCE_DIR), 45 | 46 | devtool: env.production ? 'source-map' : 'eval-source-map', 47 | 48 | entry: await getEntries(), 49 | 50 | externals: [ 51 | // Required for dynamic publicPath resolution 52 | { 'amd-module': 'module' }, 53 | 'lodash', 54 | 'jquery', 55 | 'moment', 56 | 'slate', 57 | 'emotion', 58 | '@emotion/react', 59 | '@emotion/css', 60 | 'prismjs', 61 | 'slate-plain-serializer', 62 | '@grafana/slate-react', 63 | 'react', 64 | 'react-dom', 65 | 'react-redux', 66 | 'redux', 67 | 'rxjs', 68 | 'react-router', 69 | 'react-router-dom', 70 | 'd3', 71 | 'angular', 72 | '@grafana/ui', 73 | '@grafana/runtime', 74 | '@grafana/data', 75 | 76 | // Mark legacy SDK imports as external if their name starts with the "grafana/" prefix 77 | ({ request }, callback) => { 78 | const prefix = 'grafana/'; 79 | const hasPrefix = (request) => request.indexOf(prefix) === 0; 80 | const stripPrefix = (request) => request.substr(prefix.length); 81 | 82 | if (hasPrefix(request)) { 83 | return callback(undefined, stripPrefix(request)); 84 | } 85 | 86 | callback(); 87 | }, 88 | ], 89 | 90 | // Support WebAssembly according to latest spec - makes WebAssembly module async 91 | experiments: { 92 | asyncWebAssembly: true, 93 | }, 94 | 95 | mode: env.production ? 'production' : 'development', 96 | 97 | module: { 98 | rules: [ 99 | // This must come first in the rules array otherwise it breaks sourcemaps. 100 | { 101 | test: /src\/(?:.*\/)?module\.tsx?$/, 102 | use: [ 103 | { 104 | loader: 'imports-loader', 105 | options: { 106 | imports: `side-effects grafana-public-path`, 107 | }, 108 | }, 109 | ], 110 | }, 111 | { 112 | exclude: /(node_modules)/, 113 | test: /\.[tj]sx?$/, 114 | use: { 115 | loader: 'swc-loader', 116 | options: { 117 | jsc: { 118 | baseUrl: path.resolve(process.cwd(), SOURCE_DIR), 119 | target: 'es2015', 120 | loose: false, 121 | parser: { 122 | syntax: 'typescript', 123 | tsx: true, 124 | decorators: false, 125 | dynamicImport: true, 126 | }, 127 | }, 128 | }, 129 | }, 130 | }, 131 | { 132 | test: /\.css$/, 133 | use: ['style-loader', 'css-loader'], 134 | }, 135 | { 136 | test: /\.s[ac]ss$/, 137 | use: ['style-loader', 'css-loader', 'sass-loader'], 138 | }, 139 | { 140 | test: /\.(png|jpe?g|gif|svg)$/, 141 | type: 'asset/resource', 142 | generator: { 143 | filename: Boolean(env.production) ? '[hash][ext]' : '[file]', 144 | }, 145 | }, 146 | { 147 | test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, 148 | type: 'asset/resource', 149 | generator: { 150 | filename: Boolean(env.production) ? '[hash][ext]' : '[file]', 151 | }, 152 | }, 153 | ], 154 | }, 155 | 156 | optimization: { 157 | minimize: Boolean(env.production), 158 | minimizer: [ 159 | new TerserPlugin({ 160 | terserOptions: { 161 | format: { 162 | comments: (_, { type, value }) => type === 'comment2' && value.trim().startsWith('[create-plugin]'), 163 | }, 164 | compress: { 165 | drop_console: ['log', 'info'], 166 | }, 167 | }, 168 | }), 169 | ], 170 | }, 171 | 172 | output: { 173 | clean: { 174 | keep: new RegExp(`(.*?_(amd64|arm(64)?)(.exe)?|go_plugin_build_manifest)`), 175 | }, 176 | filename: '[name].js', 177 | library: { 178 | type: 'amd', 179 | }, 180 | path: path.resolve(process.cwd(), DIST_DIR), 181 | publicPath: `public/plugins/${pluginJson.id}/`, 182 | uniqueName: pluginJson.id, 183 | }, 184 | 185 | plugins: [ 186 | virtualPublicPath, 187 | // Insert create plugin version information into the bundle 188 | new BannerPlugin({ 189 | banner: '/* [create-plugin] version: ' + cpVersion + ' */', 190 | raw: true, 191 | entryOnly: true, 192 | }), 193 | new CopyWebpackPlugin({ 194 | patterns: [ 195 | // If src/README.md exists use it; otherwise the root README 196 | // To `compiler.options.output` 197 | { from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true }, 198 | { from: 'plugin.json', to: '.' }, 199 | { from: '../LICENSE', to: '.' }, 200 | { from: '../CHANGELOG.md', to: '.', force: true }, 201 | { from: '**/*.json', to: '.' }, // TODO 202 | { from: '**/*.svg', to: '.', noErrorOnMissing: true }, // Optional 203 | { from: '**/*.png', to: '.', noErrorOnMissing: true }, // Optional 204 | { from: '**/*.html', to: '.', noErrorOnMissing: true }, // Optional 205 | { from: 'img/**/*', to: '.', noErrorOnMissing: true }, // Optional 206 | { from: 'libs/**/*', to: '.', noErrorOnMissing: true }, // Optional 207 | { from: 'static/**/*', to: '.', noErrorOnMissing: true }, // Optional 208 | { from: '**/query_help.md', to: '.', noErrorOnMissing: true }, // Optional 209 | ], 210 | }), 211 | // Replace certain template-variables in the README and plugin.json 212 | new ReplaceInFileWebpackPlugin([ 213 | { 214 | dir: DIST_DIR, 215 | files: ['plugin.json', 'README.md'], 216 | rules: [ 217 | { 218 | search: /\%VERSION\%/g, 219 | replace: getPackageJson().version, 220 | }, 221 | { 222 | search: /\%TODAY\%/g, 223 | replace: new Date().toISOString().substring(0, 10), 224 | }, 225 | { 226 | search: /\%PLUGIN_ID\%/g, 227 | replace: pluginJson.id, 228 | }, 229 | ], 230 | }, 231 | ]), 232 | ...(env.development 233 | ? [ 234 | new LiveReloadPlugin(), 235 | new ForkTsCheckerWebpackPlugin({ 236 | async: Boolean(env.development), 237 | issue: { 238 | include: [{ file: '**/*.{ts,tsx}' }], 239 | }, 240 | typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') }, 241 | }), 242 | new ESLintPlugin({ 243 | extensions: ['.ts', '.tsx'], 244 | lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files 245 | }), 246 | ] 247 | : []), 248 | ], 249 | 250 | resolve: { 251 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 252 | // handle resolving "rootDir" paths 253 | modules: [path.resolve(process.cwd(), 'src'), 'node_modules'], 254 | unsafeCache: true, 255 | }, 256 | }; 257 | 258 | if (isWSL()) { 259 | baseConfig.watchOptions = { 260 | poll: 3000, 261 | ignored: /node_modules/, 262 | }; 263 | } 264 | 265 | return baseConfig; 266 | }; 267 | 268 | export default config; 269 | -------------------------------------------------------------------------------- /.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": { 3 | "bundleGrafanaUI": false, 4 | "useReactRouterV6": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/.eslintrc" 3 | } -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release on Tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Get yarn cache directory path 15 | id: yarn-cache-dir-path 16 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 17 | 18 | - uses: actions/cache@v4 19 | id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) 20 | with: 21 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 22 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 23 | restore-keys: | 24 | ${{ runner.os }}-yarn- 25 | 26 | - name: Set up Node.js 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: '20' 30 | 31 | - name: Build package 32 | env: 33 | GRAFANA_API_KEY: ${{ secrets.GRAFANA_API_KEY }} 34 | run: ./package.sh ${{github.ref_name}} 35 | 36 | - name: Release package and md5 37 | uses: softprops/action-gh-release@v2 38 | if: startsWith(github.ref, 'refs/tags/') 39 | with: 40 | files: | 41 | nline-plotlyjs-panel-${{github.ref_name}}.zip 42 | nline-plotlyjs-panel-${{github.ref_name}}.zip.md5 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Modules 9 | node_modules/ 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # Compiled binary addons (https://nodejs.org/api/addons.html) 24 | artifacts/ 25 | work/ 26 | ci/ 27 | e2e-results/ 28 | 29 | token 30 | nline-plotlyjs-panel 31 | nline-plotlyjs-panel-*.zip 32 | nline-plotlyjs-panel-*.zip.md5 33 | .DS_Store 34 | dist/ 35 | .trunk/ 36 | keys.env 37 | .eslintcache 38 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require('./.config/.prettierrc.js'), 4 | }; 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [1.8.1] - 2024-09-30 6 | 7 | ### Changed 8 | 9 | - Added proper replaceVariables function to utils (works with scoped variables) 10 | 11 | ### Fixed 12 | 13 | - Bad empty dataframe detection 14 | 15 | ## [1.8.0] - 2024-08-27 16 | 17 | ### Changed 18 | 19 | - Add support for click, select, and zoom events 20 | - Implement dashboard time range synchronization with chart zoom 21 | - Expand script context with more Grafana variables and utilities 22 | - Update README with detailed usage instructions and examples 23 | - Improve error handling and code organization 24 | - Add migration script for compatibility with older versions 25 | 26 | ### Fixed 27 | 28 | - Titles substitute vars properly 29 | 30 | ### Note 31 | 32 | The contextual variables have been changed: 33 | - `parameters` (referencing the data, allData, layout, config) has been changed to `options` 34 | - Helper functions are bundled in a `utils` object where you can reference `timeZone`, `dayjs`, `matchTimezone`, `locationService`, `getTemplateSrv` from the `utils` object like `utils.timeZone` or `utils.locationService`. 35 | 36 | ## [1.7.0] - 2024-08-07 37 | 38 | ### Changed 39 | 40 | - Major refactor with multiple fixes and improvements 41 | - Image export reflects all config options 42 | - Light/dark theme that matches Grafana 43 | - Updated theme colors 44 | - Better error display 45 | - Clearer message when there's no data in source 46 | - Updated margins (default to 0 for all) 47 | - More memoziation to reduce rerendering 48 | - Fixed workflow and updated Node version 49 | - Updated with grafana/create-plugin 50 | - Updated license 51 | - Added a changelog 52 | 53 | ### Fixed 54 | 55 | - Bug fix for height/width resize 56 | - Fixed potential crashes with script validation 57 | 58 | ## [1.6.7] - 2024-04-08 59 | 60 | ### Fixed 61 | 62 | - Small fix for 1.6.7 63 | 64 | ## [1.6.6] - 2024-03-27 65 | 66 | ### Changed 67 | 68 | - Moved image render function out of panel 69 | - Improved error handling 70 | - Updated to create-plugin 71 | 72 | ## [1.6.5] - 2024-01-08 73 | 74 | ### Changed 75 | 76 | - Updated README.md 77 | - Fixed resizable issue 78 | 79 | ## [1.6.4] - 2023-11-16 80 | 81 | ### Fixed 82 | 83 | - Fixed timezone matcher 84 | 85 | ## [1.6.3] - 2023-11-16 86 | 87 | ### Added 88 | 89 | - Added ability to select column for time correction 90 | 91 | ## [1.6.2] - 2023-10-26 92 | 93 | ### Improved 94 | 95 | - Enhanced matchTimezone logic 96 | 97 | ## [1.6.1] - 2023-10-06 98 | 99 | ### Added 100 | 101 | - Added image export format selector 102 | 103 | ### Changed 104 | 105 | - Now passing the entire variable object 106 | 107 | ## [1.6.0] - 2023-09-25 108 | 109 | ### Fixed 110 | 111 | - Fixed "browser" timezone issue 112 | 113 | ### Changed 114 | 115 | - Removed forced defaults 116 | - Added matchTimezone function 117 | -------------------------------------------------------------------------------- /DEV.md: -------------------------------------------------------------------------------- 1 | # Development Documentation 2 | 3 | Here are some instructions to assist with local development and publishing. 4 | 5 | ## Local development 6 | 7 | ```sh 8 | docker run -d -p 3000:3000 -v "$(pwd)"/grafana-plugins:/var/lib/grafana/plugins --name=grafana grafana/grafana 9 | ``` 10 | 11 | ## Publish 12 | 13 | 1. Test plugin with 14 | 1. Change version in package.json 15 | 1. Commit changes 16 | 1. Create new tag for plugin 17 | - `git tag -a vx.x.x -m "Release vx.x.x"` 18 | - `git push --tags` 19 | 1. Add token to `keys.env` and sign the plugin `npx @grafana/toolkit plugin:sign` 20 | 1. Run `package.sh` to bundle and zip plugin and make md5 21 | 1. Attach zip and md5 files to new release on Github 22 | 1. Publish to Grafana plugins 23 | - Clone grafana-plugin-repository or update a fork of grafana-plugin-repository (Used ) 24 | - First time only, create the upstream branch 25 | `git remote add upstream https://github.com/grafana/grafana-plugin-repository.git` 26 | - `git fetch upstream` 27 | - `git merge upstream/master master` 28 | - Update the `repo.json` file 29 | - Create a PR 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 nLine Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plotly Panel for Grafana 2 | 3 | ![Marketplace](https://img.shields.io/badge/dynamic/json?logo=grafana&color=F47A20&label=marketplace&url=https%3A%2F%2Fgrafana.com%2Fapi%2Fplugins%2Fnline-plotlyjs-panel&query=%24.version) 4 | ![Downloads](https://img.shields.io/badge/dynamic/json?logo=grafana&color=F47A20&label=downloads&url=https%3A%2F%2Fgrafana.com%2Fapi%2Fplugins%2Fnline-plotlyjs-panel&query=%24.downloads) 5 | 6 | 7 | Create advanced, interactive charts in Grafana using [Plotly.js](https://plotly.com/javascript/). 8 | 9 | ## Key Features 10 | 11 | - Flexible chart creation with full Plotly.js capabilities 12 | - YAML/JSON support for easy configuration 13 | - Dark/light theme compatibility 14 | - Automatic and manual timezone adjustment 15 | - Cross-trace data application 16 | - Expandable code editors for customization 17 | - Grafana variable integration 18 | - Comprehensive error handling 19 | - High-resolution image export in multiple formats (SVG, PNG, JPEG, WebP) 20 | 21 | For a complete list of recent updates, please refer to our [changelog](https://github.com/nline/nline-plotlyjs-panel/blob/main/CHANGELOG.md). 22 | 23 | ## Getting Started 24 | 25 | The Plotly Panel, developed by [nLine](https://nline.io), offers enhanced control over data visualization in Grafana. It uses a component-based approach, allowing you to modify chart elements independently without complex JavaScript interactions. 26 | 27 | ### Panel Structure 28 | 29 | The panel configuration consists of five main components: 30 | 31 | 1. **allData**: Applied across all traces on the Plotly chart 32 | 2. **data**: Defines the chart's data series (traces) 33 | 3. **layout**: Controls the chart's appearance and axes 34 | 4. **config**: Sets chart-wide options 35 | 5. **frames**: (Optional) For animated charts 36 | 37 | These components follow the [Plotly.js schema](https://raw.githubusercontent.com/plotly/plotly.js/master/dist/plot-schema.json). You can configure them using YAML or JSON in the panel options. 38 | 39 | ### Data Transformation 40 | 41 | You can transform your data before rendering using a custom script. The script has access to: 42 | 43 | - `data`: Raw data from your Grafana data source 44 | - `variables`: Grafana dashboard and system variables 45 | - `options`: Current panel configuration 46 | - `utils`: Helper functions (e.g., timezone conversion, dayjs for date manipulation) 47 | 48 | #### Context Variables 49 | 50 | The script has access to several context variables that provide useful information and functionality: 51 | 52 | ##### `variables` 53 | 54 | This object contains [Grafana's dashboard variables](https://grafana.com/docs/grafana/latest/variables/) and native Grafana variables. Native variables take precedence over dashboard variables with the same name. 55 | 56 | Key native variables include: 57 | 58 | - `__from` and `__to`: Start and end timestamps of the current time range 59 | - `__interval` and `__interval_ms`: The interval in string format (e.g., "1h") and in milliseconds 60 | - `__timezone`: The current dashboard timezone 61 | - `__timeFilter`: A function to generate time range filter expressions 62 | - `__dashboard`: The current dashboard object 63 | 64 | ##### `utils` 65 | 66 | The `utils` object provides several utility functions and services to assist with data manipulation and panel interactions: 67 | 68 | - `timeZone`: The dashboard timezone 69 | - `dayjs`: A lightweight [date manipulation library](https://github.com/iamkun/dayjs) 70 | - `matchTimezone`: A convenience function to convert timeseries data to the user's timezone 71 | - `locationService`: Grafana's location service for URL manipulation 72 | - `getTemplateSrv`: Grafana's template service for variable substitution 73 | - `replaceVariables`: A function to substitute Grafana variables in strings 74 | 75 | ### Processing Script 76 | 77 | The script must return an object that defines the chart configuration. This object can include one or more of the following properties: 78 | 79 | - `data`: An array of trace objects defining the chart's data series 80 | - `layout`: An object controlling the chart's appearance and axes 81 | - `config`: An object setting chart-wide options 82 | - `frames`: An array of frame objects for animated charts 83 | 84 | **Note:** The `data` and `frames` properties should be arrays of objects. The "Cross-trace Data" field can be an object, which will apply the parameters to all returned traces in the _Script_ section. Objects are merged with script objects given priority (e.g., `data` from script > `allData` > `data`). 85 | 86 | The script is defined in the "Processing Script" editor. 87 | 88 | ```js 89 | // Example: Basic timeseries plot 90 | const { data, variables, options, utils } = arguments; 91 | let series = data.series[0]; 92 | let x = series.fields[0]; 93 | let y = series.fields[1]; 94 | 95 | return { 96 | data: [ 97 | { 98 | x: x.values || x.values.buffer, 99 | y: y.values || y.values.buffer, 100 | type: 'scatter', 101 | mode: 'lines', 102 | name: x.name, 103 | }, 104 | ], 105 | layout: { 106 | xaxis: { title: x.name }, 107 | yaxis: { title: y.name }, 108 | }, 109 | }; 110 | ``` 111 | 112 | ### On-click Event Handling 113 | 114 | The panel supports click, select, and zoom events. You can define custom behavior for these events using the "On-event Trigger" editor. 115 | 116 | ```javascript 117 | // Event handling 118 | const { type: eventType, data: eventData } = event; 119 | const { timeZone, dayjs, locationService, getTemplateSrv } = utils; 120 | 121 | switch (eventType) { 122 | case 'click': 123 | console.log('Click event:', eventData.points); 124 | break; 125 | case 'select': 126 | console.log('Selection event:', eventData.range); 127 | break; 128 | case 'zoom': 129 | console.log('Zoom event:', eventData); 130 | break; 131 | } 132 | ``` 133 | 134 | ## Screenshots 135 | 136 | For screenshots, please see the `src/img` folder. 137 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | 3 | services: 4 | grafana: 5 | container_name: 'nline-plotlyjs-panel' 6 | build: 7 | context: ./.config 8 | args: 9 | grafana_version: ${GRAFANA_VERSION:-latest} 10 | ports: 11 | - 3000:3000/tcp 12 | volumes: 13 | - ./dist:/var/lib/grafana/plugins/nline-plotlyjs-panel 14 | - ./provisioning:/etc/grafana/provisioning 15 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup provided by Grafana scaffolding 2 | import './.config/jest-setup'; 3 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // force timezone to UTC to allow tests to work regardless of local timezone 2 | // generally used by snapshots, but can affect specific tests 3 | process.env.TZ = 'UTC'; 4 | 5 | module.exports = { 6 | // Jest configuration provided by Grafana scaffolding 7 | ...require('./.config/jest.config'), 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nline-plotlyjs-panel", 3 | "version": "1.8.1", 4 | "description": "Grafana Plotly Plugin", 5 | "scripts": { 6 | "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 7 | "dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 8 | "docker": "docker run -d -p 3000:3000 -v \"$(pwd)\":/var/lib/grafana/plugins --name=grafana grafana/grafana", 9 | "e2e": "yarn exec cypress install && yarn exec grafana-e2e run", 10 | "e2e:update": "yarn exec cypress install && yarn exec grafana-e2e run --update-screenshots", 11 | "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", 12 | "lint:fix": "yarn run lint --fix", 13 | "md5": "md5 nline-plotlyjs-panel-%npm_package_version%.zip nline-plotlyjs-panel-%npm_package_version%.zip.md5", 14 | "package": "./package.sh", 15 | "release": "tag=$(git describe --abbrev=0) && gh release create ${tag} ./nline-plotlyjs-panel-${tag}.zip ./nline-plotlyjs-panel-${tag}.zip.md5", 16 | "upload": "tag=$(git describe --abbrev=0) && gh release upload ${tag} ./nline-plotlyjs-panel-${tag}.zip ./nline-plotlyjs-panel-${tag}.zip.md5 --clobber", 17 | "server": "docker compose up --build", 18 | "sign": "npx --yes @grafana/sign-plugin@latest", 19 | "test": "jest --watch --onlyChanged", 20 | "test:ci": "jest --passWithNoTests --maxWorkers 4", 21 | "typecheck": "tsc --noEmit", 22 | "validate": "tag=$(git describe --abbrev=0) && npx -y @grafana/plugin-validator@latest -sourceCodeUri https://github.com/nline/nline-plotlyjs-panel/tree/${tag} https://github.com/nline/nline-plotlyjs-panel/releases/download/${tag}/nline-plotlyjs-panel-${tag}.zip" 23 | }, 24 | "author": "nLine", 25 | "license": "Apache-2.0", 26 | "devDependencies": { 27 | "@babel/core": "^7.25.2", 28 | "@grafana/e2e": "^11.0.4", 29 | "@grafana/e2e-selectors": "^11.2.0", 30 | "@grafana/eslint-config": "^7.0.0", 31 | "@grafana/tsconfig": "^2.0.0", 32 | "@swc/core": "^1.7.26", 33 | "@swc/helpers": "^0.5.13", 34 | "@swc/jest": "^0.2.36", 35 | "@testing-library/jest-dom": "6.5.0", 36 | "@testing-library/react": "16.0.1", 37 | "@types/glob": "^8.1.0", 38 | "@types/jest": "^29.5.13", 39 | "@types/lodash": "^4.17.7", 40 | "@types/node": "^22.5.5", 41 | "@types/plotly.js-dist-min": "^2.3.4", 42 | "@types/react-plotly.js": "^2.6.3", 43 | "@types/react-router-dom": "^5.3.3", 44 | "@types/testing-library__jest-dom": "6.0.0", 45 | "@typescript-eslint/eslint-plugin": "^8.6.0", 46 | "@typescript-eslint/parser": "^8.6.0", 47 | "copy-webpack-plugin": "^12.0.2", 48 | "css-loader": "^7.1.2", 49 | "eslint": "9.11.0", 50 | "eslint-config-prettier": "^9.1.0", 51 | "eslint-plugin-deprecation": "^3.0.0", 52 | "eslint-plugin-jsdoc": "^50.2.4", 53 | "eslint-plugin-prettier": "^5.2.1", 54 | "eslint-plugin-react": "^7.36.1", 55 | "eslint-plugin-react-hooks": "^4.6.2", 56 | "eslint-webpack-plugin": "^4.2.0", 57 | "fork-ts-checker-webpack-plugin": "^9.0.2", 58 | "glob": "^11.0.0", 59 | "identity-obj-proxy": "3.0.0", 60 | "imports-loader": "^5.0.0", 61 | "jest": "^29.7.0", 62 | "jest-environment-jsdom": "^29.7.0", 63 | "prettier": "^3.3.3", 64 | "replace-in-file-webpack-plugin": "^1.0.6", 65 | "sass": "1.79.3", 66 | "sass-loader": "16.0.2", 67 | "style-loader": "4.0.0", 68 | "swc-loader": "^0.2.6", 69 | "ts-node": "^10.9.2", 70 | "tsconfig-paths": "^4.2.0", 71 | "typescript": "5.6.2", 72 | "webpack": "^5.94.0", 73 | "webpack-cli": "^5.1.4", 74 | "webpack-livereload-plugin": "^3.0.2", 75 | "webpack-virtual-modules": "^0.6.2", 76 | "terser-webpack-plugin": "^5.3.10" 77 | }, 78 | "dependencies": { 79 | "@emotion/css": "^11.13.0", 80 | "@grafana/data": "^11.2.0", 81 | "@grafana/runtime": "^11.2.0", 82 | "@grafana/schema": "^11.2.0", 83 | "@grafana/ui": "^11.2.0", 84 | "@types/file-saver": "^2.0.7", 85 | "@types/plotly.js": "^2.33.4", 86 | "dayjs": "^1.11.13", 87 | "deepmerge": "^4.3.1", 88 | "file-saver": "^2.0.5", 89 | "js-yaml": "^4.1.0", 90 | "plotly.js-dist-min": "^2.35.2", 91 | "react": "18.3.1", 92 | "react-dom": "18.3.1", 93 | "react-plotly.js": "^2.6.0", 94 | "react-resizable": "^3.0.5", 95 | "semver": "^7.6.3", 96 | "tslib": "2.7.0" 97 | }, 98 | "packageManager": "yarn@1.22.22" 99 | } 100 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This file is used to package the dist folder for Grafana Cloud 3 | set -e 4 | if [ -z "$1" ]; then 5 | # Local 6 | tag=$(git describe --abbrev=0) 7 | export $(xargs nline-plotlyjs-panel-"${tag}".zip.md5 22 | rm -rf nline-plotlyjs-panel 23 | -------------------------------------------------------------------------------- /src/ErrorDisplay.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert, useStyles2 } from '@grafana/ui'; 3 | import { css } from '@emotion/css'; 4 | import { GrafanaTheme2 } from '@grafana/data'; 5 | 6 | interface ErrorDisplayProps { 7 | message: string | { message: string; lineNumber: string | number; line: string }; 8 | title?: string; 9 | isNoData?: boolean; 10 | } 11 | 12 | export const ErrorDisplay: React.FC = ({ message, title = 'Error', isNoData = false }) => { 13 | const styles = useStyles2(getStyles); 14 | 15 | if (isNoData) { 16 | return ( 17 |
18 |

No data

19 |

{typeof message === 'string' ? message : message.message}

20 |
21 | ); 22 | } 23 | 24 | const errorContent = 25 | typeof message === 'string' ? ( 26 | message 27 | ) : ( 28 | <> 29 |

Error on line {message.lineNumber}: {message.message}

30 | {message.line &&
{message.line}
} 31 | 32 | ); 33 | 34 | return ( 35 | 36 | {errorContent} 37 | 38 | ); 39 | }; 40 | 41 | const getStyles = (theme: GrafanaTheme2) => ({ 42 | noDataContainer: css` 43 | display: flex; 44 | flex-direction: column; 45 | align-items: center; 46 | justify-content: center; 47 | height: 100%; 48 | color: ${theme.colors.text.secondary}; 49 | `, 50 | }); 51 | -------------------------------------------------------------------------------- /src/PanelOptionCode.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState, useMemo } from 'react'; 2 | import { StandardEditorProps } from '@grafana/data'; 3 | import { CodeEditor, Alert } from '@grafana/ui'; 4 | import './panel.css'; 5 | const { ResizableBox } = require('react-resizable'); 6 | const YAML = require('js-yaml'); 7 | 8 | interface Props extends StandardEditorProps {} 9 | 10 | export const PanelOptionCode: React.FC = React.memo(({ value, item, onChange, context }) => { 11 | const language = item?.settings?.language; 12 | const height = item?.settings?.editorHeight || 300; 13 | 14 | const [originalText, setOriginalText] = useState(typeof value === 'string' ? value : YAML.dump(value, null, 2)); 15 | const [error, setError] = useState(null); 16 | 17 | const handleBlur = useCallback( 18 | (code: string) => { 19 | setOriginalText(code); 20 | setError(null); 21 | if (language === 'yaml') { 22 | try { 23 | const parsedValue = YAML.load(code); 24 | onChange(parsedValue); 25 | } catch (error: any) { 26 | setError(`Invalid YAML: ${error.message}`); 27 | onChange(code); 28 | } 29 | } else { 30 | onChange(code); 31 | } 32 | }, 33 | [language, onChange] 34 | ); 35 | 36 | const monacoOptions = useMemo( 37 | () => ({ 38 | minimap: { enabled: false }, 39 | lineNumbers: 'on' as const, 40 | folding: true, 41 | renderValidationDecorations: 'on' as const, 42 | }), 43 | [] 44 | ); 45 | 46 | return ( 47 | <> 48 | 54 | 55 | 56 | {error && ( 57 | 58 |
{error}
59 |
60 | )} 61 | 62 | ); 63 | }); 64 | 65 | PanelOptionCode.displayName = 'PanelOptionCode'; 66 | -------------------------------------------------------------------------------- /src/PlotlyChart.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, forwardRef, useEffect, useRef, useMemo } from 'react'; 2 | import Plotly, { toImage } from 'plotly.js-dist-min'; 3 | import createPlotlyComponent from 'react-plotly.js/factory'; 4 | import { saveAs } from 'file-saver'; 5 | import { ScopedVars } from '@grafana/data'; 6 | 7 | interface ExtendedConfig extends Partial { 8 | imgFormat?: 'png' | 'jpeg' | 'webp' | 'svg'; 9 | exportWidth?: number | null; 10 | exportHeight?: number | null; 11 | resScale?: number; 12 | } 13 | 14 | interface PlotlyChartProps { 15 | data: any[]; 16 | layout: Partial; 17 | config: ExtendedConfig; 18 | frames?: Plotly.Frame[]; 19 | width: number; 20 | height: number; 21 | onEvent?: (event: { type: 'click' | 'select' | 'zoom'; data: any }) => void; 22 | title: string; 23 | replaceVariables: (value: string, scopedVars?: ScopedVars, format?: string | Function) => string; 24 | } 25 | 26 | const Plot: React.ComponentType = createPlotlyComponent(Plotly); 27 | 28 | export const PlotlyChart = forwardRef( 29 | ({ data, layout, config, frames, width, height, onEvent, title, replaceVariables }, ref) => { 30 | const latestConfigRef = useRef(config); 31 | 32 | useEffect(() => { 33 | latestConfigRef.current = config; 34 | }, [config]); 35 | 36 | const processedTitle = useMemo(() => { 37 | const replacedTitle = replaceVariables(title); 38 | return replacedTitle.replace(/\./g, ''); 39 | }, [title, replaceVariables]); 40 | 41 | const handleImageDownload = useCallback(() => { 42 | const plotlyElement = (ref as React.RefObject)?.current?.el; 43 | if (plotlyElement) { 44 | const currentLayout = plotlyElement.layout; 45 | const currentConfig = latestConfigRef.current; 46 | 47 | const exportConfig = { 48 | format: currentConfig.imgFormat || 'png', 49 | width: currentConfig.exportWidth || currentLayout.width, 50 | height: currentConfig.exportHeight || currentLayout.height, 51 | scale: currentConfig.resScale || 2, 52 | }; 53 | 54 | toImage(plotlyElement, exportConfig).then((data) => saveAs(data, `${processedTitle}.${exportConfig.format}`)); 55 | } 56 | }, [ref, processedTitle]); 57 | 58 | const updatedConfig = useMemo( 59 | () => ({ 60 | ...config, 61 | modeBarButtonsToAdd: [ 62 | { 63 | name: 'toImageGrafana', 64 | title: 'Export plot as an image', 65 | icon: Plotly.Icons.camera, 66 | click: handleImageDownload, 67 | }, 68 | ], 69 | modeBarButtonsToRemove: ['toImage'] as Plotly.ModeBarDefaultButtons[], 70 | displaylogo: false, 71 | }), 72 | [config, handleImageDownload] 73 | ); 74 | 75 | return ( 76 | 85 | onEvent?.({ 86 | type: 'click', 87 | data: clickData, 88 | }) 89 | } 90 | onSelected={(selectData: any) => 91 | onEvent?.({ 92 | type: 'select', 93 | data: selectData, 94 | }) 95 | } 96 | onRelayout={(relayoutData: any) => { 97 | if (relayoutData['xaxis.range[0]'] || relayoutData['yaxis.range[0]']) { 98 | onEvent?.({ 99 | type: 'zoom', 100 | data: relayoutData, 101 | }); 102 | } 103 | }} 104 | /> 105 | ); 106 | } 107 | ); 108 | 109 | PlotlyChart.displayName = 'PlotlyChart'; 110 | -------------------------------------------------------------------------------- /src/SimplePanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useState, useEffect, useRef } from 'react'; 2 | import { PanelProps, dateTime } from '@grafana/data'; 3 | import { getTemplateSrv, locationService } from '@grafana/runtime'; 4 | import { SimpleOptions } from 'types'; 5 | import { processData, emptyData } from './dataUtils'; 6 | import { useScriptEvaluation } from './useScriptEvaluation'; 7 | import { useChartConfig } from './useChartConfig'; 8 | import { PlotlyChart } from './PlotlyChart'; 9 | import { ErrorDisplay } from './ErrorDisplay'; 10 | import { useTheme2 } from '@grafana/ui'; 11 | 12 | interface Props extends PanelProps { 13 | onChangeTimeRange: (timeRange: { from: number; to: number }) => void; 14 | } 15 | 16 | export const SimplePanel: React.FC = ({ 17 | options, 18 | data, 19 | width, 20 | height, 21 | timeZone, 22 | replaceVariables, 23 | title, 24 | onChangeTimeRange, 25 | }) => { 26 | const plotRef = useRef(null); 27 | const [error, setError] = useState(null); 28 | const { evaluateScript, error: scriptError } = useScriptEvaluation(); 29 | 30 | const processedData = useMemo(() => { 31 | try { 32 | return processData(data, timeZone, options.timeCol); 33 | } catch (e: any) { 34 | setError(e); 35 | return null; 36 | } 37 | }, [data, timeZone, options.timeCol]); 38 | 39 | const scriptVars = useMemo(() => { 40 | if (!processedData) { 41 | return null; 42 | } 43 | 44 | const templateSrv = getTemplateSrv(); 45 | const dashboardVariables = templateSrv.getVariables().reduce((acc, v) => ({ ...acc, [v.name]: v }), {}); 46 | 47 | const nativeVariables = { 48 | __from: data.timeRange.from.valueOf(), 49 | __to: data.timeRange.to.valueOf(), 50 | __interval: data.request?.interval, 51 | __interval_ms: data.request?.intervalMs, 52 | __timezone: timeZone, 53 | __timeFilter: (field: string) => { 54 | return templateSrv.replace(`$__timeFilter(${field})`, data.request?.scopedVars); 55 | }, 56 | __dashboard: { 57 | uid: data.request?.dashboardUID, 58 | // Add other dashboard properties as needed 59 | }, 60 | }; 61 | 62 | const mergedVariables = { ...dashboardVariables, ...nativeVariables }; 63 | 64 | return { 65 | data: processedData, 66 | variables: mergedVariables, 67 | options, 68 | utils: { 69 | timeZone, 70 | dayjs: require('dayjs'), 71 | matchTimezone: require('./dataUtils').matchTimezone, 72 | locationService, 73 | getTemplateSrv, 74 | replaceVariables: (variableName: string) => templateSrv.replace(variableName, data.request?.scopedVars), 75 | }, 76 | }; 77 | }, [processedData, options, timeZone, data.timeRange, data.request]); 78 | 79 | const evaluatedScript = useMemo(() => { 80 | if (!scriptVars) { 81 | return null; 82 | } 83 | try { 84 | return evaluateScript(options.script, scriptVars); 85 | } catch (e: any) { 86 | // Don't set the error state here, let the scriptError from useScriptEvaluation handle it 87 | return null; 88 | } 89 | }, [options.script, scriptVars, evaluateScript]); 90 | 91 | const theme = useTheme2(); 92 | const chartConfig = useChartConfig(options, evaluatedScript, replaceVariables, width, height, theme, data); 93 | 94 | useEffect(() => { 95 | // Clear errors when options or data change, but not scriptError 96 | setError(null); 97 | }, [options, data]); 98 | 99 | const handleEvent = useCallback( 100 | (event: { type: 'click' | 'select' | 'zoom'; data: any }) => { 101 | if (options.onclick && scriptVars) { 102 | const eventContext = { 103 | ...scriptVars, 104 | event, 105 | }; 106 | try { 107 | evaluateScript(options.onclick, eventContext); 108 | } catch (e: any) { 109 | setError(e); 110 | } 111 | } 112 | 113 | if ( 114 | options.syncTimeRange && 115 | event.type === 'zoom' && 116 | event.data['xaxis.range[0]'] && 117 | event.data['xaxis.range[1]'] 118 | ) { 119 | const from = dateTime(event.data['xaxis.range[0]']); 120 | const to = dateTime(event.data['xaxis.range[1]']); 121 | onChangeTimeRange({ 122 | from: from.valueOf(), 123 | to: to.valueOf(), 124 | }); 125 | } 126 | }, 127 | [options.onclick, options.syncTimeRange, scriptVars, evaluateScript, onChangeTimeRange] 128 | ); 129 | 130 | if (scriptError) { 131 | return ( 132 | 140 | ); 141 | } 142 | 143 | if (error) { 144 | return ; 145 | } 146 | 147 | const { isEmpty, message } = emptyData(chartConfig.data); 148 | 149 | if (!chartConfig || isEmpty) { 150 | return ; 151 | } 152 | 153 | return ( 154 | 166 | ); 167 | }; 168 | -------------------------------------------------------------------------------- /src/dataUtils.ts: -------------------------------------------------------------------------------- 1 | import { PanelData } from '@grafana/data'; 2 | import dayjs from 'dayjs'; 3 | import utc from 'dayjs/plugin/utc'; 4 | import timezone from 'dayjs/plugin/timezone'; 5 | import merge from 'deepmerge'; 6 | import _ from 'lodash'; 7 | 8 | dayjs.extend(utc); 9 | dayjs.extend(timezone); 10 | 11 | export const matchTimezone = (timeStamps: number[], timeZone: string): number[] => { 12 | const local = dayjs.tz.guess(); 13 | const tz = timeZone === 'browser' ? local : timeZone; 14 | const localOffset = dayjs().tz(local).utcOffset(); 15 | const dashOffset = dayjs().tz(tz).utcOffset(); 16 | 17 | const offset = (localOffset - dashOffset) * 60 * 1000; 18 | return timeStamps.map((ts) => ts - offset); 19 | }; 20 | 21 | export const processData = (data: PanelData, timeZone: string, timeCol?: string) => { 22 | if (!data.series || data.series.length === 0) { 23 | return data; 24 | } 25 | 26 | const correctedSeries = data.series.map((series) => { 27 | const fields = series.fields.map((field) => { 28 | if (field.name === timeCol) { 29 | return { 30 | ...field, 31 | values: matchTimezone(field.values, timeZone), 32 | }; 33 | } 34 | return field; 35 | }); 36 | return { ...series, fields }; 37 | }); 38 | 39 | return { ...data, series: correctedSeries }; 40 | }; 41 | 42 | export const combineMerge = (target: any, source: any, options: any) => { 43 | const destination = target.slice(); 44 | source.forEach((item: any, index: any) => { 45 | if (typeof destination[index] === 'undefined') { 46 | destination[index] = options.cloneUnlessOtherwiseSpecified(item, options); 47 | } else if (options.isMergeableObject(item)) { 48 | destination[index] = merge(target[index], item, options); 49 | } else if (target.indexOf(item) === -1) { 50 | destination.push(item); 51 | } 52 | }); 53 | return destination; 54 | }; 55 | 56 | export const fmtValues = (data: any, transformFn: (value: string) => string): any => { 57 | if (Array.isArray(data)) { 58 | return _.map(data, (item) => fmtValues(item, transformFn)); 59 | } else if (typeof data === 'object' && data !== null) { 60 | return _.mapValues(data, (value) => fmtValues(value, transformFn)); 61 | } else if (typeof data === 'string') { 62 | return transformFn(data); 63 | } 64 | return data; 65 | }; 66 | 67 | export const emptyData = (data: any): { isEmpty: boolean; message: string } => { 68 | if (!Array.isArray(data) || data.length === 0) { 69 | return { isEmpty: true, message: 'Data is empty or not an array of traces' }; 70 | } 71 | 72 | for (const trace of data) { 73 | if (typeof trace === 'object' && trace !== null && Object.keys(trace).length > 0) { 74 | // Check if any property in the trace has a non-null value 75 | for (const key in trace) { 76 | if (trace[key] !== null && trace[key] !== undefined) { 77 | return { isEmpty: false, message: 'Data contains non-empty traces' }; 78 | } 79 | } 80 | } 81 | } 82 | 83 | return { isEmpty: true, message: 'All traces are empty' }; 84 | }; 85 | -------------------------------------------------------------------------------- /src/img/plotly-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nline/nline-plotlyjs-panel/ddc9e4a2d124986902e7f4629fbe50366bcb08eb/src/img/plotly-large.png -------------------------------------------------------------------------------- /src/img/plotly-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nline/nline-plotlyjs-panel/ddc9e4a2d124986902e7f4629fbe50366bcb08eb/src/img/plotly-small.png -------------------------------------------------------------------------------- /src/migrations.ts: -------------------------------------------------------------------------------- 1 | import { PanelModel } from '@grafana/data'; 2 | import { SimpleOptions } from './types'; 3 | import semver from 'semver'; 4 | 5 | export const plotlyPanelMigrationHandler = (panel: PanelModel): Partial => { 6 | const options: any = panel.options || {}; 7 | 8 | // Migrate scripts only if coming from a version before 1.8.0 9 | if (panel.pluginVersion && semver.lt(panel.pluginVersion, '1.8.0')) { 10 | if (options.script) { 11 | options.script = migrateScriptVariables(options.script); 12 | } 13 | if (options.onclick) { 14 | options.onclick = migrateScriptVariables(options.onclick); 15 | } 16 | } 17 | 18 | return options; 19 | }; 20 | 21 | const migrateScriptVariables = (script: string): string => { 22 | // Replace old variable names with new ones 23 | const replacements: Array<[RegExp, string]> = [ 24 | [/\bparameters\b/g, 'options'], 25 | [/\btimeZone\b/g, 'utils.timeZone'], 26 | [/\bdayjs\b/g, 'utils.dayjs'], 27 | [/\bmatchTimezone\b/g, 'utils.matchTimezone'], 28 | [/\blocationService\b/g, 'utils.locationService'], 29 | [/\bgetTemplateSrv\b/g, 'utils.getTemplateSrv'], 30 | ]; 31 | 32 | let migratedScript = script; 33 | for (const [oldVar, newVar] of replacements) { 34 | migratedScript = migratedScript.replace(oldVar, newVar); 35 | } 36 | 37 | return migratedScript; 38 | }; 39 | -------------------------------------------------------------------------------- /src/module.test.ts: -------------------------------------------------------------------------------- 1 | // Just a stub test 2 | describe('Placeholder test', () => { 3 | it('Return true', () => { 4 | expect(true).toBeTruthy(); 5 | }); 6 | }); 7 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { SimpleOptions, inits, base } from './types'; 2 | import { PanelPlugin, FieldOverrideContext, getFieldDisplayName } from '@grafana/data'; 3 | // Import an entire module for side effects only, without importing anything. 4 | // This runs the module's global code, but doesn't actually import any values. 5 | // It sets the global variable for Plotly before loading plotly.js 6 | import 'utils'; 7 | 8 | import { SimplePanel } from './SimplePanel'; 9 | import { PanelOptionCode } from './PanelOptionCode'; 10 | import { plotlyPanelMigrationHandler } from './migrations'; 11 | 12 | export const plugin = new PanelPlugin(SimplePanel) 13 | .setDataSupport({ annotations: true }) 14 | .setMigrationHandler(plotlyPanelMigrationHandler) 15 | .setPanelOptions((builder) => { 16 | return builder 17 | .addSelect({ 18 | name: 'Image format', 19 | description: 'File type of exported image', 20 | settings: { 21 | options: [ 22 | { value: 'svg', label: 'SVG' }, 23 | { value: 'png', label: 'PNG' }, 24 | { value: 'jpeg', label: 'JPG' }, 25 | { value: 'webp', label: 'WebP' }, 26 | ], 27 | }, 28 | path: 'imgFormat', 29 | defaultValue: 'png', 30 | }) 31 | .addNumberInput({ 32 | name: 'Exported image width', 33 | description: 'Defined width of exported image', 34 | path: 'exportWidth', 35 | }) 36 | .addNumberInput({ 37 | name: 'Exported image height', 38 | description: 'Defined height of exported image', 39 | path: 'exportHeight', 40 | }) 41 | .addNumberInput({ 42 | name: 'Exported resolution scale', 43 | description: 'Factor of exported image resolution (may cause odd spacing)', 44 | path: 'resScale', 45 | defaultValue: 2, 46 | }) 47 | .addSelect({ 48 | name: 'Timezone correction', 49 | description: 'Time column used to correct data received by Plotly into the correct timezone', 50 | path: 'timeCol', 51 | defaultValue: '', 52 | settings: { 53 | allowCustomValue: true, 54 | options: [], 55 | getOptions: async (context: FieldOverrideContext) => { 56 | const options = [{ value: '', label: 'No correction' }]; 57 | if (context && context.data) { 58 | for (const frame of context.data) { 59 | for (const field of frame.fields) { 60 | const name = getFieldDisplayName(field, frame, context.data); 61 | const value = name; 62 | options.push({ value, label: name }); 63 | } 64 | } 65 | } 66 | return Promise.resolve(options); 67 | }, 68 | }, 69 | }) 70 | .addCustomEditor({ 71 | id: 'allData', 72 | path: 'allData', 73 | name: 'Cross-trace Data', 74 | description: 'Data props applied across all traces on the Plotly chart (object)', 75 | editor: PanelOptionCode, 76 | category: ['Data Editor'], 77 | settings: { 78 | editorHeight: 150, 79 | language: 'yaml', 80 | baseValue: base.allData, 81 | initValue: inits.allData, 82 | }, 83 | defaultValue: inits.allData, 84 | }) 85 | .addCustomEditor({ 86 | id: 'data', 87 | path: 'data', 88 | name: 'Data', 89 | description: 'Data object of the Plotly chart (array)', 90 | editor: PanelOptionCode, 91 | category: ['Data Editor'], 92 | settings: { 93 | editorHeight: 150, 94 | language: 'yaml', 95 | baseValue: base.data, 96 | initValue: inits.data, 97 | }, 98 | defaultValue: inits.data, 99 | }) 100 | .addCustomEditor({ 101 | id: 'layout', 102 | path: 'layout', 103 | name: 'Layout', 104 | description: 'Layout object for the Plotly chart (defaults are applied as base)', 105 | editor: PanelOptionCode, 106 | category: ['Layout Editor'], 107 | settings: { 108 | language: 'yaml', 109 | baseValue: base.layout, 110 | initValue: inits.layout, 111 | }, 112 | defaultValue: inits.layout, 113 | }) 114 | .addCustomEditor({ 115 | id: 'config', 116 | path: 'config', 117 | name: 'Configuration', 118 | description: 'Configuration object for the Plotly chart', 119 | editor: PanelOptionCode, 120 | category: ['Config Editor'], 121 | settings: { 122 | editorHeight: 150, 123 | language: 'yaml', 124 | baseValue: base.config, 125 | initValue: inits.config, 126 | }, 127 | defaultValue: inits.config, 128 | }) 129 | .addCustomEditor({ 130 | id: 'script', 131 | path: 'script', 132 | name: 'Processing Script', 133 | description: ` 134 | Script executed whenever new data is available. 135 | Must return an object with one or more of the following properties: 136 | data, layout, config, frames.`, 137 | editor: PanelOptionCode, 138 | category: ['Script Editor'], 139 | settings: { 140 | language: 'javascript', 141 | }, 142 | defaultValue: inits.script, 143 | }) 144 | .addCustomEditor({ 145 | id: 'onclick', 146 | path: 'onclick', 147 | name: 'On-event Trigger', 148 | description: ` 149 | Script executed when chart is clicked, a selection is made, or a zoom action occurs. 150 | The 'eventType' variable will be 'click', 'select', or 'zoom'.`, 151 | editor: PanelOptionCode, 152 | category: ['On-event Editor'], 153 | settings: { 154 | language: 'javascript', 155 | }, 156 | defaultValue: inits.onclick, 157 | }) 158 | .addBooleanSwitch({ 159 | path: 'syncTimeRange', 160 | name: 'Sync Time Range', 161 | description: 'Synchronize dashboard time range with chart zoom', 162 | defaultValue: false, 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /src/panel.css: -------------------------------------------------------------------------------- 1 | .react-resizable > div:first-child { 2 | height: 100%; 3 | } 4 | .react-resizable-handle { 5 | visibility: visible !important; 6 | filter: invert(1); 7 | mix-blend-mode: difference; 8 | } 9 | -------------------------------------------------------------------------------- /src/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "panel", 3 | "name": "Plotly", 4 | "id": "nline-plotlyjs-panel", 5 | "info": { 6 | "description": "Render charts with Plotly.js", 7 | "author": { 8 | "name": "nLine" 9 | }, 10 | "keywords": ["Plotly"], 11 | "logos": { 12 | "small": "img/plotly-small.png", 13 | "large": "img/plotly-large.png" 14 | }, 15 | "links": [ 16 | { 17 | "name": "Website", 18 | "url": "https://github.com/nline/nline-plotlyjs-panel" 19 | } 20 | ], 21 | "screenshots": [ 22 | { 23 | "name": "Editor", 24 | "path": "screenshots/editor.png" 25 | }, 26 | { 27 | "name": "Panel", 28 | "path": "screenshots/panel.png" 29 | } 30 | ], 31 | "version": "%VERSION%", 32 | "updated": "%TODAY%" 33 | }, 34 | "dependencies": { 35 | "grafanaDependency": ">=9.0.0", 36 | "grafanaVersion": "9.x", 37 | "plugins": [] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/screenshots/editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nline/nline-plotlyjs-panel/ddc9e4a2d124986902e7f4629fbe50366bcb08eb/src/screenshots/editor.png -------------------------------------------------------------------------------- /src/screenshots/panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nline/nline-plotlyjs-panel/ddc9e4a2d124986902e7f4629fbe50366bcb08eb/src/screenshots/panel.png -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface SimpleOptions { 2 | imgFormat: 'svg' | 'png' | 'jpeg' | 'webp'; 3 | exportWidth: number | null; 4 | exportHeight: number | null; 5 | resScale: number; 6 | timeCol: string; 7 | syncTimeRange: boolean; 8 | title: string; 9 | allData: object; 10 | data: any[]; 11 | layout: object; 12 | config: object; 13 | frames: any[]; 14 | script: string; 15 | onclick: string; 16 | } 17 | 18 | export interface SimpleBase { 19 | allData: object; 20 | data: any[]; 21 | layout: object; 22 | config: object; 23 | frames: any[]; 24 | } 25 | 26 | export type EditorCodeType = string | undefined; 27 | 28 | export type EditorLanguageType = 'javascript' | 'html' | 'yaml' | undefined; 29 | 30 | const defaultLayout = { 31 | font: { 32 | family: 'Inter, Helvetica, Arial, sans-serif', 33 | }, 34 | xaxis: { 35 | type: 'date', 36 | autorange: true, 37 | automargin: true, 38 | }, 39 | yaxis: { 40 | autorange: true, 41 | automargin: true, 42 | }, 43 | title: { 44 | automargin: true, 45 | }, 46 | margin: { 47 | l: 0, 48 | r: 0, 49 | b: 0, 50 | t: 0, 51 | }, 52 | }; 53 | 54 | // Defaults that Plotly falls back to 55 | export const base: SimpleBase = { 56 | allData: {}, 57 | data: [], 58 | layout: defaultLayout, 59 | config: {}, 60 | frames: [], 61 | }; 62 | 63 | // Defaults that Plotly begins with as an example 64 | export const inits: SimpleOptions = { 65 | imgFormat: 'png', 66 | exportWidth: null, 67 | exportHeight: null, 68 | resScale: 2, 69 | timeCol: '', 70 | syncTimeRange: true, 71 | title: 'Plotly panel', 72 | allData: {}, 73 | data: [], 74 | layout: defaultLayout, 75 | config: {}, 76 | frames: [], 77 | script: `\ 78 | // Basic timeseries plot 79 | /* 80 | // 'data', 'variables', 'options', and 'utils' are passed as arguments 81 | 82 | let series = data.series[0]; 83 | let x = series.fields[0]; 84 | let y = series.fields[1]; 85 | 86 | return { 87 | data: [{ 88 | x: x.values || x.values.buffer, 89 | y: y.values || y.values.buffer, 90 | type: 'scatter', 91 | mode: 'lines', 92 | name: x.name 93 | }], 94 | layout: { 95 | xaxis: { title: x.name }, 96 | yaxis: { title: y.name } 97 | } 98 | } 99 | */ 100 | return {} 101 | `, 102 | onclick: `\ 103 | // Event handling 104 | /* 105 | // 'data', 'variables', 'options', 'utils', and 'event' are passed as arguments 106 | 107 | try { 108 | const { type: eventType, data: eventData } = event; 109 | const { timeZone, dayjs, locationService, getTemplateSrv } = utils; 110 | 111 | switch (eventType) { 112 | case 'click': 113 | console.log('Click event:', eventData.points); 114 | break; 115 | case 'select': 116 | console.log('Selection event:', eventData.range); 117 | break; 118 | case 'zoom': 119 | console.log('Zoom event:', eventData); 120 | break; 121 | default: 122 | console.log('Unhandled event type:', eventType, eventData); 123 | } 124 | 125 | console.log('Current time zone:', timeZone); 126 | console.log('From time:', dayjs(variables.__from).format()); 127 | console.log('To time:', dayjs(variables.__to).format()); 128 | 129 | // Example of using locationService 130 | // locationService.partial({ 'var-example': 'test' }, true); 131 | 132 | } catch (error) { 133 | console.error('Error in onclick handler:', error); 134 | } 135 | */ 136 | `, 137 | }; 138 | -------------------------------------------------------------------------------- /src/useChartConfig.ts: -------------------------------------------------------------------------------- 1 | import merge from 'deepmerge'; 2 | import { useMemo } from 'react'; 3 | import { SimpleOptions, base } from './types'; 4 | import { fmtValues, combineMerge } from './dataUtils'; 5 | 6 | export const useChartConfig = ( 7 | options: SimpleOptions, 8 | evaluatedScript: any, 9 | replaceVariables: (str: string) => string, 10 | width: number, 11 | height: number, 12 | theme: any, 13 | data: any 14 | ) => { 15 | return useMemo(() => { 16 | const textColor = theme.colors.text.primary; 17 | const backgroundColor = theme.colors.background.primary; 18 | const altbackgroundColor = theme.colors.background.secondary; 19 | 20 | const themedLayout = { 21 | font: { 22 | color: textColor, 23 | }, 24 | paper_bgcolor: backgroundColor, 25 | plot_bgcolor: backgroundColor, 26 | hoverlabel: { 27 | bgcolor: textColor, 28 | }, 29 | xaxis: { 30 | gridcolor: altbackgroundColor, 31 | }, 32 | yaxis: { 33 | gridcolor: altbackgroundColor, 34 | }, 35 | }; 36 | 37 | const mergedLayout = merge(themedLayout, options.layout ?? {}); 38 | let layout = fmtValues(mergedLayout, replaceVariables); 39 | 40 | let data = fmtValues(options.data ?? base.data, replaceVariables); 41 | const allData = fmtValues(options.allData ?? null, replaceVariables); 42 | let config = fmtValues(options.config ?? base.config, replaceVariables); 43 | let frames = fmtValues(options.frames ?? base.frames, replaceVariables); 44 | 45 | if (evaluatedScript) { 46 | data = evaluatedScript.data ? merge(data, evaluatedScript.data, { arrayMerge: combineMerge }) : data; 47 | layout = evaluatedScript.layout ? merge(layout, evaluatedScript.layout) : layout; 48 | config = evaluatedScript.config ? merge(config, evaluatedScript.config) : config; 49 | frames = evaluatedScript.frames ? merge(frames, evaluatedScript.frames, { arrayMerge: combineMerge }) : frames; 50 | } 51 | 52 | if (options.allData != null && data != null) { 53 | if (Array.isArray(data)) { 54 | data = data.map((item: any) => merge(allData, item, { arrayMerge: (_, sourceArray) => sourceArray })); 55 | } 56 | } 57 | 58 | const updatedConfig = { 59 | ...config, 60 | imgFormat: options.imgFormat, 61 | exportWidth: options.exportWidth, 62 | exportHeight: options.exportHeight, 63 | resScale: options.resScale, 64 | }; 65 | 66 | return { data, layout, config: updatedConfig, frames }; 67 | // eslint-disable-next-line react-hooks/exhaustive-deps 68 | }, [options, evaluatedScript, replaceVariables, width, height, theme, data]); 69 | }; 70 | -------------------------------------------------------------------------------- /src/useScriptEvaluation.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | 3 | export const useScriptEvaluation = () => { 4 | const [error, setError] = useState<{ message: string; lineNumber: number | string; line: string } | null>(null); 5 | 6 | const evaluateScript = useCallback((script: string, context: any) => { 7 | try { 8 | const f = new Function(...Object.keys(context), script); 9 | const result = f(...Object.values(context)); 10 | 11 | // Validate the result 12 | if (result && typeof result === 'object' && 'data' in result) { 13 | if (!Array.isArray(result.data)) { 14 | throw new Error("The 'data' property must be an array"); 15 | } 16 | } 17 | 18 | setError(null); // Clear the error when successful 19 | return result; 20 | } catch (e: any) { 21 | let lineNumber: any = 'unknown'; 22 | let line = ''; 23 | 24 | const lines = script.split('\n'); 25 | if (e.stack) { 26 | // Try to match line numbers for both Chrome and Firefox 27 | const match = e.stack.match(/:(\d+):\d+/); 28 | if (match) { 29 | lineNumber = parseInt(match[1], 10); 30 | // Adjust for the function wrapper 31 | lineNumber = Math.max(1, lineNumber - 2); 32 | line = lines[lineNumber - 1]; 33 | } 34 | } 35 | 36 | setError({ 37 | message: e.message, 38 | lineNumber, 39 | line: line, 40 | }); 41 | return null; 42 | } 43 | }, []); 44 | 45 | return { evaluateScript, error, setError }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // https://github.com/plotly/plotly.js/blob/master/dist/README.md#to-support-mathjax 2 | globalThis.PlotlyConfig = { MathJaxConfig: 'local' }; 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------