├── .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 │ ├── BuildModeWebpackPlugin.ts │ ├── constants.ts │ ├── utils.ts │ └── webpack.config.ts ├── .editorconfig ├── .eslintrc ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── add-to-project.yml │ ├── publish.yaml │ ├── push.yaml │ └── update-make-docs.yml ├── .gitignore ├── .golangci.yml ├── .nvmrc ├── .prettierrc.js ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── Magefile.go ├── README.md ├── cspell.config.json ├── docker-compose.debug.yaml ├── docker-compose.yaml ├── docs ├── Makefile ├── docs.mk ├── make-docs ├── sources │ ├── _index.md │ ├── create-a-sample-dashboard │ │ └── index.md │ ├── query-editor │ │ └── index.md │ └── setup │ │ ├── _index.md │ │ ├── authenticate.md │ │ ├── configure.md │ │ ├── install.md │ │ └── provisioning.md └── variables.mk ├── go.mod ├── go.sum ├── jest-setup.js ├── jest.config.js ├── package.json ├── pkg ├── googlesheets │ ├── columndefinition.go │ ├── columndefinition_test.go │ ├── datasource.go │ ├── datasource_test.go │ ├── googleclient.go │ ├── googlesheets.go │ ├── googlesheets_bench_test.go │ ├── googlesheets_test.go │ ├── response_info_middleware.go │ ├── testdata │ │ ├── README.md │ │ ├── invalid-date-time.json │ │ ├── mixed-data.json │ │ ├── single-cell.json │ │ ├── time-formula.json │ │ └── with-formula.json │ └── utils.go ├── main.go └── models │ ├── query.go │ └── settings.go ├── playwright.config.ts ├── src ├── DataSource.ts ├── README.md ├── components │ ├── ConfigEditor.test.tsx │ ├── ConfigEditor.tsx │ ├── ConfigurationHelp.tsx │ ├── Divider.tsx │ ├── MetaInspector.tsx │ ├── QueryEditor.test.tsx │ ├── QueryEditor.tsx │ └── index.ts ├── docs │ ├── configuration.md │ ├── img │ │ ├── copy-range.png │ │ ├── dashboard.png │ │ ├── query-editor.png │ │ ├── spreadsheet.png │ │ └── spreadsheets-list.png │ ├── provisioning.md │ └── using-the-editor.md ├── img │ ├── config-page.png │ ├── dashboard.png │ ├── graph.png │ ├── query-editor.png │ ├── sheets.svg │ ├── spreadsheet.png │ └── table.png ├── module.ts ├── plugin.json ├── tracking.ts ├── types.ts ├── utils.test.ts └── utils.ts ├── tests └── e2e │ └── smoke.spec.ts ├── tsconfig.json └── yarn.lock /.config/.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5.12.4" 3 | } 4 | -------------------------------------------------------------------------------- /.config/.eslintrc: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-eslint-config 6 | */ 7 | { 8 | "extends": ["@grafana/eslint-config"], 9 | "root": true, 10 | "rules": { 11 | "react/prop-types": "off" 12 | }, 13 | "overrides": [ 14 | { 15 | "plugins": ["deprecation"], 16 | "files": ["src/**/*.{ts,tsx}"], 17 | "rules": { 18 | "deprecation/deprecation": "warn" 19 | }, 20 | "parserOptions": { 21 | "project": "./tsconfig.json" 22 | } 23 | }, 24 | { 25 | "files": ["./tests/**/*"], 26 | "rules": { 27 | "react-hooks/rules-of-hooks": "off", 28 | }, 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /.config/.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | module.exports = { 8 | endOfLine: 'auto', 9 | printWidth: 120, 10 | trailingComma: 'es5', 11 | semi: true, 12 | jsxSingleQuote: false, 13 | singleQuote: true, 14 | useTabs: false, 15 | tabWidth: 2, 16 | }; 17 | -------------------------------------------------------------------------------- /.config/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG grafana_version=latest 2 | ARG grafana_image=grafana-enterprise 3 | 4 | FROM grafana/${grafana_image}:${grafana_version} 5 | 6 | ARG anonymous_auth_enabled=true 7 | ARG development=false 8 | ARG TARGETARCH 9 | 10 | ARG GO_VERSION=1.21.6 11 | ARG GO_ARCH=${TARGETARCH:-amd64} 12 | 13 | ENV DEV "${development}" 14 | 15 | # Make it as simple as possible to access the grafana instance for development purposes 16 | # Do NOT enable these settings in a public facing / production grafana instance 17 | ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin" 18 | ENV GF_AUTH_ANONYMOUS_ENABLED "${anonymous_auth_enabled}" 19 | ENV GF_AUTH_BASIC_ENABLED "false" 20 | # Set development mode so plugins can be loaded without the need to sign 21 | ENV GF_DEFAULT_APP_MODE "development" 22 | 23 | 24 | LABEL maintainer="Grafana Labs " 25 | 26 | ENV GF_PATHS_HOME="/usr/share/grafana" 27 | WORKDIR $GF_PATHS_HOME 28 | 29 | USER root 30 | 31 | # Installing supervisor and inotify-tools 32 | RUN if [ "${development}" = "true" ]; then \ 33 | if grep -i -q alpine /etc/issue; then \ 34 | apk add supervisor inotify-tools git; \ 35 | elif grep -i -q ubuntu /etc/issue; then \ 36 | DEBIAN_FRONTEND=noninteractive && \ 37 | apt-get update && \ 38 | apt-get install -y supervisor inotify-tools git && \ 39 | rm -rf /var/lib/apt/lists/*; \ 40 | else \ 41 | echo 'ERROR: Unsupported base image' && /bin/false; \ 42 | fi \ 43 | fi 44 | 45 | COPY supervisord/supervisord.conf /etc/supervisor.d/supervisord.ini 46 | COPY supervisord/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 47 | 48 | 49 | # Installing Go 50 | RUN if [ "${development}" = "true" ]; then \ 51 | curl -O -L https://golang.org/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz && \ 52 | rm -rf /usr/local/go && \ 53 | tar -C /usr/local -xzf go${GO_VERSION}.linux-${GO_ARCH}.tar.gz && \ 54 | echo "export PATH=$PATH:/usr/local/go/bin:~/go/bin" >> ~/.bashrc && \ 55 | rm -f go${GO_VERSION}.linux-${GO_ARCH}.tar.gz; \ 56 | fi 57 | 58 | # Installing delve for debugging 59 | RUN if [ "${development}" = "true" ]; then \ 60 | /usr/local/go/bin/go install github.com/go-delve/delve/cmd/dlv@latest; \ 61 | fi 62 | 63 | # Installing mage for plugin (re)building 64 | RUN if [ "${development}" = "true" ]; then \ 65 | git clone https://github.com/magefile/mage; \ 66 | cd mage; \ 67 | export PATH=$PATH:/usr/local/go/bin; \ 68 | go run bootstrap.go; \ 69 | fi 70 | 71 | # Inject livereload script into grafana index.html 72 | RUN sed -i 's|||g' /usr/share/grafana/public/views/index.html 73 | 74 | 75 | COPY entrypoint.sh /entrypoint.sh 76 | RUN chmod +x /entrypoint.sh 77 | ENTRYPOINT ["/entrypoint.sh"] 78 | -------------------------------------------------------------------------------- /.config/README.md: -------------------------------------------------------------------------------- 1 | # Default build configuration by Grafana 2 | 3 | **This is an auto-generated directory and is not intended to be changed! ⚠️** 4 | 5 | The `.config/` directory holds basic configuration for the different tools 6 | that are used to develop, test and build the project. In order to make it updates easier we ask you to 7 | not edit files in this folder to extend configuration. 8 | 9 | ## How to extend the basic configs? 10 | 11 | Bear in mind that you are doing it at your own risk, and that extending any of the basic configuration can lead 12 | to issues around working with the project. 13 | 14 | ### Extending the ESLint config 15 | 16 | Edit the `.eslintrc` file in the project root in order to extend the ESLint configuration. 17 | 18 | **Example:** 19 | 20 | ```json 21 | { 22 | "extends": "./.config/.eslintrc", 23 | "rules": { 24 | "react/prop-types": "off" 25 | } 26 | } 27 | ``` 28 | 29 | --- 30 | 31 | ### Extending the Prettier config 32 | 33 | Edit the `.prettierrc.js` file in the project root in order to extend the Prettier configuration. 34 | 35 | **Example:** 36 | 37 | ```javascript 38 | module.exports = { 39 | // Prettier configuration provided by Grafana scaffolding 40 | ...require('./.config/.prettierrc.js'), 41 | 42 | semi: false, 43 | }; 44 | ``` 45 | 46 | --- 47 | 48 | ### Extending the Jest config 49 | 50 | There are two configuration in the project root that belong to Jest: `jest-setup.js` and `jest.config.js`. 51 | 52 | **`jest-setup.js`:** A file that is run before each test file in the suite is executed. We are using it to 53 | set up the Jest DOM for the testing library and to apply some polyfills. ([link to Jest docs](https://jestjs.io/docs/configuration#setupfilesafterenv-array)) 54 | 55 | **`jest.config.js`:** The main Jest configuration file that extends the Grafana recommended setup. ([link to Jest docs](https://jestjs.io/docs/configuration)) 56 | 57 | #### ESM errors with Jest 58 | 59 | A common issue with the current jest config involves importing an npm package that only offers an ESM build. These packages cause jest to error with `SyntaxError: Cannot use import statement outside a module`. To work around this, we provide a list of known packages to pass to the `[transformIgnorePatterns](https://jestjs.io/docs/configuration#transformignorepatterns-arraystring)` jest configuration property. If need be, this can be extended in the following way: 60 | 61 | ```javascript 62 | process.env.TZ = 'UTC'; 63 | const { grafanaESModules, nodeModulesToTransform } = require('./config/jest/utils'); 64 | 65 | module.exports = { 66 | // Jest configuration provided by Grafana 67 | ...require('./.config/jest.config'), 68 | // Inform jest to only transform specific node_module packages. 69 | transformIgnorePatterns: [nodeModulesToTransform([...grafanaESModules, 'packageName'])], 70 | }; 71 | ``` 72 | 73 | --- 74 | 75 | ### Extending the TypeScript config 76 | 77 | Edit the `tsconfig.json` file in the project root in order to extend the TypeScript configuration. 78 | 79 | **Example:** 80 | 81 | ```json 82 | { 83 | "extends": "./.config/tsconfig.json", 84 | "compilerOptions": { 85 | "preserveConstEnums": true 86 | } 87 | } 88 | ``` 89 | 90 | --- 91 | 92 | ### Extending the Webpack config 93 | 94 | Follow these steps to extend the basic Webpack configuration that lives under `.config/`: 95 | 96 | #### 1. Create a new Webpack configuration file 97 | 98 | Create a new config file that is going to extend the basic one provided by Grafana. 99 | It can live in the project root, e.g. `webpack.config.ts`. 100 | 101 | #### 2. Merge the basic config provided by Grafana and your custom setup 102 | 103 | We are going to use [`webpack-merge`](https://github.com/survivejs/webpack-merge) for this. 104 | 105 | ```typescript 106 | // webpack.config.ts 107 | import type { Configuration } from 'webpack'; 108 | import { merge } from 'webpack-merge'; 109 | import grafanaConfig from './.config/webpack/webpack.config'; 110 | 111 | const config = async (env): Promise => { 112 | const baseConfig = await grafanaConfig(env); 113 | 114 | return merge(baseConfig, { 115 | // Add custom config here... 116 | output: { 117 | asyncChunks: true, 118 | }, 119 | }); 120 | }; 121 | 122 | export default config; 123 | ``` 124 | 125 | #### 3. Update the `package.json` to use the new Webpack config 126 | 127 | We need to update the `scripts` in the `package.json` to use the extended Webpack configuration. 128 | 129 | **Update for `build`:** 130 | 131 | ```diff 132 | -"build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 133 | +"build": "webpack -c ./webpack.config.ts --env production", 134 | ``` 135 | 136 | **Update for `dev`:** 137 | 138 | ```diff 139 | -"dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 140 | +"dev": "webpack -w -c ./webpack.config.ts --env development", 141 | ``` 142 | 143 | ### Configure grafana image to use when running docker 144 | 145 | By default, `grafana-enterprise` will be used as the docker image for all docker related commands. If you want to override this behavior, simply alter the `docker-compose.yaml` by adding the following build arg `grafana_image`. 146 | 147 | **Example:** 148 | 149 | ```yaml 150 | version: '3.7' 151 | 152 | services: 153 | grafana: 154 | container_name: 'myorg-basic-app' 155 | build: 156 | context: ./.config 157 | args: 158 | grafana_version: ${GRAFANA_VERSION:-9.1.2} 159 | grafana_image: ${GRAFANA_IMAGE:-grafana} 160 | ``` 161 | 162 | In this example, we assign the environment variable `GRAFANA_IMAGE` to the build arg `grafana_image` with a default value of `grafana`. This will allow you to set the value while running the docker compose commands, which might be convenient in some scenarios. 163 | 164 | --- 165 | -------------------------------------------------------------------------------- /.config/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ "${DEV}" = "false" ]; then 4 | echo "Starting test mode" 5 | exec /run.sh 6 | fi 7 | 8 | echo "Starting development mode" 9 | 10 | if grep -i -q alpine /etc/issue; then 11 | exec /usr/bin/supervisord -c /etc/supervisord.conf 12 | elif grep -i -q ubuntu /etc/issue; then 13 | exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf 14 | else 15 | echo 'ERROR: Unsupported base image' 16 | exit 1 17 | fi 18 | 19 | -------------------------------------------------------------------------------- /.config/jest-setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-jest-config 6 | */ 7 | 8 | import '@testing-library/jest-dom'; 9 | import { TextEncoder, TextDecoder } from 'util'; 10 | 11 | Object.assign(global, { TextDecoder, TextEncoder }); 12 | 13 | // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 14 | Object.defineProperty(global, 'matchMedia', { 15 | writable: true, 16 | value: (query) => ({ 17 | matches: false, 18 | media: query, 19 | onchange: null, 20 | addListener: jest.fn(), // deprecated 21 | removeListener: jest.fn(), // deprecated 22 | addEventListener: jest.fn(), 23 | removeEventListener: jest.fn(), 24 | dispatchEvent: jest.fn(), 25 | }), 26 | }); 27 | 28 | HTMLCanvasElement.prototype.getContext = () => {}; 29 | -------------------------------------------------------------------------------- /.config/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-jest-config 6 | */ 7 | 8 | const path = require('path'); 9 | const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils'); 10 | 11 | module.exports = { 12 | moduleNameMapper: { 13 | '\\.(css|scss|sass)$': 'identity-obj-proxy', 14 | 'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'), 15 | }, 16 | modulePaths: ['/src'], 17 | setupFilesAfterEnv: ['/jest-setup.js'], 18 | testEnvironment: 'jest-environment-jsdom', 19 | testMatch: [ 20 | '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', 21 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 22 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 23 | ], 24 | transform: { 25 | '^.+\\.(t|j)sx?$': [ 26 | '@swc/jest', 27 | { 28 | sourceMaps: 'inline', 29 | jsc: { 30 | parser: { 31 | syntax: 'typescript', 32 | tsx: true, 33 | decorators: false, 34 | dynamicImport: true, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | // Jest will throw `Cannot use import statement outside module` if it tries to load an 41 | // ES module without it being transformed first. ./config/README.md#esm-errors-with-jest 42 | transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)], 43 | }; 44 | -------------------------------------------------------------------------------- /.config/jest/mocks/react-inlinesvg.tsx: -------------------------------------------------------------------------------- 1 | // Due to the grafana/ui Icon component making fetch requests to 2 | // `/public/img/icon/.svg` we need to mock react-inlinesvg to prevent 3 | // the failed fetch requests from displaying errors in console. 4 | 5 | import React from 'react'; 6 | 7 | type Callback = (...args: any[]) => void; 8 | 9 | export interface StorageItem { 10 | content: string; 11 | queue: Callback[]; 12 | status: string; 13 | } 14 | 15 | export const cacheStore: { [key: string]: StorageItem } = Object.create(null); 16 | 17 | const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/; 18 | 19 | const InlineSVG = ({ src }: { src: string }) => { 20 | // testId will be the file name without extension (e.g. `public/img/icons/angle-double-down.svg` -> `angle-double-down`) 21 | const testId = src.replace(SVG_FILE_NAME_REGEX, '$2'); 22 | return ; 23 | }; 24 | 25 | export default InlineSVG; 26 | -------------------------------------------------------------------------------- /.config/jest/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | /* 8 | * This utility function is useful in combination with jest `transformIgnorePatterns` config 9 | * to transform specific packages (e.g.ES modules) in a projects node_modules folder. 10 | */ 11 | const nodeModulesToTransform = (moduleNames) => `node_modules\/(?!.*(${moduleNames.join('|')})\/.*)`; 12 | 13 | // Array of known nested grafana package dependencies that only bundle an ESM version 14 | const grafanaESModules = [ 15 | '.pnpm', // Support using pnpm symlinked packages 16 | '@grafana/schema', 17 | 'd3', 18 | 'd3-color', 19 | 'd3-force', 20 | 'd3-interpolate', 21 | 'd3-scale-chromatic', 22 | 'ol', 23 | 'react-colorful', 24 | 'rxjs', 25 | 'uuid', 26 | ]; 27 | 28 | module.exports = { 29 | nodeModulesToTransform, 30 | grafanaESModules, 31 | }; 32 | -------------------------------------------------------------------------------- /.config/supervisord/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | 5 | [program:grafana] 6 | user=root 7 | directory=/var/lib/grafana 8 | command=bash -c 'while [ ! -f /root/grafana-googlesheets-datasource/dist/gpx_sheets* ]; 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_sheets); 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-googlesheets-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-googlesheets-datasource 41 | command=/bin/bash -c 'git config --global --add safe.directory /root/grafana-googlesheets-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/BuildModeWebpackPlugin.ts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | 3 | const PLUGIN_NAME = 'BuildModeWebpack'; 4 | 5 | export class BuildModeWebpackPlugin { 6 | apply(compiler: webpack.Compiler) { 7 | compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => { 8 | compilation.hooks.processAssets.tap( 9 | { 10 | name: PLUGIN_NAME, 11 | stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, 12 | }, 13 | async () => { 14 | const assets = compilation.getAssets(); 15 | for (const asset of assets) { 16 | if (asset.name.endsWith('plugin.json')) { 17 | const pluginJsonString = asset.source.source().toString(); 18 | const pluginJsonWithBuildMode = JSON.stringify( 19 | { 20 | ...JSON.parse(pluginJsonString), 21 | buildMode: compilation.options.mode, 22 | }, 23 | null, 24 | 4 25 | ); 26 | compilation.updateAsset(asset.name, new webpack.sources.RawSource(pluginJsonWithBuildMode)); 27 | } 28 | } 29 | } 30 | ); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.config/webpack/constants.ts: -------------------------------------------------------------------------------- 1 | export const SOURCE_DIR = 'src'; 2 | export const DIST_DIR = 'dist'; 3 | -------------------------------------------------------------------------------- /.config/webpack/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import process from 'process'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { glob } from 'glob'; 6 | import { SOURCE_DIR } from './constants'; 7 | 8 | export function isWSL() { 9 | if (process.platform !== 'linux') { 10 | return false; 11 | } 12 | 13 | if (os.release().toLowerCase().includes('microsoft')) { 14 | return true; 15 | } 16 | 17 | try { 18 | return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); 19 | } catch { 20 | return false; 21 | } 22 | } 23 | 24 | export function getPackageJson() { 25 | return require(path.resolve(process.cwd(), 'package.json')); 26 | } 27 | 28 | export function getPluginJson() { 29 | return require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`)); 30 | } 31 | 32 | export function getCPConfigVersion() { 33 | const cprcJson = path.resolve(__dirname, '../', '.cprc.json'); 34 | return fs.existsSync(cprcJson) ? require(cprcJson).version : { version: 'unknown' }; 35 | } 36 | 37 | export function hasReadme() { 38 | return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md')); 39 | } 40 | 41 | // Support bundling nested plugins by finding all plugin.json files in src directory 42 | // then checking for a sibling module.[jt]sx? file. 43 | export async function getEntries(): Promise> { 44 | const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true }); 45 | 46 | const plugins = await Promise.all( 47 | pluginsJson.map((pluginJson) => { 48 | const folder = path.dirname(pluginJson); 49 | return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true }); 50 | }) 51 | ); 52 | 53 | return plugins.reduce((result, modules) => { 54 | return modules.reduce((result, module) => { 55 | const pluginPath = path.dirname(module); 56 | const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, ''); 57 | const entryName = pluginName === '' ? 'module' : `${pluginName}/module`; 58 | 59 | result[entryName] = module; 60 | return result; 61 | }, result); 62 | }, {}); 63 | } 64 | -------------------------------------------------------------------------------- /.config/webpack/webpack.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/get-started/set-up-development-environment#extend-the-webpack-config 6 | */ 7 | 8 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 9 | import ESLintPlugin from 'eslint-webpack-plugin'; 10 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 11 | import path from 'path'; 12 | import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin'; 13 | import TerserPlugin from 'terser-webpack-plugin'; 14 | import { SubresourceIntegrityPlugin } from "webpack-subresource-integrity"; 15 | import { type Configuration, BannerPlugin } from 'webpack'; 16 | import LiveReloadPlugin from 'webpack-livereload-plugin'; 17 | import VirtualModulesPlugin from 'webpack-virtual-modules'; 18 | 19 | import { BuildModeWebpackPlugin } from './BuildModeWebpackPlugin'; 20 | import { DIST_DIR, SOURCE_DIR } from './constants'; 21 | import { getCPConfigVersion, getEntries, getPackageJson, getPluginJson, hasReadme, isWSL } from './utils'; 22 | 23 | const pluginJson = getPluginJson(); 24 | const cpVersion = getCPConfigVersion(); 25 | 26 | const virtualPublicPath = new VirtualModulesPlugin({ 27 | 'node_modules/grafana-public-path.js': ` 28 | import amdMetaModule from 'amd-module'; 29 | 30 | __webpack_public_path__ = 31 | amdMetaModule && amdMetaModule.uri 32 | ? amdMetaModule.uri.slice(0, amdMetaModule.uri.lastIndexOf('/') + 1) 33 | : 'public/plugins/${pluginJson.id}/'; 34 | `, 35 | }); 36 | 37 | const config = async (env): Promise => { 38 | const baseConfig: Configuration = { 39 | cache: { 40 | type: 'filesystem', 41 | buildDependencies: { 42 | config: [__filename], 43 | }, 44 | }, 45 | 46 | context: path.join(process.cwd(), SOURCE_DIR), 47 | 48 | devtool: env.production ? 'source-map' : 'eval-source-map', 49 | 50 | entry: await getEntries(), 51 | 52 | externals: [ 53 | // Required for dynamic publicPath resolution 54 | { 'amd-module': 'module' }, 55 | 'lodash', 56 | 'jquery', 57 | 'moment', 58 | 'slate', 59 | 'emotion', 60 | '@emotion/react', 61 | '@emotion/css', 62 | 'prismjs', 63 | 'slate-plain-serializer', 64 | '@grafana/slate-react', 65 | 'react', 66 | 'react-dom', 67 | 'react-redux', 68 | 'redux', 69 | 'rxjs', 70 | 'react-router', 71 | 'react-router-dom', 72 | 'd3', 73 | 'angular', 74 | '@grafana/ui', 75 | '@grafana/runtime', 76 | '@grafana/data', 77 | 78 | // Mark legacy SDK imports as external if their name starts with the "grafana/" prefix 79 | ({ request }, callback) => { 80 | const prefix = 'grafana/'; 81 | const hasPrefix = (request) => request.indexOf(prefix) === 0; 82 | const stripPrefix = (request) => request.substr(prefix.length); 83 | 84 | if (hasPrefix(request)) { 85 | return callback(undefined, stripPrefix(request)); 86 | } 87 | 88 | callback(); 89 | }, 90 | ], 91 | 92 | // Support WebAssembly according to latest spec - makes WebAssembly module async 93 | experiments: { 94 | asyncWebAssembly: true, 95 | }, 96 | 97 | mode: env.production ? 'production' : 'development', 98 | 99 | module: { 100 | rules: [ 101 | // This must come first in the rules array otherwise it breaks sourcemaps. 102 | { 103 | test: /src\/(?:.*\/)?module\.tsx?$/, 104 | use: [ 105 | { 106 | loader: 'imports-loader', 107 | options: { 108 | imports: `side-effects grafana-public-path`, 109 | }, 110 | }, 111 | ], 112 | }, 113 | { 114 | exclude: /(node_modules)/, 115 | test: /\.[tj]sx?$/, 116 | use: { 117 | loader: 'swc-loader', 118 | options: { 119 | jsc: { 120 | baseUrl: path.resolve(process.cwd(), SOURCE_DIR), 121 | target: 'es2015', 122 | loose: false, 123 | parser: { 124 | syntax: 'typescript', 125 | tsx: true, 126 | decorators: false, 127 | dynamicImport: true, 128 | }, 129 | }, 130 | }, 131 | }, 132 | }, 133 | { 134 | test: /\.css$/, 135 | use: ['style-loader', 'css-loader'], 136 | }, 137 | { 138 | test: /\.s[ac]ss$/, 139 | use: ['style-loader', 'css-loader', 'sass-loader'], 140 | }, 141 | { 142 | test: /\.(png|jpe?g|gif|svg)$/, 143 | type: 'asset/resource', 144 | generator: { 145 | filename: Boolean(env.production) ? '[hash][ext]' : '[file]', 146 | }, 147 | }, 148 | { 149 | test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, 150 | type: 'asset/resource', 151 | generator: { 152 | filename: Boolean(env.production) ? '[hash][ext]' : '[file]', 153 | }, 154 | }, 155 | ], 156 | }, 157 | 158 | optimization: { 159 | minimize: Boolean(env.production), 160 | minimizer: [ 161 | new TerserPlugin({ 162 | terserOptions: { 163 | format: { 164 | comments: (_, { type, value }) => type === 'comment2' && value.trim().startsWith('[create-plugin]'), 165 | }, 166 | compress: { 167 | drop_console: ['log', 'info'] 168 | } 169 | }, 170 | }), 171 | ], 172 | }, 173 | 174 | output: { 175 | clean: { 176 | keep: new RegExp(`(.*?_(amd64|arm(64)?)(.exe)?|go_plugin_build_manifest)`), 177 | }, 178 | filename: '[name].js', 179 | chunkFilename: env.production ? '[name].js?_cache=[contenthash]' : '[name].js', 180 | library: { 181 | type: 'amd', 182 | }, 183 | path: path.resolve(process.cwd(), DIST_DIR), 184 | publicPath: `public/plugins/${pluginJson.id}/`, 185 | uniqueName: pluginJson.id, 186 | crossOriginLoading: 'anonymous', 187 | }, 188 | 189 | plugins: [ 190 | new BuildModeWebpackPlugin(), 191 | virtualPublicPath, 192 | // Insert create plugin version information into the bundle 193 | new BannerPlugin({ 194 | banner: "/* [create-plugin] version: " + cpVersion + " */", 195 | raw: true, 196 | entryOnly: true, 197 | }), 198 | new CopyWebpackPlugin({ 199 | patterns: [ 200 | // If src/README.md exists use it; otherwise the root README 201 | // To `compiler.options.output` 202 | { from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true }, 203 | { from: 'plugin.json', to: '.' }, 204 | { from: '../LICENSE', to: '.' }, 205 | { from: '../CHANGELOG.md', to: '.', force: true }, 206 | { from: '**/*.json', to: '.' }, 207 | { from: '**/*.svg', to: '.', noErrorOnMissing: true }, 208 | { from: '**/*.png', to: '.', noErrorOnMissing: true }, 209 | { from: '**/*.html', to: '.', noErrorOnMissing: true }, 210 | { from: 'img/**/*', to: '.', noErrorOnMissing: true }, 211 | { from: 'libs/**/*', to: '.', noErrorOnMissing: true }, 212 | { from: 'static/**/*', to: '.', noErrorOnMissing: true }, 213 | { from: '**/query_help.md', to: '.', noErrorOnMissing: true }, 214 | ], 215 | }), 216 | // Replace certain template-variables in the README and plugin.json 217 | new ReplaceInFileWebpackPlugin([ 218 | { 219 | dir: DIST_DIR, 220 | files: ['plugin.json', 'README.md'], 221 | rules: [ 222 | { 223 | search: /\%VERSION\%/g, 224 | replace: getPackageJson().version, 225 | }, 226 | { 227 | search: /\%TODAY\%/g, 228 | replace: new Date().toISOString().substring(0, 10), 229 | }, 230 | { 231 | search: /\%PLUGIN_ID\%/g, 232 | replace: pluginJson.id, 233 | }, 234 | ], 235 | }, 236 | ]), 237 | new SubresourceIntegrityPlugin({ 238 | hashFuncNames: ["sha256"], 239 | }), 240 | ...(env.development ? [ 241 | new LiveReloadPlugin(), 242 | new ForkTsCheckerWebpackPlugin({ 243 | async: Boolean(env.development), 244 | issue: { 245 | include: [{ file: '**/*.{ts,tsx}' }], 246 | }, 247 | typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') }, 248 | }), 249 | new ESLintPlugin({ 250 | extensions: ['.ts', '.tsx'], 251 | lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files 252 | }), 253 | ] : []), 254 | ], 255 | 256 | resolve: { 257 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 258 | // handle resolving "rootDir" paths 259 | modules: [path.resolve(process.cwd(), 'src'), 'node_modules'], 260 | unsafeCache: true, 261 | }, 262 | }; 263 | 264 | if (isWSL()) { 265 | baseConfig.watchOptions = { 266 | poll: 3000, 267 | ignored: /node_modules/, 268 | }; 269 | } 270 | 271 | return baseConfig; 272 | }; 273 | 274 | export default config; 275 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | max_line_length = 120 11 | 12 | [*.go] 13 | indent_style = tab 14 | indent_size = 4 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | 19 | [*.{js,ts,tsx,scss}] 20 | quote_type = single 21 | 22 | [*.md] 23 | trim_trailing_whitespace = false 24 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/.eslintrc" 3 | } 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @grafana/oss-big-tent 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | allow: 8 | # Keep the sdk modules up-to-date 9 | - dependency-name: "github.com/grafana/grafana-plugin-sdk-go" 10 | dependency-type: "all" 11 | commit-message: 12 | prefix: "Upgrade grafana-plugin-sdk-go " 13 | include: "scope" 14 | reviewers: 15 | - "grafana/oss-big-tent" -------------------------------------------------------------------------------- /.github/workflows/add-to-project.yml: -------------------------------------------------------------------------------- 1 | name: Add issues to OSS Big Tent team project 2 | on: 3 | issues: 4 | types: 5 | - opened 6 | pull_request: 7 | types: 8 | - opened 9 | 10 | permissions: 11 | contents: read 12 | id-token: write 13 | 14 | jobs: 15 | add-to-project: 16 | name: Add issue to project 17 | runs-on: ubuntu-latest 18 | steps: 19 | - id: get-secrets 20 | uses: grafana/shared-workflows/actions/get-vault-secrets@main # zizmor: ignore[unpinned-uses] 21 | with: 22 | repo_secrets: | 23 | GITHUB_APP_ID=grafana-oss-big-tent:app-id 24 | GITHUB_APP_PRIVATE_KEY=grafana-oss-big-tent:private-key 25 | - name: Generate a token 26 | id: generate-token 27 | uses: actions/create-github-app-token@v1 28 | with: 29 | app-id: ${{ env.GITHUB_APP_ID }} 30 | private-key: ${{ env.GITHUB_APP_PRIVATE_KEY }} 31 | owner: ${{ github.repository_owner }} 32 | - uses: actions/add-to-project@main 33 | with: 34 | project-url: https://github.com/orgs/grafana/projects/457 35 | github-token: ${{ steps.generate-token.outputs.token }} 36 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Plugins - CD 2 | run-name: Deploy ${{ inputs.branch }} to ${{ inputs.environment }} by @${{ github.actor }} 3 | permissions: 4 | attestations: write 5 | contents: write 6 | id-token: write 7 | 8 | on: 9 | workflow_dispatch: 10 | inputs: 11 | branch: 12 | description: Branch to publish from. Can be used to deploy PRs to dev 13 | default: main 14 | environment: 15 | description: Environment to publish to 16 | required: true 17 | type: choice 18 | options: 19 | - 'dev' 20 | - 'ops' 21 | - 'prod' 22 | docs-only: 23 | description: Only publish docs, do not publish the plugin 24 | default: false 25 | type: boolean 26 | 27 | jobs: 28 | cd: 29 | name: CD 30 | uses: grafana/plugin-ci-workflows/.github/workflows/cd.yml@main 31 | with: 32 | go-version: '1.24' 33 | golangci-lint-version: '1.64.6' 34 | branch: ${{ github.event.inputs.branch }} 35 | environment: ${{ github.event.inputs.environment }} 36 | docs-only: ${{ fromJSON(github.event.inputs.docs-only) }} 37 | run-playwright: true 38 | -------------------------------------------------------------------------------- /.github/workflows/push.yaml: -------------------------------------------------------------------------------- 1 | name: Plugins - CI 2 | permissions: 3 | contents: read 4 | id-token: write 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | 12 | jobs: 13 | ci: 14 | name: CI 15 | uses: grafana/plugin-ci-workflows/.github/workflows/ci.yml@main 16 | with: 17 | go-version: '1.24' 18 | golangci-lint-version: '1.64.6' 19 | plugin-version-suffix: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} 20 | run-playwright: true 21 | -------------------------------------------------------------------------------- /.github/workflows/update-make-docs.yml: -------------------------------------------------------------------------------- 1 | name: Update `make docs` procedure 2 | permissions: 3 | contents: write 4 | pull-requests: write 5 | 6 | on: 7 | schedule: 8 | - cron: '0 7 * * 1-5' 9 | workflow_dispatch: 10 | jobs: 11 | main: 12 | if: github.repository == 'grafana/google-sheets-datasource' 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 16 | with: 17 | persist-credentials: false 18 | - uses: grafana/writers-toolkit/update-make-docs@update-make-docs/v1 # zizmor: ignore[unpinned-uses] 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | ci/ 4 | dist/ 5 | provisioning 6 | .eslintcache 7 | __debug_bin 8 | 9 | # Playwright 10 | /test-results/ 11 | /playwright-report/ 12 | /blob-report/ 13 | /playwright/.cache/ 14 | /playwright/.auth/ 15 | .idea 16 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | deadline: 10m 3 | build-tags: ['mage'] 4 | 5 | linters-settings: 6 | funlen: 7 | lines: 150 8 | statements: 50 9 | goimports: 10 | local-prefixes: github.com/grafana/google-sheets-datasource 11 | revive: 12 | ignore-generated-header: true 13 | enable-all-rules: true 14 | confidence: 0.8 15 | rules: 16 | # TODO: consider enabling these and figuring out the values and enabling these 17 | # instead of using a bunch of other linters (e.g. gocognit, funlen, etc.) 18 | - name: cognitive-complexity 19 | disabled: true 20 | - name: argument-limit 21 | disabled: true 22 | - name: function-length 23 | disabled: true 24 | - name: function-result-limit 25 | disabled: true 26 | - name: banned-characters 27 | disabled: true 28 | - name: file-header 29 | disabled: true 30 | - name: cyclomatic 31 | disabled: true 32 | - name: line-length-limit 33 | disabled: true 34 | - name: max-public-structs 35 | disabled: true 36 | # TODO: this should be enabled, but we'll have to add package comments first. 37 | # We should take care of that before 1.0. 38 | - name: package-comments 39 | disabled: true 40 | # TODO: this is disabled because it complains about `inline` in JSON tags (albeit for a valid reason). 41 | # See https://github.com/mgechev/revive/issues/520 for details. 42 | - name: struct-tag 43 | disabled: true 44 | # TODO: this triggers a lot of false-positives, but might be useful in theory. 45 | # Consider spending time on figuring the args. 46 | - name: add-constant 47 | disabled: true 48 | # These linters are disabled "for good". 49 | - name: confusing-naming 50 | disabled: true 51 | - name: nested-structs 52 | disabled: true 53 | - name: unused-receiver 54 | disabled: true 55 | - name: bare-return 56 | disabled: true 57 | - name: unexported-return 58 | disabled: true 59 | - name: redundant-import-alias 60 | disabled: true 61 | gocognit: 62 | min-complexity: 50 63 | gomnd: 64 | settings: 65 | mnd: 66 | # the list of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description. 67 | checks: 68 | - argument 69 | - case 70 | - condition 71 | - operation 72 | - return 73 | ignored-numbers: 0,1 74 | misspell: 75 | locale: US 76 | 77 | linters: 78 | disable-all: true 79 | enable: 80 | - bodyclose 81 | - dogsled 82 | - dupl 83 | - errcheck 84 | - copyloopvar 85 | - funlen 86 | - gocognit 87 | - goconst 88 | - gocritic 89 | - gocyclo 90 | - gofmt 91 | - goimports 92 | - goprintffuncname 93 | - gosec 94 | - gosimple 95 | - govet 96 | - ineffassign 97 | - misspell 98 | - prealloc 99 | - revive 100 | - staticcheck 101 | - typecheck 102 | - unconvert 103 | - unparam 104 | - unused 105 | - whitespace 106 | 107 | issues: 108 | # Excluding configuration per-path, per-linter, per-text and per-source 109 | exclude-rules: 110 | - path: _test\.go 111 | linters: 112 | - gomnd 113 | - gocognit 114 | - funlen 115 | - path: Magefile.go 116 | linters: 117 | - 'deadcode' 118 | - 'unused' 119 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require('./.config/.prettierrc.js'), 4 | }; 5 | -------------------------------------------------------------------------------- /.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/main.go", 10 | "env": {}, 11 | "args": ["--standalone=true"] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## 2.0.2 6 | 7 | - Update frontend dependencies 8 | - Built with go 1.24 9 | 10 | ## 2.0.1 11 | 12 | - Update documentation 13 | - Bump `github.com/grafana/grafana-plugin-sdk-go` to 0.263.0 14 | 15 | ## 2.0.0 16 | 17 | - Plugin now requires Grafana 10.4.8 or newer 18 | 19 | ## 1.2.18 20 | 21 | - Backend dependencies update 22 | - Readme changes 23 | - Replace @grafana/experimental with @grafana/plugin-ui (#294) 24 | 25 | ## 1.2.17 26 | 27 | - Improve data source documentation 28 | - Fix error source for various http errors 29 | - Bump `cross-spawn` to 7.0.6 30 | - Bump `github.com/grafana/grafana-plugin-sdk-go` to 0.259.2 31 | 32 | ## 1.2.16 33 | 34 | - Bump `uplot` to 1.6.31 35 | - Bump `github.com/grafana/grafana-plugin-sdk-go` from 0.251.0 to 0.258.0 36 | 37 | ## 1.2.15 38 | 39 | - New documentation on grafana.com #255 40 | 41 | ## 1.2.14 42 | 43 | - Bump `path-to-regexp` from 1.8.0 to 1.9.0 #268 44 | - Bump `github.com/grafana/grafana-plugin-sdk-go` from 0.250.0 to 0.251.0. #275 45 | 46 | ## 1.2.13 47 | 48 | - Update `github.com/grafana/grafana-plugin-sdk-go` to `v0.248.0` 49 | - Add logging for response size 50 | - Fix error source for invalid JWT information 51 | 52 | ## 1.2.12 53 | 54 | - Update `github.com/grafana/grafana-plugin-sdk-go` to `v0.245.0` 55 | - Bump micromatch from 4.0.7 to 4.0.8 56 | - Bump webpack from 5.92.0 to 5.94.0 57 | - Improve efficiency when processing response 58 | - Fix timeout error source 59 | - Add the make docs procedure 60 | 61 | ## 1.2.11 62 | 63 | - Update `github.com/grafana/grafana-plugin-sdk-go` to `v0.241.0` 64 | - Fix context canceled errors to be marked as downstream 65 | 66 | ## 1.2.10 67 | 68 | - Improve handling of unknown non-api errors 69 | - Add error source to error responses 70 | 71 | ## 1.2.9 72 | 73 | - Fix showing of correct percentages values 74 | - Upgrade dependencies 75 | 76 | ## 1.2.8 77 | 78 | - Upgrade dependencies 79 | 80 | ## 1.2.7 81 | 82 | - Upgrade dependencies 83 | 84 | ## 1.2.6 85 | 86 | - Build with go 1.22 87 | - Configuration help: Add additional instruction to enable Google Sheets API 88 | 89 | ## 1.2.5 90 | 91 | - Upgrade grafana-plugin-sdk-go to latest 92 | - Added lint github workflow 93 | - Remove legacy form styles 94 | 95 | ## 1.2.4 96 | 97 | - Added feature tracking 98 | - Upgrade dependencies 99 | 100 | ## 1.2.3 101 | 102 | - Make sure we don't mutate the options object in the config page. This prevents crashes that occurred intermittently. 103 | 104 | ## 1.2.2 105 | 106 | - Handle error messages more gracefully 107 | 108 | ## v1.2.0 109 | 110 | - Refactored authentication to use grafana google sdks. With this change you can now use GCE authentication with google sheets. 111 | 112 | There was a change in the plugin configuration. Please take a look at the provisioning example in the [documentation](src/docs/provisioning.md). 113 | The change is backward compatible so you can still use the old configuration. 114 | 115 | ## v1.1.8 116 | 117 | - **Chore**: Backend binaries are now compiled with golang 1.20.4 118 | 119 | ## v1.1.7 120 | 121 | - **Chore**: Update to Golang 1.20 122 | 123 | ## v1.1.6 124 | 125 | - Fix: Don't panic when the user selects a range of empty cells. 126 | 127 | ## v1.1.5 128 | 129 | - **Chore**: Update to Golang 1.19 #160 130 | 131 | ## v1.1.4 132 | 133 | - Fix: deprecated link variant button for v9 134 | 135 | ## v1.1.3 136 | 137 | - Bump grafana dependencies to 8.3.4 138 | 139 | ## v1.1.2 140 | 141 | - Change release pipeline 142 | 143 | ## v1.1.1 144 | 145 | - Targeting Grafana 8.1+ 146 | - Documentation and link fixes 147 | - Add explicit explanation to the auth constraint 148 | 149 | ## v1.1.0 150 | 151 | - Targeting Grafana 7.2+ 152 | - Adding support for annotations 153 | - Include arm builds 154 | 155 | ## v1.0.0 156 | 157 | - Works with Grafana 7+ 158 | - Avoid crashing on unknown timezone (#69) 159 | - improved support for formula values 160 | - supports template variables 161 | 162 | ## v0.9.0 163 | 164 | - First official release (grafana 6.7) 165 | 166 | ## v0.1.0 167 | 168 | - Initial Release (preview) 169 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Magefile.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | 3 | package main 4 | 5 | import ( 6 | // mage:import 7 | build "github.com/grafana/grafana-plugin-sdk-go/build" 8 | ) 9 | 10 | // Default configures the default target. 11 | var Default = build.BuildAll 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Google Sheets data source 2 | 3 | Visualize your Google Spreadsheets with Grafana 4 | 5 | ![Visualize temperature date in Grafana Google Spreadsheets data source](https://raw.githubusercontent.com/grafana/google-sheets-datasource/main/src/docs/img/dashboard.png) 6 | 7 | ![Average temperatures in Google Sheets](https://raw.githubusercontent.com/grafana/google-sheets-datasource/main/src/docs/img/spreadsheet.png) 8 | 9 | ## Documentation 10 | 11 | For the plugin documentation, visit [plugin documentation website](https://grafana.com/docs/plugins/grafana-googlesheets-datasource/). 12 | 13 | ## Video Tutorial 14 | 15 | Watch this video to learn more about setting up the Grafana Google Sheets data source plugin: 16 | 17 | [![Google Sheets data source plugin | Visualize Spreadsheets using Grafana | Tutorial](https://img.youtube.com/vi/hqeqeQFrtSA/hq720.jpg)](https://youtu.be/hqeqeQFrtSA "Grafana Google Sheets data source plugin") 18 | 19 | > [!TIP] 20 | > 21 | > ## Give it a try using Grafana Play 22 | > 23 | > With Grafana Play, you can explore and see how it works, learning from practical examples to accelerate your development. This feature can be seen on [Google Sheets data source plugin demo](https://play.grafana.org/d/ddkar8yanj56oa/visualizing-google-sheets-data). 24 | 25 | ## Development guide 26 | 27 | This is a basic guide on how to set up your local environment, make the desired changes and see the result with a fresh Grafana installation. 28 | 29 | ## Requirements 30 | 31 | You need to install the following first: 32 | 33 | - [Mage](https://magefile.org/) 34 | - [Yarn](https://yarnpkg.com/) 35 | - [Docker Compose](https://docs.docker.com/compose/) 36 | 37 | ## Running the development version 38 | 39 | ### Compiling the Backend 40 | 41 | If you have made any changes to any `go` files, you can use [mage](https://github.com/magefile/mage) to recompile the plugin. 42 | 43 | ```sh 44 | mage buildAll 45 | ``` 46 | 47 | ### Compiling the Frontend 48 | 49 | After you made the desired changes, you can build and test the new version of the plugin using `yarn`: 50 | 51 | ```sh 52 | yarn run dev # builds and puts the output at ./dist 53 | ``` 54 | 55 | The built plug-in will be in the `dist/` directory. 56 | 57 | ### Docker Compose 58 | 59 | To test the plug-in running inside Grafana, we recommend using our Docker Compose setup: 60 | 61 | ```sh 62 | docker-compose up 63 | ``` 64 | 65 | ### Test spreadsheet 66 | 67 | Need at publicly available spreadsheet to play around with? Feel free to use [this](https://docs.google.com/spreadsheets/d/1TZlZX67Y0s4CvRro_3pCYqRCKuXer81oFp_xcsjPpe8/edit?usp=sharing) demo spreadsheet that is suitable for visualization in graphs and in tables. 68 | -------------------------------------------------------------------------------- /cspell.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePaths": [ 3 | "node_modules/**", 4 | "pkg/googlesheets/testdata", 5 | "dist/**", 6 | "mage_output_file.go", 7 | "provisioning/**", 8 | "package.json", 9 | "yarn.lock" 10 | ], 11 | "words": [ 12 | "araddon", 13 | "dataproxy", 14 | "datasource", 15 | "datasources", 16 | "datasourcetest", 17 | "dateparse", 18 | "davecgh", 19 | "errorsource", 20 | "googleapi", 21 | "googlesheets", 22 | "hqeqe", 23 | "httpadapter", 24 | "httpclient", 25 | "instancemgmt", 26 | "Middlewares", 27 | "patrickmn", 28 | "percentunit", 29 | "PTRACE", 30 | "raintank", 31 | "Sdump", 32 | "seccomp", 33 | "stackdriver", 34 | "stretchr", 35 | "subresource", 36 | "testdata", 37 | "testid", 38 | "tokenprovider", 39 | "typecheck", 40 | "uplot" 41 | ], 42 | "language": "en-US" 43 | } 44 | -------------------------------------------------------------------------------- /docker-compose.debug.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | grafana: 4 | container_name: 'grafana-googlesheets-datasource' 5 | platform: linux/amd64 6 | image: grafana/grafana-enterprise:${GF_VERSION:-main} 7 | ports: 8 | - 3000:3000/tcp 9 | volumes: 10 | - ./dist:/var/lib/grafana/plugins/grafana-googlesheets-datasource 11 | - ./provisioning:/etc/grafana/provisioning 12 | environment: 13 | - TERM=linux 14 | - GF_DEFAULT_APP_MODE=development 15 | - GF_LOG_LEVEL=debug 16 | - GF_AUTH_ANONYMOUS_ENABLED=true 17 | - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin 18 | - GF_ENTERPRISE_LICENSE_TEXT=$GF_ENTERPRISE_LICENSE_TEXT 19 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | user: root 4 | container_name: 'grafana-googlesheets-datasource' 5 | 6 | build: 7 | context: ./.config 8 | args: 9 | grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} 10 | grafana_version: ${GRAFANA_VERSION:-main} 11 | development: ${DEVELOPMENT:-false} 12 | anonymous_auth_enabled: ${ANONYMOUS_AUTH_ENABLED:-true} 13 | ports: 14 | - 3000:3000/tcp 15 | - 2345:2345/tcp # delve 16 | security_opt: 17 | - 'apparmor:unconfined' 18 | - 'seccomp:unconfined' 19 | cap_add: 20 | - SYS_PTRACE 21 | volumes: 22 | - ./dist:/var/lib/grafana/plugins/grafana-googlesheets-datasource 23 | - ./provisioning:/etc/grafana/provisioning 24 | - .:/root/grafana-googlesheets-datasource 25 | 26 | environment: 27 | NODE_ENV: development 28 | GF_LOG_FILTERS: plugin.grafana-googlesheets-datasource:debug 29 | GF_LOG_LEVEL: debug 30 | GF_DATAPROXY_LOGGING: 1 31 | GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: grafana-googlesheets-datasource 32 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | .ONESHELL: 2 | .DELETE_ON_ERROR: 3 | export SHELL := bash 4 | export SHELLOPTS := pipefail:errexit 5 | MAKEFLAGS += --warn-undefined-variables 6 | MAKEFLAGS += --no-builtin-rule 7 | 8 | include docs.mk 9 | -------------------------------------------------------------------------------- /docs/docs.mk: -------------------------------------------------------------------------------- 1 | # The source of this file is https://raw.githubusercontent.com/grafana/writers-toolkit/main/docs/docs.mk. 2 | # A changelog is included in the head of the `make-docs` script. 3 | include variables.mk 4 | -include variables.mk.local 5 | 6 | .ONESHELL: 7 | .DELETE_ON_ERROR: 8 | export SHELL := bash 9 | export SHELLOPTS := pipefail:errexit 10 | MAKEFLAGS += --warn-undefined-variables 11 | MAKEFLAGS += --no-builtin-rule 12 | 13 | .DEFAULT_GOAL: help 14 | 15 | # Adapted from https://www.thapaliya.com/en/writings/well-documented-makefiles/ 16 | .PHONY: help 17 | help: ## Display this help. 18 | help: 19 | @awk 'BEGIN { \ 20 | FS = ": ##"; \ 21 | printf "Usage:\n make \n\nTargets:\n" \ 22 | } \ 23 | /^[a-zA-Z0-9_\.\-\/%]+: ##/ { printf " %-15s %s\n", $$1, $$2 }' \ 24 | $(MAKEFILE_LIST) 25 | 26 | GIT_ROOT := $(shell git rev-parse --show-toplevel) 27 | 28 | PODMAN := $(shell if command -v podman >/dev/null 2>&1; then echo podman; else echo docker; fi) 29 | 30 | ifeq ($(PROJECTS),) 31 | $(error "PROJECTS variable must be defined in variables.mk") 32 | endif 33 | 34 | # Host port to publish container port to. 35 | ifeq ($(origin DOCS_HOST_PORT), undefined) 36 | export DOCS_HOST_PORT := 3002 37 | endif 38 | 39 | # Container image used to perform Hugo build. 40 | ifeq ($(origin DOCS_IMAGE), undefined) 41 | export DOCS_IMAGE := grafana/docs-base:latest 42 | endif 43 | 44 | # Container image used for Vale linting. 45 | ifeq ($(origin VALE_IMAGE), undefined) 46 | export VALE_IMAGE := grafana/vale:latest 47 | endif 48 | 49 | # PATH-like list of directories within which to find projects. 50 | # If all projects are checked out into the same directory, ~/repos/ for example, then the default should work. 51 | ifeq ($(origin REPOS_PATH), undefined) 52 | export REPOS_PATH := $(realpath $(GIT_ROOT)/..) 53 | endif 54 | 55 | # How to treat Hugo relref errors. 56 | ifeq ($(origin HUGO_REFLINKSERRORLEVEL), undefined) 57 | export HUGO_REFLINKSERRORLEVEL := WARNING 58 | endif 59 | 60 | # Whether to pull the latest container image before running the container. 61 | ifeq ($(origin PULL), undefined) 62 | export PULL := true 63 | endif 64 | 65 | .PHONY: docs-rm 66 | docs-rm: ## Remove the docs container. 67 | $(PODMAN) rm -f $(DOCS_CONTAINER) 68 | 69 | .PHONY: docs-pull 70 | docs-pull: ## Pull documentation base image. 71 | $(PODMAN) pull -q $(DOCS_IMAGE) 72 | 73 | make-docs: ## Fetch the latest make-docs script. 74 | make-docs: 75 | if [[ ! -f "$(CURDIR)/make-docs" ]]; then 76 | echo 'WARN: No make-docs script found in the working directory. Run `make update` to download it.' >&2 77 | exit 1 78 | fi 79 | 80 | .PHONY: docs 81 | docs: ## Serve documentation locally, which includes pulling the latest `DOCS_IMAGE` (default: `grafana/docs-base:latest`) container image. To not pull the image, set `PULL=false`. 82 | ifeq ($(PULL), true) 83 | docs: docs-pull make-docs 84 | else 85 | docs: make-docs 86 | endif 87 | $(CURDIR)/make-docs $(PROJECTS) 88 | 89 | .PHONY: docs-debug 90 | docs-debug: ## Run Hugo web server with debugging enabled. TODO: support all SERVER_FLAGS defined in website Makefile. 91 | docs-debug: make-docs 92 | WEBSITE_EXEC='hugo server --bind 0.0.0.0 --port 3002 --debug' $(CURDIR)/make-docs $(PROJECTS) 93 | 94 | .PHONY: vale 95 | vale: ## Run vale on the entire docs folder which includes pulling the latest `VALE_IMAGE` (default: `grafana/vale:latest`) container image. To not pull the image, set `PULL=false`. 96 | vale: make-docs 97 | ifeq ($(PULL), true) 98 | $(PODMAN) pull -q $(VALE_IMAGE) 99 | endif 100 | DOCS_IMAGE=$(VALE_IMAGE) $(CURDIR)/make-docs $(PROJECTS) 101 | 102 | .PHONY: update 103 | update: ## Fetch the latest version of this Makefile and the `make-docs` script from Writers' Toolkit. 104 | curl -s -LO https://raw.githubusercontent.com/grafana/writers-toolkit/main/docs/docs.mk 105 | curl -s -LO https://raw.githubusercontent.com/grafana/writers-toolkit/main/docs/make-docs 106 | chmod +x make-docs 107 | 108 | # ls static/templates/ | sed 's/-template\.md//' | xargs 109 | TOPIC_TYPES := concept multiple-tasks reference section task tutorial visualization 110 | .PHONY: $(patsubst %,topic/%,$(TOPIC_TYPES)) 111 | topic/%: ## Create a topic from the Writers' Toolkit template. Specify the topic type as the target, for example, `make topic/task TOPIC_PATH=sources/my-new-topic.md`. 112 | $(patsubst %,topic/%,$(TOPIC_TYPES)): 113 | $(if $(TOPIC_PATH),,$(error "You must set the TOPIC_PATH variable to the path where the $(@F) topic will be created. For example: make $(@) TOPIC_PATH=sources/my-new-topic.md")) 114 | mkdir -p $(dir $(TOPIC_PATH)) 115 | curl -s -o $(TOPIC_PATH) https://raw.githubusercontent.com/grafana/writers-toolkit/refs/heads/main/docs/static/templates/$(@F)-template.md 116 | -------------------------------------------------------------------------------- /docs/sources/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Google Sheets data source plugin for Grafana 3 | menuTitle: Google Sheets data source 4 | description: The Google Sheets data source lets you visualize Google spreadsheet data in Grafana dashboards. 5 | keywords: 6 | - data source 7 | - google sheets 8 | - spreadsheets 9 | - xls data 10 | - xlsx data 11 | - excel sheets 12 | - excel data 13 | - csv data 14 | - visualize spreadsheets 15 | labels: 16 | products: 17 | - oss 18 | - enterprise 19 | - cloud 20 | weight: 10 21 | --- 22 | 23 | # Google Sheets data source plugin for Grafana 24 | 25 | The Google Sheets data source plugin for Grafana lets you to visualize your Google spreadsheets in Grafana. 26 | It uses the Google Sheets API to read the data which you can view in dashboard panels or [Explore](https://grafana.com/docs/grafana/latest/explore/). 27 | 28 | Watch this video to learn more about setting up the Grafana Google Sheets data source plugin: {{< youtube id="hqeqeQFrtSA">}} 29 | 30 | {{< docs/play title="Google Sheets data source plugin demo" url="https://play.grafana.org/d/ddkar8yanj56oa/visualizing-google-sheets-data" >}} 31 | 32 | ## Requirements 33 | 34 | To use the Google Sheets data source plugin, you need: 35 | 36 | - A [Google account](https://support.google.com/accounts/answer/27441?hl=en) to be able to use Google Sheets. 37 | - Any of the following Grafana editions: 38 | - A [Grafana OSS](https://grafana.com/oss/grafana/) server. 39 | - A [Grafana Cloud](https://grafana.com/pricing/) stack. 40 | - A self-managed Grafana Enterprise server with an [activated license](/docs/grafana/latest/administration/enterprise-licensing/). 41 | 42 | ## Get started 43 | 44 | - To start using the plugin, you need to generate an access token, then install and configure the plugin. To do this, refer to [Setup](./setup/). 45 | - To learn how to use the query editor, refer to [Query Editor](./query-editor/). 46 | - To quickly visualize spreadsheet data in Grafana, refer to [Create a sample dashboard](./create-a-sample-dashboard/). 47 | 48 | ## Get the most out of the plugin 49 | 50 | - Add [annotations](/docs/grafana/latest/dashboards/build-dashboards/annotate-visualizations/) 51 | - Configure and use [variables](https://grafana.com/docs/grafana/latest/dashboards/variables/) 52 | - Apply [transformations](/docs/grafana/latest/panels-visualizations/query-transform-data/transform-data/) 53 | 54 | ## Quota 55 | 56 | The Google Sheets API has per-minute quotas, and they're refilled every minute. 57 | To understand the API quotas, refer to the [Google Sheets API Usage limits documentation](https://developers.google.com/sheets/api/limits). 58 | 59 | ## Report issues 60 | 61 | Report issues, bugs, and feature requests in the [official Google Sheets data source repository](https://github.com/grafana/google-sheets-datasource/issues). 62 | -------------------------------------------------------------------------------- /docs/sources/create-a-sample-dashboard/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Create a sample dashboard using the Google Sheets data source plugin for Grafana 3 | menuTitle: Create a sample dashboard 4 | description: Create a sample Dashboard using the Google Sheets data source plugin to visualize Google Spreadsheets data in Grafana. 5 | keywords: 6 | - data source 7 | - google sheets 8 | - spreadsheets 9 | - xls data 10 | - xlsx data 11 | - excel sheets 12 | - excel data 13 | - csv data 14 | - visualize spreadsheets 15 | labels: 16 | products: 17 | - oss 18 | - enterprise 19 | - cloud 20 | weight: 400 21 | --- 22 | 23 | # Create a sample dashboard using the Google Sheets data source plugin for Grafana 24 | 25 | In this task you're going to create a sample dashboard using a publicly available [demonstration spreadsheet](https://docs.google.com/spreadsheets/d/1TZlZX67Y0s4CvRro_3pCYqRCKuXer81oFp_xcsjPpe8/edit?usp=sharing). 26 | 27 | ## Before you begin 28 | 29 | - Ensure that you have the permissions to create a dashboard and add a data source. 30 | For more information about permissions, refer to [About users and permissions](https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/). 31 | - Configure the Google Sheets data source plugin. 32 | 33 | You can authenticate with an API key to query public Google Sheets. 34 | To create an API key, refer to [Authenticate with an API key](../setup/authenticate/#authenticate-with-an-api-key). 35 | 36 | To configure the plugin, refer to [Configure the Google Sheets Plugin](../setup/configure/). 37 | 38 | ## Create a sample dashboard 39 | 40 | To create a sample dashboard: 41 | 42 | 1. Navigate to the main menu and click on **Dashboards**. 43 | 1. Click on the **New** button and select **New Dashboard**. 44 | 1. Click on the **Add visualization** button. 45 | 1. Select the Google Sheets data source plugin. 46 | 1. Browse to the [demonstration spreadsheet](https://docs.google.com/spreadsheets/d/1TZlZX67Y0s4CvRro_3pCYqRCKuXer81oFp_xcsjPpe8/edit?usp=sharing) on your browser. 47 | 1. Copy the spreadsheet ID. It should look similar to `1TZlZX67Y0s4CvRro_3pCYqRCKuXer81oFp_xcsjPpe8`. 48 | 49 | 1. Paste the spreadsheet ID into the query editor 50 | 51 | {{< figure alt="Paste the spreadsheet ID into the query editor" src="/media/docs/plugins/google-sheets-example-1.png" >}} 52 | 53 | Grafana automatically detects this data as time series data and uses the time series panel visualization to display it. 54 | 55 | {{< figure alt="Spreadsheet data visualized in the time series panel visualization" src="/media/docs/plugins/google-sheets-example-2.png" >}} 56 | 57 | You can also use other visualizations like the bar gauge visualization: 58 | {{< figure alt="Spreadsheet data visualized in the bar gauge panel visualization" src="/media/docs/plugins/google-sheets-example-3.png" >}} 59 | 60 | ## Grafana Play demonstration 61 | 62 | Grafana Play provides a reference dashboard and lets you to modify and create your own custom dashboards. 63 | 64 | {{< docs/play title="Google Sheets data source plugin demo" url="https://play.grafana.org/d/ddkar8yanj56oa/visualizing-google-sheets-data" >}} 65 | 66 | 67 | -------------------------------------------------------------------------------- /docs/sources/query-editor/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Query editor 3 | description: Learn about the query editor for the Google Sheets data source plugin to visualize Google Spreadsheets data in Grafana. 4 | keywords: 5 | - data source 6 | - google sheets 7 | - spreadsheets 8 | - xls data 9 | - xlsx data 10 | - excel sheets 11 | - excel data 12 | - csv data 13 | - visualize spreadsheets 14 | labels: 15 | products: 16 | - oss 17 | - enterprise 18 | - cloud 19 | weight: 200 20 | --- 21 | 22 | # Query editor 23 | 24 | The Google Sheets data source query editor configures the Google Sheets API query. 25 | Refer to the following sections to understand how to set each configuration option. 26 | 27 | {{< figure alt="The Google Sheets data source query editor configured to query a Google Sheet" src="/media/docs/plugins/google-sheets-query-editor-1.png" >}} 28 | 29 | ## Spreadsheet ID 30 | 31 | The **Spreadsheet ID** field controls which spreadsheet to query. 32 | 33 | You can: 34 | 35 | - Enter a spreadsheet ID. 36 | - Enter a spreadsheet URL. 37 | 38 | The query editor automatically extracts the spreadsheet ID from the URL. 39 | 40 | - Enter a spreadsheet URL including a range. 41 | 42 | The query editor automatically extracts both spreadsheet ID and range from the URL. 43 | To copy a range: 44 | 45 | 1. Open the spreadsheet. 46 | 1. Select the cells that you want to include. 47 | 1. Right-click one of the cells and choose **Get link to this range**. 48 | The link is copied to your clipboard. 49 | 50 | {{< figure alt="Google Sheets spreadsheet with selected cells and the right click menu open" src="/media/docs/plugins/google-sheets-query-editor-3.png" caption="Google Sheets spreadsheet with selected cells and the right click menu open" >}} 51 | 52 | - Select a spreadsheet from the drop-down menu. 53 | 54 | The drop-down menu is only populated if you are using Google JWT authentication. 55 | You can only view spreadsheets shared with the service account associated with the token. 56 | 57 | To configure a service account with JWT authentication, refer to [Authenticate with a service account JWT](../setup/authenticate/#authenticate-with-a-service-account-jwt). 58 | 59 | Next to the **Spreadsheet ID** field there's an external link icon. 60 | Click that icon to open the spreadsheet in Google Sheets in a new tab. 61 | 62 | ## Range 63 | 64 | The **Range** field controls the range to query. 65 | You use [A1 notation](https://developers.google.com/sheets/api/guides/concepts#a1_notation) to specify the range. If you leave the range field empty, the Google Sheets API returns the whole first sheet in the spreadsheet. 66 | 67 | {{< admonition type="tip" >}} 68 | Use a specific range to select relevant data for faster queries and to use less of your Google Sheets API quota. 69 | {{< /admonition >}} 70 | 71 | ## Cache Time 72 | 73 | The **Cache Time** field controls how long to cache the Google Sheets API response. 74 | The cache key is a combination of spreadsheet ID and range. 75 | Changing the spreadsheet ID or range results in a different cache key. 76 | 77 | The default cache time is five minutes. 78 | To bypass the cache completely, set **Cache Time** to `0s`. 79 | 80 | ## Time Filter 81 | 82 | The **Time Filter** toggle controls whether to filter rows containing cells with time fields using the dashboard time picker time. 83 | -------------------------------------------------------------------------------- /docs/sources/setup/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Set up the Google Sheets data source plugin for Grafana 3 | menuTitle: Setup 4 | description: Learn how to install and configure the Google Sheets data source plugin. 5 | keywords: 6 | - data source 7 | - google sheets 8 | - spreadsheets 9 | - xls data 10 | - xlsx data 11 | - excel sheets 12 | - excel data 13 | - csv data 14 | - visualize spreadsheets 15 | labels: 16 | products: 17 | - oss 18 | - enterprise 19 | - cloud 20 | weight: 100 21 | --- 22 | 23 | # Set up the Google Sheets data source plugin for Grafana 24 | 25 | To set up the Google Sheets data source plugin for Grafana, refer to the following topics: 26 | 27 | {{< section menuTitle="true" withDescriptions="true" >}} 28 | -------------------------------------------------------------------------------- /docs/sources/setup/authenticate.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Authenticate 3 | menuTitle: Authenticate 4 | description: Authenticate the Google Sheets data source plugin 5 | keywords: 6 | - data source 7 | - google sheets 8 | - spreadsheets 9 | - xls data 10 | - xlsx data 11 | - excel sheets 12 | - excel data 13 | - csv data 14 | - visualize spreadsheets 15 | labels: 16 | products: 17 | - oss 18 | - enterprise 19 | - cloud 20 | weight: 102 21 | --- 22 | 23 | # Authenticate the Google Sheets data source plugin 24 | 25 | The Google Sheets data source plugin uses the Google Sheet API to access the spreadsheets. 26 | It supports the following three ways of authentication: 27 | 28 | - [with an API key](#authenticate-with-an-api-key) 29 | - [with a service account JWT](#authenticate-with-a-service-account-jwt) 30 | - [with the default GCE service account](#authenticate-with-the-default-gce-service-account) 31 | 32 | ## Authenticate with an API key 33 | 34 | If a spreadsheet is shared publicly on the internet, you can access in the Google Sheets data source with an API key. 35 | The request doesn't need to be authorized, but does need to be accompanied by an identifier which is the API key. 36 | 37 | To generate an API key: 38 | 39 | 1. Before you can use the Google APIs, you need to turn them on in a Google Cloud project. 40 | To enable the Google Sheets API, refer to the [Google Sheets API page](https://console.cloud.google.com/apis/library/sheets.googleapis.com). 41 | 1. Open the [Credentials page](https://console.developers.google.com/apis/credentials) in the Google API Console. 42 | 1. Click **Create Credentials** and then **API key**. 43 | 1. Copy the API key to an editor as you will use it later to [configure the plugin](../configure/). 44 | 45 | {{< admonition type="note" >}} 46 | If you want to know how to share a file or folder, read about that in the [official Google Drive documentation](https://support.google.com/drive/answer/2494822?co=GENIE.Platform%3DDesktop&hl=en#share_publicly). 47 | {{< /admonition >}} 48 | 49 | ## Authenticate with a service account JWT 50 | 51 | If you want to access private spreadsheets, you must use a service account authentication. 52 | A Google service account is an account that belongs to a project within an account or organization instead of to an individual end user. Your application calls Google APIs on behalf of the service account, so users aren't directly involved. 53 | 54 | The project that the service account is associated with needs to be granted access to the [Google Sheets API](https://console.cloud.google.com/apis/library/sheets.googleapis.com?q=sheet) and the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com?q=drive). 55 | 56 | The Google Sheets data source uses the scope `https://www.googleapis.com/auth/spreadsheets.readonly` to get read-only access to spreadsheets. It also uses the scope `https://www.googleapis.com/auth/drive.metadata.readonly` to list all spreadsheets that the service account has access to in Google Drive. 57 | 58 | To create a service account, generate a Google JWT file and enable the APIs: 59 | 60 | 1. Before you can use the Google APIs, you need to turn them on in a Google Cloud project. 61 | To enable the Google Sheets API, refer to the [Google Sheets API page](https://console.cloud.google.com/apis/library/sheets.googleapis.com). 62 | 1. Open the [Credentials](https://console.developers.google.com/apis/credentials) page in the Google API Console. 63 | 1. Click **Create Credentials** then **Service account**. 64 | 1. Fill out the service account details form and then click **Create**. 65 | 1. On the **Service account permissions** page, don't add a role to the service account, just click **Continue**. 66 | 1. In the next step, click **Create Key**. 67 | 68 | Choose key type `JSON` and click **Create**. 69 | 70 | It creates a JSON key file that's downloaded to your computer 71 | 72 | 1. Open the [Google Sheets API page](https://console.cloud.google.com/apis/library/sheets.googleapis.com?q=sheet) and enable access for your account. 73 | 1. Open the [Google Drive API page](https://console.cloud.google.com/apis/library/drive.googleapis.com?q=drive) and enable access for your account. 74 | You need access to the Google Drive API to list all spreadsheets that you have access to. 75 | 1. Share any private files and folders you want to access with the service account's email address. 76 | The service account's email address is the `client_email` field in the JWT file. 77 | 1. Keep the JWT file on your machine as you will use it later to [configure the plugin](../configure/). 78 | 79 | ### Sharing 80 | 81 | By default, the service account doesn't have access to any spreadsheets within the account or organization that it's associated with. 82 | To grant the service account access to files and or folders in Google Drive, you need to share the file or folder with the service account's email address. 83 | The service account's email address is the `client_email` field in the JWT file 84 | To share a file or folder, refer to the [official Google drive documentation](https://support.google.com/drive/answer/2494822?co=GENIE.Platform%3DDesktop&hl=en#share_publicly). 85 | 86 | {{< admonition type="caution" >}} 87 | Beware that after you share a file or folder with the service account, all users in Grafana with permissions on the data source are able to see the spreadsheets. 88 | {{< /admonition >}} 89 | 90 | ## Authenticate with the default GCE service account 91 | 92 | When Grafana is running on a Google Compute Engine (GCE) virtual machine, Grafana can automatically retrieve default credentials from the metadata server. 93 | As a result, there is no need to generate a private key file for the service account. 94 | You also don't need to upload the file to Grafana. 95 | 96 | To authenticate with the default GCE service account: 97 | 98 | 1. You must create a service account for use by the GCE virtual machine. 99 | For more information, refer to [Create new service account](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#createanewserviceaccount). 100 | 1. Verify that the GCE virtual machine instance is running as the service account that you created. 101 | For more information, refer to [setting up an instance to run as a service account](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#using). 102 | 1. Allow access to the specified API scope. 103 | 1. Copy the project name as you will use it later to [configure the plugin](../configure/). 104 | -------------------------------------------------------------------------------- /docs/sources/setup/configure.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Configure the Google Sheets data source plugin 3 | menuTitle: Configure 4 | description: How to configure the Google Sheets data source plugin 5 | keywords: 6 | - data source 7 | - google sheets 8 | - spreadsheets 9 | - xls data 10 | - xlsx data 11 | - excel sheets 12 | - excel data 13 | - csv data 14 | - visualize spreadsheets 15 | labels: 16 | products: 17 | - oss 18 | - enterprise 19 | - cloud 20 | weight: 103 21 | --- 22 | 23 | # Configure the Google Sheets data source plugin 24 | 25 | To configure the Google Sheets data source plugin, you need to perform the following steps: 26 | 27 | 1. Navigate into Grafana and click on the menu option on the top left. 28 | 1. Browse to the **Connections** menu and then click on the **Data sources**. 29 | 1. Click on the **Add new data source** button. 30 | 1. Click on the Google Sheets data source plugin which you have installed. 31 | 1. Go to its settings tab and find the **Authentication** section. 32 | 1. It supports the following three ways of authentication: 33 | 34 | {{< tabs >}} 35 | 36 | {{< tab-content name="with an API key" >}} 37 | Before you begin, [create an API key](../authenticate/#authenticate-with-an-api-key). 38 | 39 | 1. Select the **API Key** option in the **Authentication type**. 40 | 1. Paste the API key. 41 | 1. Click **Save & Test** button and you should see a confirmation dialog box that says "Data source is working". 42 | 43 | {{< /tab-content >}} 44 | 45 | {{< tab-content name="with a service account JWT" >}} 46 | Before you begin, [create a service account and download the JWT file](../authenticate/#authenticate-with-a-service-account-jwt). 47 | 48 | 1. Select the **Google JWT File** option in the **Authentication type**. 49 | 50 | 1. You can perform one of the following three options: 51 | 52 | 1. Upload the Google JWT file by clicking the **Click to browse files** and select the JSON file you downloaded. 53 | 1. Click the **Paste JWT Token** button and paste the complete JWT token manually 54 | 1. Click the **Fill In JWT Token manually** button and provide the JWT details including Project ID, Client email, Token URI, and Private key. 55 | 56 | 1. Click **Save & Test** button and you should see a confirmation dialog box that says "Data source is working". 57 | 58 | {{< /tab-content >}} 59 | 60 | {{< tab-content name="with the default GCE service account" >}} 61 | 62 | Before you begin, set up [authentication with the default GCE service account](../authenticate/#authenticate-with-the-default-gce-service-account) 63 | 64 | 1. Select the **GCE Default Service Account** option in the **Authentication type**. 65 | 1. Type the **Default project** name 66 | 1. Click **Save & Test** button and you should see a confirmation dialog box that says "Data source is working". 67 | 68 | {{< admonition type="tip" >}} 69 | If you see errors, check the Grafana logs for troubleshooting. 70 | {{< /admonition >}} 71 | 72 | {{< /tab-content >}} 73 | {{< /tabs >}} 74 | -------------------------------------------------------------------------------- /docs/sources/setup/install.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Install the Google Sheets data source plugin for Grafana 3 | menuTitle: Install 4 | description: Learn how to install the Google Sheets data source plugin for Grafana 5 | keywords: 6 | - data source 7 | - google sheets 8 | - spreadsheets 9 | - xls data 10 | - xlsx data 11 | - excel sheets 12 | - excel data 13 | - csv data 14 | - visualize spreadsheets 15 | labels: 16 | products: 17 | - oss 18 | - enterprise 19 | - cloud 20 | weight: 101 21 | --- 22 | 23 | # Install the Google Sheets data source plugin for Grafana 24 | 25 | You can any of the following sets of steps to install the Google Sheets data source plugin for Grafana. 26 | 27 | ## Install from plugin catalog 28 | 29 | To install the plugin from the plugin catalog: 30 | 31 | 1. Sign in to Grafana as a server administrator. 32 | 1. Click **Administration** > **Plugins and data** > **Plugins** in the side navigation menu to view all plugins. 33 | 1. Type **Google Sheets** in the Search box. 34 | 1. Click the **All** in the **State** filter option. 35 | 1. Click the plugin logo. 36 | 1. Click **Install**. 37 | 38 | ## Install from the Grafana plugins page 39 | 40 | To install the plugin from the Grafana plugins page, browse to the [Google Sheets data source plugin](https://grafana.com/grafana/plugins/grafana-googlesheets-datasource/?tab=installation) and follow the instructions provided there. 41 | 42 | ## Install from GitHub 43 | 44 | To install the plugin from GitHub: 45 | 46 | 1. Browse to the [plugin GitHub releases page](https://github.com/grafana/google-sheets-datasource/releases). 47 | 48 | 1. Find the release you want to install. 49 | 50 | 1. Download the release by clicking the release asset called `grafana-googlesheets-datasource-.zip` where _`VERSION`_ is the version of the plugin you want to install. 51 | You may need to un-collapse the **Assets** section to see it. 52 | 53 | 1. Extract the plugin into the Grafana plugins directory: 54 | 55 | On Linux or macOS, run the following commands to extract the plugin: 56 | 57 | ```bash 58 | unzip grafana-googlesheets-datasource-.zip 59 | mv grafana-googlesheets-datasource /var/lib/grafana/plugins 60 | ``` 61 | 62 | On Windows, run the following command to extract the plugin: 63 | 64 | ```powershell 65 | Expand-Archive -Path grafana-googlesheets-datasource-.zip -DestinationPath C:\grafana\data\plugins 66 | ``` 67 | 68 | 1. Restart Grafana. 69 | 70 | ## Install using `grafana-cli` 71 | 72 | To install the plugin using `grafana-cli`: 73 | 74 | 1. On Linux or macOS, open your terminal and run the following command: 75 | 76 | ```bash 77 | grafana-cli plugins install grafana-googlesheets-datasource 78 | ``` 79 | 80 | On Windows, run the following command: 81 | 82 | ```shell 83 | grafana-cli.exe plugins install grafana-googlesheets-datasource 84 | ``` 85 | 86 | 1. Restart Grafana. 87 | 88 | ### Install a custom version 89 | 90 | If you need to install a custom version of the plugin using `grafana-cli`, use the `--pluginUrl` option. 91 | 92 | ```bash 93 | grafana-cli --pluginUrl plugins install grafana-googlesheets-datasource 94 | ``` 95 | 96 | For example, to install version `1.2.10` of the plugin on Linux or macOS: 97 | 98 | ```bash 99 | grafana-cli --pluginUrl https://github.com/grafana/google-sheets-datasource/releases/download/v1.2.10/grafana-googlesheets-datasource-1.2.10.zip plugins install grafana-googlesheets-datasource 100 | ``` 101 | 102 | Or to install version `1.2.10` of the plugin on Windows: 103 | 104 | ```powershell 105 | grafana-cli.exe --pluginUrl https://github.com/grafana/google-sheets-datasource/releases/download/v1.2.10/grafana-googlesheets-datasource-1.2.10.zip plugins install grafana-googlesheets-datasource 106 | ``` 107 | -------------------------------------------------------------------------------- /docs/sources/setup/provisioning.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Google Sheets data source provisioning 3 | menuTitle: Provisioning 4 | description: About provisioning the Google Sheets data source. 5 | keywords: 6 | - data source 7 | - google sheets 8 | - spreadsheets 9 | - xls data 10 | - xlsx data 11 | - excel sheets 12 | - excel data 13 | - csv data 14 | - visualize spreadsheets 15 | labels: 16 | products: 17 | - oss 18 | - enterprise 19 | - cloud 20 | weight: 104 21 | --- 22 | 23 | # Google Sheets data source provisioning 24 | 25 | You can define and configure the Google Sheets data source in YAML files with Grafana provisioning. 26 | For more information about provisioning a data source, and for available configuration options, refer to [Provision Grafana](https://grafana.com/docs/grafana/latest/administration/provisioning/#data-sources). 27 | 28 | You can provision the data source using any of the following authentication mechanisms: 29 | 30 | - [With an API key](#with-an-api-key) 31 | - [With a service account JWT](#with-a-service-account-jwt) 32 | - [With the default GCE service account](#with-the-default-gce-service-account) 33 | 34 | ## With an API key 35 | 36 | To create the API key, refer to [Authenticate with an API key](../authenticate/#authenticate-with-an-api-key). 37 | 38 | **Example** 39 | 40 | The following YAML snippet provisions the Google Sheets data source using API key authentication. 41 | Replace _``_ with your API key, and replace _``_ with the name you want to give the data source. 42 | 43 | ```yaml 44 | apiVersion: 1 45 | datasources: 46 | - name: '' 47 | type: grafana-googlesheets-datasource 48 | enabled: true 49 | jsonData: 50 | authenticationType: 'key' 51 | secureJsonData: 52 | apiKey: '' 53 | version: 1 54 | editable: true 55 | ``` 56 | 57 | ## With a service account JWT 58 | 59 | To create a service account and its JWT file, refer to [Authenticate with a service account JWT](../authenticate/#authenticate-with-a-service-account-jwt). 60 | 61 | **Example** 62 | 63 | The following YAML snippet provisions the Google Sheets data source using service account JWT authentication. 64 | Replace _``_, _``_ with your service account details, _``_ with your JWT key data, and replace _``_ with the name you want to give the data source. 65 | 66 | ```yaml 67 | apiVersion: 1 68 | datasources: 69 | - name: '' 70 | type: grafana-googlesheets-datasource 71 | enabled: true 72 | jsonData: 73 | authenticationType: 'jwt' 74 | defaultProject: '' 75 | clientEmail: '' 76 | tokenUri: 'https://oauth2.googleapis.com/token' 77 | secureJsonData: 78 | privateKey: 79 | version: 1 80 | editable: true 81 | ``` 82 | 83 | ## With the default GCE service account 84 | 85 | You can use the Google Compute Engine (GCE) default service account to authenticate data source requests if you're running Grafana on GCE. 86 | 87 | **Example** 88 | 89 | The following YAML snippet provisions the Google Sheets data source using the default GCE service account for authentication. 90 | Replace _``_ with your GCE project ID and replace _``_ with the name you want to give the data source. 91 | 92 | ```yaml 93 | apiVersion: 1 94 | datasources: 95 | - name: '' 96 | type: grafana-googlesheets-datasource 97 | enabled: true 98 | jsonData: 99 | authenticationType: 'gce' 100 | defaultProject: '' 101 | version: 1 102 | editable: true 103 | ``` 104 | -------------------------------------------------------------------------------- /docs/variables.mk: -------------------------------------------------------------------------------- 1 | # List of projects to provide to the make-docs script. 2 | # Format is PROJECT[:[VERSION][:[REPOSITORY][:[DIRECTORY]]]] 3 | # The following PROJECTS value mounts content for the "google-sheets-datasource" project, at the "latest" version, which is the default if not explicitly set. 4 | # This results in the content being served at /docs/google-sheets-datasource/latest/. 5 | # The source of the content is the current repository which is determined by the name of the parent directory of the git root. 6 | # This overrides the default behavior of assuming the repository directory is the same as the project name. 7 | PROJECTS := google-sheets-datasource::$(notdir $(basename $(shell git rev-parse --show-toplevel))) 8 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/google-sheets-datasource 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de 7 | github.com/davecgh/go-spew v1.1.1 8 | github.com/grafana/grafana-plugin-sdk-go v0.277.1 9 | github.com/patrickmn/go-cache v2.1.0+incompatible 10 | github.com/stretchr/testify v1.10.0 11 | golang.org/x/oauth2 v0.29.0 12 | google.golang.org/api v0.169.0 13 | ) 14 | 15 | require ( 16 | github.com/apache/arrow-go/v18 v18.2.0 // indirect 17 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 18 | github.com/felixge/httpsnoop v1.0.4 // indirect 19 | github.com/go-logr/logr v1.4.2 // indirect 20 | github.com/go-logr/stdr v1.2.2 // indirect 21 | github.com/goccy/go-json v0.10.5 // indirect 22 | github.com/gogo/protobuf v1.3.2 // indirect 23 | github.com/google/s2a-go v0.1.7 // indirect 24 | github.com/grafana/otel-profiling-go v0.5.1 // indirect 25 | github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect 26 | github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect 27 | github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect 28 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect 29 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 30 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 31 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 32 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect 33 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect 34 | github.com/perimeterx/marshmallow v1.1.5 // indirect 35 | github.com/prometheus/client_golang v1.20.5 // indirect 36 | github.com/stretchr/objx v0.5.2 // indirect 37 | github.com/zeebo/xxh3 v1.0.2 // indirect 38 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 39 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect 40 | go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect 41 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 42 | go.opentelemetry.io/contrib/propagators/jaeger v1.35.0 // indirect 43 | go.opentelemetry.io/contrib/samplers/jaegerremote v0.29.0 // indirect 44 | go.opentelemetry.io/otel v1.35.0 // indirect 45 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect 46 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect 47 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 48 | go.opentelemetry.io/otel/sdk v1.35.0 // indirect 49 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 50 | go.opentelemetry.io/proto/otlp v1.5.0 // indirect 51 | golang.org/x/crypto v0.37.0 // indirect 52 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect 53 | golang.org/x/mod v0.23.0 // indirect 54 | golang.org/x/sync v0.13.0 // indirect 55 | golang.org/x/tools v0.30.0 // indirect 56 | google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect 57 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 58 | ) 59 | 60 | require ( 61 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 62 | github.com/BurntSushi/toml v1.4.0 // indirect 63 | github.com/beorn7/perks v1.0.1 // indirect 64 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 65 | github.com/cheekybits/genny v1.0.0 // indirect 66 | github.com/chromedp/cdproto v0.0.0-20250429231605-6ed5b53462d4 // indirect 67 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 68 | github.com/elazarl/goproxy v1.7.2 // indirect 69 | github.com/fatih/color v1.15.0 // indirect 70 | github.com/getkin/kin-openapi v0.132.0 // indirect 71 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 72 | github.com/go-openapi/swag v0.23.0 // indirect 73 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 74 | github.com/golang/protobuf v1.5.4 // indirect 75 | github.com/google/flatbuffers v25.2.10+incompatible // indirect 76 | github.com/google/go-cmp v0.7.0 // indirect 77 | github.com/google/uuid v1.6.0 // indirect 78 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect 79 | github.com/googleapis/gax-go/v2 v2.12.2 // indirect 80 | github.com/gorilla/mux v1.8.1 // indirect 81 | github.com/grafana/grafana-google-sdk-go v0.2.1 82 | github.com/hashicorp/go-hclog v1.6.3 // indirect 83 | github.com/hashicorp/go-plugin v1.6.3 // indirect 84 | github.com/hashicorp/yamux v0.1.1 // indirect 85 | github.com/josharian/intern v1.0.0 // indirect 86 | github.com/json-iterator/go v1.1.12 // indirect 87 | github.com/klauspost/compress v1.18.0 // indirect 88 | github.com/magefile/mage v1.15.0 // indirect 89 | github.com/mailru/easyjson v0.7.7 // indirect 90 | github.com/mattetti/filebuffer v1.0.1 // indirect 91 | github.com/mattn/go-colorable v0.1.13 // indirect 92 | github.com/mattn/go-isatty v0.0.20 // indirect 93 | github.com/mattn/go-runewidth v0.0.16 // indirect 94 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 95 | github.com/modern-go/reflect2 v1.0.2 // indirect 96 | github.com/oklog/run v1.1.0 // indirect 97 | github.com/olekukonko/tablewriter v0.0.5 // indirect 98 | github.com/pierrec/lz4/v4 v4.1.22 // indirect 99 | github.com/pkg/errors v0.9.1 100 | github.com/pmezard/go-difflib v1.0.0 // indirect 101 | github.com/prometheus/client_model v0.6.1 // indirect 102 | github.com/prometheus/common v0.63.0 // indirect 103 | github.com/prometheus/procfs v0.15.1 // indirect 104 | github.com/rivo/uniseg v0.4.4 // indirect 105 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 106 | github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect 107 | github.com/unknwon/com v1.0.1 // indirect 108 | github.com/unknwon/log v0.0.0-20200308114134-929b1006e34a // indirect 109 | github.com/urfave/cli v1.22.16 // indirect 110 | go.opencensus.io v0.24.0 // indirect 111 | golang.org/x/net v0.39.0 // indirect 112 | golang.org/x/sys v0.32.0 // indirect 113 | golang.org/x/text v0.24.0 // indirect 114 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 115 | google.golang.org/grpc v1.71.1 116 | google.golang.org/protobuf v1.36.6 // indirect 117 | gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect 118 | gopkg.in/yaml.v3 v3.0.1 // indirect 119 | ) 120 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup provided by Grafana scaffolding 2 | import './.config/jest-setup'; 3 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // force timezone to UTC to allow tests to work regardless of local timezone 2 | // generally used by snapshots, but can affect specific tests 3 | process.env.TZ = 'UTC'; 4 | 5 | module.exports = { 6 | // Jest configuration provided by Grafana scaffolding 7 | ...require('./.config/jest.config'), 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grafana-google-sheets-datasource", 3 | "version": "2.0.2", 4 | "description": "Load data from google sheets in grafana", 5 | "scripts": { 6 | "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 7 | "dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 8 | "e2e": "yarn playwright test", 9 | "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", 10 | "lint:fix": "yarn run lint --fix && prettier --write --list-different .", 11 | "server": "docker compose up --build", 12 | "server:debug": "docker compose -f ./docker-compose.debug.yaml up", 13 | "sign": "npx --yes @grafana/sign-plugin@latest", 14 | "spellcheck": "cspell -c cspell.config.json \"**/*.{ts,tsx,js,go,md,mdx,yml,yaml,json,scss,css}\"", 15 | "test": "jest --watch --onlyChanged", 16 | "test:ci": "jest --passWithNoTests --maxWorkers 4", 17 | "test:e2e": "grafana-e2e run", 18 | "test:e2e:update": "grafana-e2e run --update-screenshots", 19 | "typecheck": "tsc --noEmit" 20 | }, 21 | "repository": "github:grafana/google-sheets-datasource", 22 | "author": "Grafana Labs (https://grafana.com)", 23 | "license": "Apache-2.0", 24 | "devDependencies": { 25 | "@babel/core": "^7.21.4", 26 | "@grafana/eslint-config": "^8.0.0", 27 | "@grafana/plugin-e2e": "^1.16.2", 28 | "@grafana/tsconfig": "^2.0.0", 29 | "@playwright/test": "^1.50.0", 30 | "@stylistic/eslint-plugin-ts": "^2.9.0", 31 | "@swc/core": "^1.3.90", 32 | "@swc/helpers": "^0.5.0", 33 | "@swc/jest": "^0.2.26", 34 | "@testing-library/jest-dom": "6.1.4", 35 | "@testing-library/react": "14.0.0", 36 | "@types/glob": "^8.0.0", 37 | "@types/jest": "^29.5.0", 38 | "@types/lodash": "^4.14.194", 39 | "@types/node": "^20.8.7", 40 | "@types/react-router-dom": "^5.2.0", 41 | "@types/testing-library__jest-dom": "5.14.8", 42 | "@typescript-eslint/eslint-plugin": "^6.18.0", 43 | "@typescript-eslint/parser": "^6.18.0", 44 | "copy-webpack-plugin": "^11.0.0", 45 | "cspell": "6.26.3", 46 | "css-loader": "^6.7.3", 47 | "eslint": "^8.0.0", 48 | "eslint-config-prettier": "^8.8.0", 49 | "eslint-plugin-deprecation": "^2.0.0", 50 | "eslint-plugin-jsdoc": "^46.8.0", 51 | "eslint-plugin-prettier": "^5.0.0", 52 | "eslint-plugin-react": "^7.33.0", 53 | "eslint-plugin-react-hooks": "^4.6.0", 54 | "eslint-webpack-plugin": "^4.0.1", 55 | "fork-ts-checker-webpack-plugin": "^8.0.0", 56 | "glob": "^10.2.7", 57 | "identity-obj-proxy": "3.0.0", 58 | "imports-loader": "^5.0.0", 59 | "jest": "^29.5.0", 60 | "jest-environment-jsdom": "^29.5.0", 61 | "prettier": "^2.8.7", 62 | "replace-in-file-webpack-plugin": "^1.0.6", 63 | "sass": "1.63.2", 64 | "sass-loader": "13.3.1", 65 | "style-loader": "3.3.3", 66 | "swc-loader": "^0.2.3", 67 | "terser-webpack-plugin": "^5.3.10", 68 | "ts-node": "^10.9.2", 69 | "tsconfig-paths": "^4.2.0", 70 | "typescript": "5.5.4", 71 | "webpack": "^5.94.0", 72 | "webpack-cli": "^5.1.4", 73 | "webpack-livereload-plugin": "^3.0.2", 74 | "webpack-subresource-integrity": "^5.1.0", 75 | "webpack-virtual-modules": "^0.6.2" 76 | }, 77 | "dependencies": { 78 | "@emotion/css": "11.10.6", 79 | "@grafana/data": "10.4.8", 80 | "@grafana/google-sdk": "^0.1.1", 81 | "@grafana/plugin-ui": "^0.9.1", 82 | "@grafana/runtime": "10.4.8", 83 | "@grafana/schema": "10.4.8", 84 | "@grafana/ui": "10.4.8", 85 | "react": "18.2.0", 86 | "react-dom": "18.2.0", 87 | "tslib": "2.5.3" 88 | }, 89 | "resolutions": { 90 | "uplot": "1.6.31", 91 | "prismjs": "1.30.0" 92 | }, 93 | "packageManager": "yarn@1.22.22" 94 | } 95 | -------------------------------------------------------------------------------- /pkg/googlesheets/columndefinition.go: -------------------------------------------------------------------------------- 1 | package googlesheets 2 | 3 | import ( 4 | "strings" 5 | 6 | "google.golang.org/api/sheets/v4" 7 | ) 8 | 9 | // ColumnType is the set of possible column types 10 | type ColumnType string 11 | 12 | const ( 13 | // ColumTypeTime is the TIME type 14 | ColumTypeTime ColumnType = "TIME" 15 | // ColumTypeNumber is the NUMBER type 16 | ColumTypeNumber = "NUMBER" 17 | // ColumTypeString is the STRING type 18 | ColumTypeString = "STRING" 19 | ) 20 | 21 | // ColumnDefinition represents a spreadsheet column definition. 22 | type ColumnDefinition struct { 23 | Header string 24 | ColumnIndex int 25 | types map[ColumnType]bool 26 | units map[string]bool 27 | } 28 | 29 | // NewColumnDefinition creates a new ColumnDefinition. 30 | func NewColumnDefinition(header string, index int) *ColumnDefinition { 31 | return &ColumnDefinition{ 32 | Header: header, 33 | ColumnIndex: index, 34 | types: map[ColumnType]bool{}, 35 | units: map[string]bool{}, 36 | } 37 | } 38 | 39 | // CheckCell checks a ColumnDefinition's cell. 40 | func (cd *ColumnDefinition) CheckCell(cell *sheets.CellData) { 41 | cd.checkType(cell) 42 | cd.checkUnit(cell) 43 | } 44 | 45 | // GetType gets the type of a ColumnDefinition. 46 | func (cd *ColumnDefinition) GetType() ColumnType { 47 | if len(cd.types) == 1 { 48 | for columnType := range cd.types { 49 | return columnType 50 | } 51 | } 52 | 53 | // The column has mixed or no data types - fallback to string 54 | return ColumTypeString 55 | } 56 | 57 | // GetUnit gets the unit of a ColumnDefinition. 58 | func (cd *ColumnDefinition) GetUnit() string { 59 | if len(cd.units) == 1 { 60 | for unit := range cd.units { 61 | return unit 62 | } 63 | } 64 | 65 | return "" 66 | } 67 | 68 | // HasMixedTypes returns whether a ColumnDefinition has mixed types. 69 | func (cd *ColumnDefinition) HasMixedTypes() bool { 70 | return len(cd.types) > 1 71 | } 72 | 73 | // HasMixedUnits returns whether a ColumnDefinition has mixed units. 74 | func (cd *ColumnDefinition) HasMixedUnits() bool { 75 | return len(cd.units) > 1 76 | } 77 | 78 | func (cd *ColumnDefinition) checkType(cell *sheets.CellData) { 79 | if cell == nil || cell.FormattedValue == "" { 80 | return 81 | } 82 | 83 | // Has a number value (will not detect 0) 84 | hasNumberValue := cell.EffectiveValue != nil && cell.EffectiveValue.NumberValue != nil && *cell.EffectiveValue.NumberValue != 0 85 | hasNumberFormat := cell.EffectiveFormat != nil && cell.EffectiveFormat.NumberFormat != nil 86 | 87 | if hasNumberFormat { 88 | if cell.EffectiveFormat.NumberFormat.Type == "DATE" || 89 | cell.EffectiveFormat.NumberFormat.Type == "DATE_TIME" { 90 | cd.types["TIME"] = true 91 | return 92 | } 93 | } 94 | 95 | if hasNumberFormat || hasNumberValue || cell.FormattedValue == "0" { 96 | cd.types["NUMBER"] = true 97 | } else { 98 | cd.types["STRING"] = true 99 | } 100 | } 101 | 102 | var unitMappings = map[string]string{ 103 | "$": "currencyUSD", 104 | "£": "currencyGBP", 105 | "€": "currencyEUR", 106 | "¥": "currencyJPY", 107 | "₽": "currencyRUB", 108 | "₴": "currencyUAH", 109 | "R$": "currencyBRL", 110 | "kr.": "currencyDKK", 111 | "kr": "currencySEK", 112 | "czk": "currencyCZK", 113 | "CHF": "currencyCHF", 114 | "PLN": "currencyPLN", 115 | "฿": "currencyBTC", 116 | "R": "currencyZAR", 117 | "₹": "currencyINR", 118 | "₩": "currencyKRW", 119 | } 120 | 121 | // A lot more that can be done/improved here. For example it should be possible to extract 122 | // the number of decimals from the pattern. Read more here: https://developers.google.com/sheets/api/guides/formats 123 | func (cd *ColumnDefinition) checkUnit(cellData *sheets.CellData) { 124 | if cellData == nil || cellData.UserEnteredFormat == nil || cellData.UserEnteredFormat.NumberFormat == nil { 125 | return 126 | } 127 | 128 | switch cellData.UserEnteredFormat.NumberFormat.Type { 129 | case "NUMBER": 130 | for unit, unitID := range unitMappings { 131 | if strings.Contains(cellData.UserEnteredFormat.NumberFormat.Pattern, unit) { 132 | cd.units[unitID] = true 133 | } 134 | } 135 | case "PERCENT": 136 | cd.units["percentunit"] = true 137 | case "CURRENCY": 138 | for unit, unitID := range unitMappings { 139 | if strings.Contains(cellData.FormattedValue, unit) { 140 | cd.units[unitID] = true 141 | } 142 | } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /pkg/googlesheets/columndefinition_test.go: -------------------------------------------------------------------------------- 1 | package googlesheets 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | "google.golang.org/api/sheets/v4" 9 | ) 10 | 11 | func loadTestGridData(path string) (*sheets.GridData, error) { 12 | data, err := loadTestSheet(path) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | sheet := data.Sheets[0].Data[0] 18 | 19 | return sheet, nil 20 | } 21 | 22 | func TestColumnDefinition(t *testing.T) { 23 | sheet, err := loadTestGridData("./testdata/mixed-data.json") 24 | require.Nil(t, err) 25 | 26 | t.Run("TestDataTypes", func(t *testing.T) { 27 | t.Run("Mixed types detected", func(t *testing.T) { 28 | column := NewColumnDefinition(sheet.RowData[0].Values[10].FormattedValue, 10) 29 | for rowIndex := 1; rowIndex < len(sheet.RowData); rowIndex++ { 30 | column.CheckCell(sheet.RowData[rowIndex].Values[column.ColumnIndex]) 31 | } 32 | 33 | assert.True(t, column.HasMixedTypes()) 34 | assert.True(t, column.types["STRING"]) 35 | assert.True(t, column.types["NUMBER"]) 36 | }) 37 | 38 | t.Run("Mixed types not detected", func(t *testing.T) { 39 | column := NewColumnDefinition(sheet.RowData[0].Values[0].FormattedValue, 0) 40 | for rowIndex := 1; rowIndex < len(sheet.RowData); rowIndex++ { 41 | column.CheckCell(sheet.RowData[rowIndex].Values[column.ColumnIndex]) 42 | } 43 | 44 | assert.False(t, column.HasMixedTypes()) 45 | }) 46 | }) 47 | 48 | t.Run("TestUnits", func(t *testing.T) { 49 | t.Run("Mixed units detected", func(t *testing.T) { 50 | column := NewColumnDefinition(sheet.RowData[0].Values[11].FormattedValue, 11) 51 | for rowIndex := 1; rowIndex < len(sheet.RowData); rowIndex++ { 52 | column.CheckCell(sheet.RowData[rowIndex].Values[column.ColumnIndex]) 53 | } 54 | 55 | assert.True(t, column.HasMixedUnits()) 56 | }) 57 | 58 | t.Run("Mixed units not detected", func(t *testing.T) { 59 | column := NewColumnDefinition(sheet.RowData[0].Values[0].FormattedValue, 0) 60 | for rowIndex := 1; rowIndex < len(sheet.RowData); rowIndex++ { 61 | column.CheckCell(sheet.RowData[rowIndex].Values[column.ColumnIndex]) 62 | } 63 | 64 | assert.False(t, column.HasMixedUnits()) 65 | }) 66 | 67 | t.Run("Currency unit mapping", func(t *testing.T) { 68 | const currencyColumnIndex int = 14 69 | 70 | t.Run("SEK", func(t *testing.T) { 71 | column := NewColumnDefinition("SEK", currencyColumnIndex) 72 | column.CheckCell(sheet.RowData[1].Values[currencyColumnIndex]) 73 | assert.Equal(t, "currencySEK", column.GetUnit()) 74 | }) 75 | 76 | t.Run("USD", func(t *testing.T) { 77 | column := NewColumnDefinition("USD", currencyColumnIndex) 78 | column.CheckCell(sheet.RowData[4].Values[currencyColumnIndex]) 79 | assert.Equal(t, "currencyUSD", column.GetUnit()) 80 | }) 81 | 82 | t.Run("GBP", func(t *testing.T) { 83 | column := NewColumnDefinition("GBP", currencyColumnIndex) 84 | column.CheckCell(sheet.RowData[5].Values[currencyColumnIndex]) 85 | assert.Equal(t, "currencyGBP", column.GetUnit()) 86 | }) 87 | 88 | t.Run("EUR", func(t *testing.T) { 89 | column := NewColumnDefinition("EUR", currencyColumnIndex) 90 | column.CheckCell(sheet.RowData[6].Values[currencyColumnIndex]) 91 | assert.Equal(t, "currencyEUR", column.GetUnit()) 92 | }) 93 | 94 | t.Run("JPY", func(t *testing.T) { 95 | column := NewColumnDefinition("JPY", currencyColumnIndex) 96 | column.CheckCell(sheet.RowData[7].Values[currencyColumnIndex]) 97 | assert.Equal(t, "currencyJPY", column.GetUnit()) 98 | }) 99 | 100 | t.Run("RUB", func(t *testing.T) { 101 | column := NewColumnDefinition("RUB", currencyColumnIndex) 102 | column.CheckCell(sheet.RowData[8].Values[currencyColumnIndex]) 103 | assert.Equal(t, "currencyRUB", column.GetUnit()) 104 | }) 105 | 106 | t.Run("CHF", func(t *testing.T) { 107 | column := NewColumnDefinition("CHF", currencyColumnIndex) 108 | column.CheckCell(sheet.RowData[9].Values[currencyColumnIndex]) 109 | assert.Equal(t, "currencyCHF", column.GetUnit()) 110 | }) 111 | 112 | t.Run("INR", func(t *testing.T) { 113 | column := NewColumnDefinition("INR", currencyColumnIndex) 114 | column.CheckCell(sheet.RowData[10].Values[currencyColumnIndex]) 115 | assert.Equal(t, "currencyINR", column.GetUnit()) 116 | }) 117 | }) 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /pkg/googlesheets/datasource.go: -------------------------------------------------------------------------------- 1 | package googlesheets 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/grafana/google-sheets-datasource/pkg/models" 11 | 12 | "github.com/grafana/grafana-plugin-sdk-go/backend" 13 | "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" 14 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 15 | "github.com/grafana/grafana-plugin-sdk-go/backend/resource/httpadapter" 16 | "github.com/patrickmn/go-cache" 17 | ) 18 | 19 | var ( 20 | _ backend.QueryDataHandler = (*Datasource)(nil) 21 | _ backend.CheckHealthHandler = (*Datasource)(nil) 22 | _ backend.CallResourceHandler = (*Datasource)(nil) 23 | ) 24 | 25 | type Datasource struct { 26 | googlesheets *GoogleSheets 27 | 28 | backend.CallResourceHandler 29 | } 30 | 31 | // NewDatasource creates a new Google Sheets datasource instance. 32 | func NewDatasource(_ context.Context, _ backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { 33 | ds := &Datasource{ 34 | googlesheets: &GoogleSheets{Cache: cache.New(300*time.Second, 5*time.Second)}, 35 | } 36 | 37 | mux := http.NewServeMux() 38 | mux.HandleFunc("/spreadsheets", ds.handleResourceSpreadsheets) 39 | ds.CallResourceHandler = httpadapter.New(mux) 40 | 41 | return ds, nil 42 | } 43 | 44 | // CheckHealth checks if the datasource is working. 45 | func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { 46 | logger := backend.Logger.FromContext(ctx) 47 | res := &backend.CheckHealthResult{} 48 | logger.Debug("CheckHealth called") 49 | config, err := models.LoadSettings(req.PluginContext) 50 | 51 | if err != nil { 52 | res.Status = backend.HealthStatusError 53 | res.Message = "Unable to load settings" 54 | logger.Debug(err.Error()) 55 | return res, nil 56 | } 57 | 58 | client, err := NewGoogleClient(ctx, *config) 59 | if err != nil { 60 | res.Status = backend.HealthStatusError 61 | res.Message = "Unable to create client" 62 | logger.Debug(err.Error()) 63 | return res, nil 64 | } 65 | 66 | err = client.TestClient() 67 | if err != nil { 68 | res.Status = backend.HealthStatusError 69 | res.Message = "Permissions check failed" 70 | logger.Debug(err.Error()) 71 | return res, nil 72 | } 73 | 74 | res.Status = backend.HealthStatusOk 75 | res.Message = "Success" 76 | return res, nil 77 | } 78 | 79 | // QueryData handles queries to the datasource. 80 | func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { 81 | logger := backend.Logger.FromContext(ctx) 82 | // create response struct 83 | response := backend.NewQueryDataResponse() 84 | 85 | logger.Debug("QueryData called", "numQueries", len(req.Queries)) 86 | 87 | config, err := models.LoadSettings(req.PluginContext) 88 | if err != nil { 89 | return nil, err 90 | } 91 | 92 | for _, q := range req.Queries { 93 | queryModel, err := models.GetQueryModel(q) 94 | if err != nil { 95 | return nil, fmt.Errorf("failed to read query: %w", err) 96 | } 97 | 98 | if len(queryModel.Spreadsheet) < 1 { 99 | continue // not query really exists 100 | } 101 | dr := d.googlesheets.Query(ctx, q.RefID, queryModel, *config, q.TimeRange) 102 | if dr.Error != nil { 103 | if dr.ErrorSource == backend.ErrorSourceDownstream { 104 | // For downstream errors, we log them as warnings as they are not caused by the plugin itself 105 | logger.Debug("Query failed", "refId", q.RefID, "error", dr.Error, "errorsource", dr.ErrorSource) 106 | } else { 107 | logger.Error("Query failed", "refId", q.RefID, "error", dr.Error, "errorsource", dr.ErrorSource) 108 | } 109 | } 110 | response.Responses[q.RefID] = dr 111 | } 112 | 113 | return response, nil 114 | } 115 | 116 | func writeResult(rw http.ResponseWriter, path string, val any, err error) { 117 | response := make(map[string]any) 118 | code := http.StatusOK 119 | if err != nil { 120 | response["error"] = err.Error() 121 | code = http.StatusBadRequest 122 | } else { 123 | response[path] = val 124 | } 125 | 126 | body, err := json.Marshal(response) 127 | if err != nil { 128 | body = []byte(err.Error()) 129 | code = http.StatusInternalServerError 130 | } 131 | _, err = rw.Write(body) 132 | if err != nil { 133 | code = http.StatusInternalServerError 134 | } 135 | rw.WriteHeader(code) 136 | } 137 | 138 | func (d *Datasource) handleResourceSpreadsheets(rw http.ResponseWriter, req *http.Request) { 139 | log.DefaultLogger.Debug("Received resource call", "url", req.URL.String()) 140 | if req.Method != http.MethodGet { 141 | return 142 | } 143 | 144 | ctx := req.Context() 145 | config, err := models.LoadSettings(backend.PluginConfigFromContext(ctx)) 146 | if err != nil { 147 | writeResult(rw, "?", nil, err) 148 | return 149 | } 150 | 151 | res, err := d.googlesheets.GetSpreadsheets(ctx, *config) 152 | writeResult(rw, "spreadsheets", res, err) 153 | } 154 | -------------------------------------------------------------------------------- /pkg/googlesheets/datasource_test.go: -------------------------------------------------------------------------------- 1 | package googlesheets 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" 11 | "github.com/grafana/grafana-plugin-sdk-go/experimental/datasourcetest" 12 | "github.com/patrickmn/go-cache" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "google.golang.org/grpc/metadata" 16 | ) 17 | 18 | func TestGoogleSheetsDatasource_CheckHealth(t *testing.T) { 19 | ds := &Datasource{ 20 | googlesheets: &GoogleSheets{}, 21 | } 22 | 23 | t.Run("should return error when unable to load settings", func(t *testing.T) { 24 | req := &backend.CheckHealthRequest{ 25 | PluginContext: backend.PluginContext{ 26 | DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ 27 | JSONData: nil, 28 | }, 29 | }, 30 | } 31 | 32 | res, err := ds.CheckHealth(context.Background(), req) 33 | 34 | assert.Equal(t, backend.HealthStatusError, res.Status) 35 | assert.Equal(t, "Unable to load settings", res.Message) 36 | assert.Nil(t, err) 37 | }) 38 | 39 | t.Run("should return error when unable to create client", func(t *testing.T) { 40 | req := &backend.CheckHealthRequest{ 41 | PluginContext: backend.PluginContext{ 42 | DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ 43 | JSONData: []byte(`{"client_id":"test","client_secret":"test","token":"test"}`), 44 | }, 45 | }, 46 | } 47 | 48 | res, err := ds.CheckHealth(context.Background(), req) 49 | 50 | assert.Equal(t, backend.HealthStatusError, res.Status) 51 | assert.Equal(t, "Unable to create client", res.Message) 52 | assert.Nil(t, err) 53 | }) 54 | 55 | t.Run("should return error when permissions check failed", func(t *testing.T) { 56 | req := &backend.CheckHealthRequest{ 57 | PluginContext: backend.PluginContext{ 58 | DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ 59 | JSONData: []byte(`{"authenticationType":"key"}`), 60 | DecryptedSecureJSONData: map[string]string{"apiKey": "token"}, 61 | }, 62 | }, 63 | } 64 | 65 | ds.googlesheets = &GoogleSheets{ 66 | Cache: cache.New(300*time.Second, 5*time.Second), 67 | } 68 | 69 | res, err := ds.CheckHealth(context.Background(), req) 70 | 71 | assert.Equal(t, backend.HealthStatusError, res.Status) 72 | assert.Equal(t, "Permissions check failed", res.Message) 73 | assert.Nil(t, err) 74 | }) 75 | } 76 | 77 | func TestGoogleSheetsDatasource_QueryData(t *testing.T) { 78 | ds := &Datasource{ 79 | googlesheets: &GoogleSheets{}, 80 | } 81 | 82 | t.Run("should return error when unable to load settings", func(t *testing.T) { 83 | req := &backend.QueryDataRequest{ 84 | PluginContext: backend.PluginContext{ 85 | DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ 86 | JSONData: nil, 87 | }, 88 | }, 89 | } 90 | 91 | res, err := ds.QueryData(context.Background(), req) 92 | 93 | assert.Nil(t, res) 94 | assert.NotNil(t, err) 95 | }) 96 | 97 | t.Run("should return error when failed to read query", func(t *testing.T) { 98 | req := &backend.QueryDataRequest{ 99 | PluginContext: backend.PluginContext{ 100 | DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ 101 | JSONData: []byte(`{"client_id":"test","client_secret":"test","token":"test"}`), 102 | }, 103 | }, 104 | Queries: []backend.DataQuery{ 105 | { 106 | RefID: "test", 107 | }, 108 | }, 109 | } 110 | 111 | res, err := ds.QueryData(context.Background(), req) 112 | 113 | assert.Nil(t, res) 114 | assert.NotNil(t, err) 115 | }) 116 | } 117 | 118 | func TestGoogleSheetsMultiTenancy(t *testing.T) { 119 | const ( 120 | tenantID1 = "abc123" 121 | tenantID2 = "def456" 122 | addr = "127.0.0.1:8000" 123 | ) 124 | 125 | var instances []instancemgmt.Instance 126 | factoryInvocations := 0 127 | factory := datasource.InstanceFactoryFunc(func(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { 128 | factoryInvocations++ 129 | i, err := NewDatasource(ctx, settings) 130 | if err == nil { 131 | instances = append(instances, i) 132 | } 133 | return i, err 134 | }) 135 | 136 | tp, err := datasourcetest.Manage(factory, datasourcetest.ManageOpts{Address: addr}) 137 | require.NoError(t, err) 138 | defer func() { 139 | err = tp.Shutdown() 140 | if err != nil { 141 | t.Log("plugin shutdown error", err) 142 | } 143 | }() 144 | 145 | pCtx := backend.PluginContext{DataSourceInstanceSettings: &backend.DataSourceInstanceSettings{ 146 | ID: 1, 147 | DecryptedSecureJSONData: map[string]string{ 148 | "privateKey": "randomPrivateKey", 149 | }, 150 | JSONData: []byte(`{"authenticationType":"jwt","defaultProject": "raintank-dev", "processingLocation": "us-west1","tokenUri":"token","clientEmail":"test@grafana.com"}`), 151 | }} 152 | 153 | t.Run("Request without tenant information creates an instance", func(t *testing.T) { 154 | qdr := &backend.QueryDataRequest{PluginContext: pCtx} 155 | crr := &backend.CallResourceRequest{PluginContext: pCtx} 156 | chr := &backend.CheckHealthRequest{PluginContext: pCtx} 157 | responseSender := newTestCallResourceResponseSender() 158 | ctx := context.Background() 159 | 160 | resp, err := tp.Client.QueryData(ctx, qdr) 161 | require.NoError(t, err) 162 | require.NotNil(t, resp) 163 | require.Equal(t, 1, factoryInvocations) 164 | 165 | err = tp.Client.CallResource(ctx, crr, responseSender) 166 | require.NoError(t, err) 167 | require.Equal(t, 1, factoryInvocations) 168 | 169 | t.Run("Request from tenant #1 creates new instance", func(t *testing.T) { 170 | ctx = metadata.AppendToOutgoingContext(context.Background(), "tenantID", tenantID1) 171 | resp, err = tp.Client.QueryData(ctx, qdr) 172 | require.NoError(t, err) 173 | require.NotNil(t, resp) 174 | require.Equal(t, 2, factoryInvocations) 175 | 176 | // subsequent requests from tenantID1 with same settings will reuse instance 177 | resp, err = tp.Client.QueryData(ctx, qdr) 178 | require.NoError(t, err) 179 | require.NotNil(t, resp) 180 | require.Equal(t, 2, factoryInvocations) 181 | 182 | var chRes *backend.CheckHealthResult 183 | chRes, err = tp.Client.CheckHealth(ctx, chr) 184 | require.NoError(t, err) 185 | require.NotNil(t, chRes) 186 | require.Equal(t, 2, factoryInvocations) 187 | 188 | t.Run("Request from tenant #2 creates new instance", func(t *testing.T) { 189 | ctx = metadata.AppendToOutgoingContext(context.Background(), "tenantID", tenantID2) 190 | resp, err = tp.Client.QueryData(ctx, qdr) 191 | require.NoError(t, err) 192 | require.NotNil(t, resp) 193 | require.Equal(t, 3, factoryInvocations) 194 | 195 | // subsequent requests from tenantID2 with same settings will reuse instance 196 | err = tp.Client.CallResource(ctx, crr, responseSender) 197 | require.NoError(t, err) 198 | require.Equal(t, 3, factoryInvocations) 199 | }) 200 | 201 | // subsequent requests from tenantID1 with same settings will reuse instance 202 | ctx = metadata.AppendToOutgoingContext(context.Background(), "tenantID", tenantID1) 203 | resp, err = tp.Client.QueryData(ctx, qdr) 204 | require.NoError(t, err) 205 | require.NotNil(t, resp) 206 | require.Equal(t, 3, factoryInvocations) 207 | 208 | chRes, err = tp.Client.CheckHealth(ctx, chr) 209 | require.NoError(t, err) 210 | require.NotNil(t, chRes) 211 | require.Equal(t, 3, factoryInvocations) 212 | }) 213 | }) 214 | 215 | require.Len(t, instances, 3) 216 | require.NotEqual(t, instances[0], instances[1]) 217 | require.NotEqual(t, instances[0], instances[2]) 218 | require.NotEqual(t, instances[1], instances[2]) 219 | } 220 | 221 | type testCallResourceResponseSender struct{} 222 | 223 | func newTestCallResourceResponseSender() *testCallResourceResponseSender { 224 | return &testCallResourceResponseSender{} 225 | } 226 | 227 | func (s *testCallResourceResponseSender) Send(_ *backend.CallResourceResponse) error { 228 | return nil 229 | } 230 | -------------------------------------------------------------------------------- /pkg/googlesheets/googleclient.go: -------------------------------------------------------------------------------- 1 | package googlesheets 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/grafana/grafana-google-sdk-go/pkg/tokenprovider" 9 | "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" 10 | "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" 11 | "github.com/pkg/errors" 12 | "golang.org/x/oauth2/google" 13 | "google.golang.org/api/drive/v3" 14 | "google.golang.org/api/option" 15 | "google.golang.org/api/sheets/v4" 16 | 17 | "github.com/grafana/google-sheets-datasource/pkg/models" 18 | ) 19 | 20 | const ( 21 | sheetsRoute = "sheets" 22 | driveRoute = "drive" 23 | authenticationTypeAPIKey = "key" 24 | ) 25 | 26 | type routeInfo struct { 27 | method string 28 | scopes []string 29 | } 30 | 31 | var routes = map[string]routeInfo{ 32 | sheetsRoute: { 33 | method: "GET", 34 | scopes: []string{sheets.SpreadsheetsReadonlyScope}, 35 | }, 36 | driveRoute: { 37 | method: "GET", 38 | scopes: []string{drive.DriveReadonlyScope}, 39 | }, 40 | } 41 | 42 | // GoogleClient struct 43 | type GoogleClient struct { 44 | sheetsService *sheets.Service 45 | driveService *drive.Service 46 | auth string 47 | } 48 | 49 | type client interface { 50 | GetSpreadsheet(ctx context.Context, spreadSheetID string, sheetRange string, includeGridData bool) (*sheets.Spreadsheet, error) 51 | } 52 | 53 | // NewGoogleClient creates a new client and initializes a sheet service and a drive service 54 | func NewGoogleClient(ctx context.Context, settings models.DatasourceSettings) (*GoogleClient, error) { 55 | sheetsService, err := createSheetsService(ctx, settings) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | driveService, err := createDriveService(ctx, settings) 61 | if err != nil { 62 | return nil, err 63 | } 64 | 65 | // We cannot retrieve response information (such as size) for API key authentication 66 | // because we are not passing the httpClient to the service, and as a result, middleware cannot be provided. 67 | // Therefore, we are logging here to indicate that response information will not be retrieved, allowing us to track this behavior. 68 | // This approach is acceptable for now since we are creating a new client for each request. 69 | // If this changes in the future, the logging should be moved to a location where it handles logging for each query. 70 | logIfNotAbleToRetrieveResponseInfo(ctx, settings) 71 | 72 | return &GoogleClient{ 73 | sheetsService: sheetsService, 74 | driveService: driveService, 75 | auth: settings.AuthenticationType, 76 | }, nil 77 | } 78 | 79 | // TestClient checks that the client can connect to required services 80 | func (gc *GoogleClient) TestClient() error { 81 | // When using JWT, check the drive API 82 | if gc.auth == "jwt" { 83 | _, err := gc.driveService.Files.List().PageSize(1).Do() 84 | if err != nil { 85 | return err 86 | } 87 | } 88 | 89 | // Test spreadsheet from google 90 | spreadsheetID := "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms" 91 | readRange := "Class Data!A2:E" 92 | _, err := gc.sheetsService.Spreadsheets.Values.Get(spreadsheetID, readRange).Do() 93 | if err != nil { 94 | return err 95 | } 96 | return nil 97 | } 98 | 99 | // GetSpreadsheet gets a google spreadsheet struct by id and range 100 | func (gc *GoogleClient) GetSpreadsheet(ctx context.Context, spreadSheetID string, sheetRange string, _ bool) (*sheets.Spreadsheet, error) { 101 | req := gc.sheetsService.Spreadsheets.Get(spreadSheetID) 102 | if len(sheetRange) > 0 { 103 | req = req.Ranges(sheetRange) 104 | } 105 | return req.IncludeGridData(true).Context(ctx).Do() 106 | } 107 | 108 | // GetSpreadsheetFiles lists all files with spreadsheet mimetype that the client has access to. 109 | func (gc *GoogleClient) GetSpreadsheetFiles() ([]*drive.File, error) { 110 | fs := []*drive.File{} 111 | pageToken := "" 112 | for { 113 | q := gc.driveService.Files.List().Q("mimeType='application/vnd.google-apps.spreadsheet'") 114 | if pageToken != "" { 115 | q = q.PageToken(pageToken) 116 | } 117 | r, err := q.Do() 118 | if err != nil { 119 | return nil, fmt.Errorf("failed to list spreadsheet files, page token %q: %w", pageToken, err) 120 | } 121 | 122 | fs = append(fs, r.Files...) 123 | pageToken = r.NextPageToken 124 | if pageToken == "" { 125 | break 126 | } 127 | } 128 | 129 | return fs, nil 130 | } 131 | 132 | func createSheetsService(ctx context.Context, settings models.DatasourceSettings) (*sheets.Service, error) { 133 | if len(settings.AuthenticationType) == 0 { 134 | // If the user didn't set up auth, return a downstream error as this is a user error. 135 | return nil, errorsource.DownstreamError(errors.New("missing AuthenticationType setting"), false) 136 | } 137 | 138 | if settings.AuthenticationType == authenticationTypeAPIKey { 139 | if len(settings.APIKey) == 0 { 140 | // If the API key is not set, return a downstream error as this is a user error. 141 | return nil, errorsource.DownstreamError(errors.New("missing API Key"), false) 142 | } 143 | return sheets.NewService(ctx, option.WithAPIKey(settings.APIKey)) 144 | } 145 | 146 | client, err := newHTTPClient(settings, httpclient.Options{}, sheetsRoute) 147 | if err != nil { 148 | return nil, errors.WithMessage(err, "Failed to create http client") 149 | } 150 | 151 | srv, err := sheets.NewService(ctx, option.WithHTTPClient(client)) 152 | if err != nil { 153 | return nil, fmt.Errorf("unable to retrieve Sheets client: %v", err) 154 | } 155 | 156 | return srv, nil 157 | } 158 | 159 | func createDriveService(ctx context.Context, settings models.DatasourceSettings) (*drive.Service, error) { 160 | if len(settings.AuthenticationType) == 0 { 161 | return nil, errorsource.DownstreamError(errors.New("missing AuthenticationType setting"), false) 162 | } 163 | 164 | if settings.AuthenticationType == authenticationTypeAPIKey { 165 | if len(settings.APIKey) == 0 { 166 | // If the API key is not set, return a downstream error as this is a user error. 167 | return nil, errorsource.DownstreamError(errors.New("missing API Key"), false) 168 | } 169 | return drive.NewService(ctx, option.WithAPIKey(settings.APIKey)) 170 | } 171 | 172 | client, err := newHTTPClient(settings, httpclient.Options{}, driveRoute) 173 | if err != nil { 174 | return nil, errors.WithMessage(err, "Failed to create http client") 175 | } 176 | 177 | srv, err := drive.NewService(ctx, option.WithHTTPClient(client)) 178 | if err != nil { 179 | return nil, fmt.Errorf("unable to retrieve Drive client: %v", err) 180 | } 181 | 182 | return srv, nil 183 | } 184 | 185 | func getMiddleware(settings models.DatasourceSettings, routePath string) (httpclient.Middleware, error) { 186 | providerConfig := tokenprovider.Config{ 187 | RoutePath: routePath, 188 | RouteMethod: routes[routePath].method, 189 | DataSourceID: settings.InstanceSettings.ID, 190 | DataSourceUpdated: settings.InstanceSettings.Updated, 191 | Scopes: routes[routePath].scopes, 192 | } 193 | 194 | var provider tokenprovider.TokenProvider 195 | switch settings.AuthenticationType { 196 | case "gce": 197 | provider = tokenprovider.NewGceAccessTokenProvider(providerConfig) 198 | case "jwt": 199 | if settings.JWT != "" { 200 | jwtConfig, err := google.JWTConfigFromJSON([]byte(settings.JWT)) 201 | 202 | if err != nil { 203 | return nil, fmt.Errorf("error parsing JWT file: %w", err) 204 | } 205 | 206 | providerConfig.JwtTokenConfig = &tokenprovider.JwtTokenConfig{ 207 | Email: jwtConfig.Email, 208 | URI: jwtConfig.TokenURL, 209 | PrivateKey: jwtConfig.PrivateKey, 210 | } 211 | } else { 212 | err := validateDataSourceSettings(settings) 213 | 214 | if err != nil { 215 | return nil, err 216 | } 217 | 218 | providerConfig.JwtTokenConfig = &tokenprovider.JwtTokenConfig{ 219 | Email: settings.ClientEmail, 220 | URI: settings.TokenURI, 221 | PrivateKey: []byte(settings.PrivateKey), 222 | } 223 | } 224 | provider = tokenprovider.NewJwtAccessTokenProvider(providerConfig) 225 | } 226 | 227 | return tokenprovider.AuthMiddleware(provider), nil 228 | } 229 | 230 | func newHTTPClient(settings models.DatasourceSettings, opts httpclient.Options, route string) (*http.Client, error) { 231 | m, err := getMiddleware(settings, route) 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | opts.Middlewares = append(opts.Middlewares, m, errorsource.Middleware("grafana-googlesheets-datasource"), ResponseInfoMiddleware()) 237 | return httpclient.New(opts) 238 | } 239 | 240 | func validateDataSourceSettings(settings models.DatasourceSettings) error { 241 | if settings.DefaultProject == "" || settings.ClientEmail == "" || settings.PrivateKey == "" || settings.TokenURI == "" { 242 | return errors.New("datasource is missing authentication details") 243 | } 244 | 245 | return nil 246 | } 247 | -------------------------------------------------------------------------------- /pkg/googlesheets/googlesheets.go: -------------------------------------------------------------------------------- 1 | package googlesheets 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "strings" 9 | "time" 10 | 11 | "github.com/grafana/google-sheets-datasource/pkg/models" 12 | 13 | "github.com/araddon/dateparse" 14 | "github.com/davecgh/go-spew/spew" 15 | "github.com/grafana/grafana-plugin-sdk-go/backend" 16 | "github.com/grafana/grafana-plugin-sdk-go/data" 17 | "github.com/grafana/grafana-plugin-sdk-go/experimental/errorsource" 18 | "github.com/patrickmn/go-cache" 19 | "golang.org/x/oauth2" 20 | "google.golang.org/api/googleapi" 21 | "google.golang.org/api/sheets/v4" 22 | ) 23 | 24 | // GoogleSheets provides an interface to the Google Sheets API. 25 | type GoogleSheets struct { 26 | Cache *cache.Cache 27 | } 28 | 29 | // Query queries a spreadsheet and returns a corresponding data frame. 30 | func (gs *GoogleSheets) Query(ctx context.Context, refID string, qm *models.QueryModel, config models.DatasourceSettings, timeRange backend.TimeRange) (dr backend.DataResponse) { 31 | client, err := NewGoogleClient(ctx, config) 32 | if err != nil { 33 | dr = errorsource.Response(err) 34 | dr.Error = fmt.Errorf("unable to create Google API client: %w", err) 35 | return 36 | } 37 | 38 | // This result may be cached 39 | sheetData, meta, err := gs.getSheetData(ctx, client, qm) 40 | if err != nil { 41 | dr = errorsource.Response(err) 42 | return 43 | } 44 | 45 | frame, err := gs.transformSheetToDataFrame(ctx, sheetData, meta, refID, qm) 46 | if err != nil { 47 | dr.Error = err 48 | return 49 | } 50 | if frame == nil { 51 | return 52 | } 53 | if qm.UseTimeFilter { 54 | timeIndex := findTimeField(frame) 55 | if timeIndex >= 0 { 56 | frame, dr.Error = frame.FilterRowsByField(timeIndex, func(i any) (bool, error) { 57 | val, ok := i.(*time.Time) 58 | if !ok { 59 | return false, fmt.Errorf("invalid time column: %s", spew.Sdump(i)) 60 | } 61 | if val == nil || val.Before(timeRange.From) || val.After(timeRange.To) { 62 | return false, nil 63 | } 64 | return true, nil 65 | }) 66 | } 67 | } 68 | dr.Frames = append(dr.Frames, frame) 69 | return 70 | } 71 | 72 | // GetSpreadsheets gets spreadsheets from the Google API. 73 | func (gs *GoogleSheets) GetSpreadsheets(ctx context.Context, config models.DatasourceSettings) (map[string]string, error) { 74 | client, err := NewGoogleClient(ctx, config) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to create Google API client: %w", err) 77 | } 78 | 79 | files, err := client.GetSpreadsheetFiles() 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | fileNames := make(map[string]string, len(files)) 85 | for _, i := range files { 86 | fileNames[i.Id] = i.Name 87 | } 88 | 89 | return fileNames, nil 90 | } 91 | 92 | // getSheetData gets grid data corresponding to a spreadsheet. 93 | func (gs *GoogleSheets) getSheetData(ctx context.Context, client client, qm *models.QueryModel) (*sheets.GridData, map[string]any, error) { 94 | logger := backend.Logger.FromContext(ctx) 95 | cacheKey := qm.Spreadsheet + qm.Range 96 | if item, expires, found := gs.Cache.GetWithExpiration(cacheKey); found && qm.CacheDurationSeconds > 0 { 97 | if gridData, ok := item.(*sheets.GridData); ok { 98 | return gridData, map[string]any{ 99 | "hit": true, 100 | "expires": expires.Unix(), 101 | }, nil 102 | } 103 | return nil, nil, errors.New("invalid cache item not type of *sheets.GridData") 104 | } 105 | result, err := client.GetSpreadsheet(ctx, qm.Spreadsheet, qm.Range, true) 106 | if err != nil { 107 | if apiErr, ok := err.(*googleapi.Error); ok { 108 | // Handle API-specific errors 109 | // We use ErrorSourceFromHTTPStatus to determine error source based on HTTP status code 110 | if apiErr.Code == 404 { 111 | errWithSource := errorsource.DownstreamError(errors.New("spreadsheet not found"), false) 112 | return nil, nil, errWithSource 113 | } 114 | if apiErr.Message != "" { 115 | logger.Warn("Google API Error: " + apiErr.Message) 116 | errWithSource := errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(apiErr.Code), fmt.Errorf("google API Error %d", apiErr.Code), false) 117 | return nil, nil, errWithSource 118 | } 119 | errWithSource := errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(apiErr.Code), errors.New("unknown API error"), false) 120 | logger.Warn(apiErr.Error()) 121 | return nil, nil, errWithSource 122 | } 123 | 124 | if backend.IsDownstreamHTTPError(err) { 125 | errWithSource := errorsource.DownstreamError(err, false) 126 | return nil, nil, errWithSource 127 | } 128 | 129 | netErr, neErrOk := err.(net.Error) 130 | if neErrOk { 131 | var retrieveErr *oauth2.RetrieveError 132 | if errors.As(netErr, &retrieveErr) { 133 | errWithSource := errorsource.SourceError(backend.ErrorSourceFromHTTPStatus(retrieveErr.Response.StatusCode), err, false) 134 | return nil, nil, errWithSource 135 | } 136 | } 137 | 138 | logger.Warn("unknown error", "err", err) 139 | // This is an unknown error from the client - it might have error source middleware. 140 | // If not, it will be handled by the default error source - plugin error. 141 | return nil, nil, err 142 | } 143 | 144 | if result.Properties.TimeZone != "" { 145 | loc, err := time.LoadLocation(result.Properties.TimeZone) 146 | if err != nil { 147 | logger.Warn("could not load timezone from spreadsheet: %w", err) 148 | } else { 149 | time.Local = loc 150 | } 151 | } 152 | 153 | sheetData := result.Sheets[0].Data[0] 154 | if qm.CacheDurationSeconds > 0 { 155 | gs.Cache.Set(cacheKey, sheetData, time.Duration(qm.CacheDurationSeconds)*time.Second) 156 | } 157 | 158 | return sheetData, map[string]any{"hit": false}, nil 159 | } 160 | 161 | func (gs *GoogleSheets) transformSheetToDataFrame(ctx context.Context, sheet *sheets.GridData, meta map[string]any, refID string, qm *models.QueryModel) (*data.Frame, error) { 162 | logger := backend.Logger.FromContext(ctx) 163 | columns, start := getColumnDefinitions(sheet.RowData) 164 | warnings := []string{} 165 | 166 | converters := make([]data.FieldConverter, len(columns)) 167 | for i, column := range columns { 168 | fc, ok := converterMap[column.GetType()] 169 | if !ok { 170 | return nil, fmt.Errorf("unknown column type: %s", column.GetType()) 171 | } 172 | converters[i] = fc 173 | } 174 | 175 | inputConverter, err := data.NewFrameInputConverter(converters, len(sheet.RowData)-start) 176 | if err != nil { 177 | return nil, err 178 | } 179 | frame := inputConverter.Frame 180 | frame.RefID = refID 181 | frame.Name = refID // TODO: should set the name from metadata 182 | 183 | for i, column := range columns { 184 | field := frame.Fields[i] 185 | field.Name = column.Header 186 | field.Config = &data.FieldConfig{ 187 | DisplayName: column.Header, 188 | Unit: column.GetUnit(), 189 | } 190 | if column.HasMixedTypes() { 191 | warning := fmt.Sprintf("Multiple data types found in column %q. Using string data type", column.Header) 192 | warnings = append(warnings, warning) 193 | logger.Debug(warning) 194 | } 195 | 196 | if column.HasMixedUnits() { 197 | warning := fmt.Sprintf("Multiple units found in column %q. Formatted value will be used", column.Header) 198 | warnings = append(warnings, warning) 199 | logger.Debug(warning) 200 | } 201 | } 202 | 203 | // We want to show the warnings only once per column 204 | warningsIncludeConverterErrorForColumns := make(map[int]bool, len(columns)) 205 | for rowIndex := start; rowIndex < len(sheet.RowData); rowIndex++ { 206 | for columnIndex, cellData := range sheet.RowData[rowIndex].Values { 207 | if columnIndex >= len(columns) { 208 | continue 209 | } 210 | 211 | // Skip any empty values 212 | if cellData.FormattedValue == "" { 213 | continue 214 | } 215 | 216 | err := inputConverter.Set(columnIndex, rowIndex-start, cellData) 217 | if err != nil && !warningsIncludeConverterErrorForColumns[columnIndex] { 218 | logger.Debug("unsuccessful converting of cell data", "err", err) 219 | warnings = append(warnings, err.Error()) 220 | warningsIncludeConverterErrorForColumns[columnIndex] = true 221 | } 222 | } 223 | } 224 | 225 | meta["warnings"] = warnings 226 | meta["spreadsheetId"] = qm.Spreadsheet 227 | meta["range"] = qm.Range 228 | frame.Meta = &data.FrameMeta{Custom: meta} 229 | return frame, nil 230 | } 231 | 232 | // timeConverter handles sheets TIME column types. 233 | var timeConverter = data.FieldConverter{ 234 | OutputFieldType: data.FieldTypeNullableTime, 235 | Converter: func(i any) (any, error) { 236 | var t *time.Time 237 | cellData, ok := i.(*sheets.CellData) 238 | if !ok { 239 | return t, fmt.Errorf("expected type *sheets.CellData, but got %T", i) 240 | } 241 | parsedTime, err := dateparse.ParseLocal(cellData.FormattedValue) 242 | if err != nil { 243 | return t, fmt.Errorf("error while parsing date '%v'", cellData.FormattedValue) 244 | } 245 | return &parsedTime, nil 246 | }, 247 | } 248 | 249 | // stringConverter handles sheets STRING column types. 250 | var stringConverter = data.FieldConverter{ 251 | OutputFieldType: data.FieldTypeNullableString, 252 | Converter: func(i any) (any, error) { 253 | var s *string 254 | cellData, ok := i.(*sheets.CellData) 255 | if !ok { 256 | return s, fmt.Errorf("expected type *sheets.CellData, but got %T", i) 257 | } 258 | return &cellData.FormattedValue, nil 259 | }, 260 | } 261 | 262 | // numberConverter handles sheets STRING column types. 263 | var numberConverter = data.FieldConverter{ 264 | OutputFieldType: data.FieldTypeNullableFloat64, 265 | Converter: func(i any) (any, error) { 266 | cellData, ok := i.(*sheets.CellData) 267 | if !ok { 268 | return nil, fmt.Errorf("expected type *sheets.CellData, but got %T", i) 269 | } 270 | return cellData.EffectiveValue.NumberValue, nil 271 | }, 272 | } 273 | 274 | // converterMap is a map sheets.ColumnType to fieldConverter and 275 | // is used to create a data.FrameInputConverter for a returned sheet. 276 | var converterMap = map[ColumnType]data.FieldConverter{ 277 | "TIME": timeConverter, 278 | "STRING": stringConverter, 279 | "NUMBER": numberConverter, 280 | } 281 | 282 | func getUniqueColumnName(formattedName string, columnIndex int, columns map[string]bool) string { 283 | name := formattedName 284 | if name == "" { 285 | name = fmt.Sprintf("Field %d", columnIndex+1) 286 | } 287 | 288 | nameExist := true 289 | counter := 1 290 | for nameExist { 291 | if _, exist := columns[name]; exist { 292 | name = fmt.Sprintf("%s%d", formattedName, counter) 293 | counter++ 294 | } else { 295 | nameExist = false 296 | } 297 | } 298 | 299 | return name 300 | } 301 | 302 | func getColumnDefinitions(rows []*sheets.RowData) ([]*ColumnDefinition, int) { 303 | if len(rows) < 1 { 304 | return []*ColumnDefinition{}, 0 305 | } 306 | headerRow := rows[0].Values 307 | 308 | columns := make([]*ColumnDefinition, 0, len(headerRow)) 309 | columnMap := make(map[string]bool, len(headerRow)) 310 | start := 0 311 | if len(rows) > 1 { 312 | start = 1 313 | for columnIndex, headerCell := range headerRow { 314 | name := getUniqueColumnName(strings.TrimSpace(headerCell.FormattedValue), columnIndex, columnMap) 315 | columnMap[name] = true 316 | columns = append(columns, NewColumnDefinition(name, columnIndex)) 317 | } 318 | } else { 319 | for columnIndex := range headerRow { 320 | name := getUniqueColumnName("", columnIndex, columnMap) 321 | columnMap[name] = true 322 | columns = append(columns, NewColumnDefinition(name, columnIndex)) 323 | } 324 | } 325 | 326 | // Check the types for each column 327 | for rowIndex := start; rowIndex < len(rows); rowIndex++ { 328 | for _, column := range columns { 329 | if column.ColumnIndex < len(rows[rowIndex].Values) { 330 | column.CheckCell(rows[rowIndex].Values[column.ColumnIndex]) 331 | } 332 | } 333 | } 334 | 335 | return columns, start 336 | } 337 | -------------------------------------------------------------------------------- /pkg/googlesheets/googlesheets_bench_test.go: -------------------------------------------------------------------------------- 1 | package googlesheets 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/grafana/google-sheets-datasource/pkg/models" 8 | 9 | "github.com/grafana/grafana-plugin-sdk-go/data" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | // To avoid compiler optimizations eliminating the function under test 14 | // we are storing the result to a package level variable 15 | var Frame *data.Frame 16 | 17 | func BenchmarkTransformMixedSheetToDataFrame(b *testing.B) { 18 | sheet, err := loadTestSheet("./testdata/mixed-data.json") 19 | require.NoError(b, err) 20 | gsd := &GoogleSheets{} 21 | qm := models.QueryModel{Spreadsheet: "someId"} 22 | meta := make(map[string]any) 23 | b.ResetTimer() 24 | for i := 0; i < b.N; i++ { 25 | frame, err := gsd.transformSheetToDataFrame(context.Background(), sheet.Sheets[0].Data[0], meta, "ref1", &qm) 26 | require.NoError(b, err) 27 | Frame = frame 28 | } 29 | } 30 | 31 | func BenchmarkTransformMixedSheetWithInvalidDateToDataFrame(b *testing.B) { 32 | sheet, err := loadTestSheet("./testdata/invalid-date-time.json") 33 | require.NoError(b, err) 34 | gsd := &GoogleSheets{} 35 | qm := models.QueryModel{Spreadsheet: "someId"} 36 | meta := make(map[string]any) 37 | b.ResetTimer() 38 | for i := 0; i < b.N; i++ { 39 | frame, err := gsd.transformSheetToDataFrame(context.Background(), sheet.Sheets[0].Data[0], meta, "ref1", &qm) 40 | require.NoError(b, err) 41 | Frame = frame 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/googlesheets/googlesheets_test.go: -------------------------------------------------------------------------------- 1 | package googlesheets 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "testing" 11 | "time" 12 | 13 | "github.com/grafana/google-sheets-datasource/pkg/models" 14 | 15 | "github.com/grafana/grafana-plugin-sdk-go/backend" 16 | 17 | "github.com/patrickmn/go-cache" 18 | "github.com/stretchr/testify/assert" 19 | "github.com/stretchr/testify/mock" 20 | "github.com/stretchr/testify/require" 21 | "golang.org/x/oauth2" 22 | "google.golang.org/api/googleapi" 23 | "google.golang.org/api/sheets/v4" 24 | ) 25 | 26 | type fakeClient struct { 27 | mock.Mock 28 | } 29 | 30 | func (f *fakeClient) GetSpreadsheet(ctx context.Context, spreadSheetID string, sheetRange string, includeGridData bool) (*sheets.Spreadsheet, error) { 31 | args := f.Called(ctx, spreadSheetID, sheetRange, includeGridData) 32 | if spreadsheet, ok := args.Get(0).(*sheets.Spreadsheet); ok { 33 | return spreadsheet, args.Error(1) 34 | } 35 | return nil, args.Error(1) 36 | } 37 | 38 | func loadTestSheet(path string) (*sheets.Spreadsheet, error) { 39 | jsonBody, err := os.ReadFile(path) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | var sheet sheets.Spreadsheet 45 | if err := json.Unmarshal(jsonBody, &sheet); err != nil { 46 | return nil, err 47 | } 48 | 49 | return &sheet, nil 50 | } 51 | 52 | func TestGooglesheets(t *testing.T) { 53 | t.Run("getUniqueColumnName", func(t *testing.T) { 54 | t.Run("name is appended with number if not unique", func(t *testing.T) { 55 | columns := map[string]bool{"header": true, "name": true} 56 | name := getUniqueColumnName("header", 1, columns) 57 | assert.Equal(t, "header1", name) 58 | }) 59 | 60 | t.Run("name becomes Field + column index if header row is empty", func(t *testing.T) { 61 | columns := map[string]bool{} 62 | name := getUniqueColumnName("", 3, columns) 63 | assert.Equal(t, "Field 4", name) 64 | }) 65 | }) 66 | 67 | t.Run("getSheetData", func(t *testing.T) { 68 | t.Run("spreadsheets get cached", func(t *testing.T) { 69 | client := &fakeClient{} 70 | qm := models.QueryModel{Range: "A1:O", Spreadsheet: "someId", CacheDurationSeconds: 10} 71 | gsd := &GoogleSheets{ 72 | Cache: cache.New(300*time.Second, 50*time.Second), 73 | } 74 | require.Equal(t, 0, gsd.Cache.ItemCount()) 75 | 76 | client.On("GetSpreadsheet", context.Background(), qm.Spreadsheet, qm.Range, true).Return(loadTestSheet("./testdata/mixed-data.json")) 77 | 78 | _, meta, err := gsd.getSheetData(context.Background(), client, &qm) 79 | require.NoError(t, err) 80 | 81 | assert.False(t, meta["hit"].(bool)) 82 | assert.Equal(t, 1, gsd.Cache.ItemCount()) 83 | 84 | _, meta, err = gsd.getSheetData(context.Background(), client, &qm) 85 | require.NoError(t, err) 86 | assert.True(t, meta["hit"].(bool)) 87 | assert.Equal(t, 1, gsd.Cache.ItemCount()) 88 | client.AssertExpectations(t) 89 | }) 90 | 91 | t.Run("spreadsheets don't get cached if CacheDurationSeconds is 0", func(t *testing.T) { 92 | client := &fakeClient{} 93 | qm := models.QueryModel{Range: "A1:O", Spreadsheet: "someId", CacheDurationSeconds: 0} 94 | gsd := &GoogleSheets{ 95 | Cache: cache.New(300*time.Second, 50*time.Second), 96 | } 97 | require.Equal(t, 0, gsd.Cache.ItemCount()) 98 | 99 | client.On("GetSpreadsheet", context.Background(), qm.Spreadsheet, qm.Range, true).Return(loadTestSheet("./testdata/mixed-data.json")) 100 | 101 | _, meta, err := gsd.getSheetData(context.Background(), client, &qm) 102 | require.NoError(t, err) 103 | 104 | assert.False(t, meta["hit"].(bool)) 105 | assert.Equal(t, 0, gsd.Cache.ItemCount()) 106 | client.AssertExpectations(t) 107 | }) 108 | 109 | t.Run("api error 404", func(t *testing.T) { 110 | client := &fakeClient{} 111 | qm := &models.QueryModel{ 112 | Spreadsheet: "spreadsheet-id", 113 | Range: "Sheet1!A1:B2", 114 | CacheDurationSeconds: 60, 115 | } 116 | gsd := &GoogleSheets{ 117 | Cache: cache.New(300*time.Second, 50*time.Second), 118 | } 119 | client.On("GetSpreadsheet", context.Background(), qm.Spreadsheet, qm.Range, true).Return(&sheets.Spreadsheet{}, &googleapi.Error{ 120 | Code: 404, 121 | Message: "Not found", 122 | }) 123 | 124 | _, _, err := gsd.getSheetData(context.Background(), client, qm) 125 | 126 | assert.Error(t, err) 127 | assert.Equal(t, "spreadsheet not found", err.Error()) 128 | client.AssertExpectations(t) 129 | }) 130 | 131 | t.Run("error other than 404", func(t *testing.T) { 132 | client := &fakeClient{} 133 | qm := &models.QueryModel{ 134 | Spreadsheet: "spreadsheet-id", 135 | Range: "Sheet1!A1:B2", 136 | CacheDurationSeconds: 60, 137 | } 138 | gsd := &GoogleSheets{ 139 | Cache: cache.New(300*time.Second, 50*time.Second), 140 | } 141 | client.On("GetSpreadsheet", context.Background(), qm.Spreadsheet, qm.Range, true).Return(&sheets.Spreadsheet{}, &googleapi.Error{ 142 | Code: 403, 143 | Message: "Forbidden", 144 | }) 145 | 146 | _, _, err := gsd.getSheetData(context.Background(), client, qm) 147 | 148 | assert.Error(t, err) 149 | assert.Equal(t, "google API Error 403", err.Error()) 150 | 151 | client.AssertExpectations(t) 152 | }) 153 | 154 | t.Run("context canceled", func(t *testing.T) { 155 | client := &fakeClient{} 156 | qm := &models.QueryModel{ 157 | Spreadsheet: "spreadsheet-id", 158 | Range: "Sheet1!A1:B2", 159 | CacheDurationSeconds: 60, 160 | } 161 | gsd := &GoogleSheets{ 162 | Cache: cache.New(300*time.Second, 50*time.Second), 163 | } 164 | client.On("GetSpreadsheet", context.Background(), qm.Spreadsheet, qm.Range, true).Return(&sheets.Spreadsheet{}, context.Canceled) 165 | 166 | _, _, err := gsd.getSheetData(context.Background(), client, qm) 167 | 168 | assert.Error(t, err) 169 | assert.Equal(t, context.Canceled.Error(), err.Error()) 170 | assert.True(t, backend.IsDownstreamError(err)) 171 | 172 | client.AssertExpectations(t) 173 | }) 174 | 175 | t.Run("timeout", func(t *testing.T) { 176 | client := &fakeClient{} 177 | qm := &models.QueryModel{ 178 | Spreadsheet: "spreadsheet-id", 179 | Range: "Sheet1!A1:B2", 180 | CacheDurationSeconds: 60, 181 | } 182 | gsd := &GoogleSheets{ 183 | Cache: cache.New(300*time.Second, 50*time.Second), 184 | } 185 | 186 | client.On("GetSpreadsheet", context.Background(), qm.Spreadsheet, qm.Range, true).Return(&sheets.Spreadsheet{}, &net.OpError{Err: context.DeadlineExceeded}) 187 | 188 | _, _, err := gsd.getSheetData(context.Background(), client, qm) 189 | 190 | assert.Error(t, err) 191 | assert.True(t, backend.IsDownstreamError(err)) 192 | 193 | client.AssertExpectations(t) 194 | }) 195 | 196 | t.Run("oauth invalid grant", func(t *testing.T) { 197 | client := &fakeClient{} 198 | qm := &models.QueryModel{ 199 | Spreadsheet: "spreadsheet-id", 200 | Range: "Sheet1!A1:B2", 201 | CacheDurationSeconds: 60, 202 | } 203 | gsd := &GoogleSheets{ 204 | Cache: cache.New(300*time.Second, 50*time.Second), 205 | } 206 | 207 | // Simulated oauth2.RetrieveError 208 | retrieveErr := &oauth2.RetrieveError{ 209 | Response: &http.Response{ 210 | Status: "400 Bad Request", 211 | StatusCode: 400, 212 | }, 213 | Body: []byte(`{"error":"invalid_grant","error_description":"Invalid grant: account not found"}`), 214 | } 215 | 216 | // Simulated *url.Error wrapping the retrieveErr 217 | urlErr := &url.Error{ 218 | Op: "Get", 219 | URL: "https://sheets.googleapis.com/v4/spreadsheets/...", 220 | Err: retrieveErr, 221 | } 222 | 223 | client.On("GetSpreadsheet", context.Background(), qm.Spreadsheet, qm.Range, true).Return(&sheets.Spreadsheet{}, urlErr) 224 | 225 | _, _, err := gsd.getSheetData(context.Background(), client, qm) 226 | 227 | assert.Error(t, err) 228 | assert.True(t, backend.IsDownstreamError(err)) 229 | 230 | client.AssertExpectations(t) 231 | }) 232 | 233 | t.Run("error that doesn't have message property", func(t *testing.T) { 234 | client := &fakeClient{} 235 | qm := &models.QueryModel{ 236 | Spreadsheet: "spreadsheet-id", 237 | Range: "Sheet1!A1:B2", 238 | CacheDurationSeconds: 60, 239 | } 240 | gsd := &GoogleSheets{ 241 | Cache: cache.New(300*time.Second, 50*time.Second), 242 | } 243 | 244 | client.On("GetSpreadsheet", context.Background(), qm.Spreadsheet, qm.Range, true).Return(&sheets.Spreadsheet{}, &googleapi.Error{ 245 | Message: "", 246 | }) 247 | 248 | _, _, err := gsd.getSheetData(context.Background(), client, qm) 249 | 250 | assert.Error(t, err) 251 | assert.Equal(t, "unknown API error", err.Error()) 252 | 253 | client.AssertExpectations(t) 254 | }) 255 | }) 256 | 257 | t.Run("transformSheetToDataFrame", func(t *testing.T) { 258 | sheet, err := loadTestSheet("./testdata/mixed-data.json") 259 | require.NoError(t, err) 260 | 261 | gsd := &GoogleSheets{ 262 | Cache: cache.New(300*time.Second, 50*time.Second), 263 | } 264 | qm := models.QueryModel{Range: "A1:O", Spreadsheet: "someId", CacheDurationSeconds: 10} 265 | 266 | meta := make(map[string]any) 267 | frame, err := gsd.transformSheetToDataFrame(context.Background(), sheet.Sheets[0].Data[0], meta, "ref1", &qm) 268 | require.NoError(t, err) 269 | require.Equal(t, "ref1", frame.Name) 270 | 271 | t.Run("no of columns match", func(t *testing.T) { 272 | assert.Equal(t, 16, len(frame.Fields)) 273 | }) 274 | 275 | t.Run("no of rows matches field length", func(t *testing.T) { 276 | for _, field := range frame.Fields { 277 | assert.Equal(t, len(sheet.Sheets[0].Data[0].RowData)-1, field.Len()) 278 | } 279 | }) 280 | 281 | t.Run("meta is populated correctly", func(t *testing.T) { 282 | assert.Equal(t, qm.Spreadsheet, meta["spreadsheetId"]) 283 | assert.Equal(t, qm.Range, meta["range"]) 284 | }) 285 | 286 | t.Run("meta warnings field is populated correctly", func(t *testing.T) { 287 | warnings, ok := meta["warnings"].([]string) 288 | require.True(t, ok) 289 | assert.Equal(t, 3, len(warnings)) 290 | assert.Equal(t, "Multiple data types found in column \"MixedDataTypes\". Using string data type", warnings[0]) 291 | assert.Equal(t, "Multiple units found in column \"MixedUnits\". Formatted value will be used", warnings[1]) 292 | assert.Equal(t, "Multiple units found in column \"Mixed currencies\". Formatted value will be used", warnings[2]) 293 | // assert.Equal(t, "Multiple data types found in column \"MixedUnits\". Using string data type", warnings[2]) 294 | }) 295 | }) 296 | 297 | t.Run("query single cell", func(t *testing.T) { 298 | sheet, err := loadTestSheet("./testdata/single-cell.json") 299 | require.NoError(t, err) 300 | 301 | gsd := &GoogleSheets{ 302 | Cache: cache.New(300*time.Second, 50*time.Second), 303 | } 304 | qm := models.QueryModel{Range: "A2", Spreadsheet: "someId", CacheDurationSeconds: 10} 305 | 306 | meta := make(map[string]any) 307 | frame, err := gsd.transformSheetToDataFrame(context.Background(), sheet.Sheets[0].Data[0], meta, "ref1", &qm) 308 | require.NoError(t, err) 309 | require.Equal(t, "ref1", frame.Name) 310 | 311 | t.Run("single field", func(t *testing.T) { 312 | assert.Equal(t, 1, len(frame.Fields)) 313 | }) 314 | 315 | t.Run("single row", func(t *testing.T) { 316 | for _, field := range frame.Fields { 317 | assert.Equal(t, 1, field.Len()) 318 | } 319 | }) 320 | 321 | t.Run("single value", func(t *testing.T) { 322 | strVal, ok := frame.Fields[0].At(0).(*string) 323 | require.True(t, ok) 324 | require.NotNil(t, strVal) 325 | assert.Equal(t, "🌭", *strVal) 326 | }) 327 | }) 328 | 329 | t.Run("column id formatting", func(t *testing.T) { 330 | require.Equal(t, "A", getExcelColumnName(1)) 331 | require.Equal(t, "B", getExcelColumnName(2)) 332 | require.Equal(t, "AH", getExcelColumnName(34)) 333 | require.Equal(t, "BN", getExcelColumnName(66)) 334 | require.Equal(t, "ZW", getExcelColumnName(699)) 335 | // cspell:disable-next-line 336 | require.Equal(t, "AJIL", getExcelColumnName(24582)) 337 | }) 338 | } 339 | -------------------------------------------------------------------------------- /pkg/googlesheets/response_info_middleware.go: -------------------------------------------------------------------------------- 1 | package googlesheets 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/grafana/google-sheets-datasource/pkg/models" 8 | 9 | "github.com/grafana/grafana-plugin-sdk-go/backend" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" 11 | ) 12 | 13 | const ResponseInfoMiddlewareName = "response-info" 14 | 15 | func ResponseInfoMiddleware() httpclient.Middleware { 16 | return httpclient.NamedMiddlewareFunc(ResponseInfoMiddlewareName, RoundTripper) 17 | } 18 | 19 | func RoundTripper(_ httpclient.Options, next http.RoundTripper) http.RoundTripper { 20 | return httpclient.RoundTripperFunc(func(req *http.Request) (*http.Response, error) { 21 | res, err := next.RoundTrip(req) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | res.Body = httpclient.CountBytesReader(res.Body, func(size int64) { 27 | backend.Logger.FromContext(req.Context()).Debug("Downstream response info", "bytes", size, "url", req.URL.String(), "retrieved", true) 28 | }) 29 | return res, err 30 | }) 31 | } 32 | 33 | func logIfNotAbleToRetrieveResponseInfo(ctx context.Context, settings models.DatasourceSettings) { 34 | if settings.AuthenticationType == authenticationTypeAPIKey && len(settings.APIKey) > 0 { 35 | backend.Logger.FromContext(ctx).Debug("Downstream response info", "retrieved", false) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/googlesheets/testdata/README.md: -------------------------------------------------------------------------------- 1 | This output is generated from: 2 | 3 | https://developers.google.com/sheets/api/reference/rest/v4/spreadsheets/get 4 | 5 | 1Kn_9WKsuT-H0aJL3fvqukt27HlizMLd-KQfkNgeWj4U 6 | -------------------------------------------------------------------------------- /pkg/googlesheets/testdata/single-cell.json: -------------------------------------------------------------------------------- 1 | { 2 | "spreadsheetId": "1Kn_9WKsuT-H0aJL3fvqukt27HlizMLd-KQfkNgeWj4U", 3 | "properties": { 4 | "title": "Simple Test", 5 | "locale": "en_US", 6 | "autoRecalc": "ON_CHANGE", 7 | "timeZone": "America/Los_Angeles", 8 | "defaultFormat": { 9 | "backgroundColor": { 10 | "red": 1, 11 | "green": 1, 12 | "blue": 1 13 | }, 14 | "padding": { 15 | "top": 2, 16 | "right": 3, 17 | "bottom": 2, 18 | "left": 3 19 | }, 20 | "verticalAlignment": "BOTTOM", 21 | "wrapStrategy": "OVERFLOW_CELL", 22 | "textFormat": { 23 | "foregroundColor": {}, 24 | "fontFamily": "arial,sans,sans-serif", 25 | "fontSize": 10, 26 | "bold": false, 27 | "italic": false, 28 | "strikethrough": false, 29 | "underline": false, 30 | "foregroundColorStyle": { 31 | "rgbColor": {} 32 | } 33 | }, 34 | "backgroundColorStyle": { 35 | "rgbColor": { 36 | "red": 1, 37 | "green": 1, 38 | "blue": 1 39 | } 40 | } 41 | }, 42 | "spreadsheetTheme": { 43 | "primaryFontFamily": "Arial", 44 | "themeColors": [ 45 | { 46 | "colorType": "ACCENT1", 47 | "color": { 48 | "rgbColor": { 49 | "red": 0.25882354, 50 | "green": 0.52156866, 51 | "blue": 0.95686275 52 | } 53 | } 54 | }, 55 | { 56 | "colorType": "ACCENT2", 57 | "color": { 58 | "rgbColor": { 59 | "red": 0.91764706, 60 | "green": 0.2627451, 61 | "blue": 0.20784314 62 | } 63 | } 64 | }, 65 | { 66 | "colorType": "LINK", 67 | "color": { 68 | "rgbColor": { 69 | "red": 0.06666667, 70 | "green": 0.33333334, 71 | "blue": 0.8 72 | } 73 | } 74 | }, 75 | { 76 | "colorType": "TEXT", 77 | "color": { 78 | "rgbColor": {} 79 | } 80 | }, 81 | { 82 | "colorType": "BACKGROUND", 83 | "color": { 84 | "rgbColor": { 85 | "red": 1, 86 | "green": 1, 87 | "blue": 1 88 | } 89 | } 90 | }, 91 | { 92 | "colorType": "ACCENT5", 93 | "color": { 94 | "rgbColor": { 95 | "red": 1, 96 | "green": 0.42745098, 97 | "blue": 0.003921569 98 | } 99 | } 100 | }, 101 | { 102 | "colorType": "ACCENT6", 103 | "color": { 104 | "rgbColor": { 105 | "red": 0.27450982, 106 | "green": 0.7411765, 107 | "blue": 0.7764706 108 | } 109 | } 110 | }, 111 | { 112 | "colorType": "ACCENT4", 113 | "color": { 114 | "rgbColor": { 115 | "red": 0.20392157, 116 | "green": 0.65882355, 117 | "blue": 0.3254902 118 | } 119 | } 120 | }, 121 | { 122 | "colorType": "ACCENT3", 123 | "color": { 124 | "rgbColor": { 125 | "red": 0.9843137, 126 | "green": 0.7372549, 127 | "blue": 0.015686275 128 | } 129 | } 130 | } 131 | ] 132 | } 133 | }, 134 | "sheets": [ 135 | { 136 | "properties": { 137 | "sheetId": 0, 138 | "title": "Sheet1", 139 | "index": 0, 140 | "sheetType": "GRID", 141 | "gridProperties": { 142 | "rowCount": 1000, 143 | "columnCount": 26 144 | } 145 | }, 146 | "data": [ 147 | { 148 | "startRow": 1, 149 | "rowData": [ 150 | { 151 | "values": [ 152 | { 153 | "userEnteredValue": { 154 | "stringValue": "a" 155 | }, 156 | "effectiveValue": { 157 | "stringValue": "a" 158 | }, 159 | "formattedValue": "🌭", 160 | "effectiveFormat": { 161 | "backgroundColor": { 162 | "red": 1, 163 | "green": 1, 164 | "blue": 1 165 | }, 166 | "padding": { 167 | "top": 2, 168 | "right": 3, 169 | "bottom": 2, 170 | "left": 3 171 | }, 172 | "horizontalAlignment": "LEFT", 173 | "verticalAlignment": "BOTTOM", 174 | "wrapStrategy": "OVERFLOW_CELL", 175 | "textFormat": { 176 | "foregroundColor": {}, 177 | "fontFamily": "Arial", 178 | "fontSize": 10, 179 | "bold": false, 180 | "italic": false, 181 | "strikethrough": false, 182 | "underline": false, 183 | "foregroundColorStyle": { 184 | "rgbColor": {} 185 | } 186 | }, 187 | "hyperlinkDisplayType": "PLAIN_TEXT", 188 | "backgroundColorStyle": { 189 | "rgbColor": { 190 | "red": 1, 191 | "green": 1, 192 | "blue": 1 193 | } 194 | } 195 | } 196 | } 197 | ] 198 | } 199 | ], 200 | "rowMetadata": [ 201 | { 202 | "pixelSize": 21 203 | } 204 | ], 205 | "columnMetadata": [ 206 | { 207 | "pixelSize": 100 208 | } 209 | ] 210 | } 211 | ] 212 | } 213 | ], 214 | "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1Kn_9WKsuT-H0aJL3fvqukt27HlizMLd-KQfkNgeWj4U/edit" 215 | } 216 | -------------------------------------------------------------------------------- /pkg/googlesheets/utils.go: -------------------------------------------------------------------------------- 1 | package googlesheets 2 | 3 | import ( 4 | "github.com/grafana/grafana-plugin-sdk-go/data" 5 | ) 6 | 7 | func findTimeField(frame *data.Frame) int { 8 | timeIndices := frame.TypeIndices(data.FieldTypeTime, data.FieldTypeNullableTime) 9 | if len(timeIndices) == 0 { 10 | return -1 11 | } 12 | return timeIndices[0] 13 | } 14 | 15 | func getExcelColumnName(columnNumber int) string { 16 | dividend := columnNumber 17 | columnName := "" 18 | var modulo int 19 | 20 | for dividend > 0 { 21 | modulo = ((dividend - 1) % 26) 22 | columnName = string(rune(65+modulo)) + columnName 23 | dividend = ((dividend - modulo) / 26) 24 | } 25 | 26 | return columnName 27 | } 28 | -------------------------------------------------------------------------------- /pkg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/grafana/google-sheets-datasource/pkg/googlesheets" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" 9 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 10 | ) 11 | 12 | func main() { 13 | if err := datasource.Manage("google-sheets-datasource", googlesheets.NewDatasource, datasource.ManageOpts{}); err != nil { 14 | log.DefaultLogger.Error(err.Error()) 15 | os.Exit(1) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkg/models/query.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/grafana/grafana-plugin-sdk-go/backend" 8 | ) 9 | 10 | // QueryModel represents a spreadsheet query. 11 | type QueryModel struct { 12 | Spreadsheet string `json:"spreadsheet"` 13 | Range string `json:"range"` 14 | CacheDurationSeconds int `json:"cacheDurationSeconds"` 15 | UseTimeFilter bool `json:"useTimeFilter"` 16 | 17 | // Not from JSON 18 | TimeRange backend.TimeRange `json:"-"` 19 | MaxDataPoints int64 `json:"-"` 20 | } 21 | 22 | // GetQueryModel returns the well typed query model 23 | func GetQueryModel(query backend.DataQuery) (*QueryModel, error) { 24 | model := &QueryModel{} 25 | 26 | err := json.Unmarshal(query.JSON, &model) 27 | if err != nil { 28 | return nil, fmt.Errorf("error reading query: %s", err.Error()) 29 | } 30 | 31 | // Copy directly from the well typed query 32 | model.TimeRange = query.TimeRange 33 | model.MaxDataPoints = query.MaxDataPoints 34 | return model, nil 35 | } 36 | -------------------------------------------------------------------------------- /pkg/models/settings.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/grafana/grafana-google-sdk-go/pkg/utils" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | ) 10 | 11 | // DatasourceSettings contains Google Sheets API authentication properties. 12 | type DatasourceSettings struct { 13 | InstanceSettings backend.DataSourceInstanceSettings 14 | AuthType string `json:"authType"` // jwt | key | gce 15 | APIKey string `json:"apiKey"` 16 | DefaultProject string `json:"defaultProject"` 17 | JWT string `json:"jwt"` 18 | ClientEmail string `json:"clientEmail"` 19 | TokenURI string `json:"tokenUri"` 20 | AuthenticationType string `json:"authenticationType"` 21 | PrivateKeyPath string `json:"privateKeyPath"` 22 | 23 | // Saved in secure JSON 24 | PrivateKey string `json:"-"` 25 | } 26 | 27 | // LoadSettings gets the relevant settings from the plugin context 28 | func LoadSettings(ctx backend.PluginContext) (*DatasourceSettings, error) { 29 | model := &DatasourceSettings{} 30 | 31 | settings := ctx.DataSourceInstanceSettings 32 | err := json.Unmarshal(settings.JSONData, &model) 33 | if err != nil { 34 | return nil, fmt.Errorf("error reading settings: %s", err.Error()) 35 | } 36 | 37 | model.PrivateKey, err = utils.GetPrivateKey(settings) 38 | if err != nil { 39 | return model, err 40 | } 41 | 42 | model.APIKey = settings.DecryptedSecureJSONData["apiKey"] 43 | // Leaving this here for backward compatibility 44 | model.JWT = settings.DecryptedSecureJSONData["jwt"] 45 | model.InstanceSettings = *settings 46 | 47 | // Make sure that old settings are migrated to the new ones 48 | if model.AuthType != "" { 49 | model.AuthenticationType = model.AuthType 50 | } 51 | return model, nil 52 | } 53 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PluginOptions } from '@grafana/plugin-e2e'; 2 | import { defineConfig, devices } from '@playwright/test'; 3 | import { dirname } from 'node:path'; 4 | 5 | const pluginE2eAuth = `${dirname(require.resolve('@grafana/plugin-e2e'))}/auth`; 6 | 7 | /** 8 | * Read environment variables from file. 9 | * https://github.com/motdotla/dotenv 10 | */ 11 | // require('dotenv').config(); 12 | 13 | /** 14 | * See https://playwright.dev/docs/test-configuration. 15 | */ 16 | export default defineConfig({ 17 | testDir: './tests/e2e', 18 | /* Run tests in files in parallel */ 19 | fullyParallel: true, 20 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 21 | forbidOnly: !!process.env.CI, 22 | /* Retry on CI only */ 23 | retries: process.env.CI ? 2 : 0, 24 | /* Opt out of parallel tests on CI. */ 25 | workers: process.env.CI ? 1 : undefined, 26 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 27 | reporter: 'html', 28 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 29 | use: { 30 | /* Base URL to use in actions like `await page.goto('/')`. */ 31 | baseURL: process.env.GRAFANA_URL || `http://localhost:${process.env.PORT || 3000}`, 32 | 33 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 34 | trace: 'on-first-retry', 35 | }, 36 | 37 | /* Configure projects for major browsers */ 38 | projects: [ 39 | // 1. Login to Grafana and store the cookie on disk for use in other tests. 40 | { 41 | name: 'auth', 42 | testDir: pluginE2eAuth, 43 | testMatch: [/.*\.js/], 44 | }, 45 | // 2. Run tests in Google Chrome. Every test will start authenticated as admin user. 46 | { 47 | name: 'chromium', 48 | use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/admin.json' }, 49 | dependencies: ['auth'], 50 | }, 51 | ], 52 | }); 53 | -------------------------------------------------------------------------------- /src/DataSource.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataQueryRequest, 3 | DataQueryResponse, 4 | DataSourceInstanceSettings, 5 | ScopedVars, 6 | SelectableValue, 7 | } from '@grafana/data'; 8 | import { DataSourceOptions } from '@grafana/google-sdk'; 9 | import { DataSourceWithBackend, getTemplateSrv } from '@grafana/runtime'; 10 | import { SheetsQuery } from './types'; 11 | import { Observable } from 'rxjs'; 12 | import { trackRequest } from 'tracking'; 13 | 14 | export class DataSource extends DataSourceWithBackend { 15 | authType: string; 16 | constructor(instanceSettings: DataSourceInstanceSettings) { 17 | super(instanceSettings); 18 | this.authType = instanceSettings.jsonData.authenticationType; 19 | } 20 | 21 | query(request: DataQueryRequest): Observable { 22 | trackRequest(request); 23 | return super.query(request); 24 | } 25 | 26 | // Enables default annotation support for 7.2+ 27 | annotations = {}; 28 | 29 | // Support template variables for spreadsheet and range 30 | applyTemplateVariables(query: SheetsQuery, scopedVars: ScopedVars) { 31 | const templateSrv = getTemplateSrv(); 32 | return { 33 | ...query, 34 | spreadsheet: templateSrv.replace(query.spreadsheet, scopedVars), 35 | range: query.range ? templateSrv.replace(query.range, scopedVars) : '', 36 | }; 37 | } 38 | 39 | async getSpreadSheets(): Promise>> { 40 | return this.getResource('spreadsheets').then(({ spreadsheets }) => 41 | spreadsheets 42 | ? Object.entries(spreadsheets).map(([value, label]) => ({ label, value }) as SelectableValue) 43 | : [] 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # Google Sheets data source 2 | 3 | Visualize your Google Spreadsheets in Grafana! 4 | 5 | ## Documentation 6 | 7 | For the plugin documentation, visit [plugin documentation website](https://grafana.com/docs/plugins/grafana-googlesheets-datasource/). 8 | 9 | ## Video Tutorial 10 | 11 | Watch this video to learn more about setting up the Grafana Google Sheets data source plugin: 12 | 13 | [![Google Sheets data source plugin | Visualize Spreadsheets using Grafana | Tutorial](https://img.youtube.com/vi/hqeqeQFrtSA/hq720.jpg)](https://youtu.be/hqeqeQFrtSA "Grafana Google Sheets data source plugin") 14 | 15 | > ## Give it a try using Grafana Play 16 | > 17 | > With Grafana Play, you can explore and see how it works, learning from practical examples to accelerate your development. This feature can be seen on [Google Sheets data source plugin demo](https://play.grafana.org/d/ddkar8yanj56oa/visualizing-google-sheets-data). 18 | -------------------------------------------------------------------------------- /src/components/ConfigEditor.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, screen, fireEvent } from '@testing-library/react'; 4 | import { ConfigEditor } from './ConfigEditor'; 5 | 6 | describe('ConfigEditor', () => { 7 | it('should support old authType property', () => { 8 | const onOptionsChange = jest.fn(); 9 | // Render component with old authType property 10 | render( 11 | 15 | ); 16 | 17 | // Check that the correct auth type is selected 18 | expect(screen.getByRole('radio', { name: 'API Key' })).toBeChecked(); 19 | 20 | // Make sure that the user can still change the auth type 21 | fireEvent.click(screen.getByLabelText('Google JWT File')); 22 | 23 | // Check onOptionsChange is called with the correct value 24 | expect(onOptionsChange).toHaveBeenCalledWith({ 25 | jsonData: { authType: 'key', authenticationType: 'jwt' }, 26 | secureJsonFields: {}, 27 | }); 28 | }); 29 | 30 | it('should be backward compatible with API Key', () => { 31 | render( 32 | 36 | ); 37 | 38 | // Check that the correct auth type is selected 39 | expect(screen.getByRole('radio', { name: 'API Key' })).toBeChecked(); 40 | 41 | // Check that the API key is configured 42 | expect(screen.getByPlaceholderText('Enter API key')).toHaveAttribute('value', 'configured'); 43 | }); 44 | 45 | // 46 | 47 | it('should be backward compatible with JWT auth type', () => { 48 | render( 49 | 53 | ); 54 | 55 | // Check that the correct auth type is selected 56 | expect(screen.getByLabelText('Google JWT File')).toBeChecked(); 57 | 58 | // Check that the Private key input is configured 59 | expect(screen.getByTestId('Private Key Input')).toHaveAttribute('value', 'configured'); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/ConfigEditor.tsx: -------------------------------------------------------------------------------- 1 | import { DataSourcePluginOptionsEditorProps, onUpdateDatasourceSecureJsonDataOption } from '@grafana/data'; 2 | import { AuthConfig, DataSourceOptions } from '@grafana/google-sdk'; 3 | import { Field, SecretInput } from '@grafana/ui'; 4 | import React from 'react'; 5 | import { GoogleSheetsAuth, GoogleSheetsSecureJSONData, googleSheetsAuthTypes } from '../types'; 6 | import { getBackwardCompatibleOptions } from '../utils'; 7 | import { ConfigurationHelp } from './ConfigurationHelp'; 8 | import { DataSourceDescription } from '@grafana/plugin-ui'; 9 | import { Divider } from './Divider'; 10 | 11 | export type Props = DataSourcePluginOptionsEditorProps; 12 | 13 | export function ConfigEditor(props: Props) { 14 | const options = getBackwardCompatibleOptions(props.options); 15 | 16 | const apiKeyProps = { 17 | isConfigured: Boolean(options.secureJsonFields.apiKey), 18 | value: options.secureJsonData?.apiKey || '', 19 | placeholder: 'Enter API key', 20 | id: 'apiKey', 21 | onReset: () => 22 | props.onOptionsChange({ 23 | ...options, 24 | secureJsonFields: { ...options.secureJsonFields, apiKey: false }, 25 | secureJsonData: { apiKey: '' }, 26 | jsonData: options.jsonData, 27 | }), 28 | onChange: onUpdateDatasourceSecureJsonDataOption(props, 'apiKey'), 29 | }; 30 | 31 | return ( 32 | <> 33 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {options.jsonData.authenticationType === GoogleSheetsAuth.API && ( 48 | 49 | 50 | 51 | )} 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/components/ConfigurationHelp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Collapse, useTheme2 } from '@grafana/ui'; 3 | import { GoogleSheetsAuth } from '../types'; 4 | 5 | interface Props { 6 | authenticationType: string; 7 | } 8 | 9 | export const ConfigurationHelp = ({ authenticationType }: Props) => { 10 | const [isOpen, setIsOpen] = useState(false); 11 | const theme = useTheme2(); 12 | 13 | const renderHelpBody = () => { 14 | switch (authenticationType) { 15 | case GoogleSheetsAuth.API: 16 | return ( 17 | <> 18 |

