├── .config ├── .cprc.json ├── .eslintrc ├── .prettierrc.js ├── Dockerfile ├── README.md ├── entrypoint.sh ├── jest-setup.js ├── jest.config.js ├── jest │ ├── mocks │ │ └── react-inlinesvg.tsx │ └── utils.js ├── supervisord │ └── supervisord.conf ├── tsconfig.json ├── types │ └── custom.d.ts └── webpack │ ├── constants.ts │ ├── utils.ts │ └── webpack.config.ts ├── .cprc.json ├── .eslintrc ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── 1-bug_report.md │ └── config.yml ├── dependabot.yml ├── issue_commands.json ├── pull_request_template.md ├── release.yml ├── workflows │ ├── dependabot-reviewer.yml │ ├── detect-breaking-changes.yml │ ├── e2e.yml │ ├── grafana-bench.yml │ ├── issue_commands.yml │ ├── run-backend-tests.yml │ └── run-frontend-tests.yml └── zizmor.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .vscode └── launch.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── DEV_GUIDE.md ├── LICENSE ├── Magefile.go ├── README.md ├── config-secure ├── config.xml ├── my-own-ca.crt ├── my-own-ca.key ├── my-own-ca.srl ├── server.crt ├── server.csr ├── server.ext ├── server.key └── users.xml ├── config ├── admin.21.8.xml ├── admin.xml ├── config-preprocessed.xml ├── config.xml ├── custom.21.8.xml ├── custom.xml ├── server.crt ├── server.key └── users.xml ├── cspell.config.json ├── docker-compose.yml ├── gen-db-dashboards.js ├── go.mod ├── go.sum ├── jest-runner-serial.js ├── jest-setup.js ├── jest.config.js ├── package.json ├── pkg ├── converters │ ├── converters.go │ └── converters_test.go ├── macros │ ├── macros.go │ └── macros_test.go ├── main.go └── plugin │ ├── datasource.go │ ├── driver.go │ ├── driver_test.go │ ├── errors.go │ ├── settings.go │ └── settings_test.go ├── playwright.config.ts ├── provisioning └── datasources │ └── clickhouse.yml ├── scripts ├── ca-cert.sh ├── ca.ext ├── ca.sh └── certs.sh ├── src ├── __mocks__ │ ├── ConfigEditor.ts │ └── datasource.ts ├── ch-parser │ ├── helpers.ts │ ├── lexer.ts │ ├── parser.ts │ ├── pluginMacros.ts │ └── types.ts ├── components │ ├── Divider.tsx │ ├── LogContextPanel.test.tsx │ ├── LogsContextPanel.tsx │ ├── QueryToolbox.tsx │ ├── SqlEditor.test.tsx │ ├── SqlEditor.tsx │ ├── configEditor │ │ ├── AliasTableConfig.test.tsx │ │ ├── AliasTableConfig.tsx │ │ ├── DefaultDatabaseTableConfig.test.tsx │ │ ├── DefaultDatabaseTableConfig.tsx │ │ ├── HttpHeadersConfig.test.tsx │ │ ├── HttpHeadersConfig.tsx │ │ ├── LabeledInput.test.tsx │ │ ├── LabeledInput.tsx │ │ ├── LogsConfig.test.tsx │ │ ├── LogsConfig.tsx │ │ ├── QuerySettingsConfig.test.tsx │ │ ├── QuerySettingsConfig.tsx │ │ ├── TracesConfig.test.tsx │ │ └── TracesConfig.tsx │ ├── experimental │ │ └── ConfigSection │ │ │ ├── ConfigSection.test.tsx │ │ │ ├── ConfigSection.tsx │ │ │ ├── ConfigSubSection.test.tsx │ │ │ ├── ConfigSubSection.tsx │ │ │ ├── DataSourceDescription.test.tsx │ │ │ ├── DataSourceDescription.tsx │ │ │ ├── GenericConfigSection.test.tsx │ │ │ ├── GenericConfigSection.tsx │ │ │ └── index.ts │ ├── queryBuilder │ │ ├── AggregateEditor.test.tsx │ │ ├── AggregateEditor.tsx │ │ ├── ColumnSelect.test.tsx │ │ ├── ColumnSelect.tsx │ │ ├── ColumnsEditor.test.tsx │ │ ├── ColumnsEditor.tsx │ │ ├── DatabaseTableSelect.test.tsx │ │ ├── DatabaseTableSelect.tsx │ │ ├── DurationUnitSelect.tsx │ │ ├── EditorTypeSwitcher.test.tsx │ │ ├── EditorTypeSwitcher.tsx │ │ ├── FilterEditor.test.tsx │ │ ├── FilterEditor.tsx │ │ ├── GroupByEditor.test.tsx │ │ ├── GroupByEditor.tsx │ │ ├── LimitEditor.test.tsx │ │ ├── LimitEditor.tsx │ │ ├── ModeSwitch.test.tsx │ │ ├── ModeSwitch.tsx │ │ ├── OrderByEditor.test.tsx │ │ ├── OrderByEditor.tsx │ │ ├── OtelVersionSelect.test.tsx │ │ ├── OtelVersionSelect.tsx │ │ ├── QueryBuilder.test.tsx │ │ ├── QueryBuilder.tsx │ │ ├── QueryTypeSwitcher.test.tsx │ │ ├── QueryTypeSwitcher.tsx │ │ ├── SqlPreview.test.tsx │ │ ├── SqlPreview.tsx │ │ ├── Switch.test.tsx │ │ ├── Switch.tsx │ │ ├── TraceIdInput.test.tsx │ │ ├── TraceIdInput.tsx │ │ ├── utils.test.ts │ │ ├── utils.ts │ │ └── views │ │ │ ├── LogsQueryBuilder.tsx │ │ │ ├── TableQueryBuilder.tsx │ │ │ ├── TimeSeriesQueryBuilder.tsx │ │ │ ├── TraceQueryBuilder.tsx │ │ │ ├── logsQueryBuilderHooks.test.ts │ │ │ ├── logsQueryBuilderHooks.ts │ │ │ ├── timeSeriesQueryBuilderHooks.test.ts │ │ │ ├── timeSeriesQueryBuilderHooks.ts │ │ │ ├── traceQueryBuilderHooks.test.ts │ │ │ └── traceQueryBuilderHooks.ts │ ├── sqlProvider.test.ts │ ├── sqlProvider.ts │ ├── suggestions.test.ts │ ├── suggestions.ts │ └── ui │ │ └── CertificationKey.tsx ├── dashboards │ ├── cluster-analysis.json │ ├── data-analysis.json │ ├── opentelemetry-clickhouse.json │ ├── query-analysis.json │ └── system-dashboards.json ├── data │ ├── CHDatasource.test.ts │ ├── CHDatasource.ts │ ├── adHocFilter.test.ts │ ├── adHocFilter.ts │ ├── ast.test.ts │ ├── ast.ts │ ├── columnFilters.test.ts │ ├── columnFilters.ts │ ├── logs.test.ts │ ├── logs.ts │ ├── migration.test.ts │ ├── migration.ts │ ├── sqlGenerator.test.ts │ ├── sqlGenerator.ts │ ├── utils.test.ts │ ├── utils.ts │ ├── validate.test.ts │ └── validate.ts ├── hooks │ ├── useBuilderOptionChanges.test.ts │ ├── useBuilderOptionChanges.ts │ ├── useBuilderOptionsState.test.ts │ ├── useBuilderOptionsState.ts │ ├── useColumns.test.ts │ ├── useColumns.ts │ ├── useDatabases.test.ts │ ├── useDatabases.ts │ ├── useIsNewQuery.test.ts │ ├── useIsNewQuery.ts │ ├── useSchemaSuggestionsProvider.ts │ ├── useTables.test.ts │ ├── useTables.ts │ ├── useUniqueMapKeys.test.ts │ └── useUniqueMapKeys.ts ├── img │ └── logo.svg ├── labels.ts ├── module.ts ├── otel.ts ├── plugin.json ├── selectors.ts ├── styles.ts ├── test │ └── setupTests.ts ├── tracking.test.ts ├── tracking.ts ├── types │ ├── config.ts │ ├── queryBuilder.ts │ └── sql.ts ├── typings.d.ts ├── utils │ ├── version.test.ts │ └── version.ts └── views │ ├── CHConfigEditor.test.tsx │ ├── CHConfigEditor.tsx │ ├── CHConfigEditorHooks.test.ts │ ├── CHConfigEditorHooks.ts │ ├── CHQueryEditor.test.tsx │ └── CHQueryEditor.tsx ├── tests ├── e2e │ └── configEditor.spec.ts └── fixtures │ └── property-prices.sql ├── tsconfig.json └── yarn.lock /.config/.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5.5.1" 3 | } 4 | -------------------------------------------------------------------------------- /.config/.eslintrc: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-eslint-config 6 | */ 7 | { 8 | "extends": ["@grafana/eslint-config"], 9 | "root": true, 10 | "rules": { 11 | "react/prop-types": "off" 12 | }, 13 | "overrides": [ 14 | { 15 | "plugins": ["deprecation"], 16 | "files": ["src/**/*.{ts,tsx}"], 17 | "rules": { 18 | "deprecation/deprecation": "warn" 19 | }, 20 | "parserOptions": { 21 | "project": "./tsconfig.json" 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.config/.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | module.exports = { 8 | endOfLine: 'auto', 9 | printWidth: 120, 10 | trailingComma: 'es5', 11 | semi: true, 12 | jsxSingleQuote: false, 13 | singleQuote: true, 14 | useTabs: false, 15 | tabWidth: 2, 16 | }; 17 | -------------------------------------------------------------------------------- /.config/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG grafana_version=latest 2 | ARG grafana_image=grafana-enterprise 3 | 4 | FROM grafana/${grafana_image}:${grafana_version} 5 | 6 | ARG development=false 7 | ARG TARGETARCH 8 | 9 | ARG GO_VERSION=1.21.6 10 | ARG GO_ARCH=${TARGETARCH:-amd64} 11 | 12 | ENV DEV "${development}" 13 | 14 | # Make it as simple as possible to access the grafana instance for development purposes 15 | # Do NOT enable these settings in a public facing / production grafana instance 16 | ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin" 17 | ENV GF_AUTH_ANONYMOUS_ENABLED "true" 18 | ENV GF_AUTH_BASIC_ENABLED "false" 19 | # Set development mode so plugins can be loaded without the need to sign 20 | ENV GF_DEFAULT_APP_MODE "development" 21 | 22 | 23 | LABEL maintainer="Grafana Labs " 24 | 25 | ENV GF_PATHS_HOME="/usr/share/grafana" 26 | WORKDIR $GF_PATHS_HOME 27 | 28 | USER root 29 | 30 | # Installing supervisor and inotify-tools 31 | RUN if [ "${development}" = "true" ]; then \ 32 | if grep -i -q alpine /etc/issue; then \ 33 | apk add supervisor inotify-tools git; \ 34 | elif grep -i -q ubuntu /etc/issue; then \ 35 | DEBIAN_FRONTEND=noninteractive && \ 36 | apt-get update && \ 37 | apt-get install -y supervisor inotify-tools git && \ 38 | rm -rf /var/lib/apt/lists/*; \ 39 | else \ 40 | echo 'ERROR: Unsupported base image' && /bin/false; \ 41 | fi \ 42 | fi 43 | 44 | COPY supervisord/supervisord.conf /etc/supervisor.d/supervisord.ini 45 | COPY supervisord/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 46 | 47 | 48 | # Installing Go 49 | RUN if [ "${development}" = "true" ]; then \ 50 | curl -O -L https://golang.org/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz && \ 51 | rm -rf /usr/local/go && \ 52 | tar -C /usr/local -xzf go${GO_VERSION}.linux-${GO_ARCH}.tar.gz && \ 53 | echo "export PATH=$PATH:/usr/local/go/bin:~/go/bin" >> ~/.bashrc && \ 54 | rm -f go${GO_VERSION}.linux-${GO_ARCH}.tar.gz; \ 55 | fi 56 | 57 | # Installing delve for debugging 58 | RUN if [ "${development}" = "true" ]; then \ 59 | /usr/local/go/bin/go install github.com/go-delve/delve/cmd/dlv@latest; \ 60 | fi 61 | 62 | # Installing mage for plugin (re)building 63 | RUN if [ "${development}" = "true" ]; then \ 64 | git clone https://github.com/magefile/mage; \ 65 | cd mage; \ 66 | export PATH=$PATH:/usr/local/go/bin; \ 67 | go run bootstrap.go; \ 68 | fi 69 | 70 | # Inject livereload script into grafana index.html 71 | RUN sed -i 's|||g' /usr/share/grafana/public/views/index.html 72 | 73 | 74 | COPY entrypoint.sh /entrypoint.sh 75 | RUN chmod +x /entrypoint.sh 76 | ENTRYPOINT ["/entrypoint.sh"] 77 | -------------------------------------------------------------------------------- /.config/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "${DEV}" = "false" ]; then 4 | echo "Starting test mode" 5 | exec /run.sh 6 | fi 7 | 8 | echo "Starting development mode" 9 | 10 | if grep -i -q alpine /etc/issue; then 11 | exec /usr/bin/supervisord -c /etc/supervisord.conf 12 | elif grep -i -q ubuntu /etc/issue; then 13 | exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf 14 | else 15 | echo 'ERROR: Unsupported base image' 16 | exit 1 17 | fi 18 | 19 | -------------------------------------------------------------------------------- /.config/jest-setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-jest-config 6 | */ 7 | 8 | import '@testing-library/jest-dom'; 9 | import { TextEncoder, TextDecoder } from 'util'; 10 | 11 | Object.assign(global, { TextDecoder, TextEncoder }); 12 | 13 | // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 14 | Object.defineProperty(global, 'matchMedia', { 15 | writable: true, 16 | value: (query) => ({ 17 | matches: false, 18 | media: query, 19 | onchange: null, 20 | addListener: jest.fn(), // deprecated 21 | removeListener: jest.fn(), // deprecated 22 | addEventListener: jest.fn(), 23 | removeEventListener: jest.fn(), 24 | dispatchEvent: jest.fn(), 25 | }), 26 | }); 27 | 28 | HTMLCanvasElement.prototype.getContext = () => {}; 29 | -------------------------------------------------------------------------------- /.config/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-jest-config 6 | */ 7 | 8 | const path = require('path'); 9 | const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils'); 10 | 11 | module.exports = { 12 | moduleNameMapper: { 13 | '\\.(css|scss|sass)$': 'identity-obj-proxy', 14 | 'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'), 15 | }, 16 | modulePaths: ['/src'], 17 | setupFilesAfterEnv: ['/jest-setup.js'], 18 | testEnvironment: 'jest-environment-jsdom', 19 | testMatch: [ 20 | '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', 21 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 22 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 23 | ], 24 | transform: { 25 | '^.+\\.(t|j)sx?$': [ 26 | '@swc/jest', 27 | { 28 | sourceMaps: 'inline', 29 | jsc: { 30 | parser: { 31 | syntax: 'typescript', 32 | tsx: true, 33 | decorators: false, 34 | dynamicImport: true, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | // Jest will throw `Cannot use import statement outside module` if it tries to load an 41 | // ES module without it being transformed first. ./config/README.md#esm-errors-with-jest 42 | transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)], 43 | }; 44 | -------------------------------------------------------------------------------- /.config/jest/mocks/react-inlinesvg.tsx: -------------------------------------------------------------------------------- 1 | // Due to the grafana/ui Icon component making fetch requests to 2 | // `/public/img/icon/.svg` we need to mock react-inlinesvg to prevent 3 | // the failed fetch requests from displaying errors in console. 4 | 5 | import React from 'react'; 6 | 7 | type Callback = (...args: any[]) => void; 8 | 9 | export interface StorageItem { 10 | content: string; 11 | queue: Callback[]; 12 | status: string; 13 | } 14 | 15 | export const cacheStore: { [key: string]: StorageItem } = Object.create(null); 16 | 17 | const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/; 18 | 19 | const InlineSVG = ({ src }: { src: string }) => { 20 | // testId will be the file name without extension (e.g. `public/img/icons/angle-double-down.svg` -> `angle-double-down`) 21 | const testId = src.replace(SVG_FILE_NAME_REGEX, '$2'); 22 | return ; 23 | }; 24 | 25 | export default InlineSVG; 26 | -------------------------------------------------------------------------------- /.config/jest/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | /* 8 | * This utility function is useful in combination with jest `transformIgnorePatterns` config 9 | * to transform specific packages (e.g.ES modules) in a projects node_modules folder. 10 | */ 11 | const nodeModulesToTransform = (moduleNames) => `node_modules\/(?!.*(${moduleNames.join('|')})\/.*)`; 12 | 13 | // Array of known nested grafana package dependencies that only bundle an ESM version 14 | const grafanaESModules = [ 15 | '.pnpm', // Support using pnpm symlinked packages 16 | '@grafana/schema', 17 | 'd3', 18 | 'd3-color', 19 | 'd3-force', 20 | 'd3-interpolate', 21 | 'd3-scale-chromatic', 22 | 'ol', 23 | 'react-colorful', 24 | 'rxjs', 25 | 'uuid', 26 | ]; 27 | 28 | module.exports = { 29 | nodeModulesToTransform, 30 | grafanaESModules, 31 | }; 32 | -------------------------------------------------------------------------------- /.config/supervisord/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | 5 | [program:grafana] 6 | user=root 7 | directory=/var/lib/grafana 8 | command=bash -c 'while [ ! -f /root/grafana-clickhouse-datasource/dist/gpx_clickhouse* ]; do sleep 1; done; /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 | [program:delve] 17 | user=root 18 | command=/bin/bash -c 'pid=""; while [ -z "$pid" ]; do pid=$(pgrep -f gpx_clickhouse); done; /root/go/bin/dlv attach --api-version=2 --headless --continue --accept-multiclient --listen=:2345 $pid' 19 | stdout_logfile=/dev/fd/1 20 | stdout_logfile_maxbytes=0 21 | redirect_stderr=true 22 | killasgroup=false 23 | stopasgroup=false 24 | autostart=true 25 | autorestart=true 26 | 27 | [program:build-watcher] 28 | user=root 29 | command=/bin/bash -c 'while inotifywait -e modify,create,delete -r /var/lib/grafana/plugins/grafana-clickhouse-datasource; do echo "Change detected, restarting delve...";supervisorctl restart delve; done' 30 | stdout_logfile=/dev/fd/1 31 | stdout_logfile_maxbytes=0 32 | redirect_stderr=true 33 | killasgroup=true 34 | stopasgroup=true 35 | autostart=true 36 | 37 | [program:mage-watcher] 38 | user=root 39 | environment=PATH="/usr/local/go/bin:/root/go/bin:%(ENV_PATH)s" 40 | directory=/root/grafana-clickhouse-datasource 41 | command=/bin/bash -c 'git config --global --add safe.directory /root/grafana-clickhouse-datasource && mage -v watch' 42 | stdout_logfile=/dev/fd/1 43 | stdout_logfile_maxbytes=0 44 | redirect_stderr=true 45 | killasgroup=true 46 | stopasgroup=true 47 | autostart=true 48 | -------------------------------------------------------------------------------- /.config/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-typescript-config 6 | */ 7 | { 8 | "compilerOptions": { 9 | "alwaysStrict": true, 10 | "declaration": false, 11 | "rootDir": "../src", 12 | "baseUrl": "../src", 13 | "typeRoots": ["../node_modules/@types"], 14 | "resolveJsonModule": true 15 | }, 16 | "ts-node": { 17 | "compilerOptions": { 18 | "module": "commonjs", 19 | "target": "es5", 20 | "esModuleInterop": true 21 | }, 22 | "transpileOnly": true 23 | }, 24 | "include": ["../src", "./types"], 25 | "extends": "@grafana/tsconfig" 26 | } 27 | -------------------------------------------------------------------------------- /.config/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | // Image declarations 2 | declare module '*.gif' { 3 | const src: string; 4 | export default src; 5 | } 6 | 7 | declare module '*.jpg' { 8 | const src: string; 9 | export default src; 10 | } 11 | 12 | declare module '*.jpeg' { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module '*.png' { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module '*.webp' { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module '*.svg' { 28 | const content: string; 29 | export default content; 30 | } 31 | 32 | // Font declarations 33 | declare module '*.woff'; 34 | declare module '*.woff2'; 35 | declare module '*.eot'; 36 | declare module '*.ttf'; 37 | declare module '*.otf'; 38 | -------------------------------------------------------------------------------- /.config/webpack/constants.ts: -------------------------------------------------------------------------------- 1 | export const SOURCE_DIR = 'src'; 2 | export const DIST_DIR = 'dist'; 3 | -------------------------------------------------------------------------------- /.config/webpack/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import process from 'process'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { glob } from 'glob'; 6 | import { SOURCE_DIR } from './constants'; 7 | 8 | export function isWSL() { 9 | if (process.platform !== 'linux') { 10 | return false; 11 | } 12 | 13 | if (os.release().toLowerCase().includes('microsoft')) { 14 | return true; 15 | } 16 | 17 | try { 18 | return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); 19 | } catch { 20 | return false; 21 | } 22 | } 23 | 24 | export function getPackageJson() { 25 | return require(path.resolve(process.cwd(), 'package.json')); 26 | } 27 | 28 | export function getPluginJson() { 29 | return require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`)); 30 | } 31 | 32 | export function getCPConfigVersion() { 33 | const cprcJson = path.resolve(__dirname, '../', '.cprc.json'); 34 | return fs.existsSync(cprcJson) ? require(cprcJson).version : { version: 'unknown' }; 35 | } 36 | 37 | export function hasReadme() { 38 | return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md')); 39 | } 40 | 41 | // Support bundling nested plugins by finding all plugin.json files in src directory 42 | // then checking for a sibling module.[jt]sx? file. 43 | export async function getEntries(): Promise> { 44 | const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true }); 45 | 46 | const plugins = await Promise.all( 47 | pluginsJson.map((pluginJson) => { 48 | const folder = path.dirname(pluginJson); 49 | return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true }); 50 | }) 51 | ); 52 | 53 | return plugins.reduce((result, modules) => { 54 | return modules.reduce((result, module) => { 55 | const pluginPath = path.dirname(module); 56 | const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, ''); 57 | const entryName = pluginName === '' ? 'module' : `${pluginName}/module`; 58 | 59 | result[entryName] = module; 60 | return result; 61 | }, result); 62 | }, {}); 63 | } 64 | -------------------------------------------------------------------------------- /.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": { 3 | "bundleGrafanaUI": false, 4 | "useReactRouterV6": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/.eslintrc" 3 | } -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | # More details are here: https://help.github.com/articles/about-codeowners/ 5 | 6 | # The '*' pattern is global owners. 7 | 8 | # Order is important. The last matching pattern has the most precedence. 9 | # The folders are ordered as follows: 10 | 11 | # In each subsection folders are ordered first by depth, then alphabetically. 12 | # This should make it easy to add new rules without breaking existing ones. 13 | 14 | * @grafana/partner-datasources 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug you found when using this plugin 4 | labels: ['datasource/ClickHouse', 'type/bug'] 5 | --- 6 | 7 | 15 | 16 | **What happened**: 17 | 18 | **What you expected to happen**: 19 | 20 | **How to reproduce it (as minimally and precisely as possible)**: 21 | 22 | 30 | 31 | **Screenshots** 32 | 33 | 36 | 37 | **Anything else we need to know?**: 38 | 39 | **Environment**: 40 | 41 | - Grafana version: 42 | - Plugin version: 43 | - OS Grafana is installed on: 44 | - User OS & Browser: 45 | - Others: 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature Request 4 | url: https://github.com/grafana/clickhouse-datasource/discussions/new 5 | about: Discuss ideas for new features or changes 6 | - name: Questions & Help 7 | url: https://community.grafana.com 8 | about: Please ask and answer questions here 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | # Maintain dependencies for GitHub Actions 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "npm" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | groups: 17 | dependencies: 18 | patterns: 19 | - "@grafana/*" 20 | dev-dependencies: 21 | patterns: 22 | - "@grafana/e2e*" 23 | 24 | # Ignore most dependencies that are maintained via the create-plugin tool 25 | # See https://github.com/grafana/plugin-tools/blob/main/packages/create-plugin/templates/common/package.json 26 | ignore: 27 | - dependency-name: "@babel/core" 28 | - dependency-name: "@emotion/css" 29 | - dependency-name: "@grafana/eslint-config" 30 | - dependency-name: "@grafana/tsconfig" 31 | - dependency-name: "@swc/core" 32 | - dependency-name: "@swc/helpers" 33 | - dependency-name: "@swc/jest" 34 | - dependency-name: "@testing-library/jest-dom" 35 | - dependency-name: "@testing-library/react" 36 | - dependency-name: "@types/jest" 37 | - dependency-name: "@types/lodash" 38 | - dependency-name: "@types/node" 39 | - dependency-name: "@types/react-router-dom" 40 | - dependency-name: react 41 | - dependency-name: react-dom 42 | - dependency-name: react-router-dom 43 | - dependency-name: rxjs 44 | - dependency-name: tslib 45 | - dependency-name: copy-webpack-plugin 46 | - dependency-name: css-loader 47 | - dependency-name: eslint-webpack-plugin 48 | - dependency-name: fork-ts-checker-webpack-plugin 49 | - dependency-name: glob 50 | - dependency-name: identity-obj-proxy 51 | - dependency-name: jest 52 | - dependency-name: jest-environment-jsdom 53 | - dependency-name: prettier 54 | - dependency-name: replace-in-file-webpack-plugin 55 | - dependency-name: sass 56 | - dependency-name: sass-loader 57 | - dependency-name: style-loader 58 | - dependency-name: swc-loader 59 | - dependency-name: ts-node 60 | - dependency-name: tsconfig-paths 61 | - dependency-name: typescript 62 | - dependency-name: webpack 63 | - dependency-name: webpack-cli 64 | - dependency-name: webpack-livereload-plug 65 | -------------------------------------------------------------------------------- /.github/issue_commands.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "label", 4 | "name": "datasource/ClickHouse", 5 | "action": "addToProject", 6 | "addToProject": { 7 | "url": "https://github.com/orgs/grafana/projects/190" 8 | } 9 | }, 10 | { 11 | "type": "label", 12 | "name": "datasource/ClickHouse", 13 | "action": "removeFromProject", 14 | "addToProject": { 15 | "url": "https://github.com/orgs/grafana/projects/190" 16 | } 17 | }, 18 | { 19 | "type": "label", 20 | "name": "type/docs", 21 | "action": "addToProject", 22 | "addToProject": { 23 | "url": "https://github.com/orgs/grafana/projects/69" 24 | } 25 | }, 26 | { 27 | "type": "label", 28 | "name": "type/docs", 29 | "action": "removeFromProject", 30 | "addToProject": { 31 | "url": "https://github.com/orgs/grafana/projects/69" 32 | } 33 | } 34 | ] 35 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: Copy the following lines for the CHANGELOG 4 | labels: 5 | - changelog 6 | - title: Hidden 7 | exclude: 8 | labels: 9 | - '*' 10 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-reviewer.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot reviewer 2 | 3 | on: pull_request 4 | 5 | permissions: 6 | pull-requests: write 7 | contents: write 8 | 9 | jobs: 10 | call-workflow-passing-data: 11 | uses: grafana/security-github-actions/.github/workflows/dependabot-automerge.yaml@main 12 | with: 13 | repository-merge-method: squash 14 | # Add this to define production packages that dependabot can auto-update if the bump is minor 15 | packages-minor-autoupdate: '["@grafana/data","@grafana/ui","@grafana/runtime","@grafana/e2e","@grafana/e2e-selectors"]' 16 | secrets: inherit -------------------------------------------------------------------------------- /.github/workflows/detect-breaking-changes.yml: -------------------------------------------------------------------------------- 1 | name: Compatibility check 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | compatibilitycheck: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | with: 10 | persist-credentials: false 11 | 12 | - uses: actions/setup-node@v4.0.3 13 | with: 14 | node-version-file: '.nvmrc' 15 | - name: Install dependencies 16 | run: yarn install 17 | - name: Build plugin 18 | run: yarn build 19 | - name: Compatibility check 20 | uses: grafana/plugin-actions/is-compatible@f567fc6454619e6c8dbc2f91692197457c10a02b 21 | with: 22 | module: './src/module.ts' 23 | comment-pr: 'yes' 24 | skip-comment-if-compatible: 'yes' 25 | fail-if-incompatible: 'no' 26 | targets: '@grafana/data,@grafana/ui,@grafana/runtime,@grafana/e2e-selectors' 27 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [main, master] 5 | pull_request: 6 | branches: [main, master] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | persist-credentials: false 15 | 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: '20' 19 | cache: 'yarn' 20 | 21 | - uses: actions/setup-go@v5 22 | with: 23 | go-version: 'stable' 24 | 25 | - name: Build backend 26 | uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b 27 | with: 28 | args: buildAll 29 | version: latest 30 | 31 | - name: Install frontend dependencies 32 | run: yarn install --frozen-lockfile 33 | 34 | - name: Build frontend 35 | run: yarn build 36 | env: 37 | NODE_OPTIONS: '--max_old_space_size=4096' 38 | 39 | - name: Install Playwright Browsers 40 | run: yarn playwright install --with-deps 41 | 42 | - name: Install and run Docker Compose 43 | uses: hoverkraft-tech/compose-action@8be2d741e891ac9b8ac20825e6f3904149599925 44 | with: 45 | compose-file: './docker-compose.yml' 46 | 47 | - name: Wait for Grafana to start 48 | run: | 49 | curl http://localhost:3000 50 | #RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000) 51 | #echo $response 52 | #if [ $RESPONSE -ne 200 ]; then 53 | # sleep 5; 54 | # echo "Grafana is not accessible" 55 | # exit 1 56 | #fi 57 | 58 | - name: Run Playwright tests 59 | run: yarn playwright test 60 | 61 | - uses: actions/upload-artifact@v4 62 | if: ${{ !cancelled() }} 63 | with: 64 | name: playwright-report 65 | path: playwright-report/ 66 | retention-days: 30 67 | -------------------------------------------------------------------------------- /.github/workflows/grafana-bench.yml: -------------------------------------------------------------------------------- 1 | name: Grafana Bench 2 | on: 3 | push: 4 | # Only run on push to the main branch 5 | branches: [main, master] 6 | pull_request: 7 | branches: [main, master] 8 | 9 | jobs: 10 | test: 11 | timeout-minutes: 60 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | persist-credentials: false 17 | 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: '20' 21 | cache: 'yarn' 22 | 23 | - uses: actions/setup-go@v5 24 | with: 25 | go-version: 'stable' 26 | 27 | - name: Build backend 28 | uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b 29 | with: 30 | args: buildAll 31 | version: latest 32 | 33 | - name: Install frontend dependencies 34 | run: yarn install --frozen-lockfile 35 | 36 | - name: Build frontend 37 | run: yarn build 38 | env: 39 | NODE_OPTIONS: '--max_old_space_size=4096' 40 | 41 | - name: Install and run Docker Compose 42 | uses: hoverkraft-tech/compose-action@8be2d741e891ac9b8ac20825e6f3904149599925 43 | with: 44 | compose-file: './docker-compose.yml' 45 | 46 | - name: Ensure Grafana is running 47 | run: | 48 | curl http://localhost:3000 49 | 50 | - name: Run Grafana Bench tests 51 | run: | 52 | docker run --rm \ 53 | --network=host \ 54 | --volume="./:/home/bench/tests/" \ 55 | us-docker.pkg.dev/grafanalabs-global/docker-grafana-bench-prod/grafana-bench:v0.3.0 test \ 56 | --test-runner "playwright" \ 57 | --test-suite-base "/home/bench/tests/" \ 58 | --grafana-url "http://localhost:3000" \ 59 | --pw-execute-cmd "yarn e2e" \ 60 | --pw-prepare-cmd "yarn install --frozen-lockfile; yarn playwright install" \ 61 | --test-env-vars "CI=true" \ 62 | --log-level DEBUG 63 | -------------------------------------------------------------------------------- /.github/workflows/issue_commands.yml: -------------------------------------------------------------------------------- 1 | name: Run commands when issues are labeled 2 | on: 3 | issues: 4 | types: [labeled] 5 | jobs: 6 | main: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout Actions 10 | uses: actions/checkout@v4 11 | with: 12 | repository: 'grafana/grafana-github-actions' 13 | path: ./actions 14 | ref: main 15 | persist-credentials: false 16 | - name: Install Actions 17 | run: npm install --production --prefix ./actions 18 | - name: Run Commands 19 | uses: ./actions/commands 20 | with: 21 | token: ${{secrets.ISSUE_COMMANDS_TOKEN}} 22 | configPath: issue_commands 23 | -------------------------------------------------------------------------------- /.github/workflows/run-backend-tests.yml: -------------------------------------------------------------------------------- 1 | name: Backend unit tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - v1 7 | - main 8 | pull_request: 9 | branches: 10 | - v1 11 | - main 12 | schedule: 13 | - cron: '0 9 1 * *' 14 | 15 | jobs: 16 | run: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: true 20 | matrix: 21 | clickhouse: 22 | - 22.3 23 | - 22.8 24 | - 22.9 25 | - '22.10' 26 | - 22.11 27 | - latest 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | with: 32 | persist-credentials: false 33 | 34 | - name: Install Go 35 | uses: actions/setup-go@v5 36 | with: 37 | go-version: 'stable' 38 | 39 | - name: Build backend 40 | uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b 41 | with: 42 | args: buildAll 43 | version: latest 44 | 45 | - name: Run backend tests 46 | run: CLICKHOUSE_VERSION=${{ matrix.clickhouse }} go test -v ./... 47 | -------------------------------------------------------------------------------- /.github/workflows/run-frontend-tests.yml: -------------------------------------------------------------------------------- 1 | name: Frontend unit tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - v1 7 | - main 8 | pull_request: 9 | branches: 10 | - v1 11 | - main 12 | schedule: 13 | - cron: '0 9 1 * *' 14 | 15 | jobs: 16 | run: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: true 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | with: 24 | persist-credentials: false 25 | 26 | - name: Setup Node.js environment 27 | uses: actions/setup-node@v4.0.3 28 | with: 29 | node-version-file: '.nvmrc' 30 | 31 | - name: Install yarn dependencies 32 | run: yarn install 33 | env: 34 | NODE_OPTIONS: '--max_old_space_size=4096' 35 | 36 | - name: Check types 37 | run: yarn typecheck 38 | 39 | - name: Build Frontend 40 | run: yarn build 41 | env: 42 | NODE_OPTIONS: '--max_old_space_size=4096' 43 | -------------------------------------------------------------------------------- /.github/zizmor.yml: -------------------------------------------------------------------------------- 1 | # This is also used as the default configuration for the Zizmor reusable 2 | # workflow. 3 | 4 | rules: 5 | unpinned-uses: 6 | config: 7 | policies: 8 | actions/*: any # trust GitHub 9 | grafana/*: any # trust Grafana -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | node_modules/ 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # Compiled binary addons (https://nodejs.org/api/addons.html) 23 | dist/ 24 | artifacts/ 25 | work/ 26 | ci/ 27 | e2e-results/ 28 | test_summary.json 29 | 30 | # Editor 31 | .idea 32 | 33 | pkg/__debug_bin 34 | 35 | **/.DS_Store 36 | .eslintcache 37 | /test-results/ 38 | /playwright-report/ 39 | /blob-report/ 40 | /playwright/.cache/ 41 | /playwright/.auth/ 42 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require("./.config/.prettierrc.js") 4 | }; -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run standalone plugin", 6 | "type": "go", 7 | "request": "launch", 8 | "mode": "auto", 9 | "program": "${workspaceFolder}/pkg/", 10 | "env": {}, 11 | "args": ["--standalone=true"] 12 | }, 13 | { 14 | "name": "Debug in Container", 15 | "type": "go", 16 | "request": "attach", 17 | "mode": "remote", 18 | "remotePath": "/var/lib/grafana/plugins/grafana-k6-app/", 19 | "port": 2345, 20 | "host": "127.0.0.1", 21 | "apiVersion": 1, 22 | "trace": "verbose" 23 | }, 24 | { 25 | "name": "Debug Jest test", 26 | "type": "node", 27 | "request": "launch", 28 | "runtimeExecutable": "yarn", 29 | "runtimeArgs": ["run", "jest", "--runInBand", "${file}"], 30 | "console": "integratedTerminal", 31 | "internalConsoleOptions": "neverOpen", 32 | "port": 9229 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ClickHouse Datasource 2 | 3 | Thank you for your interest in contributing to this repository. We are glad you want to help us to improve the project and join our community. Feel free to [browse the open issues](https://github.com/grafana/clickhouse-datasource/issues). If you want more straightforward tasks to complete, [we have some](https://github.com/grafana/clickhouse-datasource/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). For more details about how you can help, please take a look at [Grafana’s Contributing Guide](https://github.com/grafana/grafana/blob/main/CONTRIBUTING.md). 4 | 5 | ## Development setup 6 | 7 | ### Getting started 8 | 9 | Clone this repository into your local environment. The frontend code lives in the `src` folder, alongside the [plugin.json file](https://grafana.com/docs/grafana/latest/developers/plugins/metadata/). The backend Go code is in the `pkg` folder. To build this plugin refer to [Build a plugin](https://grafana.com/docs/grafana/latest/developers/plugins/) 10 | 11 | ### Running the development version 12 | 13 | Before you can set up the plugin, you need to set up your environment by following [Set up your environment](https://grafana.com/tutorials/build-a-data-source-backend-plugin/#set-up-your-environment). 14 | 15 | #### Compiling the backend 16 | 17 | You can use [mage](https://github.com/magefile/mage) to compile and test the Go backend. 18 | 19 | ```sh 20 | mage test # run all Go test cases 21 | mage build:backend && mage reloadPlugin # builds and reloads the plugin in Grafana 22 | ``` 23 | 24 | #### Compiling the frontend 25 | 26 | You can build and test the frontend by using `yarn`: 27 | 28 | ```sh 29 | yarn test # run all test cases 30 | yarn dev # builds and puts the output at ./dist 31 | ``` 32 | 33 | You can also have `yarn` watch for changes and automatically recompile them: 34 | 35 | ```sh 36 | yarn watch 37 | ``` 38 | 39 | #### Running E2E tests locally 40 | 41 | 1. Install [K6](https://k6.io/docs/get-started/installation/) 42 | 2. Run `yarn test:e2e:local` 43 | 44 | ## Create a pull request 45 | 46 | Once you are ready to make a pull request, please read and follow [Create a pull request](https://github.com/grafana/grafana/blob/master/contribute/create-pull-request.md). 47 | 48 | ## Build a release for the ClickHouse data source plugin 49 | 50 | You need to have commit rights to the GitHub repository to publish a release. 51 | 52 | 1. Update the version number in the `package.json` file. 53 | 2. Update the `CHANGELOG.md` by copy and pasting the relevant PRs 54 | from [GitHub's Release drafter interface](https://github.com/grafana/clickhouse-datasource/releases/new) or by 55 | running `npm run generate-release-notes`. 56 | 3. PR the changes. 57 | 4. Once merged, follow the Drone release process that you can find [here](https://github.com/grafana/integrations-team/wiki/Plugin-Release-Process#drone-release-proces 58 | -------------------------------------------------------------------------------- /Magefile.go: -------------------------------------------------------------------------------- 1 | //+build mage 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | // mage:import 8 | build "github.com/grafana/grafana-plugin-sdk-go/build" 9 | ) 10 | 11 | // Hello prints a message (shows that you can define custom Mage targets). 12 | func Hello() { 13 | fmt.Println("hello plugin developer!") 14 | } 15 | 16 | // Default configures the default target. 17 | var Default = build.BuildAll 18 | -------------------------------------------------------------------------------- /config-secure/my-own-ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDEDCCAfigAwIBAgIUeyhiP/kbQCZacNPgv+BdoUBN6PcwDQYJKoZIhvcNAQEL 3 | BQAwDzENMAsGA1UEAwwEcm9vdDAeFw0yMTEyMTYyMDU5MjVaFw0zMTEyMTQyMDU5 4 | MjVaMA8xDTALBgNVBAMMBHJvb3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK 5 | AoIBAQDNU3zvxLjeTDnRi2Vlqd3UaO8ViV/jjQwj6Gtx7QceVe034URS7Zq8WlsV 6 | Hjn8a2e6ygebjy02Ri+fpG/NbHWOpS58VJnFAXT+iSrvyr6PHPFNx7ncNiNrwvHs 7 | 9YuD0njOWrj0JpAa8oxGm1NOpqCUg0ytwHwF4YJ9Lk9f2F6heRhlvFECWf8k58rR 8 | UI/N0eOrjp3IDpYGupfa07Sug+EExpg8/CV1mV2HHnvvcLWRMsn4yJD6gUu64anA 9 | j1LklYEFyBUUnv7L7EFCH9wOx4QZSQOWrOHVh5l60Ib/6kUm4NYW8f1MY64Cd5WZ 10 | Y5+JWGoU2wEk1kKXUfx0RGFRcHSrAgMBAAGjZDBiMB0GA1UdDgQWBBTIAlr/Acx3 11 | lsu9BqF2+GALhGlJDTAfBgNVHSMEGDAWgBTIAlr/Acx3lsu9BqF2+GALhGlJDTAP 12 | BgNVHRMBAf8EBTADAQH/MA8GA1UdEQQIMAaCBHJvb3QwDQYJKoZIhvcNAQELBQAD 13 | ggEBAMmpw+w0k3U0faH0ldyDiIZiyP+4u6VS9CEwaeB3hX5dGQr/Ya0ibqCZRKCG 14 | hp4tnMNwPPULV/P4uyM08yLi9oIP+Dm457xe7DCe7Eg87l+EZeVZY7oG3HgXMU29 15 | t9cN3N7OVqKh4XpsZw4YmaXVB5KQx+TNYjS/4+pJEXNSP+ntDckAAuXbLoj7NSEu 16 | V/uJGOMTLYgk5R1tL4+HM80lrHlGrmwsc0RiATGVFPxIZQZ5X61RSjbA+VcHncw5 17 | p+HrrnmPCAxET+B/lL6NhaIKUQmRnbdAnT+4dyKRfBYiwy+pk9N3pjqOoQAM6/Cz 18 | kdPUedu68KypMieaM23RTg0posg= 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /config-secure/my-own-ca.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDNU3zvxLjeTDnR 3 | i2Vlqd3UaO8ViV/jjQwj6Gtx7QceVe034URS7Zq8WlsVHjn8a2e6ygebjy02Ri+f 4 | pG/NbHWOpS58VJnFAXT+iSrvyr6PHPFNx7ncNiNrwvHs9YuD0njOWrj0JpAa8oxG 5 | m1NOpqCUg0ytwHwF4YJ9Lk9f2F6heRhlvFECWf8k58rRUI/N0eOrjp3IDpYGupfa 6 | 07Sug+EExpg8/CV1mV2HHnvvcLWRMsn4yJD6gUu64anAj1LklYEFyBUUnv7L7EFC 7 | H9wOx4QZSQOWrOHVh5l60Ib/6kUm4NYW8f1MY64Cd5WZY5+JWGoU2wEk1kKXUfx0 8 | RGFRcHSrAgMBAAECggEAIYapVskrWnjd0/5L3y6+XumHaF/W/WPRgKd5q8+FIwnq 9 | wv4QVu4fHvQt/SPDWhj7hf9pAJh/TGZnbky+SK+V/mWwUnLJ7OYRAWLKVP8o4Ftc 10 | d1POYEuiuvzI3eU2E58xRJiBrLQDQbMq/nhsQOJqQ/WwdoqAdcFduizunvrIcNr3 11 | IB4uUpwhvQSt8ve7BET+/rmFypHP9Ck7wvUq4QkVSKgvKrLbGwQi6gmJlf1UnDGw 12 | EHIwSfhSmKgoney4wlX3G3tk6KdxFfSA+RBTcPGRfKGqa/zWYPQ8eabQGXSkcIxS 13 | oXwJ9f4UE1vHMXveth7tPAxN33Zfx1RB6IVRPt4jSQKBgQDrYkmoT7EbCGa8GkWV 14 | m+0ijBdcwQx7ohDYi5G3smN17pDUDvaHPh/bOoEdB7cxJJ0f2UHqah6Ha7pQt55q 15 | +sfo1Tjfs53JyFFTQLmCa3HAzarggB5cPTfRDRb5CKWiMPxBkvYjVjvV/r9iseZy 16 | ojY3SGg5ezhadUuLWYi0wbE1vQKBgQDfT0CmPSObkr7Z8waHLFG3odVzT7IdsfpM 17 | QmOOXC2p/I2p5CWt9goZ60kA2exQvOkyjYSTX7xYTB8QWthkdH3G2k4UrM7tWu8X 18 | sI6NeQPXGU3Th0yzb7k5qKoI0/bFC9YBuok/5Y1Y8vP4HoKmKzQDN2UrbVMwgc/e 19 | JpfwTph2hwKBgEyhewloqGf8nDWw9+Z1FQaiRRjVYJL/eCyHg7EiSm8ic9QV6vys 20 | pQJiUZZ55JIDMYQk3ujKE5ZS5B1TKif57QtIH3P0rfH7XT6VW8+x2x7B1lewXjH5 21 | XCqa8FezEPl0qStQBQIMGP7aKMSg1j2LwcrNr+DG1NneRfHf/DmctWyhAoGAeUrl 22 | 1aXNynnJmj5rpE5JUJHhi5GVMJX0WymQQ8oDr5oTJF1crgG++NcYvxKfTjdd/uxp 23 | P1c3yUoHcW22rdGsY689y/MVLk0/IsHunB9IG7SN1kBeQ/SCSjQ3rzXaiqrkIeo9 24 | FGzN+qt0IqgH1NQQm1KibBUko2tPCd4ylv9Jxs8CgYAG6XKpW30J5m1BiJVL8lx4 25 | DTfhOvbtXiq/n5xHgqu81ven8b9MURqOkOlCnMrNatySaZgMYfCzAL+EARWgVOeI 26 | pGmXLNEDGhPnv4IrZbIiVXIzTntufQZybaJRPSYQ+r8GBDSZtNVlwPHe/up+906q 27 | H6PEd7NnDbL2TOr57Hfsow== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /config-secure/my-own-ca.srl: -------------------------------------------------------------------------------- 1 | F1882B6FB9749F92 2 | -------------------------------------------------------------------------------- /config-secure/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDFTCCAf2gAwIBAgIUT3+4BMdujNm5CYsgrvGHHq+1CtIwDQYJKoZIhvcNAQEL 3 | BQAwDzENMAsGA1UEAwwEcm9vdDAeFw0yMTEyMTYyMjE0MTJaFw0yNDAzMjAyMjE0 4 | MTJaMA4xDDAKBgNVBAMMA2ZvbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC 5 | ggEBAOVt8A/EdMGRPAIB+jEqAeLlXJFgDgGr5cyfDMX2Em8B1WGkz8rTrQmJJdGr 6 | yjKICQxAjcNoyB4XfRuoPtdJV+z+VzlvOYn5NB6Tiq+2ZYbpXMexX6lAwsYZuqvb 7 | FR0Y5BOGLKhUz7BzyZiC+GMNesPKwKBscA+82LKS446d+XrWxC5oirTnho7jj/y9 8 | SIBP7OPz5tewEMZnS2Tw8a/nhoVl0jXsbV9PwmfPtwepDN5wpSZuN56qHYBpnmz/ 9 | P6XUmIs/BeymY8tlqQ6n1PaXt4G0UVnqM8GY9WUxZ3oYbNH1kyoey4OjuwzJCCV1 10 | ldm+2maGTYJ8xRgyFC/b0gM/pvcCAwEAAaNqMGgwHwYDVR0jBBgwFoAUyAJa/wHM 11 | d5bLvQahdvhgC4RpSQ0wCQYDVR0TBAIwADALBgNVHQ8EBAMCBPAwDgYDVR0RBAcw 12 | BYIDZm9vMB0GA1UdDgQWBBR5qSjNuOQvBcSf09EE3V8WHCcnuDANBgkqhkiG9w0B 13 | AQsFAAOCAQEAHVAXj2a+s2o7mfPm26heUp/EXJfD9dNDIjBxxb8JCk8MHRMk+0tP 14 | 0PkpfCreg1TU5aUIjnIKPw5GmTK6QiDvtuwgz2pyMVqHQ2LZiKRc7sZWbhY42H21 15 | 4qfJmsheoohUyyib+hwRpektNNyuMJEDHTAzZ/5/H5kvgY4WR8mNLOHepG8JZure 16 | +P8ACffg6xx0zPqkH58TOWnBi9gwjLjSOteUnamd8XdP7mCMLYCnuV7lIaxtTspv 17 | TyCqZMgNme5rf/oP/F9O4IZPgjdPAmYDDwsJtCAnlB72FFvO4+7lOw2nl2t1DP/K 18 | MUBEB1R3Z6mmh7HqMqu1V4yLov2vJL7+aQ== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /config-secure/server.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICUzCCATsCAQAwDjEMMAoGA1UEAwwDZm9vMIIBIjANBgkqhkiG9w0BAQEFAAOC 3 | AQ8AMIIBCgKCAQEA5W3wD8R0wZE8AgH6MSoB4uVckWAOAavlzJ8MxfYSbwHVYaTP 4 | ytOtCYkl0avKMogJDECNw2jIHhd9G6g+10lX7P5XOW85ifk0HpOKr7Zlhulcx7Ff 5 | qUDCxhm6q9sVHRjkE4YsqFTPsHPJmIL4Yw16w8rAoGxwD7zYspLjjp35etbELmiK 6 | tOeGjuOP/L1IgE/s4/Pm17AQxmdLZPDxr+eGhWXSNextX0/CZ8+3B6kM3nClJm43 7 | nqodgGmebP8/pdSYiz8F7KZjy2WpDqfU9pe3gbRRWeozwZj1ZTFnehhs0fWTKh7L 8 | g6O7DMkIJXWV2b7aZoZNgnzFGDIUL9vSAz+m9wIDAQABoAAwDQYJKoZIhvcNAQEL 9 | BQADggEBALMPoIT9ppQ8bXa1Dke3V1BbEXz5oI483NR6KIdKspqOewure2GddnmQ 10 | 5ej5mo+X7Jqo8iYrA/UwxZ6Nu26GkfS7YxPy7dJa1QNwaSTnq4eQtFzzg8crcGcX 11 | 8GSsEY0JVv2ZgJ921ov0INxpkas9WA/Whs2G1+yhj8NrGSySxpxRtn8xJyuSqJLI 12 | NrshzQkaqyLHT5PtjlWRk4zWFZx9dXQY18zu7Tia4JXfH4xq+eAFYhaZv8rhKDef 13 | ibhexj+lkIOVh4EpUdhqrsfRhjYi9mv+9K88C6rVuYkG8Ln/JVG2TrvXfLgBWqtT 14 | uT1cXxOTN4ilH7Sckf/YHPTEd5nn8go= 15 | -----END CERTIFICATE REQUEST----- 16 | -------------------------------------------------------------------------------- /config-secure/server.ext: -------------------------------------------------------------------------------- 1 | authorityKeyIdentifier=keyid,issuer 2 | basicConstraints=CA:FALSE 3 | keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment 4 | subjectAltName = @alt_names 5 | 6 | [alt_names] 7 | DNS.1 = foo -------------------------------------------------------------------------------- /config-secure/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDlbfAPxHTBkTwC 3 | AfoxKgHi5VyRYA4Bq+XMnwzF9hJvAdVhpM/K060JiSXRq8oyiAkMQI3DaMgeF30b 4 | qD7XSVfs/lc5bzmJ+TQek4qvtmWG6VzHsV+pQMLGGbqr2xUdGOQThiyoVM+wc8mY 5 | gvhjDXrDysCgbHAPvNiykuOOnfl61sQuaIq054aO44/8vUiAT+zj8+bXsBDGZ0tk 6 | 8PGv54aFZdI17G1fT8Jnz7cHqQzecKUmbjeeqh2AaZ5s/z+l1JiLPwXspmPLZakO 7 | p9T2l7eBtFFZ6jPBmPVlMWd6GGzR9ZMqHsuDo7sMyQgldZXZvtpmhk2CfMUYMhQv 8 | 29IDP6b3AgMBAAECggEABzYgnp7fKz99rrjH4aNYCZqdxl4E+0133z2LNKmUcyKQ 9 | Rc3c95Gf9bhFhinBrrF33moRL5YahldOvjXEuvjts8c0N1vT59OFaHJS26+XH+hu 10 | 5S5JNjQkt3Smu91L1vIa/599xcdbwFJN2Zy3CejHrBz3Q713SkMJ/NIGABDekr3H 11 | opXiz8XPnMnxprCMlDSLWZV7NXILCdOnUnvZStzxr9/g3L/vA2ja9wvtYJQYf44Y 12 | 3hASo6wAd4mCIW/s/yJXEp6Hzxs0AKeAP3rB/Q4NCvPf77SASPxYySACDRCJYTS1 13 | f+38MhinS2wUOYtd37Byx051ip7Udym3KQ2cLQrY3QKBgQDnvCeDvmBhx/XDUXYS 14 | 9WrcZnL/BDVxPEc4ZMMfLlXVHtPNae+BBQusXVsAaCz/J3EzXq7T6b8we5tyLy8Y 15 | mxjrL6CEMFRPDN9G78clDhMoruEe912BDPXT2zfm21s2oX2n6UwIyXg+JOQBIFZ/ 16 | /MTBAr0yODxwMfcb+nKPtUYIZQKBgQD9c/tOzqUB9Bwh7a2FNEKnthCBmIXKb2Hq 17 | jmic11YxYLdyys8P4/4N12SChJeXAm4RUaCu19I4l3xDzY7OjlmlnqSHVbncPvKP 18 | zuH1L4uOxUBI+76REmyGzomXPQ7rV2OAvOEkSpwKkajHegc0zO3wS4lzBvt8xyOq 19 | jTwPyFRmKwKBgFiUMFqIe9kEkSmuyr5mdwl2U8CtACyfiO3Cfl892+tSFE3xj242 20 | 2oZxTOaz63dAwWGMcLFqKP3EUd/sr0jtiDHmC6pbuu5YkkRQRUQhxCsJ5d1rWp+I 21 | r7LimdSxxoT0Z862O60kLcU7Xrgbf1T+7sqEXIOEwX11a+qS6hWKihGNAoGBAOYA 22 | Z3GHw2Q3c4QynUIBP+/UH7yLffZMB66El1ilbZmXrEJm22sPOlCzQ4nR64LleJ8M 23 | 1WV1g1dJ2UHqe4rk0WOjyKjr2aOOGC76zkDjaaEhTYotsi0SbBwVt/TgOvbEsg50 24 | 2VdGwb4xmtmS2pFG2zIySkRxdK0yRiKS0ot7/2NLAoGAJ4wqdfPVGH9aNd1JP5Kh 25 | MUH+UB8IJYehrfSeYxVp/wmcaSyZNZALueFyFn0rdKaLseOxc/OjOg4xr9tM5z5+ 26 | hFstZwRHjt0Yb2xAyCzWBhYDpP3RZ81S3HAUtb7GL6RAvcpgXBoOgV7r6yiZMYK5 27 | 4ePsQTp/Xv9Vg2KvwQpJyCU= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /config/admin.21.8.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 1 8 | 9 | 10 | 11 | 12 | 1 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /config/admin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 1 8 | 9 | 10 | 11 | 12 | 1 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /config/config-preprocessed.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8443 8 | 9440 9 | 14 | 0.0.0.0 15 | 16 | 17 | 18 | 19 | /etc/clickhouse-server/server.crt 20 | /etc/clickhouse-server/server.key 21 | 22 | none 23 | true 24 | true 25 | sslv2,sslv3 26 | true 27 | 28 | 29 | -------------------------------------------------------------------------------- /config/custom.21.8.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | :: 4 | 0.0.0.0 5 | 1 6 | 7 | 1 8 | 9 | 10 | -------------------------------------------------------------------------------- /config/custom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | :: 4 | 0.0.0.0 5 | 1 6 | 7 | 1 8 | 9 | UTC 10 | 11 | 12 | -------------------------------------------------------------------------------- /config/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICqjCCAZICCQCmreUKLiQrrzANBgkqhkiG9w0BAQsFADAXMRUwEwYDVQQDDAxt 3 | eS5ob3N0Lm5hbWUwHhcNMjExMjE1MjAzMTU2WhcNMjIxMjE1MjAzMTU2WjAXMRUw 4 | EwYDVQQDDAxteS5ob3N0Lm5hbWUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK 5 | AoIBAQDBt3kbY3SwS89oNQhllMyK+AumX9d01bbU1ZFcLJcn9wyLK+wSFJTe6V8f 6 | hiHPZosnDcOWkspw/SBAVDCyvAx0QgjgkVj4JgCjxdXl25R9Ouz582hhTqqjpBCW 7 | n1ZKRhkau6ASHz4JF0MyemIdYB8DgnUKRfYnWtNrXS91RE/8H0U6s8kO3Msbj8GG 8 | ubTBHOPWEJt6VAnWzVXXAGCNopNLF3Z7UUmJ7IMiTm923eGP0zZzvj5kMFLBiTm3 9 | sHtSN39PJ6evJ7oeO+l+JOtBkuaA3yYuPcAHNHpK+SffPulZpx7VxmY2ot7696xM 10 | kq2wl9uswOLCrn87hum2M+Bhy673AgMBAAEwDQYJKoZIhvcNAQELBQADggEBACA/ 11 | VcLExf/XOmbueWvNg7IkBaUWL5waZf+MIlAuwaYjAsKSM/hYSY/g480XXFjN9Urm 12 | LJgIJ2T3EhGzqK+wdOITbEE7O1MM28TUex8giMKrl53XSbz2ni8dClehUJFch99D 13 | +tBVjihLJC+GP+M/jfCiWV45+MOc+Mqkz92MVNmltfhklH00IBz/a0RdD0XqdyqB 14 | Ar/U19CgbZ7D/UAyjXnYjU1ucUBpMOmvfwdIABneeyLCzWQtH0vMaTrNmLsn6QwB 15 | C2nTdEeDIim9SZkRk5Fm1N4Ya6gWdP5PV6+yXdYSw/DtYsjwbNUfQMqiJVdQKQDM 16 | cuG+FV1/eq4dnDOYw3U= 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /config/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDBt3kbY3SwS89o 3 | NQhllMyK+AumX9d01bbU1ZFcLJcn9wyLK+wSFJTe6V8fhiHPZosnDcOWkspw/SBA 4 | VDCyvAx0QgjgkVj4JgCjxdXl25R9Ouz582hhTqqjpBCWn1ZKRhkau6ASHz4JF0My 5 | emIdYB8DgnUKRfYnWtNrXS91RE/8H0U6s8kO3Msbj8GGubTBHOPWEJt6VAnWzVXX 6 | AGCNopNLF3Z7UUmJ7IMiTm923eGP0zZzvj5kMFLBiTm3sHtSN39PJ6evJ7oeO+l+ 7 | JOtBkuaA3yYuPcAHNHpK+SffPulZpx7VxmY2ot7696xMkq2wl9uswOLCrn87hum2 8 | M+Bhy673AgMBAAECggEAOOWbs5itoE5T9+aDtdpTjYm3WkGSNeXDkpW74RfTudBN 9 | Jd9bsh/LbgGbh9XMvm7+9hSL2wD4ZuFiBKL1vrmO6uKuWs82E4SN8Yxc++tXnMSe 10 | 7/c3NEV3xyKcILFiFeSq4Pg01r3IacEkYoIhqUEfOtepasALwZliuYkgNFBBMeq1 11 | jQWYs5k5h49etBc64ut+J++gFv77uJcmfetacKe4VxabX2iExW5SLmJQXLROC3o+ 12 | 9+r4bIt/jgufvODWZem0EeHWPSoL8CnT8/YCJCiX/cynyqt/LAEZGnqfHRKW3spI 13 | C/igOqzYM+86XLDG9HnV2jFGVU91euSQyLbAmLYYOQKBgQD7uQBAWCwRM6vb/6tq 14 | tga+wUhaHtynrvuLIg0lw9tUGzSxPIp9/FIGfVKhOuPBaNxRIIrInazorBwQxtFd 15 | cvCE+xTT2wcYRos5WxuSYiszIl4ltXWmzQZDnmcUubCcrJ9qfI3Q+dTFb0C0HYTz 16 | 01WQAeqsEh3dALPVruEshHKcIwKBgQDFAiUWglhL/TyxcOZgyISbzJUvb0t1lA05 17 | yE2DsgKaIX8gFHIumCl15G2tqfDb+BfwWRfs/2VX187VYy97MoRNM9Lh4tUyWddV 18 | oyZrE8t/T2MfoMF6QMKX7MU6y9eUdgIsklilJyOu2QFLu06hvF9S43bBxFgMLm3L 19 | W2UJz/x1HQKBgBLLLC6hpqCeJ/2j6AtujbBeQ+WemkDWuqcXor2oEs8DvPpil8By 20 | PzmGz82D1Q9SoehYsqPpycgRWYMTJPyCIVz8VgC/QJdaZPiiSbuzIqCNt1O/aYpL 21 | kmUoBXAxsPLxnHFZ3Ui17mHTPZR1A8EkjSXUTs4MCDjA3axdgyhMtzXbAoGBAI4e 22 | Hv0e6G1g8GCcrkSRQkBWFCTU552ZQPU3Dtv7FS91DIzq0vfT4szeDVTjLBKy5SoI 23 | S183WjdFQjrjQ0RfS9uZj/5NsTiSYOmxOSyzafCcJ0iQoiH8B6SrNBhXJlw9yRG4 24 | PORe2LnwZ6PnKjE4f5d+6ZOcfVvEPoYdl0S92kPtAoGAMrWc/n/0dKAV9CSmLXS0 25 | AAqCtXw4OJNQBjCKks4DncWoopWvGHinVJAQYPrdnDO8iKcDYm6Kg+fVo0MWCha9 26 | opWIu4hgbcsH+wzTrgvUD4VlUdqAXqbDet6iZEu1IkSuGQqathAJk0ePa1swdl10 27 | N4SFUzUUwkhTFWW0BuMH0YU= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /cspell.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePaths": [ 3 | "node_modules/**", 4 | "coverage/**", 5 | "provisioning/**", 6 | "src/dashboards/**", 7 | "dist/**", 8 | "yarn.lock", 9 | "go.sum", 10 | "mage_output_file.go" 11 | ], 12 | "words": [ 13 | "subquery", 14 | "aggregatable", 15 | "aheads", 16 | "apikey", 17 | "CHSQL", 18 | "ClickHouse", 19 | "closedate", 20 | "commitish", 21 | "concats", 22 | "createddate", 23 | "dataframe", 24 | "DataSource", 25 | "DataSources", 26 | "DateTime", 27 | "dbug", 28 | "elazarl", 29 | "emerg", 30 | "endregion", 31 | "eror", 32 | "errorsource", 33 | "fixedstring", 34 | "fromtime", 35 | "goproxy", 36 | "Grafana", 37 | "grafanalabs", 38 | "groupable", 39 | "healthcheck", 40 | "ILIKE", 41 | "instancemgmt", 42 | "lowcardinality", 43 | "magefile", 44 | "mgbench", 45 | "Milli", 46 | "millis", 47 | "networkidle", 48 | "nofile", 49 | "nolint", 50 | "Nonproxy", 51 | "oper", 52 | "otel", 53 | "OUTFILE", 54 | "paulmach", 55 | "pgsql", 56 | "picklist", 57 | "PREWHERE", 58 | "proxying", 59 | "regexes", 60 | "sdkproxy", 61 | "shopspring", 62 | "singlequote", 63 | "slvrtrn", 64 | "sqlds", 65 | "sqlutil", 66 | "stagename", 67 | "stretchr", 68 | "subresource", 69 | "sugg", 70 | "supress", 71 | "templating", 72 | "testcontainers", 73 | "testid", 74 | "timefilter", 75 | "timepicker's", 76 | "timerange", 77 | "timeseries", 78 | "timespan", 79 | "TLSCA", 80 | "totime", 81 | "traceid", 82 | "typecheck", 83 | "Ulimits", 84 | "uuidv", 85 | "vectorator", 86 | "WorkDir" 87 | ] 88 | } 89 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | container_name: grafana 4 | build: 5 | context: ./.config 6 | args: 7 | grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} 8 | grafana_version: ${GRAFANA_VERSION:-11.1.0} 9 | # remove the following line when developing to have backend debugger 10 | development: false 11 | ports: 12 | - 3000:3000 13 | volumes: 14 | - ./dist:/var/lib/grafana/plugins/grafana-clickhouse-datasource 15 | - ./provisioning:/etc/grafana/provisioning 16 | - .:/root/grafana-clickhouse-datasource 17 | healthcheck: 18 | test: ["CMD", "curl", "--fail", "http://localhost:3000/login"] 19 | interval: 1m30s 20 | timeout: 30s 21 | retries: 5 22 | start_period: 30s 23 | environment: 24 | - GF_LOG_LEVEL=debug 25 | - GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=true 26 | networks: 27 | - grafana 28 | 29 | clickhouse-server: 30 | image: clickhouse/clickhouse-server:${CLICKHOUSE_VERSION:-24.7-alpine} 31 | container_name: clickhouse-server 32 | ports: 33 | - 8123:8123 34 | - 9000:9000 35 | ulimits: 36 | nofile: 37 | soft: 262144 38 | hard: 262144 39 | healthcheck: 40 | test: ["CMD", "clickhouse-client", "--host", "clickhouse-server", "--query", "SELECT 1"] 41 | interval: 1m30s 42 | timeout: 30s 43 | retries: 5 44 | start_period: 30s 45 | networks: 46 | - grafana 47 | 48 | test-data-loader: 49 | image: clickhouse/clickhouse-server:${CLICKHOUSE_VERSION:-24.7-alpine} 50 | container_name: test-data-loader 51 | entrypoint: ["clickhouse-client", "--host", "clickhouse-server", "--queries-file", "/dev/shm/property-prices.sql"] 52 | depends_on: 53 | clickhouse-server: 54 | condition: service_healthy 55 | restart: on-failure 56 | volumes: 57 | - ./tests/fixtures/property-prices.sql:/dev/shm/property-prices.sql 58 | networks: 59 | - grafana 60 | 61 | networks: 62 | grafana: 63 | -------------------------------------------------------------------------------- /jest-runner-serial.js: -------------------------------------------------------------------------------- 1 | const JestRunner = require('jest-runner'); 2 | 3 | class SerialJestRunner extends JestRunner { 4 | constructor(...args) { 5 | super(...args); 6 | this.isSerial = true; 7 | // this.maxConcurrency = 1 8 | } 9 | } 10 | 11 | module.exports = SerialJestRunner; 12 | -------------------------------------------------------------------------------- /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": "clickhouse-datasource", 3 | "version": "4.9.0", 4 | "description": "Clickhouse Datasource", 5 | "engines": { 6 | "node": ">=20" 7 | }, 8 | "scripts": { 9 | "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 10 | "dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 11 | "e2e:report": "yarn playwright show-report", 12 | "e2e:ui": "yarn playwright test --ui", 13 | "e2e": "playwright test", 14 | "gen-dashboards": "node ./gen-db-dashboards", 15 | "lint:fix": "yarn run lint --fix", 16 | "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", 17 | "server": "docker compose up --build", 18 | "sign": "npx --yes @grafana/sign-plugin@latest", 19 | "spellcheck": "cspell -c cspell.config.json \"**/*.{ts,tsx,js,go,md,mdx,yml,yaml,json,scss,css}\"", 20 | "test:ci": "jest --passWithNoTests --maxWorkers 4", 21 | "test": "jest --watch --onlyChanged", 22 | "typecheck": "tsc --noEmit" 23 | }, 24 | "author": "Grafana Labs", 25 | "license": "Apache-2.0", 26 | "devDependencies": { 27 | "@babel/core": "^7.25.7", 28 | "@grafana/eslint-config": "^7.0.0", 29 | "@grafana/plugin-e2e": "^1.15.0", 30 | "@grafana/tsconfig": "^2.0.0", 31 | "@playwright/test": "^1.50.1", 32 | "@swc/core": "^1.7.26", 33 | "@swc/helpers": "^0.5.13", 34 | "@swc/jest": "^0.2.36", 35 | "@testing-library/dom": "^10.4.0", 36 | "@testing-library/jest-dom": "6.5.0", 37 | "@testing-library/react": "^16.0.1", 38 | "@testing-library/user-event": "^14.6.1", 39 | "@types/glob": "^8.1.0", 40 | "@types/jest": "^29.5.13", 41 | "@types/lodash": "^4.17.10", 42 | "@types/node": "^22.7.4", 43 | "@types/react-router-dom": "^5.3.3", 44 | "@types/webpack-livereload-plugin": "^2.3.6", 45 | "copy-webpack-plugin": "^12.0.2", 46 | "cspell": "^9.0.2", 47 | "css-loader": "^7.1.2", 48 | "eslint-plugin-deprecation": "^3.0.0", 49 | "eslint-webpack-plugin": "^4.2.0", 50 | "fork-ts-checker-webpack-plugin": "^9.0.2", 51 | "glob": "^11.0.0", 52 | "identity-obj-proxy": "3.0.0", 53 | "imports-loader": "^5.0.0", 54 | "jest": "^29.7.0", 55 | "jest-environment-jsdom": "^29.7.0", 56 | "prettier": "^3.3.3", 57 | "replace-in-file-webpack-plugin": "^1.0.6", 58 | "sass": "1.79.4", 59 | "sass-loader": "16.0.2", 60 | "style-loader": "4.0.0", 61 | "swc-loader": "^0.2.6", 62 | "terser-webpack-plugin": "^5.3.11", 63 | "ts-node": "^10.9.2", 64 | "tsconfig-paths": "^4.2.0", 65 | "typescript": "5.6.2", 66 | "webpack": "^5.95.0", 67 | "webpack-cli": "^5.1.4", 68 | "webpack-livereload-plugin": "^3.0.2", 69 | "webpack-subresource-integrity": "^5.1.0", 70 | "webpack-virtual-modules": "^0.6.2" 71 | }, 72 | "resolutions": { 73 | "rxjs": "^7.5.6" 74 | }, 75 | "dependencies": { 76 | "@emotion/css": "11.13.4", 77 | "@grafana/data": "^11.4.0", 78 | "@grafana/runtime": "^11.4.0", 79 | "@grafana/schema": "^11.2.2", 80 | "@grafana/ui": "^11.2.2", 81 | "js-sql-parser": "^1.6.0", 82 | "pgsql-ast-parser": "^12.0.1", 83 | "react": "18.3.1", 84 | "react-dom": "18.3.1", 85 | "react-router-dom": "5.3.4", 86 | "semver": "7.7.2", 87 | "sql-formatter": "^15.5.1", 88 | "tslib": "2.7.0" 89 | }, 90 | "packageManager": "yarn@1.22.22", 91 | "volta": { 92 | "node": "20.18.0", 93 | "yarn": "1.22.22" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /pkg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/grafana/clickhouse-datasource/pkg/plugin" 7 | "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 9 | ) 10 | 11 | func main() { 12 | if err := datasource.Manage("grafana-clickhouse-datasource", plugin.NewDatasource, datasource.ManageOpts{}); err != nil { 13 | log.DefaultLogger.Error(err.Error()) 14 | os.Exit(1) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pkg/plugin/datasource.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/grafana/grafana-plugin-sdk-go/backend" 7 | "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" 8 | "github.com/grafana/sqlds/v4" 9 | ) 10 | 11 | func NewDatasource(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { 12 | clickhousePlugin := Clickhouse{} 13 | ds := sqlds.NewDatasource(&clickhousePlugin) 14 | pluginSettings := clickhousePlugin.Settings(ctx, settings) 15 | if pluginSettings.ForwardHeaders { 16 | ds.EnableMultipleConnections = true 17 | } 18 | return ds.NewDatasource(ctx, settings) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/plugin/errors.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import "github.com/pkg/errors" 4 | 5 | var ( 6 | ErrorMessageInvalidJSON = errors.New("could not parse json") 7 | ErrorMessageInvalidHost = errors.New("invalid server host. Either empty or not set") 8 | ErrorMessageInvalidPort = errors.New("invalid port") 9 | ErrorMessageInvalidUserName = errors.New("username is either empty or not set") 10 | ErrorMessageInvalidPassword = errors.New("password is either empty or not set") 11 | ErrorMessageInvalidProtocol = errors.New("protocol is invalid, use native or http") 12 | ErrorInvalidClientCertificate = errors.New("tls: failed to find any PEM data in certificate input") 13 | ErrorInvalidCACertificate = errors.New("failed to parse TLS CA PEM certificate") 14 | ) 15 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | import type { PluginOptions } from '@grafana/plugin-e2e'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // import dotenv from 'dotenv'; 9 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 10 | 11 | /** 12 | * See https://playwright.dev/docs/test-configuration. 13 | */ 14 | export default defineConfig({ 15 | testDir: './tests/e2e', 16 | /* Run tests in files in parallel */ 17 | fullyParallel: true, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: !!process.env.CI, 20 | /* Retry on CI only */ 21 | retries: process.env.CI ? 2 : 0, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: process.env.CI ? 1 : undefined, 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: 'html', 26 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 27 | use: { 28 | /* Base URL to use in actions like `await page.goto('/')`. */ 29 | baseURL: process.env.GRAFANA_URL || `http://localhost:${process.env.PORT || 3000}`, 30 | 31 | launchOptions: { 32 | executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, 33 | }, 34 | 35 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 36 | trace: 'on-first-retry', 37 | screenshot: 'only-on-failure', 38 | video: 'on', 39 | }, 40 | 41 | /* Configure projects for major browsers */ 42 | projects: [ 43 | { 44 | name: 'auth', 45 | testDir: 'node_modules/@grafana/plugin-e2e/dist/auth', 46 | testMatch: [/.*\.js/], 47 | }, 48 | { 49 | name: 'run-tests', 50 | use: { 51 | ...devices['Desktop Chrome'], 52 | storageState: 'playwright/.auth/admin.json', 53 | }, 54 | dependencies: ['auth'], 55 | }, 56 | ], 57 | }); 58 | -------------------------------------------------------------------------------- /provisioning/datasources/clickhouse.yml: -------------------------------------------------------------------------------- 1 | # Configuration file version 2 | apiVersion: 1 3 | 4 | # List of data sources to delete from the database. 5 | deleteDatasources: 6 | - name: ClickHouse 7 | orgId: 1 8 | 9 | datasources: 10 | - name: ClickHouse 11 | type: grafana-clickhouse-datasource 12 | access: proxy 13 | url: clickhouse-server 14 | orgId: 1 15 | jsonData: 16 | host: clickhouse-server 17 | database: "test" 18 | username: "default" 19 | port: 9000 20 | protocol: native 21 | secureJsonData: 22 | # password: "" 23 | -------------------------------------------------------------------------------- /scripts/ca-cert.sh: -------------------------------------------------------------------------------- 1 | # Generate server.key and server.crt signed by our local CA. 2 | openssl genrsa -out $PWD/config-secure/server.key 2048 3 | # TODO - use localhost instead of foo? 4 | openssl req -sha256 -new -key $PWD/config-secure/server.key -out $PWD/config-secure/server.csr \ 5 | -subj "/CN=foo" \ 6 | 7 | openssl x509 -req -in $PWD/config-secure/server.csr -CA $PWD/config-secure/my-own-ca.crt -CAkey $PWD/config-secure/my-own-ca.key \ 8 | -CAcreateserial -out $PWD/config-secure/server.crt -days 825 -sha256 -extfile $PWD/config-secure/server.ext 9 | 10 | # Confirm the certificate is valid. 11 | openssl verify -CAfile $PWD/config-secure/my-own-ca.crt $PWD/config-secure/server.crt -------------------------------------------------------------------------------- /scripts/ca.ext: -------------------------------------------------------------------------------- 1 | authorityKeyIdentifier=keyid,issuer 2 | basicConstraints=CA:FALSE 3 | keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment 4 | subjectAltName = @alt_names 5 | 6 | [alt_names] 7 | DNS.1 = root -------------------------------------------------------------------------------- /scripts/ca.sh: -------------------------------------------------------------------------------- 1 | # create a ca certificate 2 | 3 | openssl genrsa -out $PWD/config-secure/my-own-ca.key 2048 4 | openssl req -new -x509 -days 3650 -key $PWD/config-secure/my-own-ca.key \ 5 | -subj "/CN=root" \ 6 | -addext "subjectAltName = DNS:root" \ 7 | -sha256 -extensions v3_ca -out $PWD/config-secure/my-own-ca.crt 8 | 9 | # if running an older version of openssl 10 | # openssl req -new -x509 -days 3650 -key $PWD/config-secure/my-own-ca.key \ 11 | # -subj "/CN=root" \ 12 | # -sha256 -extensions v3_ca -out $PWD/config-secure/my-own-ca.crt \ 13 | # -extfile $PWD/scripts/ca.ext -------------------------------------------------------------------------------- /scripts/certs.sh: -------------------------------------------------------------------------------- 1 | openssl req -subj "/CN=foo" -new \ 2 | -newkey rsa:2048 -days 365 -nodes -x509 \ 3 | -keyout $PWD/config/server.key \ 4 | -out $PWD/config/server.crt 5 | 6 | -------------------------------------------------------------------------------- /src/__mocks__/ConfigEditor.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import { ConfigEditorProps } from 'views/CHConfigEditor'; 3 | import { CHConfig } from 'types/config'; 4 | 5 | const pluginJson = JSON.parse(fs.readFileSync('./src/plugin.json', 'utf-8')); 6 | 7 | export const mockConfigEditorProps = (overrides?: Partial): ConfigEditorProps => ({ 8 | options: { 9 | ...pluginJson, 10 | jsonData: { 11 | server: 'foo.com', 12 | port: 443, 13 | path: '', 14 | username: 'user', 15 | protocol: 'http', 16 | ...overrides, 17 | }, 18 | }, 19 | onOptionsChange: jest.fn(), 20 | }); 21 | -------------------------------------------------------------------------------- /src/__mocks__/datasource.ts: -------------------------------------------------------------------------------- 1 | import { PluginType } from '@grafana/data'; 2 | import { Protocol } from 'types/config'; 3 | import { CHQuery, EditorType } from 'types/sql'; 4 | import { QueryType } from 'types/queryBuilder'; 5 | import { Datasource } from '../data/CHDatasource'; 6 | import { pluginVersion } from 'utils/version'; 7 | 8 | export const newMockDatasource = (): Datasource => { 9 | const mockDatasource = new Datasource({ 10 | id: 1, 11 | uid: 'clickhouse_ds', 12 | type: 'grafana-clickhouse-datasource', 13 | name: 'ClickHouse', 14 | jsonData: { 15 | version: pluginVersion, 16 | host: 'foo.com', 17 | port: 443, 18 | path: '', 19 | username: 'user', 20 | defaultDatabase: 'foo', 21 | defaultTable: 'bar', 22 | aliasTables: [], 23 | protocol: Protocol.Native, 24 | }, 25 | readOnly: true, 26 | access: 'direct', 27 | meta: { 28 | id: 'grafana-clickhouse-datasource', 29 | name: 'ClickHouse', 30 | type: PluginType.datasource, 31 | module: '', 32 | baseUrl: '', 33 | info: { 34 | description: '', 35 | screenshots: [], 36 | updated: '', 37 | version: '', 38 | logos: { 39 | small: '', 40 | large: '', 41 | }, 42 | author: { 43 | name: '', 44 | }, 45 | links: [], 46 | }, 47 | }, 48 | }); 49 | 50 | mockDatasource.adHocFiltersStatus = 1; // most tests should skip checking the CH version. We will set ad hoc filters to enabled to avoid running the CH version check 51 | return mockDatasource; 52 | }; 53 | 54 | export const mockDatasource = newMockDatasource(); 55 | 56 | export const mockQuery: CHQuery = { 57 | pluginVersion: '', 58 | rawSql: 'select * from foo', 59 | refId: '', 60 | editorType: EditorType.SQL, 61 | queryType: QueryType.Table 62 | }; 63 | -------------------------------------------------------------------------------- /src/ch-parser/helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper functions for character classification and string handling 3 | */ 4 | 5 | /** 6 | * Check if a character is a whitespace ASCII character 7 | */ 8 | export function isWhitespaceASCII(c: string): boolean { 9 | return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f' || c === '\v'; 10 | } 11 | 12 | /** 13 | * Check if a character is a numeric ASCII character 14 | */ 15 | export function isNumericASCII(c: string): boolean { 16 | return c >= '0' && c <= '9'; 17 | } 18 | 19 | /** 20 | * Check if a character is a word character (letter, digit, or underscore) 21 | */ 22 | export function isWordCharASCII(c: string): boolean { 23 | return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '_'; 24 | } 25 | 26 | /** 27 | * Check if a character is a hexadecimal digit 28 | */ 29 | export function isHexDigit(c: string): boolean { 30 | return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); 31 | } 32 | 33 | /** 34 | * Check if a character is a valid number separator, like underscore in 1_000_000 35 | */ 36 | export function isNumberSeparator(startOfBlock: boolean, hex: boolean, pos: number, text: string): boolean { 37 | if (startOfBlock) { 38 | return false; 39 | } 40 | 41 | if (pos >= text.length) { 42 | return false; 43 | } 44 | 45 | if (text[pos] !== '_') { 46 | return false; 47 | } 48 | 49 | if (pos + 1 >= text.length) { 50 | return false; 51 | } 52 | 53 | if (hex) { 54 | return isHexDigit(text[pos + 1]); 55 | } 56 | 57 | return isNumericASCII(text[pos + 1]); 58 | } 59 | 60 | /** 61 | * Find the first occurrence of any of the given characters 62 | */ 63 | export function findFirstSymbols(text: string, pos: number, end: number, ...symbols: string[]): number { 64 | while (pos < end) { 65 | if (symbols.includes(text[pos])) { 66 | return pos; 67 | } 68 | pos++; 69 | } 70 | return end; 71 | } 72 | 73 | /** 74 | * Find the first character that is not any of the given characters 75 | */ 76 | export function findFirstNotSymbols(text: string, pos: number, end: number, ...symbols: string[]): number { 77 | while (pos < end) { 78 | if (!symbols.includes(text[pos])) { 79 | return pos; 80 | } 81 | pos++; 82 | } 83 | return end; 84 | } 85 | 86 | /** 87 | * Skip UTF-8 whitespaces (including Unicode ones) 88 | */ 89 | export function skipWhitespacesUTF8(text: string, pos: number, end: number): number { 90 | // Skip whitespace characters in Unicode 91 | // This is a simplified version that just skips common Unicode whitespace 92 | while (pos < end) { 93 | const code = text.charCodeAt(pos); 94 | 95 | // Skip ASCII whitespace 96 | if (code <= 127 && isWhitespaceASCII(String.fromCharCode(code))) { 97 | pos++; 98 | continue; 99 | } 100 | 101 | // Skip some common Unicode whitespace 102 | // U+00A0 - NO-BREAK SPACE 103 | // U+2000 to U+200A - Various space characters 104 | // U+2028 - LINE SEPARATOR 105 | // U+2029 - PARAGRAPH SEPARATOR 106 | // U+202F - NARROW NO-BREAK SPACE 107 | // U+205F - MEDIUM MATHEMATICAL SPACE 108 | // U+3000 - IDEOGRAPHIC SPACE 109 | if ( 110 | code === 0x00A0 || 111 | (code >= 0x2000 && code <= 0x200A) || 112 | code === 0x2028 || 113 | code === 0x2029 || 114 | code === 0x202F || 115 | code === 0x205F || 116 | code === 0x3000 117 | ) { 118 | pos++; 119 | continue; 120 | } 121 | 122 | break; 123 | } 124 | 125 | return pos; 126 | } 127 | 128 | /** 129 | * Check if a character is a UTF-8 continuation octet 130 | */ 131 | export function isContinuationOctet(c: string): boolean { 132 | const code = c.charCodeAt(0); 133 | return (code & 0xC0) === 0x80; 134 | } 135 | -------------------------------------------------------------------------------- /src/ch-parser/pluginMacros.ts: -------------------------------------------------------------------------------- 1 | export interface PluginMacro { 2 | name: string; 3 | isFunction: boolean; 4 | columnType?: string; 5 | documentation: string; 6 | example?: string; 7 | } 8 | 9 | // Taken from README/docs 10 | export const pluginMacros: PluginMacro[] = [ 11 | { 12 | name: "$__dateFilter", 13 | isFunction: true, 14 | documentation: "Filters the data based on the date range of the panel", 15 | example: "date >= toDate('2022-10-21') AND date <= toDate('2022-10-23')" 16 | }, 17 | { 18 | name: "$__timeFilter", 19 | isFunction: true, 20 | documentation: "Filters the data based on the time range of the panel in seconds", 21 | example: "time >= toDateTime(1415792726) AND time <= toDateTime(1447328726)" 22 | }, 23 | { 24 | name: "$__timeFilter_ms", 25 | isFunction: true, 26 | documentation: "Filters the data based on the time range of the panel in milliseconds", 27 | example: "time >= fromUnixTimestamp64Milli(1415792726123) AND time <= fromUnixTimestamp64Milli(1447328726456)" 28 | }, 29 | { 30 | name: "$__dateTimeFilter", 31 | isFunction: true, 32 | documentation: "Shorthand that combines $__dateFilter() AND $__timeFilter() using separate Date and DateTime columns", 33 | example: "$__dateFilter(dateColumn) AND $__timeFilter(timeColumn)" 34 | }, 35 | { 36 | name: "$__fromTime", 37 | isFunction: false, 38 | columnType: "DateTime", 39 | documentation: "Replaced by the starting time of the range of the panel casted to DateTime", 40 | example: "toDateTime(1415792726)" 41 | }, 42 | { 43 | name: "$__toTime", 44 | isFunction: false, 45 | columnType: "DateTime", 46 | documentation: "Replaced by the ending time of the range of the panel casted to DateTime", 47 | example: "toDateTime(1447328726)" 48 | }, 49 | { 50 | name: "$__fromTime_ms", 51 | isFunction: false, 52 | columnType: "DateTime64(3)", 53 | documentation: "Replaced by the starting time of the range of the panel casted to DateTime64(3)", 54 | example: "fromUnixTimestamp64Milli(1415792726123)" 55 | }, 56 | { 57 | name: "$__toTime_ms", 58 | isFunction: false, 59 | columnType: "Datetime64(3)", 60 | documentation: "Replaced by the ending time of the range of the panel casted to DateTime64(3)", 61 | example: "fromUnixTimestamp64Milli(1447328726456)" 62 | }, 63 | { 64 | name: "$__interval_s", 65 | isFunction: false, 66 | columnType: "INTERVAL", 67 | documentation: "Replaced by the interval in seconds", 68 | example: "20" 69 | }, 70 | { 71 | name: "$__timeInterval", 72 | isFunction: true, 73 | columnType: "DateTime", 74 | documentation: "Replaced by a function calculating the interval based on window size in seconds, useful when grouping", 75 | example: "toStartOfInterval(toDateTime(column), INTERVAL 20 second)" 76 | }, 77 | { 78 | name: "$__timeInterval_ms", 79 | isFunction: true, 80 | columnType: "DateTime64(3)", 81 | documentation: "Replaced by a function calculating the interval based on window size in milliseconds, useful when grouping", 82 | example: "toStartOfInterval(toDateTime64(column, 3), INTERVAL 20 millisecond)" 83 | }, 84 | { 85 | name: "$__conditionalAll", 86 | isFunction: true, 87 | columnType: "Condition", 88 | documentation: "Replaced by the first parameter when the template variable in the second parameter does not select every value. Replaced by 1=1 when the template variable selects every value", 89 | example: "condition or 1=1" 90 | } 91 | ]; 92 | -------------------------------------------------------------------------------- /src/components/Divider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Divider as GrafanaDivider, useTheme2 } from '@grafana/ui'; 3 | import { config } from '@grafana/runtime'; 4 | import { isVersionGtOrEq } from 'utils/version'; 5 | 6 | export function Divider() { 7 | const theme = useTheme2(); 8 | return isVersionGtOrEq(config.buildInfo.version, '10.1.0') ? ( 9 | 10 | ) : ( 11 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/LogContextPanel.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import LogsContextPanel, { _testExports } from './LogsContextPanel'; 4 | import { Components } from 'selectors'; 5 | 6 | describe('LogsContextPanel', () => { 7 | it('shows an alert when no columns are matched', () => { 8 | const result = render(); 9 | expect(result.getByTestId(Components.LogsContextPanel.alert)).toBeInTheDocument(); 10 | }); 11 | 12 | it('renders LogContextKey components for each column', () => { 13 | const mockColumns = [ 14 | { name: 'host', value: '127.0.0.1' }, 15 | { name: 'service', value: 'test-api' }, 16 | ]; 17 | 18 | const result = render(); 19 | 20 | expect(result.getAllByTestId(Components.LogsContextPanel.LogsContextKey)).toHaveLength(2); 21 | expect(result.getByText('host')).toBeInTheDocument(); 22 | expect(result.getByText('127.0.0.1')).toBeInTheDocument(); 23 | expect(result.getByText('service')).toBeInTheDocument(); 24 | expect(result.getByText('test-api')).toBeInTheDocument(); 25 | }); 26 | }); 27 | 28 | describe('LogContextKey', () => { 29 | const LogContextKey = _testExports.LogContextKey; 30 | 31 | it('renders the expected keys', () => { 32 | const props = { 33 | name: 'testName', 34 | value: 'testValue', 35 | primaryColor: '#000', 36 | primaryTextColor: '#aaa', 37 | secondaryColor: '#111', 38 | secondaryTextColor: '#bbb', 39 | }; 40 | 41 | 42 | const result = render(); 43 | 44 | expect(result.getByTestId(Components.LogsContextPanel.LogsContextKey)).toBeInTheDocument(); 45 | expect(result.getByText('testName')).toBeInTheDocument(); 46 | expect(result.getByText('testValue')).toBeInTheDocument(); 47 | }); 48 | }); 49 | 50 | describe('iconMatcher', () => { 51 | const iconMatcher = _testExports.iconMatcher; 52 | 53 | it('returns correct icons for different context names', () => { 54 | expect(iconMatcher('database')).toBe('database'); 55 | expect(iconMatcher('???')).toBe('align-left'); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/components/QueryToolbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { css } from '@emotion/css'; 3 | 4 | import { Icon, IconButton, Stack, Tooltip, useTheme2 } from '@grafana/ui'; 5 | 6 | interface QueryToolboxProps { 7 | showTools?: boolean; 8 | onFormatCode?: () => void; 9 | } 10 | 11 | export function QueryToolbox({ showTools, onFormatCode }: QueryToolboxProps) { 12 | const theme = useTheme2(); 13 | 14 | const styles = useMemo(() => { 15 | return { 16 | container: css({ 17 | border: `1px solid ${theme.colors.border.medium}`, 18 | borderTop: 'none', 19 | padding: theme.spacing(0.5, 0.5, 0.5, 0.5), 20 | display: 'flex', 21 | flexGrow: 1, 22 | justifyContent: 'space-between', 23 | fontSize: theme.typography.bodySmall.fontSize, 24 | }), 25 | error: css({ 26 | color: theme.colors.error.text, 27 | fontSize: theme.typography.bodySmall.fontSize, 28 | fontFamily: theme.typography.fontFamilyMonospace, 29 | }), 30 | valid: css({ 31 | color: theme.colors.success.text, 32 | }), 33 | info: css({ 34 | color: theme.colors.text.secondary, 35 | }), 36 | hint: css({ 37 | color: theme.colors.text.disabled, 38 | whiteSpace: 'nowrap', 39 | cursor: 'help', 40 | }), 41 | }; 42 | }, [theme]); 43 | 44 | let style = {}; 45 | 46 | if (!showTools) { 47 | style = { height: 0, padding: 0, visibility: 'hidden' }; 48 | } 49 | 50 | return ( 51 |
52 | {showTools && ( 53 |
54 | 55 | {onFormatCode && ( 56 | { 58 | onFormatCode(); 59 | }} 60 | name="brackets-curly" 61 | size="xs" 62 | tooltip="Format query" 63 | /> 64 | )} 65 | 66 | 67 | 68 | 69 |
70 | )} 71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/components/configEditor/DefaultDatabaseTableConfig.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import { DefaultDatabaseTableConfig } from './DefaultDatabaseTableConfig'; 4 | import allLabels from 'labels'; 5 | 6 | describe('DefaultDatabaseTableConfig', () => { 7 | it('should render', () => { 8 | const result = render( {}} onDefaultTableChange={() => {}} />); 9 | expect(result.container.firstChild).not.toBeNull(); 10 | }); 11 | 12 | it('should call onDefaultDatabaseChange when default database is changed', () => { 13 | const onDefaultDatabaseChange = jest.fn(); 14 | const result = render( {}} />); 15 | expect(result.container.firstChild).not.toBeNull(); 16 | 17 | const databaseInput = result.getByLabelText(allLabels.components.Config.DefaultDatabaseTableConfig.database.label); 18 | expect(databaseInput).toBeInTheDocument(); 19 | fireEvent.change(databaseInput, { target: { value: 'test' } }); 20 | fireEvent.blur(databaseInput); 21 | expect(onDefaultDatabaseChange).toHaveBeenCalledTimes(1); 22 | expect(onDefaultDatabaseChange).toHaveBeenCalledWith(expect.any(Object)); 23 | }); 24 | 25 | it('should call onDefaultTableChange when default table is changed', () => { 26 | const onDefaultTableChange = jest.fn(); 27 | const result = render( {}} onDefaultTableChange={onDefaultTableChange} />); 28 | expect(result.container.firstChild).not.toBeNull(); 29 | 30 | const tableInput = result.getByLabelText(allLabels.components.Config.DefaultDatabaseTableConfig.table.label); 31 | expect(tableInput).toBeInTheDocument(); 32 | fireEvent.change(tableInput, { target: { value: 'test' } }); 33 | fireEvent.blur(tableInput); 34 | expect(onDefaultTableChange).toHaveBeenCalledTimes(1); 35 | expect(onDefaultTableChange).toHaveBeenCalledWith(expect.any(Object)); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/configEditor/DefaultDatabaseTableConfig.tsx: -------------------------------------------------------------------------------- 1 | import React, { SyntheticEvent } from 'react'; 2 | import { ConfigSection } from 'components/experimental/ConfigSection'; 3 | import { Input, Field } from '@grafana/ui'; 4 | import allLabels from 'labels'; 5 | 6 | interface DefaultDatabaseTableConfigProps { 7 | defaultDatabase?: string; 8 | defaultTable?: string; 9 | onDefaultDatabaseChange: (e: SyntheticEvent) => void; 10 | onDefaultTableChange: (e: SyntheticEvent) => void; 11 | } 12 | 13 | export const DefaultDatabaseTableConfig = (props: DefaultDatabaseTableConfigProps) => { 14 | const { defaultDatabase, defaultTable, onDefaultDatabaseChange, onDefaultTableChange } = props; 15 | const labels = allLabels.components.Config.DefaultDatabaseTableConfig; 16 | 17 | return ( 18 | 19 | 23 | 33 | 34 | 38 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/configEditor/LabeledInput.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import { LabeledInput } from './LabeledInput'; 4 | 5 | describe('LabeledInput', () => { 6 | it('should render', () => { 7 | const result = render( {}} />); 8 | expect(result.container.firstChild).not.toBeNull(); 9 | }); 10 | 11 | it('should call onChange when input is changed', async () => { 12 | const onChange = jest.fn(); 13 | const result = render(); 14 | expect(result.container.firstChild).not.toBeNull(); 15 | 16 | const input = result.getByPlaceholderText('test'); 17 | expect(input).toBeInTheDocument(); 18 | fireEvent.change(input, { target: { value: 'changed' } }); 19 | fireEvent.blur(input); 20 | expect(onChange).toHaveBeenCalledTimes(1); 21 | expect(onChange).toHaveBeenCalledWith('changed'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/configEditor/LabeledInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Input, InlineFormLabel } from '@grafana/ui'; 3 | 4 | interface LabeledInputProps { 5 | label: string; 6 | tooltip?: string; 7 | placeholder?: string; 8 | disabled?: boolean; 9 | value: string; 10 | onChange: (value: string) => void; 11 | } 12 | 13 | export function LabeledInput(props: LabeledInputProps) { 14 | const { label, tooltip, placeholder, disabled, value, onChange } = props; 15 | 16 | return ( 17 |
18 | 19 | {label} 20 | 21 | onChange(e.currentTarget.value)} 26 | placeholder={placeholder} 27 | /> 28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/components/experimental/ConfigSection/ConfigSection.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { screen, render } from '@testing-library/react'; 3 | import { ConfigSection } from './ConfigSection'; 4 | 5 | describe('', () => { 6 | it('should render title as

', () => { 7 | render( 8 | 9 |
Content
10 |
11 | ); 12 | 13 | expect(screen.getByText('Test title').tagName).toBe('H3'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/experimental/ConfigSection/ConfigSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GenericConfigSection, Props as GenericConfigSectionProps } from './GenericConfigSection'; 3 | 4 | type Props = Omit; 5 | 6 | export const ConfigSection = ({ children, ...props }: Props) => { 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/experimental/ConfigSection/ConfigSubSection.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { screen, render } from '@testing-library/react'; 3 | import { ConfigSubSection } from './ConfigSubSection'; 4 | 5 | describe('', () => { 6 | it('should render title as

', () => { 7 | render( 8 | 9 |
Content
10 |
11 | ); 12 | 13 | expect(screen.getByText('Test title').tagName).toBe('H6'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/experimental/ConfigSection/ConfigSubSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GenericConfigSection, Props as GenericConfigSectionProps } from './GenericConfigSection'; 3 | 4 | type Props = Omit; 5 | 6 | export const ConfigSubSection = ({ children, ...props }: Props) => { 7 | return ( 8 | 9 | {children} 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/experimental/ConfigSection/DataSourceDescription.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DataSourceDescription } from './DataSourceDescription'; 3 | import { render } from '@testing-library/react'; 4 | 5 | describe('', () => { 6 | it('should render data source name', () => { 7 | const dataSourceName = 'Test data source name'; 8 | const { getByText } = render( 9 | 10 | ); 11 | 12 | expect(getByText(dataSourceName, { exact: false })).toBeInTheDocument(); 13 | }); 14 | 15 | it('should render docs link', () => { 16 | const docsLink = 'https://grafana.com/test-datasource-docs'; 17 | const { getByText } = render( 18 | 19 | ); 20 | 21 | const docsLinkEl = getByText('view the documentation'); 22 | 23 | expect(docsLinkEl.getAttribute('href')).toBe(docsLink); 24 | }); 25 | 26 | it('should render text about required fields by default', () => { 27 | const { getByText } = render( 28 | 32 | ); 33 | 34 | expect(getByText('Fields marked with', { exact: false })).toBeInTheDocument(); 35 | }); 36 | 37 | it('should not render text about required fields when `hasRequiredFields` props is `false`', () => { 38 | const { getByText } = render( 39 | 44 | ); 45 | 46 | expect(() => getByText('Fields marked with', { exact: false })).toThrow(); 47 | }); 48 | 49 | it('should render passed `className`', () => { 50 | const { container } = render( 51 | 56 | ); 57 | 58 | expect(container.firstChild).toHaveClass('test-class-name'); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/components/experimental/ConfigSection/DataSourceDescription.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { cx, css } from '@emotion/css'; 3 | import { useTheme2 } from '@grafana/ui'; 4 | 5 | type Props = { 6 | dataSourceName: string; 7 | docsLink: string; 8 | hasRequiredFields?: boolean; 9 | className?: string; 10 | }; 11 | 12 | export const DataSourceDescription = ({ dataSourceName, docsLink, hasRequiredFields = true, className }: Props) => { 13 | const theme = useTheme2(); 14 | 15 | const styles = { 16 | container: css({ 17 | p: { 18 | margin: 0, 19 | }, 20 | 'p + p': { 21 | marginTop: theme.spacing(2), 22 | }, 23 | }), 24 | text: css({ 25 | ...theme.typography.body, 26 | color: theme.colors.text.secondary, 27 | a: css({ 28 | color: theme.colors.text.link, 29 | textDecoration: 'underline', 30 | '&:hover': { 31 | textDecoration: 'none', 32 | }, 33 | }), 34 | }), 35 | }; 36 | 37 | return ( 38 |
39 |

40 | Before you can use the {dataSourceName} data source, you must configure it below or in the config file. For 41 | detailed instructions,{' '} 42 | 43 | view the documentation 44 | 45 | . 46 |

47 | {hasRequiredFields && ( 48 |

49 | Fields marked with * are required 50 |

51 | )} 52 |
53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/experimental/ConfigSection/GenericConfigSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, ReactNode } from 'react'; 2 | import { css } from '@emotion/css'; 3 | import { useTheme2, IconButton, IconName } from '@grafana/ui'; 4 | 5 | export type Props = { 6 | title: string; 7 | description?: ReactNode; 8 | isCollapsible?: boolean; 9 | isInitiallyOpen?: boolean; 10 | kind?: 'section' | 'sub-section'; 11 | className?: string; 12 | children: ReactNode; 13 | }; 14 | 15 | export const GenericConfigSection = ({ 16 | children, 17 | title, 18 | description, 19 | isCollapsible = false, 20 | isInitiallyOpen = true, 21 | kind = 'section', 22 | className, 23 | }: Props) => { 24 | const { colors, typography, spacing } = useTheme2(); 25 | const [isOpen, setIsOpen] = useState(isCollapsible ? isInitiallyOpen : true); 26 | const iconName: IconName = isOpen ? 'angle-up' : 'angle-down'; 27 | const isSubSection = kind === 'sub-section'; 28 | const collapsibleButtonAriaLabel = `${isOpen ? 'Collapse' : 'Expand'} section ${title}`; 29 | 30 | const styles = { 31 | header: css({ 32 | display: 'flex', 33 | justifyContent: 'space-between', 34 | alignItems: 'center', 35 | }), 36 | title: css({ 37 | margin: 0, 38 | }), 39 | subtitle: css({ 40 | margin: 0, 41 | fontWeight: typography.fontWeightRegular, 42 | }), 43 | descriptionText: css({ 44 | marginTop: spacing(isSubSection ? 0.25 : 0.5), 45 | marginBottom: 0, 46 | ...typography.bodySmall, 47 | color: colors.text.secondary, 48 | }), 49 | content: css({ 50 | marginTop: spacing(2), 51 | }), 52 | }; 53 | 54 | return ( 55 |
56 |
57 | {kind === 'section' ?

{title}

:
{title}
} 58 | {isCollapsible && ( 59 | setIsOpen(!isOpen)} 62 | type="button" 63 | size="xl" 64 | aria-label={collapsibleButtonAriaLabel} 65 | /> 66 | )} 67 |
68 | {description &&

{description}

} 69 | {isOpen &&
{children}
} 70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/experimental/ConfigSection/index.ts: -------------------------------------------------------------------------------- 1 | export { ConfigSection } from './ConfigSection'; 2 | export { ConfigSubSection } from './ConfigSubSection'; 3 | export { DataSourceDescription } from './DataSourceDescription' 4 | -------------------------------------------------------------------------------- /src/components/queryBuilder/AggregateEditor.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { AggregateEditor } from './AggregateEditor'; 5 | import { selectors } from 'selectors'; 6 | import { AggregateColumn, AggregateType } from 'types/queryBuilder'; 7 | 8 | describe('AggregateEditor', () => { 9 | it('should render with no aggregates', () => { 10 | const result = render( {}} />); 11 | expect(result.container.firstChild).not.toBeNull(); 12 | }); 13 | 14 | it('should render with aggregates', () => { 15 | const testAggregate: AggregateColumn = { aggregateType: AggregateType.Count, column: 'foo', alias: 'f' }; 16 | const result = render( {}} />); 17 | expect(result.container.firstChild).not.toBeNull(); 18 | 19 | const firstAggregate = result.getByTestId(selectors.components.QueryBuilder.AggregateEditor.itemWrapper); 20 | expect(firstAggregate).toBeInTheDocument(); 21 | }); 22 | 23 | it('should call onAggregatesChange when add aggregate button is clicked', async () => { 24 | const onAggregatesChange = jest.fn(); 25 | const result = render(); 26 | expect(result.container.firstChild).not.toBeNull(); 27 | 28 | const addButton = result.getByTestId(selectors.components.QueryBuilder.AggregateEditor.addButton); 29 | expect(addButton).toBeInTheDocument(); 30 | await userEvent.click(addButton); 31 | expect(onAggregatesChange).toBeCalledTimes(1); 32 | expect(onAggregatesChange).toBeCalledWith([expect.anything()]); 33 | }); 34 | 35 | it('should call onAggregatesChange when remove aggregate button is clicked', async () => { 36 | const testAggregate: AggregateColumn = { aggregateType: AggregateType.Count, column: 'foo', alias: 'f' }; 37 | const onAggregatesChange = jest.fn(); 38 | const result = render(); 39 | expect(result.container.firstChild).not.toBeNull(); 40 | 41 | const removeButton = result.getByTestId(selectors.components.QueryBuilder.AggregateEditor.itemRemoveButton); 42 | expect(removeButton).toBeInTheDocument(); 43 | await userEvent.click(removeButton); 44 | expect(onAggregatesChange).toBeCalledWith([]); 45 | }); 46 | 47 | it('should call onAggregatesChange when aggregate is updated', async () => { 48 | const inputAggregate: AggregateColumn = { aggregateType: AggregateType.Count, column: 'foo', alias: 'f' }; 49 | const expectedAggregate: AggregateColumn = { aggregateType: AggregateType.Sum, column: 'foo', alias: 'f' }; 50 | const onAggregatesChange = jest.fn(); 51 | const result = render(); 52 | expect(result.container.firstChild).not.toBeNull(); 53 | 54 | const aggregateSelect = result.getAllByRole('combobox')[0]; 55 | expect(aggregateSelect).toBeInTheDocument(); 56 | fireEvent.keyDown(aggregateSelect, { key: 'ArrowDown' }); 57 | fireEvent.keyDown(aggregateSelect, { key: 'ArrowDown' }); 58 | fireEvent.keyDown(aggregateSelect, { key: 'Enter' }); 59 | expect(onAggregatesChange).toBeCalledWith([expectedAggregate]); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/queryBuilder/ColumnSelect.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import { ColumnSelect } from './ColumnSelect'; 4 | import { SelectedColumn, TableColumn } from 'types/queryBuilder'; 5 | 6 | describe('ColumnSelect', () => { 7 | const testLabel = 'Label'; 8 | const testTooltip = 'Tooltip'; 9 | 10 | it('should render with empty properties', () => { 11 | const result = render( 12 | {}} 16 | label={testLabel} 17 | tooltip={testTooltip} 18 | /> 19 | ); 20 | expect(result.container.firstChild).not.toBeNull(); 21 | }); 22 | 23 | it('should render with valid properties', () => { 24 | const allColumns: readonly TableColumn[] = [{ name: 'foo', type: 'string', picklistValues: [] }]; 25 | const selectedColumn: SelectedColumn = { name: 'foo' }; 26 | const result = render( 27 | {}} 31 | label={testLabel} 32 | tooltip={testTooltip} 33 | /> 34 | ); 35 | expect(result.container.firstChild).not.toBeNull(); 36 | expect(result.getByText('foo')).not.toBeUndefined(); 37 | }); 38 | 39 | it('should call onColumnChange when a new column is selected', () => { 40 | const allColumns: readonly TableColumn[] = [ 41 | { name: 'one', type: 'string', picklistValues: [] }, 42 | { name: 'two', type: 'string', picklistValues: [] } 43 | ]; 44 | const onColumnChange = jest.fn(); 45 | const result = render( 46 | 53 | ); 54 | expect(result.container.firstChild).not.toBeNull(); 55 | 56 | const multiSelect = result.getByRole('combobox'); 57 | expect(multiSelect).toBeInTheDocument(); 58 | fireEvent.keyDown(multiSelect, { key: 'ArrowDown' }); 59 | fireEvent.keyDown(multiSelect, { key: 'Enter' }); 60 | expect(onColumnChange).toHaveBeenCalledTimes(1); 61 | expect(onColumnChange).toHaveBeenCalledWith(expect.any(Object)); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/components/queryBuilder/ColumnSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SelectableValue } from '@grafana/data'; 3 | import { InlineFormLabel, Select } from '@grafana/ui'; 4 | import { ColumnHint, SelectedColumn, TableColumn } from 'types/queryBuilder'; 5 | import { styles } from 'styles'; 6 | 7 | interface ColumnSelectProps { 8 | allColumns: readonly TableColumn[]; 9 | selectedColumn: SelectedColumn | undefined; 10 | onColumnChange: (c: SelectedColumn | undefined) => void; 11 | columnFilterFn?: (c: TableColumn) => boolean; 12 | columnHint?: ColumnHint; 13 | label: string; 14 | tooltip: string; 15 | disabled?: boolean; 16 | invalid?: boolean; 17 | wide?: boolean; 18 | inline?: boolean; 19 | clearable?: boolean; 20 | } 21 | 22 | const defaultFilterFn = () => true; 23 | 24 | export const ColumnSelect = (props: ColumnSelectProps) => { 25 | const { allColumns, selectedColumn, onColumnChange, columnFilterFn, columnHint, label, tooltip, disabled, invalid, wide, inline, clearable } = props; 26 | const selectedColumnName = selectedColumn?.name; 27 | const columns: Array> = allColumns. 28 | filter(columnFilterFn || defaultFilterFn). 29 | map(c => ({ label: c.label || c.name, value: c.name })); 30 | 31 | // Select component WILL NOT display the value if it isn't present in the options. 32 | let staleOption = false; 33 | if (selectedColumn && !columns.find(c => c.value === selectedColumn.name)) { 34 | columns.push({ label: selectedColumn.alias || selectedColumn.name, value: selectedColumn.name }); 35 | staleOption = true; 36 | } 37 | 38 | const onChange = (selected: SelectableValue) => { 39 | if (!selected || !selected.value) { 40 | onColumnChange(undefined); 41 | return; 42 | } 43 | 44 | const column = allColumns.find(c => c.name === selected!.value)!; 45 | const nextColumn: SelectedColumn = { 46 | name: column?.name || selected!.value, 47 | type: column?.type, 48 | hint: columnHint, 49 | }; 50 | 51 | if (column && column.label !== undefined) { 52 | nextColumn.alias = column.label; 53 | } 54 | 55 | onColumnChange(nextColumn); 56 | } 57 | 58 | const labelStyle = 'query-keyword ' + (inline ? styles.QueryEditor.inlineField : ''); 59 | 60 | return ( 61 |
62 | 63 | {label} 64 | 65 | 66 | disabled={disabled} 67 | invalid={invalid || staleOption} 68 | options={columns} 69 | value={selectedColumnName} 70 | placeholder={selectedColumnName || undefined} 71 | onChange={onChange} 72 | width={wide ? 25 : 20} 73 | menuPlacement={'bottom'} 74 | isClearable={clearable === undefined || clearable} 75 | allowCustomValue 76 | /> 77 |
78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /src/components/queryBuilder/ColumnsEditor.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | import { ColumnsEditor } from './ColumnsEditor'; 4 | import { TableColumn, SelectedColumn } from 'types/queryBuilder'; 5 | import { selectors } from 'selectors'; 6 | 7 | describe('ColumnsEditor', () => { 8 | const allColumns: readonly TableColumn[] = [ 9 | { name: 'name', type: 'string', picklistValues: [] }, 10 | { name: 'dummy', type: 'string', picklistValues: [] }, 11 | ]; 12 | const selectedColumns: SelectedColumn[] = [ 13 | { name: 'name' }, 14 | ]; 15 | 16 | it('should render default value when no options passed', () => { 17 | const result = render( {}} />); 18 | expect(result.container.firstChild).not.toBeNull(); 19 | expect(result.getByTestId(selectors.components.QueryBuilder.ColumnsEditor.multiSelectWrapper)).toBeInTheDocument(); 20 | }); 21 | 22 | it('should render the correct values when passed', () => { 23 | const result = render( {}} />); 24 | expect(result.container.firstChild).not.toBeNull(); 25 | expect(result.getByTestId(selectors.components.QueryBuilder.ColumnsEditor.multiSelectWrapper)).toBeInTheDocument(); 26 | 27 | const multiSelect = result.getByRole('combobox'); 28 | expect(multiSelect).toBeInTheDocument(); 29 | fireEvent.keyDown(multiSelect, { key: 'ArrowDown' }); 30 | expect(result.getByText('name')).toBeInTheDocument(); 31 | expect(result.getByText('dummy')).toBeInTheDocument(); 32 | }); 33 | 34 | it('should call onSelectedColumnsChange when a column is selected', () => { 35 | const onSelectedColumnsChange = jest.fn(); 36 | const result = render(); 37 | expect(result.container.firstChild).not.toBeNull(); 38 | expect(result.getByTestId(selectors.components.QueryBuilder.ColumnsEditor.multiSelectWrapper)).toBeInTheDocument(); 39 | 40 | const multiSelect = result.getByRole('combobox'); 41 | expect(multiSelect).toBeInTheDocument(); 42 | fireEvent.keyDown(multiSelect, { key: 'ArrowDown' }); 43 | fireEvent.keyDown(multiSelect, { key: 'ArrowDown' }); 44 | fireEvent.keyDown(multiSelect, { key: 'Enter' }); 45 | 46 | expect(onSelectedColumnsChange).toBeCalledTimes(1); 47 | expect(onSelectedColumnsChange).toBeCalledWith([expect.any(Object), expect.any(Object)]); 48 | }); 49 | 50 | it('should call onSelectedColumnsChange when a column is deselected', () => { 51 | const onSelectedColumnsChange = jest.fn(); 52 | const result = render(); 53 | expect(result.container.firstChild).not.toBeNull(); 54 | expect(result.getByTestId(selectors.components.QueryBuilder.ColumnsEditor.multiSelectWrapper)).toBeInTheDocument(); 55 | 56 | const removeButton = result.getByTestId('times'); // find by "x" symbol 57 | fireEvent.click(removeButton); 58 | expect(onSelectedColumnsChange).toBeCalledTimes(1); 59 | expect(onSelectedColumnsChange).toBeCalledWith([]); 60 | }); 61 | 62 | it('should close when clicked outside', () => { 63 | const onSelectedColumnsChange = jest.fn(); 64 | const result = render(); 65 | expect(onSelectedColumnsChange).toHaveBeenCalledTimes(0); 66 | 67 | const multiSelect = result.getByRole('combobox'); 68 | expect(multiSelect).toBeInTheDocument(); 69 | 70 | expect(result.queryAllByText('dummy').length).toBe(0); // is popup closed 71 | fireEvent.keyDown(multiSelect, { key: 'ArrowDown' }); 72 | expect(result.getByText('dummy')).toBeInTheDocument(); // is popup open 73 | fireEvent.keyDown(multiSelect, { key: 'Esc' }); 74 | expect(result.queryAllByText('dummy').length).toBe(0); // is popup closed 75 | expect(onSelectedColumnsChange).toHaveBeenCalledTimes(0); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/components/queryBuilder/DatabaseTableSelect.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { InlineFormLabel, Select } from '@grafana/ui'; 3 | import { Datasource } from '../../data/CHDatasource'; 4 | import labels from 'labels'; 5 | import { styles } from '../../styles'; 6 | import useTables from 'hooks/useTables'; 7 | import useDatabases from 'hooks/useDatabases'; 8 | 9 | export type DatabaseSelectProps = { 10 | datasource: Datasource; 11 | database: string; 12 | onDatabaseChange: (value: string) => void 13 | }; 14 | 15 | export const DatabaseSelect = (props: DatabaseSelectProps) => { 16 | const { datasource, onDatabaseChange, database } = props; 17 | const databases = useDatabases(datasource); 18 | const { label, tooltip, empty } = labels.components.DatabaseSelect; 19 | 20 | const options = databases.map(d => ({ label: d, value: d })); 21 | options.push({ label: empty, value: '' }); // Allow a blank value 22 | 23 | // Add selected value to the list if it does not exist. 24 | // When loading an existing query, the saved value may no longer be in the list 25 | if (database && !databases.includes(database)) { 26 | options.push({ label: database, value: database }); 27 | } 28 | 29 | useEffect(() => { 30 | // Auto select default db 31 | if (!database) { 32 | onDatabaseChange(datasource.getDefaultDatabase()); 33 | } 34 | }, [datasource, database, onDatabaseChange]); 35 | 36 | return ( 37 | <> 38 | 39 | {label} 40 | 41 | 49 | 50 | ); 51 | }; 52 | 53 | export type TableSelectProps = { 54 | datasource: Datasource; 55 | database: string; 56 | table: string; 57 | onTableChange: (value: string) => void; 58 | }; 59 | 60 | export const TableSelect = (props: TableSelectProps) => { 61 | const { datasource, onTableChange, database, table } = props; 62 | const tables = useTables(datasource, database); 63 | const { label, tooltip, empty } = labels.components.TableSelect; 64 | 65 | const options = tables.map(t => ({ label: t, value: t })); 66 | options.push({ label: empty, value: '' }); // Allow a blank value 67 | 68 | // Include saved value in case it's no longer listed 69 | if (table && !tables.includes(table)) { 70 | options.push({ label: table, value: table }); 71 | } 72 | 73 | useEffect(() => { 74 | // Auto select first/default table 75 | if (database && !table && tables.length > 0) { 76 | onTableChange(datasource.getDefaultTable() || tables[0]); 77 | } 78 | }, [database, table, tables, datasource, onTableChange]); 79 | 80 | return ( 81 | <> 82 | 83 | {label} 84 | 85 | 93 | 94 | ); 95 | }; 96 | 97 | export type DatabaseTableSelectProps = { 98 | datasource: Datasource; 99 | database: string; 100 | onDatabaseChange: (value: string) => void 101 | table: string; 102 | onTableChange: (value: string) => void; 103 | }; 104 | 105 | export const DatabaseTableSelect = (props: DatabaseTableSelectProps) => { 106 | const { datasource, database, onDatabaseChange, table, onTableChange } = props; 107 | 108 | return ( 109 |
110 | 111 | 112 |
113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /src/components/queryBuilder/DurationUnitSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TimeUnit } from "types/queryBuilder"; 3 | import allLabels from 'labels'; 4 | import { InlineFormLabel, Select } from '@grafana/ui'; 5 | import { SelectableValue } from '@grafana/data'; 6 | import { styles } from 'styles'; 7 | 8 | interface DurationUnitSelectProps { 9 | unit: TimeUnit; 10 | onChange: (u: TimeUnit) => void; 11 | disabled?: boolean; 12 | inline?: boolean; 13 | }; 14 | 15 | const durationUnitOptions: ReadonlyArray> = [ 16 | { label: TimeUnit.Seconds, value: TimeUnit.Seconds }, 17 | { label: TimeUnit.Milliseconds, value: TimeUnit.Milliseconds }, 18 | { label: TimeUnit.Microseconds, value: TimeUnit.Microseconds }, 19 | { label: TimeUnit.Nanoseconds, value: TimeUnit.Nanoseconds }, 20 | ]; 21 | 22 | export const DurationUnitSelect = (props: DurationUnitSelectProps) => { 23 | const { unit, onChange, disabled, inline } = props; 24 | const { label, tooltip } = allLabels.components.TraceQueryBuilder.columns.durationUnit; 25 | 26 | return ( 27 |
28 | 29 | {label} 30 | 31 | 32 | disabled={disabled} 33 | options={durationUnitOptions as Array>} 34 | value={unit} 35 | onChange={v => onChange(v.value!)} 36 | width={inline ? 25 : 30} 37 | menuPlacement={'bottom'} 38 | /> 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/queryBuilder/EditorTypeSwitcher.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { EditorTypeSwitcher } from './EditorTypeSwitcher'; 4 | import { CHQuery, CHSqlQuery, EditorType } from 'types/sql'; 5 | import labels from 'labels'; 6 | 7 | const options = { 8 | SQLEditor: labels.types.EditorType.sql, 9 | QueryBuilder: labels.types.EditorType.builder, 10 | }; 11 | 12 | describe('EditorTypeSwitcher', () => { 13 | it('should render default query', () => { 14 | const result = render( 15 | {}} 18 | onRunQuery={() => {}} 19 | /> 20 | ); 21 | expect(result.container.firstChild).not.toBeNull(); 22 | expect(result.getByLabelText(options.SQLEditor)).not.toBeChecked(); 23 | expect(result.getByLabelText(options.QueryBuilder)).toBeChecked(); 24 | }); 25 | 26 | it('should render legacy query (query without query type)', () => { 27 | const result = render( 28 | {}} 31 | onRunQuery={() => {}} 32 | /> 33 | ); 34 | expect(result.container.firstChild).not.toBeNull(); 35 | expect(result.getByLabelText(options.SQLEditor)).toBeChecked(); 36 | expect(result.getByLabelText(options.QueryBuilder)).not.toBeChecked(); 37 | }); 38 | 39 | it('should render SQL editor', () => { 40 | const result = render( 41 | {}} 49 | onRunQuery={() => {}} 50 | /> 51 | ); 52 | expect(result.container.firstChild).not.toBeNull(); 53 | expect(result.getByLabelText(options.SQLEditor)).toBeChecked(); 54 | expect(result.getByLabelText(options.QueryBuilder)).not.toBeChecked(); 55 | }); 56 | 57 | it('should render Query Builder', () => { 58 | const result = render( 59 | {}} 67 | onRunQuery={() => {}} 68 | /> 69 | ); 70 | expect(result.container.firstChild).not.toBeNull(); 71 | expect(result.getByLabelText(options.SQLEditor)).not.toBeChecked(); 72 | expect(result.getByLabelText(options.QueryBuilder)).toBeChecked(); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /src/components/queryBuilder/GroupByEditor.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | import { GroupByEditor } from './GroupByEditor'; 4 | import { TableColumn } from 'types/queryBuilder'; 5 | 6 | describe('GroupByEditor', () => { 7 | it('should render with empty properties', () => { 8 | const result = render( {}} />); 9 | expect(result.container.firstChild).not.toBeNull(); 10 | }); 11 | 12 | it('should render with valid properties', () => { 13 | const allColumns: readonly TableColumn[] = [{ name: 'a', type: 'string', picklistValues: [] }]; 14 | const groupBy: string[] = ['a', 'b']; 15 | const result = render( {}} />); 16 | expect(result.container.firstChild).not.toBeNull(); 17 | }); 18 | 19 | it('should call onGroupByChange when a new column is selected', () => { 20 | const allColumns: readonly TableColumn[] = [{ name: 'a', type: 'string', picklistValues: [] }]; 21 | const groupBy: string[] = ['b']; 22 | const onGroupByChange = jest.fn(); 23 | const result = render(); 24 | expect(result.container.firstChild).not.toBeNull(); 25 | 26 | const multiSelect = result.getByRole('combobox'); 27 | expect(multiSelect).toBeInTheDocument(); 28 | 29 | expect(result.queryAllByText('a').length).toBe(0); // is popup closed 30 | fireEvent.keyDown(multiSelect, { key: 'ArrowDown' }); 31 | expect(result.queryAllByText('a').length).toBe(1); // is popup open 32 | fireEvent.keyDown(multiSelect, { key: 'Enter' }); 33 | expect(result.queryAllByText('a').length).toBe(0); // is popup closed 34 | expect(onGroupByChange).toBeCalledTimes(1); 35 | expect(onGroupByChange).toBeCalledWith(expect.any(Object)); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/queryBuilder/GroupByEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { InlineFormLabel, MultiSelect } from '@grafana/ui'; 3 | import { SelectableValue } from '@grafana/data'; 4 | import { TableColumn } from 'types/queryBuilder'; 5 | import labels from 'labels'; 6 | import { styles } from 'styles'; 7 | import { selectors } from 'selectors'; 8 | 9 | interface GroupByEditorProps { 10 | allColumns: readonly TableColumn[]; 11 | groupBy: string[]; 12 | onGroupByChange: (groupBy: string[]) => void; 13 | } 14 | 15 | export const GroupByEditor = (props: GroupByEditorProps) => { 16 | const { allColumns, groupBy, onGroupByChange } = props; 17 | const [isOpen, setIsOpen] = useState(false); 18 | const { label, tooltip } = labels.components.GroupByEditor; 19 | const options: Array> = allColumns.map(c => ({ label: c.name, value: c.name })); 20 | 21 | const onChange = (selection: Array>) => { 22 | setIsOpen(false); 23 | onGroupByChange(selection.map(s => s.value!)); 24 | }; 25 | 26 | return ( 27 |
28 | 29 | {label} 30 | 31 |
32 | setIsOpen(true)} 36 | onCloseMenu={() => setIsOpen(false)} 37 | value={groupBy} 38 | onChange={onChange} 39 | allowCustomValue={true} 40 | menuPlacement={'bottom'} 41 | /> 42 |
43 |
44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/queryBuilder/LimitEditor.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import { LimitEditor } from './LimitEditor'; 4 | import { selectors } from 'selectors'; 5 | 6 | describe('LimitEditor', () => { 7 | it('should render', () => { 8 | const result = render( {}} />); 9 | expect(result.container.firstChild).not.toBeNull(); 10 | }); 11 | 12 | it('should call onLimitChange when limit is changed', () => { 13 | const onLimitChange = jest.fn(); 14 | const result = render(); 15 | expect(result.container.firstChild).not.toBeNull(); 16 | 17 | const limitInput = result.getByTestId(selectors.components.QueryBuilder.LimitEditor.input); 18 | expect(limitInput).toBeInTheDocument(); 19 | fireEvent.change(limitInput, { target: { value: 5 } }); 20 | fireEvent.blur(limitInput); 21 | expect(limitInput).toHaveValue(5); 22 | expect(onLimitChange).toBeCalledTimes(1); 23 | expect(onLimitChange).toBeCalledWith(5); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/queryBuilder/LimitEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { InlineFormLabel, Input } from '@grafana/ui'; 3 | import labels from 'labels'; 4 | import { selectors } from 'selectors'; 5 | 6 | interface LimitEditorProps { 7 | limit: number; 8 | onLimitChange: (limit: number) => void; 9 | } 10 | 11 | export const LimitEditor = (props: LimitEditorProps) => { 12 | const [limit, setLimit] = useState(props.limit || 0); 13 | const { label, tooltip } = labels.components.LimitEditor; 14 | 15 | return ( 16 |
17 | 18 | {label} 19 | 20 | setLimit(e.currentTarget.valueAsNumber)} 27 | onBlur={() => props.onLimitChange(limit)} 28 | /> 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/queryBuilder/ModeSwitch.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { fireEvent, render } from '@testing-library/react'; 3 | import { ModeSwitch } from './ModeSwitch'; 4 | 5 | describe('ModeSwitch', () => { 6 | const labelA = 'A'; 7 | const labelB = 'B'; 8 | const label = 'test'; 9 | const tooltip = 'tooltip'; 10 | 11 | it('should render', () => { 12 | const result = render( 13 | {}} 18 | label={label} 19 | tooltip={tooltip} 20 | /> 21 | ); 22 | 23 | expect(result.container.firstChild).not.toBeNull(); 24 | }); 25 | 26 | it('should call onChange when mode is changed', () => { 27 | const onChange = jest.fn(); 28 | const result = render( 29 | 37 | ); 38 | expect(result.container.firstChild).not.toBeNull(); 39 | 40 | const buttonA = result.getByText(labelA); 41 | expect(buttonA).toBeInTheDocument(); 42 | const buttonB = result.getByText(labelB); 43 | expect(buttonB).toBeInTheDocument(); 44 | 45 | fireEvent.click(buttonB); 46 | expect(onChange).toBeCalledTimes(1); 47 | expect(onChange).toBeCalledWith(true); 48 | 49 | result.rerender( 50 | 58 | ); 59 | 60 | fireEvent.click(buttonA); 61 | expect(onChange).toBeCalledTimes(2); 62 | expect(onChange).toBeCalledWith(false); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/queryBuilder/ModeSwitch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RadioButtonGroup, InlineFormLabel } from '@grafana/ui'; 3 | 4 | export interface ModeSwitchProps { 5 | labelA: string; 6 | labelB: string; 7 | value: boolean; 8 | onChange: (value: boolean) => void; 9 | label: string; 10 | tooltip: string; 11 | }; 12 | 13 | /** 14 | * Component for switching between modes. Boxes are labeled unlike regular Switch. 15 | */ 16 | export const ModeSwitch = (props: ModeSwitchProps) => { 17 | const { labelA, labelB, value, onChange, label, tooltip } = props; 18 | 19 | const options = [ 20 | { 21 | label: labelA, 22 | value: false, 23 | }, 24 | { 25 | label: labelB, 26 | value: true, 27 | }, 28 | ]; 29 | 30 | return ( 31 |
32 | 33 | {label} 34 | 35 | 36 | options={options} 37 | value={value} 38 | onChange={v => onChange(v)} 39 | /> 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/queryBuilder/OtelVersionSelect.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import { OtelVersionSelect } from './OtelVersionSelect'; 4 | import otel from 'otel'; 5 | 6 | describe('OtelVersionSelect', () => { 7 | const testVersion = otel.getLatestVersion(); 8 | const testVersionName = testVersion.name; 9 | 10 | it('should render with empty properties', () => { 11 | const result = render( 12 | {}} 15 | selectedVersion="" 16 | onVersionChange={() => {}} 17 | /> 18 | ); 19 | expect(result.container.firstChild).not.toBeNull(); 20 | }); 21 | 22 | it('should render with valid properties', () => { 23 | const result = render( 24 | {}} 27 | selectedVersion={testVersion.version} 28 | onVersionChange={() => {}} 29 | /> 30 | ); 31 | expect(result.container.firstChild).not.toBeNull(); 32 | expect(result.getByText(testVersionName)).toBeInTheDocument(); 33 | }); 34 | 35 | it('should call onEnabledChange when the switch is enabled', () => { 36 | const onEnabledChange = jest.fn(); 37 | const result = render( 38 | {}} 43 | /> 44 | ); 45 | expect(result.container.firstChild).not.toBeNull(); 46 | 47 | const toggle = result.getByRole('checkbox'); 48 | expect(toggle).toBeInTheDocument(); 49 | fireEvent.click(toggle); 50 | expect(onEnabledChange).toBeCalledTimes(1); 51 | expect(onEnabledChange).toBeCalledWith(true); 52 | }); 53 | 54 | it('should call onEnabledChange when the switch is disabled', () => { 55 | const onEnabledChange = jest.fn(); 56 | const result = render( 57 | {}} 62 | /> 63 | ); 64 | expect(result.container.firstChild).not.toBeNull(); 65 | 66 | const toggle = result.getByRole('checkbox'); 67 | expect(toggle).toBeInTheDocument(); 68 | fireEvent.click(toggle); 69 | expect(onEnabledChange).toBeCalledTimes(1); 70 | expect(onEnabledChange).toBeCalledWith(false); 71 | }); 72 | 73 | it('should call onVersionChange when a new version is selected', () => { 74 | const onVersionChange = jest.fn(); 75 | const result = render( 76 | {}} 79 | selectedVersion={testVersion.version} 80 | onVersionChange={onVersionChange} 81 | /> 82 | ); 83 | expect(result.container.firstChild).not.toBeNull(); 84 | 85 | const select = result.getByRole('combobox'); 86 | expect(select).toBeInTheDocument(); 87 | fireEvent.keyDown(select, { key: 'ArrowDown' }); 88 | fireEvent.keyDown(select, { key: 'Enter' }); 89 | expect(onVersionChange).toBeCalledTimes(1); 90 | expect(onVersionChange).toBeCalledWith(expect.any(String)); 91 | }); 92 | 93 | it('should disable version selection when switch is disabled', () => { 94 | const result = render( 95 | {}} 98 | selectedVersion={testVersion.version} 99 | onVersionChange={() => {}} 100 | /> 101 | ); 102 | expect(result.container.firstChild).not.toBeNull(); 103 | 104 | const select = result.getByRole('combobox', { hidden: true }); 105 | expect(select).toBeDisabled(); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/components/queryBuilder/OtelVersionSelect.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { SelectableValue } from '@grafana/data'; 3 | import { InlineFormLabel, Select, Switch as GrafanaSwitch, useTheme } from '@grafana/ui'; 4 | import otel from 'otel'; 5 | import selectors from 'labels'; 6 | 7 | interface OtelVersionSelectProps { 8 | enabled: boolean, 9 | onEnabledChange: (enabled: boolean) => void, 10 | selectedVersion: string, 11 | onVersionChange: (version: string) => void, 12 | wide?: boolean, 13 | } 14 | 15 | export const OtelVersionSelect = (props: OtelVersionSelectProps) => { 16 | const { enabled, onEnabledChange, selectedVersion, onVersionChange, wide } = props; 17 | const { label, tooltip } = selectors.components.OtelVersionSelect; 18 | const options: SelectableValue[] = otel.versions.map(v => ({ 19 | label: v.name, 20 | value: v.version 21 | })); 22 | 23 | useEffect(() => { 24 | // Use latest version if not set or doesn't exist (which may happen if config is broken) 25 | if (selectedVersion === '' || !otel.getVersion(selectedVersion)) { 26 | onVersionChange(otel.getLatestVersion().version); 27 | } 28 | }, [selectedVersion, onVersionChange]); 29 | 30 | const theme = useTheme(); 31 | const switchContainerStyle: React.CSSProperties = { 32 | padding: `0 ${theme.spacing.sm}`, 33 | height: `${theme.spacing.formInputHeight}px`, 34 | display: 'flex', 35 | alignItems: 'center', 36 | }; 37 | 38 | return ( 39 |
40 | 41 | {label} 42 | 43 |
44 | onEnabledChange(e.currentTarget.checked)} 48 | role="checkbox" 49 | /> 50 |
51 | setInputId(e.currentTarget.value)} 33 | onBlur={() => onChange(inputId)} 34 | /> 35 |
36 | ) 37 | } 38 | 39 | export default TraceIdInput; 40 | -------------------------------------------------------------------------------- /src/components/queryBuilder/views/TableQueryBuilder.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { ColumnsEditor } from '../ColumnsEditor'; 3 | import { AggregateColumn, BuilderMode, Filter, OrderBy, QueryBuilderOptions, SelectedColumn } from 'types/queryBuilder'; 4 | import { OrderByEditor, getOrderByOptions } from '../OrderByEditor'; 5 | import { LimitEditor } from '../LimitEditor'; 6 | import { FiltersEditor } from '../FilterEditor'; 7 | import allLabels from 'labels'; 8 | import { ModeSwitch } from '../ModeSwitch'; 9 | import { AggregateEditor } from '../AggregateEditor'; 10 | import { GroupByEditor } from '../GroupByEditor'; 11 | import { Datasource } from 'data/CHDatasource'; 12 | import { useBuilderOptionChanges } from 'hooks/useBuilderOptionChanges'; 13 | import useColumns from 'hooks/useColumns'; 14 | import { BuilderOptionsReducerAction, setOptions } from 'hooks/useBuilderOptionsState'; 15 | 16 | interface TableQueryBuilderProps { 17 | datasource: Datasource; 18 | builderOptions: QueryBuilderOptions; 19 | builderOptionsDispatch: React.Dispatch; 20 | } 21 | 22 | interface TableQueryBuilderState { 23 | isAggregateMode: boolean; 24 | selectedColumns: SelectedColumn[]; 25 | aggregates: AggregateColumn[]; 26 | groupBy: string[]; 27 | orderBy: OrderBy[]; 28 | limit: number; 29 | filters: Filter[]; 30 | } 31 | 32 | export const TableQueryBuilder = (props: TableQueryBuilderProps) => { 33 | const { datasource, builderOptions, builderOptionsDispatch } = props; 34 | const allColumns = useColumns(datasource, builderOptions.database, builderOptions.table); 35 | const labels = allLabels.components.TableQueryBuilder; 36 | const builderState: TableQueryBuilderState = useMemo(() => ({ 37 | isAggregateMode: builderOptions.mode === BuilderMode.Aggregate, 38 | selectedColumns: builderOptions.columns || [], 39 | aggregates: builderOptions.aggregates || [], 40 | groupBy: builderOptions.groupBy || [], 41 | orderBy: builderOptions.orderBy || [], 42 | limit: builderOptions.limit || 0, 43 | filters: builderOptions.filters || [], 44 | }), [builderOptions]); 45 | 46 | const onOptionChange = useBuilderOptionChanges(next => { 47 | builderOptionsDispatch(setOptions({ 48 | mode: next.isAggregateMode ? BuilderMode.Aggregate : BuilderMode.List, 49 | columns: next.selectedColumns, 50 | aggregates: next.aggregates, 51 | groupBy: next.groupBy, 52 | filters: next.filters, 53 | orderBy: next.orderBy, 54 | limit: next.limit 55 | })); 56 | }, builderState); 57 | 58 | return ( 59 |
60 | 68 | 69 | 75 | 76 | {builderState.isAggregateMode && ( 77 | <> 78 | 79 | 80 | 81 | )} 82 | 83 | 88 | 89 | 97 |
98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /src/components/queryBuilder/views/timeSeriesQueryBuilderHooks.ts: -------------------------------------------------------------------------------- 1 | import { columnFilterDateTime } from 'data/columnFilters'; 2 | import { BuilderOptionsReducerAction, setColumnByHint, setOptions } from 'hooks/useBuilderOptionsState'; 3 | import React, { useEffect, useRef } from 'react'; 4 | import { ColumnHint, DateFilterWithoutValue, Filter, FilterOperator, OrderBy, OrderByDirection, SelectedColumn, TableColumn } from 'types/queryBuilder'; 5 | 6 | // Finds and selects a default log time column, updates when table changes 7 | export const useDefaultTimeColumn = (allColumns: readonly TableColumn[], table: string, timeColumn: SelectedColumn | undefined, builderOptionsDispatch: React.Dispatch) => { 8 | const didSetDefaultTime = useRef(Boolean(timeColumn)); 9 | const lastTable = useRef(table || ''); 10 | if (table !== lastTable.current) { 11 | didSetDefaultTime.current = false; 12 | } 13 | 14 | useEffect(() => { 15 | if (didSetDefaultTime.current || allColumns.length === 0 || !table) { 16 | return; 17 | } 18 | 19 | const col = allColumns.filter(columnFilterDateTime)[0]; 20 | if (!col) { 21 | return; 22 | } 23 | 24 | const timeColumn: SelectedColumn = { 25 | name: col.name, 26 | type: col.type, 27 | hint: ColumnHint.Time 28 | }; 29 | 30 | builderOptionsDispatch(setColumnByHint(timeColumn)); 31 | lastTable.current = table; 32 | didSetDefaultTime.current = true; 33 | }, [allColumns, table, builderOptionsDispatch]); 34 | }; 35 | 36 | // Apply default filters on table change 37 | export const useDefaultFilters = (table: string, isNewQuery: boolean, builderOptionsDispatch: React.Dispatch) => { 38 | const appliedDefaultFilters = useRef(!isNewQuery); 39 | const lastTable = useRef(table || ''); 40 | if (table !== lastTable.current) { 41 | appliedDefaultFilters.current = false; 42 | } 43 | 44 | useEffect(() => { 45 | if (!table || appliedDefaultFilters.current) { 46 | return; 47 | } 48 | 49 | const defaultFilters: Filter[] = [ 50 | { 51 | type: 'datetime', 52 | operator: FilterOperator.WithInGrafanaTimeRange, 53 | filterType: 'custom', 54 | key: '', 55 | hint: ColumnHint.Time, 56 | condition: 'AND' 57 | } as DateFilterWithoutValue 58 | ]; 59 | 60 | const defaultOrderBy: OrderBy[] = [ 61 | { name: '', hint: ColumnHint.Time, dir: OrderByDirection.ASC, default: true } 62 | ]; 63 | 64 | lastTable.current = table; 65 | appliedDefaultFilters.current = true; 66 | builderOptionsDispatch(setOptions({ 67 | filters: defaultFilters, 68 | orderBy: defaultOrderBy, 69 | })); 70 | }, [table, builderOptionsDispatch]); 71 | }; 72 | -------------------------------------------------------------------------------- /src/components/sqlProvider.test.ts: -------------------------------------------------------------------------------- 1 | import { formatSql } from "./sqlProvider" 2 | 3 | describe('SQL Formatter', () => { 4 | it('formats SQL', () => { 5 | const input = 'SELECT 1, 2, 3 FROM test LIMIT 1'; 6 | const expected = 'SELECT\n 1,\n 2,\n 3\nFROM\n test\nLIMIT\n 1'; 7 | 8 | const actual = formatSql(input, 1); 9 | expect(actual).toBe(expected); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/sqlProvider.ts: -------------------------------------------------------------------------------- 1 | import { Monaco, MonacoEditor, monacoTypes } from '@grafana/ui'; 2 | import { format } from 'sql-formatter'; 3 | 4 | declare const monaco: Monaco; 5 | 6 | interface Model { 7 | getValueInRange: Function; 8 | getWordUntilPosition: Function; 9 | getValue: Function; 10 | getOffsetAt: Function; 11 | } 12 | 13 | interface Position { 14 | lineNumber: number; 15 | column: number; 16 | } 17 | 18 | export interface Range { 19 | startLineNumber: number; 20 | endLineNumber: number; 21 | startColumn: number; 22 | endColumn: number; 23 | } 24 | 25 | export interface SuggestionResponse { 26 | suggestions: monacoTypes.languages.CompletionItem[]; 27 | } 28 | 29 | export interface Suggestion { 30 | label: string; 31 | kind: number; 32 | documentation: string; 33 | insertText: string; 34 | range: Range; 35 | detail?: string; 36 | sortText?: string; 37 | } 38 | 39 | export type Fetcher = { 40 | (text: string, range: Range, cursorPosition: number): Promise; 41 | }; 42 | 43 | export function formatSql(rawSql: string, tabWidth = 4): string { 44 | // The default formatter doesn't like the $, so we swap it out 45 | const macroPrefix = '$'; 46 | const swapIdentifier = 'GRAFANA_DOLLAR_TOKEN'; 47 | const removedVariables = rawSql.replaceAll(macroPrefix, swapIdentifier); 48 | const formattedRaw = format(removedVariables, { 49 | language: 'postgresql', 50 | tabWidth 51 | }); 52 | 53 | const formatted = formattedRaw.replaceAll(swapIdentifier, macroPrefix); 54 | return formatted; 55 | } 56 | 57 | export function registerSQL(lang: string, editor: MonacoEditor, fetchSuggestions: Fetcher) { 58 | // show options outside query editor 59 | editor.updateOptions({ fixedOverflowWidgets: true, scrollBeyondLastLine: false }); 60 | 61 | // const registeredLang = monaco.languages.getLanguages().find((l: Lang) => l.id === lang); 62 | // if (registeredLang !== undefined) { 63 | // return monaco.editor; 64 | // } 65 | 66 | // monaco.languages.register({ id: lang }); 67 | 68 | // just extend sql for now so we get syntax highlighting 69 | monaco.languages.registerCompletionItemProvider('sql', { 70 | triggerCharacters: [' ', '.', '$'], 71 | provideCompletionItems: async (model: Model, position: Position) => { 72 | const word = model.getWordUntilPosition(position); 73 | const range: Range = { 74 | startLineNumber: position.lineNumber, 75 | endLineNumber: position.lineNumber, 76 | startColumn: word.startColumn, 77 | endColumn: word.endColumn, 78 | }; 79 | 80 | return fetchSuggestions(model.getValue(), range, model.getOffsetAt(position)); 81 | }, 82 | }); 83 | 84 | monaco.languages.registerDocumentFormattingEditProvider('sql', { 85 | provideDocumentFormattingEdits(model, options) { 86 | return [ 87 | { 88 | range: model.getFullModelRange(), 89 | text: formatSql(model.getValue(), options.tabSize) 90 | } 91 | ]; 92 | } 93 | }); 94 | 95 | return monaco.editor; 96 | } 97 | -------------------------------------------------------------------------------- /src/components/suggestions.test.ts: -------------------------------------------------------------------------------- 1 | import { SqlFunction, TableColumn } from "types/queryBuilder"; 2 | import { getSuggestions, Schema } from "./suggestions"; 3 | import { Range } from "./sqlProvider"; 4 | import { pluginMacros } from "ch-parser/pluginMacros"; 5 | 6 | describe('Suggestions', () => { 7 | it('shows suggestions', async () => { 8 | const sql = `SELECT number, (SELECT query, FROM system.query_log LIMIT 1) FROM system.numbers LIMIT 1`; 9 | const cursorPosition = 30; // here ^ after "query" 10 | const range: Range = { 11 | startLineNumber: 0, 12 | endLineNumber: 0, 13 | startColumn: cursorPosition, 14 | endColumn: cursorPosition + 1, 15 | }; 16 | 17 | const schema: Schema = { 18 | databases: async (): Promise => ['default', 'system'], 19 | tables: async (db?: string): Promise => ['numbers', 'query_log'], 20 | columns: async (db: string, table: string): Promise => [ 21 | { label: "query", type: "String" } as TableColumn, 22 | { label: "EventDate", type: "DateTime" } as TableColumn, 23 | ], 24 | functions: async (): Promise => [ 25 | { name: 'toDateTime' } as SqlFunction, 26 | ], 27 | defaultDatabase: 'default' 28 | }; 29 | 30 | (window as any).monaco = { 31 | languages: { 32 | CompletionItemKind: { 33 | Function: 1, 34 | Field: 3, 35 | Variable: 4, 36 | Class: 5, 37 | Module: 8, 38 | }, 39 | CompletionItemInsertTextRule: { 40 | InsertAsSnippet: 4 41 | } 42 | } 43 | }; 44 | 45 | const suggestions = await getSuggestions(sql, schema, range, cursorPosition); 46 | const suggestionsByLabel = new Map(suggestions.map(s => [s.label, s])); 47 | 48 | const columnNumber = suggestionsByLabel.get('number'); 49 | expect(columnNumber).toBeUndefined(); // number is out of scope of the provided subquery 50 | 51 | // Should show all macros 52 | for (let macro of pluginMacros) { 53 | const macroSuggestion = suggestionsByLabel.get(macro.name); 54 | expect(macroSuggestion).not.toBeUndefined(); 55 | } 56 | 57 | // Should have current columns in context 58 | const columnQuery = suggestionsByLabel.get('query'); 59 | expect(columnQuery).not.toBeUndefined(); 60 | 61 | // Should show unused columns from table 62 | const columnEventDate = suggestionsByLabel.get('EventDate'); 63 | expect(columnEventDate).not.toBeUndefined(); 64 | 65 | // Should show functions 66 | const functionToDateTime = suggestionsByLabel.get('toDateTime'); 67 | expect(functionToDateTime).not.toBeUndefined(); 68 | }) 69 | }); 70 | -------------------------------------------------------------------------------- /src/components/ui/CertificationKey.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, MouseEvent, FC } from 'react'; 2 | import { Input, Button, TextArea, Field } from '@grafana/ui'; 3 | 4 | interface Props { 5 | label: string; 6 | hasCert: boolean; 7 | placeholder: string; 8 | onChange: (event: ChangeEvent) => void; 9 | onClick: (event: MouseEvent) => void; 10 | } 11 | 12 | export const CertificationKey: FC = ({ hasCert, label, onChange, onClick, placeholder }) => { 13 | return ( 14 | 15 | {hasCert ? ( 16 | <> 17 | 18 | 21 | 22 | ) : ( 23 |