├── .config ├── .cprc.json ├── .eslintrc ├── .prettierrc.js ├── Dockerfile ├── README.md ├── entrypoint.sh ├── jest-setup.js ├── jest.config.js ├── jest │ ├── mocks │ │ └── react-inlinesvg.tsx │ └── utils.js ├── mocks │ └── react-inlinesvg.tsx ├── supervisord │ └── supervisord.conf ├── tsconfig.json ├── types │ └── custom.d.ts └── webpack │ ├── BuildModeWebpackPlugin.ts │ ├── constants.ts │ ├── tsconfig.webpack.json │ ├── utils.ts │ └── webpack.config.ts ├── .cprc.json ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ ├── push.yml │ └── stale.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .theia └── settings.json ├── .vscode └── launch.json ├── .yarn └── releases │ └── yarn-4.6.0.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cspell.config.json ├── docker-compose.yml ├── jest-setup.js ├── jest.config.js ├── package.json ├── playwright.config.ts ├── provisioning ├── README.md ├── dashboards │ ├── Polystat-CSV-Content-Bug.json │ ├── Tooltip-Composite-Bug.json │ ├── dashboard.json │ └── default.yaml └── datasources │ └── datasources.yml ├── src ├── __mocks__ │ └── models │ │ ├── composites.ts │ │ └── models.ts ├── __snapshots__ │ └── migrations.test.ts.snap ├── components │ ├── Polystat.test.tsx │ ├── Polystat.tsx │ ├── PolystatPanel.tsx │ ├── alignment.test.ts │ ├── alignment.ts │ ├── auto_font_scaler.test.ts │ ├── auto_font_scaler.ts │ ├── composites │ │ ├── CompositeEditor.tsx │ │ ├── CompositeItem.tsx │ │ ├── CompositeMetricItem.test.tsx │ │ ├── CompositeMetricItem.tsx │ │ ├── __snapshots__ │ │ │ └── CompositeMetricItem.test.tsx.snap │ │ └── types.ts │ ├── defaults.ts │ ├── gradients │ │ ├── Gradients.test.tsx │ │ ├── Gradients.tsx │ │ ├── __snapshots__ │ │ │ └── Gradients.test.tsx.snap │ │ ├── color.test.ts │ │ └── color.ts │ ├── layout │ │ ├── layoutManager.test.ts │ │ ├── layoutManager.ts │ │ └── types.ts │ ├── metric_hints.test.ts │ ├── metric_hints.ts │ ├── overrides │ │ ├── OverrideEditor.tsx │ │ ├── OverrideItem.tsx │ │ └── types.ts │ ├── styles.ts │ ├── suggestions.ts │ ├── thresholds │ │ ├── GlobalThresholdEditor.tsx │ │ ├── ThresholdItem.tsx │ │ ├── ThresholdsEditor.tsx │ │ └── types.ts │ ├── tooltips │ │ ├── Tooltip.test.tsx │ │ ├── Tooltip.tsx │ │ └── __snapshots__ │ │ │ └── Tooltip.test.tsx.snap │ └── types.ts ├── data │ ├── clickThroughTransformer.test.ts │ ├── clickThroughTransformer.ts │ ├── composite_processor.test.ts │ ├── composite_processor.ts │ ├── deframer.test.ts │ ├── deframer.ts │ ├── override_processor.test.ts │ ├── override_processor.ts │ ├── processor.test.ts │ ├── processor.ts │ ├── snapshotdata.ts │ ├── stats.ts │ ├── threshold_processor.test.ts │ ├── threshold_processor.ts │ ├── time_formatter.ts │ ├── types.ts │ └── valueMappingsWrapper │ │ ├── index.ts │ │ ├── v7 │ │ └── valueMappings.ts │ │ ├── v8 │ │ ├── thresholds.ts │ │ ├── types │ │ │ ├── fieldColor.ts │ │ │ ├── thresholds.ts │ │ │ └── valueMappings.ts │ │ └── valueMappings.ts │ │ └── valueMappings.test.ts ├── img │ ├── logo-credit.html │ ├── polystat.svg │ └── screenshots │ │ ├── polystat-v2-agent-all-visible.png │ │ ├── polystat-v2-agent-scaled-down-tooltip.png │ │ ├── polystat-v2-agent-scaled-down.png │ │ ├── polystat-v2-composite-add-metric.png │ │ ├── polystat-v2-composite-animated.gif │ │ ├── polystat-v2-composite-bottom-menu.png │ │ ├── polystat-v2-composite-rendered.png │ │ ├── polystat-v2-composite-with-tooltip.png │ │ ├── polystat-v2-composites-all.png │ │ ├── polystat-v2-custom-clickthrough-target.png │ │ ├── polystat-v2-global-all.png │ │ ├── polystat-v2-global-shapes.png │ │ ├── polystat-v2-global-showtimestamp.png │ │ ├── polystat-v2-global-timestamp-above.png │ │ ├── polystat-v2-global-timestamp-below.png │ │ ├── polystat-v2-layout-manual.png │ │ ├── polystat-v2-layout-warning.png │ │ ├── polystat-v2-options-layout.png │ │ ├── polystat-v2-override-bottom-menu.png │ │ ├── polystat-v2-overrides-no-thresholds.png │ │ ├── polystat-v2-overrides-rendered-thresholds.png │ │ ├── polystat-v2-overrides-with-thresholds.png │ │ ├── polystat-v2-shape-circle.png │ │ ├── polystat-v2-shape-hexagon-pointed-top.png │ │ ├── polystat-v2-shape-square.png │ │ ├── polystat-v2-sizing-auto.png │ │ ├── polystat-v2-sizing-manual.png │ │ ├── polystat-v2-sorting-directions.png │ │ ├── polystat-v2-sorting-fields.png │ │ ├── polystat-v2-sorting.png │ │ ├── polystat-v2-text-auto-all.png │ │ ├── polystat-v2-text-font-color-picker.png │ │ ├── polystat-v2-text-manual-font-color.png │ │ ├── polystat-v2-text-manual-fontsize.png │ │ ├── polystat-v2-text-use-ellipses.png │ │ ├── polystat-v2-tooltips-all.png │ │ ├── polystat-v2-tooltips-display-modes.png │ │ ├── polystat-v2-tooltips-primary-sortby-field.png │ │ ├── polystat-v2-tooltips-sort-directions.png │ │ ├── regex-alias-after.png │ │ └── regex-alias-before.png ├── migrations.test.ts ├── migrations.ts ├── module.test.ts ├── module.ts ├── plugin.json ├── utils.test.ts ├── utils.ts └── uuid.d.ts ├── tests └── auth-bypassed │ ├── phase1-core │ └── grafana-version.spec.ts │ ├── phase2-installed │ └── check-installed.spec.ts │ └── phase3-panel │ ├── create-composite.spec.ts │ └── create-panel.spec.ts ├── tsconfig.json └── yarn.lock /.config/.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5.13.0" 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 | 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-array', 19 | 'd3-axis', 20 | 'd3-brush', 21 | 'd3-chord', 22 | 'd3-color', 23 | 'd3-contour', 24 | 'd3-delaunay', 25 | 'd3-dispatch', 26 | 'd3-drag', 27 | 'd3-dsv', 28 | 'd3-ease', 29 | 'd3-fetch', 30 | 'd3-force', 31 | 'd3-format', 32 | 'd3-geo', 33 | 'd3-hierarchy', 34 | 'd3-interpolate', 35 | 'd3-path', 36 | 'd3-polygon', 37 | 'd3-quadtree', 38 | 'd3-random', 39 | 'd3-scale', 40 | 'd3-scale-chromatic', 41 | 'd3-selection', 42 | 'd3-shape', 43 | 'd3-time', 44 | 'd3-time-format', 45 | 'd3-timer', 46 | 'd3-transition', 47 | 'd3-zoom', 48 | 'delaunator', 49 | 'internmap', 50 | 'ol', 51 | 'react-colorful', 52 | 'robust-predicates', 53 | 'rxjs', 54 | 'uuid', 55 | ]; 56 | 57 | module.exports = { 58 | nodeModulesToTransform, 59 | grafanaESModules, 60 | }; 61 | -------------------------------------------------------------------------------- /.config/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/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/tsconfig.webpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "esModuleInterop": true 6 | }, 7 | "transpileOnly": true, 8 | "transpiler": "ts-node/transpilers/swc-experimental" 9 | } 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": { 3 | "bundleGrafanaUI": false, 4 | "useReactRouterV6": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/.eslintrc", 3 | "plugins": ["@grafana/eslint-plugin-plugins"], 4 | "rules": { 5 | "@grafana/plugins/import-is-compatible": ["warn"], 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 2 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | open-pull-requests-limit: 5 13 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Plugins - CD 2 | run-name: Deploy ${{ inputs.branch }} to ${{ inputs.environment }} by @${{ github.actor }} 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | branch: 8 | description: Branch to publish from. Can be used to deploy PRs to dev 9 | default: main 10 | environment: 11 | description: Environment to publish to 12 | required: true 13 | type: choice 14 | options: 15 | - "dev" 16 | - "ops" 17 | - "prod" 18 | docs-only: 19 | description: Only publish docs, do not publish the plugin 20 | default: false 21 | type: boolean 22 | 23 | permissions: {} 24 | 25 | jobs: 26 | cd: 27 | name: CD 28 | uses: grafana/plugin-ci-workflows/.github/workflows/cd.yml@main # zizmor: ignore[unpinned-uses] 29 | permissions: 30 | contents: write 31 | id-token: write 32 | attestations: write 33 | with: 34 | branch: ${{ github.event.inputs.branch }} 35 | environment: ${{ github.event.inputs.environment }} 36 | docs-only: ${{ fromJSON(github.event.inputs.docs-only) }} 37 | attestation: true 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Plugins - CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | permissions: {} 10 | 11 | jobs: 12 | ci: 13 | name: CI 14 | uses: grafana/plugin-ci-workflows/.github/workflows/ci.yml@main # zizmor: ignore[unpinned-uses] 15 | permissions: 16 | contents: read 17 | id-token: write 18 | with: 19 | plugin-version-suffix: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} 20 | run-playwright-with-skip-grafana-dev-image: true 21 | run-playwright-with-grafana-dependency: ">=10.0.0" 22 | 23 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 4 * * *' 5 | permissions: 6 | issues: write 7 | pull-requests: write 8 | jobs: 9 | stale: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/stale@v9 13 | with: 14 | # Number of days of inactivity before a stale Issue or Pull Request is closed. 15 | # Set to -1 to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 16 | days-before-close: 60 17 | # Number of days of inactivity before an Issue or Pull Request becomes stale 18 | days-before-stale: 90 19 | exempt-issue-labels: no stalebot 20 | exempt-pr-labels: no stalebot 21 | operations-per-run: 100 22 | stale-issue-label: stale 23 | stale-pr-label: stale 24 | stale-pr-message: > 25 | This pull request has been automatically marked as stale because it has not had 26 | activity in the last 30 days. It will be closed in 2 weeks if no further activity occurs. Please 27 | feel free to give a status update now, ping for review, or re-open when it's ready. 28 | Thank you for your contributions! 29 | close-pr-message: > 30 | This pull request has been automatically closed because it has not had 31 | activity in the last 2 weeks. Please feel free to give a status update now, ping for review, or re-open when it's ready. 32 | Thank you for your contributions! -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | provisioning-private 4 | coverage 5 | .DS_Store 6 | .vscode/*.log 7 | .yarn/install-state.gz 8 | .eslintcache 9 | .idea 10 | .stignore 11 | blob-report/ 12 | tests/.auth 13 | tests/.cache/ 14 | playwright/ 15 | playwright-report/ 16 | test-results/ 17 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require("./.config/.prettierrc.js") 4 | }; -------------------------------------------------------------------------------- /.theia/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "name": "vscode-jest-tests", 10 | "request": "launch", 11 | "args": [ 12 | "--runInBand" 13 | ], 14 | "cwd": "${workspaceFolder}", 15 | "console": "integratedTerminal", 16 | "internalConsoleOptions": "neverOpen", 17 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableGlobalCache: true 2 | 3 | enableTelemetry: 0 4 | 5 | nodeLinker: node-modules 6 | 7 | yarnPath: .yarn/releases/yarn-4.6.0.cjs 8 | -------------------------------------------------------------------------------- /cspell.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "en", 3 | "ignorePaths": [ 4 | "CHANGELOG.md", 5 | "README.md", 6 | "node_modules/**", 7 | "dist/**", 8 | "ci/dist/**", 9 | "coverage/**", 10 | "provisioning/**", 11 | "src/dashboards/**", 12 | "tests/__mocks__/**", 13 | "yarn.lock" 14 | ], 15 | "words": [ 16 | "Abbrevation", 17 | "Appender", 18 | "braintree", 19 | "Cascader", 20 | "Clickthrough", 21 | "clickthroughs", 22 | "dataframe", 23 | "dataframes", 24 | "datapoints", 25 | "DATAPROXY", 26 | "datasource", 27 | "deframer", 28 | "delaunator", 29 | "diffperc", 30 | "drilldown", 31 | "esversion", 32 | "framename", 33 | "gdev", 34 | "grafana", 35 | "hexbin", 36 | "iasc", 37 | "idesc", 38 | "internmap", 39 | "jackspeak", 40 | "logmin", 41 | "magn", 42 | "networkidle", 43 | "Neue", 44 | "ngalert", 45 | "polystat", 46 | "Polystat", 47 | "Roboto", 48 | "showreport", 49 | "subresource", 50 | "testdata", 51 | "testid", 52 | "textbox", 53 | "timeseries", 54 | "tinycolor", 55 | "tippyjs", 56 | "toplabel", 57 | "typecheck", 58 | "unicons", 59 | "uuidv", 60 | "valuemap", 61 | "varname", 62 | "verdana", 63 | "workdir" 64 | ], 65 | "enabled": true 66 | } 67 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | #image: grafana/grafana:8.4.11 4 | #image: grafana/grafana:8.5.27 5 | #image: grafana/grafana:9.0.9 6 | #image: grafana/grafana:9.1.8 7 | #image: grafana/grafana:9.2.20 8 | #image: grafana/grafana:9.3.16 9 | #image: grafana/grafana:9.4.17 10 | #image: grafana/grafana:9.5.19 11 | #image: grafana/grafana:10.0.13 12 | #image: grafana/grafana:10.1.10 13 | #image: grafana/grafana:10.2.7 14 | #image: grafana/grafana:10.3.6 15 | #image: grafana/grafana:10.4.3 16 | # args: 17 | container_name: grafana-polystat-panel 18 | image: grafana/${GRAFANA_IMAGE:-grafana}:${GRAFANA_VERSION:-11.2.5} 19 | ports: 20 | - 3000:3000 21 | volumes: 22 | - ./dist:/var/lib/grafana/plugins/grafana-polystat-panel 23 | - ./provisioning:/etc/grafana/provisioning 24 | environment: 25 | GF_AUTH_ANONYMOUS_ENABLED: 1 26 | GF_AUTH_ANONYMOUS_ORG_ROLE: Admin 27 | GF_DATAPROXY_LOGGING: 1 28 | GF_LIVE_ALLOWED_ORIGINS: '*' 29 | GF_LOG_LEVEL: debug 30 | GF_LOG_FILTERS: alerting.scheduler:info,ngalert.scheduler:info,ngalert.state.manager:info,provisioning.dashboard:info 31 | TERM: linux 32 | GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-polystat-panel 33 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup provided by Grafana scaffolding 2 | import './.config/jest-setup'; 3 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /*jshint esversion: 9 */ 2 | 3 | process.env.TZ = 'UTC'; 4 | const { grafanaESModules, nodeModulesToTransform } = require('./.config/jest/utils'); 5 | 6 | module.exports = { 7 | // Jest configuration provided by Grafana 8 | ...require('./.config/jest.config'), 9 | // Inform jest to only transform specific node_module packages. 10 | transformIgnorePatterns: [nodeModulesToTransform([...grafanaESModules, 'rxjs'])], 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grafana-polystat-panel", 3 | "version": "2.1.14", 4 | "description": "Grafana Polystat 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 | "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", 9 | "lint:fix": "yarn run lint --fix && prettier --write --list-different .", 10 | "playwright:test": "npx playwright test", 11 | "playwright:test:ui": "npx playwright test --ui", 12 | "playwright:showreport": "npx playwright show-report", 13 | "server": "docker compose up --build", 14 | "sign": "npx --yes @grafana/sign-plugin@latest", 15 | "spellcheck": "cspell -c cspell.config.json \"**/*.{ts,tsx,js,go,md,mdx,yml,yaml,json,scss,css}\"", 16 | "test": "jest --watch --onlyChanged", 17 | "test:ci": "jest --passWithNoTests --maxWorkers 4", 18 | "test:coverage": "jest --maxWorkers 4 --coverage", 19 | "typecheck": "tsc --noEmit", 20 | "e2e": "playwright test" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/grafana/grafana-polystat-panel.git" 25 | }, 26 | "author": "Grafana Labs (https://grafana.com)", 27 | "license": "Apache-2.0", 28 | "bugs": { 29 | "url": "https://github.com/grafana/grafana-polystat-panel/issues", 30 | "email": "plugins@grafana.com" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.21.4", 34 | "@babel/helper-validator-option": "7.18.6", 35 | "@braintree/sanitize-url": "^7.0.1", 36 | "@grafana/eslint-config": "^8.0.0", 37 | "@grafana/eslint-plugin-plugins": "^0.2.1", 38 | "@grafana/plugin-e2e": "^1.13.1", 39 | "@grafana/plugin-meta-extractor": "^0.0.7", 40 | "@grafana/tsconfig": "^2.0.0", 41 | "@playwright/test": "^1.47.2", 42 | "@stylistic/eslint-plugin-ts": "^2.9.0", 43 | "@swc/core": "^1.3.90", 44 | "@swc/helpers": "^0.5.0", 45 | "@swc/jest": "^0.2.26", 46 | "@testing-library/jest-dom": "6.6.3", 47 | "@testing-library/react": "12.1.5", 48 | "@testing-library/react-hooks": "^8.0.1", 49 | "@types/d3": "7.4.3", 50 | "@types/d3-hexbin": "0.2.5", 51 | "@types/glob": "^8.1.0", 52 | "@types/hoist-non-react-statics": "3.3.5", 53 | "@types/jest": "^29.5.14", 54 | "@types/jquery": "3.5.31", 55 | "@types/lodash": "^4.14.194", 56 | "@types/node": "^20.11.25", 57 | "@types/react": "17.0.44", 58 | "@types/react-dom": "17.0.15", 59 | "@types/react-router-dom": "^5.2.0", 60 | "@types/semver": "^7.5.8", 61 | "@types/testing-library__jest-dom": "5.14.8", 62 | "@types/tinycolor2": "1.4.6", 63 | "@typescript-eslint/eslint-plugin": "^6.18.0", 64 | "@typescript-eslint/parser": "^6.18.0", 65 | "copy-webpack-plugin": "^11.0.0", 66 | "cspell": "^8.14.4", 67 | "css-loader": "^6.7.3", 68 | "eslint": "^8.57.0", 69 | "eslint-config-prettier": "^8.8.0", 70 | "eslint-plugin-deprecation": "^2.0.0", 71 | "eslint-plugin-jsdoc": "^48.1.0", 72 | "eslint-plugin-prettier": "^4.0.0", 73 | "eslint-plugin-react": "^7.33.0", 74 | "eslint-plugin-react-hooks": "^4.6.0", 75 | "eslint-webpack-plugin": "^4.0.1", 76 | "fork-ts-checker-webpack-plugin": "^8.0.0", 77 | "glob": "^10.2.7", 78 | "identity-obj-proxy": "3.0.0", 79 | "imports-loader": "^5.0.0", 80 | "jest": "^29.7.0", 81 | "jest-canvas-mock": "2.4.0", 82 | "jest-environment-jsdom": "^29.7.0", 83 | "jshint": "2.13.6", 84 | "moment-timezone": "^0.5.45", 85 | "prettier": "^3.2.5", 86 | "replace-in-file-webpack-plugin": "^1.0.6", 87 | "sass": "1.77.0", 88 | "sass-loader": "13.3.1", 89 | "semver": "^7.6.3", 90 | "style-loader": "3.3.3", 91 | "swc-loader": "^0.2.3", 92 | "terser-webpack-plugin": "^5.3.10", 93 | "ts-node": "^10.9.2", 94 | "tsconfig-paths": "^4.2.0", 95 | "typescript": "5.6.2", 96 | "webpack": "^5.94.0", 97 | "webpack-cli": "^5.1.4", 98 | "webpack-livereload-plugin": "^3.0.2", 99 | "webpack-subresource-integrity": "^5.1.0", 100 | "webpack-virtual-modules": "^0.6.2" 101 | }, 102 | "dependencies": { 103 | "@emotion/css": "11.10.6", 104 | "@emotion/react": "11.7.1", 105 | "@grafana/data": "^9.5.21", 106 | "@grafana/runtime": "^9.5.21", 107 | "@grafana/schema": "^11.3.2", 108 | "@grafana/ui": "^9.5.21", 109 | "d3-hexbin": "0.2.2", 110 | "emotion": "11.0.0", 111 | "react": "17.0.2", 112 | "react-dom": "17.0.2", 113 | "react-redux": "7.2.6", 114 | "react-tooltip": "5.28.0", 115 | "tinycolor2": "^1.6.0", 116 | "tslib": "2.5.3" 117 | }, 118 | "engines": { 119 | "node": ">= 20" 120 | }, 121 | "resolutions": { 122 | "@types/react": "17.0.44" 123 | }, 124 | "packageManager": "yarn@4.6.0" 125 | } 126 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { defineConfig, devices } from '@playwright/test'; 3 | import type { PluginOptions } from '@grafana/plugin-e2e'; 4 | 5 | const pluginE2eAuth = `${dirname(require.resolve('@grafana/plugin-e2e'))}/auth`; 6 | 7 | export default defineConfig({ 8 | testDir: './tests', 9 | /* Run tests in files in parallel */ 10 | fullyParallel: true, 11 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 12 | forbidOnly: !!process.env.CI, 13 | /* Retry on CI only */ 14 | retries: process.env.CI ? 2 : 0, 15 | /* Opt out of parallel tests on CI. */ 16 | workers: process.env.CI ? 1 : undefined, 17 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 18 | reporter: 'html', 19 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 20 | use: { 21 | /* Base URL to use in actions like `await page.goto('/')`. */ 22 | baseURL: 'http://localhost:3000', 23 | 24 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 25 | trace: 'on-first-retry', 26 | httpCredentials: { 27 | username: 'admin', 28 | password: 'admin', 29 | }, 30 | }, 31 | 32 | /* Configure projects for major browsers */ 33 | projects: [ 34 | { 35 | name: 'auth', 36 | testDir: pluginE2eAuth, 37 | testMatch: [/.*\.js/], 38 | }, 39 | { 40 | name: 'run-tests', 41 | use: { 42 | ...devices['Desktop Chrome'], 43 | // @grafana/plugin-e2e writes the auth state to this file, 44 | // the path should not be modified 45 | storageState: 'playwright/.auth/admin.json', 46 | launchOptions: { 47 | args: ['--disable-features=PlzDedicatedWorker'], // because https://github.com/microsoft/playwright/pull/34400 48 | }, 49 | channel: 'chrome', 50 | }, 51 | dependencies: ['auth'], 52 | } 53 | ], 54 | 55 | }); 56 | -------------------------------------------------------------------------------- /provisioning/README.md: -------------------------------------------------------------------------------- 1 | For more information see [Provision dashboards and data sources](https://grafana.com/tutorials/provision-dashboards-and-data-sources/) 2 | -------------------------------------------------------------------------------- /provisioning/dashboards/Polystat-CSV-Content-Bug.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_TESTDATA_DB", 5 | "label": "TestData DB", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "testdata", 9 | "pluginName": "TestData" 10 | } 11 | ], 12 | "__elements": {}, 13 | "__requires": [ 14 | { 15 | "type": "grafana", 16 | "id": "grafana", 17 | "name": "Grafana", 18 | "version": "9.5.14" 19 | }, 20 | { 21 | "type": "panel", 22 | "id": "grafana-polystat-panel", 23 | "name": "Polystat", 24 | "version": "2.1.11" 25 | }, 26 | { 27 | "type": "datasource", 28 | "id": "testdata", 29 | "name": "TestData", 30 | "version": "1.0.0" 31 | } 32 | ], 33 | "annotations": { 34 | "list": [ 35 | { 36 | "builtIn": 1, 37 | "datasource": { 38 | "type": "grafana", 39 | "uid": "-- Grafana --" 40 | }, 41 | "enable": true, 42 | "hide": true, 43 | "iconColor": "rgba(0, 211, 255, 1)", 44 | "name": "Annotations & Alerts", 45 | "type": "dashboard" 46 | } 47 | ] 48 | }, 49 | "editable": true, 50 | "fiscalYearStartMonth": 0, 51 | "graphTooltip": 0, 52 | "id": null, 53 | "links": [], 54 | "liveNow": false, 55 | "panels": [ 56 | { 57 | "datasource": { 58 | "type": "testdata", 59 | "uid": "trlxrdZVk" 60 | }, 61 | "fieldConfig": { 62 | "defaults": { 63 | "mappings": [] 64 | }, 65 | "overrides": [] 66 | }, 67 | "gridPos": { 68 | "h": 8, 69 | "w": 12, 70 | "x": 0, 71 | "y": 0 72 | }, 73 | "id": 1, 74 | "options": { 75 | "autoSizeColumns": true, 76 | "autoSizePolygons": true, 77 | "autoSizeRows": true, 78 | "compositeConfig": { 79 | "animationSpeed": "1500", 80 | "composites": [], 81 | "enabled": true 82 | }, 83 | "compositeGlobalAliasingEnabled": false, 84 | "ellipseCharacters": 18, 85 | "ellipseEnabled": false, 86 | "globalAutoScaleFonts": true, 87 | "globalClickthrough": "", 88 | "globalClickthroughCustomTarget": "", 89 | "globalClickthroughCustomTargetEnabled": false, 90 | "globalClickthroughNewTabEnabled": true, 91 | "globalClickthroughSanitizedEnabled": true, 92 | "globalDecimals": 2, 93 | "globalDisplayMode": "all", 94 | "globalDisplayTextTriggeredEmpty": "OK", 95 | "globalFillColor": "rgba(10, 85, 161, 1)", 96 | "globalFontSize": 12, 97 | "globalGradientsEnabled": true, 98 | "globalOperator": "mean", 99 | "globalPolygonBorderColor": "rgba(0, 0, 0, 0)", 100 | "globalPolygonBorderSize": 2, 101 | "globalPolygonSize": 25, 102 | "globalRegexPattern": "", 103 | "globalShape": "hexagon_pointed_top", 104 | "globalShowTimestampEnabled": false, 105 | "globalShowTimestampFontSize": 12, 106 | "globalShowTimestampFormat": "YYYY-MM-DD HH:mm:ss", 107 | "globalShowTimestampPosition": "below_value", 108 | "globalShowTimestampYOffset": 0, 109 | "globalShowTooltipColumnHeadersEnabled": true, 110 | "globalShowValueEnabled": true, 111 | "globalTextFontAutoColorEnabled": true, 112 | "globalTextFontColor": "#000000", 113 | "globalTextFontFamily": "Roboto", 114 | "globalThresholdsConfig": [], 115 | "globalTooltipsEnabled": true, 116 | "globalTooltipsFontFamily": "Roboto", 117 | "globalTooltipsShowTimestampEnabled": true, 118 | "globalTooltipsShowValueEnabled": true, 119 | "globalUnitFormat": "short", 120 | "layoutDisplayLimit": 100, 121 | "layoutNumColumns": 8, 122 | "layoutNumRows": 8, 123 | "overrideConfig": { 124 | "overrides": [] 125 | }, 126 | "sortByDirection": 1, 127 | "sortByField": "name", 128 | "tooltipDisplayMode": "all", 129 | "tooltipDisplayTextTriggeredEmpty": "OK", 130 | "tooltipPrimarySortByField": "thresholdLevel", 131 | "tooltipPrimarySortDirection": 1, 132 | "tooltipSecondarySortByField": "value", 133 | "tooltipSecondarySortDirection": 1 134 | }, 135 | "pluginVersion": "2.1.11", 136 | "targets": [ 137 | { 138 | "csvContent": "Field1,Field2,Field3,Float,Time\nA,5,6,6.7,1621987000000\nB,6,7,8.9,1621988000000", 139 | "datasource": { 140 | "type": "testdata", 141 | "uid": "${DS_TESTDATA_DB}" 142 | }, 143 | "refId": "A", 144 | "scenarioId": "csv_content" 145 | } 146 | ], 147 | "title": "Panel Title", 148 | "type": "grafana-polystat-panel" 149 | } 150 | ], 151 | "refresh": "", 152 | "schemaVersion": 38, 153 | "style": "dark", 154 | "tags": [], 155 | "templating": { 156 | "list": [] 157 | }, 158 | "time": { 159 | "from": "2021-05-25T23:56:40.000Z", 160 | "to": "2021-05-26T00:13:20.000Z" 161 | }, 162 | "timepicker": {}, 163 | "timezone": "", 164 | "title": "Polystat-CSV-Content", 165 | "uid": "b45011c5-1eec-471d-932b-afa22f526efc", 166 | "version": 1, 167 | "weekStart": "" 168 | } 169 | -------------------------------------------------------------------------------- /provisioning/dashboards/dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "type": "dashboard" 15 | } 16 | ] 17 | }, 18 | "editable": true, 19 | "fiscalYearStartMonth": 0, 20 | "graphTooltip": 0, 21 | "id": 1, 22 | "links": [], 23 | "liveNow": false, 24 | "panels": [ 25 | { 26 | "datasource": { 27 | "type": "grafana", 28 | "uid": "grafana" 29 | }, 30 | "gridPos": { 31 | "h": 8, 32 | "w": 12, 33 | "x": 0, 34 | "y": 0 35 | }, 36 | "id": 1, 37 | "options": { 38 | "seriesCountSize": "sm", 39 | "showSeriesCount": false, 40 | "text": "Default value of text input option" 41 | }, 42 | "targets": [ 43 | { 44 | "datasource": { 45 | "type": "datasource", 46 | "uid": "grafana" 47 | }, 48 | "queryType": "randomWalk", 49 | "refId": "A" 50 | } 51 | ], 52 | "title": "Panel Title", 53 | "type": "grafana-polystat-panel" 54 | }, 55 | { 56 | "datasource": { 57 | "type": "grafana-testdata-datasource", 58 | "uid": "trlxrdZVk" 59 | }, 60 | "gridPos": { 61 | "h": 8, 62 | "w": 12, 63 | "x": 12, 64 | "y": 0 65 | }, 66 | "id": 2, 67 | "options": { 68 | "seriesCountSize": "sm", 69 | "showSeriesCount": false, 70 | "text": "Default value of text input option" 71 | }, 72 | "targets": [ 73 | { 74 | "alias": "", 75 | "datasource": { 76 | "type": "grafana-testdata-datasource", 77 | "uid": "db84e60d-b92a-4089-82cb-34842fb1754b" 78 | }, 79 | "refId": "A", 80 | "scenarioId": "no_data_points" 81 | } 82 | ], 83 | "title": "Panel Title", 84 | "type": "grafana-polystat-panel" 85 | } 86 | ], 87 | "refresh": "", 88 | "schemaVersion": 39, 89 | "tags": [], 90 | "templating": { 91 | "list": [] 92 | }, 93 | "time": { 94 | "from": "now-6h", 95 | "to": "now" 96 | }, 97 | "timepicker": {}, 98 | "timezone": "", 99 | "title": "Provisioned polystat dashboard", 100 | "uid": "a538aeff-5a8a-42a5-901c-938d896fdd6f", 101 | "version": 1, 102 | "weekStart": "" 103 | } 104 | -------------------------------------------------------------------------------- /provisioning/dashboards/default.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'grafana-polystat-panel' 5 | type: file 6 | options: 7 | path: /etc/grafana/provisioning/dashboards 8 | -------------------------------------------------------------------------------- /provisioning/datasources/datasources.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: TestData DB 5 | type: testdata 6 | uid: trlxrdZVk 7 | isDefault: true 8 | -------------------------------------------------------------------------------- /src/__mocks__/models/composites.ts: -------------------------------------------------------------------------------- 1 | import { ShowTimestampFormats } from 'components/types'; 2 | import { CompositeItemType } from '../../components/composites/types'; 3 | 4 | export const compositeA: CompositeItemType = { 5 | name: 'composite-a', 6 | label: 'composite-a', 7 | order: 0, 8 | isTemplated: false, 9 | displayMode: 'all', 10 | enabled: true, 11 | showName: true, 12 | showMembers: false, 13 | showValue: true, 14 | showComposite: true, 15 | showTimestampEnabled: false, 16 | showTimestampFormat: ShowTimestampFormats[0].value, 17 | showTimestampYOffset: 0, 18 | clickThrough: '', 19 | clickThroughOpenNewTab: true, 20 | clickThroughSanitize: true, 21 | clickThroughCustomTargetEnabled: false, 22 | clickThroughCustomTarget: '', 23 | metrics: [ 24 | { 25 | seriesMatch: '/series/', 26 | order: 0, 27 | }, 28 | ], 29 | }; 30 | 31 | export const compositeB: CompositeItemType = { 32 | name: 'composite-b', 33 | label: 'composite-b', 34 | order: 0, 35 | isTemplated: false, 36 | displayMode: 'triggered', 37 | enabled: true, 38 | showName: true, 39 | showMembers: false, 40 | showValue: true, 41 | showComposite: true, 42 | showTimestampEnabled: false, 43 | showTimestampFormat: ShowTimestampFormats[0].value, 44 | showTimestampYOffset: 0, 45 | clickThrough: '', 46 | clickThroughOpenNewTab: true, 47 | clickThroughSanitize: true, 48 | clickThroughCustomTargetEnabled: false, 49 | clickThroughCustomTarget: '', 50 | metrics: [ 51 | { 52 | seriesMatch: '/series/', 53 | order: 0, 54 | }, 55 | ], 56 | }; 57 | 58 | export const compositeC: CompositeItemType = { 59 | name: 'composite-numerical', 60 | label: 'composite-numerical', 61 | order: 0, 62 | isTemplated: false, 63 | displayMode: 'all', 64 | enabled: true, 65 | showName: true, 66 | showMembers: false, 67 | showValue: true, 68 | showComposite: true, 69 | showTimestampEnabled: false, 70 | showTimestampFormat: ShowTimestampFormats[0].value, 71 | showTimestampYOffset: 0, 72 | clickThrough: '', 73 | clickThroughOpenNewTab: true, 74 | clickThroughSanitize: true, 75 | clickThroughCustomTargetEnabled: false, 76 | clickThroughCustomTarget: '', 77 | metrics: [ 78 | { 79 | seriesMatch: '/\\d+/', 80 | order: 0, 81 | }, 82 | ], 83 | }; 84 | -------------------------------------------------------------------------------- /src/__mocks__/models/models.ts: -------------------------------------------------------------------------------- 1 | import { FieldConfig, FieldType, toDataFrame } from '@grafana/data'; 2 | import { DataFrameToPolystat } from '../../data/processor'; 3 | import { PolystatModel } from '../../components/types'; 4 | 5 | const field: FieldConfig = { 6 | decimals: 2, 7 | unit: 'MBs', 8 | }; 9 | 10 | const time = new Date('01 October 2022 10:28 UTC').getTime(); 11 | // 12 | const frameA = toDataFrame({ 13 | fields: [ 14 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 15 | { name: 'A-series02', type: FieldType.number, values: [200, 210, 220], config: field }, 16 | ], 17 | }); 18 | export const modelA: PolystatModel = DataFrameToPolystat(frameA, 'mean')[0]; 19 | // 20 | const frameB = toDataFrame({ 21 | fields: [ 22 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 23 | { name: 'B-series03', type: FieldType.number, values: [500, 510, 520] }, 24 | ], 25 | }); 26 | // 27 | export const modelB: PolystatModel = DataFrameToPolystat(frameB, 'mean')[0]; 28 | const frameC = toDataFrame({ 29 | fields: [ 30 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 31 | { name: 'C-series01', type: FieldType.number, values: [333, 444, 555] }, 32 | ], 33 | }); 34 | export const modelC: PolystatModel = DataFrameToPolystat(frameC, 'mean')[0]; 35 | 36 | /* 37 | Numerical Metric Names 38 | */ 39 | const numericalFrameA = toDataFrame({ 40 | fields: [ 41 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 42 | { name: '02', type: FieldType.number, values: [200, 210, 220], config: field }, 43 | ], 44 | }); 45 | export const numericalModelA: PolystatModel = DataFrameToPolystat(numericalFrameA, 'mean')[0]; 46 | 47 | const numericalFrameB = toDataFrame({ 48 | fields: [ 49 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 50 | { name: '03', type: FieldType.number, values: [500, 510, 520] }, 51 | ], 52 | }); 53 | export const numericalModelB: PolystatModel = DataFrameToPolystat(numericalFrameB, 'mean')[0]; 54 | 55 | const numericalFrameC = toDataFrame({ 56 | fields: [ 57 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 58 | { name: '01', type: FieldType.number, values: [333, 444, 555] }, 59 | ], 60 | }); 61 | export const numericalModelC: PolystatModel = DataFrameToPolystat(numericalFrameC, 'mean')[0]; 62 | 63 | /* 64 | Numerical Metric Names 65 | */ 66 | const casedFrameA = toDataFrame({ 67 | fields: [ 68 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 69 | { name: 'series-a1', type: FieldType.number, values: [200, 210, 220], config: field }, 70 | ], 71 | }); 72 | export const casedModelA: PolystatModel = DataFrameToPolystat(casedFrameA, 'mean')[0]; 73 | 74 | const casedFrameB = toDataFrame({ 75 | fields: [ 76 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 77 | { name: 'series-a3', type: FieldType.number, values: [500, 510, 520] }, 78 | ], 79 | }); 80 | export const casedModelB: PolystatModel = DataFrameToPolystat(casedFrameB, 'mean')[0]; 81 | 82 | const casedFrameC = toDataFrame({ 83 | fields: [ 84 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 85 | { name: 'series-A2', type: FieldType.number, values: [333, 444, 555] }, 86 | ], 87 | }); 88 | export const casedModelC: PolystatModel = DataFrameToPolystat(casedFrameC, 'mean')[0]; 89 | -------------------------------------------------------------------------------- /src/__snapshots__/migrations.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Polystat -> PolystatV2 migrations migrates old polystat config 1`] = ` 4 | { 5 | "autoSizeColumns": true, 6 | "autoSizePolygons": true, 7 | "autoSizeRows": true, 8 | "compositeConfig": { 9 | "animationSpeed": "2500", 10 | "composites": [], 11 | "enabled": true, 12 | }, 13 | "compositeGlobalAliasingEnabled": false, 14 | "ellipseCharacters": 18, 15 | "ellipseEnabled": false, 16 | "globalAutoScaleFonts": true, 17 | "globalClickthrough": "https://grafana.com", 18 | "globalClickthroughCustomTarget": "", 19 | "globalClickthroughCustomTargetEnabled": false, 20 | "globalClickthroughNewTabEnabled": false, 21 | "globalClickthroughSanitizedEnabled": true, 22 | "globalDecimals": 2, 23 | "globalDisplayMode": "all", 24 | "globalDisplayTextTriggeredEmpty": "OK", 25 | "globalFillColor": "#0a55a1", 26 | "globalFontSize": 12, 27 | "globalGradientsEnabled": true, 28 | "globalOperator": "mean", 29 | "globalPolygonBorderColor": "#000000", 30 | "globalPolygonBorderSize": 2, 31 | "globalPolygonSize": 50, 32 | "globalRegexPattern": "", 33 | "globalShape": "square", 34 | "globalShowTimestampEnabled": false, 35 | "globalShowTimestampFontSize": 12, 36 | "globalShowTimestampFormat": "HH:mm:ss", 37 | "globalShowTimestampPosition": "above_value", 38 | "globalShowTimestampYOffset": 0, 39 | "globalShowTooltipColumnHeadersEnabled": true, 40 | "globalShowValueEnabled": true, 41 | "globalTextFontAutoColor": "#000000", 42 | "globalTextFontAutoColorEnabled": true, 43 | "globalTextFontColor": "#000000", 44 | "globalTextFontFamily": "Roboto", 45 | "globalThresholdsConfig": [], 46 | "globalTooltipsEnabled": true, 47 | "globalTooltipsFontFamily": "Roboto", 48 | "globalTooltipsShowTimestampEnabled": true, 49 | "globalTooltipsShowValueEnabled": true, 50 | "globalUnitFormat": "short", 51 | "layoutDisplayLimit": 100, 52 | "layoutNumColumns": 6, 53 | "layoutNumRows": 8, 54 | "overrideConfig": { 55 | "overrides": [], 56 | }, 57 | "panelId": 0, 58 | "radius": 100, 59 | "sortByDirection": 1, 60 | "sortByField": "name", 61 | "tooltipDisplayMode": "all", 62 | "tooltipDisplayTextTriggeredEmpty": "OK", 63 | "tooltipPrimarySortByField": "thresholdLevel", 64 | "tooltipPrimarySortDirection": 2, 65 | "tooltipSecondarySortByField": "value", 66 | "tooltipSecondarySortDirection": 2, 67 | } 68 | `; 69 | 70 | exports[`Polystat -> PolystatV2 migrations migrates old polystat config with mappings 1`] = ` 71 | { 72 | "autoSizeColumns": true, 73 | "autoSizePolygons": true, 74 | "autoSizeRows": true, 75 | "compositeConfig": { 76 | "animationSpeed": "2500", 77 | "composites": [], 78 | "enabled": true, 79 | }, 80 | "compositeGlobalAliasingEnabled": false, 81 | "ellipseCharacters": 18, 82 | "ellipseEnabled": false, 83 | "globalAutoScaleFonts": true, 84 | "globalClickthrough": "https://grafana.com", 85 | "globalClickthroughCustomTarget": "", 86 | "globalClickthroughCustomTargetEnabled": false, 87 | "globalClickthroughNewTabEnabled": false, 88 | "globalClickthroughSanitizedEnabled": true, 89 | "globalDecimals": 2, 90 | "globalDisplayMode": "all", 91 | "globalDisplayTextTriggeredEmpty": "OK", 92 | "globalFillColor": "#0a55a1", 93 | "globalFontSize": 12, 94 | "globalGradientsEnabled": true, 95 | "globalOperator": "mean", 96 | "globalPolygonBorderColor": "#000000", 97 | "globalPolygonBorderSize": 2, 98 | "globalPolygonSize": 50, 99 | "globalRegexPattern": "", 100 | "globalShape": "square", 101 | "globalShowTimestampEnabled": false, 102 | "globalShowTimestampFontSize": 12, 103 | "globalShowTimestampFormat": "HH:mm:ss", 104 | "globalShowTimestampPosition": "above_value", 105 | "globalShowTimestampYOffset": 0, 106 | "globalShowTooltipColumnHeadersEnabled": true, 107 | "globalShowValueEnabled": true, 108 | "globalTextFontAutoColor": "#000000", 109 | "globalTextFontAutoColorEnabled": true, 110 | "globalTextFontColor": "#000000", 111 | "globalTextFontFamily": "Roboto", 112 | "globalThresholdsConfig": [], 113 | "globalTooltipsEnabled": true, 114 | "globalTooltipsFontFamily": "Roboto", 115 | "globalTooltipsShowTimestampEnabled": true, 116 | "globalTooltipsShowValueEnabled": true, 117 | "globalUnitFormat": "short", 118 | "layoutDisplayLimit": 100, 119 | "layoutNumColumns": 6, 120 | "layoutNumRows": 8, 121 | "overrideConfig": { 122 | "overrides": [], 123 | }, 124 | "panelId": 0, 125 | "radius": 100, 126 | "sortByDirection": 1, 127 | "sortByField": "name", 128 | "tooltipDisplayMode": "all", 129 | "tooltipDisplayTextTriggeredEmpty": "OK", 130 | "tooltipPrimarySortByField": "thresholdLevel", 131 | "tooltipPrimarySortDirection": 2, 132 | "tooltipSecondarySortByField": "value", 133 | "tooltipSecondarySortDirection": 2, 134 | } 135 | `; 136 | 137 | exports[`Polystat -> PolystatV2 migrations migrates old polystat config with mappings 2`] = ` 138 | { 139 | "fieldConfig": { 140 | "defaults": { 141 | "mappings": [ 142 | { 143 | "options": { 144 | "match": "null", 145 | "result": { 146 | "color": undefined, 147 | "text": "N/A", 148 | }, 149 | }, 150 | "type": "special", 151 | }, 152 | { 153 | "options": { 154 | "30.386": { 155 | "color": undefined, 156 | "text": "Nominal", 157 | }, 158 | }, 159 | "type": "value", 160 | }, 161 | ], 162 | }, 163 | "overrides": [], 164 | }, 165 | "options": {}, 166 | } 167 | `; 168 | -------------------------------------------------------------------------------- /src/components/Polystat.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { getTextToDisplay } from './Polystat'; 4 | 5 | describe('Test Polystat', () => { 6 | describe('Ellipse Generation', () => { 7 | it('returns ellipses only for metrics that require them', () => { 8 | const shortName = getTextToDisplay(true, true, 12, true, 12, 'abc', 'abc'); 9 | expect(shortName).toEqual('abc'); 10 | const longName = getTextToDisplay(true, true, 12, true, 12, '123456789012', '123456789012'); 11 | expect(longName).toEqual('123456789012'); 12 | const veryLongName = getTextToDisplay(true, true, 12, true, 9, '1234567890123', '1234567890123'); 13 | expect(veryLongName).toEqual('123456789...'); 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/PolystatPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import { PanelProps, GrafanaTheme2, LoadingState } from '@grafana/data'; 4 | import { PolystatOptions, PolystatModel } from './types'; 5 | import { Polystat } from './Polystat'; 6 | import { css, cx } from '@emotion/css'; 7 | import { useStyles2, useTheme, useTheme2 } from '@grafana/ui'; 8 | import { ProcessDataFrames } from '../data/processor'; 9 | 10 | interface Props extends PanelProps {} 11 | 12 | const getComponentStyles = (theme: GrafanaTheme2) => { 13 | return { 14 | wrapper: css` 15 | position: relative; 16 | `, 17 | container: css` 18 | align-items: center; 19 | justify-content: center; 20 | display: flex; 21 | height: 100%; 22 | width: 100%; 23 | & svg > g > polygon { 24 | fill: transparent; 25 | } 26 | `, 27 | }; 28 | }; 29 | 30 | 31 | export const PolystatPanel: React.FC = ({ options, data, id, width, height, replaceVariables, fieldConfig, timeZone }) => { 32 | const styles = useStyles2(getComponentStyles); 33 | const currentThemeV1 = useTheme(); // V8 34 | const currentThemeV2 = useTheme2(); // V9+ 35 | let [cachedProcessedData, setCachedProcessedData] = useState(); 36 | useEffect(() => { 37 | if (data.state === LoadingState.Done) { 38 | // each series is a converted to a model we can use 39 | const processedData = ProcessDataFrames( 40 | options.compositeConfig.enabled, 41 | options.compositeConfig.composites, 42 | options.overrideConfig.overrides, 43 | data, 44 | replaceVariables, 45 | fieldConfig, 46 | options.globalClickthrough, 47 | options.globalClickthroughNewTabEnabled, 48 | options.globalClickthroughSanitizedEnabled, 49 | options.globalClickthroughCustomTargetEnabled, 50 | options.globalClickthroughCustomTarget, 51 | options.globalOperator, 52 | options.globalDecimals, 53 | options.globalDisplayMode, 54 | options.globalRegexPattern, 55 | options.globalFillColor, 56 | options.globalThresholdsConfig, 57 | options.globalUnitFormat, 58 | true, // TODO: future configurable global option to not display label 59 | options.globalShowValueEnabled, 60 | options.globalShowTimestampEnabled, 61 | options.globalShowTimestampFormat, 62 | options.sortByDirection, 63 | options.sortByField, 64 | options.compositeGlobalAliasingEnabled, 65 | timeZone, 66 | currentThemeV1, 67 | currentThemeV2, 68 | ); 69 | setCachedProcessedData(processedData); 70 | 71 | } 72 | }, [data, fieldConfig, options, replaceVariables, currentThemeV1, currentThemeV2, timeZone]); 73 | 74 | if (cachedProcessedData === undefined) { 75 | return ( 76 | <>Loading... please wait 77 | ) 78 | } 79 | 80 | return ( 81 |
90 |
91 | 153 |
154 |
155 | ); 156 | }; 157 | -------------------------------------------------------------------------------- /src/components/alignment.test.ts: -------------------------------------------------------------------------------- 1 | import { GetAlignments } from './alignment'; 2 | 3 | describe('Test GetAlignments', () => { 4 | beforeEach(() => { 5 | 6 | }); 7 | 8 | describe('Label Only', () => { 9 | it('returns alignment when just label is showing', () => { 10 | }); 11 | }); 12 | 13 | }); 14 | -------------------------------------------------------------------------------- /src/components/alignment.ts: -------------------------------------------------------------------------------- 1 | import { PolygonShapes } from "./types"; 2 | 3 | export const GetAlignments = ( 4 | shape: PolygonShapes, 5 | diameterX: number, 6 | diameterY: number, 7 | textAreaHeight: number, 8 | activeValueFontSize: number, 9 | activeLabelFontSize: number, 10 | activeTimestampFontSize: number, 11 | showTimestampEnabled: boolean, 12 | ) => { 13 | let valueWithLabelTextAlignment = textAreaHeight / 2 / 2 + activeValueFontSize / 2; 14 | let valueOnlyTextAlignment = activeValueFontSize / 2; 15 | let labelWithValueTextAlignment = -(textAreaHeight / 2 / 2) + activeLabelFontSize / 2; 16 | let labelOnlyTextAlignment = activeLabelFontSize / 2; 17 | let labelTextAlignmentX = 0; 18 | let labelValueAlignmentX = 0; 19 | let valueWithTimestampAlignment = valueWithLabelTextAlignment / 2; 20 | let timestampAlignment = textAreaHeight * 0.33 / 2 + activeTimestampFontSize / 2; 21 | switch (shape) { 22 | case PolygonShapes.HEXAGON_POINTED_TOP: 23 | // offset when only showing label 24 | labelOnlyTextAlignment = activeLabelFontSize * 0.37; 25 | if (showTimestampEnabled && activeTimestampFontSize > 0) { 26 | // adjust value down 27 | valueWithLabelTextAlignment = textAreaHeight * 0.67 / 2 + activeValueFontSize / 2; 28 | } 29 | break; 30 | case PolygonShapes.CIRCLE: 31 | // offset when only showing label 32 | labelOnlyTextAlignment = activeLabelFontSize * 0.37; 33 | if (showTimestampEnabled && activeTimestampFontSize > 0) { 34 | // adjust value down 35 | valueWithLabelTextAlignment = textAreaHeight * 0.67 / 2 + activeValueFontSize / 2; 36 | } 37 | break; 38 | case PolygonShapes.SQUARE: 39 | // square is "centered" at top left, not the center 40 | 41 | // compute alignment for each text element, base coordinate is in the top left corner (text is anchored at its bottom): 42 | // - Value text (bottom text) will be aligned (positively i.e. lower) in the middle of the bottom half of the text area 43 | // - Label text (top text) will be aligned in the middle of the top half of the text area 44 | valueWithLabelTextAlignment = diameterY / 1.5 + activeValueFontSize / 2; 45 | valueOnlyTextAlignment = diameterY / 2 + activeLabelFontSize * 0.37; 46 | labelWithValueTextAlignment = diameterY / 4 + activeLabelFontSize / 2; 47 | // alignment is equal to the half of height plus a fraction of the fontSize 48 | labelOnlyTextAlignment = diameterY / 2 + activeLabelFontSize * 0.37; 49 | // 50 | labelTextAlignmentX = diameterX / 2; 51 | labelValueAlignmentX = diameterX / 2; 52 | if (showTimestampEnabled && activeTimestampFontSize > 0) { 53 | // line spacing offset is needed 54 | timestampAlignment = diameterY / 1.5 - (activeTimestampFontSize * 0.67); 55 | } 56 | break; 57 | } 58 | return { 59 | valueWithLabelTextAlignment, 60 | valueOnlyTextAlignment, 61 | valueWithTimestampAlignment, 62 | labelWithValueTextAlignment, 63 | labelOnlyTextAlignment, 64 | labelTextAlignmentX, 65 | labelValueAlignmentX, 66 | timestampAlignment, 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/auto_font_scaler.test.ts: -------------------------------------------------------------------------------- 1 | import { PolystatModel } from './types'; 2 | import { AutoFontScalar } from './auto_font_scaler'; 3 | 4 | describe('Test AutoFontScaler', () => { 5 | beforeEach(() => { 6 | 7 | }); 8 | 9 | describe('Scale for Label Only', () => { 10 | it('returns font size for a labels', () => { 11 | }); 12 | }); 13 | 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/auto_font_scaler.ts: -------------------------------------------------------------------------------- 1 | import { PolystatModel } from "./types"; 2 | import { getTextSizeForWidthAndHeight } from '../utils'; 3 | 4 | 5 | export const AutoFontScalar = ( 6 | fontFamily: string, 7 | textAreaWidth: number, 8 | textAreaHeight: number, 9 | valueEnabled: boolean, 10 | showTimestamp: boolean, 11 | data: PolystatModel[] 12 | ) => { 13 | // TODO: 6 is VERY small, perhaps 10 as a min? 14 | // A hint from the config could be used (max characters) 15 | const minFont = 6; 16 | const maxFont = 240; 17 | // this ensures we have space between label and value 18 | const maxLinesToDisplay = 2; 19 | let showEllipses = false; 20 | // number of characters to show on polygon 21 | let numOfChars = 0; 22 | 23 | // find the most text that will be displayed over all items 24 | // displayName will have the "processed" name with Global Regex applied 25 | let maxLabel = getMaxLabel(data); 26 | // estimate how big of a font can be used 27 | // Two lines of text must fit with vertical spacing included 28 | // if it is too small, hide everything 29 | let activeLabelFontSize = computeTextFontSize( 30 | maxLabel, 31 | fontFamily, 32 | minFont, 33 | maxFont, 34 | maxLinesToDisplay, 35 | textAreaWidth, 36 | textAreaHeight 37 | ); 38 | if (activeLabelFontSize < minFont) { 39 | showEllipses = true; 40 | numOfChars = 18; 41 | maxLabel = maxLabel.substring(0, numOfChars + 2); 42 | activeLabelFontSize = computeTextFontSize( 43 | maxLabel, 44 | fontFamily, 45 | minFont, 46 | maxFont, 47 | maxLinesToDisplay, 48 | textAreaWidth, 49 | textAreaHeight 50 | ); 51 | if (activeLabelFontSize < minFont) { 52 | numOfChars = 10; 53 | maxLabel = maxLabel.substring(0, numOfChars + 2); 54 | activeLabelFontSize = computeTextFontSize( 55 | maxLabel, 56 | fontFamily, 57 | minFont, 58 | maxFont, 59 | maxLinesToDisplay, 60 | textAreaWidth, 61 | textAreaHeight 62 | ); 63 | if (activeLabelFontSize < minFont) { 64 | numOfChars = 6; 65 | maxLabel = maxLabel.substring(0, numOfChars + 2); 66 | activeLabelFontSize = computeTextFontSize( 67 | maxLabel, 68 | fontFamily, 69 | minFont, 70 | maxFont, 71 | maxLinesToDisplay, 72 | textAreaWidth, 73 | textAreaHeight 74 | ); 75 | } 76 | } 77 | } 78 | 79 | // same for the value and timestamp option, also check for sub metrics size in case of composite 80 | let {maxValue, maxTimestamp} = getMaxValueAndTimestamp(data); 81 | // assume no timestamp 82 | let activeValueFontSize = computeTextFontSize( 83 | maxValue, 84 | fontFamily, 85 | minFont, 86 | maxFont, 87 | maxLinesToDisplay, 88 | textAreaWidth, 89 | textAreaHeight 90 | ); 91 | if (showTimestamp) { 92 | // two lines to be displayed, sharing half of the normal space for the value 93 | activeValueFontSize = computeTextFontSize( 94 | maxValue, 95 | fontFamily, 96 | minFont, 97 | maxFont, 98 | 2, 99 | textAreaWidth, 100 | (textAreaHeight * 0.67) 101 | ); 102 | } 103 | // timestamp shares the same space as the value, but is always smaller 104 | let activeTimestampFontSize = computeTextFontSize( 105 | maxTimestamp, 106 | fontFamily, 107 | minFont, 108 | maxFont, 109 | 2, 110 | textAreaWidth, 111 | (textAreaHeight * 0.33) 112 | ); 113 | 114 | if (activeTimestampFontSize < minFont) { 115 | // do not render it, too small 116 | activeTimestampFontSize = 0; 117 | } 118 | 119 | // NOTE: allow different sizes, the value could be displayed larger than the label 120 | // value should never be larger than the label 121 | //if (activeValueFontSize > activeLabelFontSize) { 122 | // activeValueFontSize = activeLabelFontSize; 123 | //} 124 | let activeCompositeValueFontSize = activeValueFontSize; 125 | let haveCompositeValueEnabled = false; 126 | // check if there are any composites with value enabled 127 | if (data) { 128 | for (let i = 0; i < data.length; i++) { 129 | const item = data[i]; 130 | if (item.isComposite && item.showValue) { 131 | // at least one composite has showValue set 132 | haveCompositeValueEnabled = true; 133 | break; 134 | } 135 | } 136 | } 137 | if (!valueEnabled) { 138 | activeValueFontSize = 0; 139 | // if there are no composites with value enabled, set it to zero also 140 | if (!haveCompositeValueEnabled) { 141 | activeCompositeValueFontSize = 0; 142 | } 143 | } 144 | 145 | return { activeLabelFontSize, activeValueFontSize, activeCompositeValueFontSize, activeTimestampFontSize, showEllipses, numOfChars }; 146 | }; 147 | 148 | /** 149 | * Finds the longest text that to be displayed 150 | * 151 | * @param {PolystatModel[]} data [PolystatModel data] 152 | * 153 | * @return {string} [longest label as a string] 154 | */ 155 | const getMaxLabel = (data: PolystatModel[]): string => { 156 | let maxLabel = ''; 157 | for (let i = 0; i < data.length; i++) { 158 | let nameToCheck = data[i].name; 159 | // use the displayName since it has been formatted 160 | if (data[i].displayName !== '') { 161 | nameToCheck = data[i].displayName; 162 | } 163 | if (nameToCheck.length > maxLabel.length) { 164 | maxLabel = nameToCheck; 165 | } 166 | } 167 | return maxLabel; 168 | }; 169 | 170 | /** 171 | * Finds the longest value and timestamp text to be displayed 172 | * 173 | * @param {PolystatModel[]} data [PolystatModel data] 174 | * 175 | * @return {string[]} [value,timestamp] 176 | */ 177 | const getMaxValueAndTimestamp = (data: PolystatModel[]) => { 178 | // same for the value, also check for sub metrics size in case of composite 179 | // timestamp is also calculated here 180 | let maxValue = ''; 181 | let maxTimestamp = ''; 182 | for (let i = 0; i < data.length; i++) { 183 | if (data[i].valueFormatted.length > maxValue.length) { 184 | maxValue = data[i].valueFormatted; 185 | } 186 | if (data[i].timestampFormatted.length > maxTimestamp.length) { 187 | maxTimestamp = data[i].timestampFormatted; 188 | } 189 | const subMetricCount = data[i].members.length; 190 | if (subMetricCount > 0) { 191 | let counter = 0; 192 | while (counter < subMetricCount) { 193 | const checkContent = data[i].members[counter].displayName + ': ' + data[i].members[counter].valueFormatted; 194 | if (checkContent && checkContent.length > maxValue.length) { 195 | maxValue = checkContent; 196 | } 197 | const checkCompositeTimestamp = data[i].members[counter].timestampFormatted; 198 | if (checkCompositeTimestamp && checkCompositeTimestamp.length > maxTimestamp.length) { 199 | maxTimestamp = checkCompositeTimestamp; 200 | } 201 | counter++; 202 | } 203 | } 204 | } 205 | return {maxValue, maxTimestamp}; 206 | }; 207 | 208 | const computeTextFontSize = ( 209 | text: string, 210 | font: string, 211 | minFont: number, 212 | maxFont: number, 213 | linesToDisplay: number, 214 | textAreaWidth: number, 215 | textAreaHeight: number 216 | ): number => { 217 | return getTextSizeForWidthAndHeight( 218 | text, 219 | `?px ${font}`, 220 | textAreaWidth, 221 | textAreaHeight / linesToDisplay, // multiple lines of text 222 | minFont, 223 | maxFont 224 | ); 225 | }; 226 | -------------------------------------------------------------------------------- /src/components/composites/CompositeMetricItem.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import { CompositeMetricItem } from './CompositeMetricItem'; 5 | import { CompositeMetricItemProps, CompositeMetric } from './types'; 6 | import { FieldType, toDataFrame } from '@grafana/data'; 7 | 8 | describe('Test CompositeMetricItem', () => { 9 | const time = new Date().getTime(); 10 | 11 | const frameA = toDataFrame({ 12 | fields: [ 13 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 14 | { name: 'A-series', type: FieldType.number, values: [200, 210, 220] }, 15 | ], 16 | }); 17 | 18 | const aMetric: CompositeMetric = { 19 | seriesMatch: '.*', 20 | order: 0, 21 | } 22 | const props: CompositeMetricItemProps = { 23 | metric: aMetric, 24 | index: 0, 25 | disabled: false, 26 | removeMetric: undefined, 27 | updateMetric: undefined, 28 | updateMetricAlias: undefined, 29 | context: { data: [frameA] }, 30 | }; 31 | beforeEach(() => { }); 32 | 33 | describe('Metric Hints', () => { 34 | it('returns set of hint from labels', () => { 35 | const { container } = render( 36 | 37 | ); 38 | console.log(container.innerHTML); 39 | expect(container.innerHTML).toMatchSnapshot(); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/components/composites/CompositeMetricItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import { Input, Field, IconButton, HorizontalGroup, Cascader, CascaderOption, FieldSet } from '@grafana/ui'; 4 | import { CompositeMetricItemProps } from './types'; 5 | import { getMetricHints } from '../metric_hints'; 6 | 7 | export const CompositeMetricItem: React.FC = (props) => { 8 | const [metricHints, setMetricHints] = useState([]); 9 | 10 | async function copySelectedMetricToClipboard(index: number) { 11 | if (props.metric.seriesMatch) { 12 | const aValue = props.metric.seriesMatch; 13 | if ('clipboard' in navigator) { 14 | if (aValue) { 15 | return await navigator.clipboard.writeText(aValue); 16 | } 17 | } else { 18 | if (aValue) { 19 | // use the old method if clipboard is not available 20 | // eslint-disable-next-line deprecation/deprecation 21 | return document.execCommand('copy', true, aValue); 22 | } 23 | } 24 | } 25 | } 26 | 27 | const updateMetric = (v: string) => { 28 | props.updateMetric(props.index, v); 29 | }; 30 | const updateMetricAlias = (alias: string) => { 31 | props.updateMetricAlias(props.index, alias); 32 | }; 33 | 34 | useEffect(() => { 35 | if (props.context.data) { 36 | const frames = props.context.data; 37 | let hints: CascaderOption[] = []; 38 | let metricHints = getMetricHints(frames); 39 | for (const metricName of metricHints) { 40 | hints.push({ 41 | label: metricName, 42 | value: metricName, 43 | }); 44 | } 45 | setMetricHints(hints); 46 | } 47 | }, [props.context.data]); 48 | 49 | return ( 50 |
51 | 52 | props.removeMetric(props.index)} 58 | /> 59 | copySelectedMetricToClipboard(props.index)} 65 | /> 66 | 67 | updateMetric(val)} 74 | /> 75 | 76 | 77 | updateMetricAlias(e.currentTarget.value)} /> 80 | 81 | 82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/components/composites/__snapshots__/CompositeMetricItem.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Test CompositeMetricItem Metric Hints returns set of hint from labels 1`] = `"
"`; 4 | -------------------------------------------------------------------------------- /src/components/composites/types.ts: -------------------------------------------------------------------------------- 1 | import { SelectableValue } from '@grafana/data'; 2 | 3 | export const DisplayModes: SelectableValue[] = [ 4 | { value: 'all', label: 'Show All' }, 5 | { value: 'triggered', label: 'Show Triggered' }, 6 | ]; 7 | 8 | export interface CompositeMetric { 9 | seriesMatch: string; 10 | compositeMatch?: CompositeItemType[]; 11 | alias?: string; 12 | ID?: string; 13 | order: number; 14 | seriesName?: string; 15 | seriesNameEscaped?: string; 16 | } 17 | 18 | export interface CompositeItemType { 19 | name: string; 20 | label: string; 21 | order: number; 22 | isTemplated: boolean; 23 | displayMode: string; 24 | enabled: boolean; 25 | showName: boolean; 26 | showValue: boolean; 27 | showComposite: boolean; 28 | showMembers: boolean; 29 | showTimestampEnabled: boolean; 30 | showTimestampFormat: string; 31 | showTimestampYOffset: number; 32 | metrics: CompositeMetric[]; 33 | clickThrough: string | ''; 34 | clickThroughSanitize: boolean; 35 | clickThroughOpenNewTab: boolean; 36 | clickThroughCustomTargetEnabled: boolean; 37 | clickThroughCustomTarget: string; 38 | } 39 | 40 | export interface CompositeItemTracker { 41 | composite: CompositeItemType; 42 | order: number; 43 | ID: string; 44 | } 45 | 46 | export interface CompositeItemProps { 47 | composite: CompositeItemType; 48 | ID: string; 49 | enabled: boolean; 50 | setter: any; 51 | remover: any; 52 | moveUp: any; 53 | moveDown: any; 54 | createDuplicate: any; 55 | context: any; 56 | } 57 | 58 | export interface CompositeMetricItemProps { 59 | metric: CompositeMetric; 60 | index: number; 61 | disabled: boolean; 62 | removeMetric: any; 63 | updateMetric: any; 64 | updateMetricAlias: any; 65 | context: any; 66 | } 67 | -------------------------------------------------------------------------------- /src/components/defaults.ts: -------------------------------------------------------------------------------- 1 | import { Color } from './gradients/color'; 2 | import { FontFamilies } from './types'; 3 | 4 | /** 5 | * Color to use when rendering without any thresholds/overrides 6 | */ 7 | export const GLOBAL_FILL_COLOR_RGBA = 'rgba(10, 85, 161, 1)'; // "#0a55a1" 8 | export const GLOBAL_FILL_COLOR_HEX = '#0a55a1'; 9 | export const GLOBAL_FILL_COLOR = new Color(10, 85, 161); 10 | 11 | export const GLOBAL_BORDER_COLOR_RGBA = 'rgba(0, 0, 0, 0)'; // "#000000" 12 | /** 13 | * Color for threshold OK state 14 | */ 15 | export const DEFAULT_OK_COLOR_RGBA = 'rgba(41, 156, 70, 1))'; // #299c46 16 | export const DEFAULT_OK_COLOR_HEX = '#299c46'; 17 | export const DEFAULT_OK_COLOR = new Color(41, 156, 70); 18 | /** 19 | * Color for threshold Warning state 20 | */ 21 | export const DEFAULT_WARNING_COLOR_RGBA = 'rgba(237, 129, 40, 1)'; // alternates // #FFC837 // '#e5ac0e' 22 | export const DEFAULT_WARNING_COLOR_HEX = '#ed8128'; // alternates // #FFC837 // '#e5ac0e' 23 | export const DEFAULT_WARNING_COLOR = new Color(237, 129, 40); 24 | /** 25 | * Color for threshold Critical state 26 | */ 27 | export const DEFAULT_CRITICAL_COLOR_RGBA = 'rgba(245, 54, 54, 1)'; 28 | export const DEFAULT_CRITICAL_COLOR_HEX = '#f53636'; 29 | export const DEFAULT_CRITICAL_COLOR = new Color(245, 54, 54); 30 | 31 | export const DEFAULT_NO_THRESHOLD_COLOR_RGBA = GLOBAL_FILL_COLOR_RGBA; 32 | export const DEFAULT_NO_THRESHOLD_COLOR_HEX = GLOBAL_FILL_COLOR_HEX; 33 | export const DEFAULT_NO_THRESHOLD_COLOR = new Color(64, 64, 160); 34 | 35 | export const DEFAULT_NO_DATA_COLOR_HEX = '#808080'; // "grey" 36 | 37 | /** 38 | * Unit to apply to all metrics without overrides 39 | */ 40 | export const GLOBAL_UNIT_FORMAT = 'short'; 41 | /** 42 | * Number of decimals to display in polygon 43 | */ 44 | export const GLOBAL_DISPLAY_DECIMALS = 2; 45 | /** 46 | * Show all metrics 47 | */ 48 | export const GLOBAL_DISPLAY_MODE = 'all'; 49 | /** 50 | * Display OK when global mode is set to triggered and there are no triggers 51 | */ 52 | export const GLOBAL_DISPLAY_TEXT_TRIGGERED_EMPTY = 'OK'; 53 | /** 54 | * Display average (mean) stat for metric 55 | */ 56 | export const GLOBAL_OPERATOR_NAME = 'mean'; // mean 57 | 58 | export const GLOBAL_OVERRIDE_COLORS = [ 59 | DEFAULT_OK_COLOR_HEX, 60 | DEFAULT_WARNING_COLOR_HEX, 61 | DEFAULT_CRITICAL_COLOR_HEX, 62 | DEFAULT_NO_THRESHOLD_COLOR_HEX, 63 | ]; 64 | 65 | export const GLOBAL_TEXT_FONT_FAMILY = FontFamilies.INTER; 66 | export const GLOBAL_TOOLTIP_FONT_FAMILY = FontFamilies.INTER; 67 | export const GLOBAL_TEXT_FONT_FAMILY_LEGACY = FontFamilies.ROBOTO; 68 | export const GLOBAL_TOOLTIP_FONT_FAMILY_LEGACY = FontFamilies.ROBOTO; 69 | -------------------------------------------------------------------------------- /src/components/gradients/Gradients.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import { Gradients, GradientProps } from './Gradients'; 5 | 6 | describe('Test Gradients', () => { 7 | const props: GradientProps = { 8 | data: [], 9 | gradientId: 'abc', 10 | }; 11 | beforeEach(() => {}); 12 | 13 | describe('Gradient Generation', () => { 14 | it('returns set of gradients', () => { 15 | const { container } = render( 16 | 17 | 18 | 19 | ); 20 | //console.log(container.innerHTML); 21 | expect(container.innerHTML).toMatchSnapshot(); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/gradients/Gradients.tsx: -------------------------------------------------------------------------------- 1 | import { DEFAULT_CRITICAL_COLOR, DEFAULT_OK_COLOR, DEFAULT_WARNING_COLOR } from '../defaults'; 2 | import React from 'react'; 3 | 4 | import { Color } from './color'; 5 | 6 | export interface GradientProps { 7 | data: any; 8 | gradientId: string; 9 | } 10 | export const Gradients: React.FC = (options) => { 11 | const pureLight = new Color(255, 255, 255); 12 | 13 | const createGradients = (data: any): any => { 14 | const gradients = []; 15 | const pureLight = new Color(255, 255, 255); 16 | for (let i = 0; i < data.length; i++) { 17 | const aColorStart = new Color(0, 0, 0); 18 | // color can be in hex or in rgb 19 | let useColor: string = data[i].color; 20 | if (useColor.startsWith('rgba')) { 21 | useColor = Color.RGBAToHex(useColor); 22 | } 23 | aColorStart.fromHex(useColor); 24 | const aColorEnd = aColorStart.Mul(pureLight, 0.7); 25 | gradients.push({ start: aColorStart.asHex(), end: aColorEnd.asHex() }); 26 | } 27 | return gradients; 28 | }; 29 | const colorGradients = createGradients(options.data); 30 | const okColorStart = DEFAULT_OK_COLOR; // '#299c46', // "rgba(50, 172, 45, 1)", // green 31 | const okColorEnd = okColorStart.Mul(pureLight, 0.7); 32 | const warningColorStart = DEFAULT_WARNING_COLOR; // #FFC837 // '#e5ac0e', // "rgba(237, 129, 40, 1)", // yellow 33 | const warningColorEnd = warningColorStart.Mul(pureLight, 0.7); 34 | const criticalColorStart = DEFAULT_CRITICAL_COLOR; // #e52d27 // '#bf1b00', // "rgba(245, 54, 54, 1)", // red 35 | const criticalColorEnd = criticalColorStart.Mul(pureLight, 0.7); 36 | 37 | const gradientId = options.gradientId; 38 | return ( 39 | <> 40 | 41 | {colorGradients.map((aGradient: any, index: number) => { 42 | return ( 43 | 51 | 52 | 53 | 54 | ); 55 | })} 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/components/gradients/__snapshots__/Gradients.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Test Gradients Gradient Generation returns set of gradients 1`] = `""`; 4 | -------------------------------------------------------------------------------- /src/components/gradients/color.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for Color 3 | */ 4 | 5 | import { Color } from './color'; 6 | 7 | describe('Color Gradient', () => { 8 | describe('With rgba', () => { 9 | const rgbaValue = 'rgba(237, 129, 40, 0.89)'; 10 | it('returns valid gradient', () => { 11 | const generated = Color.RGBAToHex(rgbaValue); 12 | expect(generated).toEqual('#ed8128e2'); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/gradients/color.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Generic class to provide gradient colors 3 | 4 | Based on https://codepen.io/anon/pen/wWxGkr 5 | 6 | */ 7 | export class Color { 8 | r: number; 9 | g: number; 10 | b: number; 11 | 12 | constructor(r: number, g: number, b: number) { 13 | this.r = r; 14 | this.g = g; 15 | this.b = b; 16 | } 17 | 18 | asHex() { 19 | return '#' + ((1 << 24) + (this.r << 16) + (this.g << 8) + this.b).toString(16).slice(1); 20 | } 21 | 22 | asRGB() { 23 | return 'rgb(' + this.r + ',' + this.g + ',' + this.b + ')'; 24 | } 25 | 26 | blendWith(col: Color, a: number) { 27 | const r = Math.round(col.r * (1 - a) + this.r * a); 28 | const g = Math.round(col.g * (1 - a) + this.g * a); 29 | const b = Math.round(col.b * (1 - a) + this.b * a); 30 | return new Color(r, g, b); 31 | } 32 | 33 | Mul(col: Color, a: number) { 34 | const r = Math.round((col.r / 255) * this.r * a); 35 | const g = Math.round((col.g / 255) * this.g * a); 36 | const b = Math.round((col.b / 255) * this.b * a); 37 | return new Color(r, g, b); 38 | } 39 | 40 | RGBToHex(rgb: any) { 41 | let sep = rgb.indexOf(',') > -1 ? ',' : ' '; 42 | rgb = rgb.substr(4).split(')')[0].split(sep); 43 | // Convert %s to 0–255 44 | for (let R in rgb) { 45 | let r = rgb[R]; 46 | if (r.indexOf('%') > -1) { 47 | rgb[R] = Math.round((r.substr(0, r.length - 1) / 100) * 255); 48 | } 49 | } 50 | } 51 | 52 | static RGBAToHex(orig: string) { 53 | const rgb = orig.replace(/\s/g, '').match(/^rgba?\((\d+),(\d+),(\d+),?([^,\s)]+)?/i); 54 | const alpha = ((rgb && rgb[4]) || '').trim(); 55 | let hex = rgb 56 | ? (parseInt(rgb[1], 10) | (1 << 8)).toString(16).slice(1) + 57 | (parseInt(rgb[2], 10) | (1 << 8)).toString(16).slice(1) + 58 | (parseInt(rgb[3], 10) | (1 << 8)).toString(16).slice(1) 59 | : orig; 60 | 61 | let a = '1'; 62 | if (alpha !== '') { 63 | const alphaVal = parseFloat(alpha); 64 | // multiply before convert to HEX 65 | a = ((alphaVal * 255) | (1 << 8)).toString(16).slice(1); 66 | } 67 | return '#' + hex + a; 68 | } 69 | 70 | fromHex(hex: string) { 71 | // http://stackoverflow.com/questions/5623838/rgb-to-hex-and-hex-to-rgb 72 | hex = hex.substring(1, 7); 73 | const bigint = parseInt(hex, 16); 74 | this.r = (bigint >> 16) & 255; 75 | this.g = (bigint >> 8) & 255; 76 | this.b = bigint & 255; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/components/layout/layoutManager.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tests for LayoutManager 3 | */ 4 | 5 | import { LayoutManager } from './layoutManager'; 6 | import { PolygonShapes } from '../types'; 7 | 8 | describe('Layout Manager', () => { 9 | describe('With hexagon layout', () => { 10 | const lm = new LayoutManager(100, 100, 1, 1, 100, false, PolygonShapes.HEXAGON_POINTED_TOP); 11 | const generated = lm.generateHexagonPointedTopLayout(); 12 | it('returns one packed hexagon', () => { 13 | expect(generated).toEqual({}); 14 | }); 15 | }); 16 | describe('With square layout', () => { 17 | const lm = new LayoutManager(100, 100, 1, 1, 100, false, PolygonShapes.SQUARE); 18 | const generated = lm.generateUniformLayout(); 19 | it('returns one packed square', () => { 20 | expect(generated).toEqual({}); 21 | }); 22 | }); 23 | describe('With circle layout', () => { 24 | const lm = new LayoutManager(100, 100, 1, 1, 100, false, PolygonShapes.CIRCLE); 25 | const generated = lm.generateUniformLayout(); 26 | it('returns one packed circle', () => { 27 | expect(generated).toEqual({}); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/components/layout/types.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface LayoutPoint { 3 | x: number, 4 | y: number, 5 | } 6 | -------------------------------------------------------------------------------- /src/components/metric_hints.test.ts: -------------------------------------------------------------------------------- 1 | import { DataFrame, FieldType, toDataFrame } from '@grafana/data'; 2 | import { getMetricHints } from './metric_hints'; 3 | 4 | describe('Test Metric Hints', () => { 5 | let wideFrame: DataFrame; 6 | let narrowFrame: DataFrame; 7 | let frameCW: DataFrame; 8 | let frameInfluxDB: DataFrame; 9 | let time: number; 10 | 11 | beforeEach(() => { 12 | time = new Date().getTime(); 13 | 14 | wideFrame = toDataFrame({ 15 | fields: [ 16 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 17 | { name: 'A-series', type: FieldType.number, values: [200, 210, 220] }, 18 | { 19 | name: 'B-series', 20 | type: FieldType.number, 21 | values: [100, 110, 120], 22 | labels: { '__name__': 'B with Label', 'fake-label-b': 'BName' } 23 | }, 24 | ], 25 | }); 26 | 27 | narrowFrame = toDataFrame({ 28 | fields: [ 29 | { name: 'time', type: FieldType.time, values: [time, time + 1, time + 2] }, 30 | { 31 | name: 'C-series', 32 | type: FieldType.number, 33 | values: [101, 111, 121], 34 | labels: { '__not_name__': 'C with label', 'fake-label-c': 'CLabel' } 35 | }, 36 | ], 37 | }); 38 | 39 | // sample cloudwatch dataframe 40 | frameCW = toDataFrame({ 41 | name: '5XXError', 42 | fields: [ 43 | { name: 'Time', type: FieldType.time, values: [time, time + 1, time + 2] }, 44 | { 45 | name: 'Value', 46 | type: 'number', 47 | typeInfo: { frame: 'float64', nullable: true }, 48 | labels: {}, 49 | config: { 50 | displayNameFromDS: '5XXError', 51 | links: [], 52 | }, 53 | values: [1.1, 2.2, 3.3], 54 | }, 55 | ], 56 | }); 57 | 58 | frameInfluxDB = toDataFrame({ 59 | name: 'changePctDay.mean { coin: btc currency: usd }', 60 | fields: [ 61 | { name: 'Time', type: FieldType.time, values: [time, time + 1, time + 2] }, 62 | { 63 | name: 'value', 64 | type: 'number', 65 | typeInfo: { frame: 'float64', nullable: true }, 66 | labels: { 67 | "coin": "btc", 68 | "currency": "usd" 69 | }, 70 | config: { 71 | displayNameFromDS: 'changePctDay.mean { coin: btc currency: usd }', 72 | links: [], 73 | }, 74 | values: [1.1, 2.2, 3.3], 75 | }, 76 | ], 77 | }); 78 | 79 | }); 80 | 81 | 82 | describe('Metric Hints', () => { 83 | it('returns set of hints from labels', () => { 84 | const hints = getMetricHints([wideFrame, narrowFrame]); 85 | expect(hints.size).toEqual(3); 86 | let val = [...hints][0]; 87 | expect(val).toEqual('A-series'); 88 | val = [...hints][1]; 89 | expect(val).toEqual('B with Label{fake-label-b="BName"}'); 90 | val = [...hints][2]; 91 | expect(val).toEqual('C-series'); 92 | }); 93 | it('returns hints with no labels', () => { 94 | const hints = getMetricHints([narrowFrame]); 95 | expect(hints.size).toEqual(1); 96 | let val = [...hints][0]; 97 | expect(val).toEqual('C-series'); 98 | }); 99 | it('returns hints from a CW frame', () => { 100 | const hints = getMetricHints([frameCW]); 101 | expect(hints.size).toEqual(1); 102 | let val = [...hints][0]; 103 | expect(val).toEqual('5XXError'); 104 | }); 105 | it('returns hints from an influxdb source', () => { 106 | const hints = getMetricHints([frameInfluxDB]); 107 | expect(hints.size).toEqual(1); 108 | let val = [...hints][0]; 109 | expect(val).toEqual('changePctDay.mean { coin: btc currency: usd }'); 110 | }); 111 | 112 | }); 113 | 114 | }); 115 | -------------------------------------------------------------------------------- /src/components/metric_hints.ts: -------------------------------------------------------------------------------- 1 | import { FieldType } from '@grafana/data'; 2 | 3 | // builds unique metric names to shorten the list displayed 4 | export const getMetricHints = (frames: any) => { 5 | let metricHints = new Set(); 6 | for (let i = 0; i < frames.length; i++) { 7 | // start with empty hint 8 | let hintValue = ''; 9 | // the frame may have a name defined, start with it, fields will change it as needed 10 | if (frames[i]?.name) { 11 | hintValue = frames[i].name; 12 | } 13 | // iterate over fields, get all number types and provide as hints 14 | for (const aField of frames[i].fields) { 15 | if (aField.type === FieldType.number) { 16 | // update the hint to use the field Name if we didn't get a value from above 17 | if ((aField.name) && (hintValue === '')) { 18 | hintValue = aField.name; 19 | } 20 | // check for a label with __name__ and use it instead 21 | if (aField?.labels && ('__name__' in aField.labels)) { 22 | hintValue = aField.labels['__name__']; 23 | // append the rest of the labels 24 | const appendLabels: string[] = []; 25 | for (const aLabel in aField.labels) { 26 | if (aLabel !== '__name__') { 27 | appendLabels.push(`${aLabel}="${aField.labels[aLabel]}"`); 28 | } 29 | } 30 | if (appendLabels.length > 0) { 31 | // sort them first 32 | appendLabels.sort(); 33 | hintValue += '{' + appendLabels.join('') + '}'; 34 | } 35 | } 36 | // update the hint to use the displayNameFromDS value 37 | // (the query has a specified a naming convention) 38 | if (aField?.config && aField.config?.displayNameFromDS) { 39 | hintValue = aField.config?.displayNameFromDS; 40 | } 41 | metricHints.add(hintValue); 42 | } 43 | } 44 | } 45 | return metricHints; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/overrides/OverrideEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { StandardEditorProps } from '@grafana/data'; 3 | import { OverrideItem } from './OverrideItem'; 4 | import { OverrideItemType, OverrideItemTracker } from './types'; 5 | import { v4 as UUIdv4 } from 'uuid'; 6 | import { Button, Collapse } from '@grafana/ui'; 7 | import { PolystatThreshold } from '../thresholds/types'; 8 | import { OperatorOptions, ShowTimestampFormats } from '../types'; 9 | import { 10 | DEFAULT_CRITICAL_COLOR_HEX, 11 | DEFAULT_NO_THRESHOLD_COLOR_HEX, 12 | DEFAULT_OK_COLOR_HEX, 13 | DEFAULT_WARNING_COLOR_HEX, 14 | } from '../defaults'; 15 | 16 | export interface OverrideEditorSettings { 17 | overrides: OverrideItemType[]; 18 | enabled: boolean; 19 | } 20 | 21 | interface Props extends StandardEditorProps {} 22 | 23 | export const OverrideEditor: React.FC = ({ item, context, onChange }) => { 24 | const [settings] = useState(context.options.overrideConfig); 25 | const [tracker, _setTracker] = useState((): OverrideItemTracker[] => { 26 | if (!settings.overrides) { 27 | return [] as OverrideItemTracker[]; 28 | } 29 | const items: OverrideItemTracker[] = []; 30 | settings.overrides.forEach((value: OverrideItemType, index: number) => { 31 | items[index] = { 32 | override: value, 33 | order: index, 34 | ID: UUIdv4(), 35 | }; 36 | }); 37 | return items; 38 | }); 39 | 40 | const setTracker = (v: OverrideItemTracker[]) => { 41 | _setTracker(v); 42 | const allOverrides: OverrideItemType[] = []; 43 | v.forEach((element) => { 44 | allOverrides.push(element.override); 45 | }); 46 | const overrideConfig = { 47 | overrides: allOverrides, 48 | enabled: settings.enabled, 49 | }; 50 | onChange(overrideConfig as any); 51 | }; 52 | 53 | const [isOpen, setIsOpen] = useState((): boolean[] => { 54 | if (!tracker) { 55 | return [] as boolean[]; 56 | } 57 | let size = tracker.length; 58 | const openStates: boolean[] = []; 59 | while (size--) { 60 | openStates[size] = false; 61 | } 62 | return openStates; 63 | }); 64 | 65 | const updateOverride = (index: number, value: OverrideItemType) => { 66 | tracker[index].override = value; 67 | // works ... setTracker(tracker); 68 | setTracker([...tracker]); 69 | }; 70 | 71 | const createDuplicate = (index: number) => { 72 | const original = tracker[index].override; 73 | const order = tracker.length; 74 | const anOverride: OverrideItemType = { 75 | label: `${original.label} Copy`, 76 | enabled: original.enabled, 77 | metricName: original.metricName, 78 | alias: original.alias, 79 | thresholds: original.thresholds, 80 | prefix: original.prefix, 81 | suffix: original.suffix, 82 | clickThrough: original.clickThrough, 83 | clickThroughOpenNewTab: original.clickThroughOpenNewTab, 84 | clickThroughSanitize: original.clickThroughSanitize, 85 | clickThroughCustomTargetEnabled: original.clickThroughCustomTargetEnabled, 86 | clickThroughCustomTarget: original.clickThroughCustomTarget, 87 | showTimestampEnabled: false, 88 | showTimestampFormat: ShowTimestampFormats[0].value, 89 | showTimestampYOffset: 0, 90 | unitFormat: original.unitFormat, 91 | scaledDecimals: original.scaledDecimals, 92 | decimals: original.decimals, 93 | colors: original.colors, 94 | operatorName: original.operatorName, 95 | order: order, 96 | }; 97 | const aTracker: OverrideItemTracker = { 98 | override: anOverride, 99 | order: order, 100 | ID: UUIdv4(), 101 | }; 102 | setTracker([...tracker, aTracker]); 103 | setIsOpen([...isOpen, true]); 104 | }; 105 | 106 | // generic move 107 | const arrayMove = (arr: any, oldIndex: number, newIndex: number) => { 108 | if (newIndex >= arr.length) { 109 | let k = newIndex - arr.length + 1; 110 | while (k--) { 111 | arr.push(undefined); 112 | } 113 | } 114 | arr.splice(newIndex, 0, arr.splice(oldIndex, 1)[0]); 115 | }; 116 | 117 | const moveDown = (index: number) => { 118 | if (index !== tracker.length - 1) { 119 | arrayMove(tracker, index, index + 1); 120 | // reorder 121 | for (let i = 0; i < tracker.length; i++) { 122 | tracker[i].order = i; 123 | tracker[i].override.order = i; 124 | } 125 | setTracker([...tracker]); 126 | } 127 | }; 128 | 129 | const moveUp = (index: number) => { 130 | if (index > 0) { 131 | arrayMove(tracker, index, index - 1); 132 | // reorder 133 | for (let i = 0; i < tracker.length; i++) { 134 | tracker[i].order = i; 135 | tracker[i].override.order = i; 136 | } 137 | setTracker([...tracker]); 138 | } 139 | }; 140 | 141 | const removeOverride = (index: number) => { 142 | const allOverrides = [...tracker]; 143 | let removeIndex = 0; 144 | for (let i = 0; i < allOverrides.length; i++) { 145 | if (allOverrides[i].order === index) { 146 | removeIndex = i; 147 | break; 148 | } 149 | } 150 | allOverrides.splice(removeIndex, 1); 151 | // reorder 152 | for (let i = 0; i < allOverrides.length; i++) { 153 | allOverrides[i].order = i; 154 | allOverrides[i].override.order = i; 155 | } 156 | setTracker([...allOverrides]); 157 | }; 158 | 159 | const toggleOpener = (index: number) => { 160 | const currentState = [...isOpen]; 161 | currentState[index] = !currentState[index]; 162 | setIsOpen([...currentState]); 163 | }; 164 | 165 | const addItem = () => { 166 | const order = tracker.length; 167 | const anOverride: OverrideItemType = { 168 | label: `Override-${order}`, 169 | enabled: true, 170 | metricName: '', 171 | alias: '', 172 | thresholds: [] as PolystatThreshold[], 173 | prefix: '', 174 | suffix: '', 175 | clickThrough: '', 176 | clickThroughOpenNewTab: true, 177 | clickThroughSanitize: true, 178 | clickThroughCustomTargetEnabled: false, 179 | clickThroughCustomTarget: '', 180 | showTimestampEnabled: false, 181 | showTimestampFormat: ShowTimestampFormats[0].value, 182 | showTimestampYOffset: 0, 183 | unitFormat: 'short', 184 | scaledDecimals: null, 185 | decimals: '2', 186 | colors: [ 187 | DEFAULT_OK_COLOR_HEX, 188 | DEFAULT_WARNING_COLOR_HEX, 189 | DEFAULT_CRITICAL_COLOR_HEX, 190 | DEFAULT_NO_THRESHOLD_COLOR_HEX, 191 | ], 192 | operatorName: OperatorOptions[0].value, 193 | order: order, 194 | }; 195 | const aTracker: OverrideItemTracker = { 196 | override: anOverride, 197 | order: order, 198 | ID: UUIdv4(), 199 | }; 200 | setTracker([...tracker, aTracker]); 201 | // add an opener also 202 | setIsOpen([...isOpen, true]); 203 | }; 204 | 205 | return ( 206 | <> 207 | 210 | {tracker && 211 | tracker.map((tracker: OverrideItemTracker, index: number) => { 212 | return ( 213 | toggleOpener(index)} 218 | collapsible 219 | > 220 | 232 | 233 | ); 234 | })} 235 | 236 | ); 237 | }; 238 | -------------------------------------------------------------------------------- /src/components/overrides/types.ts: -------------------------------------------------------------------------------- 1 | import { PolystatThreshold } from 'components/thresholds/types'; 2 | 3 | export interface OverrideItemProps { 4 | override: OverrideItemType; 5 | ID: string; 6 | enabled: boolean; 7 | setter: any; 8 | remover: any; 9 | moveUp: any; 10 | moveDown: any; 11 | createDuplicate: any; 12 | context: any; 13 | } 14 | 15 | export interface OverrideItemType { 16 | label: string; 17 | metricName: string; 18 | alias: string; 19 | thresholds: PolystatThreshold[]; 20 | colors: string[]; 21 | unitFormat: string; 22 | decimals: string; 23 | scaledDecimals: number | null; 24 | enabled: boolean; 25 | operatorName: string; // mean/min/max etc 26 | prefix: string; 27 | suffix: string; 28 | clickThrough: string | ''; 29 | clickThroughSanitize: boolean; 30 | clickThroughOpenNewTab: boolean; 31 | clickThroughCustomTargetEnabled: boolean; 32 | clickThroughCustomTarget: string; 33 | showTimestampEnabled: boolean; 34 | showTimestampFormat: string; 35 | showTimestampYOffset: number; 36 | order: number; 37 | } 38 | 39 | export interface OverrideItemTracker { 40 | override: OverrideItemType; 41 | order: number; 42 | ID: string; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/styles.ts: -------------------------------------------------------------------------------- 1 | import { GrafanaTheme2 } from '@grafana/data'; 2 | import { css } from '@emotion/css'; 3 | 4 | export const getErrorMessageStyles = (theme: GrafanaTheme2) => css` 5 | font-size: ${theme.typography.h1.fontSize}; 6 | text-align: center; 7 | justify-content: center; 8 | color: ${theme.colors.error.shade}; 9 | `; 10 | 11 | export const getNoTriggerTextStyles = (theme: GrafanaTheme2) => css` 12 | font-size: ${theme.typography.h1.fontSize}; 13 | text-align: center; 14 | justify-content: center; 15 | color: ${theme.colors.success.shade}; 16 | `; 17 | 18 | export const getWrapperStyles = (theme: GrafanaTheme2) => css` 19 | fill: transparent; 20 | display: flex; 21 | align-items: center; 22 | text-align: center; 23 | justify-content: center; 24 | `; 25 | 26 | export const getSVGStyles = (theme: GrafanaTheme2) => css` 27 | text-align: center; 28 | align-items: center; 29 | justify-content: center; 30 | fill: transparent; 31 | `; 32 | 33 | export const getSVGPathStyles = (theme: GrafanaTheme2) => css` 34 | outline: none !important; 35 | `; 36 | -------------------------------------------------------------------------------- /src/components/suggestions.ts: -------------------------------------------------------------------------------- 1 | import { VisualizationSuggestionsBuilder } from '@grafana/data'; 2 | import { PolystatOptions } from './types'; 3 | 4 | export class PolystatDataSuggestionsSupplier { 5 | getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { 6 | const { dataSummary: ds } = builder; 7 | 8 | if (!ds.hasData) { 9 | return; 10 | } 11 | if (!ds.hasNumberField) { 12 | return; 13 | } 14 | 15 | const list = builder.getListAppender({ 16 | name: 'Polystat', 17 | pluginId: 'grafana-polystat-panel', 18 | options: {}, 19 | }); 20 | 21 | list.append({ 22 | name: 'Polystat', 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/thresholds/GlobalThresholdEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { StandardEditorProps } from '@grafana/data'; 3 | import { Field } from '@grafana/ui'; 4 | 5 | import { PolystatThreshold } from './types'; 6 | import { ThresholdsEditor } from './ThresholdsEditor'; 7 | 8 | export interface GlobalThresholdEditorSettings {} 9 | 10 | interface Props extends StandardEditorProps {} 11 | 12 | export const GlobalThresholdEditor: React.FC = ({ context, onChange }) => { 13 | const [globalThresholds, setGlobalThresholds] = useState(context.options.globalThresholdsConfig); 14 | const setThresholds = (val: PolystatThreshold[]) => { 15 | setGlobalThresholds(val); 16 | onChange(val as any); 17 | }; 18 | 19 | return ( 20 | <> 21 | 22 | 23 | 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/thresholds/ThresholdItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { GrafanaTheme2, SelectableValue } from '@grafana/data'; 3 | import { Input, ColorPicker, IconButton, useStyles2, Select } from '@grafana/ui'; 4 | import { css } from '@emotion/css'; 5 | 6 | import { PolystatThreshold, ThresholdStates } from './types'; 7 | 8 | interface ThresholdItemProps { 9 | threshold: PolystatThreshold; 10 | key: string; 11 | ID: string; 12 | valueSetter: any; 13 | colorSetter: any; 14 | stateSetter: any; 15 | remover: any; 16 | index: number; 17 | disabled: boolean; 18 | } 19 | 20 | export const ThresholdItem: React.FC = (options: ThresholdItemProps) => { 21 | const styles = useStyles2(getThresholdStyles); 22 | const getThreshold = (thresholdId: number) => { 23 | const keys = ThresholdStates.keys(); 24 | for (const aKey of keys) { 25 | if (ThresholdStates[aKey].value === thresholdId) { 26 | return ThresholdStates[aKey]; 27 | } 28 | } 29 | // no match, return current by default 30 | return ThresholdStates[0]; 31 | }; 32 | 33 | const [threshold, setThreshold] = useState(getThreshold(options.threshold.state)); 34 | 35 | return ( 36 | options.valueSetter(options.index, Number(e.currentTarget.value))} 42 | value={options.threshold.value} 43 | prefix={ 44 |
45 |
46 | options.colorSetter(options.index, color)} 49 | enableNamedColors={true} 50 | /> 51 |
52 |
53 | } 54 | suffix={ 55 | <> 56 |