Generate an API key

19 |
    20 |
  1. 21 | Open the{' '} 22 | 28 | Credentials page 29 | {' '} 30 | in the Google API Console. 31 |
  2. 32 |
  3. 33 | Click Create Credentials and then click API key. 34 |
  4. 35 |
  5. 36 | Before using Google APIs, you need to turn them on in a Google Cloud project.{' '} 37 | 43 | Enable the API 44 | 45 |
  6. 46 |
  7. 47 | Copy the key and paste it in the API Key field above. The file contents are encrypted and saved in the 48 | Grafana database. 49 |
  8. 50 |
51 | 52 | ); 53 | case GoogleSheetsAuth.GCE: 54 | return ( 55 | <> 56 |

57 | When Grafana is running on a Google Compute Engine (GCE) virtual machine, Grafana can automatically 58 | retrieve default credentials from the metadata server. As a result, there is no need to generate a private 59 | key file for the service account. You also do not need to upload the file to Grafana. The following 60 | preconditions must be met before Grafana can retrieve default credentials. 61 |

62 | 63 |
    64 |
  1. 65 | You must create a Service Account for use by the GCE virtual machine. For more information, refer to{' '} 66 | 72 | Create new service account 73 | 74 | . 75 |
  2. 76 |
  3. 77 | Verify that the GCE virtual machine instance is running as the service account that you created. For 78 | more information, refer to{' '} 79 | 85 | setting up an instance to run as a service account 86 | 87 | . 88 |
  4. 89 |
  5. Allow access to the specified API scope.
  6. 90 |
91 | 92 |

93 | For more information about creating and enabling service accounts for GCE instances, refer to{' '} 94 | 100 | enabling service accounts for instances in Google documentation 101 | 102 | . 103 |

104 | 105 | ); 106 | default: 107 | // Default is JWT 108 | return ( 109 | <> 110 |

