├── .config ├── .cprc.json ├── .eslintrc ├── .prettierrc.js ├── Dockerfile ├── README.md ├── entrypoint.sh ├── jest-setup.js ├── jest.config.js ├── jest │ ├── mocks │ │ └── react-inlinesvg.tsx │ └── utils.js ├── supervisord │ └── supervisord.conf ├── tsconfig.json ├── types │ └── custom.d.ts └── webpack │ ├── constants.ts │ ├── publicPath.ts │ ├── utils.ts │ └── webpack.config.ts ├── .cprc.json ├── .devcontainer └── devcontainer.json ├── .eslintrc ├── .github └── workflows │ ├── ci.yml │ ├── is-compatible.yml │ └── release.yml ├── .gitignore ├── .markdownlint.json ├── .nvmrc ├── .prettierrc.js ├── CHANGELOG.md ├── DEVELOPMENT.md ├── LICENSE ├── README.md ├── data └── .keep ├── docker-compose.yaml ├── jest-setup.js ├── jest.config.js ├── package-lock.json ├── package.json ├── provisioning ├── dashboards │ ├── dashboard.json │ └── default.yml └── datasources │ └── default.yml ├── src ├── ConfigEditor.test.tsx ├── ConfigEditor.tsx ├── DataSource.test.ts ├── DataSource.ts ├── QueryEditor.test.tsx ├── QueryEditor.tsx ├── img │ ├── configuration.png │ ├── logo.png │ └── screenshot.png ├── migrations.test.ts ├── migrations.ts ├── module.test.ts ├── module.ts ├── plugin.json └── types.ts └── tsconfig.json /.config/.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4.16.1" 3 | } 4 | -------------------------------------------------------------------------------- /.config/.eslintrc: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-eslint-config 6 | */ 7 | { 8 | "extends": ["@grafana/eslint-config"], 9 | "root": true, 10 | "rules": { 11 | "react/prop-types": "off" 12 | }, 13 | "overrides": [ 14 | { 15 | "plugins": ["deprecation"], 16 | "files": ["src/**/*.{ts,tsx}"], 17 | "rules": { 18 | "deprecation/deprecation": "warn" 19 | }, 20 | "parserOptions": { 21 | "project": "./tsconfig.json" 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.config/.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | module.exports = { 8 | endOfLine: 'auto', 9 | printWidth: 120, 10 | trailingComma: 'es5', 11 | semi: true, 12 | jsxSingleQuote: false, 13 | singleQuote: true, 14 | useTabs: false, 15 | tabWidth: 2, 16 | }; 17 | -------------------------------------------------------------------------------- /.config/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG grafana_version=latest 2 | ARG grafana_image=grafana-enterprise 3 | 4 | FROM grafana/${grafana_image}:${grafana_version} 5 | 6 | ARG development=false 7 | ARG TARGETARCH 8 | 9 | 10 | ENV DEV "${development}" 11 | 12 | # Make it as simple as possible to access the grafana instance for development purposes 13 | # Do NOT enable these settings in a public facing / production grafana instance 14 | ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin" 15 | ENV GF_AUTH_ANONYMOUS_ENABLED "true" 16 | ENV GF_AUTH_BASIC_ENABLED "false" 17 | # Set development mode so plugins can be loaded without the need to sign 18 | ENV GF_DEFAULT_APP_MODE "development" 19 | 20 | 21 | LABEL maintainer="Grafana Labs " 22 | 23 | ENV GF_PATHS_HOME="/usr/share/grafana" 24 | WORKDIR $GF_PATHS_HOME 25 | 26 | USER root 27 | 28 | # Installing supervisor and inotify-tools 29 | RUN if [ "${development}" = "true" ]; then \ 30 | if grep -i -q alpine /etc/issue; then \ 31 | apk add supervisor inotify-tools git; \ 32 | elif grep -i -q ubuntu /etc/issue; then \ 33 | DEBIAN_FRONTEND=noninteractive && \ 34 | apt-get update && \ 35 | apt-get install -y supervisor inotify-tools git && \ 36 | rm -rf /var/lib/apt/lists/*; \ 37 | else \ 38 | echo 'ERROR: Unsupported base image' && /bin/false; \ 39 | fi \ 40 | fi 41 | 42 | COPY supervisord/supervisord.conf /etc/supervisor.d/supervisord.ini 43 | COPY supervisord/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 44 | 45 | 46 | 47 | # Inject livereload script into grafana index.html 48 | RUN sed -i 's|||g' /usr/share/grafana/public/views/index.html 49 | 50 | 51 | COPY entrypoint.sh /entrypoint.sh 52 | RUN chmod +x /entrypoint.sh 53 | ENTRYPOINT ["/entrypoint.sh"] 54 | -------------------------------------------------------------------------------- /.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/create-a-plugin/extend-a-plugin/extend-configurations#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: jest.fn().mockImplementation((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/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-jest-config 6 | */ 7 | 8 | const path = require('path'); 9 | const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils'); 10 | 11 | module.exports = { 12 | moduleNameMapper: { 13 | '\\.(css|scss|sass)$': 'identity-obj-proxy', 14 | 'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'), 15 | }, 16 | modulePaths: ['/src'], 17 | setupFilesAfterEnv: ['/jest-setup.js'], 18 | testEnvironment: 'jest-environment-jsdom', 19 | testMatch: [ 20 | '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', 21 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 22 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 23 | ], 24 | transform: { 25 | '^.+\\.(t|j)sx?$': [ 26 | '@swc/jest', 27 | { 28 | sourceMaps: 'inline', 29 | jsc: { 30 | parser: { 31 | syntax: 'typescript', 32 | tsx: true, 33 | decorators: false, 34 | dynamicImport: true, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | // Jest will throw `Cannot use import statement outside module` if it tries to load an 41 | // ES module without it being transformed first. ./config/README.md#esm-errors-with-jest 42 | transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)], 43 | }; 44 | -------------------------------------------------------------------------------- /.config/jest/mocks/react-inlinesvg.tsx: -------------------------------------------------------------------------------- 1 | // Due to the grafana/ui Icon component making fetch requests to 2 | // `/public/img/icon/.svg` we need to mock react-inlinesvg to prevent 3 | // the failed fetch requests from displaying errors in console. 4 | 5 | import React from 'react'; 6 | 7 | type Callback = (...args: any[]) => void; 8 | 9 | export interface StorageItem { 10 | content: string; 11 | queue: Callback[]; 12 | status: string; 13 | } 14 | 15 | export const cacheStore: { [key: string]: StorageItem } = Object.create(null); 16 | 17 | const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/; 18 | 19 | const InlineSVG = ({ src }: { src: string }) => { 20 | // testId will be the file name without extension (e.g. `public/img/icons/angle-double-down.svg` -> `angle-double-down`) 21 | const testId = src.replace(SVG_FILE_NAME_REGEX, '$2'); 22 | return ; 23 | }; 24 | 25 | export default InlineSVG; 26 | -------------------------------------------------------------------------------- /.config/jest/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | /* 8 | * This utility function is useful in combination with jest `transformIgnorePatterns` config 9 | * to transform specific packages (e.g.ES modules) in a projects node_modules folder. 10 | */ 11 | const nodeModulesToTransform = (moduleNames) => `node_modules\/(?!.*(${moduleNames.join('|')})\/.*)`; 12 | 13 | // Array of known nested grafana package dependencies that only bundle an ESM version 14 | const grafanaESModules = [ 15 | '.pnpm', // Support using pnpm symlinked packages 16 | '@grafana/schema', 17 | 'd3', 18 | 'd3-color', 19 | 'd3-force', 20 | 'd3-interpolate', 21 | 'd3-scale-chromatic', 22 | 'ol', 23 | 'react-colorful', 24 | 'rxjs', 25 | 'uuid', 26 | ]; 27 | 28 | module.exports = { 29 | nodeModulesToTransform, 30 | grafanaESModules, 31 | }; 32 | -------------------------------------------------------------------------------- /.config/supervisord/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | 5 | [program:grafana] 6 | user=root 7 | directory=/var/lib/grafana 8 | command=/run.sh 9 | stdout_logfile=/dev/fd/1 10 | stdout_logfile_maxbytes=0 11 | redirect_stderr=true 12 | killasgroup=true 13 | stopasgroup=true 14 | autostart=true 15 | 16 | -------------------------------------------------------------------------------- /.config/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-typescript-config 6 | */ 7 | { 8 | "compilerOptions": { 9 | "alwaysStrict": true, 10 | "declaration": false, 11 | "rootDir": "../src", 12 | "baseUrl": "../src", 13 | "typeRoots": ["../node_modules/@types"], 14 | "resolveJsonModule": true 15 | }, 16 | "ts-node": { 17 | "compilerOptions": { 18 | "module": "commonjs", 19 | "target": "es5", 20 | "esModuleInterop": true 21 | }, 22 | "transpileOnly": true 23 | }, 24 | "include": ["../src", "./types"], 25 | "extends": "@grafana/tsconfig" 26 | } 27 | -------------------------------------------------------------------------------- /.config/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | // Image declarations 2 | declare module '*.gif' { 3 | const src: string; 4 | export default src; 5 | } 6 | 7 | declare module '*.jpg' { 8 | const src: string; 9 | export default src; 10 | } 11 | 12 | declare module '*.jpeg' { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module '*.png' { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module '*.webp' { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module '*.svg' { 28 | const content: string; 29 | export default content; 30 | } 31 | 32 | // Font declarations 33 | declare module '*.woff'; 34 | declare module '*.woff2'; 35 | declare module '*.eot'; 36 | declare module '*.ttf'; 37 | declare module '*.otf'; 38 | -------------------------------------------------------------------------------- /.config/webpack/constants.ts: -------------------------------------------------------------------------------- 1 | export const SOURCE_DIR = 'src'; 2 | export const DIST_DIR = 'dist'; 3 | -------------------------------------------------------------------------------- /.config/webpack/publicPath.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * This file dynamically sets the public path at runtime based on the location of the plugin's AMD module. 5 | * It relies on the magic `module` which is defined by the AMD loader. 6 | * https://github.com/requirejs/requirejs/wiki/Differences-between-the-simplified-CommonJS-wrapper-and-standard-AMD-define#module 7 | * 8 | * We fallback to the plugin root so that older versions of Grafana will continue to load the plugin correctly. 9 | */ 10 | 11 | // @ts-nocheck 12 | import amdMetaModule from 'amd-module'; 13 | 14 | __webpack_public_path__ = 15 | amdMetaModule && amdMetaModule.uri 16 | ? amdMetaModule.uri.slice(0, amdMetaModule.uri.lastIndexOf('/') + 1) 17 | : 'public/plugins/fetzerch-sunandmoon-datasource/'; 18 | -------------------------------------------------------------------------------- /.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/create-a-plugin/extend-a-plugin/extend-configurations#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 LiveReloadPlugin from 'webpack-livereload-plugin'; 12 | import path from 'path'; 13 | import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin'; 14 | import TerserPlugin from 'terser-webpack-plugin'; 15 | import { type Configuration, BannerPlugin } from 'webpack'; 16 | 17 | import { getPackageJson, getPluginJson, hasReadme, getEntries, isWSL, getCPConfigVersion } from './utils'; 18 | import { SOURCE_DIR, DIST_DIR } from './constants'; 19 | 20 | const pluginJson = getPluginJson(); 21 | const cpVersion = getCPConfigVersion(); 22 | 23 | const config = async (env): Promise => { 24 | const baseConfig: Configuration = { 25 | cache: { 26 | type: 'filesystem', 27 | buildDependencies: { 28 | config: [__filename], 29 | }, 30 | }, 31 | 32 | context: path.join(process.cwd(), SOURCE_DIR), 33 | 34 | devtool: env.production ? 'source-map' : 'eval-source-map', 35 | 36 | entry: await getEntries(), 37 | 38 | externals: [ 39 | // Required for dynamic publicPath resolution 40 | { 'amd-module': 'module' }, 41 | 'lodash', 42 | 'jquery', 43 | 'moment', 44 | 'slate', 45 | 'emotion', 46 | '@emotion/react', 47 | '@emotion/css', 48 | 'prismjs', 49 | 'slate-plain-serializer', 50 | '@grafana/slate-react', 51 | 'react', 52 | 'react-dom', 53 | 'react-redux', 54 | 'redux', 55 | 'rxjs', 56 | 'react-router', 57 | 'react-router-dom', 58 | 'd3', 59 | 'angular', 60 | '@grafana/ui', 61 | '@grafana/runtime', 62 | '@grafana/data', 63 | 64 | // Mark legacy SDK imports as external if their name starts with the "grafana/" prefix 65 | ({ request }, callback) => { 66 | const prefix = 'grafana/'; 67 | const hasPrefix = (request) => request.indexOf(prefix) === 0; 68 | const stripPrefix = (request) => request.substr(prefix.length); 69 | 70 | if (hasPrefix(request)) { 71 | return callback(undefined, stripPrefix(request)); 72 | } 73 | 74 | callback(); 75 | }, 76 | ], 77 | 78 | // Support WebAssembly according to latest spec - makes WebAssembly module async 79 | experiments: { 80 | asyncWebAssembly: true, 81 | }, 82 | 83 | mode: env.production ? 'production' : 'development', 84 | 85 | module: { 86 | rules: [ 87 | { 88 | exclude: /(node_modules)/, 89 | test: /\.[tj]sx?$/, 90 | use: { 91 | loader: 'swc-loader', 92 | options: { 93 | jsc: { 94 | baseUrl: path.resolve(process.cwd(), SOURCE_DIR), 95 | target: 'es2015', 96 | loose: false, 97 | parser: { 98 | syntax: 'typescript', 99 | tsx: true, 100 | decorators: false, 101 | dynamicImport: true, 102 | }, 103 | }, 104 | }, 105 | }, 106 | }, 107 | { 108 | test: /src\/(?:.*\/)?module\.tsx?$/, 109 | use: [ 110 | { 111 | loader: 'imports-loader', 112 | options: { 113 | imports: `side-effects ${path.join(__dirname, 'publicPath.ts')}`, 114 | }, 115 | }, 116 | ], 117 | }, 118 | { 119 | test: /\.css$/, 120 | use: ['style-loader', 'css-loader'], 121 | }, 122 | { 123 | test: /\.s[ac]ss$/, 124 | use: ['style-loader', 'css-loader', 'sass-loader'], 125 | }, 126 | { 127 | test: /\.(png|jpe?g|gif|svg)$/, 128 | type: 'asset/resource', 129 | generator: { 130 | filename: Boolean(env.production) ? '[hash][ext]' : '[file]', 131 | }, 132 | }, 133 | { 134 | test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, 135 | type: 'asset/resource', 136 | generator: { 137 | filename: Boolean(env.production) ? '[hash][ext]' : '[file]', 138 | }, 139 | }, 140 | ], 141 | }, 142 | 143 | optimization: { 144 | minimize: Boolean(env.production), 145 | minimizer: [ 146 | new TerserPlugin({ 147 | terserOptions: { 148 | format: { 149 | comments: (_, { type, value }) => type === 'comment2' && value.trim().startsWith('[create-plugin]'), 150 | }, 151 | }, 152 | }), 153 | ], 154 | }, 155 | 156 | output: { 157 | clean: { 158 | keep: new RegExp(`(.*?_(amd64|arm(64)?)(.exe)?|go_plugin_build_manifest)`), 159 | }, 160 | filename: '[name].js', 161 | library: { 162 | type: 'amd', 163 | }, 164 | path: path.resolve(process.cwd(), DIST_DIR), 165 | publicPath: `public/plugins/${pluginJson.id}/`, 166 | uniqueName: pluginJson.id, 167 | }, 168 | 169 | plugins: [ 170 | // Insert create plugin version information into the bundle 171 | new BannerPlugin({ 172 | banner: '/* [create-plugin] version: ' + cpVersion + ' */', 173 | raw: true, 174 | entryOnly: true, 175 | }), 176 | new CopyWebpackPlugin({ 177 | patterns: [ 178 | // If src/README.md exists use it; otherwise the root README 179 | // To `compiler.options.output` 180 | { from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true }, 181 | { from: 'plugin.json', to: '.' }, 182 | { from: '../LICENSE', to: '.' }, 183 | { from: '../CHANGELOG.md', to: '.', force: true }, 184 | { from: '**/*.json', to: '.' }, // TODO 185 | { from: '**/*.svg', to: '.', noErrorOnMissing: true }, // Optional 186 | { from: '**/*.png', to: '.', noErrorOnMissing: true }, // Optional 187 | { from: '**/*.html', to: '.', noErrorOnMissing: true }, // Optional 188 | { from: 'img/**/*', to: '.', noErrorOnMissing: true }, // Optional 189 | { from: 'libs/**/*', to: '.', noErrorOnMissing: true }, // Optional 190 | { from: 'static/**/*', to: '.', noErrorOnMissing: true }, // Optional 191 | { from: '**/query_help.md', to: '.', noErrorOnMissing: true }, // Optional 192 | ], 193 | }), 194 | // Replace certain template-variables in the README and plugin.json 195 | new ReplaceInFileWebpackPlugin([ 196 | { 197 | dir: DIST_DIR, 198 | files: ['plugin.json', 'README.md'], 199 | rules: [ 200 | { 201 | search: /\%VERSION\%/g, 202 | replace: getPackageJson().version, 203 | }, 204 | { 205 | search: /\%TODAY\%/g, 206 | replace: new Date().toISOString().substring(0, 10), 207 | }, 208 | { 209 | search: /\%PLUGIN_ID\%/g, 210 | replace: pluginJson.id, 211 | }, 212 | ], 213 | }, 214 | ]), 215 | ...(env.development 216 | ? [ 217 | new LiveReloadPlugin(), 218 | new ForkTsCheckerWebpackPlugin({ 219 | async: Boolean(env.development), 220 | issue: { 221 | include: [{ file: '**/*.{ts,tsx}' }], 222 | }, 223 | typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') }, 224 | }), 225 | new ESLintPlugin({ 226 | extensions: ['.ts', '.tsx'], 227 | lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files 228 | }), 229 | ] 230 | : []), 231 | ], 232 | 233 | resolve: { 234 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 235 | // handle resolving "rootDir" paths 236 | modules: [path.resolve(process.cwd(), 'src'), 'node_modules'], 237 | unsafeCache: true, 238 | }, 239 | }; 240 | 241 | if (isWSL()) { 242 | baseConfig.watchOptions = { 243 | poll: 3000, 244 | ignored: /node_modules/, 245 | }; 246 | } 247 | 248 | return baseConfig; 249 | }; 250 | 251 | export default config; 252 | -------------------------------------------------------------------------------- /.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": { 3 | "bundleGrafanaUI": false, 4 | "useReactRouterV6": false 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm" 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | 11 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 12 | // "forwardPorts": [], 13 | 14 | // Use 'postCreateCommand' to run commands after the container is created. 15 | // "postCreateCommand": "yarn install", 16 | 17 | // Configure tool-specific properties. 18 | // "customizations": {}, 19 | 20 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 21 | // "remoteUser": "root" 22 | } 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/.eslintrc" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | branches: 10 | - master 11 | - main 12 | 13 | permissions: read-all 14 | 15 | jobs: 16 | build: 17 | name: Build, lint and unit tests 18 | runs-on: ubuntu-latest 19 | outputs: 20 | plugin-id: ${{ steps.metadata.outputs.plugin-id }} 21 | plugin-version: ${{ steps.metadata.outputs.plugin-version }} 22 | has-e2e: ${{ steps.check-for-e2e.outputs.has-e2e }} 23 | has-backend: ${{ steps.check-for-backend.outputs.has-backend }} 24 | env: 25 | GRAFANA_ACCESS_POLICY_TOKEN: ${{ secrets.GRAFANA_ACCESS_POLICY_TOKEN }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Setup Node.js environment 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: '20' 32 | cache: 'npm' 33 | 34 | - name: Install dependencies 35 | run: npm ci 36 | 37 | - name: Check types 38 | run: npm run typecheck 39 | - name: Lint 40 | run: npm run lint 41 | - name: Unit tests 42 | run: npm run test:ci 43 | - name: Build frontend 44 | run: npm run build 45 | - name: Run markdownlint 46 | run: npm run markdownlint 47 | 48 | - name: Check for backend 49 | id: check-for-backend 50 | run: | 51 | if [ -f "Magefile.go" ] 52 | then 53 | echo "has-backend=true" >> $GITHUB_OUTPUT 54 | fi 55 | 56 | - name: Setup Go environment 57 | if: steps.check-for-backend.outputs.has-backend == 'true' 58 | uses: actions/setup-go@v5 59 | with: 60 | go-version: '1.21' 61 | 62 | - name: Test backend 63 | if: steps.check-for-backend.outputs.has-backend == 'true' 64 | uses: magefile/mage-action@v3 65 | with: 66 | version: latest 67 | args: coverage 68 | 69 | - name: Build backend 70 | if: steps.check-for-backend.outputs.has-backend == 'true' 71 | uses: magefile/mage-action@v3 72 | with: 73 | version: latest 74 | args: buildAll 75 | 76 | - name: Check for E2E 77 | id: check-for-e2e 78 | run: | 79 | if [ -f "playwright.config.ts" ] 80 | then 81 | echo "has-e2e=true" >> $GITHUB_OUTPUT 82 | fi 83 | 84 | - name: Sign plugin 85 | run: npm run sign 86 | if: ${{ env.GRAFANA_ACCESS_POLICY_TOKEN != '' }} 87 | 88 | - name: Get plugin metadata 89 | id: metadata 90 | run: | 91 | sudo apt-get install jq 92 | 93 | export GRAFANA_PLUGIN_ID=$(cat dist/plugin.json | jq -r .id) 94 | export GRAFANA_PLUGIN_VERSION=$(cat dist/plugin.json | jq -r .info.version) 95 | export GRAFANA_PLUGIN_ARTIFACT=${GRAFANA_PLUGIN_ID}-${GRAFANA_PLUGIN_VERSION}.zip 96 | 97 | echo "plugin-id=${GRAFANA_PLUGIN_ID}" >> $GITHUB_OUTPUT 98 | echo "plugin-version=${GRAFANA_PLUGIN_VERSION}" >> $GITHUB_OUTPUT 99 | echo "archive=${GRAFANA_PLUGIN_ARTIFACT}" >> $GITHUB_OUTPUT 100 | 101 | - name: Package plugin 102 | id: package-plugin 103 | run: | 104 | mv dist ${{ steps.metadata.outputs.plugin-id }} 105 | zip ${{ steps.metadata.outputs.archive }} ${{ steps.metadata.outputs.plugin-id }} -r 106 | 107 | - name: Archive Build 108 | uses: actions/upload-artifact@v4 109 | with: 110 | name: ${{ steps.metadata.outputs.plugin-id }}-${{ steps.metadata.outputs.plugin-version }} 111 | path: ${{ steps.metadata.outputs.plugin-id }} 112 | retention-days: 5 113 | 114 | - name: Coveralls 115 | uses: coverallsapp/github-action@master 116 | with: 117 | github-token: ${{ secrets.GITHUB_TOKEN }} 118 | 119 | resolve-versions: 120 | name: Resolve e2e images 121 | runs-on: ubuntu-latest 122 | timeout-minutes: 3 123 | needs: build 124 | if: ${{ needs.build.outputs.has-e2e == 'true' }} 125 | outputs: 126 | matrix: ${{ steps.resolve-versions.outputs.matrix }} 127 | steps: 128 | - name: Checkout 129 | uses: actions/checkout@v4 130 | - name: Resolve Grafana E2E versions 131 | id: resolve-versions 132 | uses: grafana/plugin-actions/e2e-version@main 133 | 134 | playwright-tests: 135 | needs: [resolve-versions, build] 136 | timeout-minutes: 15 137 | strategy: 138 | fail-fast: false 139 | matrix: 140 | GRAFANA_IMAGE: ${{fromJson(needs.resolve-versions.outputs.matrix)}} 141 | name: e2e test ${{ matrix.GRAFANA_IMAGE.name }}@${{ matrix.GRAFANA_IMAGE.VERSION }} 142 | runs-on: ubuntu-latest 143 | steps: 144 | - uses: actions/checkout@v4 145 | 146 | - name: Download plugin 147 | if: needs.build.outputs.has-backend == 'true' 148 | uses: actions/download-artifact@v4 149 | with: 150 | path: dist 151 | name: ${{ needs.build.outputs.plugin-id }}-${{ needs.build.outputs.plugin-version }} 152 | 153 | - name: Execute permissions on binary 154 | if: needs.build.outputs.has-backend == 'true' 155 | run: | 156 | chmod +x ./dist/gpx_cicd_linux_amd64 157 | 158 | - name: Setup Node.js environment 159 | uses: actions/setup-node@v4 160 | with: 161 | node-version: '20' 162 | cache: 'npm' 163 | 164 | - name: Install dev dependencies 165 | run: npm ci 166 | 167 | - name: Start Grafana 168 | run: | 169 | docker-compose pull 170 | DEVELOPMENT=false GRAFANA_VERSION=${{ matrix.GRAFANA_IMAGE.VERSION }} GRAFANA_IMAGE=${{ matrix.GRAFANA_IMAGE.NAME }} docker-compose up -d 171 | 172 | - name: Wait for Grafana to start 173 | uses: nev7n/wait_for_response@v1 174 | with: 175 | url: 'http://localhost:3000/' 176 | responseCode: 200 177 | timeout: 60000 178 | interval: 500 179 | 180 | - name: Install Playwright Browsers 181 | run: npm exec playwright install chromium --with-deps 182 | 183 | - name: Run Playwright tests 184 | id: run-tests 185 | run: npm run e2e 186 | 187 | - name: Docker logs 188 | if: ${{ always() && steps.run-tests.outcome == 'failure' }} 189 | run: | 190 | docker logs fetzerch-sunandmoon-datasource >& grafana-server.log 191 | 192 | - name: Stop grafana docker 193 | run: docker-compose down 194 | 195 | - name: Upload server log 196 | uses: actions/upload-artifact@v4 197 | if: ${{ always() && steps.run-tests.outcome == 'failure' }} 198 | with: 199 | name: ${{ matrix.GRAFANA_IMAGE.NAME }}-v${{ matrix.GRAFANA_IMAGE.VERSION }}-${{github.run_id}}-server-log 200 | path: grafana-server.log 201 | retention-days: 5 202 | 203 | # Uncomment this step to upload the Playwright report to Github artifacts. 204 | # If your repository is public, the report will be public on the Internet so beware not to expose sensitive information. 205 | # - name: Upload artifacts 206 | # uses: actions/upload-artifact@v4 207 | # if: ${{ always() && steps.run-tests.outcome == 'failure' }} 208 | # with: 209 | # name: playwright-report-${{ matrix.GRAFANA_IMAGE.NAME }}-v${{ matrix.GRAFANA_IMAGE.VERSION }}-${{github.run_id}} 210 | # path: playwright-report/ 211 | # retention-days: 5 212 | -------------------------------------------------------------------------------- /.github/workflows/is-compatible.yml: -------------------------------------------------------------------------------- 1 | name: Latest Grafana API compatibility check 2 | on: [pull_request] 3 | 4 | jobs: 5 | compatibilitycheck: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - name: Setup Node.js environment 10 | uses: actions/setup-node@v4 11 | with: 12 | node-version: '20' 13 | cache: 'npm' 14 | - name: Install dependencies 15 | run: npm ci 16 | - name: Build plugin 17 | run: npm run build 18 | - name: Compatibility check 19 | run: npx @grafana/levitate@latest is-compatible --path src/module.ts --target @grafana/data,@grafana/ui,@grafana/runtime 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This GitHub Action automates the process of building Grafana plugins. 2 | # (For more information, see https://github.com/grafana/plugin-actions/blob/main/build-plugin/README.md) 3 | name: Release 4 | 5 | on: 6 | push: 7 | tags: 8 | - 'v*' # Run workflow on version tags, e.g. v1.0.0. 9 | 10 | permissions: read-all 11 | 12 | jobs: 13 | release: 14 | permissions: 15 | contents: write 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: grafana/plugin-actions/build-plugin@release 20 | with: 21 | policy_token: ${{ secrets.GRAFANA_ACCESS_POLICY_TOKEN }} 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | node_modules/ 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # Compiled binary addons (https://nodejs.org/api/addons.html) 23 | dist/ 24 | artifacts/ 25 | work/ 26 | ci/ 27 | e2e-results/ 28 | 29 | # Editor 30 | .idea 31 | 32 | # Cache 33 | .eslintcache 34 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD024": { 4 | "allow_different_nesting": true 5 | }, 6 | "MD041": false 7 | } 8 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require('./.config/.prettierrc.js'), 4 | }; 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.3.3] - 2024-07-09 11 | 12 | ### Fixed 13 | 14 | - Prepare for compatibility with Grafana 12.0. 15 | 16 | ## [0.3.2] - 2024-06-09 17 | 18 | ### Added 19 | 20 | - Provide maximum sun altitude value. 21 | 22 | ## [0.3.1] - 2024-06-08 23 | 24 | ### Fixed 25 | 26 | - Compatibility with Grafana 11.0. 27 | 28 | ## [0.3.0] - 2022-11-11 29 | 30 | ### Fixed 31 | 32 | - Noon and Midnight notifications did not show correct values for non default 33 | timezones. 34 | - The 'Min interval' query option was not taken into account. 35 | 36 | ### Added 37 | 38 | - Events / annotations can be queried as metrics. This allows to visualize for 39 | example today's sunrise/sunset time on dashboards. ([#20](https://github.com/fetzerch/grafana-sunandmoon-datasource/issues/20)) 40 | - Dashboard options such as units are automatically set. 41 | 42 | ### Changed 43 | 44 | - The annotations were migrated to the new react based plugin framework 45 | introduced in Grafana 7.2 and the plugin is no longer compatible with older 46 | versions of Grafana. 47 | - A single query now supports multiple metrics. 48 | 49 | ## [0.2.1] - 2020-11-27 50 | 51 | ### Added 52 | 53 | - Allow per query override of location. ([#35](https://github.com/fetzerch/grafana-sunandmoon-datasource/issues/35), 54 | [#5](https://github.com/fetzerch/grafana-sunandmoon-datasource/issues/5)) 55 | 56 | ## [0.2.0] - 2020-11-20 57 | 58 | ### Fixed 59 | 60 | - Glitches in graphs caused by out of order data. ([#29](https://github.com/fetzerch/grafana-sunandmoon-datasource/issues/29), 61 | [grafana/#28804](https://github.com/grafana/grafana/issues/28804)) 62 | - Annotations are no longer shown. ([#31](https://github.com/fetzerch/grafana-sunandmoon-datasource/issues/31)) 63 | - A metric is now preselected when using the datasource on a new panel. 64 | 65 | ### Added 66 | 67 | - Location is now automatically configured. 68 | 69 | ### Changed 70 | 71 | - The plugin was migrated to the new plugin framework introduced in Grafana 72 | 7.0 and is no longer compatible with older versions of Grafana. 73 | 74 | ## [0.1.6] - 2020-05-24 75 | 76 | ### Fixed 77 | 78 | - Compatibility with Grafana 7.0. 79 | 80 | ## [0.1.5] - 2019-08-01 81 | 82 | ### Fixed 83 | 84 | - Annotations were incorrectly tagged with a single tag per character 85 | (e.g. `s`, `u`, `n` instead of `sun`). ([grafana/#15987](https://github.com/grafana/grafana/pull/15987)) 86 | - Compatibility with Grafana v3. 87 | - Compatibility with Grafana v6.3. 88 | 89 | ## [0.1.4] - 2019-03-08 90 | 91 | ### Added 92 | 93 | - Support [datasource provisioning](https://docs.grafana.org/administration/provisioning/#datasources). 94 | ([#11](https://github.com/fetzerch/grafana-sunandmoon-datasource/issues/11)) 95 | - Annotations for noon and midnight. ([#3](https://github.com/fetzerch/grafana-sunandmoon-datasource/issues/3)) 96 | 97 | ## [0.1.3] - 2019-02-24 98 | 99 | ### Fixed 100 | 101 | - Update [SunCalc](https://github.com/mourner/suncalc#changelog) dependency to 102 | 1.8.0 with improved precision for moonrise/moonset calculations. ([#8](https://github.com/fetzerch/grafana-sunandmoon-datasource/issues/8)) 103 | - Update build dependencies to the latest versions. ([#12](https://github.com/fetzerch/grafana-sunandmoon-datasource/pull/12)) 104 | 105 | ## [0.1.2] - 2016-12-12 106 | 107 | ### Fixed 108 | 109 | - Compatibility with Grafana 4.0. ([#6](https://github.com/fetzerch/grafana-sunandmoon-datasource/issues/6)) 110 | 111 | ## [0.1.1] - 2016-07-01 112 | 113 | ### Fixed 114 | 115 | - Only the first of multiple configured annotations was displayed. ([#2](https://github.com/fetzerch/grafana-sunandmoon-datasource/pull/2)) 116 | 117 | ## [0.1.0] - 2016-06-27 118 | 119 | ### Added 120 | 121 | - Initial version with sun and moon calculation and annotations. 122 | 123 | [Unreleased]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.3.3...HEAD 124 | [0.3.3]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.3.1...v0.3.3 125 | [0.3.2]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.3.1...v0.3.2 126 | [0.3.1]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.3.0...v0.3.1 127 | [0.3.0]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.2.1...v0.3.0 128 | [0.2.1]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.2.0...v0.2.1 129 | [0.2.0]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.1.6...v0.2.0 130 | [0.1.6]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.1.5...v0.1.6 131 | [0.1.5]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.1.4...v0.1.5 132 | [0.1.4]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.1.3...v0.1.4 133 | [0.1.3]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.1.2...v0.1.3 134 | [0.1.2]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.1.1...v0.1.2 135 | [0.1.1]: https://github.com/fetzerch/grafana-sunandmoon-datasource/compare/v0.1.0...v0.1.1 136 | [0.1.0]: https://github.com/fetzerch/grafana-sunandmoon-datasource/commits/v0.1.0 137 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Docker based Grafana plugin development environment 2 | 3 | This directory contains a [Docker Compose](https://docs.docker.com/compose/) 4 | configuration that sets up Grafana in a Docker container, installs and 5 | configures the plugin from source and provides a sample Dashboard for testing. 6 | 7 | ## Usage guide 8 | 9 | In order to make live changes to the plugin available to Grafana, the sources 10 | have to be translated with Babel whenever they change: 11 | 12 | ```console 13 | npm install 14 | npm run watch 15 | ``` 16 | 17 | Grafana needs to be restarted whenever the `plugin.json` is modified. 18 | 19 | ### Start Grafana 20 | 21 | This command starts Grafana in the background: 22 | 23 | ```console 24 | sudo UID=$(id -u) GID=$(id -g) docker compose up -d 25 | ``` 26 | 27 | ### Stop Grafana 28 | 29 | This command stops Grafana, but keeps the container its data: 30 | 31 | ```console 32 | sudo UID=$(id -u) GID=$(id -g) docker compose stop 33 | ``` 34 | 35 | ### Cleanup the container 36 | 37 | This command stops Grafana, removes the container and its data: 38 | 39 | ```console 40 | sudo git clean -xffd data 41 | sudo UID=$(id -u) GID=$(id -g) docker compose rm -sf 42 | ``` 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Christian Fetzer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/fetzerch/grafana-sunandmoon-datasource/actions/workflows/ci.yml/badge.svg)](https://github.com/fetzerch/grafana-sunandmoon-datasource/actions/workflows/ci.yml) 2 | [![Coverage Status](https://coveralls.io/repos/github/fetzerch/grafana-sunandmoon-datasource/badge.svg)](https://coveralls.io/github/fetzerch/grafana-sunandmoon-datasource) 3 | 4 | # Sun and Moon Datasource Plugin for Grafana 5 | 6 | SunAndMoon is a Datasource Plugin for [Grafana](https://grafana.org) that 7 | calculates the position of Sun and Moon as well as the Moon illumination using 8 | [SunCalc](https://github.com/mourner/suncalc). 9 | 10 | Additionally it provides annotations for sunrise, sunset, twilight, 11 | moonrise, moonset and others. 12 | 13 | ![SunAndMoon](https://raw.githubusercontent.com/fetzerch/grafana-sunandmoon-datasource/master/src/img/screenshot.png) 14 | 15 | ## Configuration 16 | 17 | All values are location dependent, the reference location needs to be 18 | configured in the datasource settings. 19 | 20 | ![SunAndMoon Configuration](https://raw.githubusercontent.com/fetzerch/grafana-sunandmoon-datasource/master/src/img/configuration.png) 21 | 22 | ## License 23 | 24 | This projected is licensed under the terms of the MIT license. 25 | -------------------------------------------------------------------------------- /data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fetzerch/grafana-sunandmoon-datasource/73fd31f60e6aeb9afda7e467f5cc376b507dbb9c/data/.keep -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | user: "${UID:-root}:${GID:-root}" 4 | container_name: 'fetzerch-sunandmoon-datasource' 5 | 6 | platform: 'linux/amd64' 7 | build: 8 | context: ./.config 9 | args: 10 | grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} 11 | grafana_version: ${GRAFANA_VERSION:-11.0.0} 12 | development: ${DEVELOPMENT:-false} 13 | ports: 14 | - 3000:3000/tcp 15 | volumes: 16 | - ./dist:/var/lib/grafana/plugins/fetzerch-sunandmoon-datasource 17 | - ./provisioning:/etc/grafana/provisioning 18 | - .:/root/fetzerch-sunandmoon-datasource 19 | # Persist Grafana settings. 20 | #- ./data:/var/lib/grafana 21 | 22 | environment: 23 | NODE_ENV: development 24 | GF_LOG_FILTERS: plugin.fetzerch-sunandmoon-datasource:debug 25 | GF_LOG_LEVEL: debug 26 | GF_DATAPROXY_LOGGING: 1 27 | GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: fetzerch-sunandmoon-datasource 28 | -------------------------------------------------------------------------------- /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": "sunandmoon-datasource", 3 | "private": true, 4 | "version": "0.3.3", 5 | "description": "Calculates position of Sun and Moon.", 6 | "scripts": { 7 | "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 8 | "dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 9 | "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", 10 | "lint:fix": "npm run lint -- --fix", 11 | "markdownlint": "markdownlint *.md dev-env/*.md", 12 | "server": "docker compose up --build", 13 | "sign": "npx --yes @grafana/sign-plugin@latest", 14 | "start": "yarn watch", 15 | "test": "jest --watch --onlyChanged", 16 | "test:ci": "jest --passWithNoTests --maxWorkers 4 --coverage", 17 | "typecheck": "tsc --noEmit" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/fetzerch/grafana-sunandmoon-datasource.git" 22 | }, 23 | "author": { 24 | "name": "Christian Fetzer", 25 | "email": "fetzer.ch@gmail.com", 26 | "url": "https://fetzerch.github.io" 27 | }, 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/fetzerch/grafana-sunandmoon-datasource/issues" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.21.4", 34 | "@grafana/eslint-config": "^7.0.0", 35 | "@grafana/tsconfig": "^1.2.0-rc1", 36 | "@swc/core": "^1.3.90", 37 | "@swc/helpers": "^0.5.0", 38 | "@swc/jest": "^0.2.26", 39 | "@testing-library/jest-dom": "6.1.4", 40 | "@testing-library/react": "14.0.0", 41 | "@types/jest": "^29.5.0", 42 | "@types/lodash": "^4.14.194", 43 | "@types/node": "^20.8.7", 44 | "@types/react-router-dom": "^5.2.0", 45 | "@types/suncalc": "^1.8.1", 46 | "@types/testing-library__jest-dom": "5.14.8", 47 | "@typescript-eslint/eslint-plugin": "^7.12.0", 48 | "copy-webpack-plugin": "^11.0.0", 49 | "css-loader": "^6.7.3", 50 | "eslint-plugin-deprecation": "^2.0.0", 51 | "eslint-plugin-jsdoc": "^48.2.9", 52 | "eslint-plugin-react": "^7.34.2", 53 | "eslint-webpack-plugin": "^4.0.1", 54 | "fork-ts-checker-webpack-plugin": "^8.0.0", 55 | "glob": "^10.2.7", 56 | "identity-obj-proxy": "3.0.0", 57 | "jest": "^29.5.0", 58 | "jest-environment-jsdom": "^29.5.0", 59 | "markdownlint": "^0.26.2", 60 | "markdownlint-cli": "^0.32.2", 61 | "prettier": "^2.8.7", 62 | "react-select-event": "^5.5.1", 63 | "replace-in-file-webpack-plugin": "^1.0.6", 64 | "sass": "1.63.2", 65 | "sass-loader": "13.3.1", 66 | "style-loader": "3.3.3", 67 | "swc-loader": "^0.2.3", 68 | "ts-node": "^10.9.1", 69 | "tsconfig-paths": "^4.2.0", 70 | "typescript": "4.8.4", 71 | "webpack": "^5.86.0", 72 | "webpack-cli": "^5.1.4", 73 | "webpack-livereload-plugin": "^3.0.2", 74 | "imports-loader": "^5.0.0" 75 | }, 76 | "dependencies": { 77 | "@emotion/css": "11.10.6", 78 | "@grafana/data": "^10.4.0", 79 | "@grafana/runtime": "^10.4.0", 80 | "@grafana/schema": "^10.4.0", 81 | "@grafana/ui": "^10.4.0", 82 | "react": "18.2.0", 83 | "react-dom": "18.2.0", 84 | "suncalc": "^1.9.0", 85 | "tslib": "2.5.3" 86 | }, 87 | "engines": { 88 | "node": ">=14" 89 | }, 90 | "homepage": "https://github.com/fetzerch/grafana-sunandmoon-datasource", 91 | "packageManager": "npm@10.7.0" 92 | } 93 | -------------------------------------------------------------------------------- /provisioning/dashboards/dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": "-- Grafana --", 7 | "enable": true, 8 | "hide": true, 9 | "iconColor": "rgba(0, 211, 255, 1)", 10 | "name": "Annotations & Alerts", 11 | "type": "dashboard" 12 | }, 13 | { 14 | "datasource": "sunandmoon", 15 | "enable": true, 16 | "iconColor": "rgb(238, 139, 9)", 17 | "mappings": {}, 18 | "name": "Sunrise / Sunset", 19 | "target": { 20 | "refId": "annotation_query", 21 | "target": [ 22 | "sunrise", 23 | "sunset" 24 | ] 25 | } 26 | }, 27 | { 28 | "datasource": "sunandmoon", 29 | "enable": false, 30 | "iconColor": "rgb(21, 101, 224)", 31 | "mappings": {}, 32 | "name": "Moonrise / Moonset", 33 | "target": { 34 | "refId": "annotation_query", 35 | "target": [ 36 | "moonrise", 37 | "moonset" 38 | ] 39 | } 40 | }, 41 | { 42 | "datasource": "sunandmoon", 43 | "enable": false, 44 | "iconColor": "rgb(98, 164, 0)", 45 | "mappings": {}, 46 | "name": "Golden Hour", 47 | "target": { 48 | "refId": "annotation_query", 49 | "target": [ 50 | "goldenHour" 51 | ] 52 | } 53 | }, 54 | { 55 | "datasource": "sunandmoon", 56 | "enable": true, 57 | "iconColor": "text", 58 | "mappings": {}, 59 | "name": "Noon / Midnight", 60 | "target": { 61 | "refId": "annotation_query", 62 | "target": [ 63 | "noon", 64 | "midnight" 65 | ] 66 | } 67 | } 68 | ] 69 | }, 70 | "editable": true, 71 | "gnetId": null, 72 | "graphTooltip": 0, 73 | "id": 1, 74 | "links": [], 75 | "panels": [ 76 | { 77 | "datasource": "sunandmoon", 78 | "fieldConfig": { 79 | "defaults": { 80 | "color": { 81 | "fixedColor": "super-light-orange", 82 | "mode": "fixed" 83 | }, 84 | "mappings": [], 85 | "thresholds": { 86 | "mode": "absolute", 87 | "steps": [ 88 | { 89 | "color": "green", 90 | "value": null 91 | } 92 | ] 93 | }, 94 | "unit": "time: HH:mm" 95 | }, 96 | "overrides": [] 97 | }, 98 | "gridPos": { 99 | "h": 3, 100 | "w": 3, 101 | "x": 0, 102 | "y": 0 103 | }, 104 | "id": 5, 105 | "options": { 106 | "colorMode": "value", 107 | "graphMode": "none", 108 | "justifyMode": "auto", 109 | "orientation": "auto", 110 | "reduceOptions": { 111 | "calcs": [ 112 | "lastNotNull" 113 | ], 114 | "fields": "/^Time$/", 115 | "values": false 116 | }, 117 | "text": {}, 118 | "textMode": "auto" 119 | }, 120 | "pluginVersion": "8.0.0", 121 | "targets": [ 122 | { 123 | "refId": "A", 124 | "target": [ 125 | "sunrise" 126 | ] 127 | } 128 | ], 129 | "title": "Sunrise", 130 | "transformations": [], 131 | "type": "stat" 132 | }, 133 | { 134 | "datasource": "sunandmoon", 135 | "fieldConfig": { 136 | "defaults": { 137 | "color": { 138 | "fixedColor": "light-yellow", 139 | "mode": "fixed" 140 | }, 141 | "mappings": [], 142 | "thresholds": { 143 | "mode": "absolute", 144 | "steps": [ 145 | { 146 | "color": "green", 147 | "value": null 148 | } 149 | ] 150 | }, 151 | "unit": "time: HH:mm" 152 | }, 153 | "overrides": [] 154 | }, 155 | "gridPos": { 156 | "h": 3, 157 | "w": 3, 158 | "x": 3, 159 | "y": 0 160 | }, 161 | "id": 7, 162 | "options": { 163 | "colorMode": "value", 164 | "graphMode": "none", 165 | "justifyMode": "auto", 166 | "orientation": "auto", 167 | "reduceOptions": { 168 | "calcs": [ 169 | "lastNotNull" 170 | ], 171 | "fields": "/^Time$/", 172 | "values": false 173 | }, 174 | "text": {}, 175 | "textMode": "auto" 176 | }, 177 | "pluginVersion": "8.0.0", 178 | "targets": [ 179 | { 180 | "refId": "A", 181 | "target": [ 182 | "solarNoon" 183 | ] 184 | } 185 | ], 186 | "title": "Solar noon", 187 | "transformations": [], 188 | "type": "stat" 189 | }, 190 | { 191 | "datasource": "-- Mixed --", 192 | "fieldConfig": { 193 | "defaults": { 194 | "color": { 195 | "fixedColor": "light-red", 196 | "mode": "fixed" 197 | }, 198 | "mappings": [], 199 | "thresholds": { 200 | "mode": "absolute", 201 | "steps": [ 202 | { 203 | "color": "green", 204 | "value": null 205 | } 206 | ] 207 | }, 208 | "unit": "time: HH:mm" 209 | }, 210 | "overrides": [] 211 | }, 212 | "gridPos": { 213 | "h": 3, 214 | "w": 3, 215 | "x": 6, 216 | "y": 0 217 | }, 218 | "id": 6, 219 | "options": { 220 | "colorMode": "value", 221 | "graphMode": "none", 222 | "justifyMode": "auto", 223 | "orientation": "auto", 224 | "reduceOptions": { 225 | "calcs": [ 226 | "lastNotNull" 227 | ], 228 | "fields": "/^Time$/", 229 | "values": false 230 | }, 231 | "text": {}, 232 | "textMode": "auto" 233 | }, 234 | "pluginVersion": "8.0.0", 235 | "targets": [ 236 | { 237 | "datasource": "sunandmoon", 238 | "refId": "A", 239 | "target": [ 240 | "sunset" 241 | ] 242 | } 243 | ], 244 | "title": "Sunset", 245 | "transformations": [], 246 | "type": "stat" 247 | }, 248 | { 249 | "datasource": "sunandmoon", 250 | "fieldConfig": { 251 | "defaults": { 252 | "color": { 253 | "fixedColor": "light-yellow", 254 | "mode": "fixed" 255 | }, 256 | "mappings": [], 257 | "thresholds": { 258 | "mode": "absolute", 259 | "steps": [ 260 | { 261 | "color": "green", 262 | "value": null 263 | } 264 | ] 265 | }, 266 | "unit": "short" 267 | }, 268 | "overrides": [] 269 | }, 270 | "gridPos": { 271 | "h": 3, 272 | "w": 3, 273 | "x": 9, 274 | "y": 0 275 | }, 276 | "id": 8, 277 | "options": { 278 | "colorMode": "value", 279 | "graphMode": "none", 280 | "justifyMode": "auto", 281 | "orientation": "auto", 282 | "reduceOptions": { 283 | "calcs": [ 284 | "lastNotNull" 285 | ], 286 | "fields": "", 287 | "values": false 288 | }, 289 | "text": {}, 290 | "textMode": "auto" 291 | }, 292 | "pluginVersion": "8.0.0", 293 | "targets": [ 294 | { 295 | "refId": "A", 296 | "target": [ 297 | "sun_azimuth" 298 | ] 299 | } 300 | ], 301 | "title": "Sun Azimuth", 302 | "transformations": [], 303 | "type": "stat" 304 | }, 305 | { 306 | "datasource": "sunandmoon", 307 | "description": "", 308 | "fieldConfig": { 309 | "defaults": { 310 | "color": { 311 | "fixedColor": "blue", 312 | "mode": "fixed" 313 | }, 314 | "mappings": [], 315 | "thresholds": { 316 | "mode": "percentage", 317 | "steps": [ 318 | { 319 | "color": "dark-blue", 320 | "value": null 321 | } 322 | ] 323 | } 324 | }, 325 | "overrides": [] 326 | }, 327 | "gridPos": { 328 | "h": 3, 329 | "w": 3, 330 | "x": 12, 331 | "y": 0 332 | }, 333 | "id": 3, 334 | "options": { 335 | "orientation": "auto", 336 | "reduceOptions": { 337 | "calcs": [ 338 | "lastNotNull" 339 | ], 340 | "fields": "", 341 | "values": false 342 | }, 343 | "showThresholdLabels": false, 344 | "showThresholdMarkers": false, 345 | "text": {} 346 | }, 347 | "pluginVersion": "8.0.0", 348 | "targets": [ 349 | { 350 | "refId": "A", 351 | "target": [ 352 | "moon_illumination" 353 | ] 354 | } 355 | ], 356 | "title": "Moon illumination", 357 | "type": "gauge" 358 | }, 359 | { 360 | "datasource": "sunandmoon", 361 | "fieldConfig": { 362 | "defaults": { 363 | "color": { 364 | "fixedColor": "light-blue", 365 | "mode": "fixed" 366 | }, 367 | "mappings": [], 368 | "thresholds": { 369 | "mode": "absolute", 370 | "steps": [ 371 | { 372 | "color": "green", 373 | "value": null 374 | } 375 | ] 376 | }, 377 | "unit": "time: HH:mm" 378 | }, 379 | "overrides": [] 380 | }, 381 | "gridPos": { 382 | "h": 3, 383 | "w": 3, 384 | "x": 15, 385 | "y": 0 386 | }, 387 | "id": 9, 388 | "options": { 389 | "colorMode": "value", 390 | "graphMode": "none", 391 | "justifyMode": "auto", 392 | "orientation": "auto", 393 | "reduceOptions": { 394 | "calcs": [ 395 | "lastNotNull" 396 | ], 397 | "fields": "/^Time$/", 398 | "values": false 399 | }, 400 | "text": {}, 401 | "textMode": "auto" 402 | }, 403 | "pluginVersion": "8.0.0", 404 | "targets": [ 405 | { 406 | "refId": "A", 407 | "target": [ 408 | "moonrise" 409 | ] 410 | } 411 | ], 412 | "title": "Moonrise", 413 | "transformations": [], 414 | "type": "stat" 415 | }, 416 | { 417 | "datasource": "sunandmoon", 418 | "fieldConfig": { 419 | "defaults": { 420 | "color": { 421 | "fixedColor": "semi-dark-blue", 422 | "mode": "fixed" 423 | }, 424 | "mappings": [], 425 | "thresholds": { 426 | "mode": "absolute", 427 | "steps": [ 428 | { 429 | "color": "green", 430 | "value": null 431 | } 432 | ] 433 | }, 434 | "unit": "time: HH:mm" 435 | }, 436 | "overrides": [] 437 | }, 438 | "gridPos": { 439 | "h": 3, 440 | "w": 3, 441 | "x": 18, 442 | "y": 0 443 | }, 444 | "id": 10, 445 | "options": { 446 | "colorMode": "value", 447 | "graphMode": "none", 448 | "justifyMode": "auto", 449 | "orientation": "auto", 450 | "reduceOptions": { 451 | "calcs": [ 452 | "lastNotNull" 453 | ], 454 | "fields": "/^Time$/", 455 | "values": false 456 | }, 457 | "text": {}, 458 | "textMode": "auto" 459 | }, 460 | "pluginVersion": "8.0.0", 461 | "targets": [ 462 | { 463 | "refId": "A", 464 | "target": [ 465 | "moonset" 466 | ] 467 | } 468 | ], 469 | "title": "Moonrise", 470 | "transformations": [], 471 | "type": "stat" 472 | }, 473 | { 474 | "datasource": "sunandmoon", 475 | "fieldConfig": { 476 | "defaults": { 477 | "color": { 478 | "fixedColor": "blue", 479 | "mode": "fixed" 480 | }, 481 | "mappings": [], 482 | "thresholds": { 483 | "mode": "absolute", 484 | "steps": [ 485 | { 486 | "color": "green", 487 | "value": null 488 | } 489 | ] 490 | }, 491 | "unit": "short" 492 | }, 493 | "overrides": [] 494 | }, 495 | "gridPos": { 496 | "h": 3, 497 | "w": 3, 498 | "x": 21, 499 | "y": 0 500 | }, 501 | "id": 11, 502 | "options": { 503 | "colorMode": "value", 504 | "graphMode": "none", 505 | "justifyMode": "auto", 506 | "orientation": "auto", 507 | "reduceOptions": { 508 | "calcs": [ 509 | "lastNotNull" 510 | ], 511 | "fields": "", 512 | "values": false 513 | }, 514 | "text": {}, 515 | "textMode": "auto" 516 | }, 517 | "pluginVersion": "8.0.0", 518 | "targets": [ 519 | { 520 | "refId": "A", 521 | "target": [ 522 | "moon_azimuth" 523 | ] 524 | } 525 | ], 526 | "title": "Moon Azimuth", 527 | "transformations": [], 528 | "type": "stat" 529 | }, 530 | { 531 | "datasource": "sunandmoon", 532 | "fieldConfig": { 533 | "defaults": { 534 | "color": { 535 | "mode": "palette-classic" 536 | }, 537 | "custom": { 538 | "axisLabel": "", 539 | "axisPlacement": "auto", 540 | "barAlignment": 0, 541 | "drawStyle": "line", 542 | "fillOpacity": 0, 543 | "gradientMode": "none", 544 | "hideFrom": { 545 | "legend": false, 546 | "tooltip": false, 547 | "viz": false 548 | }, 549 | "lineInterpolation": "linear", 550 | "lineStyle": { 551 | "fill": "solid" 552 | }, 553 | "lineWidth": 1, 554 | "pointSize": 5, 555 | "scaleDistribution": { 556 | "type": "linear" 557 | }, 558 | "showPoints": "never", 559 | "spanNulls": true, 560 | "stacking": { 561 | "group": "A", 562 | "mode": "none" 563 | }, 564 | "thresholdsStyle": { 565 | "mode": "off" 566 | } 567 | }, 568 | "mappings": [], 569 | "thresholds": { 570 | "mode": "absolute", 571 | "steps": [ 572 | { 573 | "color": "green", 574 | "value": null 575 | } 576 | ] 577 | }, 578 | "unit": "degree" 579 | }, 580 | "overrides": [ 581 | { 582 | "matcher": { 583 | "id": "byName", 584 | "options": "Moon altitude" 585 | }, 586 | "properties": [ 587 | { 588 | "id": "color", 589 | "value": { 590 | "fixedColor": "#447EBC", 591 | "mode": "fixed", 592 | "seriesBy": "min" 593 | } 594 | } 595 | ] 596 | }, 597 | { 598 | "matcher": { 599 | "id": "byName", 600 | "options": "Sun altitude" 601 | }, 602 | "properties": [ 603 | { 604 | "id": "color", 605 | "value": { 606 | "fixedColor": "#CCA300", 607 | "mode": "fixed" 608 | } 609 | } 610 | ] 611 | }, 612 | { 613 | "matcher": { 614 | "id": "byName", 615 | "options": "Moon illumination" 616 | }, 617 | "properties": [ 618 | { 619 | "id": "color", 620 | "value": { 621 | "fixedColor": "light-blue", 622 | "mode": "fixed" 623 | } 624 | } 625 | ] 626 | } 627 | ] 628 | }, 629 | "gridPos": { 630 | "h": 7, 631 | "w": 24, 632 | "x": 0, 633 | "y": 3 634 | }, 635 | "id": 1, 636 | "links": [], 637 | "options": { 638 | "legend": { 639 | "calcs": [], 640 | "displayMode": "list", 641 | "placement": "bottom", 642 | "showLegend": true 643 | }, 644 | "tooltip": { 645 | "mode": "multi", 646 | "sort": "none" 647 | } 648 | }, 649 | "pluginVersion": "9.2.3", 650 | "targets": [ 651 | { 652 | "refId": "A", 653 | "target": [ 654 | "sun_altitude" 655 | ] 656 | }, 657 | { 658 | "refId": "B", 659 | "target": [ 660 | "moon_altitude" 661 | ] 662 | }, 663 | { 664 | "refId": "C", 665 | "target": [ 666 | "moon_illumination" 667 | ] 668 | } 669 | ], 670 | "title": "Sun & Moon Position", 671 | "type": "timeseries" 672 | } 673 | ], 674 | "schemaVersion": 30, 675 | "style": "dark", 676 | "tags": [], 677 | "templating": { 678 | "list": [] 679 | }, 680 | "time": { 681 | "from": "now-7d", 682 | "to": "now" 683 | }, 684 | "timepicker": { 685 | "refresh_intervals": [ 686 | "5s", 687 | "10s", 688 | "30s", 689 | "1m", 690 | "5m", 691 | "15m", 692 | "30m", 693 | "1h", 694 | "2h", 695 | "1d" 696 | ], 697 | "time_options": [ 698 | "5m", 699 | "15m", 700 | "1h", 701 | "6h", 702 | "12h", 703 | "24h", 704 | "2d", 705 | "7d", 706 | "30d" 707 | ] 708 | }, 709 | "timezone": "browser", 710 | "title": "Sun and Moon Datasource", 711 | "uid": "000000009", 712 | "version": 3 713 | } 714 | -------------------------------------------------------------------------------- /provisioning/dashboards/default.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: Default 5 | type: file 6 | options: 7 | path: /etc/grafana/provisioning/dashboards 8 | -------------------------------------------------------------------------------- /provisioning/datasources/default.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: sunandmoon 5 | type: fetzerch-sunandmoon-datasource 6 | jsonData: 7 | latitude: 48.0 8 | longitude: 10.0 9 | editable: true 10 | -------------------------------------------------------------------------------- /src/ConfigEditor.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { fireEvent, render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom' 4 | import { ConfigEditor } from './ConfigEditor'; 5 | 6 | describe('ConfigEditor', () => { 7 | let props: any; 8 | 9 | beforeEach(() => { 10 | props = { 11 | options: { 12 | jsonData: { 13 | latitude: 48, 14 | longitude: 10, 15 | }, 16 | }, 17 | onOptionsChange: {}, 18 | }; 19 | }); 20 | 21 | it('should render without throwing an error', () => { 22 | expect(() => render()).not.toThrow(); 23 | }); 24 | 25 | it('should initially set latitude and longitude from geolocation', () => { 26 | const onOptionsChange = jest.fn(); 27 | const options: any = { 28 | jsonData: {} 29 | }; 30 | render(); 31 | expect(onOptionsChange).not.toHaveBeenCalled(); 32 | 33 | const mockGeolocation = { 34 | getCurrentPosition: jest.fn().mockImplementationOnce((success) => 35 | Promise.resolve( 36 | success({ 37 | coords: { 38 | latitude: 40, 39 | longitude: 5, 40 | }, 41 | }) 42 | ) 43 | ), 44 | clearWatch: jest.fn(), 45 | watchPosition: jest.fn(), 46 | }; 47 | Object.defineProperty(global.navigator, 'geolocation', { value: mockGeolocation }); 48 | render(); 49 | expect(onOptionsChange).toHaveBeenLastCalledWith({ jsonData: { latitude: 40, longitude: 5 } }); 50 | }); 51 | 52 | it('should update latitude and longitude on change', () => { 53 | const onOptionsChange = jest.fn(); 54 | render(); 55 | const latitudeField = screen.getByLabelText('Latitude'); 56 | const longitudeField = screen.getByLabelText('Longitude'); 57 | 58 | fireEvent.change(latitudeField, { target: { value: 50 } }); 59 | expect(onOptionsChange).toHaveBeenLastCalledWith({ jsonData: { latitude: 50, longitude: 10 } }); 60 | fireEvent.change(longitudeField, { target: { value: 20 } }); 61 | expect(onOptionsChange).toHaveBeenLastCalledWith({ jsonData: { latitude: 48, longitude: 20 } }); 62 | fireEvent.change(latitudeField, { target: { value: '' } }); 63 | expect(onOptionsChange).toHaveBeenLastCalledWith({ jsonData: { latitude: undefined, longitude: 10 } }); 64 | fireEvent.change(longitudeField, { target: { value: '' } }); 65 | expect(onOptionsChange).toHaveBeenLastCalledWith({ jsonData: { latitude: 48, longitude: undefined } }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/ConfigEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, PureComponent } from 'react'; 2 | import { Alert, InlineField, Input } from '@grafana/ui'; 3 | import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; 4 | import { SunAndMoonDataSourceOptions } from './types'; 5 | 6 | export interface Props extends DataSourcePluginOptionsEditorProps { } 7 | 8 | export class ConfigEditor extends PureComponent { 9 | constructor(props: Props) { 10 | super(props); 11 | 12 | this.fillPositionFromGeoLocation(); 13 | } 14 | 15 | fillPositionFromGeoLocation = () => { 16 | const { options } = this.props; 17 | const { jsonData } = options; 18 | if (jsonData.latitude === undefined && jsonData.longitude === undefined) { 19 | if (navigator.geolocation) { 20 | navigator.geolocation.getCurrentPosition((position) => { 21 | const { onOptionsChange, options } = this.props; 22 | const jsonData = { 23 | ...options.jsonData, 24 | latitude: position.coords.latitude, 25 | longitude: position.coords.longitude, 26 | }; 27 | onOptionsChange({ ...options, jsonData }); 28 | }); 29 | } 30 | } 31 | }; 32 | 33 | onLatitudeChange = (event: ChangeEvent) => { 34 | const value = parseFloat(event.target.value); 35 | const { onOptionsChange, options } = this.props; 36 | const jsonData = { 37 | ...options.jsonData, 38 | latitude: isNaN(value) ? undefined : value, 39 | }; 40 | onOptionsChange({ ...options, jsonData }); 41 | }; 42 | 43 | onLongitudeChange = (event: ChangeEvent) => { 44 | const value = parseFloat(event.target.value); 45 | const { onOptionsChange, options } = this.props; 46 | const jsonData = { 47 | ...options.jsonData, 48 | longitude: isNaN(value) ? undefined : value, 49 | }; 50 | onOptionsChange({ ...options, jsonData }); 51 | }; 52 | 53 | render() { 54 | const { options } = this.props; 55 | const { jsonData } = options; 56 | 57 | return ( 58 |
59 |

