├── .config ├── .eslintrc ├── .prettierrc.js ├── Dockerfile ├── README.md ├── jest-setup.js ├── jest.config.js ├── jest │ ├── mocks │ │ └── react-inlinesvg.tsx │ └── utils.js ├── tsconfig.json ├── types │ └── custom.d.ts └── webpack │ ├── constants.ts │ ├── utils.ts │ └── webpack.config.ts ├── .eslintrc ├── .github ├── dependabot.yml └── workflows │ └── citest.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yaml ├── img ├── annotations.png ├── piechart.png └── variables.png ├── jest-setup.js ├── jest.config.js ├── package.json ├── src ├── ConfigEditor.tsx ├── QueryEditor.tsx ├── datasource.ts ├── img │ ├── logo.svg │ └── screenshot.png ├── module.ts ├── plugin.json └── types.ts ├── tsconfig.json └── yarn.lock /.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.github.io/plugin-tools/docs/advanced-configuration#extending-the-eslint-config 6 | */ 7 | { 8 | "extends": ["@grafana/eslint-config"], 9 | "root": true, 10 | "rules": { 11 | "react/prop-types": "off" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.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 | }; -------------------------------------------------------------------------------- /.config/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG grafana_version=latest 2 | ARG grafana_image=grafana-enterprise 3 | 4 | FROM grafana/${grafana_image}:${grafana_version} 5 | 6 | # Make it as simple as possible to access the grafana instance for development purposes 7 | # Do NOT enable these settings in a public facing / production grafana instance 8 | ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin" 9 | ENV GF_AUTH_ANONYMOUS_ENABLED "true" 10 | ENV GF_AUTH_BASIC_ENABLED "false" 11 | # Set development mode so plugins can be loaded without the need to sign 12 | ENV GF_DEFAULT_APP_MODE "development" 13 | 14 | USER root 15 | -------------------------------------------------------------------------------- /.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 found with the current jest config involves importing an npm package which 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 behaviour 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 are assigning the environment variable `GRAFANA_IMAGE` to the build arg `grafana_image` with a default value of `grafana`. This will give you the possibility to set the value while running the docker-compose commands which might be convinent in some scenarios. 163 | 164 | --- 165 | -------------------------------------------------------------------------------- /.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.github.io/plugin-tools/docs/advanced-configuration#extending-the-jest-config 6 | */ 7 | 8 | import '@testing-library/jest-dom'; 9 | 10 | // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 11 | Object.defineProperty(global, 'matchMedia', { 12 | writable: true, 13 | value: jest.fn().mockImplementation((query) => ({ 14 | matches: false, 15 | media: query, 16 | onchange: null, 17 | addListener: jest.fn(), // deprecated 18 | removeListener: jest.fn(), // deprecated 19 | addEventListener: jest.fn(), 20 | removeEventListener: jest.fn(), 21 | dispatchEvent: jest.fn(), 22 | })), 23 | }); 24 | 25 | HTMLCanvasElement.prototype.getContext = () => {}; 26 | -------------------------------------------------------------------------------- /.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.github.io/plugin-tools/docs/advanced-configuration#extending-the-jest-config 6 | */ 7 | 8 | const path = require('path'); 9 | const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils'); 10 | 11 | module.exports = { 12 | moduleNameMapper: { 13 | '\\.(css|scss|sass)$': 'identity-obj-proxy', 14 | 'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'), 15 | }, 16 | modulePaths: ['/src'], 17 | setupFilesAfterEnv: ['/jest-setup.js'], 18 | testEnvironment: 'jest-environment-jsdom', 19 | testMatch: [ 20 | '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', 21 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 22 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 23 | ], 24 | transform: { 25 | '^.+\\.(t|j)sx?$': [ 26 | '@swc/jest', 27 | { 28 | sourceMaps: 'inline', 29 | jsc: { 30 | parser: { 31 | syntax: 'typescript', 32 | tsx: true, 33 | decorators: false, 34 | dynamicImport: true, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | // Jest will throw `Cannot use import statement outside module` if it tries to load an 41 | // ES module without it being transformed first. ./config/README.md#esm-errors-with-jest 42 | transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)], 43 | }; 44 | -------------------------------------------------------------------------------- /.config/jest/mocks/react-inlinesvg.tsx: -------------------------------------------------------------------------------- 1 | // Due to the grafana/ui Icon component making fetch requests to 2 | // `/public/img/icon/.svg` we need to mock react-inlinesvg to prevent 3 | // the failed fetch requests from displaying errors in console. 4 | 5 | import React from 'react'; 6 | 7 | type Callback = (...args: any[]) => void; 8 | 9 | export interface StorageItem { 10 | content: string; 11 | queue: Callback[]; 12 | status: string; 13 | } 14 | 15 | export const cacheStore: { [key: string]: StorageItem } = Object.create(null); 16 | 17 | const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/; 18 | 19 | const InlineSVG = ({ src }: { src: string }) => { 20 | // testId will be the file name without extension (e.g. `public/img/icons/angle-double-down.svg` -> `angle-double-down`) 21 | const testId = src.replace(SVG_FILE_NAME_REGEX, '$2'); 22 | return ; 23 | }; 24 | 25 | export default InlineSVG; 26 | -------------------------------------------------------------------------------- /.config/jest/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | /* 8 | * This utility function is useful in combination with jest `transformIgnorePatterns` config 9 | * to transform specific packages (e.g.ES modules) in a projects node_modules folder. 10 | */ 11 | const nodeModulesToTransform = (moduleNames) => `node_modules\/(?!(${moduleNames.join('|')})\/)`; 12 | 13 | // Array of known nested grafana package dependencies that only bundle an ESM version 14 | const grafanaESModules = [ 15 | '.pnpm', // Support using pnpm symlinked packages 16 | '@grafana/schema', 17 | 'd3', 18 | 'd3-color', 19 | 'd3-force', 20 | 'd3-interpolate', 21 | 'd3-scale-chromatic', 22 | 'ol', 23 | 'react-colorful', 24 | 'rxjs', 25 | 'uuid', 26 | ]; 27 | 28 | module.exports = { 29 | nodeModulesToTransform, 30 | grafanaESModules, 31 | }; 32 | -------------------------------------------------------------------------------- /.config/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.github.io/plugin-tools/docs/advanced-configuration#extending-the-typescript-config 6 | */ 7 | { 8 | "compilerOptions": { 9 | "alwaysStrict": true, 10 | "declaration": false, 11 | "rootDir": "../src", 12 | "baseUrl": "../src", 13 | "typeRoots": ["../node_modules/@types"], 14 | "resolveJsonModule": true 15 | }, 16 | "ts-node": { 17 | "compilerOptions": { 18 | "module": "commonjs", 19 | "target": "es5", 20 | "esModuleInterop": true 21 | }, 22 | "transpileOnly": true 23 | }, 24 | "include": ["../src", "./types"], 25 | "extends": "@grafana/tsconfig" 26 | } 27 | -------------------------------------------------------------------------------- /.config/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | // Image declarations 2 | declare module '*.gif' { 3 | const src: string; 4 | export default src; 5 | } 6 | 7 | declare module '*.jpg' { 8 | const src: string; 9 | export default src; 10 | } 11 | 12 | declare module '*.jpeg' { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module '*.png' { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module '*.webp' { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module '*.svg' { 28 | const content: string; 29 | export default content; 30 | } 31 | 32 | // Font declarations 33 | declare module '*.woff'; 34 | declare module '*.woff2'; 35 | declare module '*.eot'; 36 | declare module '*.ttf'; 37 | declare module '*.otf'; 38 | -------------------------------------------------------------------------------- /.config/webpack/constants.ts: -------------------------------------------------------------------------------- 1 | export const SOURCE_DIR = 'src'; 2 | export const DIST_DIR = 'dist'; 3 | -------------------------------------------------------------------------------- /.config/webpack/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import util from 'util'; 4 | import { glob } from 'glob'; 5 | import { SOURCE_DIR } from './constants'; 6 | 7 | export function getPackageJson() { 8 | return require(path.resolve(process.cwd(), 'package.json')); 9 | } 10 | 11 | export function getPluginJson() { 12 | return require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`)); 13 | } 14 | 15 | export function hasReadme() { 16 | return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md')); 17 | } 18 | 19 | // Support bundling nested plugins by finding all plugin.json files in src directory 20 | // then checking for a sibling module.[jt]sx? file. 21 | export async function getEntries(): Promise> { 22 | const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true }); 23 | 24 | const plugins = await Promise.all(pluginsJson.map((pluginJson) => { 25 | const folder = path.dirname(pluginJson); 26 | return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true }); 27 | }) 28 | ); 29 | 30 | return plugins.reduce((result, modules) => { 31 | return modules.reduce((result, module) => { 32 | const pluginPath = path.dirname(module); 33 | const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, ''); 34 | const entryName = pluginName === '' ? 'module' : `${pluginName}/module`; 35 | 36 | result[entryName] = module; 37 | return result; 38 | }, result); 39 | }, {}); 40 | } 41 | -------------------------------------------------------------------------------- /.config/webpack/webpack.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.github.io/plugin-tools/docs/advanced-configuration#extending-the-webpack-config 6 | */ 7 | 8 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 9 | import ESLintPlugin from 'eslint-webpack-plugin'; 10 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 11 | import LiveReloadPlugin from 'webpack-livereload-plugin'; 12 | import path from 'path'; 13 | import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin'; 14 | import { Configuration } from 'webpack'; 15 | 16 | import { getPackageJson, getPluginJson, hasReadme, getEntries } from './utils'; 17 | import { SOURCE_DIR, DIST_DIR } from './constants'; 18 | 19 | const pluginJson = getPluginJson(); 20 | 21 | const config = async (env): Promise => ({ 22 | cache: { 23 | type: 'filesystem', 24 | buildDependencies: { 25 | config: [__filename], 26 | }, 27 | }, 28 | 29 | context: path.join(process.cwd(), SOURCE_DIR), 30 | 31 | devtool: env.production ? 'source-map' : 'eval-source-map', 32 | 33 | entry: await getEntries(), 34 | 35 | externals: [ 36 | 'lodash', 37 | 'jquery', 38 | 'moment', 39 | 'slate', 40 | 'emotion', 41 | '@emotion/react', 42 | '@emotion/css', 43 | 'prismjs', 44 | 'slate-plain-serializer', 45 | '@grafana/slate-react', 46 | 'react', 47 | 'react-dom', 48 | 'react-redux', 49 | 'redux', 50 | 'rxjs', 51 | 'react-router', 52 | 'react-router-dom', 53 | 'd3', 54 | 'angular', 55 | '@grafana/ui', 56 | '@grafana/runtime', 57 | '@grafana/data', 58 | 59 | // Mark legacy SDK imports as external if their name starts with the "grafana/" prefix 60 | ({ request }, callback) => { 61 | const prefix = 'grafana/'; 62 | const hasPrefix = (request) => request.indexOf(prefix) === 0; 63 | const stripPrefix = (request) => request.substr(prefix.length); 64 | 65 | if (hasPrefix(request)) { 66 | return callback(undefined, stripPrefix(request)); 67 | } 68 | 69 | callback(); 70 | }, 71 | ], 72 | 73 | mode: env.production ? 'production' : 'development', 74 | 75 | module: { 76 | rules: [ 77 | { 78 | exclude: /(node_modules)/, 79 | test: /\.[tj]sx?$/, 80 | use: { 81 | loader: 'swc-loader', 82 | options: { 83 | jsc: { 84 | baseUrl: './src', 85 | target: 'es2015', 86 | loose: false, 87 | parser: { 88 | syntax: 'typescript', 89 | tsx: true, 90 | decorators: false, 91 | dynamicImport: true, 92 | }, 93 | }, 94 | }, 95 | }, 96 | }, 97 | { 98 | test: /\.css$/, 99 | use: ["style-loader", "css-loader"] 100 | }, 101 | { 102 | test: /\.s[ac]ss$/, 103 | use: ['style-loader', 'css-loader', 'sass-loader'], 104 | }, 105 | { 106 | test: /\.(png|jpe?g|gif|svg)$/, 107 | type: 'asset/resource', 108 | generator: { 109 | // Keep publicPath relative for host.com/grafana/ deployments 110 | publicPath: `public/plugins/${pluginJson.id}/img/`, 111 | outputPath: 'img/', 112 | filename: Boolean(env.production) ? '[hash][ext]' : '[name][ext]', 113 | }, 114 | }, 115 | { 116 | test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, 117 | type: 'asset/resource', 118 | generator: { 119 | // Keep publicPath relative for host.com/grafana/ deployments 120 | publicPath: `public/plugins/${pluginJson.id}/fonts/`, 121 | outputPath: 'fonts/', 122 | filename: Boolean(env.production) ? '[hash][ext]' : '[name][ext]', 123 | }, 124 | }, 125 | ], 126 | }, 127 | 128 | output: { 129 | clean: { 130 | keep: new RegExp(`.*?_(amd64|arm(64)?)(.exe)?`), 131 | }, 132 | filename: '[name].js', 133 | library: { 134 | type: 'amd', 135 | }, 136 | path: path.resolve(process.cwd(), DIST_DIR), 137 | publicPath: '/', 138 | }, 139 | 140 | plugins: [ 141 | new CopyWebpackPlugin({ 142 | patterns: [ 143 | // If src/README.md exists use it; otherwise the root README 144 | // To `compiler.options.output` 145 | { from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true }, 146 | { from: 'plugin.json', to: '.' }, 147 | { from: '../LICENSE', to: '.' }, 148 | { from: '../CHANGELOG.md', to: '.', force: true }, 149 | { from: '**/*.json', to: '.' }, // TODO 150 | { from: '**/*.svg', to: '.', noErrorOnMissing: true }, // Optional 151 | { from: '**/*.png', to: '.', noErrorOnMissing: true }, // Optional 152 | { from: '**/*.html', to: '.', noErrorOnMissing: true }, // Optional 153 | { from: 'img/**/*', to: '.', noErrorOnMissing: true }, // Optional 154 | { from: 'libs/**/*', to: '.', noErrorOnMissing: true }, // Optional 155 | { from: 'static/**/*', to: '.', noErrorOnMissing: true }, // Optional 156 | ], 157 | }), 158 | // Replace certain template-variables in the README and plugin.json 159 | new ReplaceInFileWebpackPlugin([ 160 | { 161 | dir: DIST_DIR, 162 | files: ['plugin.json', 'README.md'], 163 | rules: [ 164 | { 165 | search: /\%VERSION\%/g, 166 | replace: getPackageJson().version, 167 | }, 168 | { 169 | search: /\%TODAY\%/g, 170 | replace: new Date().toISOString().substring(0, 10), 171 | }, 172 | { 173 | search: /\%PLUGIN_ID\%/g, 174 | replace: pluginJson.id, 175 | }, 176 | ], 177 | }, 178 | ]), 179 | new ForkTsCheckerWebpackPlugin({ 180 | async: Boolean(env.development), 181 | issue: { 182 | include: [{ file: '**/*.{ts,tsx}' }], 183 | }, 184 | typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') }, 185 | }), 186 | new ESLintPlugin({ 187 | extensions: ['.ts', '.tsx'], 188 | lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files 189 | }), 190 | ...(env.development ? [new LiveReloadPlugin()] : []), 191 | ], 192 | 193 | resolve: { 194 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 195 | // handle resolving "rootDir" paths 196 | modules: [path.resolve(process.cwd(), 'src'), 'node_modules'], 197 | unsafeCache: true, 198 | }, 199 | }); 200 | 201 | export default config; 202 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/.eslintrc" 3 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | pull-request-branch-name: 8 | separator: "-" 9 | open-pull-requests-limit: 10 10 | -------------------------------------------------------------------------------- /.github/workflows/citest.yml: -------------------------------------------------------------------------------- 1 | name: citest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | inputs: 8 | debug_enabled: 9 | type: boolean 10 | description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' 11 | required: false 12 | default: false 13 | 14 | jobs: 15 | citest: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: abbbi/github-actions-tune@v1 20 | - name: Setup tmate session, see https://github.com/marketplace/actions/debugging-with-tmate 21 | uses: mxschmitt/action-tmate@v3 22 | if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} 23 | - name: make test 24 | run: make test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | *.*~ 4 | coverage/ 5 | dist/ 6 | .yarnrc 7 | .npm/ 8 | .bash_history 9 | .cache/ 10 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require("./.config/.prettierrc.js") 4 | }; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | 2.2.0 2025-04-10 4 | - rework query editor select fields 5 | - replace variables in columns 6 | 7 | 2.1.2 2025-01-15 8 | - fix remove columns button 9 | - fix broken query if FROM contains url parameter already 10 | 11 | 2.1.1 2024-08-02 12 | - remove console.log debug output 13 | 14 | 2.1.0 2024-08-02 15 | - improve query editor 16 | 17 | 2.0.8 2024-05-14 18 | - add support for stats queries as timeseries having multiple name columns 19 | 20 | 2.0.7 2024-04-23 21 | - improve query parsing for variable queries 22 | 23 | 2.0.6 2024-04-23 24 | - add url encode helper to query editor 25 | 26 | 2.0.5 2024-04-19 27 | - add support for column field config as part of the query result 28 | - make from and columns field editable in the queryeditor 29 | - make column selection work for hash response data 30 | 31 | 2.0.4 2023-12-04 32 | - remove time filter restriction 33 | - update grafana toolkit to 10.1.5 34 | 35 | 2.0.3 2023-07-14 36 | - make drag/drop more obvious 37 | - set correct field type for numeric columns 38 | - fix removing * from column list 39 | 40 | 2.0.2 2023-05-30 41 | - fix using variables in path/from field 42 | 43 | 2.0.1 2022-12-02 44 | - fix syntax error in variables query 45 | 46 | 2.0.0 2022-10-28 47 | - rebuild with react for grafana 9 48 | - add support for logs explorer 49 | - query editor: 50 | - support sorting columns 51 | 52 | 1.0.7 2022-02-11 53 | - rebuild for grafana 8 54 | - update dependencies 55 | 56 | 1.0.6 2021-01-04 57 | - sign plugin 58 | - switch package builds to yarn 59 | 60 | 1.0.5 2020-09-11 61 | - improve packaging 62 | 63 | 1.0.4 2020-06-29 64 | - fix export with "Export for sharing externally" enabled 65 | 66 | 1.0.3 2019-02-15 67 | - support aggregation functions 68 | - convert hash responses into tables 69 | - support timeseries based panels 70 | 71 | 1.0.2 2019-01-04 72 | - add more time styles 73 | 74 | 1.0.1 2018-09-30 75 | - fix annotation query parser 76 | 77 | 1.0.0 2018-09-14 78 | - inital release 79 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Sven Nierlein 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PLUGINNAME=sni-thruk-datasource 2 | TAGVERSION=$(shell git describe --tag --exact-match 2>/dev/null | sed -e 's/^v//') 3 | DOCKER=docker run \ 4 | -t \ 5 | --rm \ 6 | -v $(shell pwd):/src \ 7 | -w "/src" \ 8 | -u $(shell id -u):$(shell id -g) \ 9 | -e "HOME=/src" \ 10 | -e "GRAFANA_ACCESS_POLICY_TOKEN=$(GRAFANA_ACCESS_POLICY_TOKEN)" 11 | NODEVERSION=20 12 | export NODE_PATH=$(shell pwd)/node_modules 13 | YARN=yarn 14 | SHELL=bash 15 | 16 | build: 17 | $(DOCKER) --name $(PLUGINNAME)-build node:$(NODEVERSION) bash -c "$(YARN) install && $(YARN) run build" 18 | 19 | buildwatch: 20 | $(DOCKER) -i --name $(PLUGINNAME)-buildwatch node:$(NODEVERSION) bash -c "$(YARN) install && $(YARN) run dev" 21 | 22 | buildupgrade: 23 | rm -f package-lock.json 24 | $(DOCKER) --name $(PLUGINNAME)-buildupgrade node:$(NODEVERSION) bash -c "$(YARN) install && $(YARN) upgrade $(filter-out $@,$(MAKECMDGOALS))" 25 | 26 | buildyarn: 27 | $(DOCKER) --name $(PLUGINNAME)-buildyarn node:$(NODEVERSION) bash -c "$(YARN) $(filter-out $@,$(MAKECMDGOALS))" 28 | 29 | buildaudit: 30 | $(DOCKER) --name $(PLUGINNAME)-buildaudit node:$(NODEVERSION) bash -c "$(YARN) install && $(YARN) audit" 31 | 32 | buildsign: 33 | $(DOCKER) --name $(PLUGINNAME)-buildsign node:$(NODEVERSION) bash -c "$(YARN) install && npx @grafana/sign-plugin" 34 | 35 | buildnpm: 36 | $(DOCKER) --name $(PLUGINNAME)-buildnpm node:$(NODEVERSION) bash -c "npm $(filter-out $@,$(MAKECMDGOALS))" 37 | 38 | prettier: 39 | $(DOCKER) --name $(PLUGINNAME)-buildpret node:$(NODEVERSION) npx prettier --write --ignore-unknown src/ 40 | 41 | prettiercheck: 42 | $(DOCKER) --name $(PLUGINNAME)-buildprtchck node:$(NODEVERSION) npx prettier --check --ignore-unknown src/ 43 | 44 | buildshell: 45 | $(DOCKER) -i --name $(PLUGINNAME)-buildshell node:$(NODEVERSION) bash 46 | 47 | test: build prettiercheck 48 | 49 | dev: 50 | @mkdir -p dist 51 | docker compose up 52 | 53 | clean: 54 | -docker compose rm -f 55 | -sudo chown $(shell id -u):$(shell id -g) -R dist node_modules 56 | rm -rf dist 57 | rm -rf node_modules 58 | rm -rf .yarnrc 59 | rm -rf .npm 60 | 61 | releasebuild: 62 | @if [ "x$(TAGVERSION)" = "x" ]; then echo "ERROR: must be on a git tag, got: $(shell git describe --tag --dirty)"; exit 1; fi 63 | $(MAKE) clean 64 | $(MAKE) build 65 | $(MAKE) GRAFANA_ACCESS_POLICY_TOKEN=$(GRAFANA_ACCESS_POLICY_TOKEN) buildsign 66 | mv dist/ $(PLUGINNAME) 67 | rm -f $(PLUGINNAME)-$(TAGVERSION).zip 68 | zip $(PLUGINNAME)-$(TAGVERSION).zip $(PLUGINNAME) -r 69 | rm -rf $(PLUGINNAME) 70 | @echo "release build successful: $(TAGVERSION)" 71 | ls -la $(PLUGINNAME)-$(TAGVERSION).zip 72 | 73 | # just skip unknown make targets 74 | .DEFAULT: 75 | @if [[ "$(MAKECMDGOALS)" =~ ^buildupgrade ]] || [[ "$(MAKECMDGOALS)" =~ ^buildyarn ]] || [[ "$(MAKECMDGOALS)" =~ ^buildnpm ]] ; then \ 76 | : ; \ 77 | else \ 78 | echo "unknown make target(s): $(MAKECMDGOALS)"; \ 79 | exit 1; \ 80 | fi 81 | 82 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Thruk Grafana Datasource - a Grafana backend datasource using Thruks REST API 2 | 3 | ![Thruk Grafana Datasource](https://raw.githubusercontent.com/sni/grafana-thruk-datasource/master/src/img/screenshot.png "Thruk Grafana Datasource") 4 | 5 | ## Installation 6 | 7 | Search for `thruk` in the Grafana plugins directory or simply use the grafana-cli command: 8 | 9 | %> grafana-cli plugins install sni-thruk-datasource 10 | 11 | Also [OMD-Labs](https://labs.consol.de/omd/) comes with this datasource included, so if 12 | you use OMD-Labs, everything is setup already. 13 | 14 | Otherwise follow these steps: 15 | 16 | %> cd var/grafana/plugins 17 | %> git clone -b release-1.0.4 https://github.com/sni/grafana-thruk-datasource.git 18 | %> restart grafana 19 | 20 | Replace `release-1.0.4` with the last available release branch. 21 | 22 | ## Create Datasource 23 | 24 | Add a new datasource and select: 25 | 26 | Use the Grafana proxy. 27 | 28 | - Type 'Thruk' 29 | - Url to Thruk, ex.: 'https://localhost/sitename/thruk' 30 | 31 | ## Table Queries 32 | 33 | Using the table panel, you can display most data from the rest api. However 34 | only text, numbers and timestamps can be displayed in a sane way. Support for nested 35 | data structures is limited. 36 | 37 | Select the rest path from where you want to display data. Then choose all columns. Aggregation 38 | functions can be added as well and always affect the column following afterwards. 39 | 40 | ## Variable Queries 41 | 42 | Thruks rest api can be used to fill grafana variables. For example to get all 43 | hosts of a certain hostgroup, use this example query: 44 | 45 | SELECT name FROM hosts WHERE groups >= 'linux' 46 | 47 | ## Annotation Queries 48 | 49 | Annotation queries can be used to add logfile entries into your graphs. 50 | Please note that annotations are shared across all graphs in a dashboard. 51 | 52 | It is important to use at least a time filter. 53 | 54 | ![Annotations](https://raw.githubusercontent.com/sni/grafana-thruk-datasource/master/img/annotations.png "Annotations Editor") 55 | 56 | ## Single Stat Queries 57 | 58 | Single stats are best used with REST endpoints which return aggregated values 59 | already or use aggregation functions like, `avg`, `sum`, `min`, `max` or `count`. 60 | 61 | ## Timeseries based panels 62 | 63 | Althouth Thruk isn't a timeseries databases und usually only returns table 64 | data, some queries can be converted to fake timeseries if the panel cannot 65 | handle table data. 66 | 67 | You can either use queries which have 2 columns (name, value) or queries 68 | which only return a single result row with numeric values only. 69 | 70 | ### Statistic Data Pie Chart 71 | 72 | For example the pie chart plugin can be used with stats queries like this: 73 | 74 | SELECT count() state, state FROM /hosts 75 | 76 | The query is expected to fetch 2 columns. The first is the value, the second is the name. 77 | 78 | ### Single Host Pie Chart 79 | 80 | Ex.: Use statistics data for a single host to put it into a pie chart: 81 | 82 | SELECT num_services_ok, num_services_warn, num_services_crit, num_services_unknown FROM /hosts WHERE name = '$name' LIMIT 1 83 | 84 | ![Pie Chart](https://raw.githubusercontent.com/sni/grafana-thruk-datasource/master/img/piechart.png "Pie Chart") 85 | 86 | ## Using Variables 87 | 88 | Dashboard variables can be used in almost all queries. For example if you 89 | define a dashboard variable named `host` you can then use `$host` in your 90 | queries. 91 | 92 | There is a special syntax for time filter: `field = $time` which will be 93 | replaced by `(field >= starttime AND field <= endtime)`. This can be used to 94 | reduce results to the dashboards timeframe. 95 | 96 | SELECT time, message FROM /hosts/$host/alerts WHERE time = $time 97 | 98 | which is the same as 99 | 100 | SELECT time, message FROM /alerts WHERE host_name = "$host" AND time = $time 101 | 102 | ![Variables](https://raw.githubusercontent.com/sni/grafana-thruk-datasource/master/img/variables.png "Variables Editor") 103 | 104 | ## Development 105 | 106 | To test and improve the plugin you can run Grafana instance in Docker using 107 | following command (in the source directory of this plugin): 108 | 109 | %> make dev 110 | 111 | This will start a grafana container and a build watcher which updates the 112 | plugin is the dist/ folder. 113 | 114 | The dev instance can be accessed at `http://localhost:3000`` 115 | 116 | Note: You need to add the datasource manually and you need to run "make build" once 117 | before starting the dev container, otherwise Grafana won't find the datasource. 118 | 119 | The grafana widget documentation is available here: https://developers.grafana.com/ui/latest/ 120 | 121 | ### Testing 122 | 123 | For testing you can use the demo Thruk instance at: 124 | 125 | - URL: https://demo.thruk.org/demo/thruk/ 126 | - Basic Auth: test / test 127 | 128 | ### Create Release 129 | 130 | How to create a new release: 131 | 132 | %> export RELVERSION=1.0.7 133 | %> export GRAFANA_ACCESS_POLICY_TOKEN=... 134 | %> vi package.json # replace version 135 | %> vi CHANGELOG.md # add changelog entry 136 | %> git commit -am "Release v${RELVERSION}" 137 | %> git tag -a v${RELVERSION} -m "Create release tag v${RELVERSION}" 138 | %> make GRAFANA_ACCESS_POLICY_TOKEN=${GRAFANA_ACCESS_POLICY_TOKEN} releasebuild 139 | # create release here https://github.com/sni/grafana-thruk-datasource/releases/new 140 | # submit plugin update here https://grafana.com/orgs/sni/plugins 141 | 142 | ## Changelog 143 | 144 | see CHANGELOG.md 145 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | build: 4 | context: ./.config 5 | args: 6 | grafana_image: ${GRAFANA_IMAGE:-grafana-oss} 7 | grafana_version: ${GRAFANA_VERSION:-latest} 8 | ports: 9 | - 3000:3000/tcp 10 | volumes: 11 | - ./dist:/var/lib/grafana/plugins/sni-thruk-datasource 12 | - ./provisioning:/etc/grafana/provisioning 13 | environment: 14 | - GF_USERS_DEFAULT_THEME=light 15 | depends_on: 16 | buildwatch: 17 | condition: service_healthy # start as soon as the first build is ready 18 | buildwatch: 19 | image: node:20 20 | command: bash -c "yarn install && yarn run dev" 21 | working_dir: /src 22 | healthcheck: 23 | test: test -f /src/dist/module.js 24 | interval: 3s 25 | start_period: 200s 26 | volumes: 27 | - .:/src 28 | -------------------------------------------------------------------------------- /img/annotations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sni/grafana-thruk-datasource/2ae2800ab491fbd28e6441b36a80cddad3207ec3/img/annotations.png -------------------------------------------------------------------------------- /img/piechart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sni/grafana-thruk-datasource/2ae2800ab491fbd28e6441b36a80cddad3207ec3/img/piechart.png -------------------------------------------------------------------------------- /img/variables.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sni/grafana-thruk-datasource/2ae2800ab491fbd28e6441b36a80cddad3207ec3/img/variables.png -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup provided by Grafana scaffolding 2 | import './.config/jest-setup'; 3 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // force timezone to UTC to allow tests to work regardless of local timezone 2 | // generally used by snapshots, but can affect specific tests 3 | process.env.TZ = 'UTC'; 4 | 5 | module.exports = { 6 | // Jest configuration provided by Grafana scaffolding 7 | ...require('./.config/jest.config'), 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grafana-thruk-datasource", 3 | "version": "2.2.0", 4 | "description": "grafana thruk datasource", 5 | "author": "Sven Nierlein", 6 | "license": "MIT", 7 | "homepage": "https://github.com/sni/grafana-thruk-datasource#readme", 8 | "bugs": { 9 | "url": "https://github.com/sni/grafana-thruk-datasource/issues" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/sni/grafana-thruk-datasource.git" 14 | }, 15 | "scripts": { 16 | "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 17 | "dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 18 | "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", 19 | "lint:fix": "yarn run lint --fix", 20 | "prettier:check": "prettier --list-different \"**/*.{ts,tsx,scss}\"", 21 | "prettier:write": "prettier --list-different \"**/*.{ts,tsx,scss}\" --write", 22 | "server": "docker-compose up --build", 23 | "sign": "npx --yes @grafana/sign-plugin@latest", 24 | "start": "yarn watch", 25 | "test": "jest --watch --onlyChanged", 26 | "test:ci": "jest --passWithNoTests --maxWorkers 4", 27 | "typecheck": "tsc --noEmit" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "^7.21.4", 31 | "@grafana/eslint-config": "^7.0.0", 32 | "@grafana/sign-plugin": "^3.0.3", 33 | "@grafana/tsconfig": "^1.2.0-rc1", 34 | "@swc/core": "1.3.75", 35 | "@swc/helpers": "^0.5.0", 36 | "@swc/jest": "^0.2.26", 37 | "@testing-library/jest-dom": "^5.16.5", 38 | "@testing-library/react": "^12.1.4", 39 | "@types/jest": "^29.5.0", 40 | "@types/lodash": "^4.14.194", 41 | "@types/node": "^18.15.11", 42 | "copy-webpack-plugin": "^11.0.0", 43 | "css-loader": "^6.7.3", 44 | "eslint-webpack-plugin": "^4.0.1", 45 | "fork-ts-checker-webpack-plugin": "^8.0.0", 46 | "glob": "^10.2.7", 47 | "identity-obj-proxy": "3.0.0", 48 | "jest": "^29.5.0", 49 | "jest-environment-jsdom": "^29.5.0", 50 | "prettier": "^2.8.7", 51 | "replace-in-file-webpack-plugin": "^1.0.6", 52 | "sass": "1.63.2", 53 | "sass-loader": "13.3.1", 54 | "style-loader": "3.3.3", 55 | "swc-loader": "^0.2.3", 56 | "ts-node": "^10.9.1", 57 | "tsconfig-paths": "^4.2.0", 58 | "typescript": "4.8.4", 59 | "webpack": "^5.94.0", 60 | "webpack-cli": "^5.1.4", 61 | "webpack-livereload-plugin": "^3.0.2" 62 | }, 63 | "dependencies": { 64 | "@emotion/css": "^11.1.3", 65 | "@grafana/data": "^11.1.0", 66 | "@grafana/runtime": "^11.1.0", 67 | "@grafana/ui": "^11.1.0", 68 | "@hello-pangea/dnd": "^18.0.1", 69 | "react": "17.0.2", 70 | "react-dom": "17.0.2", 71 | "tslib": "2.6.3" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ConfigEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import { DataSourceHttpSettings } from '@grafana/ui'; 3 | import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; 4 | import { ThrukDataSourceOptions } from './types'; 5 | 6 | interface Props extends DataSourcePluginOptionsEditorProps {} 7 | 8 | interface State {} 9 | 10 | export class ConfigEditor extends PureComponent { 11 | render() { 12 | const { onOptionsChange, options } = this.props; 13 | if (!options.jsonData.keepCookies) { 14 | options.jsonData.keepCookies = ['thruk_auth']; 15 | } 16 | return ( 17 |
18 | <> 19 | 25 | 26 |
27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/QueryEditor.tsx: -------------------------------------------------------------------------------- 1 | import { defaults, debounce } from 'lodash'; 2 | import React, { useMemo, useRef } from 'react'; 3 | import { DragDropContext, Droppable, Draggable, DropResult } from '@hello-pangea/dnd'; 4 | import { 5 | SegmentSection, 6 | InlineLabel, 7 | ComboboxOption, 8 | Input, 9 | SegmentAsync, 10 | InlineField, 11 | IconButton, 12 | Combobox, 13 | } from '@grafana/ui'; 14 | import { QueryEditorProps, SelectableValue } from '@grafana/data'; 15 | import { getTemplateSrv } from '@grafana/runtime'; 16 | import { DataSource } from './datasource'; 17 | import { ThrukDataSourceOptions, ThrukQuery, defaultQuery } from './types'; 18 | 19 | type Props = QueryEditorProps; 20 | 21 | export const QueryEditor = (props: Props) => { 22 | const { onRunQuery } = props; 23 | const debouncedRunQuery = useMemo(() => debounce(onRunQuery, 500), [onRunQuery]); 24 | props.query = defaults(props.query, defaultQuery); 25 | 26 | const prependDashboardVariables = (data: string[]) => { 27 | getTemplateSrv() 28 | .getVariables() 29 | .forEach((v, i) => { 30 | data.unshift('/^$' + v.name + '$/'); 31 | }); 32 | return data; 33 | }; 34 | 35 | const loadTables = (filter?: string): Promise => { 36 | return props.datasource 37 | .request('GET', '/index?columns=url&protocol=get') 38 | .then((response) => { 39 | return response.data.map((row: { url?: string }) => { 40 | return row.url; 41 | }); 42 | }) 43 | .then(prependDashboardVariables) 44 | .then((data) => 45 | data.filter((item) => { 46 | return !filter || (item && item.toLowerCase().includes(filter.toLowerCase())); 47 | }) 48 | ); 49 | }; 50 | 51 | const loadColumns = (filter?: string): Promise => { 52 | if (!props.query.table) { 53 | return Promise.resolve(['*']); 54 | } 55 | return props.datasource 56 | .request('GET', props.datasource._appendUrlParam(props.query.table, 'limit=1')) 57 | .then((response) => { 58 | if (!response.data) { 59 | return ['*']; 60 | } 61 | if (Array.isArray(response.data) && response.data[0]) { 62 | return Object.keys(response.data[0]).map((key: string, i: number) => { 63 | return key; 64 | }); 65 | } 66 | if (response.data instanceof Object) { 67 | return Object.keys(response.data).map((key: string, i: number) => { 68 | return key; 69 | }); 70 | } 71 | return ['*']; 72 | }) 73 | .then((data: string[]) => { 74 | ['avg()', 'min()', 'max()', 'sum()', 'count()'].reverse().forEach((el) => { 75 | data.unshift(el); 76 | }); 77 | return data; 78 | }) 79 | .then(prependDashboardVariables) 80 | .then((data) => 81 | data.filter((item) => { 82 | return !filter || (item && item.toLowerCase().includes(filter.toLowerCase())); 83 | }) 84 | ); 85 | }; 86 | 87 | const onValueChange = (key: keyof ThrukQuery, value: any) => { 88 | props.query[key] = value as never; 89 | props.onChange(props.query); 90 | debouncedRunQuery(); 91 | }; 92 | 93 | const onDragEnd = (result: DropResult) => { 94 | if (!result.destination) { 95 | return; 96 | } 97 | const [removed] = props.query.columns.splice(result.source.index, 1); 98 | props.query.columns.splice(result.destination.index, 0, removed); 99 | props.onChange(props.query); 100 | debouncedRunQuery(); 101 | }; 102 | const getListStyle = (isDraggingOver: boolean) => ({ 103 | background: isDraggingOver ? 'lightblue' : '', 104 | display: 'flex', 105 | overflow: 'auto', 106 | }); 107 | const getItemStyle = (isDragging: boolean, draggableStyle: any) => ({ 108 | userSelect: 'none', 109 | background: isDragging ? 'lightgreen' : '', 110 | ...draggableStyle, 111 | }); 112 | const css = ` 113 | .thruk-dnd-label { 114 | padding: 0 12px; 115 | cursor: grab; 116 | overflow-y: hidden; 117 | } 118 | DIV.thruk-dnd-list DIV:first-child LABEL.thruk-dnd-label { 119 | padding: 0 12px 0 0; 120 | } 121 | .thruk-dnd-label:hover { 122 | background: lightblue; 123 | cursor: grab; 124 | } 125 | .thruk-dnd-label LABEL { 126 | padding: 0 4px; 127 | margin: 0; 128 | cursor: text; 129 | } 130 | `; 131 | 132 | let outputRef = useRef(null); 133 | let copyBtn = useRef(null); 134 | return ( 135 | <> 136 | 137 |
138 | 139 | <> 140 | 141 | { 146 | onValueChange('table', v !== null ? v.value : '/'); 147 | }} 148 | options={(filter?: string): Promise => { 149 | return loadTables(filter).then((data) => { 150 | return data.map((item) => { 151 | return { value: item }; 152 | }); 153 | }); 154 | }} 155 | minWidth={30} 156 | maxWidth={300} 157 | width={'auto'} 158 | /> 159 | 160 | 161 | 162 |
163 |
164 | 165 | <> 166 | 167 | 168 | 169 | {(provided, snapshot) => ( 170 |
176 | {props.query.columns.map((sel, index) => ( 177 | 178 | {(provided, snapshot) => ( 179 |
185 | 186 | => { 191 | return loadColumns(filter).then((data) => { 192 | return data.map((item) => { 193 | return { value: item, label: item }; 194 | }); 195 | }); 196 | }} 197 | width={'auto'} 198 | minWidth={5} 199 | onChange={(v) => { 200 | if (v === null) { 201 | // remove segment 202 | props.query.columns.splice(index, 1); 203 | } else { 204 | props.query.columns[index] = v.value; 205 | } 206 | // remove '*' from list 207 | let i = props.query.columns.indexOf('*'); 208 | if (i !== -1) { 209 | props.query.columns.splice(i, 1); 210 | } 211 | if (props.query.columns.length === 0) { 212 | props.query.columns.push('*'); 213 | } 214 | props.onChange(props.query); 215 | debouncedRunQuery(); 216 | }} 217 | /> 218 | 219 |
220 | )} 221 |
222 | ))} 223 | {provided.placeholder} 224 |
225 | )} 226 |
227 |
228 | => { 232 | return loadColumns(filter).then((data) => { 233 | return data.map((item) => { 234 | return { value: item, label: item }; 235 | }); 236 | }); 237 | }} 238 | onChange={(v) => { 239 | props.query.columns.push(v.value); 240 | // remove '*' from list 241 | let i = props.query.columns.indexOf('*'); 242 | if (i !== -1) { 243 | props.query.columns.splice(i, 1); 244 | } 245 | props.onChange(props.query); 246 | debouncedRunQuery(); 247 | }} 248 | inputMinWidth={200} 249 | /> 250 | 251 | 252 | 253 |
254 |
255 | 256 | <> 257 | 258 | { 262 | onValueChange('condition', v.currentTarget.value); 263 | }} 264 | /> 265 |
266 |
267 | 268 | <> 269 | 270 | { 274 | let limit = Number(v.currentTarget.value); 275 | if (limit <= 0) { 276 | onValueChange('limit', undefined); 277 | } else { 278 | onValueChange('limit', limit); 279 | } 280 | }} 281 | type={'number'} 282 | width={10} 283 | /> 284 | AS
) as unknown as string}> 285 | <> 286 | 287 | { 295 | onValueChange('type', v); 296 | }} 297 | isClearable={false} 298 | createCustomValue={false} 299 | width="auto" 300 | minWidth={15} 301 | /> 302 | 303 | 304 | 305 | Helper) as unknown as string}> 306 | <> 307 | 308 | { 312 | if (outputRef.current) { 313 | if ((outputRef.current as any) instanceof HTMLInputElement) { 314 | let inp = outputRef.current as HTMLInputElement; 315 | inp.value = encodeURIComponent(v.currentTarget.value); 316 | } 317 | } 318 | }} 319 | /> 320 | 321 | { 329 | if (outputRef.current) { 330 | if ((outputRef.current as any) instanceof HTMLInputElement) { 331 | let inp = outputRef.current as HTMLInputElement; 332 | try { 333 | if (navigator.clipboard) { 334 | navigator.clipboard.writeText(inp.value); 335 | } 336 | if (copyBtn.current) { 337 | if ((copyBtn.current as any) instanceof HTMLButtonElement) { 338 | let btn = copyBtn.current as HTMLButtonElement; 339 | btn.style.transition = ''; 340 | btn.style.backgroundColor = '#00b500'; 341 | setTimeout(() => { 342 | btn.style.transition = 'background-color 1s'; 343 | btn.style.backgroundColor = ''; 344 | }, 500); 345 | } 346 | } 347 | } catch (e) { 348 | console.warn(e); 349 | } 350 | } 351 | } 352 | }} 353 | /> 354 | 355 | 356 | ); 357 | }; 358 | -------------------------------------------------------------------------------- /src/datasource.ts: -------------------------------------------------------------------------------- 1 | import defaults from 'lodash/defaults'; 2 | import { 3 | DataQueryRequest, 4 | DataQueryResponse, 5 | DataQueryResponseData, 6 | DataSourceApi, 7 | DataSourceInstanceSettings, 8 | MetricFindValue, 9 | MutableDataFrame, 10 | FieldType, 11 | TimeRange, 12 | ScopedVars, 13 | FieldSchema, 14 | AnnotationQuery, 15 | FieldConfig, 16 | } from '@grafana/data'; 17 | import { BackendSrvRequest, getBackendSrv, toDataQueryResponse, getTemplateSrv } from '@grafana/runtime'; 18 | import { lastValueFrom, Observable, throwError } from 'rxjs'; 19 | 20 | import { ThrukQuery, ThrukDataSourceOptions, defaultQuery, ThrukColumnConfig, ThrukColumnMetaColumn } from './types'; 21 | import { isNumber } from 'lodash'; 22 | 23 | export class DataSource extends DataSourceApi { 24 | url?: string; 25 | basicAuth?: string; 26 | withCredentials?: boolean; 27 | isProxyAccess: boolean; 28 | 29 | constructor(instanceSettings: DataSourceInstanceSettings) { 30 | super(instanceSettings); 31 | 32 | this.url = instanceSettings.url; 33 | this.basicAuth = instanceSettings.basicAuth; 34 | this.withCredentials = instanceSettings.withCredentials; 35 | this.isProxyAccess = instanceSettings.access === 'proxy'; 36 | 37 | this.annotations = { 38 | prepareQuery(anno: AnnotationQuery): ThrukQuery | undefined { 39 | let target = anno.target; 40 | return target; 41 | }, 42 | }; 43 | } 44 | 45 | async testDatasource() { 46 | let url = '/thruk?columns=thruk_version'; 47 | return this.request('GET', url) 48 | .then((response) => { 49 | if (response.status === 200 && response.data.thruk_version) { 50 | return { 51 | status: 'success', 52 | message: 'Successfully connected to Thruk v' + response.data.thruk_version, 53 | }; 54 | } 55 | return { status: 'error', message: 'invalid url, did not find thruk version in response.' }; 56 | }) 57 | .catch((err) => { 58 | return { status: 'error', message: 'Datasource error: ' + err.message }; 59 | }); 60 | } 61 | 62 | // metricFindQuery gets called from variables page 63 | async metricFindQuery(query_string: string, options?: any): Promise { 64 | if (query_string === '') { 65 | return []; 66 | } 67 | 68 | let query = this.parseVariableQuery(this.replaceVariables(query_string)); 69 | 70 | return this.request( 71 | 'GET', 72 | query.table + 73 | '?q=' + 74 | encodeURIComponent(this.replaceVariables(query.condition || '')) + 75 | '&columns=' + 76 | encodeURIComponent(this.replaceVariables(query.columns.join(','))) + 77 | '&limit=' + 78 | encodeURIComponent(this.replaceVariables((query.limit > 0 ? query.limit : '').toString())) 79 | ).then((response) => { 80 | let key = query.columns[0]; 81 | return response.data.map((row: any) => { 82 | return { text: row[key], value: row[key] }; 83 | }); 84 | }); 85 | } 86 | 87 | // standard dashboard queries / explorer 88 | async query(options: DataQueryRequest): Promise { 89 | const templateSrv = getTemplateSrv(); 90 | const data: DataQueryResponseData[] = []; 91 | 92 | // set defaults and replace template variables 93 | options.targets.map((target) => { 94 | target = defaults(target, defaultQuery); 95 | target.table = this.replaceVariables(target.table, undefined, options.scopedVars); 96 | target.limit = Number(templateSrv.replace(String(target.limit || ''))); 97 | }); 98 | 99 | options.targets = options.targets.filter((t) => !t.hide); 100 | options.targets = options.targets.filter((t) => t.table); /* hide queries without a table filter */ 101 | 102 | if (options.targets.length <= 0) { 103 | return toDataQueryResponse({}); 104 | } 105 | 106 | let queries: any[] = []; 107 | let columns: ThrukColumnConfig[] = []; 108 | 109 | options.targets.map((target) => { 110 | let col = this._buildColumns(target.columns); 111 | 112 | let path = target.table; 113 | path = path.replace(/^\//, ''); 114 | path = this.replaceVariables(path, options.range, options.scopedVars); 115 | 116 | path = this._appendUrlParam( 117 | path, 118 | 'limit=' + encodeURIComponent(this.replaceVariables((target.limit > 0 ? target.limit : '').toString())) 119 | ); 120 | if (col.hasColumns) { 121 | path = path + '&columns=' + encodeURIComponent(this.replaceVariables(col.columns.join(','))); 122 | } 123 | if (target.condition) { 124 | path = 125 | path + '&q=' + encodeURIComponent(this.replaceVariables(target.condition, options.range, options.scopedVars)); 126 | } 127 | 128 | queries.push(this.request('GET', path, null, { 'X-THRUK-OutputFormat': 'wrapped_json' })); 129 | columns.push(col); 130 | }); 131 | 132 | await Promise.allSettled(queries).then((results) => { 133 | results.forEach((result, i) => { 134 | switch (result.status) { 135 | case 'rejected': 136 | throw new Error('failed to fetch data: ' + result.reason); 137 | break; 138 | case 'fulfilled': 139 | options.targets[i].result = result.value; 140 | break; 141 | } 142 | }); 143 | }); 144 | 145 | options.targets.map((target, i: number) => { 146 | if (!target.result || !target.result.data) { 147 | throw new Error('Query failed, got no result data'); 148 | return; 149 | } 150 | let meta = undefined; 151 | let metaColumns: Record = {}; 152 | if (!Array.isArray(target.result.data)) { 153 | if (target.result.data.data && target.result.data.meta) { 154 | meta = target.result.data.meta; 155 | target.result.data = target.result.data.data; 156 | } 157 | if (!Array.isArray(target.result.data)) { 158 | target.result.data = [target.result.data]; 159 | } 160 | } 161 | let fields = columns[i].fields; 162 | if (!columns[i].hasColumns) { 163 | // extract columns from first result row if no columns given 164 | if (target.result && target.result.data && target.result.data.length > 0) { 165 | Object.keys(target.result.data[0]).forEach((key: string, i: number) => { 166 | fields.push( 167 | this.buildField( 168 | metaColumns[key]?.name || key, 169 | metaColumns[key]?.type, 170 | metaColumns[key]?.config as FieldConfig 171 | ) 172 | ); 173 | }); 174 | } 175 | } 176 | if (meta && meta.columns) { 177 | meta.columns.forEach((column: ThrukColumnMetaColumn, i: number) => { 178 | metaColumns[column.name] = column; 179 | fields[i].name = column.name; 180 | if (column.type) { 181 | fields[i].type = this.str2fieldtype(column.type); 182 | } 183 | if (column.config) { 184 | fields[i].config = column.config as FieldConfig; 185 | } 186 | }); 187 | } 188 | 189 | // adjust number / time field types 190 | if (target.result && target.result.data && target.result.data.length > 0) { 191 | fields.forEach((field: FieldSchema, i: number) => { 192 | if (fields[i].type !== FieldType.string) { 193 | return true; 194 | } 195 | if (isNumber(target.result.data[0][field.name])) { 196 | fields[i].type = FieldType.number; 197 | } 198 | return true; 199 | }); 200 | } 201 | 202 | const query = defaults(target, defaultQuery); 203 | if (target.type === 'timeseries') { 204 | target.type = 'graph'; 205 | } 206 | 207 | if (target.type === 'graph') { 208 | this._fakeTimeseries(data, query, target.result.data as Array<{}>, options, columns[i]); 209 | return; 210 | } 211 | 212 | const frame = new MutableDataFrame({ 213 | refId: query.refId, 214 | meta: { 215 | preferredVisualisationType: target.type, 216 | }, 217 | fields: fields, 218 | }); 219 | target.result.data.forEach((row: any, j: number) => { 220 | let dataRow: any[] = []; 221 | fields.forEach((f: FieldSchema, j: number) => { 222 | if (f.type === FieldType.time) { 223 | dataRow.push(row[f.name] * 1000); 224 | } else { 225 | dataRow.push(row[f.name]); 226 | } 227 | }); 228 | frame.appendRow(dataRow); 229 | }); 230 | data.push(frame); 231 | }); 232 | 233 | return { data }; 234 | } 235 | 236 | /** 237 | * Builds a FieldSchema object based on the provided key and optional type. 238 | * 239 | * @param {string} key - The name of the field. 240 | * @param {FieldType} [type] - The type of the field. If not provided, it will be inferred based on the key. 241 | * @return {FieldSchema} The built FieldSchema object. 242 | */ 243 | buildField(key: string, type?: FieldType | string, config?: FieldConfig): FieldSchema { 244 | if (type !== undefined) { 245 | let ftype = FieldType.string; 246 | if (typeof type === 'string') { 247 | ftype = this.str2fieldtype(type); 248 | } 249 | return { name: key, type: ftype, config: config }; 250 | } 251 | // seconds (from availabilty checks) 252 | if (key.match(/time_(down|up|unreachable|indeterminate|ok|warn|unknown|critical)/)) { 253 | return { name: key, type: FieldType.number, config: { unit: 's' } }; 254 | } 255 | // timestamp fields 256 | if (key.match(/^(last_|next_|start_|end_|time)/)) { 257 | return { name: key, type: FieldType.time }; 258 | } 259 | return { name: key, type: FieldType.string }; 260 | } 261 | 262 | str2fieldtype(str: string): FieldType { 263 | switch (str) { 264 | case 'number': 265 | return FieldType.number; 266 | case 'time': 267 | return FieldType.time; 268 | case 'bool': 269 | case 'boolean': 270 | return FieldType.boolean; 271 | } 272 | return FieldType.string; 273 | } 274 | 275 | replaceVariables(str: string, range?: TimeRange, scopedVars?: ScopedVars) { 276 | const templateSrv = getTemplateSrv(); 277 | str = templateSrv.replace(str, scopedVars, function (s: any) { 278 | if (s && Array.isArray(s)) { 279 | return '^(' + s.join('|') + ')$'; 280 | } 281 | return s; 282 | }); 283 | 284 | // replace time filter 285 | if (range) { 286 | let matches = str.match(/(\w+)\s*=\s*\$time/); 287 | if (matches && matches[1]) { 288 | let field = matches[1]; 289 | let timefilter = '(' + field + ' > ' + Math.floor(range.from.toDate().getTime() / 1000); 290 | timefilter += ' AND ' + field + ' < ' + Math.floor(range.to.toDate().getTime() / 1000); 291 | timefilter += ')'; 292 | str = str.replace(matches[0], timefilter); 293 | } 294 | } 295 | 296 | // fixup list regex filters 297 | let regex = new RegExp(/([\w_]+)\s*(>=|=)\s*"\^\((.*?)\)\$"/); 298 | let matches = str.match(regex); 299 | while (matches) { 300 | let groups: string[] = []; 301 | let segments = matches[3].split('|'); 302 | segments.forEach((s) => { 303 | if (matches !== null) { 304 | groups.push(matches[1] + ' ' + matches[2] + ' "' + s + '"'); 305 | } 306 | }); 307 | str = str.replace(matches[0], '(' + groups.join(' OR ') + ')'); 308 | matches = str.match(regex); 309 | } 310 | 311 | return str; 312 | } 313 | 314 | parseVariableQuery(query: string): ThrukQuery { 315 | let tmp = query.match(/^\s*SELECT\s+(.+)\s+FROM\s+([\w_\/]+)(|\s+WHERE\s+(.*))(|\s+LIMIT\s+(\d+))\s*$/i); 316 | if (!tmp) { 317 | throw new Error( 318 | 'query syntax error, expecting: SELECT [,] FROM [WHERE ] [LIMIT ]' 319 | ); 320 | } 321 | return { 322 | table: tmp[2], 323 | columns: [tmp[1]], 324 | condition: tmp[4], 325 | limit: tmp[6] ? Number(tmp[6]) : 0, 326 | type: 'table', 327 | } as ThrukQuery; 328 | } 329 | 330 | async request(method: string, url: string, data?: any, headers?: BackendSrvRequest['headers']): Promise { 331 | try { 332 | let result = await lastValueFrom(this._request(method, url, data, headers)); 333 | let resultData = result.data; 334 | if (!Array.isArray(resultData)) { 335 | if (resultData && resultData.data && resultData.meta) { 336 | resultData = resultData.data; 337 | } 338 | } 339 | 340 | // pass throught thruk errors 341 | if (resultData && resultData.message && resultData.code && resultData.code >= 400) { 342 | let description = resultData.description; 343 | if (description) { 344 | description = description.replace(/\s+at\s+.*\s+line\s+\d+\./, ''); 345 | } 346 | throw new Error(resultData.code + ' ' + resultData.message + (description ? ' (' + description + ')' : '')); 347 | } 348 | return result; 349 | } catch (error: unknown) { 350 | console.warn('failed to fetch ' + url); 351 | console.warn(error); 352 | if (typeof error === 'string') { 353 | throw new Error(error); 354 | } 355 | if (error instanceof Error) { 356 | throw error; 357 | } 358 | 359 | let httpError = error as { status: number; statusText: string; data?: any }; 360 | if (httpError.status) { 361 | let extra = ''; 362 | if (httpError.data) { 363 | if (httpError.data.response) { 364 | let matches = httpError.data.response.match(/

(.*?)<\/h1>/); 365 | if (matches[1] && matches[1] !== httpError.statusText) { 366 | extra = ' (' + matches[1] + ')'; 367 | } 368 | } 369 | if ( 370 | httpError.data.message && 371 | httpError.data.message !== httpError.data.response && 372 | httpError.data.message !== httpError.statusText 373 | ) { 374 | extra += ' ' + httpError.data.message; 375 | } 376 | if (httpError.data.description) { 377 | extra += ' (' + httpError.data.description + ')'; 378 | } 379 | } 380 | throw new Error(httpError.status + ' ' + httpError.statusText + extra); 381 | } 382 | 383 | throw new Error('failed to fetch data, unknown error'); 384 | } 385 | } 386 | 387 | _request(method: string, url: string, data?: any, headers?: BackendSrvRequest['headers']): Observable { 388 | if (!this.isProxyAccess) { 389 | return throwError( 390 | () => 391 | new Error('Browser access mode in the Thruk datasource is no longer available. Switch to server access mode.') 392 | ); 393 | } 394 | 395 | const options: BackendSrvRequest = { 396 | url: this._buildUrl(url), 397 | method, 398 | data, 399 | headers, 400 | }; 401 | 402 | if (this.basicAuth || this.withCredentials) { 403 | options.withCredentials = true; 404 | } 405 | if (this.basicAuth) { 406 | options.headers = { 407 | Authorization: this.basicAuth, 408 | }; 409 | } 410 | 411 | return getBackendSrv().fetch(options); 412 | } 413 | 414 | _buildUrl(url: string): string { 415 | this.url = this.url?.replace(/\/$/, ''); 416 | url = url.replace(/^\//, ''); 417 | url = this.url + '/r/v1/' + url; 418 | return url; 419 | } 420 | 421 | _appendUrlParam(url: string, param: string): string { 422 | if (url.match(/\?/)) { 423 | return url + '&' + param; 424 | } 425 | return url + '?' + param; 426 | } 427 | 428 | _fixup_regex(value: any) { 429 | if (value === undefined || value == null) { 430 | return value; 431 | } 432 | let matches = value.match(/^\/?\^?\{(.*)\}\$?\/?$/); 433 | if (!matches) { 434 | return value; 435 | } 436 | let values = matches[1].split(/,/); 437 | for (let x = 0; x < values.length; x++) { 438 | values[x] = values[x].replace(/\//, '\\/'); 439 | } 440 | return '/^(' + values.join('|') + ')$/'; 441 | } 442 | 443 | _buildColumns(columns?: string[]): ThrukColumnConfig { 444 | let hasColumns = false; 445 | let hasStats = false; 446 | let newColumns: string[] = []; 447 | let fields: FieldSchema[] = []; 448 | 449 | if (!columns) { 450 | columns = []; 451 | } 452 | if (columns.length === 0 || (columns.length === 1 && columns[0] === '*')) { 453 | columns = []; 454 | } 455 | if (columns.length > 0) { 456 | columns.forEach((col) => { 457 | if (col.match(/^(.*)\(\)$/)) { 458 | hasStats = true; 459 | return false; 460 | } 461 | return true; 462 | }); 463 | let op: string | undefined; 464 | columns.forEach((col) => { 465 | let matches = col.match(/^(.*)\(\)$/); 466 | if (matches && matches[1]) { 467 | op = matches[1]; 468 | } else { 469 | if (op) { 470 | col = op + '(' + col + ')'; 471 | op = undefined; 472 | } 473 | fields.push(this.buildField(col)); 474 | newColumns.push(col); 475 | } 476 | }); 477 | hasColumns = true; 478 | } 479 | return { columns: newColumns, fields: fields, hasColumns: hasColumns, hasStats: hasStats }; 480 | } 481 | 482 | _fakeTimeseries( 483 | response: DataQueryResponseData[], 484 | target: ThrukQuery, 485 | data: any[], 486 | options: DataQueryRequest, 487 | columns: ThrukColumnConfig 488 | ) { 489 | let steps = 10; 490 | let from = options.range.from.unix(); 491 | let to = options.range.to.unix(); 492 | let step = Math.floor((to - from) / steps); 493 | 494 | if (data.length === 0) { 495 | return; 496 | } 497 | 498 | let orderedColumns: string[] = Object.keys(data[0]); 499 | if (columns.columns.length > 0) { 500 | orderedColumns = columns.columns; 501 | } 502 | 503 | // convert single row results with multiple columns into usable data rows 504 | if (data.length === 1 && orderedColumns.length > 2) { 505 | let converted: any[] = []; 506 | orderedColumns.forEach((key) => { 507 | converted.push([key, data[0][key]]); 508 | }); 509 | data = converted; 510 | } 511 | 512 | let valueCol: string[] = []; 513 | let nameCol: string[] = []; 514 | // find first column using aggregation function and use this as value 515 | orderedColumns.forEach((key) => { 516 | if (key.match(/^\w+\(.*\)$/)) { 517 | valueCol.push(key); 518 | } 519 | }); 520 | 521 | // nothing found, use first column with a numeric value 522 | if (valueCol.length === 0) { 523 | orderedColumns.forEach((key) => { 524 | if (isNumber(data[0][key])) { 525 | valueCol.push(key); 526 | } 527 | }); 528 | } 529 | 530 | // use first available column if none set yet 531 | if (valueCol.length === 0) { 532 | valueCol.push(orderedColumns[0]); 533 | } 534 | 535 | // name columns are all remaining columns not used as value 536 | orderedColumns.forEach((key) => { 537 | if (!valueCol.includes(key)) { 538 | nameCol.push(key); 539 | } 540 | }); 541 | 542 | // create timeseries based on group by keys 543 | data.forEach((row) => { 544 | let val = row[valueCol[0]]; 545 | let names: string[] = []; 546 | if (nameCol.length === 0) { 547 | names = [valueCol[0]]; 548 | } else { 549 | nameCol.forEach((key) => { 550 | names.push(row[key]); 551 | }); 552 | } 553 | let alias = names.join(';'); 554 | const frame = new MutableDataFrame({ 555 | refId: target.refId, 556 | meta: { 557 | preferredVisualisationType: 'graph', 558 | }, 559 | fields: [ 560 | { name: 'time', type: FieldType.time }, 561 | { name: alias, type: FieldType.number }, 562 | ], 563 | }); 564 | 565 | for (let y = 0; y < steps; y++) { 566 | let row: any = { 567 | time: (from + step * y) * 1000, 568 | }; 569 | row[alias] = val; 570 | frame.add(row); 571 | } 572 | response.push(frame); 573 | }); 574 | } 575 | } 576 | -------------------------------------------------------------------------------- /src/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 48 | 57 | 61 | 67 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /src/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sni/grafana-thruk-datasource/2ae2800ab491fbd28e6441b36a80cddad3207ec3/src/img/screenshot.png -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { DataSourcePlugin } from '@grafana/data'; 2 | import { DataSource } from './datasource'; 3 | import { ConfigEditor } from './ConfigEditor'; 4 | import { QueryEditor } from './QueryEditor'; 5 | import { ThrukQuery, ThrukDataSourceOptions } from './types'; 6 | 7 | export const plugin = new DataSourcePlugin(DataSource) 8 | .setConfigEditor(ConfigEditor) 9 | .setQueryEditor(QueryEditor); 10 | -------------------------------------------------------------------------------- /src/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/grafana/grafana/master/docs/sources/developers/plugins/plugin.schema.json", 3 | "name": "Thruk", 4 | "id": "sni-thruk-datasource", 5 | "type": "datasource", 6 | 7 | "metrics": true, 8 | "annotations": true, 9 | 10 | "info": { 11 | "description": "thruk datasource", 12 | "author": { 13 | "name": "Sven Nierlein", 14 | "email": "sven.nierlein@consol.de", 15 | "url": "http://labs.consol.de" 16 | }, 17 | "logos": { 18 | "small": "img/logo.svg", 19 | "large": "img/logo.svg" 20 | }, 21 | "links": [ 22 | { "name": "GitHub", "url": "https://github.com/sni/grafana-thruk-datasource" }, 23 | { "name": "MIT License", "url": "https://github.com/sni/grafana-thruk-datasource/blob/master/LICENSE" } 24 | ], 25 | "keywords": ["naemon", "nagios", "thruk"], 26 | "screenshots": [{ "name": "thruk-datasource query editor", "path": "img/screenshot.png" }], 27 | "version": "%VERSION%", 28 | "updated": "%TODAY%" 29 | }, 30 | 31 | "dependencies": { 32 | "grafanaDependency": ">=11.0.0", 33 | "plugins": [] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DataQuery, DataSourceJsonData, FieldSchema } from '@grafana/data'; 2 | 3 | export interface ThrukQuery extends DataQuery { 4 | table: string; 5 | columns: string[]; 6 | condition: string; 7 | limit: number; 8 | type: 'table' | 'graph' | 'logs' | 'timeseries'; 9 | 10 | result?: any; 11 | } 12 | 13 | export const defaultQuery: Partial = { 14 | table: '/', 15 | columns: ['*'], 16 | condition: '', 17 | type: 'table', 18 | }; 19 | 20 | export interface ThrukDataSourceOptions extends DataSourceJsonData { 21 | keepCookies?: string[]; 22 | } 23 | 24 | export interface ThrukColumnConfig { 25 | columns: string[]; 26 | fields: FieldSchema[]; 27 | hasColumns: boolean; 28 | hasStats: boolean; 29 | } 30 | 31 | export interface ThrukColumnMeta { 32 | columns: ThrukColumnMetaColumn[]; 33 | } 34 | 35 | export interface ThrukColumnMetaColumn { 36 | name: string; 37 | type: string; 38 | config: any; 39 | } 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.json" 3 | } --------------------------------------------------------------------------------