Generate a JWT file

111 |
    112 |
  1. 113 | Open the{' '} 114 | 120 | Credentials 121 | {' '} 122 | page in the Google API Console. 123 |
  2. 124 |
  3. 125 | Click Create Credentials then click Service account. 126 |
  4. 127 |
  5. On the Create service account page, enter the Service account details.
  6. 128 |
  7. 129 | On the Create service account page, fill in the Service account details and 130 | then click Create 131 |
  8. 132 |
  9. 133 | On the Service account permissions page, don’t add a role to the service account. 134 | Just click Continue 135 |
  10. 136 |
  11. 137 | In the next step, click Create Key. Choose key type JSON and click{' '} 138 | Create. A JSON key file will be created and downloaded to your computer 139 |
  12. 140 |
  13. 141 | Open the{' '} 142 | 148 | Google Sheets 149 | {' '} 150 | in API Library and enable access for your account 151 |
  14. 152 |
  15. 153 | Open the{' '} 154 | 160 | Google Drive 161 | {' '} 162 | in API Library and enable access for your account. Access to the Google Drive API is used to list all 163 | spreadsheets that you have access to. 164 |
  16. 165 |
  17. 166 | Share any private files/folders you want to access with the service account's email address. The 167 | email is specified as client_email in the Google JWT File. 168 |
  18. 169 |
  19. 170 | Drag the file to the dotted zone above. Then click Save & Test. The file contents will be 171 | encrypted and saved in the Grafana database. 172 |
  20. 173 |