Sun and Moon reference location

60 | 61 | This datasource calculates sun and moon metrics that are relative to a location on earth. 62 |
63 | The geographic position is expressed as latitude and longitude in decimal degrees. 64 |
65 |
66 | 67 | 79 | 80 |
81 |
82 | 83 | 95 | 96 |
97 |
98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/DataSource.test.ts: -------------------------------------------------------------------------------- 1 | import map from 'lodash/map'; 2 | 3 | import { SunAndMoonDataSource } from './DataSource'; 4 | import { DataSourceInstanceSettings, DataQueryRequest, dateTime } from '@grafana/data'; 5 | import { SunAndMoonDataSourceOptions, SunAndMoonQuery, sunAndMoonMetrics, sunAndMoonAnnotations } from './types'; 6 | 7 | jest.mock('@grafana/runtime', () => { 8 | const original = jest.requireActual('@grafana/runtime'); 9 | return { 10 | ...original, 11 | getTemplateSrv: () => ({ 12 | replace: (s: string) => s, 13 | }), 14 | }; 15 | }); 16 | 17 | describe('SunAndMoonDatasource', () => { 18 | let datasource: SunAndMoonDataSource; 19 | 20 | beforeEach(() => { 21 | const config = { 22 | jsonData: { 23 | latitude: 48, 24 | longitude: 10, 25 | }, 26 | } as DataSourceInstanceSettings; 27 | datasource = new SunAndMoonDataSource(config); 28 | }); 29 | 30 | it('getDefaultQuery should return a default', () => { 31 | const app: any = {}; 32 | expect(datasource.getDefaultQuery!(app).target!).toHaveLength(1); 33 | }); 34 | 35 | it('query should return an empty array when no targets are set', () => { 36 | const options: any = { 37 | targets: [], 38 | maxDataPoints: 10, 39 | range: { 40 | from: dateTime('2016/06/25', 'YYYY/MM/DD'), 41 | to: dateTime('2016/06/26', 'YYYY/MM/DD'), 42 | }, 43 | }; 44 | return datasource.query(options).then((results) => { 45 | expect(results.data).toHaveLength(0); 46 | }); 47 | }); 48 | 49 | it('query should return data for all implemented metrics', () => { 50 | const targets = map({ ...sunAndMoonMetrics, ...sunAndMoonAnnotations }, (metric, value) => { 51 | return { refId: value, target: [value] }; 52 | }); 53 | const options = { 54 | targets: targets, 55 | maxDataPoints: 10, 56 | range: { 57 | from: dateTime('2019/03/26', 'YYYY/MM/DD'), 58 | to: dateTime('2019/03/27', 'YYYY/MM/DD'), 59 | }, 60 | } as DataQueryRequest; 61 | return datasource.query(options).then((results) => { 62 | expect(results.data).toHaveLength(options.targets.length); 63 | }); 64 | }); 65 | 66 | it('query should not return data for unknown metrics', () => { 67 | const options = { 68 | targets: [ 69 | { refId: 'A', target: ['notexistent'] }, 70 | ], 71 | maxDataPoints: 10, 72 | range: { 73 | from: dateTime('2019/03/26', 'YYYY/MM/DD'), 74 | to: dateTime('2019/03/27', 'YYYY/MM/DD'), 75 | }, 76 | } as DataQueryRequest; 77 | return datasource.query(options).then((results) => { 78 | expect(results.data).toHaveLength(0); 79 | }); 80 | }); 81 | 82 | it('query should not return data for hidden targets', () => { 83 | const options = { 84 | targets: [ 85 | { refId: 'A', target: ['moon_illumination'] }, 86 | { refId: 'B', target: ['moon_altitude'], hide: true }, 87 | ], 88 | maxDataPoints: 10, 89 | range: { 90 | from: dateTime('2019/03/26', 'YYYY/MM/DD'), 91 | to: dateTime('2019/03/27', 'YYYY/MM/DD'), 92 | }, 93 | } as DataQueryRequest; 94 | return datasource.query(options).then((results) => { 95 | expect(results.data).toHaveLength(1); 96 | }); 97 | }); 98 | 99 | it('query should support position override', async () => { 100 | const options = { 101 | targets: [{ refId: 'A', target: ['sun_altitude'], latitude: '50', longitude: '20' }], 102 | maxDataPoints: 10, 103 | range: { 104 | from: dateTime('2019/03/26', 'YYYY/MM/DD'), 105 | to: dateTime('2019/03/27', 'YYYY/MM/DD'), 106 | }, 107 | } as DataQueryRequest; 108 | return datasource.query(options).then((results) => { 109 | expect(results.data).toHaveLength(options.targets.length); 110 | }); 111 | }); 112 | 113 | it('query should inform user about position override errors', () => { 114 | const options = { 115 | targets: [{ refId: 'A', target: ['moon_illumination'], latitude: 'xxx', longitude: 'yyy' }], 116 | maxDataPoints: 10, 117 | range: { 118 | from: dateTime('2019/03/26', 'YYYY/MM/DD'), 119 | to: dateTime('2019/03/27', 'YYYY/MM/DD'), 120 | }, 121 | } as DataQueryRequest; 122 | return expect(datasource.query(options)).rejects.toThrow('Error in query'); 123 | }); 124 | 125 | it('query should support legacy single value metrics', () => { 126 | const options = { 127 | targets: [ 128 | { refId: 'A', target: 'sun_altitude' }, 129 | ], 130 | maxDataPoints: 10, 131 | range: { 132 | from: dateTime('2019/03/26', 'YYYY/MM/DD'), 133 | to: dateTime('2019/03/27', 'YYYY/MM/DD'), 134 | }, 135 | } as any; 136 | return datasource.query(options).then((results) => { 137 | expect(results.data).toHaveLength(1); 138 | }); 139 | }); 140 | 141 | it('query should support legacy annotation queries', () => { 142 | const options = { 143 | targets: [ 144 | { refId: 'A', query: 'sunrise,moonrise' }, 145 | ], 146 | maxDataPoints: 10, 147 | range: { 148 | from: dateTime('2019/03/26', 'YYYY/MM/DD'), 149 | to: dateTime('2019/03/27', 'YYYY/MM/DD'), 150 | }, 151 | } as any; 152 | return datasource.query(options).then((results) => { 153 | expect(results.data).toHaveLength(2); 154 | }); 155 | }); 156 | 157 | it('testDatasource should return success for valid gps position', () => { 158 | datasource.latitude = 0; 159 | datasource.longitude = 0; 160 | return datasource.testDatasource().then((results) => { 161 | expect(results).toMatchObject({ status: 'success' }); 162 | }); 163 | }); 164 | 165 | it('testDatasource should return error if latitude < -90', () => { 166 | datasource.latitude = -91; 167 | datasource.longitude = 0; 168 | return datasource.testDatasource().then((results) => { 169 | expect(results).toMatchObject({ status: 'error' }); 170 | }); 171 | }); 172 | 173 | it('testDatasource should return error if latitude > -90', () => { 174 | datasource.latitude = 91; 175 | datasource.longitude = 0; 176 | return datasource.testDatasource().then((results) => { 177 | expect(results).toMatchObject({ status: 'error' }); 178 | }); 179 | }); 180 | 181 | it('testDatasource should return error if longitude < -360', () => { 182 | datasource.latitude = 0; 183 | datasource.longitude = -361; 184 | return datasource.testDatasource().then((results) => { 185 | expect(results).toMatchObject({ status: 'error' }); 186 | }); 187 | }); 188 | 189 | it('testDatasource should return error if longitude > 360', () => { 190 | datasource.latitude = 0; 191 | datasource.longitude = 361; 192 | return datasource.testDatasource().then((results) => { 193 | expect(results).toMatchObject({ status: 'error' }); 194 | }); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /src/DataSource.ts: -------------------------------------------------------------------------------- 1 | import * as SunCalc from 'suncalc'; 2 | 3 | import { 4 | CoreApp, 5 | DataQueryRequest, 6 | DataQueryResponse, 7 | DataSourceApi, 8 | DataSourceInstanceSettings, 9 | dateTimeParse, 10 | FieldType, 11 | MutableDataFrame, 12 | dateTime, 13 | } from '@grafana/data'; 14 | 15 | import { getTemplateSrv } from '@grafana/runtime'; 16 | 17 | import { 18 | SunAndMoonQuery, 19 | SunAndMoonDataSourceOptions, 20 | sunAndMoonDefaultQuery, 21 | sunAndMoonMetrics, 22 | sunAndMoonAnnotations, 23 | } from './types'; 24 | 25 | import { migrateQuery } from 'migrations'; 26 | 27 | export class SunAndMoonDataSource extends DataSourceApi { 28 | latitude?: number; 29 | longitude?: number; 30 | 31 | /* istanbul ignore next: workaround for https://github.com/gotwarlost/istanbul/issues/690 */ 32 | constructor(instanceSettings: DataSourceInstanceSettings) { 33 | super(instanceSettings); 34 | 35 | this.latitude = instanceSettings.jsonData.latitude; 36 | this.longitude = instanceSettings.jsonData.longitude; 37 | this.annotations = {} 38 | } 39 | 40 | getDefaultQuery?(app: CoreApp): Partial { 41 | return sunAndMoonDefaultQuery; 42 | } 43 | 44 | async query(options: DataQueryRequest): Promise { 45 | const { range } = options; 46 | 47 | let errors: string[] = []; 48 | const targets = options.targets.filter((target) => !target.hide); 49 | 50 | let frames: MutableDataFrame[] = []; 51 | for (const target of targets) { 52 | let latitude = this.latitude; 53 | if (!!target.latitude) { 54 | latitude = parseFloat(getTemplateSrv().replace(target.latitude, options.scopedVars)); 55 | if (isNaN(latitude) || latitude < -90 || latitude > 90) { 56 | errors.push(`Error in query ${target.refId}: Latitude '${latitude}' not in range -+90.`); 57 | } 58 | } 59 | let longitude = this.longitude; 60 | if (!!target.longitude) { 61 | longitude = parseFloat(getTemplateSrv().replace(target.longitude, options.scopedVars)); 62 | if (isNaN(longitude) || longitude < -360 || longitude > 360) { 63 | errors.push(`Error in query ${target.refId}: Longitude '${longitude}' not in range -+360`); 64 | } 65 | } 66 | 67 | migrateQuery(target); 68 | 69 | const metrics = target.target!.filter((target) => target in sunAndMoonMetrics); 70 | const annotations = target.target!.filter((target) => target in sunAndMoonAnnotations); 71 | 72 | if (metrics.length) { 73 | for (const metric of metrics) { 74 | const frame = new MutableDataFrame({ 75 | refId: target.refId, 76 | name: sunAndMoonMetrics[metric].title, 77 | fields: [ 78 | { name: 'Time', type: FieldType.time }, 79 | { name: 'Value', type: FieldType.number, config: sunAndMoonMetrics[metric].config }, 80 | ], 81 | }); 82 | let value = undefined; 83 | for (let time = range.from.valueOf(); time < range.to.valueOf(); time += options.intervalMs) { 84 | switch (metric) { 85 | case 'moon_illumination': 86 | value = SunCalc.getMoonIllumination(new Date(time)).fraction; 87 | break; 88 | case 'moon_altitude': 89 | value = (SunCalc.getMoonPosition(new Date(time), latitude!, longitude!).altitude * 180) / Math.PI; 90 | break; 91 | case 'moon_azimuth': 92 | value = (SunCalc.getMoonPosition(new Date(time), latitude!, longitude!).azimuth * 180) / Math.PI + 180; 93 | break; 94 | case 'moon_distance': 95 | value = SunCalc.getMoonPosition(new Date(time), latitude!, longitude!).distance; 96 | break; 97 | case 'sun_altitude': 98 | value = (SunCalc.getPosition(new Date(time), latitude!, longitude!).altitude * 180) / Math.PI; 99 | break; 100 | case 'sun_azimuth': 101 | value = (SunCalc.getPosition(new Date(time), latitude!, longitude!).azimuth * 180) / Math.PI + 180; 102 | break; 103 | case 'sun_maximum_altitude': 104 | let solar_noon = SunCalc.getTimes(new Date(time), latitude!, longitude!).solarNoon; 105 | value = (SunCalc.getPosition(solar_noon, latitude!, longitude!).altitude * 180) / Math.PI; 106 | break; 107 | } 108 | if (value !== undefined) { 109 | frame.add({ Time: time, Value: value }); 110 | } 111 | } 112 | frames.push(frame); 113 | } 114 | } 115 | if (annotations.length) { 116 | for (const annotation of annotations) { 117 | const frame = new MutableDataFrame({ 118 | refId: target.refId, 119 | name: sunAndMoonAnnotations[annotation].title, 120 | fields: [ 121 | { name: 'Time', type: FieldType.time }, 122 | { name: 'Title', type: FieldType.string }, 123 | { name: 'Text', type: FieldType.string }, 124 | { name: 'Tags', type: FieldType.other }, 125 | ], 126 | }); 127 | 128 | for (const date = dateTime(range.from.valueOf()); date < dateTime(range.to.valueOf()).add(1, 'days'); date.add(1, 'days')) { 129 | let time = undefined; 130 | switch (annotation) { 131 | case 'sunrise': 132 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).sunrise; 133 | break; 134 | case 'sunriseEnd': 135 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).sunriseEnd; 136 | break; 137 | case 'goldenHour': 138 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).goldenHour; 139 | break; 140 | case 'goldenHourEnd': 141 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).goldenHourEnd; 142 | break; 143 | case 'solarNoon': 144 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).solarNoon; 145 | break; 146 | case 'sunsetStart': 147 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).sunsetStart; 148 | break; 149 | case 'sunset': 150 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).sunset; 151 | break; 152 | case 'dusk': 153 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).dusk; 154 | break; 155 | case 'nauticalDusk': 156 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).nauticalDusk; 157 | break; 158 | case 'nauticalDawn': 159 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).nauticalDawn; 160 | break; 161 | case 'night': 162 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).night; 163 | break; 164 | case 'nightEnd': 165 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).nightEnd; 166 | break; 167 | case 'nadir': 168 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).nadir; 169 | break; 170 | case 'dawn': 171 | time = SunCalc.getTimes(date.toDate(), latitude!, longitude!).dawn; 172 | break; 173 | case 'moonrise': 174 | time = SunCalc.getMoonTimes(date.toDate(), latitude!, longitude!).rise; 175 | break; 176 | case 'moonset': 177 | time = SunCalc.getMoonTimes(date.toDate(), latitude!, longitude!).set; 178 | break; 179 | case 'noon': 180 | time = dateTimeParse(`${date.format("YYYY-MM-DD")} 12:00:00`, { timeZone: options.timezone }); 181 | break; 182 | case 'midnight': 183 | time = dateTimeParse(`${date.format("YYYY-MM-DD")} 00:00:00`, { timeZone: options.timezone }); 184 | break; 185 | } 186 | if (time !== undefined) { 187 | frame.add({ 188 | Time: time!.valueOf(), 189 | Title: sunAndMoonAnnotations[annotation].title, 190 | Text: sunAndMoonAnnotations[annotation].text, 191 | Tags: sunAndMoonAnnotations[annotation].tags, 192 | }); 193 | } 194 | } 195 | frames.push(frame); 196 | } 197 | } 198 | } 199 | 200 | if (errors.length) { 201 | throw new Error(errors.join(' ')); 202 | } else { 203 | return { data: frames }; 204 | } 205 | } 206 | 207 | async testDatasource() { 208 | let errors: string[] = []; 209 | if (this.latitude === undefined || this.latitude < -90 || this.latitude > 90) { 210 | errors.push('Latitude not in range -+90.'); 211 | } 212 | if (this.longitude === undefined || this.longitude < -360 || this.longitude > 360) { 213 | errors.push('Longitude not in range -+360.'); 214 | } 215 | if (errors.length) { 216 | return { status: 'error', title: 'Error', message: errors.join(' ') }; 217 | } else { 218 | return { status: 'success', title: 'Success', message: 'Datasource added successfully.' }; 219 | } 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/QueryEditor.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { fireEvent, render, screen } from '@testing-library/react'; 3 | import '@testing-library/jest-dom' 4 | import { select } from 'react-select-event'; 5 | import { QueryEditor } from './QueryEditor'; 6 | 7 | describe('QueryEditor', () => { 8 | let props: any; 9 | 10 | beforeEach(() => { 11 | props = { 12 | query: { 13 | refId: 'test', 14 | target: ['sun_altitude'], 15 | latitude: '', 16 | longitude: '', 17 | }, 18 | datasource: { 19 | latitude: 50, 20 | longitude: 10, 21 | }, 22 | }; 23 | }); 24 | 25 | it('should render without throwing an error', () => { 26 | expect(() => render()).not.toThrow(); 27 | }); 28 | 29 | it('should update query on change', async () => { 30 | const onChange = jest.fn(); 31 | const onRunQuery = jest.fn(); 32 | render(); 33 | 34 | const metricSelect = await screen.findByLabelText('Metric'); 35 | expect(metricSelect).toBeInTheDocument(); 36 | 37 | await select(metricSelect, 'Moon altitude', { container: document.body }); 38 | expect(onChange).toHaveBeenCalledWith({ ...props.query, target: ['sun_altitude', 'moon_altitude'] }); 39 | expect(onRunQuery).toHaveBeenCalled(); 40 | 41 | const latitudeInput = await screen.findByLabelText('Latitude'); 42 | fireEvent.change(latitudeInput, { target: { value: '50' } }); 43 | expect(onChange).toHaveBeenCalledWith({ ...props.query, latitude: '50' }); 44 | expect(onRunQuery).toHaveBeenCalled(); 45 | 46 | const longitudeInput = await screen.findByLabelText('Longitude'); 47 | fireEvent.change(longitudeInput, { target: { value: '20' } }); 48 | expect(onChange).toHaveBeenCalledWith({ ...props.query, longitude: '20' }); 49 | expect(onRunQuery).toHaveBeenCalled(); 50 | }); 51 | 52 | it('should support legacy single element queries', async () => { 53 | let legacyProps = { ...props }; 54 | legacyProps.query.target = "moon_distance"; 55 | render(); 56 | 57 | await screen.findByText("Moon distance"); 58 | }); 59 | 60 | it('should support legacy annotation queries', async () => { 61 | let legacyAnnotationProps = { ...props }; 62 | legacyAnnotationProps.query.target = undefined; 63 | legacyAnnotationProps.query.query = "sunrise,sunset"; 64 | render(); 65 | 66 | await screen.findByText("Sunrise"); 67 | await screen.findByText("Sunset"); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/QueryEditor.tsx: -------------------------------------------------------------------------------- 1 | import defaults from 'lodash/defaults'; 2 | import map from 'lodash/map'; 3 | 4 | import React, { ChangeEvent, PureComponent } from 'react'; 5 | import { InlineFormLabel, InlineField, Input, MultiSelect } from '@grafana/ui'; 6 | import { QueryEditorProps, SelectableValue } from '@grafana/data'; 7 | import { SunAndMoonDataSource } from './DataSource'; 8 | import { SunAndMoonDataSourceOptions, SunAndMoonQuery, sunAndMoonDefaultQuery, sunAndMoonMetrics, sunAndMoonAnnotations } from './types'; 9 | import { migrateQuery } from 'migrations'; 10 | 11 | export type Props = QueryEditorProps; 12 | 13 | const metrics = map({ ...sunAndMoonMetrics, ...sunAndMoonAnnotations }, (metric, value) => { 14 | return { label: metric.title, value: value, description: metric.text }; 15 | }) as Array>; 16 | 17 | export class QueryEditor extends PureComponent { 18 | onMetricChanged = (selected: Array>) => { 19 | const { onChange, query, onRunQuery } = this.props; 20 | onChange({ ...query, target: selected.map(selection => selection.value!) }); 21 | onRunQuery(); 22 | }; 23 | 24 | onLatitudeChange = (event: ChangeEvent) => { 25 | const { onChange, query, onRunQuery } = this.props; 26 | onChange({ ...query, latitude: event.target.value }); 27 | onRunQuery(); 28 | }; 29 | 30 | onLongitudeChange = (event: ChangeEvent) => { 31 | const { onChange, query, onRunQuery } = this.props; 32 | onChange({ ...query, longitude: event.target.value }); 33 | onRunQuery(); 34 | }; 35 | 36 | render() { 37 | migrateQuery(this.props.query); 38 | 39 | const query = defaults(this.props.query, sunAndMoonDefaultQuery); 40 | const { datasource } = this.props; 41 | 42 | return ( 43 |
44 |
45 | Metric 46 | 54 |
55 |
56 | 57 | 65 | 66 | 67 | 75 | 76 |
77 |
78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/img/configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fetzerch/grafana-sunandmoon-datasource/73fd31f60e6aeb9afda7e467f5cc376b507dbb9c/src/img/configuration.png -------------------------------------------------------------------------------- /src/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fetzerch/grafana-sunandmoon-datasource/73fd31f60e6aeb9afda7e467f5cc376b507dbb9c/src/img/logo.png -------------------------------------------------------------------------------- /src/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fetzerch/grafana-sunandmoon-datasource/73fd31f60e6aeb9afda7e467f5cc376b507dbb9c/src/img/screenshot.png -------------------------------------------------------------------------------- /src/migrations.test.ts: -------------------------------------------------------------------------------- 1 | import { migrateQuery } from './migrations' 2 | 3 | describe('Migrations', () => { 4 | it('should migrate single string queries', () => { 5 | let query = { 6 | target: "single_string" 7 | }; 8 | 9 | migrateQuery(query); 10 | 11 | expect(query).toStrictEqual({ 12 | target: ["single_string"] 13 | }) 14 | }); 15 | 16 | it('should migrate legacy annotation queries', () => { 17 | let query = { 18 | query: "*, annotation1 ,annotation2" 19 | }; 20 | 21 | migrateQuery(query); 22 | 23 | expect(query).toStrictEqual({ 24 | target: ["annotation1", "annotation2"] 25 | }) 26 | 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/migrations.ts: -------------------------------------------------------------------------------- 1 | // Migrate old single string and annotation queries 2 | export const migrateQuery = (query: any) => { 3 | if (query.query) { 4 | let targets: string[] = query.query.split(",").map((target: string) => target.trim()); 5 | query.target = targets.filter(target => target !== "*"); 6 | delete query.query; 7 | } else if (typeof query.target === "string") { 8 | query.target = [query.target]; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/module.test.ts: -------------------------------------------------------------------------------- 1 | import { plugin } from './module'; 2 | 3 | describe('SunAndMoonModule', () => { 4 | it('module should export plugin', () => { 5 | expect(plugin).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { DataSourcePlugin } from '@grafana/data'; 2 | import { SunAndMoonDataSource } from './DataSource'; 3 | import { ConfigEditor } from './ConfigEditor'; 4 | import { QueryEditor } from './QueryEditor'; 5 | import { SunAndMoonQuery, SunAndMoonDataSourceOptions } from './types'; 6 | 7 | export const plugin = new DataSourcePlugin( 8 | SunAndMoonDataSource 9 | ) 10 | .setConfigEditor(ConfigEditor) 11 | .setQueryEditor(QueryEditor) 12 | -------------------------------------------------------------------------------- /src/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/grafana/grafana/master/docs/sources/developers/plugins/plugin.schema.json", 3 | "name": "Sun and Moon", 4 | "id": "fetzerch-sunandmoon-datasource", 5 | "type": "datasource", 6 | "metrics": true, 7 | "annotations": true, 8 | "info": { 9 | "description": "Calculates position of Sun and Moon.", 10 | "author": { 11 | "name": "Christian Fetzer", 12 | "email": "fetzer.ch@gmail.com", 13 | "url": "https://fetzerch.github.io" 14 | }, 15 | "keywords": [ 16 | "sun", 17 | "moon", 18 | "altitude", 19 | "azimuth", 20 | "illumination", 21 | "distance" 22 | ], 23 | "logos": { 24 | "small": "img/logo.png", 25 | "large": "img/logo.png" 26 | }, 27 | "links": [ 28 | { 29 | "name": "GitHub", 30 | "url": "https://github.com/fetzerch/grafana-sunandmoon-datasource" 31 | }, 32 | { 33 | "name": "MIT License", 34 | "url": "https://github.com/fetzerch/grafana-sunandmoon-datasource/blob/master/LICENSE" 35 | } 36 | ], 37 | "screenshots": [ 38 | { 39 | "name": "Sun and Moon Position", 40 | "path": "img/screenshot.png" 41 | } 42 | ], 43 | "version": "%VERSION%", 44 | "updated": "%TODAY%" 45 | }, 46 | "dependencies": { 47 | "grafanaDependency": ">=7.3.0", 48 | "grafanaVersion": "7.3.0", 49 | "plugins": [] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DataSourceJsonData } from '@grafana/data'; 2 | import { DataQuery } from '@grafana/schema'; 3 | 4 | export const sunAndMoonMetrics: any = { 5 | moon_illumination: { 6 | title: 'Moon illumination', 7 | text: 'Percentage of the moon illuminated by the sun (0.0 - 1.0)', 8 | config: { unit: 'percentunit', decimals: 0 }, 9 | }, 10 | moon_altitude: { 11 | title: 'Moon altitude', 12 | text: 'Height of the moon in degrees (-90 - 90)', 13 | config: { unit: 'degree', min: 0 }, 14 | }, 15 | moon_azimuth: { 16 | title: 'Moon azimuth', 17 | text: 'Direction of the moon along the horizon in degrees (0 - 360)', 18 | config: { unit: 'degree' }, 19 | }, 20 | moon_distance: { 21 | title: 'Moon distance', 22 | text: 'Distance to the moon in kilometers', 23 | config: { unit: 'lengthkm', decimals: 0 }, 24 | }, 25 | sun_altitude: { 26 | title: 'Sun altitude', 27 | text: 'Height of the sun in degrees (-90 - 90)', 28 | config: { unit: 'degree', min: 0 }, 29 | }, 30 | sun_azimuth: { 31 | title: 'Sun azimuth', 32 | text: 'Direction of the sun along the horizon in degrees (0 - 360)', 33 | config: { unit: 'degree' }, 34 | }, 35 | sun_maximum_altitude: { 36 | title: 'Maximum sun altitude of the day', 37 | text: 'Maximum height of the sun of the day (at solar noon) in degrees (-90 - 90)', 38 | config: { unit: 'degree', min: 0 }, 39 | }, 40 | }; 41 | 42 | export const sunAndMoonAnnotations: any = { 43 | sunrise: { 44 | title: 'Sunrise', 45 | text: 'Top edge of the sun appears on the horizon', 46 | tags: ['sun'], 47 | }, 48 | sunriseEnd: { 49 | title: 'Sunrise ends', 50 | text: 'Bottom edge of the sun touches the horizon', 51 | tags: ['sun'], 52 | }, 53 | goldenHourEnd: { 54 | title: 'Morning golden hour ends', 55 | text: 'Soft light, best time for photography', 56 | tags: ['sun'], 57 | }, 58 | solarNoon: { 59 | title: 'Solar noon', 60 | text: 'Sun is in the highest position', 61 | tags: ['sun'], 62 | }, 63 | goldenHour: { 64 | title: 'Evening golden hour starts', 65 | text: 'Soft light, best time for photography', 66 | tags: ['sun'], 67 | }, 68 | sunsetStart: { 69 | title: 'Sunset starts', 70 | text: 'Bottom edge of the sun touches the horizon', 71 | tags: ['sun'], 72 | }, 73 | sunset: { 74 | title: 'Sunset', 75 | text: 'Sun disappears below the horizon, evening civil twilight starts', 76 | tags: ['sun'], 77 | }, 78 | dusk: { 79 | title: 'Dusk', 80 | text: 'Evening nautical twilight starts', 81 | tags: ['sun'], 82 | }, 83 | nauticalDusk: { 84 | title: 'Nautical dusk', 85 | text: 'Evening astronomical twilight starts', 86 | tags: ['sun'], 87 | }, 88 | night: { 89 | title: 'Night starts', 90 | text: 'Dark enough for astronomical observations', 91 | tags: ['sun'], 92 | }, 93 | nadir: { 94 | title: 'Nadir', 95 | text: 'Darkest moment of the night, sun is in the lowest position', 96 | tags: ['sun'], 97 | }, 98 | nightEnd: { 99 | title: 'Night ends', 100 | text: 'Morning astronomical twilight starts', 101 | tags: ['sun'], 102 | }, 103 | nauticalDawn: { 104 | title: 'Nautical dawn', 105 | text: 'Morning nautical twilight starts', 106 | tags: ['sun'], 107 | }, 108 | dawn: { 109 | title: 'Dawn', 110 | text: 'Morning nautical twilight ends, morning civil twilight starts', 111 | tags: ['sun'], 112 | }, 113 | moonrise: { 114 | title: 'Moonrise', 115 | text: 'Top edge of the moon appears on the horizon', 116 | tags: ['moon'], 117 | }, 118 | moonset: { 119 | title: 'Moonset', 120 | text: 'Moon disappears below the horizon', 121 | tags: ['moon'], 122 | }, 123 | noon: { 124 | title: 'Noon', 125 | text: "12 o'clock in the daytime", 126 | tags: ['time'], 127 | }, 128 | midnight: { 129 | title: 'Midnight', 130 | text: "12 o'clock in the night", 131 | tags: ['time'], 132 | }, 133 | }; 134 | 135 | export interface SunAndMoonQuery extends DataQuery { 136 | target?: string[]; 137 | latitude?: string; 138 | longitude?: string; 139 | } 140 | 141 | export const sunAndMoonDefaultQuery: Partial = { 142 | target: ['moon_illumination'], 143 | }; 144 | 145 | export interface SunAndMoonDataSourceOptions extends DataSourceJsonData { 146 | latitude?: number; 147 | longitude?: number; 148 | } 149 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------