├── .nvmrc ├── .cprc.json ├── .vscode ├── settings.json └── launch.json ├── jest-setup.js ├── .config ├── webpack │ ├── constants.ts │ ├── utils.ts │ └── webpack.config.ts ├── .eslintrc ├── .prettierrc.js ├── entrypoint.sh ├── types │ └── custom.d.ts ├── tsconfig.json ├── jest-setup.js ├── jest │ ├── mocks │ │ └── react-inlinesvg.tsx │ └── utils.js ├── jest.config.js ├── supervisord │ └── supervisord.conf ├── Dockerfile └── README.md ├── src ├── img │ ├── Screen1.png │ ├── Screen2.png │ ├── Screen3.png │ ├── Screen4.png │ ├── HistorianUI.png │ ├── assets_query.png │ ├── events_query.png │ ├── measurements_query.png │ ├── datasource_configuration.png │ └── logo.svg ├── components │ ├── TagsSection │ │ ├── types.ts │ │ ├── util.ts │ │ ├── TagsSection.tsx │ │ └── Tag.tsx │ ├── util │ │ ├── AddButton.tsx │ │ ├── MaybeRegexInput.tsx │ │ ├── AssetPropertiesSelect.tsx │ │ ├── DatabaseSelect.tsx │ │ ├── MeasurementSelect.tsx │ │ └── DateRangePicker.tsx │ ├── GroupBySection │ │ ├── Group.tsx │ │ └── GroupBySection.tsx │ ├── Cascader │ │ ├── optionMappings.ts │ │ └── styles.ts │ └── Autocomplete │ │ └── Autocomplete.tsx ├── module.ts ├── util │ ├── eventFilter.ts │ ├── util.ts │ ├── migration.ts │ └── semver.ts ├── CustomVariableEditor │ ├── Pagination.tsx │ ├── EventTypeFilter.tsx │ ├── DatabaseFilter.tsx │ ├── PropertyValuesFilter.tsx │ ├── AssetFilter.tsx │ ├── MeasurementFilter.tsx │ ├── EventTypePropertyFilter.tsx │ └── AssetPropertyFilter.tsx ├── plugin.json ├── AnnotationsQueryEditor │ └── AnnotationsQueryEditor.tsx ├── QueryEditor │ └── RawQueryEditor.tsx ├── README.md └── ConfigEditor.tsx ├── .npmrc ├── .eslintrc ├── .prettierrc.js ├── tsconfig.json ├── Magefile.go ├── jest.config.js ├── provisioning ├── dashboards │ └── default.yml └── datasources │ └── datasources.yml ├── pkg ├── proto │ ├── arrow.proto │ └── arrow.pb.go ├── main.go ├── api │ ├── api.go │ ├── errors.go │ ├── util.go │ └── query.go ├── util │ ├── semver_test.go │ ├── util.go │ ├── semver.go │ └── periodic_property_values.go ├── datasource │ ├── health.go │ ├── errors.go │ ├── settings.go │ ├── util.go │ └── historian.go └── schemas │ ├── events.go │ ├── queries.go │ ├── attributes.go │ ├── timeseries.go │ └── database.go ├── .github ├── workflows │ ├── is-compatible.yml │ └── release.yml └── actions │ ├── pnpm │ └── action.yml │ └── grafana │ └── action.yml ├── .gitignore ├── .bra.toml ├── docker-compose.yaml ├── README.md ├── package.json ├── SCHEMA.md ├── Makefile ├── go.mod └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "features": { 3 | "useReactRouterV6": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /jest-setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup provided by Grafana scaffolding 2 | import './.config/jest-setup' 3 | -------------------------------------------------------------------------------- /.config/webpack/constants.ts: -------------------------------------------------------------------------------- 1 | export const SOURCE_DIR = 'src'; 2 | export const DIST_DIR = 'dist'; 3 | -------------------------------------------------------------------------------- /src/img/Screen1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factrylabs/factry-historian-datasource/HEAD/src/img/Screen1.png -------------------------------------------------------------------------------- /src/img/Screen2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factrylabs/factry-historian-datasource/HEAD/src/img/Screen2.png -------------------------------------------------------------------------------- /src/img/Screen3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factrylabs/factry-historian-datasource/HEAD/src/img/Screen3.png -------------------------------------------------------------------------------- /src/img/Screen4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factrylabs/factry-historian-datasource/HEAD/src/img/Screen4.png -------------------------------------------------------------------------------- /src/img/HistorianUI.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factrylabs/factry-historian-datasource/HEAD/src/img/HistorianUI.png -------------------------------------------------------------------------------- /src/img/assets_query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factrylabs/factry-historian-datasource/HEAD/src/img/assets_query.png -------------------------------------------------------------------------------- /src/img/events_query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factrylabs/factry-historian-datasource/HEAD/src/img/events_query.png -------------------------------------------------------------------------------- /src/img/measurements_query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factrylabs/factry-historian-datasource/HEAD/src/img/measurements_query.png -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # registry=http://development.factry.cloud:4873/ 2 | auto-install-peers=true 3 | shamefully-hoist=true 4 | side-effects-cache=false 5 | -------------------------------------------------------------------------------- /src/img/datasource_configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/factrylabs/factry-historian-datasource/HEAD/src/img/datasource_configuration.png -------------------------------------------------------------------------------- /src/components/TagsSection/types.ts: -------------------------------------------------------------------------------- 1 | export interface QueryTag { 2 | key: string 3 | operator?: string 4 | condition?: string 5 | value: string 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/.eslintrc", 3 | "rules": { 4 | "react/react-in-jsx-scope": "off", 5 | "react/jsx-uses-react": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require('./.config/.prettierrc.js'), 4 | semi: false, 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx", 5 | "types": ["react", "react-dom"], 6 | "noUnusedLocals": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Magefile.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | // +build mage 3 | 4 | package main 5 | 6 | import ( 7 | // mage:import 8 | build "github.com/grafana/grafana-plugin-sdk-go/build" 9 | ) 10 | 11 | // Default configures the default target. 12 | var Default = build.BuildAll 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /provisioning/dashboards/default.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: Default # A uniquely identifiable name for the provider 5 | folder: Examples # The folder where to place the dashboards 6 | type: file 7 | options: 8 | path: 9 | /etc/grafana/provisioning/dashboards/examples 10 | -------------------------------------------------------------------------------- /pkg/proto/arrow.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | option go_package = "github.com/factrylabs/factry-historian-datasource.git/pkg/proto"; 4 | 5 | package proto; 6 | 7 | message DataResponse { 8 | // Arrow encoded DataFrames 9 | // Frame has its own meta, warnings, and repeats refId 10 | repeated bytes frames = 1; 11 | 12 | // Error message 13 | string error = 2; 14 | } 15 | -------------------------------------------------------------------------------- /.config/.eslintrc: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.github.io/plugin-tools/docs/advanced-configuration#extending-the-eslint-config 6 | */ 7 | { 8 | "extends": ["@grafana/eslint-config"], 9 | "root": true, 10 | "rules": { 11 | "react/prop-types": "off" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { DataSourcePlugin } from '@grafana/data' 2 | import { DataSource } from './datasource' 3 | import { ConfigEditor } from './ConfigEditor' 4 | import { QueryEditor } from './QueryEditor' 5 | import { Query, HistorianDataSourceOptions } from './types' 6 | 7 | export const plugin = new DataSourcePlugin(DataSource) 8 | .setConfigEditor(ConfigEditor) 9 | .setQueryEditor(QueryEditor) 10 | -------------------------------------------------------------------------------- /.config/.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | module.exports = { 8 | "endOfLine": "auto", 9 | "printWidth": 120, 10 | "trailingComma": "es5", 11 | "semi": true, 12 | "jsxSingleQuote": false, 13 | "singleQuote": true, 14 | "useTabs": false, 15 | "tabWidth": 2 16 | }; -------------------------------------------------------------------------------- /.config/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 | -------------------------------------------------------------------------------- /pkg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | historianDataSource "github.com/factrylabs/factry-historian-datasource.git/pkg/datasource" 7 | "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 9 | ) 10 | 11 | func main() { 12 | if err := datasource.Manage("factry-historian-datasource", historianDataSource.NewDataSource, datasource.ManageOpts{}); err != nil { 13 | log.DefaultLogger.Error(err.Error()) 14 | os.Exit(1) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /pkg/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/go-resty/resty/v2" 5 | ) 6 | 7 | // API is used to communicate with the historian API 8 | type API struct { 9 | client *resty.Client 10 | } 11 | 12 | // NewAPIWithToken creates a new instance of API using a token 13 | func NewAPIWithToken(url string, token string, organization string) (*API, error) { 14 | headers := map[string]string{ 15 | "x-organization-uuid": organization, 16 | } 17 | client := resty.New() 18 | client.SetHeaders(headers) 19 | client.SetAuthToken(token) 20 | client.SetBaseURL(url) 21 | api := &API{client} 22 | return api, nil 23 | } 24 | -------------------------------------------------------------------------------- /src/components/TagsSection/util.ts: -------------------------------------------------------------------------------- 1 | import { SelectableValue } from '@grafana/data' 2 | import { QueryTag } from './types' 3 | 4 | export function toSelectableValue(t: T): SelectableValue { 5 | return { label: t, value: t } 6 | } 7 | 8 | export function getOperator(tag: QueryTag): string { 9 | return tag.operator ?? (isRegex(tag.value) ? '=~' : '=') 10 | } 11 | 12 | export function getCondition(tag: QueryTag, isFirst: boolean): string | undefined { 13 | return isFirst ? undefined : tag.condition ?? 'AND' 14 | } 15 | 16 | export function isRegex(text: string): boolean { 17 | return /^\/.*\/$/.test(text) 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/is-compatible.yml: -------------------------------------------------------------------------------- 1 | name: Latest Grafana API compatibility check 2 | on: [pull_request] 3 | 4 | concurrency: 5 | group: ${{ github.workflow }}-${{ github.ref }} 6 | cancel-in-progress: true 7 | 8 | jobs: 9 | compatibilitycheck: 10 | permissions: 11 | contents: read 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: ./.github/actions/pnpm 16 | - name: Build plugin 17 | run: pnpm 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 | -------------------------------------------------------------------------------- /.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 | grafana-plugins/* 33 | .pnpm-store 34 | node-modules/ 35 | factry-historian-datasource/ 36 | *.zip 37 | *.sha1 38 | .eslintcache 39 | -------------------------------------------------------------------------------- /provisioning/datasources/datasources.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: 'Factry Historian' 5 | type: 'factry-historian-datasource' 6 | access: proxy 7 | isDefault: true 8 | orgId: 1 9 | version: 1 10 | editable: true 11 | jsonData: 12 | url: 'https://grafana.historian.factry.dev:8000' 13 | organization: '02b2a43c-cfa1-11ed-9299-0242ac170006' 14 | secureJsonData: 15 | token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MTQ0ODEyMDYsImp0aSI6ImdyYWZhbmEtc2lnbmluZyIsInVzZXIiOnsidXVpZCI6ImE1YzJlNjI0LTA2ZWYtMTFlZi1iNTY0LTAyNDJhYzEzMDAwMiJ9fQ.q2O9AkJvPl0fr8VSUCn5sL64KnXNJmeIPKYPqVsLVOA' 16 | 17 | -------------------------------------------------------------------------------- /.bra.toml: -------------------------------------------------------------------------------- 1 | # default configuration created by the `mage watch` command. 2 | # this file can be edited and should be checked into source control. 3 | # see https://github.com/unknwon/bra/blob/master/templates/default.bra.toml for more configuration options. 4 | [run] 5 | init_cmds = [ 6 | ["mage", "-v", "build:debug"], 7 | ["mage", "-v" , "reloadPlugin"] 8 | ] 9 | watch_all = true 10 | follow_symlinks = false 11 | ignore = [".git", "node_modules", "dist"] 12 | ignore_files = ["mage_output_file.go"] 13 | watch_dirs = [ 14 | "pkg", 15 | "src", 16 | ] 17 | watch_exts = [".go", ".json"] 18 | build_delay = 2000 19 | cmds = [ 20 | ["mage", "-v", "build:debug"], 21 | ["mage", "-v" , "reloadPlugin"] 22 | ] 23 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Connect to server", 9 | "type": "go", 10 | "request": "attach", 11 | "mode": "remote", 12 | "substitutePath": [ 13 | { 14 | "from": "${workspaceFolder}", 15 | "to": "/root/factry-historian-datasource" 16 | } 17 | ], 18 | "remotePath": "/root/factry-historian-datasource", 19 | "port": 2345, 20 | "host": "127.0.0.1" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /pkg/api/errors.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/go-resty/resty/v2" 9 | ) 10 | 11 | // HttpError historian http error 12 | type HttpError struct { 13 | Info string 14 | Error string 15 | } 16 | 17 | func handleHistorianError(response *resty.Response) error { 18 | var httpErr HttpError 19 | if err := json.Unmarshal(response.Body(), &httpErr); err != nil { 20 | return errors.New(string(response.Body())) 21 | } 22 | 23 | if httpErr.Info == "" { 24 | return fmt.Errorf("StatusCode: %v, Error: %v", response.StatusCode(), httpErr.Error) 25 | } 26 | 27 | return fmt.Errorf("StatusCode: %v, Info: %v, Error: %v", response.StatusCode(), httpErr.Info, httpErr.Error) 28 | } 29 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/components/util/AddButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { SelectableValue } from '@grafana/data' 3 | 4 | import { Seg } from './Seg' 5 | 6 | function unwrap(value: T | null | undefined): T { 7 | if (value == null) { 8 | throw new Error('value must not be nullish') 9 | } 10 | return value 11 | } 12 | 13 | type Props = { 14 | loadOptions: () => Promise 15 | allowCustomValue?: boolean 16 | onAdd: (v: string) => void 17 | } 18 | 19 | export const AddButton = ({ loadOptions, allowCustomValue, onAdd }: Props): JSX.Element => { 20 | return ( 21 | { 26 | onAdd(unwrap(v.value)) 27 | }} 28 | /> 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /.config/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.github.io/plugin-tools/docs/advanced-configuration#extending-the-typescript-config 6 | */ 7 | { 8 | "compilerOptions": { 9 | "alwaysStrict": true, 10 | "declaration": false, 11 | "rootDir": "../src", 12 | "baseUrl": "../src", 13 | "typeRoots": ["../node_modules/@types"], 14 | "resolveJsonModule": true 15 | }, 16 | "ts-node": { 17 | "compilerOptions": { 18 | "module": "commonjs", 19 | "target": "es5", 20 | "esModuleInterop": true 21 | }, 22 | "transpileOnly": true 23 | }, 24 | "include": ["../src", "./types"], 25 | "extends": "@grafana/tsconfig" 26 | } 27 | -------------------------------------------------------------------------------- /.config/jest-setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.github.io/plugin-tools/docs/advanced-configuration#extending-the-jest-config 6 | */ 7 | 8 | import '@testing-library/jest-dom'; 9 | 10 | // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 11 | Object.defineProperty(global, 'matchMedia', { 12 | writable: true, 13 | value: jest.fn().mockImplementation((query) => ({ 14 | matches: false, 15 | media: query, 16 | onchange: null, 17 | addListener: jest.fn(), // deprecated 18 | removeListener: jest.fn(), // deprecated 19 | addEventListener: jest.fn(), 20 | removeEventListener: jest.fn(), 21 | dispatchEvent: jest.fn(), 22 | })), 23 | }); 24 | 25 | HTMLCanvasElement.prototype.getContext = () => {}; 26 | -------------------------------------------------------------------------------- /pkg/util/semver_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/factrylabs/factry-historian-datasource.git/pkg/util" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSemverCompare(t *testing.T) { 11 | tests := []struct { 12 | a, b string 13 | expected bool 14 | }{ 15 | {"1.0.0", "1.0.1", true}, 16 | {"1.0.1", "1.0.0", false}, 17 | {"1.0.0", "1.0.0", false}, 18 | {"1.2.3", "1.2.4", true}, 19 | {"1.2.3", "1.3.0", true}, 20 | {"2.0.0", "1.9.9", false}, 21 | {"1.10.0", "1.2.0", false}, 22 | {"1.0.0", "1.0.0-alpha", false}, 23 | {"1.0.0-alpha", "1.0.0", true}, 24 | {"1.0.0-alpha.1", "1.0.0-alpha", true}, 25 | {"1.0.0-alpha.1", "1.0.0-beta", true}, 26 | } 27 | 28 | for _, tt := range tests { 29 | t.Run(tt.a+"_"+tt.b, func(t *testing.T) { 30 | result := util.SemverCompare(tt.a, tt.b) 31 | assert.Equal(t, tt.expected, result < 0) 32 | }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pkg/datasource/health.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/grafana/grafana-plugin-sdk-go/backend" 8 | ) 9 | 10 | // Health consts 11 | const ( 12 | SuccessfulHealthCheckMessage string = "Connection test successful, %v timeseries database(s) found" 13 | ) 14 | 15 | // CheckHealth checks the health by fetching the time series databases 16 | func (d *HistorianDataSource) CheckHealth(ctx context.Context, _ *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) { 17 | databases, err := d.API.GetTimeseriesDatabases(ctx, "") 18 | if err != nil { 19 | return &backend.CheckHealthResult{ 20 | Status: backend.HealthStatusError, 21 | Message: fmt.Sprintf("Error performing health check: %v", err), 22 | }, nil 23 | } 24 | 25 | return &backend.CheckHealthResult{ 26 | Status: backend.HealthStatusOk, 27 | Message: fmt.Sprintf(SuccessfulHealthCheckMessage, len(databases)), 28 | }, nil 29 | } 30 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/actions/pnpm/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Setup pnpm' 2 | description: 'Sets up pnpm for use in other actions' 3 | 4 | runs: 5 | using: 'composite' 6 | steps: 7 | - uses: actions/checkout@v4 8 | - name: Install Node.js 9 | uses: actions/setup-node@v4 10 | with: 11 | node-version: 20 12 | 13 | - uses: pnpm/action-setup@v4 14 | name: Install pnpm 15 | with: 16 | version: 9.6.0 17 | run_install: false 18 | 19 | - name: Get pnpm store directory 20 | shell: bash 21 | run: | 22 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 23 | - uses: actions/cache@v4 24 | name: Setup pnpm cache 25 | with: 26 | path: ${{ env.STORE_PATH }} 27 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 28 | restore-keys: | 29 | ${{ runner.os }}-pnpm-store- 30 | - name: Install dependencies 31 | shell: bash 32 | run: pnpm install --frozen-lockfile 33 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/components/GroupBySection/Group.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Seg } from 'components/util/Seg' 3 | import { SelectableValue } from '@grafana/data' 4 | 5 | type GroupProps = { 6 | group: string 7 | loadOptions: () => Promise 8 | onRemove: () => void 9 | onChange: (group: string) => void 10 | } 11 | 12 | export const Group = ({ group, loadOptions, onRemove, onChange }: GroupProps): JSX.Element => { 13 | const defaultOptions = async () => { 14 | const options = await loadOptions() 15 | return [{ label: '-- remove group by --', value: undefined } as SelectableValue].concat(options) 16 | } 17 | 18 | return ( 19 | defaultOptions()} 23 | onChange={(v) => { 24 | const { value } = v 25 | if (value === undefined) { 26 | onRemove() 27 | } else { 28 | onChange(value ?? '') 29 | } 30 | }} 31 | /> 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /src/util/eventFilter.ts: -------------------------------------------------------------------------------- 1 | import { isFeatureEnabled } from './semver' 2 | 3 | export type KnownOperator = 4 | | '=' 5 | | '<' 6 | | '>' 7 | | '<=' 8 | | '>=' 9 | | '!=' 10 | | 'IN' 11 | | 'NOT IN' 12 | | '~' 13 | | '!~' 14 | | 'IS NULL' 15 | | 'IS NOT NULL' 16 | | 'EXISTS' 17 | | 'NOT EXISTS' 18 | export type KnownCondition = 'AND' | 'OR' 19 | 20 | export const operatorsWithoutValue: KnownOperator[] = ['IS NULL', 'IS NOT NULL', 'EXISTS', 'NOT EXISTS'] 21 | 22 | const basicOperators: KnownOperator[] = ['=', '!=', '<', '<=', '>', '>='] 23 | const v72Operators: KnownOperator[] = ['~', '!~', 'IN', 'NOT IN', 'IS NULL', 'IS NOT NULL', 'EXISTS', 'NOT EXISTS'] 24 | 25 | export function needsValue(operator: KnownOperator): boolean { 26 | return !operatorsWithoutValue.includes(operator) 27 | } 28 | 29 | export function getValueFilterOperatorsForVersion(version: string): KnownOperator[] { 30 | let operators = basicOperators 31 | if (version && isFeatureEnabled(version, '7.2.0', true)) { 32 | operators = operators.concat(v72Operators) 33 | } 34 | 35 | return operators 36 | } 37 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | grafana: 3 | container_name: 'factry-historian-datasource' 4 | cap_add: 5 | - SYS_PTRACE 6 | security_opt: 7 | - 'apparmor:unconfined' 8 | - 'seccomp:unconfined' 9 | environment: 10 | NODE_ENV: development 11 | GF_LOG_FILTERS: plugin.factry-historian-datasource:debug 12 | GF_LOG_LEVEL: debug 13 | GF_DATAPROXY_LOGGING: 1 14 | GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: factry-historian-datasource 15 | GF_FEATURE_TOGGLES_ENABLE: 'sqlExpressions' 16 | build: 17 | context: ./.config 18 | args: 19 | grafana_image: ${GRAFANA_IMAGE:-grafana} 20 | grafana_version: ${GRAFANA_VERSION:-12.3.0} 21 | development: ${DEVELOPMENT:-true} 22 | ports: 23 | - 3001:3000/tcp 24 | - 2345:2345/tcp 25 | volumes: 26 | - ./dist:/var/lib/grafana/plugins/factry-historian-datasource 27 | - ./provisioning:/etc/grafana/provisioning 28 | - .:/root/factry-historian-datasource 29 | - grafana-data:/var/lib/grafana 30 | - go-mod:/root/go/pkg/mod 31 | volumes: 32 | grafana-data: 33 | driver: local 34 | go-mod: 35 | -------------------------------------------------------------------------------- /src/components/Cascader/optionMappings.ts: -------------------------------------------------------------------------------- 1 | import { BaseOptionType as RCCascaderOption } from 'rc-cascader/lib/Cascader' 2 | 3 | import { CascaderOption } from './Cascader' 4 | 5 | type onChangeType = ((values: string[], options: CascaderOption[]) => void) | undefined 6 | 7 | export const onChangeCascader = (onChanged: onChangeType) => (values: Array, options: RCCascaderOption[]) => { 8 | if (onChanged) { 9 | // map values to strings for backwards compatibility with Cascader components 10 | onChanged( 11 | values.map((value) => String(value)), 12 | fromRCOptions(options) 13 | ) 14 | } 15 | } 16 | 17 | type onLoadDataType = ((options: CascaderOption[]) => void) | undefined 18 | 19 | export const onLoadDataCascader = (onLoadData: onLoadDataType) => (options: RCCascaderOption[]) => { 20 | if (onLoadData) { 21 | onLoadData(fromRCOptions(options)) 22 | } 23 | } 24 | 25 | const fromRCOptions = (options: RCCascaderOption[]): CascaderOption[] => { 26 | return options.map(fromRCOption) 27 | } 28 | 29 | const fromRCOption = (option: RCCascaderOption): CascaderOption => { 30 | return { 31 | value: option.value ?? '', 32 | label: option.label as string ?? '', 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/util/MaybeRegexInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent, FormEvent, useState } from 'react' 2 | import { Input, Tooltip } from '@grafana/ui' 3 | import { isRegex, isValidRegex } from 'util/util' 4 | 5 | export interface MaybeRegexInputProps { 6 | onChange: (val: string, valid: boolean) => void 7 | initialValue?: string 8 | placeHolder?: string 9 | width?: number 10 | } 11 | 12 | export function MaybeRegexInput (props: MaybeRegexInputProps) { 13 | const [error, setError] = useState() 14 | 15 | const onChange = (event: FormEvent) => { 16 | const keyword = (event as ChangeEvent).target.value as string 17 | let valid = false 18 | if (isRegex(keyword)) { 19 | if (isValidRegex(keyword)) { 20 | setError(undefined) 21 | valid = true 22 | } else { 23 | setError('Invalid regex') 24 | } 25 | } else { 26 | setError(undefined) 27 | valid = true 28 | } 29 | 30 | props.onChange(keyword, valid) 31 | } 32 | 33 | return ( 34 | <>{error}} 36 | theme="error" 37 | placement="right" 38 | show={error !== undefined} 39 | interactive={false} 40 | > 41 | onChange(e)} width={props.width} /> 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /.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 | id-token: write 17 | attestations: write 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: ./.github/actions/pnpm 22 | - uses: ./.github/actions/grafana 23 | with: 24 | policy_token: ${{ secrets.GRAFANA_POLICY_TOKEN }} 25 | attestation: true 26 | id: build 27 | - name: Upload to Factry Portal 28 | run: | 29 | curl -X POST \ 30 | -H "Authorization: Bearer ${{ secrets.FACTRY_PORTAL_PRODUCT_UPDATES_JWT_TOKEN }}" \ 31 | -F "productType=${{steps.build.outputs.plugin-id}}" \ 32 | -F "product=grafana-datasource" \ 33 | -F "os=any" \ 34 | -F "arch=any" \ 35 | -F "version=${{steps.build.outputs.plugin-version}}" \ 36 | -F "binary=@${{ steps.build.outputs.archive }}" \ 37 | -F "signature=$(cat ${{ steps.build.outputs.archive-sha1sum }})" \ 38 | --url "${{vars.FACTRY_PORTAL_PRODUCT_UPDATES_URL }}" 39 | -------------------------------------------------------------------------------- /pkg/datasource/errors.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import "github.com/pkg/errors" 4 | 5 | // error consts 6 | var ( 7 | ErrorMessageInvalidJSON = errors.New("could not parse json") 8 | ErrorMessageInvalidURL = errors.New("invalid url. Either empty or not set") 9 | ErrorMessageInvalidPort = errors.New("invalid port") 10 | ErrorMessageInvalidUserName = errors.New("username is either empty or not set") 11 | ErrorMessageInvalidPassword = errors.New("password is either empty or not set") 12 | ErrorQueryDataNotImplemented = errors.New("query data not implemented") 13 | ErrorInvalidResourceCallQuery = errors.New("invalid resource query") 14 | ErrorFailedUnmarshalingResourceQuery = errors.New("failed to unmarshal resource query") 15 | ErrorQueryParsingNotImplemented = errors.New("query parsing not implemented yet") 16 | ErrorUnmarshalingSettings = errors.New("error while unmarshaling settings") 17 | ErrorInvalidSentryConfig = errors.New("invalid sentry configuration") 18 | ErrorInvalidAuthToken = errors.New("empty or invalid auth token found") 19 | ErrorInvalidOrganizationSlug = errors.New("invalid or empty organization slug") 20 | ErrorUnknownQueryType = errors.New("unknown query type") 21 | ErrorMessageMissingCredentials = errors.New("no token") 22 | ErrorMessageNoOrganization = errors.New("no organization selected") 23 | ) 24 | -------------------------------------------------------------------------------- /src/components/util/AssetPropertiesSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { MultiSelect } from '@grafana/ui' 3 | import type { SelectableValue } from '@grafana/data' 4 | import { Asset, AssetProperty } from 'types' 5 | 6 | export interface Props { 7 | selectedAssets: Asset[] 8 | assetProperties: AssetProperty[] 9 | initialValue: string[] 10 | templateVariables: Array> 11 | onChange: (values: Array>) => void 12 | onOpenMenu?: () => void 13 | } 14 | 15 | export const AssetProperties = (props: Props): JSX.Element => { 16 | const onSelectProperties = (items: Array>): void => { 17 | props.onChange(items) 18 | } 19 | 20 | const availableProperties = (assets: Asset[]): Array> => { 21 | const properties = props.assetProperties 22 | .filter((e) => props.selectedAssets.find((a) => a.UUID === e.AssetUUID)) 23 | .map((e) => e.Name) 24 | return properties 25 | .filter((value, index, self) => self.indexOf(value) === index) 26 | .map((e) => { 27 | return { label: e, value: e } as SelectableValue 28 | }) 29 | .concat(props.templateVariables) 30 | } 31 | 32 | return ( 33 | <> 34 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/CustomVariableEditor/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import { InlineField, InlineFieldRow, Input } from '@grafana/ui' 2 | import React, { useState } from 'react' 3 | import { labelWidth, Pagination as PaginationType } from 'types' 4 | 5 | export interface PaginationProps { 6 | onChange: (value: PaginationType) => void 7 | initialValue?: PaginationType 8 | tooltipText?: string 9 | } 10 | 11 | export function Pagination (props: PaginationProps) { 12 | const [inputLimit, setInputLimit] = useState(String(props.initialValue?.Limit ?? '')) 13 | 14 | const onLimitChange = (event: React.FocusEvent) => { 15 | const value = event.currentTarget.value 16 | setInputLimit(value) 17 | const parsed = parseInt(value, 10) 18 | if (!isNaN(parsed) && parsed > 0) { 19 | props.onChange({ ...props.initialValue, Limit: parsed, Page: 0 }) 20 | } 21 | } 22 | 23 | return ( 24 | <> 25 | 26 | 32 | setInputLimit(e.currentTarget.value)} 38 | onBlur={onLimitChange} 39 | /> 40 | 41 | 42 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /src/CustomVariableEditor/EventTypeFilter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import { InlineField, InlineFieldRow } from '@grafana/ui' 4 | import { DataSource } from 'datasource' 5 | import { EventTypeFilter, fieldWidth, labelWidth } from 'types' 6 | import { MaybeRegexInput } from 'components/util/MaybeRegexInput' 7 | import { useDebounce } from 'QueryEditor/util' 8 | 9 | export function EventTypeFilterRow (props: { 10 | datasource: DataSource 11 | onChange: (val: EventTypeFilter, valid: boolean) => void 12 | initialValue?: EventTypeFilter 13 | }) { 14 | const [keyword, setKeyword] = useDebounce(props.initialValue?.Keyword ?? '', 500, (value) => 15 | props.onChange( 16 | { 17 | ...props.initialValue, 18 | Keyword: value, 19 | }, 20 | keywordValid 21 | ) 22 | ) 23 | const [keywordValid, setKeywordValid] = useState(true) 24 | 25 | const onKeywordChange = (value: string, valid: boolean) => { 26 | setKeywordValid(valid) 27 | setKeyword(value) 28 | } 29 | return ( 30 | <> 31 | 32 | Searches database by name, to use a regex surround pattern with /} 37 | > 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /src/CustomVariableEditor/DatabaseFilter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | 3 | import { InlineField, InlineFieldRow } from '@grafana/ui' 4 | import { DataSource } from 'datasource' 5 | import { fieldWidth, labelWidth, TimeseriesDatabaseFilter } from 'types' 6 | import { MaybeRegexInput } from 'components/util/MaybeRegexInput' 7 | import { useDebounce } from 'QueryEditor/util' 8 | 9 | export function DatabaseFilterRow(props: { 10 | datasource: DataSource 11 | onChange: (val: TimeseriesDatabaseFilter, valid: boolean) => void 12 | initialValue?: TimeseriesDatabaseFilter 13 | }) { 14 | const [keyword, setKeyword] = useDebounce(props.initialValue?.Keyword ?? '', 500, (value) => 15 | props.onChange( 16 | { 17 | ...props.initialValue, 18 | Keyword: value, 19 | }, 20 | keywordValid 21 | ) 22 | ) 23 | const [keywordValid, setKeywordValid] = useState(true) 24 | 25 | const onKeywordChange = (value: string, valid: boolean) => { 26 | setKeywordValid(valid) 27 | setKeyword(value) 28 | } 29 | return ( 30 | <> 31 | 32 | Searches database by name, to use a regex surround pattern with /} 37 | > 38 | 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/datasource/settings.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | ) 10 | 11 | // Settings - data loaded from grafana settings database 12 | type Settings struct { 13 | URL string `json:"url,omitempty"` 14 | Token string `json:"-,omitempty"` 15 | Organization string `json:"organization,omitempty"` 16 | Timeout string `json:"timeout,omitempty"` 17 | QueryTimeout string `json:"queryTimeout,omitempty"` 18 | InsecureSkipVerify bool `json:"tlsSkipVerify,omitempty"` 19 | } 20 | 21 | func (settings *Settings) isValid() (err error) { 22 | if settings.URL == "" { 23 | return ErrorMessageInvalidURL 24 | } 25 | 26 | if settings.Token == "" { 27 | return ErrorMessageMissingCredentials 28 | } 29 | 30 | if settings.Organization == "" { 31 | return ErrorMessageNoOrganization 32 | } 33 | 34 | return nil 35 | } 36 | 37 | // LoadSettings will read and validate Settings from the DataSourceConfig 38 | func LoadSettings(config backend.DataSourceInstanceSettings) (settings Settings, err error) { 39 | if err := json.Unmarshal(config.JSONData, &settings); err != nil { 40 | return settings, fmt.Errorf("%s: %w", err.Error(), ErrorMessageInvalidJSON) 41 | } 42 | 43 | if strings.TrimSpace(settings.Timeout) == "" { 44 | settings.Timeout = "10" 45 | } 46 | if strings.TrimSpace(settings.QueryTimeout) == "" { 47 | settings.QueryTimeout = "60" 48 | } 49 | settings.Token = config.DecryptedSecureJSONData["token"] 50 | return settings, settings.isValid() 51 | } 52 | -------------------------------------------------------------------------------- /.config/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.github.io/plugin-tools/docs/advanced-configuration#extending-the-jest-config 6 | */ 7 | 8 | const path = require('path'); 9 | const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils'); 10 | 11 | module.exports = { 12 | moduleNameMapper: { 13 | '\\.(css|scss|sass)$': 'identity-obj-proxy', 14 | 'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'), 15 | }, 16 | modulePaths: ['/src'], 17 | setupFilesAfterEnv: ['/jest-setup.js'], 18 | testEnvironment: 'jest-environment-jsdom', 19 | testMatch: [ 20 | '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', 21 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 22 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 23 | ], 24 | transform: { 25 | '^.+\\.(t|j)sx?$': [ 26 | '@swc/jest', 27 | { 28 | sourceMaps: 'inline', 29 | jsc: { 30 | parser: { 31 | syntax: 'typescript', 32 | tsx: true, 33 | decorators: false, 34 | dynamicImport: true, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | // Jest will throw `Cannot use import statement outside module` if it tries to load an 41 | // ES module without it being transformed first. ./config/README.md#esm-errors-with-jest 42 | transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)], 43 | }; 44 | -------------------------------------------------------------------------------- /src/util/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if a given string is formatted as a regular expression. 3 | * 4 | * A string is considered a regular expression if it starts and ends with a '/' character. 5 | * 6 | * @param str - The string to check. 7 | * @returns `true` if the string is formatted as a regular expression, `false` otherwise. 8 | */ 9 | export function isRegex(str: string): boolean { 10 | return str.length >= 2 && str.startsWith('/') && str.endsWith('/') 11 | } 12 | 13 | /** 14 | * Checks if a given string is a valid regular expression. 15 | * 16 | * This function first checks if the string is a valid regex pattern by calling `isRegex`. 17 | * If it is, it attempts to create a new `RegExp` object from the string (excluding the first and last characters). 18 | * If the creation of the `RegExp` object succeeds, the function returns `true`. 19 | * If an error is thrown during the creation of the `RegExp` object, the function returns `false`. 20 | * If the string is not a valid regex pattern, the function returns `false`. 21 | * 22 | * @param str - The string to be checked. 23 | * @returns `true` if the string is a valid regular expression, `false` otherwise. 24 | */ 25 | export function isValidRegex(str: string): boolean { 26 | if (isRegex(str)) { 27 | try { 28 | new RegExp(str.slice(1, -1)) 29 | return true 30 | } catch (e) { 31 | return false 32 | } 33 | } 34 | 35 | return false 36 | } 37 | 38 | const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i 39 | 40 | export function isUUID(str: string): boolean { 41 | return uuidRegex.test(str) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/datasource/util.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "encoding/json" 5 | "maps" 6 | "net/http" 7 | "slices" 8 | 9 | "github.com/grafana/grafana-plugin-sdk-go/data" 10 | ) 11 | 12 | func getStringSetFromFrames(frames data.Frames, fieldName string) []string { 13 | values := map[string]struct{}{} 14 | 15 | for _, frame := range frames { 16 | field, fieldFound := frame.FieldByName(fieldName) 17 | if fieldFound == -1 { 18 | continue 19 | } 20 | 21 | for i := range field.Len() { 22 | value, ok := field.At(i).(*string) 23 | if !ok || value == nil { 24 | continue 25 | } 26 | 27 | values[*value] = struct{}{} 28 | } 29 | } 30 | 31 | return slices.AppendSeq(make([]string, 0, len(values)), maps.Keys(values)) 32 | } 33 | 34 | func handleJSON(f func(http.ResponseWriter, *http.Request) (interface{}, error)) http.HandlerFunc { 35 | return func(rw http.ResponseWriter, req *http.Request) { 36 | response, err := f(rw, req) 37 | if err != nil { 38 | http.Error(rw, err.Error(), http.StatusBadRequest) 39 | return 40 | } 41 | 42 | if response == nil { 43 | http.Error(rw, "received empty response", http.StatusInternalServerError) 44 | return 45 | } 46 | 47 | // Marshal the response to JSON 48 | jsonResponse, err := json.Marshal(response) 49 | if err != nil { 50 | http.Error(rw, err.Error(), http.StatusInternalServerError) 51 | return 52 | } 53 | // Set the content type and write the response 54 | if _, err := rw.Write(jsonResponse); err != nil { 55 | http.Error(rw, err.Error(), http.StatusInternalServerError) 56 | } 57 | rw.Header().Set("Content-Type", "application/json") 58 | rw.WriteHeader(http.StatusOK) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/util/migration.ts: -------------------------------------------------------------------------------- 1 | import { EventTypePropertiesValuesFilter, OldEventTypePropertiesValuesFilter } from 'types' 2 | 3 | function isOldEventTypePropertiesValuesFilter(filter: any): filter is OldEventTypePropertiesValuesFilter { 4 | return 'EventTypePropertyUUID' in filter 5 | } 6 | 7 | /** 8 | * Migrates an old EventTypePropertiesValuesFilter to the current format. 9 | * 10 | * This function detects and transforms filters using the deprecated 11 | * `EventTypePropertyUUID` field into the current format where the UUID 12 | * is moved into the `EventFilter.Properties` array. 13 | * 14 | * @param old - The filter object, possibly in a deprecated format. 15 | * @returns A migrated filter conforming to the current EventTypePropertiesValuesFilter interface. 16 | * 17 | * @remarks 18 | * The old format using `EventTypePropertyUUID` was deprecated in v2.2.0. 19 | * Use EventFilter.Properties[] instead to define filter properties. 20 | */ 21 | export function migrateEventTypePropertiesValuesFilter( 22 | old: OldEventTypePropertiesValuesFilter | EventTypePropertiesValuesFilter | undefined 23 | ): EventTypePropertiesValuesFilter | undefined { 24 | if (!old) { 25 | return undefined 26 | } 27 | 28 | if (!isOldEventTypePropertiesValuesFilter(old)) { 29 | return old 30 | } 31 | 32 | const { EventTypePropertyUUID, ...rest } = old 33 | 34 | const migrated = { 35 | ...rest, 36 | EventFilter: { 37 | ...rest.EventFilter, 38 | Properties: rest.EventFilter?.Properties ?? [], 39 | }, 40 | } 41 | 42 | if (EventTypePropertyUUID && !migrated.EventFilter.Properties.includes(EventTypePropertyUUID)) { 43 | migrated.EventFilter.Properties.push(EventTypePropertyUUID) 44 | } 45 | 46 | return migrated 47 | } 48 | -------------------------------------------------------------------------------- /pkg/datasource/historian.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/factrylabs/factry-historian-datasource.git/pkg/api" 7 | "github.com/go-playground/form" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend" 9 | "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt" 10 | ) 11 | 12 | // Make sure Datasource implements required interfaces. 13 | var ( 14 | _ backend.QueryDataHandler = (*HistorianDataSource)(nil) 15 | _ backend.CheckHealthHandler = (*HistorianDataSource)(nil) 16 | _ backend.CallResourceHandler = (*HistorianDataSource)(nil) 17 | _ instancemgmt.InstanceDisposer = (*HistorianDataSource)(nil) 18 | ) 19 | 20 | // DataSource consts 21 | const ( 22 | PluginID string = "factry-historian-datasource" 23 | DefaultHistorianURL string = "http://127.0.0.1:8000" 24 | ) 25 | 26 | // HistorianDataSource ... 27 | type HistorianDataSource struct { 28 | API *api.API 29 | Decoder *form.Decoder 30 | resourceHandler backend.CallResourceHandler 31 | } 32 | 33 | // NewDataSource creates a new data source instance 34 | func NewDataSource(_ context.Context, s backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) { 35 | settings, err := LoadSettings(s) 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | historianDataSource := &HistorianDataSource{ 41 | Decoder: form.NewDecoder(), 42 | } 43 | historianDataSource.API, err = api.NewAPIWithToken(settings.URL, settings.Token, settings.Organization) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | historianDataSource.resourceHandler = historianDataSource.initializeResourceRoutes() 49 | 50 | return historianDataSource, nil 51 | } 52 | 53 | // Dispose here tells plugin SDK that plugin wants to clean up resources when a new instance is created. 54 | func (*HistorianDataSource) Dispose() {} 55 | -------------------------------------------------------------------------------- /.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 util from 'util'; 6 | import { glob } from 'glob'; 7 | import { SOURCE_DIR } from './constants'; 8 | 9 | export function isWSL() { 10 | if (process.platform !== 'linux') { 11 | return false; 12 | } 13 | 14 | if (os.release().toLowerCase().includes('microsoft')) { 15 | return true; 16 | } 17 | 18 | try { 19 | return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); 20 | } catch { 21 | return false; 22 | } 23 | } 24 | 25 | export function getPackageJson() { 26 | return require(path.resolve(process.cwd(), 'package.json')); 27 | } 28 | 29 | export function getPluginJson() { 30 | return require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`)); 31 | } 32 | 33 | export function hasReadme() { 34 | return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md')); 35 | } 36 | 37 | // Support bundling nested plugins by finding all plugin.json files in src directory 38 | // then checking for a sibling module.[jt]sx? file. 39 | export async function getEntries(): Promise> { 40 | const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true }); 41 | 42 | const plugins = await Promise.all( 43 | pluginsJson.map((pluginJson) => { 44 | const folder = path.dirname(pluginJson); 45 | return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true }); 46 | }) 47 | ); 48 | 49 | return plugins.reduce((result, modules) => { 50 | return modules.reduce((result, module) => { 51 | const pluginPath = path.dirname(module); 52 | const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, ''); 53 | const entryName = pluginName === '' ? 'module' : `${pluginName}/module`; 54 | 55 | result[entryName] = module; 56 | return result; 57 | }, result); 58 | }, {}); 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Factry Historian Logo Factry Historian Datasource 2 | 3 | The Factry Historian datasource plugin for [Grafana](https://grafana.com) allows you to seamlessly visualize time-series and event data collected and stored by [Factry Historian](https://www.factry.io/historian). Connect your Historian instance and start building dashboards with your industrial data in minutes. 4 | 5 | > ⚡ New to Factry Historian? 6 | If you don’t have Factry Historian running yet, no problem! You can quickly trial the software using a [ready-to-run docker-compose setup](https://github.com/factrylabs/historian). It spins up a local Historian instance with a 2-hour runtime limit, perfect for testing the Grafana datasource and exploring your data without installing anything permanently. 7 | 8 | ## Development 9 | 10 | A data source backend plugin consists of both frontend and backend components, frontend components reside in `/src` and backend components reside in `/pkg`. 11 | 12 | ### Prerequisites 13 | 14 | - [Go 1.23.2 or later](https://golang.org/dl/) 15 | - [Mage](https://magefile.org/) 16 | - [Node.js 20 LTS](https://nodejs.org/en/download/) with [pnpm 8](https://pnpm.io/installation) 17 | - [Docker](https://docs.docker.com/get-docker/) 18 | 19 | After starting the application in either debug or normal mode, navigate to [http://localhost:3001](http://localhost:3001) to view the plugin in Grafana. The datasource, along with some dashboards, will be automatically provisioned. 20 | 21 | ### Debug mode 22 | 23 | To run the plugin in debug mode, with filewatchers and hot-reloading enabled: 24 | 25 | ```bash 26 | make clean # optional 27 | make run_debug 28 | ``` 29 | 30 | ### Normal mode 31 | 32 | To run the plugin in normal mode, without filewatchers and hot-reloading: 33 | 34 | ```bash 35 | make clean # optional 36 | make build_all 37 | make run_server 38 | ``` 39 | -------------------------------------------------------------------------------- /src/components/util/DatabaseSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { SelectableValue } from '@grafana/data' 4 | import { AsyncMultiSelect } from '@grafana/ui' 5 | import { DataSource } from 'datasource' 6 | import { TimeseriesDatabaseFilter } from 'types' 7 | import { debouncePromise } from 'QueryEditor/util' 8 | 9 | export interface DatabaseSelectProps { 10 | datasource: DataSource 11 | onChange: (val: string[]) => void 12 | initialValue?: string[] 13 | selectedDatabases: Array> | undefined 14 | setSelectedDatabases: React.Dispatch> | undefined>> 15 | templateVariables: Array> 16 | width?: number 17 | } 18 | 19 | export function DatabaseSelect(props: DatabaseSelectProps) { 20 | let initialLoadDone = false 21 | 22 | const loadDatabaseOptions = async (query: string): Promise>> => { 23 | const filter: TimeseriesDatabaseFilter = { 24 | Keyword: query, 25 | } 26 | const databases = await props.datasource.getTimeseriesDatabases(filter) 27 | const selectableValues = databases 28 | .map((e) => { 29 | return { 30 | label: e.Name, 31 | value: e.UUID, 32 | } as SelectableValue 33 | }) 34 | .concat(props.templateVariables) 35 | if (!initialLoadDone) { 36 | props.setSelectedDatabases(selectableValues.filter((e) => props.initialValue?.includes(e.value ?? ''))) 37 | initialLoadDone = true 38 | } 39 | return selectableValues 40 | } 41 | 42 | const onDatabaseChange = (values: Array>) => { 43 | props.onChange(values.map((e) => e.value ?? '')) 44 | props.setSelectedDatabases(values) 45 | } 46 | 47 | return ( 48 | onDatabaseChange(value)} 51 | defaultOptions 52 | loadOptions={debouncePromise(loadDatabaseOptions, 300)} 53 | value={props.selectedDatabases} 54 | width={props.width} 55 | /> 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /.config/supervisord/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | 5 | [program:grafana] 6 | user=root 7 | directory=/var/lib/grafana 8 | command=bash -c 'while [ ! -f /root/factry-historian-datasource/dist/gpx_factry* ]; do sleep 1; done; /run.sh' 9 | stdout_logfile=/dev/fd/1 10 | stdout_logfile_maxbytes=0 11 | redirect_stderr=true 12 | killasgroup=true 13 | stopasgroup=true 14 | autostart=true 15 | 16 | [program:plugin-watcher] 17 | user=root 18 | # restart delve if the PID for gpx_factry changes 19 | command=/bin/bash -c 'while true; do pid=$(pgrep gpx_factry); if [ "$pid" != "$last_pid" ]; then echo "PID changed, restarting delve..."; supervisorctl restart delve; last_pid=$pid; fi; sleep 1; done' 20 | stdout_logfile=/dev/fd/1 21 | stdout_logfile_maxbytes=0 22 | redirect_stderr=true 23 | killasgroup=true 24 | stopasgroup=true 25 | autostart=true 26 | 27 | [program:delve] 28 | user=root 29 | command=/bin/bash -c 'pid=""; while [ -z "$pid" ]; do pid=$(pgrep gpx_factry); done; /root/go/bin/dlv attach --api-version=2 --headless --continue --accept-multiclient --listen=:2345 $pid' 30 | stdout_logfile=/dev/fd/1 31 | stdout_logfile_maxbytes=0 32 | redirect_stderr=true 33 | killasgroup=false 34 | stopasgroup=false 35 | autostart=true 36 | autorestart=true 37 | startretries=999 38 | 39 | [program:build-watcher] 40 | user=root 41 | command=/bin/bash -c 'while inotifywait -e modify,create,delete -r /var/lib/grafana/plugins/factry-historian-datasource; do echo "Change detected, restarting delve...";supervisorctl restart delve; done' 42 | stdout_logfile=/dev/fd/1 43 | stdout_logfile_maxbytes=0 44 | redirect_stderr=true 45 | killasgroup=true 46 | stopasgroup=true 47 | autostart=true 48 | 49 | [program:mage-watcher] 50 | user=root 51 | environment=PATH="/usr/local/go/bin:/root/go/bin:%(ENV_PATH)s" 52 | directory=/root/factry-historian-datasource 53 | command=/bin/bash -c 'git config --global --add safe.directory /root/factry-historian-datasource && mage -v watch' 54 | stdout_logfile=/dev/fd/1 55 | stdout_logfile_maxbytes=0 56 | redirect_stderr=true 57 | killasgroup=true 58 | stopasgroup=true 59 | autostart=true 60 | -------------------------------------------------------------------------------- /src/components/GroupBySection/GroupBySection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { InlineField } from '@grafana/ui' 3 | import { getTemplateSrv } from '@grafana/runtime' 4 | import { AddButton } from 'components/util/AddButton' 5 | import { Group } from './Group' 6 | import { toSelectableValue } from 'components/TagsSection/util' 7 | 8 | type Props = { 9 | groups: string[] 10 | onChange: (groups: string[]) => void 11 | getTagKeyOptions?: () => Promise 12 | } 13 | 14 | const defaultKeys = () => Promise.resolve(['status']) 15 | 16 | export const GroupBySection = ({ groups, onChange, getTagKeyOptions = defaultKeys }: Props): JSX.Element => { 17 | const templateVariables = getTemplateSrv() 18 | .getVariables() 19 | .map((e) => { 20 | return { label: `$${e.name}`, value: `$${e.name}` } 21 | }) 22 | 23 | const onGroupChange = (newGroup: string, index: number) => { 24 | const newGroups = groups.map((group, i) => { 25 | return index === i ? newGroup : group 26 | }) 27 | onChange(newGroups) 28 | } 29 | 30 | const onGroupRemove = (index: number) => { 31 | const newGroups = groups.filter((t, i) => i !== index) 32 | onChange(newGroups) 33 | } 34 | 35 | const addNewGroup = (group: string) => { 36 | onChange([...groups, group]) 37 | } 38 | 39 | const getGroupByOptions = async () => { 40 | const tags = await getTagKeyOptions() 41 | return tags.map(toSelectableValue).concat(templateVariables) 42 | } 43 | 44 | return ( 45 | <> 46 | {groups.map((t, i) => { 47 | return ( 48 | 49 | onGroupChange(newGroup || '', i)} 53 | onRemove={() => onGroupRemove(i)} 54 | /> 55 | 56 | ) 57 | })} 58 | 59 | { 63 | addNewGroup(v) 64 | }} 65 | /> 66 | 67 | 68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /src/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/grafana/grafana/master/docs/sources/developers/plugins/plugin.schema.json", 3 | "type": "datasource", 4 | "name": "Factry Historian Datasource", 5 | "id": "factry-historian-datasource", 6 | "metrics": true, 7 | "backend": true, 8 | "executable": "gpx_factry-historian-datasource", 9 | "alerting": true, 10 | "annotations": true, 11 | "category": "tsdb", 12 | "info": { 13 | "description": "A datasource plugin for Factry Historian", 14 | "author": { 15 | "name": "Factry", 16 | "url": "https://www.factry.io" 17 | }, 18 | "keywords": [ 19 | "historian", 20 | "factry" 21 | ], 22 | "logos": { 23 | "small": "img/logo.svg", 24 | "large": "img/logo.svg" 25 | }, 26 | "links": [ 27 | { 28 | "name": "GitHub", 29 | "url": "https://github.com/factrylabs/factry-historian-datasource" 30 | }, 31 | { 32 | "name": "Website", 33 | "url": "https://factry.io" 34 | }, 35 | { 36 | "name": "License", 37 | "url": "https://github.com/factrylabs/factry-historian-datasource/blob/0fd287221ba1e57c87fbdfd5d570e6537296c8c6/LICENSE" 38 | }, 39 | { 40 | "name": "Documentation", 41 | "url": "https://docs.factry.io/reference/factry-historian-datasource-plugin-for-grafana" 42 | }, 43 | { 44 | "name": "Changelog", 45 | "url": "https://docs.factry.io/changelog/factry-historian-datasource-for-grafana" 46 | } 47 | ], 48 | "screenshots": [ 49 | { 50 | "name": "Events query example", 51 | "path": "img/Screen2.png" 52 | }, 53 | { 54 | "name": "Measurements query example", 55 | "path": "img/Screen3.png" 56 | }, 57 | { 58 | "name": "Asset properties query example", 59 | "path": "img/Screen4.png" 60 | }, 61 | { 62 | "name": "Historian asset tree example", 63 | "path": "img/HistorianUI.png" 64 | } 65 | ], 66 | "version": "%VERSION%", 67 | "updated": "%TODAY%" 68 | }, 69 | "dependencies": { 70 | "grafanaDependency": ">=11.6.0", 71 | "plugins": [] 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/schemas/events.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/grafana/grafana-plugin-sdk-go/backend" 8 | ) 9 | 10 | // EventSource enum for the source of an event 11 | type EventSource string 12 | 13 | // EventStatus enum for the status of an event 14 | type EventStatus string 15 | 16 | // EventSources 17 | const ( 18 | EventSourceManual EventSource = "manual" 19 | EventSourceAutomatic EventSource = "auto" 20 | ) 21 | 22 | // EventStatus 23 | const ( 24 | EventStatusProcessed EventStatus = "processed" 25 | EventStatusOpen EventStatus = "open" 26 | EventStatusIncomplete EventStatus = "incomplete" 27 | ) 28 | 29 | // Event has the fields of an event that are used by the data source 30 | type Event struct { 31 | StartTime time.Time 32 | StopTime *time.Time 33 | ParentUUID *uuid.UUID 34 | Properties *EventProperties 35 | Parent *Event 36 | Source EventSource 37 | Status EventStatus 38 | UUID uuid.UUID 39 | AssetUUID uuid.UUID 40 | EventTypeUUID uuid.UUID 41 | EventConfigurationUUID uuid.UUID 42 | } 43 | 44 | // EventFilter is used to filter events 45 | type EventFilter struct { 46 | StartTime *time.Time 47 | StopTime *time.Time 48 | AssetUUIDs []uuid.UUID 49 | EventTypeUUIDs []uuid.UUID 50 | Status []string 51 | EventConfigurations []uuid.UUID 52 | PropertyFilter []EventPropertyValueFilter 53 | Limit int 54 | ExcludeManualEvents bool 55 | Ascending bool 56 | PreloadProperties bool 57 | } 58 | 59 | // EventProperties is the database representation of event properties 60 | type EventProperties struct { 61 | Properties Attributes 62 | EventUUID uuid.UUID 63 | } 64 | 65 | // EventPropertyValueFilter us used to filter event property values 66 | type EventPropertyValueFilter struct { 67 | Property string 68 | Datatype string 69 | Value []interface{} 70 | Operator string 71 | Condition string 72 | Parent bool 73 | } 74 | 75 | // EventPropertyValuesRequest is a request for event property values 76 | type EventPropertyValuesRequest struct { 77 | EventQuery 78 | HistorianInfo 79 | backend.TimeRange 80 | } 81 | 82 | // EventPropertyValues is a list of event property values 83 | type EventPropertyValues []interface{} 84 | -------------------------------------------------------------------------------- /pkg/schemas/queries.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import "time" 4 | 5 | // MeasurementQueryOptions are measurement query options 6 | type MeasurementQueryOptions struct { 7 | Tags map[string]string 8 | Aggregation *Aggregation 9 | Limit *int 10 | GroupBy []string 11 | IncludeLastKnownPoint bool 12 | FillInitialEmptyValues bool 13 | UseEngineeringSpecs bool 14 | DisplayDatabaseName bool 15 | DisplayDescription bool 16 | MetadataAsLabels bool 17 | ChangesOnly bool 18 | TruncateInterval bool 19 | ValueFilters []ValueFilter 20 | Datatypes []string 21 | } 22 | 23 | // ValueFilter is used to filter the values returned by the historian 24 | type ValueFilter struct { 25 | Value interface{} 26 | Operator string 27 | Condition string 28 | } 29 | 30 | // AssetMeasurementQuery is used to build the time series query to send to the historian 31 | type AssetMeasurementQuery struct { 32 | Assets []string 33 | AssetProperties []string 34 | Options MeasurementQueryOptions 35 | } 36 | 37 | // MeasurementQuery is used to build the time series query to send to the historian 38 | type MeasurementQuery struct { 39 | Databases []string 40 | Measurement string 41 | Measurements []string 42 | Options MeasurementQueryOptions 43 | Regex string 44 | IsRegex bool 45 | } 46 | 47 | // RawQuery is used to query raw time series data 48 | type RawQuery struct { 49 | Query string 50 | Format Format 51 | TimeseriesDatabase string 52 | } 53 | 54 | // EventQuery is used to query events 55 | type EventQuery struct { 56 | Type string 57 | Assets []string 58 | EventTypes []string 59 | Statuses []string 60 | Properties []string 61 | PropertyFilter []EventPropertyValueFilter 62 | IncludeParentInfo bool 63 | QueryAssetProperties bool 64 | OverrideAssets []string 65 | AssetProperties []string 66 | Options *MeasurementQueryOptions 67 | Limit int 68 | OverrideTimeRange bool `json:"overrideTimeRange"` 69 | TimeRange TimeRange 70 | } 71 | 72 | // TimeRange contains a user-defined time range that can be used to override the grafana dashboard time range 73 | type TimeRange struct { 74 | From *time.Time `json:"fromParsed,omitempty"` 75 | To *time.Time `json:"toParsed,omitempty"` 76 | } 77 | -------------------------------------------------------------------------------- /.config/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG grafana_version=latest 2 | ARG grafana_image=grafana-oss 3 | 4 | FROM --platform=$BUILDPLATFORM grafana/${grafana_image}:${grafana_version} 5 | 6 | ARG development=true 7 | ARG GO_VERSION=1.23.2 8 | 9 | RUN 10 | 11 | ENV DEV "${development}" 12 | 13 | # Make it as simple as possible to access the grafana instance for development purposes 14 | # Do NOT enable these settings in a public facing / production grafana instance 15 | ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin" 16 | ENV GF_AUTH_ANONYMOUS_ENABLED "true" 17 | ENV GF_AUTH_BASIC_ENABLED "false" 18 | # Set development mode so plugins can be loaded without the need to sign 19 | ENV GF_DEFAULT_APP_MODE "development" 20 | 21 | 22 | LABEL maintainer="Grafana Labs " 23 | 24 | ENV GF_PATHS_HOME="/usr/share/grafana" 25 | WORKDIR $GF_PATHS_HOME 26 | 27 | USER root 28 | 29 | # Installing supervisor and inotify-tools 30 | RUN if [ "${development}" = "true" ]; then \ 31 | if grep -i -q alpine /etc/issue; then \ 32 | apk add supervisor inotify-tools git; \ 33 | elif grep -i -q ubuntu /etc/issue; then \ 34 | DEBIAN_FRONTEND=noninteractive && \ 35 | apt-get update && \ 36 | apt-get install -y supervisor inotify-tools git && \ 37 | rm -rf /var/lib/apt/lists/*; \ 38 | else \ 39 | echo 'ERROR: Unsupported base image' && /bin/false; \ 40 | fi \ 41 | fi 42 | 43 | COPY supervisord/supervisord.conf /etc/supervisor.d/supervisord.ini 44 | COPY supervisord/supervisord.conf /etc/supervisor/conf.d/supervisord.conf 45 | 46 | 47 | # Installing Go 48 | RUN if [ "${development}" = "true" ]; then \ 49 | GO_ARCH=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/); \ 50 | curl -O -L https://golang.org/dl/go${GO_VERSION}.linux-${GO_ARCH}.tar.gz && \ 51 | rm -rf /usr/local/go && \ 52 | tar -C /usr/local -xzf go${GO_VERSION}.linux-${GO_ARCH}.tar.gz && \ 53 | echo "export PATH=$PATH:/usr/local/go/bin:~/go/bin" >> ~/.bashrc && \ 54 | rm -f go${GO_VERSION}.linux-${GO_ARCH}.tar.gz; \ 55 | fi 56 | 57 | # Installing delve for debugging 58 | RUN if [ "${development}" = "true" ]; then \ 59 | /usr/local/go/bin/go install github.com/go-delve/delve/cmd/dlv@latest; \ 60 | fi 61 | 62 | # Installing mage for plugin (re)building 63 | RUN if [ "${development}" = "true" ]; then \ 64 | git clone https://github.com/magefile/mage; \ 65 | cd mage; \ 66 | export PATH=$PATH:/usr/local/go/bin; \ 67 | go run bootstrap.go; \ 68 | fi 69 | 70 | # Inject livereload script into grafana index.html 71 | RUN sed -i 's|||g' /usr/share/grafana/public/views/index.html 72 | 73 | 74 | COPY entrypoint.sh /entrypoint.sh 75 | RUN chmod +x /entrypoint.sh 76 | ENTRYPOINT ["/entrypoint.sh"] 77 | -------------------------------------------------------------------------------- /src/components/Autocomplete/Autocomplete.tsx: -------------------------------------------------------------------------------- 1 | // based on https://www.digitalocean.com/community/tutorials/react-react-autocomplete 2 | import { css } from '@emotion/css' 3 | import React from 'react' 4 | 5 | import { GrafanaTheme2 } from '@grafana/data' 6 | import { useStyles2 } from '@grafana/ui' 7 | 8 | export interface Props { 9 | showSuggestions: boolean 10 | activeLabel: string 11 | filteredSuggestions: string[] 12 | activeSuggestion: number 13 | focusCascade: boolean 14 | onClickSuggestion: (e: any) => void 15 | } 16 | 17 | export const Autocomplete = ({ 18 | showSuggestions, 19 | activeLabel, 20 | filteredSuggestions, 21 | activeSuggestion, 22 | focusCascade, 23 | onClickSuggestion, 24 | }: Props): JSX.Element | null => { 25 | const styles = useStyles2(getStyles) 26 | let suggestionsListComponent = null 27 | if (showSuggestions && activeLabel && !focusCascade) { 28 | if (filteredSuggestions.length) { 29 | suggestionsListComponent = ( 30 |
    31 | {filteredSuggestions.map((suggestion, index) => { 32 | let className = styles.option 33 | 34 | // Flag the active suggestion with a class 35 | if (index === activeSuggestion) { 36 | className += ' ' + styles.optionSelected 37 | } 38 | return ( 39 |
  • 40 | {suggestion} 41 |
  • 42 | ) 43 | })} 44 |