174 | 175 | ); 176 | } 177 | }; 178 | return ( 179 | setIsOpen((x) => !x)} 184 | > 185 | {renderHelpBody()} 186 | 187 | ); 188 | }; 189 | -------------------------------------------------------------------------------- /src/components/Divider.tsx: -------------------------------------------------------------------------------- 1 | import { css } from '@emotion/css'; 2 | import { GrafanaTheme2 } from '@grafana/data'; 3 | import { useStyles2 } from '@grafana/ui'; 4 | import React from 'react'; 5 | 6 | // this custom component is necessary because the Grafana UI component is not backwards compatible with Grafana < 10.1.0 7 | export const Divider = () => { 8 | const styles = useStyles2(getStyles); 9 | return
; 10 | }; 11 | 12 | const getStyles = (theme: GrafanaTheme2) => { 13 | return { 14 | horizontalDivider: css({ 15 | borderTop: `1px solid ${theme.colors.border.weak}`, 16 | margin: theme.spacing(2, 0), 17 | width: '100%', 18 | }), 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/MetaInspector.tsx: -------------------------------------------------------------------------------- 1 | import { DataFrame, MetadataInspectorProps } from '@grafana/data'; 2 | import { DataSourceOptions } from '@grafana/google-sdk'; 3 | import React, { PureComponent } from 'react'; 4 | import { DataSource } from '../DataSource'; 5 | import { SheetResponseMeta, SheetsQuery } from '../types'; 6 | 7 | export type Props = MetadataInspectorProps; 8 | 9 | export class MetaInspector extends PureComponent { 10 | state = { index: 0 }; 11 | 12 | renderInfo = (frame: DataFrame) => { 13 | const meta = frame.meta?.custom as SheetResponseMeta; 14 | if (!meta) { 15 | return null; 16 | } 17 | 18 | return ( 19 |
20 |

Info

21 |
{JSON.stringify(meta, null, 2)}
22 |
23 | ); 24 | }; 25 | 26 | render() { 27 | const { data } = this.props; 28 | if (!data || !data.length) { 29 | return
No Data
; 30 | } 31 | return ( 32 |
33 |

Google Sheets Metadata

34 | {data.map((frame) => { 35 | return this.renderInfo(frame); 36 | })} 37 |
38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/QueryEditor.test.tsx: -------------------------------------------------------------------------------- 1 | import { getGoogleSheetRangeInfoFromURL, formatCacheTimeLabel } from './QueryEditor'; 2 | 3 | describe('QueryEditor', () => { 4 | it('should extract id from URL', () => { 5 | const url = 6 | 'https://docs.google.com/spreadsheets/d/1m2idieRUHdzWTu3_cpYs1lUfP_jwfgL8NBaLtqLmia8/edit#gid=790763898&range=B19:F20'; 7 | const info = getGoogleSheetRangeInfoFromURL(url); 8 | // cspell:disable-next-line 9 | expect(info.spreadsheet).toBe('1m2idieRUHdzWTu3_cpYs1lUfP_jwfgL8NBaLtqLmia8'); 10 | expect(info.range).toBe('B19:F20'); 11 | }); 12 | 13 | it('should format cache time seconds label correctly', () => { 14 | expect(formatCacheTimeLabel(0)).toBe('0s'); 15 | expect(formatCacheTimeLabel(20)).toBe('20s'); 16 | expect(formatCacheTimeLabel(60)).toBe('1m'); 17 | expect(formatCacheTimeLabel(60 * 30)).toBe('30m'); 18 | expect(formatCacheTimeLabel(60 * 60)).toBe('1h'); 19 | expect(formatCacheTimeLabel(60 * 60 * 10)).toBe('10h'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/QueryEditor.tsx: -------------------------------------------------------------------------------- 1 | import { QueryEditorProps } from '@grafana/data'; 2 | import { DataSourceOptions } from '@grafana/google-sdk'; 3 | import { InlineFieldRow, InlineFormLabel, InlineSwitch, Input, LinkButton, Segment, SegmentAsync } from '@grafana/ui'; 4 | import React, { ChangeEvent, PureComponent } from 'react'; 5 | import { DataSource } from '../DataSource'; 6 | import { SheetsQuery } from '../types'; 7 | import { reportInteraction } from '@grafana/runtime'; 8 | import { css } from '@emotion/css'; 9 | 10 | type Props = QueryEditorProps; 11 | 12 | export function getGoogleSheetRangeInfoFromURL(url: string): Partial { 13 | let idx = url?.indexOf('/d/'); 14 | if (!idx) { 15 | // The original value 16 | return { spreadsheet: url }; 17 | } 18 | 19 | let id = url.substring(idx + 3); 20 | idx = id.indexOf('/'); 21 | if (idx) { 22 | id = id.substring(0, idx); 23 | } 24 | 25 | idx = url.indexOf('range='); 26 | if (idx > 0) { 27 | const sub = url.substring(idx + 'range='.length); 28 | return { spreadsheet: id, range: sub }; 29 | } 30 | return { spreadsheet: id }; 31 | } 32 | 33 | export function toGoogleURL(info: SheetsQuery): string { 34 | let url = `https://docs.google.com/spreadsheets/d/${info.spreadsheet}/view`; 35 | if (info.range) { 36 | url += '#range=' + info.range; 37 | } 38 | return url; 39 | } 40 | 41 | const defaultCacheDuration = 300; 42 | 43 | export const formatCacheTimeLabel = (s: number = defaultCacheDuration) => { 44 | if (s < 60) { 45 | return s + 's'; 46 | } else if (s < 3600) { 47 | return s / 60 + 'm'; 48 | } 49 | 50 | return s / 3600 + 'h'; 51 | }; 52 | 53 | export class QueryEditor extends PureComponent { 54 | componentDidMount() { 55 | if (!this.props.query.hasOwnProperty('cacheDurationSeconds')) { 56 | this.props.query.cacheDurationSeconds = defaultCacheDuration; // um :( 57 | } 58 | } 59 | 60 | onRangeChange = (event: ChangeEvent) => { 61 | this.props.onChange({ 62 | ...this.props.query, 63 | range: event.target.value, 64 | }); 65 | }; 66 | 67 | onSpreadsheetIDChange = (item: any) => { 68 | const { query, onRunQuery, onChange } = this.props; 69 | 70 | if (!item.value) { 71 | return; // ignore delete? 72 | } 73 | 74 | const v = item.value; 75 | // Check for pasted full URLs 76 | if (/(.*)\/spreadsheets\/d\/(.*)/.test(v)) { 77 | onChange({ ...query, ...getGoogleSheetRangeInfoFromURL(v) }); 78 | } else { 79 | onChange({ ...query, spreadsheet: v }); 80 | } 81 | onRunQuery(); 82 | }; 83 | 84 | toggleUseTimeFilter = (event?: React.SyntheticEvent) => { 85 | const { query, onChange, onRunQuery } = this.props; 86 | 87 | reportInteraction('grafana_google_sheets_time_filter_toggled', { 88 | checked: !query.useTimeFilter, 89 | }); 90 | 91 | onChange({ 92 | ...query, 93 | useTimeFilter: !query.useTimeFilter, 94 | }); 95 | onRunQuery(); 96 | }; 97 | 98 | render() { 99 | const { query, onRunQuery, onChange, datasource } = this.props; 100 | const styles = getStyles(); 101 | 102 | return ( 103 | <> 104 | 105 | 110 | The spreadsheetId is used to identify which spreadsheet is to be accessed or altered. This 111 | ID is the value between the "/d/" and the "/edit" in the URL of your spreadsheet. 112 |

113 | } 114 | > 115 | Spreadsheet ID 116 |
117 | datasource.getSpreadSheets()} 119 | placeholder="Enter SpreadsheetID" 120 | value={query.spreadsheet} 121 | allowCustomValue={true} 122 | onChange={this.onSpreadsheetIDChange} 123 | /> 124 | {query.spreadsheet && ( 125 | reportInteraction('grafana_google_sheets_document_opened', {})} 132 | /> 133 | )} 134 | 135 |
136 | 137 | 138 | 143 | A string like Sheet1!A1:B2, that refers to a group of cells in the spreadsheet, and is 144 | typically used in formulas. Named ranges are also supported. When a named range conflicts with a 145 | sheet’s name, the named range is preferred. 146 |

147 | } 148 | > 149 | Range 150 |
151 | 159 | 160 | 161 |
162 | 163 | 164 | 169 | Cache Time 170 | 171 | ({ 174 | label: formatCacheTimeLabel(value), 175 | value, 176 | description: value ? '' : 'Response is not cached at all', 177 | }))} 178 | onChange={({ value }) => { 179 | reportInteraction('grafana_google_sheets_cache_updated', { 180 | secondsValue: value, 181 | }); 182 | 183 | onChange({ ...query, cacheDurationSeconds: value! }); 184 | }} 185 | /> 186 | 187 | 188 | 189 | 190 | 195 | Use Time Filter 196 | 197 | 202 | 203 | 204 | 205 | ); 206 | } 207 | } 208 | 209 | const QueryRowTerminator = () => { 210 | const styles = getStyles(); 211 | 212 | return ( 213 | 214 | <> 215 | 216 | ); 217 | }; 218 | 219 | const getStyles = () => { 220 | return { 221 | rowSpacing: css({ 222 | marginBottom: '4px', 223 | }), 224 | rowTerminator: css({ 225 | flexGrow: 1, 226 | }), 227 | marginRight: css({ 228 | marginRight: '4px', 229 | }), 230 | }; 231 | }; 232 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { QueryEditor } from './QueryEditor'; 2 | export { ConfigEditor } from './ConfigEditor'; 3 | export { MetaInspector } from './MetaInspector'; 4 | -------------------------------------------------------------------------------- /src/docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuring the Google Sheets data source 2 | 3 | The Google Sheets data source is using the [Google Sheet API](https://developers.google.com/sheets/api) to access spreadsheets. The data source supports two ways of authenticating against the Google Sheets API. **API Key** auth is used to access public spreadsheets, and **Google JWT File** auth using a service account is used to access private files. 4 | 5 | ## API Key 6 | 7 | If a spreadsheet is shared publicly on the Internet, it can be accessed in the Google Sheets data source using **API Key** auth. When accessing public spreadsheets using the Google Sheets API, the request doesn't need to be authorized, but does need to be accompanied by an identifier, such as an API key. 8 | 9 | To generate an API Key, follow the steps in the Google Sheets data source configuration page. 10 | 11 | If you want to know how to share a file or folder, read about that in the [official Google drive documentation](https://support.google.com/drive/answer/2494822?co=GENIE.Platform%3DDesktop&hl=en#share_publicly). 12 | 13 | ## Google JWT File 14 | 15 | Whenever access to private spreadsheets is necessary, service account auth using a Google JWT File should be used. A Google service account is an account that belongs to a project within an account or organization instead of to an individual end user. Your application calls Google APIs on behalf of the service account, so users aren't directly involved. 16 | 17 | The project that the service account is associated with needs to be granted access to the [Google Sheets API](https://console.cloud.google.com/apis/library/sheets.googleapis.com?q=sheet) and the [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com?q=drive). 18 | 19 | The Google Sheets data source uses the scope `https://www.googleapis.com/auth/spreadsheets.readonly` to get read-only access to spreadsheets. It also uses the scope `https://www.googleapis.com/auth/drive.metadata.readonly` to list all spreadsheets that the service account has access to in Google Drive. 20 | 21 | To create a service account, generate a Google JWT file and enable the APIs. For more detailed instructions, refer to the steps documented for the Google Sheets data source in the "Add a data source" page in Grafana. 22 | 23 | ### Sharing 24 | 25 | By default, the service account doesn't have access to any spreadsheets within the account/organization that it is associated with. To grant the service account access to files and/or folders in Google Drive, you need to share the file/folder with the service account's email address. The email is specified in the Google JWT File. If you want to know how to share a file or folder, please refer to the [official Google drive documentation](https://support.google.com/drive/answer/2494822?co=GENIE.Platform%3DDesktop&hl=en#share_publicly). 26 | 27 | > **_:warning:_** Beware that once a file/folder is shared with the service account, all users in Grafana will be able to see the spreadsheet/spreadsheets. 28 | 29 | ## Configure a GCE Default Service Account 30 | 31 | When Grafana is running on a Google Compute Engine (GCE) virtual machine, Grafana can automatically retrieve default credentials from the metadata server. As a result, there is no need to generate a private key file for the service account. You also do not need to upload the file to Grafana. The following preconditions must be met before Grafana can retrieve default credentials. 32 | 33 | - You must create a Service Account for use by the GCE virtual machine. For more information, refer to [Create new service account](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#createanewserviceaccount). 34 | - Verify that the GCE virtual machine instance is running as the service account that you created. For more information, refer to [setting up an instance to run as a service account](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#using). 35 | - Allow access to the specified API scope (`"https://www.googleapis.com/auth/spreadsheets.readonly"`). 36 | 37 | For more information about creating and enabling service accounts for GCE instances, refer to [enabling service accounts for instances in Google documentation](https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances). 38 | -------------------------------------------------------------------------------- /src/docs/img/copy-range.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/docs/img/copy-range.png -------------------------------------------------------------------------------- /src/docs/img/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/docs/img/dashboard.png -------------------------------------------------------------------------------- /src/docs/img/query-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/docs/img/query-editor.png -------------------------------------------------------------------------------- /src/docs/img/spreadsheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/docs/img/spreadsheet.png -------------------------------------------------------------------------------- /src/docs/img/spreadsheets-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/docs/img/spreadsheets-list.png -------------------------------------------------------------------------------- /src/docs/provisioning.md: -------------------------------------------------------------------------------- 1 | # Provisioning 2 | 3 | It's possible configure the Google Sheets data source using config files with Grafana's provisioning system. You can read more about how it works and all the settings you can set for data sources on the [provisioning docs page](https://grafana.com/docs/grafana/latest/administration/provisioning/#datasources). 4 | 5 | Here is a provisioning example using API key authentication type. 6 | 7 | ```yaml 8 | apiVersion: 1 9 | datasources: 10 | - name: GoogleSheetsDatasourceApiKey 11 | type: google-sheets-datasource 12 | enabled: true 13 | jsonData: 14 | authenticationType: 'key' 15 | secureJsonData: 16 | apiKey: 'your-api-key' 17 | version: 1 18 | editable: true 19 | ``` 20 | 21 | Here is a provisioning example using a Google JWT file (service account) authentication type. 22 | 23 | ```yaml 24 | apiVersion: 1 25 | datasources: 26 | - name: GoogleSheetsDatasourceJWT 27 | type: google-sheets-datasource 28 | enabled: true 29 | jsonData: 30 | authenticationType: 'jwt' 31 | defaultProject: 'your-project-id' 32 | clientEmail: 'your-client-email' 33 | tokenUri: 'https://oauth2.googleapis.com/token' 34 | secureJsonData: 35 | privateKey: '-----BEGIN PRIVATE KEY-----\nnn-----END PRIVATE KEY-----\n' 36 | version: 1 37 | editable: true 38 | ``` 39 | 40 | Here is a provisioning example using a GCE authentication type. 41 | 42 | ```yaml 43 | apiVersion: 1 44 | datasources: 45 | - name: GoogleSheetsDatasourceJWT 46 | type: google-sheets-datasource 47 | enabled: true 48 | jsonData: 49 | authenticationType: 'gce' 50 | defaultProject: 'your-project-id' 51 | version: 1 52 | editable: true 53 | ``` 54 | -------------------------------------------------------------------------------- /src/docs/using-the-editor.md: -------------------------------------------------------------------------------- 1 | # Using the editor 2 | 3 | ![Visualize temperature date in Grafana Google Spreadsheets data source](./img/query-editor.png) 4 | 5 | Please refer to the Google Sheets API [common terms](https://developers.google.com/sheets/api/guides/concepts#spreadsheet_id) to get detailed information on what **spreadsheet ID** and **range** is. 6 | 7 | ## Spreadsheet ID 8 | 9 | Once the **Spreadsheet ID** field is clicked, you have the following options: 10 | 11 | - Enter a spreadsheet ID 12 | - Enter a spreadsheet URL. The query editor will then extract the spreadsheet ID from the URL. 13 | - Select a spreadsheet from the dropdown. The dropdown will only be populated if [Google JWT File](./configuration.md) auth is used and as long as spreadsheets are shared with the service account. Read about configuring JWT Auth [here](./configuration.md). 14 | ![Available spreadsheets listed in a dropdown](./img/spreadsheets-list.png) 15 | - Enter a link to a certain range. The query editor will then extract both spreadsheet ID and range from the URL. To copy a range, open the Spreadsheet and select the cells that you want to include. Then right click and select `Get link to this range`. The link will be stored in the clipboard. 16 | ![Available spreadsheets listed in a dropdown](./img/copy-range.png) 17 | 18 | Right next to the Spreadsheet ID input field there's button. If you click on that button, the spreadsheet will be opened in Google Sheets in a separate tab. 19 | 20 | ## Range 21 | 22 | [A1 notation](https://developers.google.com/sheets/api/guides/concepts#a1_notation) is used to specify the range. If the range field is left blank, the Google Sheet API will return the whole first sheet in the spreadsheet. 23 | 24 | ## Cache time 25 | 26 | The Google Sheets data source has a caching feature that makes it possible to cache the Spreadsheet API response. The cache key is a combination of spreadsheet ID and range. The default cache time is set to five minutes, but that can be changed by selecting another option from the **Cache Time** field. By setting cache time to `0s`, the cache will be bypassed. 27 | 28 | ## Time filter 29 | 30 | In case the Google Sheets data source was able to parse all cells in a column to the [Golang Time](https://golang.org/pkg/time/) data type, you'll be able to filter out all the rows in the Spreadsheet that are outside the bounds of the time range that is specified in the dashboard in Grafana. To do that you need to enable the **Use Time Filter** option in the query editor. This feature might be useful when you want to visualize spreadsheet data using a Graph panel. 31 | -------------------------------------------------------------------------------- /src/img/config-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/img/config-page.png -------------------------------------------------------------------------------- /src/img/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/img/dashboard.png -------------------------------------------------------------------------------- /src/img/graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/img/graph.png -------------------------------------------------------------------------------- /src/img/query-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/img/query-editor.png -------------------------------------------------------------------------------- /src/img/sheets.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/img/spreadsheet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/img/spreadsheet.png -------------------------------------------------------------------------------- /src/img/table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/google-sheets-datasource/910cb41403e1d968b7c38c64ab5933deb1b77ca9/src/img/table.png -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { DataSourcePlugin } from '@grafana/data'; 2 | import { DataSourceOptions } from '@grafana/google-sdk'; 3 | import { ConfigEditor, MetaInspector, QueryEditor } from './components'; 4 | import { DataSource } from './DataSource'; 5 | import { SheetsQuery } from './types'; 6 | 7 | export const plugin = new DataSourcePlugin(DataSource) 8 | .setConfigEditor(ConfigEditor) 9 | .setQueryEditor(QueryEditor) 10 | .setMetadataInspector(MetaInspector); 11 | -------------------------------------------------------------------------------- /src/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json", 3 | 4 | "type": "datasource", 5 | "name": "Google Sheets", 6 | "id": "grafana-googlesheets-datasource", 7 | 8 | "backend": true, 9 | "executable": "gpx_sheets", 10 | "metrics": true, 11 | "annotations": true, 12 | 13 | "info": { 14 | "description": "The Google Sheets data source plugin for Grafana lets you to visualize your Google spreadsheets in Grafana", 15 | "author": { 16 | "name": "Grafana Labs", 17 | "url": "https://grafana.com" 18 | }, 19 | "keywords": [ 20 | "google", 21 | "sheets", 22 | "google sheets", 23 | "spreadsheets", 24 | "excel", 25 | "cloud provider", 26 | "google api", 27 | "analytics", 28 | "developer tools" 29 | ], 30 | "logos": { 31 | "small": "img/sheets.svg", 32 | "large": "img/sheets.svg" 33 | }, 34 | "links": [ 35 | { 36 | "name": "Docs", 37 | "url": "https://grafana.com/docs/plugins/grafana-googlesheets-datasource/" 38 | }, 39 | { 40 | "name": "Website", 41 | "url": "https://github.com/grafana/google-sheets-datasource" 42 | }, 43 | { 44 | "name": "Report bug", 45 | "url": "https://github.com/grafana/google-sheets-datasource/issues" 46 | } 47 | ], 48 | "screenshots": [ 49 | { 50 | "name": "Average temperature dashboard example", 51 | "path": "img/dashboard.png" 52 | }, 53 | { 54 | "name": "Average temperature spreadsheet example", 55 | "path": "img/spreadsheet.png" 56 | }, 57 | { 58 | "name": "Average temperature table example", 59 | "path": "img/table.png" 60 | }, 61 | { 62 | "name": "Average temperature graph example", 63 | "path": "img/graph.png" 64 | }, 65 | { 66 | "name": "Query editor", 67 | "path": "img/query-editor.png" 68 | }, 69 | { 70 | "name": "Config page - Google JWT File auth", 71 | "path": "img/config-page.png" 72 | } 73 | ], 74 | "version": "%VERSION%", 75 | "updated": "%TODAY%" 76 | }, 77 | "dependencies": { 78 | "grafanaDependency": ">=10.4.8", 79 | "grafanaVersion": "10.4.x", 80 | "plugins": [] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/tracking.ts: -------------------------------------------------------------------------------- 1 | import { CoreApp, DataQueryRequest } from '@grafana/data'; 2 | import { reportInteraction } from '@grafana/runtime'; 3 | import { SheetsQuery } from 'types'; 4 | 5 | export const trackRequest = (request: DataQueryRequest) => { 6 | if (request.app === CoreApp.Dashboard || request.app === CoreApp.PanelViewer) { 7 | return; 8 | } 9 | 10 | request.targets.forEach((target) => { 11 | reportInteraction('grafana_google_sheets_query_executed', { 12 | app: request.app, 13 | useTimeFilter: target.useTimeFilter ?? false, 14 | cacheDurationSeconds: target.cacheDurationSeconds ?? 0, 15 | }); 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DataQuery } from '@grafana/schema'; 2 | import { GoogleAuthType, GOOGLE_AUTH_TYPE_OPTIONS, DataSourceSecureJsonData } from '@grafana/google-sdk'; 3 | 4 | export const GoogleSheetsAuth = { 5 | ...GoogleAuthType, 6 | API: 'key', 7 | } as const; 8 | 9 | export const googleSheetsAuthTypes = [{ label: 'API Key', value: GoogleSheetsAuth.API }, ...GOOGLE_AUTH_TYPE_OPTIONS]; 10 | 11 | export interface GoogleSheetsSecureJSONData extends DataSourceSecureJsonData { 12 | apiKey?: string; 13 | } 14 | 15 | export interface CacheInfo { 16 | hit: boolean; 17 | count: number; 18 | expires: string; 19 | } 20 | 21 | export interface SheetResponseMeta { 22 | spreadsheetId: string; 23 | range: string; 24 | majorDimension: string; 25 | cache: CacheInfo; 26 | warnings: string[]; 27 | } 28 | 29 | //------------------------------------------------------------------------------- 30 | // The Sheets specific types 31 | //------------------------------------------------------------------------------- 32 | 33 | export interface SheetsQuery extends DataQuery { 34 | spreadsheet: string; 35 | range?: string; 36 | cacheDurationSeconds?: number; 37 | useTimeFilter?: boolean; 38 | } 39 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { GoogleSheetsAuth } from './types'; 2 | import { getBackwardCompatibleOptions } from './utils'; 3 | 4 | describe('getBackwardCompatibleOptions', () => { 5 | it('should not mutate the option object', () => { 6 | const options: any = Object.freeze({ 7 | jsonData: Object.freeze({ 8 | authenticationType: GoogleSheetsAuth.JWT, 9 | }), 10 | secureJsonFields: Object.freeze({}), 11 | }); 12 | expect(getBackwardCompatibleOptions(options)).toEqual(options); 13 | }); 14 | 15 | it('should set authenticationType to authType if authType is set', () => { 16 | const options: any = { 17 | jsonData: { 18 | authType: GoogleSheetsAuth.API, 19 | }, 20 | secureJsonFields: {}, 21 | }; 22 | const expectedOptions = { 23 | jsonData: { 24 | authenticationType: GoogleSheetsAuth.API, 25 | authType: GoogleSheetsAuth.API, 26 | }, 27 | secureJsonFields: {}, 28 | }; 29 | expect(getBackwardCompatibleOptions(options)).toEqual(expectedOptions); 30 | }); 31 | 32 | it('should set JWT fields to "configured" if JWT is set in secureJsonFields', () => { 33 | const options: any = { 34 | jsonData: { 35 | authenticationType: GoogleSheetsAuth.JWT, 36 | clientEmail: '', 37 | defaultProject: '', 38 | tokenUri: '', 39 | }, 40 | secureJsonFields: { 41 | jwt: true, 42 | }, 43 | }; 44 | const expectedOptions = { 45 | jsonData: { 46 | authenticationType: GoogleSheetsAuth.JWT, 47 | clientEmail: 'configured', 48 | defaultProject: 'configured', 49 | tokenUri: 'configured', 50 | }, 51 | secureJsonFields: { 52 | jwt: true, 53 | privateKey: true, 54 | }, 55 | }; 56 | expect(getBackwardCompatibleOptions(options)).toEqual(expectedOptions); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { GoogleSheetsAuth } from './types'; 2 | import { Props } from './components/ConfigEditor'; 3 | 4 | export function getBackwardCompatibleOptions(options: Props['options']): Props['options'] { 5 | const changedOptions = { 6 | ...options, 7 | jsonData: { ...options.jsonData }, 8 | secureJsonFields: { ...options.secureJsonFields }, 9 | }; 10 | // Make sure we support the old authType property 11 | changedOptions.jsonData.authenticationType = options.jsonData.authenticationType || options.jsonData.authType!; 12 | 13 | // Show a configured message for the JWT fields when JWT is set in secureJsonFields 14 | if (changedOptions.jsonData.authenticationType === GoogleSheetsAuth.JWT && options.secureJsonFields?.jwt) { 15 | changedOptions.jsonData.clientEmail = 'configured'; 16 | changedOptions.jsonData.defaultProject = 'configured'; 17 | changedOptions.jsonData.tokenUri = 'configured'; 18 | changedOptions.secureJsonFields.privateKey = true; 19 | } 20 | 21 | return changedOptions; 22 | } 23 | -------------------------------------------------------------------------------- /tests/e2e/smoke.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@grafana/plugin-e2e'; 2 | 3 | test('Smoke test: plugin loads', async ({ createDataSourceConfigPage, page }) => { 4 | await createDataSourceConfigPage({ type: 'grafana-googlesheets-datasource' }); 5 | 6 | await expect(await page.getByText('Type: Google Sheets', { exact: true })).toBeVisible(); 7 | await expect(await page.locator('legend', { hasText: 'Authentication' })).toBeVisible(); 8 | }); 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------