├── .config ├── .cprc.json ├── .eslintrc ├── .prettierrc.js ├── Dockerfile ├── README.md ├── docker-compose-base.yaml ├── 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 │ ├── BuildModeWebpackPlugin.ts │ ├── constants.ts │ ├── utils.ts │ └── webpack.config.ts ├── .cprc.json ├── .eslintrc ├── .github └── workflows │ ├── bundle-stats.yml │ ├── ci.yml │ ├── cp-update.yml │ ├── is-compatible.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docker-compose.yaml ├── jest-setup.js ├── jest.config.js ├── package-lock.json ├── package.json ├── playwright.config.ts ├── src ├── ACESVGjsPanel.tsx ├── README.md ├── examples.ts ├── img │ ├── ace-svg-react.png │ ├── logo-small-background.svg │ ├── logo-type.svg │ └── logo.svg ├── module.ts ├── options.tsx ├── plugin.json └── types.ts └── tsconfig.json /.config/.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5.19.1" 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 | "files": ["./tests/**/*"], 26 | "rules": { 27 | "react-hooks/rules-of-hooks": "off" 28 | } 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.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 anonymous_auth_enabled=true 7 | ARG development=false 8 | ARG TARGETARCH 9 | 10 | 11 | ENV DEV "${development}" 12 | 13 | # Make it as simple as possible to access the grafana instance for development purposes 14 | # Do NOT enable these settings in a public facing / production grafana instance 15 | ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin" 16 | ENV GF_AUTH_ANONYMOUS_ENABLED "${anonymous_auth_enabled}" 17 | ENV GF_AUTH_BASIC_ENABLED "false" 18 | # Set development mode so plugins can be loaded without the need to sign 19 | ENV GF_DEFAULT_APP_MODE "development" 20 | 21 | 22 | LABEL maintainer="Grafana Labs " 23 | 24 | ENV GF_PATHS_HOME="/usr/share/grafana" 25 | WORKDIR $GF_PATHS_HOME 26 | 27 | USER root 28 | 29 | # Installing supervisor and inotify-tools 30 | RUN if [ "${development}" = "true" ]; then \ 31 | if grep -i -q alpine /etc/issue; then \ 32 | apk add supervisor inotify-tools git; \ 33 | elif grep -i -q ubuntu /etc/issue; then \ 34 | DEBIAN_FRONTEND=noninteractive && \ 35 | apt-get update && \ 36 | apt-get install -y supervisor inotify-tools git && \ 37 | rm -rf /var/lib/apt/lists/*; \ 38 | else \ 39 | echo 'ERROR: Unsupported base image' && /bin/false; \ 40 | fi \ 41 | fi 42 | 43 | COPY supervisord/supervisord.conf /etc/supervisor.d/supervisord.ini 44 | COPY supervisord/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 45 | 46 | 47 | 48 | # Inject livereload script into grafana index.html 49 | RUN sed -i 's|||g' /usr/share/grafana/public/views/index.html 50 | 51 | 52 | COPY entrypoint.sh /entrypoint.sh 53 | RUN chmod +x /entrypoint.sh 54 | ENTRYPOINT ["/entrypoint.sh"] 55 | -------------------------------------------------------------------------------- /.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 | extends: 155 | file: .config/docker-compose-base.yaml 156 | service: grafana 157 | build: 158 | args: 159 | grafana_version: ${GRAFANA_VERSION:-9.1.2} 160 | grafana_image: ${GRAFANA_IMAGE:-grafana} 161 | ``` 162 | 163 | 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. 164 | 165 | --- 166 | -------------------------------------------------------------------------------- /.config/docker-compose-base.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | user: root 4 | container_name: 'aceiot-svg-panel' 5 | 6 | build: 7 | context: . 8 | args: 9 | grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} 10 | grafana_version: ${GRAFANA_VERSION:-11.5.3} 11 | development: ${DEVELOPMENT:-false} 12 | anonymous_auth_enabled: ${ANONYMOUS_AUTH_ENABLED:-true} 13 | ports: 14 | - 3000:3000/tcp 15 | volumes: 16 | - ../dist:/var/lib/grafana/plugins/aceiot-svg-panel 17 | - ../provisioning:/etc/grafana/provisioning 18 | - ..:/root/aceiot-svg-panel 19 | 20 | environment: 21 | NODE_ENV: development 22 | GF_LOG_FILTERS: plugin.aceiot-svg-panel:debug 23 | GF_LOG_LEVEL: debug 24 | GF_DATAPROXY_LOGGING: 1 25 | GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: aceiot-svg-panel 26 | -------------------------------------------------------------------------------- /.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/BuildModeWebpackPlugin.ts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | 3 | const PLUGIN_NAME = 'BuildModeWebpack'; 4 | 5 | export class BuildModeWebpackPlugin { 6 | apply(compiler: webpack.Compiler) { 7 | compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { 8 | compilation.hooks.processAssets.tap( 9 | { 10 | name: PLUGIN_NAME, 11 | stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, 12 | }, 13 | async () => { 14 | const assets = compilation.getAssets(); 15 | for (const asset of assets) { 16 | if (asset.name.endsWith('plugin.json')) { 17 | const pluginJsonString = asset.source.source().toString(); 18 | const pluginJsonWithBuildMode = JSON.stringify( 19 | { 20 | ...JSON.parse(pluginJsonString), 21 | buildMode: compilation.options.mode, 22 | }, 23 | null, 24 | 4 25 | ); 26 | compilation.updateAsset(asset.name, new webpack.sources.RawSource(pluginJsonWithBuildMode)); 27 | } 28 | } 29 | } 30 | ); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.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 { SubresourceIntegrityPlugin } from 'webpack-subresource-integrity'; 15 | import { type Configuration, BannerPlugin } from 'webpack'; 16 | import LiveReloadPlugin from 'webpack-livereload-plugin'; 17 | import VirtualModulesPlugin from 'webpack-virtual-modules'; 18 | 19 | import { BuildModeWebpackPlugin } from './BuildModeWebpackPlugin'; 20 | import { DIST_DIR, SOURCE_DIR } from './constants'; 21 | import { getCPConfigVersion, getEntries, getPackageJson, getPluginJson, hasReadme, isWSL } from './utils'; 22 | 23 | const pluginJson = getPluginJson(); 24 | const cpVersion = getCPConfigVersion(); 25 | 26 | const virtualPublicPath = new VirtualModulesPlugin({ 27 | 'node_modules/grafana-public-path.js': ` 28 | import amdMetaModule from 'amd-module'; 29 | 30 | __webpack_public_path__ = 31 | amdMetaModule && amdMetaModule.uri 32 | ? amdMetaModule.uri.slice(0, amdMetaModule.uri.lastIndexOf('/') + 1) 33 | : 'public/plugins/${pluginJson.id}/'; 34 | `, 35 | }); 36 | 37 | const config = async (env): Promise => { 38 | const baseConfig: Configuration = { 39 | cache: { 40 | type: 'filesystem', 41 | buildDependencies: { 42 | config: [__filename], 43 | }, 44 | }, 45 | 46 | context: path.join(process.cwd(), SOURCE_DIR), 47 | 48 | devtool: env.production ? 'source-map' : 'eval-source-map', 49 | 50 | entry: await getEntries(), 51 | 52 | externals: [ 53 | // Required for dynamic publicPath resolution 54 | { 'amd-module': 'module' }, 55 | 'lodash', 56 | 'jquery', 57 | 'moment', 58 | 'slate', 59 | 'emotion', 60 | '@emotion/react', 61 | '@emotion/css', 62 | 'prismjs', 63 | 'slate-plain-serializer', 64 | '@grafana/slate-react', 65 | 'react', 66 | 'react-dom', 67 | 'react-redux', 68 | 'redux', 69 | 'rxjs', 70 | 'react-router', 71 | 'react-router-dom', 72 | 'd3', 73 | 'angular', 74 | /^@grafana\/ui/i, 75 | /^@grafana\/runtime/i, 76 | /^@grafana\/data/i, 77 | 78 | // Mark legacy SDK imports as external if their name starts with the "grafana/" prefix 79 | ({ request }, callback) => { 80 | const prefix = 'grafana/'; 81 | const hasPrefix = (request) => request.indexOf(prefix) === 0; 82 | const stripPrefix = (request) => request.substr(prefix.length); 83 | 84 | if (hasPrefix(request)) { 85 | return callback(undefined, stripPrefix(request)); 86 | } 87 | 88 | callback(); 89 | }, 90 | ], 91 | 92 | // Support WebAssembly according to latest spec - makes WebAssembly module async 93 | experiments: { 94 | asyncWebAssembly: true, 95 | }, 96 | 97 | mode: env.production ? 'production' : 'development', 98 | 99 | module: { 100 | rules: [ 101 | // This must come first in the rules array otherwise it breaks sourcemaps. 102 | { 103 | test: /src\/(?:.*\/)?module\.tsx?$/, 104 | use: [ 105 | { 106 | loader: 'imports-loader', 107 | options: { 108 | imports: `side-effects grafana-public-path`, 109 | }, 110 | }, 111 | ], 112 | }, 113 | { 114 | exclude: /(node_modules)/, 115 | test: /\.[tj]sx?$/, 116 | use: { 117 | loader: 'swc-loader', 118 | options: { 119 | jsc: { 120 | baseUrl: path.resolve(process.cwd(), SOURCE_DIR), 121 | target: 'es2015', 122 | loose: false, 123 | parser: { 124 | syntax: 'typescript', 125 | tsx: true, 126 | decorators: false, 127 | dynamicImport: true, 128 | }, 129 | }, 130 | }, 131 | }, 132 | }, 133 | { 134 | test: /\.css$/, 135 | use: ['style-loader', 'css-loader'], 136 | }, 137 | { 138 | test: /\.s[ac]ss$/, 139 | use: ['style-loader', 'css-loader', 'sass-loader'], 140 | }, 141 | { 142 | test: /\.(png|jpe?g|gif|svg)$/, 143 | type: 'asset/resource', 144 | generator: { 145 | filename: Boolean(env.production) ? '[hash][ext]' : '[file]', 146 | }, 147 | }, 148 | { 149 | test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, 150 | type: 'asset/resource', 151 | generator: { 152 | filename: Boolean(env.production) ? '[hash][ext]' : '[file]', 153 | }, 154 | }, 155 | ], 156 | }, 157 | 158 | optimization: { 159 | minimize: Boolean(env.production), 160 | minimizer: [ 161 | new TerserPlugin({ 162 | terserOptions: { 163 | format: { 164 | comments: (_, { type, value }) => type === 'comment2' && value.trim().startsWith('[create-plugin]'), 165 | }, 166 | compress: { 167 | drop_console: ['log', 'info'], 168 | }, 169 | }, 170 | }), 171 | ], 172 | }, 173 | 174 | output: { 175 | clean: { 176 | keep: new RegExp(`(.*?_(amd64|arm(64)?)(.exe)?|go_plugin_build_manifest)`), 177 | }, 178 | filename: '[name].js', 179 | chunkFilename: env.production ? '[name].js?_cache=[contenthash]' : '[name].js', 180 | library: { 181 | type: 'amd', 182 | }, 183 | path: path.resolve(process.cwd(), DIST_DIR), 184 | publicPath: `public/plugins/${pluginJson.id}/`, 185 | uniqueName: pluginJson.id, 186 | crossOriginLoading: 'anonymous', 187 | }, 188 | 189 | plugins: [ 190 | new BuildModeWebpackPlugin(), 191 | virtualPublicPath, 192 | // Insert create plugin version information into the bundle 193 | new BannerPlugin({ 194 | banner: '/* [create-plugin] version: ' + cpVersion + ' */', 195 | raw: true, 196 | entryOnly: true, 197 | }), 198 | new CopyWebpackPlugin({ 199 | patterns: [ 200 | // If src/README.md exists use it; otherwise the root README 201 | // To `compiler.options.output` 202 | { from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true }, 203 | { from: 'plugin.json', to: '.' }, 204 | { from: '../LICENSE', to: '.' }, 205 | { from: '../CHANGELOG.md', to: '.', force: true }, 206 | { from: '**/*.json', to: '.' }, 207 | { from: '**/*.svg', to: '.', noErrorOnMissing: true }, 208 | { from: '**/*.png', to: '.', noErrorOnMissing: true }, 209 | { from: '**/*.html', to: '.', noErrorOnMissing: true }, 210 | { from: 'img/**/*', to: '.', noErrorOnMissing: true }, 211 | { from: 'libs/**/*', to: '.', noErrorOnMissing: true }, 212 | { from: 'static/**/*', to: '.', noErrorOnMissing: true }, 213 | { from: '**/query_help.md', to: '.', noErrorOnMissing: true }, 214 | ], 215 | }), 216 | // Replace certain template-variables in the README and plugin.json 217 | new ReplaceInFileWebpackPlugin([ 218 | { 219 | dir: DIST_DIR, 220 | files: ['plugin.json', 'README.md'], 221 | rules: [ 222 | { 223 | search: /\%VERSION\%/g, 224 | replace: getPackageJson().version, 225 | }, 226 | { 227 | search: /\%TODAY\%/g, 228 | replace: new Date().toISOString().substring(0, 10), 229 | }, 230 | { 231 | search: /\%PLUGIN_ID\%/g, 232 | replace: pluginJson.id, 233 | }, 234 | ], 235 | }, 236 | ]), 237 | new SubresourceIntegrityPlugin({ 238 | hashFuncNames: ['sha256'], 239 | }), 240 | ...(env.development 241 | ? [ 242 | new LiveReloadPlugin(), 243 | new ForkTsCheckerWebpackPlugin({ 244 | async: Boolean(env.development), 245 | issue: { 246 | include: [{ file: '**/*.{ts,tsx}' }], 247 | }, 248 | typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') }, 249 | }), 250 | new ESLintPlugin({ 251 | extensions: ['.ts', '.tsx'], 252 | lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files 253 | }), 254 | ] 255 | : []), 256 | ], 257 | 258 | resolve: { 259 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 260 | // handle resolving "rootDir" paths 261 | modules: [path.resolve(process.cwd(), 'src'), 'node_modules'], 262 | unsafeCache: true, 263 | }, 264 | }; 265 | 266 | if (isWSL()) { 267 | baseConfig.watchOptions = { 268 | poll: 3000, 269 | ignored: /node_modules/, 270 | }; 271 | } 272 | 273 | return baseConfig; 274 | }; 275 | 276 | export default config; 277 | -------------------------------------------------------------------------------- /.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": { 3 | "bundleGrafanaUI": false, 4 | "useReactRouterV6": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/.eslintrc" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/bundle-stats.yml: -------------------------------------------------------------------------------- 1 | name: Bundle Stats 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | actions: read 12 | 13 | jobs: 14 | compare: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | 21 | - uses: grafana/plugin-actions/bundle-size@main 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | branches: 10 | - master 11 | - main 12 | 13 | permissions: read-all 14 | 15 | jobs: 16 | build: 17 | name: Build, lint and unit tests 18 | runs-on: ubuntu-latest 19 | outputs: 20 | plugin-id: ${{ steps.metadata.outputs.plugin-id }} 21 | plugin-version: ${{ steps.metadata.outputs.plugin-version }} 22 | has-e2e: ${{ steps.check-for-e2e.outputs.has-e2e }} 23 | has-backend: ${{ steps.check-for-backend.outputs.has-backend }} 24 | env: 25 | GRAFANA_ACCESS_POLICY_TOKEN: ${{ secrets.GRAFANA_ACCESS_POLICY_TOKEN }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Setup Node.js environment 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: '22' 32 | cache: 'npm' 33 | 34 | - name: Install dependencies 35 | run: npm ci 36 | 37 | - name: Check types 38 | run: npm run typecheck 39 | - name: Lint 40 | run: npm run lint 41 | - name: Unit tests 42 | run: npm run test:ci 43 | - name: Build frontend 44 | run: | 45 | if [ "${{ github.event_name }}" == "push" ] && [ "${{ github.ref }}" == "refs/heads/main" ]; then 46 | npm run build -- --profile --json stats.json 47 | else 48 | npm run build 49 | fi 50 | 51 | - name: Check for backend 52 | id: check-for-backend 53 | run: | 54 | if [ -f "Magefile.go" ] 55 | then 56 | echo "has-backend=true" >> $GITHUB_OUTPUT 57 | fi 58 | 59 | - name: Setup Go environment 60 | if: steps.check-for-backend.outputs.has-backend == 'true' 61 | uses: actions/setup-go@v5 62 | with: 63 | go-version: '1.22' 64 | 65 | - name: Test backend 66 | if: steps.check-for-backend.outputs.has-backend == 'true' 67 | uses: magefile/mage-action@v3 68 | with: 69 | version: latest 70 | args: coverage 71 | 72 | - name: Build backend 73 | if: steps.check-for-backend.outputs.has-backend == 'true' 74 | uses: magefile/mage-action@v3 75 | with: 76 | version: latest 77 | args: buildAll 78 | 79 | - name: Check for E2E 80 | id: check-for-e2e 81 | run: | 82 | if [ -f "playwright.config.ts" ] 83 | then 84 | echo "has-e2e=true" >> $GITHUB_OUTPUT 85 | fi 86 | 87 | - name: Sign plugin 88 | run: npm run sign 89 | if: ${{ env.GRAFANA_ACCESS_POLICY_TOKEN != '' }} 90 | 91 | - name: Get plugin metadata 92 | id: metadata 93 | run: | 94 | sudo apt-get install jq 95 | 96 | export GRAFANA_PLUGIN_ID=$(cat dist/plugin.json | jq -r .id) 97 | export GRAFANA_PLUGIN_VERSION=$(cat dist/plugin.json | jq -r .info.version) 98 | export GRAFANA_PLUGIN_ARTIFACT=${GRAFANA_PLUGIN_ID}-${GRAFANA_PLUGIN_VERSION}.zip 99 | 100 | echo "plugin-id=${GRAFANA_PLUGIN_ID}" >> $GITHUB_OUTPUT 101 | echo "plugin-version=${GRAFANA_PLUGIN_VERSION}" >> $GITHUB_OUTPUT 102 | echo "archive=${GRAFANA_PLUGIN_ARTIFACT}" >> $GITHUB_OUTPUT 103 | 104 | - name: Package plugin 105 | id: package-plugin 106 | run: | 107 | mv dist ${{ steps.metadata.outputs.plugin-id }} 108 | zip ${{ steps.metadata.outputs.archive }} ${{ steps.metadata.outputs.plugin-id }} -r 109 | 110 | - name: Check plugin.json 111 | run: | 112 | docker run --pull=always \ 113 | -v $PWD/${{ steps.metadata.outputs.archive }}:/archive.zip \ 114 | grafana/plugin-validator-cli -analyzer=metadatavalid /archive.zip 115 | 116 | - name: Archive Build 117 | uses: actions/upload-artifact@v4 118 | with: 119 | name: ${{ steps.metadata.outputs.plugin-id }}-${{ steps.metadata.outputs.plugin-version }} 120 | path: ${{ steps.metadata.outputs.plugin-id }} 121 | retention-days: 5 122 | 123 | - name: Upload stats.json artifact 124 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 125 | uses: actions/upload-artifact@v4 126 | with: 127 | name: main-branch-stats 128 | path: stats.json 129 | overwrite: true 130 | 131 | resolve-versions: 132 | name: Resolve e2e images 133 | runs-on: ubuntu-latest 134 | timeout-minutes: 3 135 | needs: build 136 | if: ${{ needs.build.outputs.has-e2e == 'true' }} 137 | outputs: 138 | matrix: ${{ steps.resolve-versions.outputs.matrix }} 139 | steps: 140 | - name: Checkout 141 | uses: actions/checkout@v4 142 | - name: Resolve Grafana E2E versions 143 | id: resolve-versions 144 | uses: grafana/plugin-actions/e2e-version@main 145 | 146 | playwright-tests: 147 | needs: [resolve-versions, build] 148 | timeout-minutes: 15 149 | strategy: 150 | fail-fast: false 151 | matrix: 152 | GRAFANA_IMAGE: ${{fromJson(needs.resolve-versions.outputs.matrix)}} 153 | name: e2e test ${{ matrix.GRAFANA_IMAGE.name }}@${{ matrix.GRAFANA_IMAGE.VERSION }} 154 | runs-on: ubuntu-latest 155 | steps: 156 | - uses: actions/checkout@v4 157 | 158 | - name: Download plugin 159 | uses: actions/download-artifact@v4 160 | with: 161 | path: dist 162 | name: ${{ needs.build.outputs.plugin-id }}-${{ needs.build.outputs.plugin-version }} 163 | 164 | - name: Execute permissions on binary 165 | if: needs.build.outputs.has-backend == 'true' 166 | run: | 167 | chmod +x ./dist/gpx_* 168 | 169 | - name: Setup Node.js environment 170 | uses: actions/setup-node@v4 171 | with: 172 | node-version: '22' 173 | cache: 'npm' 174 | 175 | - name: Install dev dependencies 176 | run: npm ci 177 | 178 | - name: Start Grafana 179 | run: | 180 | docker compose pull 181 | DEVELOPMENT=false GRAFANA_VERSION=${{ matrix.GRAFANA_IMAGE.VERSION }} GRAFANA_IMAGE=${{ matrix.GRAFANA_IMAGE.NAME }} docker compose up -d 182 | 183 | - name: Wait for grafana server 184 | uses: grafana/plugin-actions/wait-for-grafana@main 185 | 186 | - name: Install Playwright Browsers 187 | run: npm exec playwright install chromium --with-deps 188 | 189 | - name: Run Playwright tests 190 | id: run-tests 191 | run: npm run e2e 192 | 193 | - name: Docker logs 194 | if: ${{ always() && steps.run-tests.outcome == 'failure' }} 195 | run: | 196 | docker logs aceiot-svg-panel >& grafana-server.log 197 | 198 | - name: Stop grafana docker 199 | run: docker compose down 200 | 201 | - name: Upload server log 202 | uses: actions/upload-artifact@v4 203 | if: ${{ always() && steps.run-tests.outcome == 'failure' }} 204 | with: 205 | name: ${{ matrix.GRAFANA_IMAGE.NAME }}-v${{ matrix.GRAFANA_IMAGE.VERSION }}-${{github.run_id}}-server-log 206 | path: grafana-server.log 207 | retention-days: 5 208 | 209 | # Uncomment this step to upload the Playwright report to Github artifacts. 210 | # If your repository is public, the report will be public on the Internet so beware not to expose sensitive information. 211 | # - name: Upload artifacts 212 | # uses: actions/upload-artifact@v4 213 | # if: ${{ always() && steps.run-tests.outcome == 'failure' }} 214 | # with: 215 | # name: playwright-report-${{ matrix.GRAFANA_IMAGE.NAME }}-v${{ matrix.GRAFANA_IMAGE.VERSION }}-${{github.run_id}} 216 | # path: playwright-report/ 217 | # retention-days: 5 218 | -------------------------------------------------------------------------------- /.github/workflows/cp-update.yml: -------------------------------------------------------------------------------- 1 | name: Create Plugin Update 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 0 1 * *' # run once a month on the 1st day 7 | 8 | # To use the default github token with the following elevated permissions make sure to check: 9 | # **Allow GitHub Actions to create and approve pull requests** in https://github.com/ORG_NAME/REPO_NAME/settings/actions. 10 | # Alternatively create a fine-grained personal access token for your repository with 11 | # `contents: read and write` and `pull requests: read and write` and pass it to the action. 12 | 13 | permissions: 14 | contents: write 15 | pull-requests: write 16 | 17 | jobs: 18 | release: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: grafana/plugin-actions/create-plugin-update@main 22 | # Uncomment to use a fine-grained personal access token instead of default github token 23 | # (For more info on how to generate the token see https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) 24 | # with: 25 | # Make sure to save the token in your repository secrets 26 | # token: $ 27 | -------------------------------------------------------------------------------- /.github/workflows/is-compatible.yml: -------------------------------------------------------------------------------- 1 | name: Latest Grafana API compatibility check 2 | on: [pull_request] 3 | 4 | jobs: 5 | compatibilitycheck: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - name: Setup Node.js environment 10 | uses: actions/setup-node@v4 11 | with: 12 | node-version: '22' 13 | cache: 'npm' 14 | - name: Install dependencies 15 | run: npm ci 16 | - name: Build plugin 17 | run: npm run build 18 | - name: Compatibility check 19 | run: npx @grafana/levitate@latest is-compatible --path src/module.ts --target @grafana/data,@grafana/ui,@grafana/runtime 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This GitHub Action automates the process of building Grafana plugins. 2 | # (For more information, see https://github.com/grafana/plugin-actions/blob/main/build-plugin/README.md) 3 | name: Release 4 | 5 | on: 6 | push: 7 | tags: 8 | - 'v*' # Run workflow on version tags, e.g. v1.0.0. 9 | 10 | permissions: read-all 11 | 12 | jobs: 13 | release: 14 | permissions: 15 | contents: write 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: grafana/plugin-actions/build-plugin@release 20 | # Uncomment to enable plugin signing 21 | # (For more info on how to generate the access policy token see https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin#generate-an-access-policy-token) 22 | with: 23 | # Make sure to save the token in your repository secrets 24 | policy_token: ${{ secrets.GRAFANA_PLUGIN_SIGNING_TOKEN }} 25 | # Usage of GRAFANA_API_KEY is deprecated, prefer `policy_token` option above 26 | #grafana_token: $ 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | .pnpm-debug.log* 8 | 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 | dist/ 25 | artifacts/ 26 | work/ 27 | ci/ 28 | 29 | # e2e test directories 30 | /test-results/ 31 | /playwright-report/ 32 | /blob-report/ 33 | /playwright/.cache/ 34 | /playwright/.auth/ 35 | 36 | # Editor 37 | .idea 38 | 39 | .eslintcache 40 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require('./.config/.prettierrc.js'), 4 | }; 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 0.1.5 6 | 7 | - Fix dependabot issues with dependencies 8 | - `ws`, `braces`, `fast-loops`, `webpack`, `path-to-regexp` 9 | - Update node version to 22 10 | - Update dependency versions and workflows 11 | - Adds new workflows to bundle stats and update plugin creation 12 | - Update broken badges on main README page 13 | - Add important note about CSS precedence 14 | - Add additional developer instructions 15 | - Move `console.log` into **User JS Init Code** 16 | - Split README into two files: "Grafana marketplace" and "for developers" 17 | - Remove deprecated `` and `` classes, use `` instead 18 | - Remove unused media files 19 | 20 | ## 0.1.4 21 | 22 | - Resolve `SvgMappings` event error (thanks, [@hyunki85](https://github.com/hyunki85)!) 23 | - Update dependencies and workflows 24 | 25 | ## 0.1.3 26 | 27 | - Add some additional useful links to the readme 28 | - Improve code quality by adding `readonly`/`const` modifiers, access modifiers, and function return types 29 | - Replace Monaco code editor with builtin Grafana `CodeEditor` 30 | - Simplify and add comments in the init source in the example 31 | 32 | ## 0.1.2 33 | 34 | - Fix the builtin example 35 | - Update dependency versions 36 | - Update commands in reamde from `yarn` to `npm` 37 | - Remove provisioning folder 38 | - Remove devDependency on `emotion` 39 | 40 | ## 0.1.1 41 | 42 | - Fix bad link in readme 43 | - Update dependency versions 44 | - Clean out some old config files 45 | 46 | ## 0.1.0 47 | 48 | - Update framework for Grafana v10 compatibility -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 ACE IoT Solutions LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ACE.SVG 2 | View the main README page in [src/README.md](./src/README.md) which is shown on the [Grafana Plugin Marketplace](https://grafana.com/grafana/plugins/aceiot-svg-panel/). 3 | 4 | ## Developer Instructions 5 | ### Software Requirements 6 | - [`Node`](https://nodejs.org/en) (version 20+) 7 | - [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) (version 10+) 8 | - [`git`](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 9 | - [`Docker`](https://www.docker.com/) 10 | 11 | ### Getting Started 12 | 1. Clone the repository 13 | ```BASH 14 | git clone https://github.com/ACE-IoT-Solutions/ace-svg-react.git 15 | ``` 16 | 1. Install dependencies 17 | ```BASH 18 | npm i 19 | ``` 20 | 1. Build plugin in development (watch) mode 21 | ```BASH 22 | npm run dev 23 | ``` 24 | 1. Build plugin in production mode 25 | ```BASH 26 | npm run build 27 | ``` 28 | 1. Spin up a Grafana image in Docker 29 | ```BASH 30 | npm run server 31 | ``` -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | user: root 4 | container_name: 'aceiot-svg-panel' 5 | 6 | build: 7 | context: ./.config 8 | args: 9 | grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} 10 | grafana_version: ${GRAFANA_VERSION:-11.3.2} 11 | development: ${DEVELOPMENT:-false} 12 | ports: 13 | - 3000:3000/tcp 14 | volumes: 15 | - ./dist:/var/lib/grafana/plugins/aceiot-svg-panel 16 | - ./provisioning:/etc/grafana/provisioning 17 | - .:/root/aceiot-svg-panel 18 | 19 | environment: 20 | NODE_ENV: development 21 | GF_LOG_FILTERS: plugin.aceiot-svg-panel:debug 22 | GF_LOG_LEVEL: debug 23 | GF_DATAPROXY_LOGGING: 1 24 | GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: aceiot-svg-panel 25 | -------------------------------------------------------------------------------- /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": "aceiot-acesvg-panel", 3 | "version": "0.1.5", 4 | "description": "SVG Visualization Panel", 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 | "test": "jest --watch --onlyChanged", 9 | "test:ci": "jest --passWithNoTests --maxWorkers 4", 10 | "typecheck": "tsc --noEmit", 11 | "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", 12 | "lint:fix": "npm run lint -- --fix && prettier --write --list-different .", 13 | "e2e": "playwright test", 14 | "server": "docker compose up --build", 15 | "sign": "npx --yes @grafana/sign-plugin@latest", 16 | "update": "npx -y @grafana/create-plugin@latest update", 17 | "clean": "rm -rf node_modules package-lock.json .eslintcache dist" 18 | }, 19 | "repository": "github:ACE-IoT-Solutions/ace-svg-react", 20 | "author": "Andrew Rodgers", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/ACE-IoT-Solutions/ace-svg-react/issues", 24 | "email": "andrew@aceiotsolutions.com" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.21.4", 28 | "@grafana/eslint-config": "^8.0.0", 29 | "@grafana/plugin-e2e": "^1.17.1", 30 | "@grafana/tsconfig": "^2.0.0", 31 | "@playwright/test": "1.50.1", 32 | "@stylistic/eslint-plugin-ts": "^2.9.0", 33 | "@swc/core": "^1.3.90", 34 | "@swc/helpers": "^0.5.0", 35 | "@swc/jest": "^0.2.26", 36 | "@testing-library/jest-dom": "6.1.4", 37 | "@testing-library/react": "14.0.0", 38 | "@types/jest": "^29.5.0", 39 | "@types/node": "^20.8.7", 40 | "@types/react-router-dom": "^5.2.0", 41 | "@types/testing-library__jest-dom": "5.14.8", 42 | "@typescript-eslint/eslint-plugin": "^6.18.0", 43 | "@typescript-eslint/parser": "^6.18.0", 44 | "copy-webpack-plugin": "^11.0.0", 45 | "css-loader": "^6.7.3", 46 | "eslint": "^8.0.0", 47 | "eslint-config-prettier": "^8.8.0", 48 | "eslint-plugin-jsdoc": "^46.8.0", 49 | "eslint-plugin-react": "^7.33.0", 50 | "eslint-plugin-react-hooks": "^4.6.0", 51 | "eslint-plugin-deprecation": "^2.0.0", 52 | "eslint-webpack-plugin": "^4.0.1", 53 | "fork-ts-checker-webpack-plugin": "^8.0.0", 54 | "glob": "^10.2.7", 55 | "identity-obj-proxy": "3.0.0", 56 | "imports-loader": "^5.0.0", 57 | "jest": "^29.5.0", 58 | "jest-environment-jsdom": "^29.5.0", 59 | "prettier": "^2.8.7", 60 | "replace-in-file-webpack-plugin": "^1.0.6", 61 | "sass": "1.63.2", 62 | "sass-loader": "13.3.1", 63 | "semver": "^7.6.3", 64 | "style-loader": "3.3.3", 65 | "swc-loader": "^0.2.3", 66 | "terser-webpack-plugin": "^5.3.10", 67 | "ts-node": "^10.9.2", 68 | "tsconfig-paths": "^4.2.0", 69 | "typescript": "5.5.4", 70 | "webpack": "^5.94.0", 71 | "webpack-cli": "^5.1.4", 72 | "webpack-livereload-plugin": "^3.0.2", 73 | "webpack-subresource-integrity": "^5.1.0", 74 | "webpack-virtual-modules": "^0.6.2" 75 | }, 76 | "engines": { 77 | "node": ">=22" 78 | }, 79 | "dependencies": { 80 | "@emotion/css": "11.10.6", 81 | "@grafana/data": "^11.5.3", 82 | "@grafana/runtime": "^11.5.3", 83 | "@grafana/ui": "^11.5.3", 84 | "@grafana/schema": "^11.5.3", 85 | "react": "18.2.0", 86 | "react-dom": "18.2.0", 87 | "@svgdotjs/svg.js": "^3.2.0", 88 | "tslib": "2.5.3" 89 | }, 90 | "packageManager": "npm@10.9.2" 91 | } 92 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOptions } from '@grafana/plugin-e2e'; 2 | import { defineConfig, devices } from '@playwright/test'; 3 | import { dirname } from 'node:path'; 4 | 5 | const pluginE2eAuth = `${dirname(require.resolve('@grafana/plugin-e2e'))}/auth`; 6 | 7 | /** 8 | * Read environment variables from file. 9 | * https://github.com/motdotla/dotenv 10 | */ 11 | // require('dotenv').config(); 12 | 13 | /** 14 | * See https://playwright.dev/docs/test-configuration. 15 | */ 16 | export default defineConfig({ 17 | testDir: './tests', 18 | /* Run tests in files in parallel */ 19 | fullyParallel: true, 20 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 21 | forbidOnly: !!process.env.CI, 22 | /* Retry on CI only */ 23 | retries: process.env.CI ? 2 : 0, 24 | /* Opt out of parallel tests on CI. */ 25 | workers: process.env.CI ? 1 : undefined, 26 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 27 | reporter: 'html', 28 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 29 | use: { 30 | /* Base URL to use in actions like `await page.goto('/')`. */ 31 | baseURL: process.env.GRAFANA_URL || 'http://localhost:3000', 32 | 33 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 34 | trace: 'on-first-retry', 35 | }, 36 | 37 | /* Configure projects for major browsers */ 38 | projects: [ 39 | // 1. Login to Grafana and store the cookie on disk for use in other tests. 40 | { 41 | name: 'auth', 42 | testDir: pluginE2eAuth, 43 | testMatch: [/.*\.js/], 44 | }, 45 | // 2. Run tests in Google Chrome. Every test will start authenticated as admin user. 46 | { 47 | name: 'chromium', 48 | use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/admin.json' }, 49 | dependencies: ['auth'], 50 | }, 51 | ], 52 | }); 53 | -------------------------------------------------------------------------------- /src/ACESVGjsPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { PanelProps } from '@grafana/data'; 3 | import { ACESVGOptions, SVGIDMapping } from 'types'; 4 | import { Dom as SVGDom, Element as SVGElement, extend as SVGExtend, Runner as SVGRunner, SVG } from '@svgdotjs/svg.js'; 5 | 6 | interface MappedElements { 7 | [key: string]: SVGElement | SVGDom; 8 | } 9 | 10 | interface Props extends PanelProps { } 11 | 12 | interface PanelState { 13 | readonly addAllIDs: boolean; 14 | readonly svgNode: SVGElement | SVGDom | null; 15 | readonly svgSource: string | null; 16 | readonly mappedElements: MappedElements | null; 17 | readonly svgMappings: SVGIDMapping[]; 18 | readonly initFunctionSource: string; 19 | readonly initFunction: Function | null; 20 | readonly eventFunctionSource: string; 21 | readonly eventFunction: Function | null; 22 | readonly initialized: boolean; 23 | readonly context: any; 24 | } 25 | 26 | interface TextMappedElement extends SVGElement { 27 | readonly textElement: Element; 28 | } 29 | 30 | SVGExtend(SVGElement, { 31 | openOnClick: function (this: SVGElement, url: string) { 32 | return window.open(url); 33 | }, 34 | animateContRotate: function (this: SVGElement, speed: number) { 35 | return ( 36 | this.animate(speed) 37 | .ease('-') 38 | //@ts-ignore 39 | .rotate(360) 40 | .loop() 41 | ); 42 | }, 43 | showOn: function (this: SVGElement, on: boolean) { 44 | if (on) { 45 | this.show(); 46 | } else { 47 | this.hide(); 48 | } 49 | }, 50 | animateOn: function (this: SVGElement, speed: number, on: boolean, animation: Function) { 51 | if (on) { 52 | //@ts-ignore 53 | if (this.timeline()._runners.length === 0) { 54 | animation(this.animate(speed)); 55 | } else { 56 | this.timeline().play(); 57 | } 58 | } else { 59 | this.timeline().stop(); 60 | } 61 | }, 62 | stopAnimation: function (this: SVGRunner) { 63 | this.timeline().stop(); 64 | }, 65 | getParentNode: function (this: SVGElement) { 66 | return this.node.parentNode; 67 | }, 68 | getTopNode: function (this: SVGElement) { 69 | let currentNode: Element = this.node as Element; 70 | while (true) { 71 | if (currentNode.parentNode && !currentNode.className.includes('svg-object')) { 72 | currentNode = currentNode.parentNode as Element; 73 | } else { 74 | return currentNode; 75 | } 76 | } 77 | }, 78 | }); 79 | SVGExtend(SVGDom, { 80 | updateXHTMLFontText: function (this: SVGDom, newText: string) { 81 | let currentElement: Element | TextMappedElement = this.node; 82 | let i = 0; 83 | while (currentElement.localName !== 'xhtml:font') { 84 | if (currentElement.firstElementChild && i < 10) { 85 | currentElement = currentElement.firstElementChild; 86 | i++; 87 | } else { 88 | return; 89 | } 90 | } 91 | currentElement.innerHTML = newText; 92 | }, 93 | }); 94 | 95 | export class ACESVGPanel extends PureComponent { 96 | constructor(props: Props) { 97 | super(props); 98 | this.state = { 99 | addAllIDs: false, 100 | svgNode: null, 101 | svgSource: null, 102 | svgMappings: [], 103 | mappedElements: null, 104 | initFunctionSource: '', 105 | initFunction: null, 106 | eventFunctionSource: '', 107 | eventFunction: null, 108 | initialized: false, 109 | context: {}, 110 | }; 111 | } 112 | 113 | private initializeMappings(svgNode: SVGElement | SVGDom): void { 114 | const svgMappings = this.props.options.svgMappings, 115 | currentElements: MappedElements = {}; 116 | for (let i = 0; i < svgMappings.length; i++) { 117 | if (svgMappings[i].mappedName !== '') { 118 | currentElements[this.props.options.svgMappings[i].mappedName] = svgNode.findOne( 119 | `#${this.props.options.svgMappings[i].svgId}` 120 | )!; 121 | } 122 | } 123 | this.setState({ mappedElements: currentElements }); 124 | } 125 | 126 | private mapAllIDs(svgNode: SVGDom): void { 127 | const svgMappings: SVGIDMapping[] = [...this.props.options.svgMappings]; 128 | const nodeFilterID: NodeFilter = { 129 | acceptNode(node: Element) { 130 | if (node.id) { 131 | if (node.id !== '') { 132 | return NodeFilter.FILTER_ACCEPT; 133 | } 134 | } 135 | return NodeFilter.FILTER_REJECT; 136 | }, 137 | }; 138 | const svgWalker = document.createTreeWalker(svgNode.node, NodeFilter.SHOW_ALL, nodeFilterID); 139 | let currentNode: Element | null = svgWalker.currentNode as Element; 140 | while (currentNode) { 141 | if (currentNode && currentNode.id) { 142 | if (svgMappings.filter((mapping) => (currentNode ? mapping.svgId === currentNode.id : false)).length === 0) { 143 | svgMappings.push({ svgId: currentNode.id, mappedName: '' }); 144 | } 145 | } 146 | currentNode = svgWalker.nextNode() as Element; 147 | } 148 | this.setState({ svgMappings: [...svgMappings], initialized: false }); 149 | this.props.options.svgMappings = [...svgMappings]; 150 | this.props.onOptionsChange(this.props.options); 151 | this.forceUpdate(); 152 | } 153 | 154 | private mappingClickHandler(event: React.MouseEvent): void { 155 | if (event.target) { 156 | let clicked = event.target as Element; 157 | let loopCount = 0; 158 | const svgMappings: SVGIDMapping[] = [...this.props.options.svgMappings]; 159 | if (clicked.id) { 160 | while (clicked.id === '') { 161 | loopCount++; 162 | if (loopCount > 20) { 163 | return; 164 | } 165 | clicked = clicked.parentNode as Element; 166 | } 167 | for (let i = 0; i < svgMappings.length; i++) { 168 | if (svgMappings[i].svgId === clicked.id) { 169 | return; 170 | } 171 | } 172 | svgMappings.push({ svgId: clicked.id, mappedName: '' }); 173 | this.setState({ svgMappings: [...svgMappings], initialized: false }); 174 | this.props.options.svgMappings = [...svgMappings]; 175 | this.props.onOptionsChange(this.props.options); 176 | this.forceUpdate(); 177 | } 178 | } 179 | } 180 | 181 | private renderSVG(element: SVGSVGElement | SVGDom | null): string | null { 182 | if (element) { 183 | if ( 184 | this.props.options.initSource !== this.state.initFunctionSource || 185 | this.state.addAllIDs !== this.props.options.addAllIDs 186 | ) { 187 | this.setState({ 188 | initFunctionSource: this.props.options.initSource, 189 | addAllIDs: this.props.options.addAllIDs, 190 | initialized: false, 191 | }); 192 | } 193 | if (!this.state.initialized) { 194 | const svgNode = SVG(element); 195 | svgNode.clear(); 196 | svgNode.svg(this.props.options.svgSource); 197 | svgNode.size(this.props.width, this.props.height); 198 | if (this.props.options.addAllIDs) { 199 | this.mapAllIDs(svgNode); 200 | } 201 | this.initializeMappings(svgNode); 202 | this.setState({ svgNode: svgNode }); 203 | 204 | try { 205 | const initFunction = Function( 206 | 'data', 207 | 'options', 208 | 'svgnode', 209 | 'svgmap', 210 | 'context', 211 | this.props.replaceVariables(this.props.options.initSource) 212 | ); 213 | this.setState({ initFunction }); 214 | if (this.state.mappedElements && initFunction) { 215 | initFunction(this.props.data, this.props.options, this.state.svgNode, this.state.mappedElements); 216 | this.setState({ initialized: true }); 217 | } 218 | } catch (e) { 219 | this.setState({ initialized: true }); 220 | console.error('User init code failed:', e); 221 | } 222 | } 223 | 224 | try { 225 | let eventFunction = this.state.eventFunction; 226 | if (this.props.options.eventSource !== this.state.eventFunctionSource) { 227 | const eventFunctionSource = this.props.options.eventSource; 228 | eventFunction = Function( 229 | 'data', 230 | 'options', 231 | 'svgnode', 232 | 'svgmap', 233 | 'context', 234 | this.props.replaceVariables(eventFunctionSource) 235 | ); 236 | this.setState({ eventFunctionSource: eventFunctionSource, eventFunction: eventFunction, initialized: false }); 237 | } 238 | if (this.state.mappedElements && eventFunction) { 239 | eventFunction( 240 | this.props.data, 241 | this.props.options, 242 | this.state.svgNode, 243 | this.state.mappedElements, 244 | this.context 245 | ); 246 | } 247 | } catch (e) { 248 | console.error('User event code failed:', e); 249 | } 250 | 251 | return this.state.svgNode ? this.state.svgNode.svg() : null; 252 | } else { 253 | return null; 254 | } 255 | } 256 | 257 | public render(): React.JSX.Element { 258 | return ( 259 |
262 | this.renderSVG(ref)} 269 | > 270 |
271 | ); 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | ![ACE.SVG LOGO](https://github.com/ACE-IoT-Solutions/ace-svg-react/raw/main/src/img/logo-type.svg) 2 | # React powered SVG Panel Plugin for Grafana 3 | 4 | ![Dynamic JSON Badge (Version)](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgrafana.com%2Fapi%2Fplugins%2Faceiot-svg-panel&query=version&prefix=v&logo=grafana&label=Version&color=orange) 5 | ![Dynamic JSON Badge (Downloads)](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgrafana.com%2Fapi%2Fplugins%2Faceiot-svg-panel&query=downloads&logo=grafana&label=Downloads&color=orange) 6 | [![CI](https://github.com/ACE-IoT-Solutions/ace-svg-react/actions/workflows/ci.yml/badge.svg)](https://github.com/ACE-IoT-Solutions/ace-svg-react/actions/workflows/ci.yml) 7 | 8 | ![Screenshot of plugin](https://media.giphy.com/media/YRE4DxPYqy3XCR6rL4/giphy.gif) 9 | 10 | This is an SVG panel plugin for Grafana 10.x+ 11 | 12 | ## Why yet another SVG panel plugin? 13 | Yes, I definitely considered naming it YASPP... 14 | The existing SVG panel plugins all use the pre-7.0, Angular panel API. I wanted to be able to use all of the features included in the new data API, as well as potential performance improvements from migrating to React. 15 | I have also taken a slightly different direction/philosophy from some of the other SVG plugins. 16 | 17 | ## How does it work? 18 | - Users of this plugin are expected to have ready to animate SVG graphics created in an vector design package (See [Inkscape](https://inkscape.org/), [Adobe Illustrator](https://www.adobe.com/products/illustrator.html), or a web tool like [draw.io](https://app.diagrams.net/)). 19 | - Users upload their existing SVG layouts into the plugin, then enable the "Click to Map" functionality, allowing you to easily map arbitrary group, path or text span IDs to named tokens. 20 | - Users then perform additional setup tasks in an init function that gets called each time the SVG panel is loaded. 21 | - Finally, users can access the elements of the SVG using the named tokens from within the render function, called whenever the panel receieves new data. 22 | - User code in either the render or init functions have access to all of the mapped elements, the root SVG document, a ephemeral state store and the DataFrames for the panel. 23 | 24 | ## What are the goals for this project? 25 | 1. Be the most performant way to visualize arbitrary SVG graphics and animations within Grafana 26 | 2. Stay out of the way - This plugin will focus on providing simple, obvious tools to reduce the friction of converting your static SVG graphics into data driven visualizations, without limiting your options. 27 | 3. Stay focused - This plugin will not attempt to be an online graphics editor. 28 | 29 | ## Getting started 30 | 1. Create a new panel and select ACE.SVG as the visualization 31 | 2. Paste your SVG source file into the SVG Document tab editor. 32 | 3. Use the 'Click to Map' functionality in the SVG Mapping tab to map SVG Elements to variables. 33 | 4. Write code that you want to run once on dashboard load in the User JS Init tab editor. 34 | 5. Write code that you want to run on when data is received in the User JS Render tab editor. 35 | 6. Make generous use of the browser JS console and console.log() to explore the features of the plugin and SVG.js 36 | 37 | ## Execution Environment Interfaces 38 | The plugin makes available several interfaces to the SVG document and Grafana in the execution context of the Init and Render functions. 39 | Below are details for each one, remember you can always use the browser JS console and console.log() for more detail. 40 | 41 | - `svgmap` svgmap is a JS object where each mapped SVG.js Element is available as a property. Example: If you have an svgid 'rect4524' and you've provided the mapped name bigSquare, you would access the SVG.js methods on that element under `svgmap.bigSquare`. The svgmap is also included as `options.svgMappings` 42 | - `data` data is the raw object passed in to the panel for rendering by Grafana, it contains the DataFrames with all timeseries data selected by the active queries and time range. This interface is a bit complex and verbose, so we'll be adding helper methods to make common use cases simpler. [Grafana DataFrame Docs](https://grafana.com/developers/plugin-tools/introduction/data-frames) 43 | - `options` options is the raw panel options object manaeged by the dashboard to store panel state. It includes the source code for the svg and functions as well as any mappings or other config data. While you can manually access everything about the panel with this interface, the most common use case is to add properties to it from the Init function for the render function to use later. For instance, if you have a common animation in a panel, you might create a function for setting it up in Init, and attach it to the options object as a property, so that you can use it in the Render function and keep the logic flow clear. 44 | - `svgnode` is a reference to the SVG.js object 45 | - `context` is an initially empty object passed into the init function. The init function may store 46 | any context or state that you to preserve between init and renderings. This is not required, but 47 | for some advanced use cases - for example, svg generators in the init function - being able 48 | to pass data from init to render (and between render invocations) can be useful. 49 | 50 | The 'data' object contains both the query definition and the results of the query. In the case of the 51 | user initialization function there likely won't be any results (yet), but the query definition should 52 | be present. This _may_ be useful for some initial rendering purposes, but caution is advised as the 53 | query may be updated (for example while the user is updating the query) so anything done here should 54 | likely be done in the update function as well. 55 | 56 | ### Important Notes 57 | - An object's `style` property takes precedence over [`fill()`](https://svgjs.dev/docs/3.2/manipulating/#fill). This could be an issue when creating graphics in Inkscape, which uses the `style` property to assign the fill color. To fix this, you must remove the `fill:#FFFFFF;` part under that element's `style`. 58 | 59 | ## Learn more 60 | - [SVG.js](https://svgjs.dev) The Core SVG library made available for SVG DOM manipulations. 61 | - [Grafana Docs](https://grafana.com/docs/) Documentation for users and developers. 62 | - [Mozilla Developer](https://developer.mozilla.org/en-US/docs/Web/SVG) Documentation on the SVG standard itself. 63 | -------------------------------------------------------------------------------- /src/examples.ts: -------------------------------------------------------------------------------- 1 | import { ACESVGDefaults } from './types'; 2 | 3 | export const props_defaults: ACESVGDefaults = { 4 | svgNode: ` 5 | 20 | 22 | 23 | 25 | image/svg+xml 26 | 28 | 29 | 30 | 31 | 32 | 56 | 58 | 60 | 63 | 67 | 68 | 71 | 75 | 76 | 79 | 83 | 84 | 87 | 91 | 92 | 95 | 99 | 100 | 103 | 107 | 108 | 111 | 114 | 115 | 118 | 122 | 123 | 130 | 138 | 139 | 142 | 146 | 147 | 150 | 154 | 155 | 158 | 161 | 162 | 165 | 169 | 170 | 177 | 185 | 186 | 189 | 193 | 194 | 197 | 200 | 201 | 204 | 208 | 209 | 216 | 224 | 225 | 228 | 232 | 233 | 234 | 244 | 254 | 264 | 274 | 284 | 289 | 294 | 299 | 304 | 314 | 323 | 333 | 342 | 347 | 352 | 357 | 362 | 367 | 372 | 377 | 380 | 385 | 390 | 395 | 400 | 405 | 410 | 415 | 420 | 425 | 430 | 437 | 444 | 451 | 452 | 455 | 460 | 465 | 470 | 475 | 480 | 485 | 490 | 491 | 495 | 499 | 501 | 505 | 509 | 513 | 514 | 518 | 522 | 526 | 530 | 534 | 538 | 540 | 542 | 546 | 550 | 554 | 558 | 562 | 566 | 567 | 568 | 572 | 576 | 580 | 584 | 588 | 592 | 596 | 600 | 602 | 606 | 610 | 614 | 615 | 619 | 623 | 627 | 631 | 635 | 639 | 643 | 647 | 651 | 655 | 658 | 661 | 664 | 667 | 670 | 671 | 675 | 679 | 683 | 687 | 691 | 693 | 697 | 701 | 705 | 709 | 710 | 712 | 716 | 720 | 723 | 726 | 732 | 733 | 737 | 741 | 745 | 746 | 747 | > 748 | `, 749 | initSource: `// example of creating a function in the init script 750 | console.log('initializing'); 751 | options.animateLogo = (svgmap, data) => { 752 | // extract the data series values and store the min/max 753 | let values = data.series[0].fields[1].values, 754 | min = Math.min(...values), 755 | max = Math.max(...values), 756 | iconHeight = svgmap.iconbg.height(); 757 | // use SVG.js for animation: https://svgjs.dev/docs/3.0/ 758 | [svgmap.barOne, svgmap.barTwo, svgmap.barThree, svgmap.barFour].forEach(elem => { 759 | let x = elem.x(), 760 | y = iconHeight * (values[0] - min) / (max - min) - elem.height() / 2; 761 | elem.animate(1000) 762 | .ease('<>') 763 | .move(x, y) 764 | .loop(0, true); 765 | }); 766 | }`, 767 | eventSource: `// example of calling a function defined in the init script 768 | options.animateLogo(svgmap, data); 769 | // Here we're going to initialized some variables just to make things less verbose 770 | // This is the raw data buffer from the values field of the DataFrame 771 | let values = data.series[0].fields[1].values; 772 | // here we collect the most recent value from the Data Frame 773 | let lastValue = values[values.length - 1]; 774 | // We need to collect the center of the fan as a static value here, otherwise it will cause a feedback loop in the animation. 775 | // The rotate animation will use the center of the bounding box by default, but of irregular shaped items, like these fan blades 776 | // the center is not the center axis of rotation 777 | const fanX = svgmap.fanBlades.cx(); 778 | const fanY = svgmap.fanBlades.cy(); 779 | // Here we're defining a state condition to use later in our pipe animation 780 | let leakCond = lastValue > 10 && lastValue < 45; 781 | 782 | // Here we're simply making the visibility of the alert icon on top of tbe bell dependent on the last value being greater than 50 783 | svgmap.alarmIcon.showOn(lastValue > 50); 784 | // Now we use the same logic to apply an animation to the sound waves around the bell 785 | svgmap.alarmRings.animateOn(1500, (lastValue > 50), (elem) => { 786 | // this callback is passed the resulting SVG.js runner object which you then chain your animation actions on. 787 | // Here, we're simply scaling the dark sound wave lines by 10%, with bidirectional easing and looping with swing 788 | elem.ease('<>').transform({scale: 1.1, relative: true}).loop(0, true); 789 | }) 790 | // Here we animate the fan, using the static variables we create earlier to get closer to the center of the blades 791 | // if the item we were rotating was centered in the bounding box, we could just call rotate(360) with no axis specificied 792 | svgmap.fanBlades.animateOn(1000, (lastValue > 40), (elem) => { 793 | elem.ease('-').rotate(360, fanX - 1, fanY +3).loop(); 794 | }); 795 | // these simply hide the elements of the lamp when below the threshold 796 | svgmap.lampLens.showOn(lastValue>10); 797 | svgmap.lampRays.showOn(lastValue>10); 798 | // Here we hide the water drop when leakCond is false 799 | svgmap.waterDrop.showOn(leakCond); 800 | // and here we animate the water drop when leakCond is true 801 | svgmap.waterDrop.animateOn(2000 - lastValue *20, leakCond, (elem) => { 802 | // we're using single direction easing to give a gravity affect, and scaling the drop down as it falls off the screen 803 | elem.ease('<').transform({translateY: 1000, scale: 0.00001}).loop(); 804 | });`, 805 | svgMappings: [ 806 | { 807 | mappedName: 'barTwo', 808 | svgId: 'rect4526', 809 | }, 810 | { 811 | mappedName: 'barThree', 812 | svgId: 'rect4528', 813 | }, 814 | { 815 | mappedName: 'barFour', 816 | svgId: 'rect4530', 817 | }, 818 | { 819 | mappedName: 'iconbg', 820 | svgId: 'rect4522', 821 | }, 822 | { 823 | mappedName: 'spline', 824 | svgId: 'path4538', 825 | }, 826 | { 827 | mappedName: 'barOne', 828 | svgId: 'rect4524', 829 | }, 830 | { 831 | mappedName: 'fanBlades', 832 | svgId: 'g1550', 833 | }, 834 | { 835 | mappedName: 'alarmIcon', 836 | svgId: 'g1721', 837 | }, 838 | { 839 | mappedName: 'lampLens', 840 | svgId: 'g1942', 841 | }, 842 | { 843 | mappedName: 'lampRays', 844 | svgId: 'g1917', 845 | }, 846 | { 847 | mappedName: 'waterDrop', 848 | svgId: 'g1995', 849 | }, 850 | { 851 | mappedName: 'alarmRings', 852 | svgId: 'g1891', 853 | }, 854 | ], 855 | }; 856 | -------------------------------------------------------------------------------- /src/img/ace-svg-react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ACE-IoT-Solutions/ace-svg-react/c6cef2fc479a4850cd885b45d2dc42b5eece56cf/src/img/ace-svg-react.png -------------------------------------------------------------------------------- /src/img/logo-small-background.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/img/logo-type.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/img/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { PanelPlugin } from '@grafana/data'; 2 | import { ACESVGOptions } from './types'; 3 | import { ACESVGPanel } from './ACESVGjsPanel'; 4 | import { optionsBuilder } from './options'; 5 | 6 | export const plugin = new PanelPlugin(ACESVGPanel).useFieldConfig().setPanelOptions(optionsBuilder); 7 | -------------------------------------------------------------------------------- /src/options.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PanelOptionsEditorBuilder, PanelOptionsEditorProps } from '@grafana/data'; 3 | import { ACESVGOptions, SVGIDMapping } from './types'; 4 | import { props_defaults } from 'examples'; 5 | import { Button, Stack, Input, Label, Tooltip, CodeEditor } from '@grafana/ui'; 6 | 7 | interface SVGIDMappingProps { 8 | readonly value: SVGIDMapping; 9 | readonly index?: number; 10 | readonly onChangeItem?: (a: SVGIDMapping, b: number) => void | undefined; 11 | readonly onAdd?: (a: SVGIDMapping) => void; 12 | readonly onDelete?: (a: number) => void; 13 | } 14 | 15 | class SvgMapping extends React.PureComponent { 16 | constructor(props: SVGIDMappingProps) { 17 | super(props); 18 | this.state = { ...props.value }; 19 | } 20 | 21 | public render(): React.JSX.Element { 22 | const { value, index, onChangeItem, onAdd, onDelete } = this.props; 23 | return ( 24 | 25 | 26 | { 32 | const svgId = e.currentTarget.value; 33 | this.setState({ svgId: svgId }); 34 | onChangeItem && index && onChangeItem({ ...value, svgId: svgId }, index); 35 | }} 36 | /> 37 | 38 | { 44 | const mappedName = e.currentTarget.value; 45 | this.setState({ mappedName: mappedName }); 46 | onChangeItem && index && onChangeItem({ ...value, mappedName: mappedName }, index); 47 | }} 48 | /> 49 | {value.svgId && onDelete && index !== undefined && ( 50 | 51 | 61 | 62 | )} 63 | {!value.svgId && onAdd && ( 64 | 65 | 75 | 76 | )} 77 | 78 | ); 79 | } 80 | } 81 | 82 | class SvgMappings extends React.PureComponent> { 83 | private readonly onChangeItem = (updatedMapping: SVGIDMapping, index: number): void => { 84 | const newMappings = [...this.props.value]; 85 | newMappings[index] = updatedMapping; 86 | this.props.onChange(newMappings); 87 | }; 88 | 89 | private readonly onAdd = (newMapping: SVGIDMapping): void => { 90 | if (newMapping.svgId !== '') { 91 | const newMappings = [...this.props.value, newMapping]; 92 | this.props.onChange(newMappings); 93 | } 94 | }; 95 | 96 | private readonly onDelete = (index: number): void => { 97 | const newMappings = [...this.props.value]; 98 | newMappings.splice(index, 1); 99 | this.props.onChange(newMappings); 100 | }; 101 | 102 | public render(): React.JSX.Element { 103 | const svgMappings = this.props.value; 104 | return ( 105 | 106 | 107 | 108 | 118 | 119 | 120 | 121 | {svgMappings.map((currentMapping: SVGIDMapping, index: number) => { 122 | return ( 123 | 130 | ); 131 | })} 132 | 133 | ); 134 | } 135 | } 136 | 137 | export function optionsBuilder(builder: PanelOptionsEditorBuilder): PanelOptionsEditorBuilder { 138 | return builder 139 | .addCustomEditor({ 140 | category: ['SVG Document'], 141 | path: 'svgSource', 142 | name: 'SVG Source', 143 | description: `Editor for SVG Document, while small tweaks can be made here, we recommend using a dedicated 144 | Graphical SVG Editor and simply pasting the resulting XML here`, 145 | id: 'svgSource', 146 | defaultValue: props_defaults.svgNode, 147 | editor(props) { 148 | return ; 149 | }, 150 | }) 151 | .addCustomEditor({ 152 | category: ['User JS Render'], 153 | path: 'eventSource', 154 | name: 'User JS Render Code', 155 | description: `The User JS Render code is executed whenever new data is available, the root svg document is available as 'svgnode', 156 | and elements you've mapped using the SVG Mapping tools below are available as properties on the 'svgmap' object. 157 | The Grafana DataFrame is provided as 'data' and the 'options' object can be used to pass values and references between 158 | the Render context and the Init context.`, 159 | id: 'eventSource', 160 | defaultValue: props_defaults.eventSource, 161 | editor(props) { 162 | return ; 163 | }, 164 | }) 165 | .addCustomEditor({ 166 | category: ['User JS Init'], 167 | path: 'initSource', 168 | name: 'User JS Init Code', 169 | description: `The User JS Init code is executed once when the panel loads, you can use this to define helper functions that 170 | you later reference in the User JS Render code section. The sections have identical execution contexts, and any 171 | JS objects you want to reference between them will need to be attached to the options object as properties.`, 172 | id: 'initSource', 173 | defaultValue: props_defaults.initSource, 174 | editor(props) { 175 | return ; 176 | }, 177 | }) 178 | .addBooleanSwitch({ 179 | category: ['SVG Mapping'], 180 | path: 'addAllIDs', 181 | name: 'Add all SVG Element IDs', 182 | description: 183 | 'Parse the SVG Document for Elements with IDs assigned and automatically add them to the mapping list', 184 | defaultValue: false, 185 | }) 186 | .addBooleanSwitch({ 187 | category: ['SVG Mapping'], 188 | path: 'captureMappings', 189 | name: 'Enable SVG Mapping on Click', 190 | description: 191 | 'When activated, clicking an element in the panel will attempt to map the clicked element or its nearest parent element with an ID assigned', 192 | defaultValue: false, 193 | }) 194 | .addCustomEditor({ 195 | category: ['SVG Mapping'], 196 | id: 'svgMappings', 197 | path: 'svgMappings', 198 | name: 'SVG Mappings', 199 | description: 200 | 'The SVG ID should match an element in the SVG document with an existing ID tag, the element will be attached to the "svgmap" object in the user code execution contexts as a property using the Mapped Name provided below', 201 | defaultValue: props_defaults.svgMappings, 202 | editor: SvgMappings, 203 | }); 204 | } 205 | -------------------------------------------------------------------------------- /src/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json", 3 | "type": "panel", 4 | "name": "ACE.SVG", 5 | "id": "aceiot-svg-panel", 6 | "info": { 7 | "keywords": [ 8 | "svg", 9 | "hmi", 10 | "iiot" 11 | ], 12 | "description": "SVG Visualization Panel", 13 | "author": { 14 | "name": "Andrew Rodgers", 15 | "url": "https://aceiotsolutions.com/" 16 | }, 17 | "logos": { 18 | "small": "img/logo-small-background.svg", 19 | "large": "img/logo-type.svg" 20 | }, 21 | "links": [ 22 | { 23 | "name": "Website", 24 | "url": "https://github.com/ACE-IoT-Solutions/ace-svg-react" 25 | }, 26 | { 27 | "name": "License", 28 | "url": "https://github.com/ACE-IoT-Solutions/ace-svg-react/blob/master/LICENSE" 29 | } 30 | ], 31 | "screenshots": [ 32 | { 33 | "name": "In Action", 34 | "path": "img/ace-svg-react.png" 35 | } 36 | ], 37 | "version": "%VERSION%", 38 | "updated": "%TODAY%" 39 | }, 40 | "dependencies": { 41 | "grafanaDependency": ">=10.4.0", 42 | "plugins": [] 43 | } 44 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface SVGIDMapping { 2 | readonly svgId: string; 3 | readonly mappedName: string; 4 | } 5 | export interface ACESVGOptions { 6 | readonly captureMappings: boolean; 7 | readonly addAllIDs: boolean; 8 | readonly svgSource: string; 9 | readonly eventSource: string; 10 | readonly initSource: string; 11 | svgMappings: SVGIDMapping[]; 12 | } 13 | export interface ACESVGDefaults { 14 | readonly svgNode: string; 15 | readonly initSource: string; 16 | readonly eventSource: string; 17 | readonly svgMappings: SVGIDMapping[]; 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------