45 | ) 46 | } 47 | } 48 | 49 | return suggestionsListComponent 50 | } 51 | 52 | const getStyles = (theme: GrafanaTheme2) => { 53 | return { 54 | container: css` 55 | background-color: ${theme.colors.background.secondary}; 56 | min-width: 350px; 57 | border-radius: ${theme.shape.borderRadius(2)}; 58 | margin-bottom: ${theme.spacing(4)}; 59 | list-style: none; 60 | position: absolute; 61 | z-index: 1000; 62 | `, 63 | no_suggestions: css` 64 | background-color: ${theme.colors.background.secondary}; 65 | min-width: 350px; 66 | border-radius: ${theme.shape.borderRadius(2)}; 67 | margin-bottom: ${theme.spacing(4)}; 68 | color: #999; 69 | padding: 0.5rem; 70 | `, 71 | option: css` 72 | label: grafana-select-option; 73 | padding: 8px; 74 | display: flex; 75 | align-items: center; 76 | flex-direction: row; 77 | flex-shrink: 0; 78 | white-space: nowrap; 79 | cursor: pointer; 80 | border-left: 2px solid transparent; 81 | &:hover { 82 | background: ${theme.colors.action.hover}; 83 | } 84 | `, 85 | optionSelected: css` 86 | background: ${theme.colors.action.selected}; 87 | `, 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /pkg/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | // Unique checks if all values in the array are unique 11 | func Unique[T comparable](arr []T) bool { 12 | seen := make(map[T]bool) 13 | for _, str := range arr { 14 | if seen[str] { 15 | return false 16 | } 17 | seen[str] = true 18 | } 19 | return true 20 | } 21 | 22 | // Model is an interface for objects with a UUID field 23 | type Model interface { 24 | // GetUUID returns the UUID of the object 25 | GetUUID() uuid.UUID 26 | } 27 | 28 | // ByUUID converts a slice of objects with a UUID field to a map with the UUID as key 29 | func ByUUID[T Model](arr []T) map[uuid.UUID]T { 30 | return ByFunc(arr, func(obj T) (uuid.UUID, bool) { 31 | return obj.GetUUID(), true 32 | }) 33 | } 34 | 35 | // ByFunc converts a slice of objects to a map, using the provided key selector 36 | func ByFunc[T any, K comparable](arr []T, keySelector func(T) (K, bool)) map[K]T { 37 | m := make(map[K]T) 38 | for _, obj := range arr { 39 | if key, ok := keySelector(obj); ok { 40 | m[key] = obj 41 | } 42 | } 43 | return m 44 | } 45 | 46 | // GetUUIDs returns a slice of GetUUIDs from a slice of objects with a UUID field 47 | func GetUUIDs[T Model](arr []T) []uuid.UUID { 48 | uuids := make([]uuid.UUID, len(arr)) 49 | for i, obj := range arr { 50 | uuids[i] = obj.GetUUID() 51 | } 52 | return uuids 53 | } 54 | 55 | // DeepCopy performs a deep copy 56 | func DeepCopy[T any](dst *T, src T) error { 57 | data, err := json.Marshal(src) 58 | if err != nil { 59 | return err 60 | } 61 | 62 | return json.Unmarshal(data, dst) 63 | } 64 | 65 | // MarshalStructToMap marshals a struct into a map[string]interface{} 66 | func MarshalStructToMap(input interface{}) map[string]interface{} { 67 | result := make(map[string]interface{}) 68 | 69 | // Get the reflection of the input struct 70 | value := reflect.ValueOf(input) 71 | typ := reflect.TypeOf(input) 72 | 73 | // Ensure the input is a struct 74 | if value.Kind() != reflect.Struct { 75 | return result 76 | } 77 | 78 | // Iterate over struct fields 79 | for i := 0; i < value.NumField(); i++ { 80 | if !typ.Field(i).IsExported() { 81 | continue 82 | } 83 | 84 | fieldName := typ.Field(i).Name 85 | 86 | fieldValue := value.Field(i).Interface() 87 | 88 | // Add the field name and value to the map 89 | result[fieldName] = fieldValue 90 | } 91 | 92 | return result 93 | } 94 | 95 | // Ptr returns a pointer to the provided value 96 | func Ptr[T any](v T) *T { 97 | return &v 98 | } 99 | 100 | // PtrSlice returns a slice of pointers to the provided values 101 | func PtrSlice[T any](v []T) []*T { 102 | ptrs := make([]*T, len(v)) 103 | for i := range v { 104 | ptrs[i] = &v[i] 105 | } 106 | return ptrs 107 | } 108 | -------------------------------------------------------------------------------- /src/CustomVariableEditor/PropertyValuesFilter.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEvent } from 'react' 2 | 3 | import { DataSource } from 'datasource' 4 | import { EventQuery, EventTypePropertiesValuesFilter, HistorianInfo, TimeRange, labelWidth } from 'types' 5 | import { SelectableValue } from '@grafana/data' 6 | import { EventFilter } from 'QueryEditor/EventFilter' 7 | import { InlineField, InlineFieldRow, InlineSwitch } from '@grafana/ui' 8 | import { DateRangePicker } from 'components/util/DateRangePicker' 9 | 10 | export function PropertyValuesFilterRow (props: { 11 | datasource: DataSource 12 | onChange: (val: EventTypePropertiesValuesFilter) => void 13 | initialValue?: EventTypePropertiesValuesFilter 14 | templateVariables: Array> 15 | historianInfo: HistorianInfo | undefined 16 | }) { 17 | const onChangeEventFilter = (query: EventQuery) => { 18 | props.onChange({ 19 | ...props.initialValue, 20 | EventFilter: { 21 | ...props.initialValue?.EventFilter!, 22 | ...query, 23 | }, 24 | HistorianInfo: props.historianInfo, 25 | }) 26 | } 27 | 28 | const onChangeTimeRange = (value: TimeRange): void => { 29 | props.onChange({ 30 | ...props.initialValue, 31 | EventFilter: { 32 | ...props.initialValue?.EventFilter!, 33 | TimeRange: value, 34 | }, 35 | HistorianInfo: props.historianInfo, 36 | }) 37 | } 38 | 39 | const onChangeOverrideTimeRange = (event: ChangeEvent): void => { 40 | props.onChange({ 41 | ...props.initialValue, 42 | EventFilter: { 43 | ...props.initialValue?.EventFilter!, 44 | OverrideTimeRange: event.target.checked, 45 | }, 46 | HistorianInfo: props.historianInfo, 47 | }) 48 | } 49 | 50 | return !props.initialValue ? ( 51 | <> 52 | ) : ( 53 | <> 54 | 61 | 62 | 63 | 64 |
65 | 70 |
71 |
72 |
73 | {props.initialValue?.EventFilter.OverrideTimeRange && ( 74 | 80 | )} 81 | 82 | ) 83 | } 84 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "factry-historian-datasource", 3 | "version": "3.0.0", 4 | "description": "A datasource plugin for Factry Historian", 5 | "scripts": { 6 | "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 7 | "build:dev": "webpack -c ./.config/webpack/webpack.config.ts --env development", 8 | "dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 9 | "test": "jest --watch --onlyChanged", 10 | "test:ci": "jest --passWithNoTests --maxWorkers 4", 11 | "typecheck": "tsc --noEmit", 12 | "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", 13 | "lint:fix": "npm run lint -- --fix", 14 | "e2e": "playwright test", 15 | "server": "docker-compose up --build", 16 | "sign": "npx --yes @grafana/sign-plugin@latest" 17 | }, 18 | "author": "Factry", 19 | "license": "Apache-2.0", 20 | "devDependencies": { 21 | "@babel/core": "^7.28.4", 22 | "@grafana/eslint-config": "^6.0.1", 23 | "@grafana/tsconfig": "1.2.0-rc1", 24 | "@swc/core": "1.3.75", 25 | "@swc/helpers": "^0.5.17", 26 | "@swc/jest": "^0.2.39", 27 | "@testing-library/jest-dom": "^5.17.0", 28 | "@testing-library/react": "^14.x", 29 | "@types/debounce-promise": "^3.1.9", 30 | "@types/jest": "^29.5.14", 31 | "@types/lodash": "^4.17.20", 32 | "@types/node": "^18.19.130", 33 | "@types/react": "^18", 34 | "@types/react-dom": "^18", 35 | "@typescript-eslint/parser": "^5.62.0", 36 | "copy-webpack-plugin": "^11.0.0", 37 | "css-loader": "^6.11.0", 38 | "eslint-config-prettier": "^8.10.2", 39 | "eslint-plugin-prettier": "^4.2.5", 40 | "eslint-webpack-plugin": "^4.2.0", 41 | "fork-ts-checker-webpack-plugin": "^8.0.0", 42 | "glob": "^10.5.0", 43 | "identity-obj-proxy": "3.0.0", 44 | "jest": "^29.7.0", 45 | "jest-environment-jsdom": "^29.7.0", 46 | "prettier": "^2.8.8", 47 | "replace-in-file-webpack-plugin": "^1.0.6", 48 | "sass": "1.63.2", 49 | "sass-loader": "13.3.1", 50 | "style-loader": "3.3.3", 51 | "swc-loader": "^0.2.6", 52 | "ts-node": "^10.9.2", 53 | "tsconfig-paths": "^4.2.0", 54 | "typescript": "5.9.2", 55 | "webpack": "^5.102.1", 56 | "webpack-cli": "^5.1.4", 57 | "webpack-livereload-plugin": "^3.0.2" 58 | }, 59 | "resolutions": { 60 | "rxjs": "7.3.0", 61 | "form-data": "4.0.4" 62 | }, 63 | "engines": { 64 | "node": ">=14" 65 | }, 66 | "dependencies": { 67 | "@emotion/css": "^11.13.5", 68 | "@grafana/data": "~11.6.0", 69 | "@grafana/plugin-e2e": "^1.19.9", 70 | "@grafana/runtime": "~11.6.0", 71 | "@grafana/schema": "~11.6.0", 72 | "@grafana/ui": "~11.6.0", 73 | "debounce-promise": "^3.1.2", 74 | "downshift": "^9.0.10", 75 | "react": "^18.3.1", 76 | "react-dom": "^18.3.1", 77 | "tslib": "2.5.3" 78 | }, 79 | "packageManager": "pnpm@9.6.0", 80 | "pnpm": { 81 | "overrides": { 82 | "form-data": "4.0.4", 83 | "rxjs": "7.3.0", 84 | "uplot": "1.6.31" 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/AnnotationsQueryEditor/AnnotationsQueryEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { QueryEditorProps } from '@grafana/data' 3 | import { getTemplateSrv } from '@grafana/runtime' 4 | import { Events } from 'QueryEditor/Events' 5 | import { DataSource } from 'datasource' 6 | import { HistorianDataSourceOptions, Query, EventQuery, PropertyType } from 'types' 7 | 8 | type Props = QueryEditorProps 9 | 10 | export class AnnotationsQueryEditor extends Component { 11 | constructor(props: QueryEditorProps) { 12 | super(props) 13 | this.onChangeEventQuery = this.onChangeEventQuery.bind(this) 14 | this.onChangeSeriesLimit = this.onChangeSeriesLimit.bind(this) 15 | } 16 | 17 | templateVariables = getTemplateSrv() 18 | .getVariables() 19 | .map((e) => { 20 | return { label: `$${e.name}`, value: `$${e.name}` } 21 | }) 22 | 23 | async componentDidMount (): Promise { 24 | const { query } = this.props 25 | 26 | try { 27 | await this.props.datasource.refreshInfo() 28 | } catch (_) { } 29 | if (!query.query) { 30 | this.onChangeEventQuery({ 31 | Type: PropertyType.Simple, 32 | Assets: [], 33 | Statuses: [], 34 | PropertyFilter: [], 35 | EventTypes: [], 36 | Properties: [], 37 | QueryAssetProperties: false, 38 | OverrideAssets: [], 39 | OverrideTimeRange: false, 40 | TimeRange: { from: '', to: '' }, 41 | }) 42 | } 43 | } 44 | 45 | onChangeEventQuery (eventQuery: EventQuery): void { 46 | const { onChange, query } = this.props 47 | query.queryType = 'EventQuery' 48 | query.query = eventQuery 49 | query.historianInfo = this.props.datasource.historianInfo 50 | onChange(query) 51 | this.onRunQuery(this.props) 52 | } 53 | 54 | onChangeSeriesLimit (value: number): void { 55 | const { onChange, query } = this.props 56 | query.seriesLimit = value 57 | query.historianInfo = this.props.datasource.historianInfo 58 | onChange(query) 59 | this.onRunQuery(this.props) 60 | } 61 | 62 | onRunQuery ( 63 | props: Readonly & 64 | Readonly<{ 65 | children?: React.ReactNode 66 | }> 67 | ) { 68 | if (!props.query.queryType) { 69 | return 70 | } 71 | 72 | if (props.query.queryType === 'EventQuery') { 73 | const query = props.query.query as EventQuery 74 | if (!query?.EventTypes || !query?.Assets) { 75 | return 76 | } 77 | } 78 | 79 | this.props.onRunQuery() 80 | } 81 | 82 | render () { 83 | return ( 84 | <> 85 | {this.props.query.query && ( 86 | 94 | )} 95 | 96 | ) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pkg/schemas/attributes.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/spf13/cast" 8 | ) 9 | 10 | // Attributes is a custom type for Handling the postgres JSONB datatype 11 | type Attributes map[string]interface{} 12 | 13 | // GetString returns string value for key 14 | func (a Attributes) GetString(key string) string { 15 | val, ok := a[key] 16 | if !ok || val == nil { 17 | return "" 18 | } 19 | 20 | return cast.ToString(val) 21 | } 22 | 23 | // GetInt returns a int value for a key 24 | func (a Attributes) GetInt(key string) (int, error) { 25 | emptyInt := 0 26 | val, ok := a[key] 27 | if !ok || val == nil { 28 | return emptyInt, fmt.Errorf("failed to get value from key: %v", key) 29 | } 30 | 31 | return cast.ToIntE(val) 32 | } 33 | 34 | // GetFloat64 returns a float64 value for a key 35 | func (a Attributes) GetFloat64(key string) (float64, error) { 36 | emptyFloat := 0.0 37 | val, ok := a[key] 38 | if !ok || val == nil { 39 | return emptyFloat, fmt.Errorf("failed to get value from key: %v", key) 40 | } 41 | 42 | return cast.ToFloat64E(val) 43 | } 44 | 45 | // GetBool returns a bool value for a key 46 | func (a Attributes) GetBool(key string) (bool, error) { 47 | val, ok := a[key] 48 | if !ok || val == nil { 49 | return false, fmt.Errorf("failed to get value from key: %v", key) 50 | } 51 | 52 | return cast.ToBoolE(val) 53 | } 54 | 55 | // GetSlice returns a slice value for a key 56 | func (a Attributes) GetSlice(key string) ([]interface{}, error) { 57 | val, ok := a[key] 58 | if !ok || val == nil { 59 | return nil, fmt.Errorf("failed to get value from key: %v", key) 60 | } 61 | 62 | return cast.ToSliceE(val) 63 | } 64 | 65 | // GetMap returns a map value for a key 66 | func (a Attributes) GetMap(key string) (map[string]interface{}, error) { 67 | val, ok := a[key] 68 | if !ok || val == nil { 69 | return nil, fmt.Errorf("failed to get value from key: %v", key) 70 | } 71 | 72 | if result, ok := a[key].(Attributes); ok { 73 | return result, nil 74 | } 75 | 76 | return cast.ToStringMapE(val) 77 | } 78 | 79 | // GetAttributes returns an Attributes value for a key 80 | func (a Attributes) GetAttributes(key string) (Attributes, error) { 81 | return a.GetMap(key) 82 | } 83 | 84 | // AttributesArray is an array of Attributes 85 | type AttributesArray []map[string]interface{} 86 | 87 | // Get tries to parse an interface from the map 88 | func (a Attributes) Get(key string, object interface{}) error { 89 | value, ok := a[key] 90 | if !ok { 91 | return fmt.Errorf("error getting %s from attributes: key not found", key) 92 | } 93 | 94 | jsonbody, err := json.Marshal(value) 95 | if err != nil { 96 | return fmt.Errorf("error getting %s from attributes: %v", key, err) 97 | } 98 | 99 | if err := json.Unmarshal(jsonbody, &object); err != nil { 100 | return fmt.Errorf("error getting %s from attributes: %v", key, err) 101 | } 102 | return nil 103 | } 104 | 105 | // UnMarshal tries to parse an interface from the map 106 | func (a Attributes) UnMarshal(object interface{}) error { 107 | jsonbody, err := json.Marshal(a) 108 | if err != nil { 109 | return fmt.Errorf("error unmarshalling attributes: %v", err) 110 | } 111 | 112 | if err := json.Unmarshal(jsonbody, &object); err != nil { 113 | return fmt.Errorf("error unmarshalling attributes: %v", err) 114 | } 115 | return nil 116 | } 117 | -------------------------------------------------------------------------------- /pkg/schemas/timeseries.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Format is a string 8 | type Format string 9 | 10 | // Formats is a list of possible formats to return 11 | const ( 12 | ArrowFormat = "arrow" 13 | ) 14 | 15 | // AggregationType is a string 16 | type AggregationType string 17 | 18 | // AggregationTypes is a list of supported aggregations 19 | const ( 20 | Count AggregationType = "count" 21 | Integral AggregationType = "integral" 22 | Mean AggregationType = "mean" 23 | Median AggregationType = "median" 24 | Mode AggregationType = "mode" 25 | Spread AggregationType = "spread" 26 | Stddev AggregationType = "stddev" 27 | Sum AggregationType = "sum" 28 | First AggregationType = "first" 29 | Last AggregationType = "last" 30 | Max AggregationType = "max" 31 | Min AggregationType = "min" 32 | TWA AggregationType = "twa" 33 | ) 34 | 35 | // FillType is a string 36 | type FillType string 37 | 38 | // FillType is a list of supported fill types 39 | const ( 40 | None FillType = "none" 41 | Null FillType = "null" 42 | Previous FillType = "previous" 43 | Linear FillType = "linear" 44 | Zero FillType = "0" 45 | ) 46 | 47 | // MeasurementByName is used to identify which measurement to query 48 | // @Description Identifier for a measurement 49 | type MeasurementByName struct { 50 | // The database in which we can find the measurement 51 | Database string `validate:"required" example:"historian"` 52 | // The name of the measurement 53 | Measurement string `validate:"required" example:"ghent.line_1.motor_2.speed"` 54 | } 55 | 56 | // Aggregation defines an aggregation function 57 | // @Description Aggregation function for time series data 58 | type Aggregation struct { 59 | // The aggregation function to use 60 | Name AggregationType `validate:"required" example:"mean"` 61 | // The period on which to aggregate the time series data of none is given the aggregation spans the given time period 62 | Period string `example:"5m"` 63 | // Optional arguments for the aggregation function 64 | Arguments []interface{} 65 | // What to do when there is no data in the aggregation window 66 | Fill FillType `example:"null"` 67 | } 68 | 69 | // Query contains all required parameters to perform a query 70 | // @Description A query object is used to describe a query to 71 | // @Description retrieve time series data. 72 | type Query struct { 73 | // An array of measurements to query defined by uuid 74 | MeasurementUUIDs []string `validate:"required_without=Measurements"` 75 | // An array of measurements to query defined by database name and measurement name 76 | Measurements []MeasurementByName `validate:"required_without=MeasurementUUIDs"` 77 | // The start time of the query (inclusive) 78 | Start time.Time `validate:"required"` 79 | // The end time of the query (not inclusive) 80 | End *time.Time 81 | // A list of key value pairs to filter on 82 | Tags map[string]string 83 | // An array of tags to group by 84 | GroupBy []string 85 | // The maximum number of records to return per measurement, 0 for no limit 86 | Limit int 87 | // The offset for query 88 | Offset int 89 | // The aggregate function to call 90 | Aggregation *Aggregation 91 | // Reverse the sort order of records, normally ascending by timestamp 92 | Desc bool 93 | // Join will join the results on time filling in null values so a data point will be available for every timestamp 94 | Join bool 95 | // Format 96 | Format Format `json:",omitempty"` 97 | ValueFilters []ValueFilter 98 | } 99 | -------------------------------------------------------------------------------- /src/components/TagsSection/TagsSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { getTemplateSrv } from '@grafana/runtime' 3 | import { AddButton } from 'components/util/AddButton' 4 | import { Tag } from './Tag' 5 | import { getCondition, getOperator, toSelectableValue } from './util' 6 | import { KnownCondition, KnownOperator } from '../../util/eventFilter' 7 | 8 | export interface QueryTag { 9 | key: string 10 | operator?: string 11 | condition?: string 12 | value: string 13 | } 14 | 15 | type Props = { 16 | tags: QueryTag[] 17 | operators?: KnownOperator[] 18 | conditions?: KnownCondition[] 19 | placeholder?: string 20 | onChange: (tags: QueryTag[]) => void 21 | getTagKeyOptions?: () => Promise 22 | getTagValueOptions?: (key: string) => Promise 23 | } 24 | 25 | const defaultKeys = () => Promise.resolve(['status']) 26 | 27 | const defaultValues = () => Promise.resolve(['Good']) 28 | 29 | export const TagsSection = ({ 30 | tags, 31 | operators, 32 | conditions, 33 | placeholder = '', 34 | onChange, 35 | getTagKeyOptions = defaultKeys, 36 | getTagValueOptions = defaultValues, 37 | }: Props): JSX.Element => { 38 | const templateVariables = getTemplateSrv() 39 | .getVariables() 40 | .map((e) => { 41 | return { label: `$${e.name}`, value: `$${e.name}` } 42 | }) 43 | 44 | const onTagChange = (newTag: QueryTag, index: number) => { 45 | const newTags = tags.map((tag, i) => { 46 | return index === i ? newTag : tag 47 | }) 48 | onChange(newTags) 49 | } 50 | 51 | const onTagRemove = (index: number) => { 52 | const newTags = tags.filter((t, i) => i !== index) 53 | onChange(newTags) 54 | } 55 | 56 | const tagKeyOptions = () => { 57 | return getTagKeyOptions().then((tags) => { 58 | return tags.concat(templateVariables.map((e) => e.value)) 59 | }) 60 | } 61 | 62 | const tagValueOptions = (key: string) => { 63 | return getTagValueOptions(key).then((tags) => { 64 | return tags.concat(templateVariables.map((e) => e.value)) 65 | }) 66 | } 67 | 68 | const getTagKeySegmentOptions = () => { 69 | return getTagKeyOptions().then((tags) => { 70 | return tags.map(toSelectableValue).concat(templateVariables) 71 | }) 72 | } 73 | 74 | const addNewTag = (tagKey: string, isFirst: boolean) => { 75 | const minimalTag: QueryTag = { 76 | key: tagKey, 77 | value: placeholder, 78 | } 79 | 80 | const newTag: QueryTag = { 81 | key: minimalTag.key, 82 | value: minimalTag.value, 83 | operator: getOperator(minimalTag), 84 | condition: getCondition(minimalTag, isFirst), 85 | } 86 | 87 | onChange([...tags, newTag]) 88 | } 89 | 90 | return ( 91 | <> 92 | {tags.map((t, i) => ( 93 | { 100 | onTagChange(newT, i) 101 | }} 102 | onRemove={() => { 103 | onTagRemove(i) 104 | }} 105 | getTagKeyOptions={tagKeyOptions} 106 | getTagValueOptions={tagValueOptions} 107 | /> 108 | ))} 109 | { 113 | addNewTag(v, tags.length === 0) 114 | }} 115 | /> 116 | 117 | ) 118 | } 119 | -------------------------------------------------------------------------------- /pkg/schemas/database.go: -------------------------------------------------------------------------------- 1 | package schemas 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | // BaseModel has the fields of a base model that are used by the data source 8 | type BaseModel struct { 9 | UUID uuid.UUID 10 | Name string 11 | } 12 | 13 | // GetUUID returns the UUID of the base model 14 | func (m BaseModel) GetUUID() uuid.UUID { 15 | return m.UUID 16 | } 17 | 18 | // Collector has the fields of a collector that are used by the data source 19 | type Collector struct { 20 | BaseModel 21 | Description string 22 | CollectorType string 23 | } 24 | 25 | // TimeseriesDatabaseType has the fields of a time series database type that are used by the data source 26 | type TimeseriesDatabaseType struct { 27 | Name string 28 | } 29 | 30 | // TimeseriesDatabase has the fields of a time series database that are used by the data source 31 | type TimeseriesDatabase struct { 32 | BaseModel 33 | TimeseriesDatabaseType *TimeseriesDatabaseType 34 | Description string 35 | } 36 | 37 | // Measurement has the fields of a measurement that are used by the data source 38 | type Measurement struct { 39 | BaseModel 40 | Collector *Collector 41 | Database *TimeseriesDatabase 42 | Attributes Attributes 43 | Description string 44 | Datatype string 45 | Status string 46 | CollectorUUID uuid.UUID 47 | DatabaseUUID uuid.UUID 48 | } 49 | 50 | // Asset has the fields of an asset that are used by the data source 51 | type Asset struct { 52 | BaseModel 53 | ParentUUID *uuid.UUID 54 | Parent *Asset 55 | Description string 56 | Status string 57 | AssetPath string 58 | } 59 | 60 | // AssetProperty has the fields of an asset property that are used by the data source 61 | type AssetProperty struct { 62 | BaseModel 63 | AssetUUID uuid.UUID 64 | MeasurementUUID uuid.UUID 65 | } 66 | 67 | // EventType has the fields of an event type that are used by the data source 68 | type EventType struct { 69 | BaseModel 70 | Description string 71 | Properties []EventTypeProperty 72 | ParentUUID *uuid.UUID 73 | } 74 | 75 | // PropertyDatatype enum for the event type property datatype 76 | type PropertyDatatype string 77 | 78 | // PropertyType enum for the event type property type 79 | type PropertyType string 80 | 81 | // The order determines how EventTypePropertyDatatype gets sorted 82 | const ( 83 | EventTypePropertyDatatypeNumber PropertyDatatype = "number" 84 | EventTypePropertyDatatypeBool PropertyDatatype = "boolean" 85 | EventTypePropertyDatatypeString PropertyDatatype = "string" 86 | ) 87 | 88 | // The order determines how EventTypePropertyType gets sorted 89 | const ( 90 | EventTypePropertyTypeSimple PropertyType = "simple" 91 | EventTypePropertyTypePeriodic PropertyType = "periodic" 92 | EventTypePropertyTypePeriodicWithDimension PropertyType = "periodic_with_dimension" 93 | ) 94 | 95 | // EventTypeProperty has the fields of an event type property that are used by the data source 96 | type EventTypeProperty struct { 97 | BaseModel 98 | Datatype PropertyDatatype 99 | Type PropertyType 100 | EventTypeUUID uuid.UUID 101 | UoM string 102 | } 103 | 104 | // EventConfiguration has the fields of an event configuration that are used by the data source 105 | type EventConfiguration struct { 106 | BaseModel 107 | AssetUUID uuid.UUID 108 | EventTypeUUID uuid.UUID 109 | } 110 | 111 | // HistorianInfo has the fields of the historian info that are used by the data source 112 | type HistorianInfo struct { 113 | Version string 114 | APIVersion string 115 | } 116 | -------------------------------------------------------------------------------- /src/QueryEditor/RawQueryEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import { SelectableValue } from '@grafana/data' 3 | import { CodeEditor, InlineField, InlineFieldRow, Select } from '@grafana/ui' 4 | import { DataSource } from 'datasource' 5 | import { getTemplateSrv } from '@grafana/runtime' 6 | import { selectable } from './util' 7 | import { labelWidth, RawQuery, TimeseriesDatabase } from 'types' 8 | 9 | export interface Props { 10 | datasource: DataSource 11 | query: RawQuery 12 | onChangeRawQuery (queryString: RawQuery): void 13 | } 14 | 15 | export const RawQueryEditor = (props: Props): JSX.Element => { 16 | const [loading, setLoading] = useState(true) 17 | const [databases, setDatabases] = useState([]) 18 | 19 | useEffect(() => { 20 | const load = async () => { 21 | const databases = await props.datasource.getTimeseriesDatabases() 22 | setDatabases(databases) 23 | setLoading(false) 24 | } 25 | load() 26 | }, [props.datasource]) 27 | 28 | const selectableTimeseriesDatabases = (databases: TimeseriesDatabase[]): Array> => { 29 | const result: Array> = [] 30 | databases.forEach((database) => { 31 | result.push({ label: database.Name, value: database.UUID, description: database.Description }) 32 | }) 33 | return [ 34 | ...getTemplateSrv() 35 | .getVariables() 36 | .map((e) => { 37 | return { label: `$${e.name}`, value: `$${e.name}` } 38 | }), 39 | ...result, 40 | ] 41 | } 42 | 43 | const getTimeseriesDatabaseType = (database: string): string => { 44 | return databases.find((e) => e.UUID === database)?.TimeseriesDatabaseType?.Name || 'Unknown database type' 45 | } 46 | 47 | const onUpdateQuery = (query: string): void => { 48 | props.onChangeRawQuery({ 49 | ...props.query, 50 | Query: query, 51 | }) 52 | } 53 | 54 | const onTimeseriesDatabaseChange = (event: SelectableValue): void => { 55 | props.onChangeRawQuery({ 56 | ...props.query, 57 | TimeseriesDatabase: event.value ?? '', 58 | }) 59 | } 60 | 61 | return ( 62 | <> 63 | {!loading && ( 64 | <> 65 | 66 | 72 | 89 | 90 | 91 | 92 | 93 | 102 | 103 | 104 | 105 | 110 | 116 | 117 | 118 | 119 | 124 | handleFromInputBlur()} 131 | /> 132 | 137 | 145 | 146 | 147 | 148 | 149 | 150 | 151 |
152 | 153 |
154 | handleToInputBlur()} 159 | /> 160 | 164 | 172 |
173 |
174 |
175 |
176 | 177 | ) 178 | } 179 | 180 | const getStyles = (_: GrafanaTheme2) => ({ 181 | dateRangeField: css({ 182 | display: 'flex', 183 | flexDirection: 'row', 184 | 'div[data-testid="date-time-picker"]': { 185 | width: '2.5rem', 186 | }, 187 | 'div[data-testid="date-time-picker"] div[data-testid="input-wrapper"] div:first-child': { 188 | visibility: 'hidden', 189 | width: 0, 190 | marginLeft: '-1px', 191 | }, 192 | }), 193 | dateRangePicker: css({ 194 | 'label': { 195 | display: 'block', 196 | textAlign: 'right', 197 | }, 198 | 'button': { 199 | height: '32px', 200 | }, 201 | }), 202 | }); 203 | -------------------------------------------------------------------------------- /.config/webpack/webpack.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.github.io/plugin-tools/docs/advanced-configuration#extending-the-webpack-config 6 | */ 7 | 8 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 9 | import ESLintPlugin from 'eslint-webpack-plugin'; 10 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 11 | import LiveReloadPlugin from 'webpack-livereload-plugin'; 12 | import path from 'path'; 13 | import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin'; 14 | import { Configuration } from 'webpack'; 15 | 16 | import { getPackageJson, getPluginJson, hasReadme, getEntries, isWSL } from './utils'; 17 | import { SOURCE_DIR, DIST_DIR } from './constants'; 18 | 19 | const pluginJson = getPluginJson(); 20 | 21 | const config = async (env): Promise => { 22 | const baseConfig: Configuration = { 23 | cache: { 24 | type: 'filesystem', 25 | buildDependencies: { 26 | config: [__filename], 27 | }, 28 | }, 29 | 30 | context: path.join(process.cwd(), SOURCE_DIR), 31 | 32 | devtool: env.production ? 'source-map' : 'eval-source-map', 33 | 34 | entry: await getEntries(), 35 | 36 | externals: [ 37 | 'lodash', 38 | 'jquery', 39 | 'moment', 40 | 'slate', 41 | 'emotion', 42 | '@emotion/react', 43 | '@emotion/css', 44 | 'prismjs', 45 | 'slate-plain-serializer', 46 | '@grafana/slate-react', 47 | 'react', 48 | 'react-dom', 49 | 'react-redux', 50 | 'redux', 51 | 'rxjs', 52 | 'react-router', 53 | 'react-router-dom', 54 | 'd3', 55 | 'angular', 56 | '@grafana/ui', 57 | '@grafana/runtime', 58 | '@grafana/data', 59 | 60 | // Mark legacy SDK imports as external if their name starts with the "grafana/" prefix 61 | ({ request }, callback) => { 62 | const prefix = 'grafana/'; 63 | const hasPrefix = (request) => request.indexOf(prefix) === 0; 64 | const stripPrefix = (request) => request.substr(prefix.length); 65 | 66 | if (hasPrefix(request)) { 67 | return callback(undefined, stripPrefix(request)); 68 | } 69 | 70 | callback(); 71 | }, 72 | ], 73 | 74 | mode: env.production ? 'production' : 'development', 75 | 76 | module: { 77 | rules: [ 78 | { 79 | exclude: /(node_modules)/, 80 | test: /\.[tj]sx?$/, 81 | use: { 82 | loader: 'swc-loader', 83 | options: { 84 | jsc: { 85 | baseUrl: './src', 86 | target: 'es2015', 87 | loose: false, 88 | parser: { 89 | syntax: 'typescript', 90 | tsx: true, 91 | decorators: false, 92 | dynamicImport: true, 93 | }, 94 | }, 95 | }, 96 | }, 97 | }, 98 | { 99 | test: /\.css$/, 100 | use: ["style-loader", "css-loader"] 101 | }, 102 | { 103 | test: /\.s[ac]ss$/, 104 | use: ['style-loader', 'css-loader', 'sass-loader'], 105 | }, 106 | { 107 | test: /\.(png|jpe?g|gif|svg)$/, 108 | type: 'asset/resource', 109 | generator: { 110 | // Keep publicPath relative for host.com/grafana/ deployments 111 | publicPath: `public/plugins/${pluginJson.id}/img/`, 112 | outputPath: 'img/', 113 | filename: Boolean(env.production) ? '[hash][ext]' : '[name][ext]', 114 | }, 115 | }, 116 | { 117 | test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, 118 | type: 'asset/resource', 119 | generator: { 120 | // Keep publicPath relative for host.com/grafana/ deployments 121 | publicPath: `public/plugins/${pluginJson.id}/fonts/`, 122 | outputPath: 'fonts/', 123 | filename: Boolean(env.production) ? '[hash][ext]' : '[name][ext]', 124 | }, 125 | }, 126 | ], 127 | }, 128 | 129 | output: { 130 | clean: { 131 | keep: new RegExp(`(.*?_(amd64|arm(64)?)(.exe)?|go_plugin_build_manifest)`), 132 | }, 133 | filename: '[name].js', 134 | library: { 135 | type: 'amd', 136 | }, 137 | path: path.resolve(process.cwd(), DIST_DIR), 138 | publicPath: '/', 139 | }, 140 | 141 | plugins: [ 142 | new CopyWebpackPlugin({ 143 | patterns: [ 144 | // If src/README.md exists use it; otherwise the root README 145 | // To `compiler.options.output` 146 | { from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true }, 147 | { from: 'plugin.json', to: '.' }, 148 | { from: '../LICENSE', to: '.' }, 149 | { from: '../CHANGELOG.md', to: '.', force: true }, 150 | { from: '**/*.json', to: '.' }, // TODO 151 | { from: '**/*.svg', to: '.', noErrorOnMissing: true }, // Optional 152 | { from: '**/*.png', to: '.', noErrorOnMissing: true }, // Optional 153 | { from: '**/*.html', to: '.', noErrorOnMissing: true }, // Optional 154 | { from: 'img/**/*', to: '.', noErrorOnMissing: true }, // Optional 155 | { from: 'libs/**/*', to: '.', noErrorOnMissing: true }, // Optional 156 | { from: 'static/**/*', to: '.', noErrorOnMissing: true }, // Optional 157 | ], 158 | }), 159 | // Replace certain template-variables in the README and plugin.json 160 | new ReplaceInFileWebpackPlugin([ 161 | { 162 | dir: DIST_DIR, 163 | files: ['plugin.json', 'README.md'], 164 | rules: [ 165 | { 166 | search: /\%VERSION\%/g, 167 | replace: getPackageJson().version, 168 | }, 169 | { 170 | search: /\%TODAY\%/g, 171 | replace: new Date().toISOString().substring(0, 10), 172 | }, 173 | { 174 | search: /\%PLUGIN_ID\%/g, 175 | replace: pluginJson.id, 176 | }, 177 | ], 178 | }, 179 | ]), 180 | new ForkTsCheckerWebpackPlugin({ 181 | async: Boolean(env.development), 182 | issue: { 183 | include: [{ file: '**/*.{ts,tsx}' }], 184 | }, 185 | typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') }, 186 | }), 187 | new ESLintPlugin({ 188 | extensions: ['.ts', '.tsx'], 189 | lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files 190 | }), 191 | ...(env.development ? [new LiveReloadPlugin()] : []), 192 | ], 193 | 194 | resolve: { 195 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 196 | // handle resolving "rootDir" paths 197 | modules: [path.resolve(process.cwd(), 'src'), 'node_modules'], 198 | unsafeCache: true, 199 | }, 200 | } 201 | 202 | if(isWSL()) { 203 | baseConfig.watchOptions = { 204 | poll: 3000, 205 | ignored: /node_modules/, 206 | }} 207 | 208 | 209 | return baseConfig; 210 | 211 | }; 212 | 213 | export default config; 214 | --------------------------------------------------------------------------------