├── .nvmrc ├── .config ├── .cprc.json ├── webpack │ ├── constants.ts │ ├── utils.ts │ └── webpack.config.ts ├── .prettierrc.js ├── .eslintrc ├── Dockerfile ├── types │ └── custom.d.ts ├── tsconfig.json ├── jest-setup.js ├── jest │ ├── mocks │ │ └── react-inlinesvg.tsx │ └── utils.js ├── jest.config.js └── README.md ├── .eslintrc ├── tsconfig.json ├── jest-setup.js ├── src ├── img │ ├── osi-pi.png │ ├── annotations.png │ ├── configuration.png │ └── configurator.png ├── module.ts ├── components │ ├── Forms.tsx │ └── QueryEditorModeSwitcher.tsx ├── plugin.json ├── types.ts ├── query │ └── AnnotationsQueryEditor.tsx ├── config │ └── ConfigEditor.tsx └── helper.ts ├── dist ├── img │ └── osi-pi.png ├── plugin.json ├── README.md └── LICENSE ├── docs └── img │ ├── sample.png │ ├── annotations.png │ ├── configuration.png │ ├── configurator.png │ ├── event_frame.png │ ├── variable_new.png │ ├── pi_point_query.png │ ├── system_overview.png │ ├── eventframe_new_1.png │ ├── eventframe_new_2.png │ ├── template_setup_1.png │ ├── event_frame_setup_1.png │ ├── event_frame_setup_2.png │ └── elements_and_attributes.png ├── .prettierrc.js ├── Magefile.go ├── pkg ├── plugin │ ├── timeseries_value_models.go │ ├── timeseries_response_withoutsub_model.go │ ├── timeseries_response_withsub_model.go │ ├── timeseries_response_error_model.go │ ├── timeseries_response_single_model.go │ ├── timeseries_response_summary_model.go │ ├── cache.go │ ├── datasource_models.go │ ├── timeseries_query_models.go │ ├── annotation_models.go │ ├── timeseries_response_models.go │ ├── annotation_query.go │ ├── webidcache.go │ ├── steam.go │ ├── datasource.go │ └── helpers.go └── main.go ├── jest.config.js ├── docker-compose.yaml ├── .gitignore ├── .github └── workflows │ ├── release.yml │ └── ci.yml ├── CHANGELOG.md ├── package.json ├── README.md ├── go.mod └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.config/.cprc.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4.2.1" 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/.eslintrc" 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.json" 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/osi-pi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/src/img/osi-pi.png -------------------------------------------------------------------------------- /dist/img/osi-pi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/dist/img/osi-pi.png -------------------------------------------------------------------------------- /docs/img/sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/sample.png -------------------------------------------------------------------------------- /src/img/annotations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/src/img/annotations.png -------------------------------------------------------------------------------- /docs/img/annotations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/annotations.png -------------------------------------------------------------------------------- /docs/img/configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/configuration.png -------------------------------------------------------------------------------- /docs/img/configurator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/configurator.png -------------------------------------------------------------------------------- /docs/img/event_frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/event_frame.png -------------------------------------------------------------------------------- /docs/img/variable_new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/variable_new.png -------------------------------------------------------------------------------- /src/img/configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/src/img/configuration.png -------------------------------------------------------------------------------- /src/img/configurator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/src/img/configurator.png -------------------------------------------------------------------------------- /docs/img/pi_point_query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/pi_point_query.png -------------------------------------------------------------------------------- /docs/img/system_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/system_overview.png -------------------------------------------------------------------------------- /docs/img/eventframe_new_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/eventframe_new_1.png -------------------------------------------------------------------------------- /docs/img/eventframe_new_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/eventframe_new_2.png -------------------------------------------------------------------------------- /docs/img/template_setup_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/template_setup_1.png -------------------------------------------------------------------------------- /docs/img/event_frame_setup_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/event_frame_setup_1.png -------------------------------------------------------------------------------- /docs/img/event_frame_setup_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/event_frame_setup_2.png -------------------------------------------------------------------------------- /docs/img/elements_and_attributes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GridProtectionAlliance/osisoftpi-grafana/HEAD/docs/img/elements_and_attributes.png -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prettier configuration provided by Grafana scaffolding 3 | ...require('./.config/.prettierrc.js'), 4 | }; 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pkg/plugin/timeseries_value_models.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | type PointDigitalState struct { 4 | IsSystem bool `json:"IsSystem"` 5 | Name string `json:"Name"` 6 | Value int `json:"Value"` 7 | } 8 | 9 | func (p PiBatchContentItem) isGood() bool { 10 | return p.Good 11 | } 12 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.config/.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | module.exports = { 8 | endOfLine: 'auto', 9 | printWidth: 120, 10 | trailingComma: 'es5', 11 | semi: true, 12 | jsxSingleQuote: false, 13 | singleQuote: true, 14 | useTabs: false, 15 | tabWidth: 2, 16 | }; 17 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | 3 | services: 4 | grafana: 5 | container_name: 'gridprotectionalliance-osisoftpi-datasource' 6 | platform: 'linux/amd64' 7 | build: 8 | context: ./.config 9 | args: 10 | grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise} 11 | grafana_version: ${GRAFANA_VERSION:-10.3.3} 12 | ports: 13 | - 3000:3000/tcp 14 | volumes: 15 | - ./dist:/var/lib/grafana/plugins/gridprotectionalliance-osisoftpi-datasource 16 | - ./provisioning:/etc/grafana/provisioning 17 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { DataSourcePlugin } from '@grafana/data'; 2 | import { PIWebAPIConfigEditor } from './config/ConfigEditor'; 3 | import { PIWebAPIQueryEditor } from './query/QueryEditor'; 4 | import { PiWebAPIDatasource } from './datasource'; 5 | import { PIWebAPIQuery, PIWebAPIDataSourceJsonData } from './types'; 6 | 7 | export const plugin = new DataSourcePlugin( 8 | PiWebAPIDatasource 9 | ) 10 | .setQueryEditor(PIWebAPIQueryEditor) 11 | .setConfigEditor(PIWebAPIConfigEditor); 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | node_modules 21 | dist 22 | vendor 23 | .tscache 24 | coverage 25 | .idea 26 | .vscode 27 | .eslintcache 28 | 29 | # OS generated files 30 | .DS_Store 31 | 32 | # Editor 33 | .idea 34 | 35 | # VS Code 36 | .vscode 37 | 38 | # Codealike 39 | codealike.json -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" # Run workflow on version tags, e.g. v1.0.0. 7 | 8 | # necessary to create releases 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: grafana/plugin-actions/build-plugin@main 19 | with: 20 | # see https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin#generate-an-access-policy-token to generate it 21 | # save the value in your repository secrets 22 | policy_token: ${{ secrets.GRAFANA_ACCESS_POLICY_TOKEN }} 23 | -------------------------------------------------------------------------------- /pkg/plugin/timeseries_response_withoutsub_model.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | type PiBatchDataWithoutSubItems struct { 4 | Links map[string]interface{} `json:"Links"` 5 | Items []PiBatchContentItem `json:"Items"` 6 | UnitsAbbreviation string `json:"UnitsAbbreviation"` 7 | } 8 | 9 | func (p PiBatchDataWithoutSubItems) getUnits(typeFilter string) string { 10 | return p.UnitsAbbreviation 11 | } 12 | 13 | func (p PiBatchDataWithoutSubItems) getItems(typeFilter string) *[]PiBatchContentItem { 14 | return &p.Items 15 | } 16 | 17 | func (p PiBatchDataWithoutSubItems) getSummaryTypes() *[]string { 18 | typeValues := make([]string, 1) 19 | typeValues[0] = "" 20 | return &typeValues 21 | } 22 | -------------------------------------------------------------------------------- /.config/.eslintrc: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-eslint-config 6 | */ 7 | { 8 | "extends": ["@grafana/eslint-config"], 9 | "root": true, 10 | "rules": { 11 | "react/prop-types": "off" 12 | }, 13 | "overrides": [ 14 | { 15 | "plugins": ["deprecation"], 16 | "files": ["src/**/*.{ts,tsx}"], 17 | "rules": { 18 | "deprecation/deprecation": "warn" 19 | }, 20 | "parserOptions": { 21 | "project": "./tsconfig.json" 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.config/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG grafana_version=latest 2 | ARG grafana_image=grafana-enterprise 3 | 4 | FROM grafana/${grafana_image}:${grafana_version} 5 | 6 | # Make it as simple as possible to access the grafana instance for development purposes 7 | # Do NOT enable these settings in a public facing / production grafana instance 8 | ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin" 9 | ENV GF_AUTH_ANONYMOUS_ENABLED "true" 10 | ENV GF_AUTH_BASIC_ENABLED "false" 11 | # Set development mode so plugins can be loaded without the need to sign 12 | ENV GF_DEFAULT_APP_MODE "development" 13 | 14 | # Inject livereload script into grafana index.html 15 | USER root 16 | RUN sed -i 's|||g' /usr/share/grafana/public/views/index.html 17 | -------------------------------------------------------------------------------- /.config/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | // Image declarations 2 | declare module '*.gif' { 3 | const src: string; 4 | export default src; 5 | } 6 | 7 | declare module '*.jpg' { 8 | const src: string; 9 | export default src; 10 | } 11 | 12 | declare module '*.jpeg' { 13 | const src: string; 14 | export default src; 15 | } 16 | 17 | declare module '*.png' { 18 | const src: string; 19 | export default src; 20 | } 21 | 22 | declare module '*.webp' { 23 | const src: string; 24 | export default src; 25 | } 26 | 27 | declare module '*.svg' { 28 | const content: string; 29 | export default content; 30 | } 31 | 32 | // Font declarations 33 | declare module '*.woff'; 34 | declare module '*.woff2'; 35 | declare module '*.eot'; 36 | declare module '*.ttf'; 37 | declare module '*.otf'; 38 | -------------------------------------------------------------------------------- /.config/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-typescript-config 6 | */ 7 | { 8 | "compilerOptions": { 9 | "alwaysStrict": true, 10 | "declaration": false, 11 | "rootDir": "../src", 12 | "baseUrl": "../src", 13 | "typeRoots": ["../node_modules/@types"], 14 | "resolveJsonModule": true 15 | }, 16 | "ts-node": { 17 | "compilerOptions": { 18 | "module": "commonjs", 19 | "target": "es5", 20 | "esModuleInterop": true 21 | }, 22 | "transpileOnly": true 23 | }, 24 | "include": ["../src", "./types"], 25 | "extends": "@grafana/tsconfig" 26 | } 27 | -------------------------------------------------------------------------------- /.config/jest-setup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-jest-config 6 | */ 7 | 8 | import '@testing-library/jest-dom'; 9 | 10 | // https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom 11 | Object.defineProperty(global, 'matchMedia', { 12 | writable: true, 13 | value: jest.fn().mockImplementation((query) => ({ 14 | matches: false, 15 | media: query, 16 | onchange: null, 17 | addListener: jest.fn(), // deprecated 18 | removeListener: jest.fn(), // deprecated 19 | addEventListener: jest.fn(), 20 | removeEventListener: jest.fn(), 21 | dispatchEvent: jest.fn(), 22 | })), 23 | }); 24 | 25 | HTMLCanvasElement.prototype.getContext = () => {}; 26 | -------------------------------------------------------------------------------- /.config/jest/mocks/react-inlinesvg.tsx: -------------------------------------------------------------------------------- 1 | // Due to the grafana/ui Icon component making fetch requests to 2 | // `/public/img/icon/.svg` we need to mock react-inlinesvg to prevent 3 | // the failed fetch requests from displaying errors in console. 4 | 5 | import React from 'react'; 6 | 7 | type Callback = (...args: any[]) => void; 8 | 9 | export interface StorageItem { 10 | content: string; 11 | queue: Callback[]; 12 | status: string; 13 | } 14 | 15 | export const cacheStore: { [key: string]: StorageItem } = Object.create(null); 16 | 17 | const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/; 18 | 19 | const InlineSVG = ({ src }: { src: string }) => { 20 | // testId will be the file name without extension (e.g. `public/img/icons/angle-double-down.svg` -> `angle-double-down`) 21 | const testId = src.replace(SVG_FILE_NAME_REGEX, '$2'); 22 | return ; 23 | }; 24 | 25 | export default InlineSVG; 26 | -------------------------------------------------------------------------------- /.config/jest/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in .config/README.md 5 | */ 6 | 7 | /* 8 | * This utility function is useful in combination with jest `transformIgnorePatterns` config 9 | * to transform specific packages (e.g.ES modules) in a projects node_modules folder. 10 | */ 11 | const nodeModulesToTransform = (moduleNames) => `node_modules\/(?!.*(${moduleNames.join('|')})\/.*)`; 12 | 13 | // Array of known nested grafana package dependencies that only bundle an ESM version 14 | const grafanaESModules = [ 15 | '.pnpm', // Support using pnpm symlinked packages 16 | '@grafana/schema', 17 | 'd3', 18 | 'd3-color', 19 | 'd3-force', 20 | 'd3-interpolate', 21 | 'd3-scale-chromatic', 22 | 'ol', 23 | 'react-colorful', 24 | 'rxjs', 25 | 'uuid', 26 | ]; 27 | 28 | module.exports = { 29 | nodeModulesToTransform, 30 | grafanaESModules, 31 | }; 32 | -------------------------------------------------------------------------------- /pkg/plugin/timeseries_response_withsub_model.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | type PiBatchDataWithSubItems struct { 4 | Links map[string]interface{} `json:"Links"` 5 | Items []struct { 6 | WebId string `json:"WebId"` 7 | Name string `json:"Name"` 8 | Path string `json:"Path"` 9 | Links PiBatchContentLinks `json:"Links"` 10 | Items []PiBatchContentItem `json:"Items"` 11 | UnitsAbbreviation string `json:"UnitsAbbreviation"` 12 | } `json:"Items"` 13 | Error *string 14 | } 15 | 16 | func (p PiBatchDataWithSubItems) getUnits(typeFilter string) string { 17 | return p.Items[0].UnitsAbbreviation 18 | } 19 | 20 | func (p PiBatchDataWithSubItems) getItems(typeFilter string) *[]PiBatchContentItem { 21 | return &p.Items[0].Items 22 | } 23 | 24 | func (p PiBatchDataWithSubItems) getSummaryTypes() *[]string { 25 | typeValues := make([]string, 1) 26 | typeValues[0] = "" 27 | return &typeValues 28 | } 29 | -------------------------------------------------------------------------------- /pkg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/GridProtectionAlliance/osisoftpi-grafana/pkg/plugin" 7 | "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" 8 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 9 | ) 10 | 11 | func main() { 12 | // Start listening to requests sent from Grafana. This call is blocking so 13 | // it won't finish until Grafana shuts down the process or the plugin choose 14 | // to exit by itself using os.Exit. Manage automatically manages life cycle 15 | // of datasource instances. It accepts datasource instance factory as first 16 | // argument. This factory will be automatically called on incoming request 17 | // from Grafana to create different instances of SampleDatasource (per datasource 18 | // ID). When datasource configuration changed Dispose method will be called and 19 | // new datasource instance created using NewSampleDatasource factory. 20 | if err := datasource.Manage("gridprotectionalliance-osisoftpi-datasource", plugin.NewPIWebAPIDatasource, datasource.ManageOpts{}); err != nil { 21 | log.DefaultLogger.Error("PiWebAPI main", "Plugin", err.Error()) 22 | os.Exit(1) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/plugin/timeseries_response_error_model.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import "encoding/json" 4 | 5 | type ErrorResponse struct { 6 | Errors []string `json:"Errors"` 7 | } 8 | 9 | type PiBatchDataError struct { 10 | Error *ErrorResponse 11 | } 12 | 13 | func (p PiBatchDataError) getUnits(typeFilter string) string { 14 | return "" 15 | } 16 | 17 | func (p PiBatchDataError) getItems(typeFilter string) *[]PiBatchContentItem { 18 | var items []PiBatchContentItem 19 | return &items 20 | } 21 | 22 | func (p PiBatchDataError) getSummaryTypes() *[]string { 23 | typeValues := make([]string, 1) 24 | typeValues[0] = "" 25 | return &typeValues 26 | } 27 | 28 | func convertError(data interface{}) (*[]string, error) { 29 | raw, err := json.Marshal(data) 30 | if err != nil { 31 | return nil, err 32 | } 33 | var errors ErrorResponse 34 | err = json.Unmarshal(raw, &errors) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return &errors.Errors, nil 39 | } 40 | 41 | func createPiBatchDataError(errorMessage *[]string) *PiBatchDataError { 42 | errorResponse := &ErrorResponse{Errors: *errorMessage} 43 | resContent := &PiBatchDataError{Error: errorResponse} 44 | return resContent 45 | } 46 | -------------------------------------------------------------------------------- /pkg/plugin/timeseries_response_single_model.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | type PiBatchDataWithSingleItem struct { 4 | Links map[string]interface{} `json:"Links"` 5 | Items []struct { 6 | WebId string `json:"WebId"` 7 | Name string `json:"Name"` 8 | Path string `json:"Path"` 9 | Links PiBatchContentLinks `json:"Links"` 10 | Value PiBatchContentItem `json:"Value"` 11 | } `json:"Items"` 12 | Error *string 13 | } 14 | 15 | type PiBatchDataWithFloatItem struct { 16 | Links map[string]interface{} `json:"Links"` 17 | Items []PiBatchContentItem `json:"Items"` 18 | Error *string 19 | } 20 | 21 | func (p PiBatchDataWithSingleItem) getUnits(typeFilter string) string { 22 | return p.Items[0].Value.UnitsAbbreviation 23 | } 24 | 25 | func (p PiBatchDataWithSingleItem) getItems(typeFilter string) *[]PiBatchContentItem { 26 | var items []PiBatchContentItem 27 | items = append(items, p.Items[0].Value) 28 | return &items 29 | } 30 | 31 | func (p PiBatchDataWithSingleItem) getSummaryTypes() *[]string { 32 | typeValues := make([]string, 1) 33 | typeValues[0] = "" 34 | return &typeValues 35 | } 36 | 37 | func (p PiBatchDataWithFloatItem) getUnits(typeFilter string) string { 38 | return p.Items[0].UnitsAbbreviation 39 | } 40 | 41 | func (p PiBatchDataWithFloatItem) getItems(typeFilter string) *[]PiBatchContentItem { 42 | return &p.Items 43 | } 44 | 45 | func (p PiBatchDataWithFloatItem) getSummaryTypes() *[]string { 46 | typeValues := make([]string, 1) 47 | typeValues[0] = "" 48 | return &typeValues 49 | } 50 | -------------------------------------------------------------------------------- /src/components/Forms.tsx: -------------------------------------------------------------------------------- 1 | import React, { InputHTMLAttributes, FunctionComponent } from 'react'; 2 | import { InlineFormLabel } from '@grafana/ui'; 3 | 4 | export interface Props extends InputHTMLAttributes { 5 | label: string; 6 | tooltip?: string; 7 | labelWidth?: number; 8 | children?: React.ReactNode; 9 | queryEditor?: JSX.Element; 10 | } 11 | 12 | export const QueryField: FunctionComponent> = ({ label, labelWidth = 12, tooltip, children }) => ( 13 | <> 14 | 15 | {label} 16 | 17 | {children} 18 | 19 | ); 20 | 21 | export const QueryRowTerminator = () => { 22 | return ( 23 |
24 |
25 |
26 | ); 27 | }; 28 | 29 | export const QueryInlineField = ({ ...props }) => { 30 | return ( 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export const QueryEditorRow = (props: Partial) => { 38 | return ( 39 |
40 | {props.children} 41 | 42 |
43 | ); 44 | }; 45 | 46 | export const QueryRawInlineField = ({ ...props }) => { 47 | return ( 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export const QueryRawEditorRow = (props: Partial) => { 55 | return <>{props.children}; 56 | }; 57 | -------------------------------------------------------------------------------- /pkg/plugin/timeseries_response_summary_model.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | type PiBatchDataSummaryItems struct { 4 | Links map[string]interface{} `json:"Links"` 5 | Items []struct { 6 | WebId string `json:"WebId"` 7 | Name string `json:"Name"` 8 | Path string `json:"Path"` 9 | Links PiBatchContentLinks `json:"Links"` 10 | Items []PiBatchSummaryItem `json:"Items"` 11 | } `json:"Items"` 12 | Error *string 13 | } 14 | 15 | type PiBatchSummaryItem struct { 16 | Type string `json:"Type"` 17 | Value PiBatchContentItem `json:"Value"` 18 | } 19 | 20 | func (p PiBatchDataSummaryItems) getUnits(typeFilter string) string { 21 | var units string 22 | if len(typeFilter) == 0 { 23 | return "" 24 | } 25 | 26 | for _, item := range p.Items[0].Items { 27 | if item.Type == typeFilter { 28 | units = item.Value.UnitsAbbreviation 29 | break 30 | } 31 | } 32 | return units 33 | } 34 | 35 | func (p PiBatchDataSummaryItems) getItems(typeFilter string) *[]PiBatchContentItem { 36 | var items []PiBatchContentItem 37 | for _, item := range p.Items[0].Items { 38 | if item.Type == typeFilter { 39 | items = append(items, item.Value) 40 | } 41 | } 42 | return &items 43 | } 44 | 45 | func (p PiBatchDataSummaryItems) getSummaryTypes() *[]string { 46 | var types []string 47 | seenTypes := make(map[string]bool) 48 | for _, item := range p.Items[0].Items { 49 | if _, exists := seenTypes[item.Type]; !exists { 50 | types = append(types, item.Type) 51 | seenTypes[item.Type] = true 52 | } 53 | } 54 | return &types 55 | } 56 | -------------------------------------------------------------------------------- /pkg/plugin/cache.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // Cache is a basic in-memory key-value cache implementation. 8 | type Cache[K comparable, V any] struct { 9 | items map[K]V // The map storing key-value pairs. 10 | mu sync.Mutex // Mutex for controlling concurrent access to the cache. 11 | } 12 | 13 | // New creates a new Cache instance. 14 | func newCache[K comparable, V any]() *Cache[K, V] { 15 | return &Cache[K, V]{ 16 | items: make(map[K]V), 17 | } 18 | } 19 | 20 | // Set adds or updates a key-value pair in the cache. 21 | func (c *Cache[K, V]) Set(key K, value V) { 22 | c.mu.Lock() 23 | defer c.mu.Unlock() 24 | 25 | c.items[key] = value 26 | } 27 | 28 | // Get retrieves the value associated with the given key from the cache. The bool 29 | // return value will be false if no matching key is found, and true otherwise. 30 | func (c *Cache[K, V]) Get(key K) (V, bool) { 31 | c.mu.Lock() 32 | defer c.mu.Unlock() 33 | 34 | value, found := c.items[key] 35 | return value, found 36 | } 37 | 38 | // Remove deletes the key-value pair with the specified key from the cache. 39 | func (c *Cache[K, V]) Remove(key K) { 40 | c.mu.Lock() 41 | defer c.mu.Unlock() 42 | 43 | delete(c.items, key) 44 | } 45 | 46 | // Pop removes and returns the value associated with the specified key from the cache. 47 | func (c *Cache[K, V]) Pop(key K) (V, bool) { 48 | c.mu.Lock() 49 | defer c.mu.Unlock() 50 | 51 | value, found := c.items[key] 52 | 53 | // If the key is found, delete the key-value pair from the cache. 54 | if found { 55 | delete(c.items, key) 56 | } 57 | 58 | return value, found 59 | } 60 | -------------------------------------------------------------------------------- /.config/jest.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-jest-config 6 | */ 7 | 8 | const path = require('path'); 9 | const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils'); 10 | 11 | module.exports = { 12 | moduleNameMapper: { 13 | '\\.(css|scss|sass)$': 'identity-obj-proxy', 14 | 'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'), 15 | }, 16 | modulePaths: ['/src'], 17 | setupFilesAfterEnv: ['/jest-setup.js'], 18 | testEnvironment: 'jest-environment-jsdom', 19 | testMatch: [ 20 | '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', 21 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 22 | '/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}', 23 | ], 24 | transform: { 25 | '^.+\\.(t|j)sx?$': [ 26 | '@swc/jest', 27 | { 28 | sourceMaps: 'inline', 29 | jsc: { 30 | parser: { 31 | syntax: 'typescript', 32 | tsx: true, 33 | decorators: false, 34 | dynamicImport: true, 35 | }, 36 | }, 37 | }, 38 | ], 39 | }, 40 | // Jest will throw `Cannot use import statement outside module` if it tries to load an 41 | // ES module without it being transformed first. ./config/README.md#esm-errors-with-jest 42 | transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)], 43 | }; 44 | -------------------------------------------------------------------------------- /dist/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/grafana/grafana/master/docs/sources/developers/plugins/plugin.schema.json", 3 | "name": "OSIsoft-PI", 4 | "type": "datasource", 5 | "id": "gridprotectionalliance-osisoftpi-datasource", 6 | "backend": true, 7 | "executable": "gpx_osipiwebapi", 8 | "metrics": true, 9 | "annotations": true, 10 | "alerting": true, 11 | "streaming": false, 12 | "info": { 13 | "description": "Datasource plugin for OSIsoft PI Web API", 14 | "author": { 15 | "name": "Grid Protection Alliance", 16 | "url": "https://github.com/GridProtectionAlliance/osisoftpi-grafana" 17 | }, 18 | "keywords": ["OSIsoft", "PI", "Historian", "GPA"], 19 | "logos": { 20 | "small": "img/logo.svg", 21 | "large": "img/logo.svg" 22 | }, 23 | "links": [ 24 | { 25 | "name": "Website", 26 | "url": "https://github.com/GridProtectionAlliance/osisoftpi-grafana" 27 | }, 28 | { 29 | "name": "License", 30 | "url": "https://github.com/GridProtectionAlliance/osisoftpi-grafana/blob/master/LICENSE" 31 | }, 32 | { 33 | "name": "PI System", 34 | "url": "https://www.osisoft.com/pi-system" 35 | } 36 | ], 37 | "screenshots": [ 38 | {"name": "Query Editor", "path": "img/configurator.png"}, 39 | {"name": "Datasource Configuration", "path": "img/configuration.png"}, 40 | {"name": "Annotations Editor", "path": "img/annotations.png"} 41 | ], 42 | "version": "5.2.0", 43 | "updated": "2025-03-21" 44 | }, 45 | "dependencies": { 46 | "grafanaDependency": ">=10.1.0", 47 | "plugins": [] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/grafana/grafana/master/docs/sources/developers/plugins/plugin.schema.json", 3 | "name": "OSIsoft-PI", 4 | "type": "datasource", 5 | "id": "gridprotectionalliance-osisoftpi-datasource", 6 | "backend": true, 7 | "executable": "gpx_osipiwebapi", 8 | "metrics": true, 9 | "annotations": true, 10 | "alerting": true, 11 | "streaming": false, 12 | "info": { 13 | "description": "Datasource plugin for OSIsoft PI Web API", 14 | "author": { 15 | "name": "Grid Protection Alliance", 16 | "url": "https://github.com/GridProtectionAlliance/osisoftpi-grafana" 17 | }, 18 | "keywords": ["OSIsoft", "PI", "Historian", "GPA"], 19 | "logos": { 20 | "small": "img/logo.svg", 21 | "large": "img/logo.svg" 22 | }, 23 | "links": [ 24 | { 25 | "name": "Website", 26 | "url": "https://github.com/GridProtectionAlliance/osisoftpi-grafana" 27 | }, 28 | { 29 | "name": "License", 30 | "url": "https://github.com/GridProtectionAlliance/osisoftpi-grafana/blob/master/LICENSE" 31 | }, 32 | { 33 | "name": "PI System", 34 | "url": "https://www.osisoft.com/pi-system" 35 | } 36 | ], 37 | "screenshots": [ 38 | {"name": "Query Editor", "path": "img/configurator.png"}, 39 | {"name": "Datasource Configuration", "path": "img/configuration.png"}, 40 | {"name": "Annotations Editor", "path": "img/annotations.png"} 41 | ], 42 | "version": "%VERSION%", 43 | "updated": "%TODAY%" 44 | }, 45 | "dependencies": { 46 | "grafanaDependency": ">=10.1.0", 47 | "plugins": [] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/QueryEditorModeSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Button, ConfirmModal } from '@grafana/ui'; 3 | 4 | type Props = { 5 | isRaw: boolean; 6 | onChange: (newIsRaw: boolean) => void; 7 | }; 8 | 9 | export const QueryEditorModeSwitcher = ({ isRaw, onChange }: Props): JSX.Element => { 10 | const [isModalOpen, setModalOpen] = useState(false); 11 | 12 | useEffect(() => { 13 | // if the isRaw changes, we hide the modal 14 | setModalOpen(false); 15 | }, [isRaw]); 16 | 17 | if (isRaw) { 18 | return ( 19 | <> 20 | 30 | { 37 | onChange(false); 38 | }} 39 | onDismiss={() => { 40 | setModalOpen(false); 41 | }} 42 | /> 43 | 44 | ); 45 | } else { 46 | return ( 47 | 56 | ); 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /.config/webpack/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import process from 'process'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import { glob } from 'glob'; 6 | import { SOURCE_DIR } from './constants'; 7 | 8 | export function isWSL() { 9 | if (process.platform !== 'linux') { 10 | return false; 11 | } 12 | 13 | if (os.release().toLowerCase().includes('microsoft')) { 14 | return true; 15 | } 16 | 17 | try { 18 | return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft'); 19 | } catch { 20 | return false; 21 | } 22 | } 23 | 24 | export function getPackageJson() { 25 | return require(path.resolve(process.cwd(), 'package.json')); 26 | } 27 | 28 | export function getPluginJson() { 29 | return require(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`)); 30 | } 31 | 32 | export function hasReadme() { 33 | return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md')); 34 | } 35 | 36 | // Support bundling nested plugins by finding all plugin.json files in src directory 37 | // then checking for a sibling module.[jt]sx? file. 38 | export async function getEntries(): Promise> { 39 | const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true }); 40 | 41 | const plugins = await Promise.all( 42 | pluginsJson.map((pluginJson) => { 43 | const folder = path.dirname(pluginJson); 44 | return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true }); 45 | }) 46 | ); 47 | 48 | return plugins.reduce((result, modules) => { 49 | return modules.reduce((result, module) => { 50 | const pluginPath = path.dirname(module); 51 | const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, ''); 52 | const entryName = pluginName === '' ? 'module' : `${pluginName}/module`; 53 | 54 | result[entryName] = module; 55 | return result; 56 | }, result); 57 | }, {}); 58 | } 59 | -------------------------------------------------------------------------------- /pkg/plugin/datasource_models.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | "time" 7 | 8 | "github.com/go-co-op/gocron" 9 | "github.com/gorilla/websocket" 10 | "github.com/grafana/grafana-plugin-sdk-go/backend" 11 | "github.com/grafana/grafana-plugin-sdk-go/backend/datasource" 12 | ) 13 | 14 | type Datasource struct { 15 | settings backend.DataSourceInstanceSettings 16 | queryMux *datasource.QueryTypeMux 17 | StreamHandler backend.StreamHandler 18 | httpClient *http.Client 19 | webIDCache WebIDCache 20 | webCache *Cache[string, PiBatchData] 21 | channelConstruct map[string]StreamChannelConstruct 22 | datasourceMutex *sync.Mutex 23 | scheduler *gocron.Scheduler 24 | websocketConnectionsMutex *sync.Mutex 25 | websocketConnections map[string]*websocket.Conn 26 | sendersByWebID map[string]map[*backend.StreamSender]bool 27 | streamChannels map[string]chan []byte 28 | dataSourceOptions *PIWebAPIDataSourceJsonData 29 | initalTime time.Time 30 | totalCalls int 31 | callRate float64 32 | } 33 | 34 | type PIWebAPIDataSourceJsonData struct { 35 | URL *string `json:"url,omitempty"` 36 | Access *string `json:"access,omitempty"` 37 | PIServer *string `json:"piserver,omitempty"` 38 | AFServer *string `json:"afserver,omitempty"` 39 | AFDatabase *string `json:"afdatabase,omitempty"` 40 | PIPoint *bool `json:"pipoint,omitempty"` 41 | NewFormat *bool `json:"newFormat,omitempty"` 42 | MaxCacheTime *int `json:"maxCacheTime,omitempty"` 43 | UseUnit *bool `json:"useUnit,omitempty"` 44 | UseExperimental *bool `json:"useExperimental,omitempty"` 45 | UseStreaming *bool `json:"useStreaming,omitempty"` 46 | UseResponseCache *bool `json:"useResponseCache,omitempty"` 47 | } 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js environment 17 | uses: actions/setup-node@v2.1.2 18 | with: 19 | node-version: "20.x" 20 | 21 | - name: Get yarn cache directory path 22 | id: yarn-cache-dir-path 23 | run: echo "::set-output name=dir::$(yarn cache dir)" 24 | 25 | - name: Cache yarn cache 26 | uses: actions/cache@v4 27 | id: cache-yarn-cache 28 | with: 29 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 30 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 31 | restore-keys: | 32 | ${{ runner.os }}-yarn- 33 | 34 | - name: Cache node_modules 35 | id: cache-node-modules 36 | uses: actions/cache@v4 37 | with: 38 | path: node_modules 39 | key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }} 40 | restore-keys: | 41 | ${{ runner.os }}-${{ matrix.node-version }}-nodemodules- 42 | 43 | - name: Install dependencies 44 | run: yarn install --frozen-lockfile 45 | 46 | - name: Build and test frontend 47 | run: yarn build 48 | 49 | - name: Check for backend 50 | id: check-for-backend 51 | run: | 52 | if [ -f "Magefile.go" ] 53 | then 54 | echo "::set-output name=has-backend::true" 55 | fi 56 | 57 | - name: Setup Go environment 58 | if: steps.check-for-backend.outputs.has-backend == 'true' 59 | uses: actions/setup-go@v4 60 | with: 61 | go-version: "1.22" 62 | 63 | - name: Test backend 64 | if: steps.check-for-backend.outputs.has-backend == 'true' 65 | uses: magefile/mage-action@v3 66 | with: 67 | version: latest 68 | args: coverage 69 | 70 | - name: Build backend 71 | if: steps.check-for-backend.outputs.has-backend == 'true' 72 | uses: magefile/mage-action@v3 73 | with: 74 | version: latest 75 | args: buildAll 76 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0 4 | 5 | - Initial release. 6 | 7 | ## 2.0.0 8 | 9 | - Move to React based framework. 10 | 11 | ## 3.1.0 12 | 13 | - Added calculation to PI Points 14 | - Added PI point configuration (thanks to @TheFern2) 15 | - Added option to use last value from PiWebAPI 16 | - Updated to Grafana plugin SDK v9.3.6 17 | 18 | ## 4.0.0 19 | 20 | - Added a new dataframe label format. It can be disabled in the configuration page for backward compatibility 21 | - Added engineering units to Dataframe field. This can be globaly disabled in the configuration page 22 | - Optimized queries using PIWebAPI batch endpoint 23 | - Improved raw query processing 24 | - Added variable support in raw query 25 | - Fixed annotations support 26 | - Updated to Grafana plugin SDK v9.4.7 27 | - Fixed PI AF calculation 28 | - Added plugin screenshots 29 | 30 | ## 4.1.0 31 | 32 | - Modified the PI Webapi controller endpoints used when calculation is selected 33 | - Allow calculation when last value option is selected 34 | - When calculation is selected, change label from Interpolated to Interval 35 | - Fixed issue with variable in Element Path 36 | 37 | ## 4.2.0 38 | 39 | - Fixed issue that only odd attributes were been shown 40 | - Fixed issue when fetching afServerWebId 41 | 42 | ## 5.0.0 43 | 44 | - Migrated backend to Go language 45 | - Changed the query editor layout 46 | - Support Grafana version 11 47 | - Drop support for Grafana 8.x and 9.x 48 | 49 | ## 5.1.0 50 | 51 | - Add units and description to new format - issue #154 52 | - Fixed digital state - issue #159 53 | - Fixed summary data - issue #160 54 | - Fixed an error in recorded max number of points - issue #162 55 | - Fix issue with summary when migrating from previous versions - issue $160 56 | 57 | - Updated the query editor layout 58 | - Added boundary type support in recorded values 59 | - Recognize partial usage of variables in elements 60 | - Added configuration to hide API errors in panel 61 | - Truncate time from grafana date time picker to seconds 62 | - Fixed warnings during deploy 63 | - Fixed LICENSE file 64 | 65 | ### 5.2.0 66 | 67 | - Improved query performance to PiWebAPI by joing all queries in Panel into one batch request only 68 | - Change the Query Editor layout 69 | - Increased WebID cache from 1 hour to 12 hours and made it configurable 70 | 71 | - Added experimental feature to cache latest response in case of request failure to PiWebAPI -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "grid-protection-alliance-osisoftpi-grafana", 3 | "version": "5.2.0", 4 | "description": "OSISoft PI Grafana Plugin", 5 | "scripts": { 6 | "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 7 | "dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 8 | "e2e": "yarn exec cypress install && yarn exec grafana-e2e run", 9 | "e2e:update": "yarn exec cypress install && yarn exec grafana-e2e run --update-screenshots", 10 | "lint": "eslint --cache --ignore-path ./.gitignore --ext .js,.jsx,.ts,.tsx .", 11 | "lint:fix": "yarn run lint --fix", 12 | "server": "docker-compose up --build", 13 | "sign": "npx --yes @grafana/sign-plugin@latest", 14 | "start": "yarn watch", 15 | "test": "jest --watch --onlyChanged", 16 | "test:ci": "jest --passWithNoTests --maxWorkers 4", 17 | "typecheck": "tsc --noEmit", 18 | "build:backend": "mage -v build:linux && mage -v build:windows && mage -v build:darwin" 19 | }, 20 | "author": "GridProtectionAlliance", 21 | "license": "Apache-2.0", 22 | "devDependencies": { 23 | "@babel/core": "^7.24.6", 24 | "@grafana/eslint-config": "^7.0.0", 25 | "@grafana/plugin-e2e": "^1.2.0", 26 | "@grafana/tsconfig": "^1.3.0-rc1", 27 | "@swc/core": "^1.5.7", 28 | "@swc/helpers": "^0.5.11", 29 | "@swc/jest": "^0.2.36", 30 | "@testing-library/jest-dom": "^6.4.5", 31 | "@testing-library/react": "^15.0.7", 32 | "@types/jest": "^29.5.12", 33 | "@types/lodash": "^4.17.4", 34 | "@types/node": "^20.12.12", 35 | "@types/react-router-dom": "^5.3.3", 36 | "@types/testing-library__jest-dom": "5.14.8", 37 | "copy-webpack-plugin": "^12.0.2", 38 | "css-loader": "^7.1.2", 39 | "eslint-plugin-deprecation": "^2.0.0", 40 | "eslint-webpack-plugin": "^4.2.0", 41 | "fork-ts-checker-webpack-plugin": "^9.0.2", 42 | "glob": "^10.4.1", 43 | "identity-obj-proxy": "3.0.0", 44 | "jest": "^29.7.0", 45 | "jest-environment-jsdom": "^29.7.0", 46 | "prettier": "^3.2.5", 47 | "replace-in-file-webpack-plugin": "^1.0.6", 48 | "sass": "^1.77.2", 49 | "sass-loader": "^14.2.1", 50 | "style-loader": "^4.0.0", 51 | "swc-loader": "^0.2.6", 52 | "ts-node": "^10.9.2", 53 | "tsconfig-paths": "^4.2.0", 54 | "typescript": "5.4.5", 55 | "webpack": "^5.91.0", 56 | "webpack-cli": "^5.1.4", 57 | "webpack-livereload-plugin": "^3.0.2" 58 | }, 59 | "engines": { 60 | "node": ">=20" 61 | }, 62 | "dependencies": { 63 | "@emotion/css": "^11.11.2", 64 | "@grafana/data": "^11.0.0", 65 | "@grafana/runtime": "^11.0.0", 66 | "@grafana/schema": "^11.0.0", 67 | "@grafana/ui": "^11.0.0", 68 | "react": "18.3.1", 69 | "react-dom": "18.3.1", 70 | "tslib": "^2.6.2" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PI Web API Datasource for Grafana 2 | 3 | This data source provides access to OSIsoft PI and PI-AF data through PI Web API. 4 | 5 | ![display](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/system_overview.png) 6 | 7 | # Usage 8 | 9 | ## Datasource Configuration 10 | 11 | Create a new instance of the data source from the Grafana Data Sources 12 | administration page. 13 | 14 | It is recommended to use "proxy" access settings. 15 | You may need to add "Basic" authentication to your PIWebAPI 16 | server configuration and add credentials to the data source settings. 17 | 18 | NOTE: If you are using PI-Coresight, it is recommended to create a new 19 | instance of PI Web API for use with this plugin. 20 | 21 | See [PI Web API Documentation](https://docs.osisoft.com/bundle/pi-web-api) 22 | for more information on configuring PI Web API. 23 | 24 | 25 | ## Querying via the PI Asset Framework 26 | 27 | ![elements_and_attributes.png](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/elements_and_attributes.png) 28 | 29 | 1. Verify that the `PI Point Search` toggle is greyed off 30 | 2. In `Element` click `Select AF Database` and choose desired database in list 31 | * A new ui segment should appear: `Select AF Element` 32 | * A known bug currently exists where this new ui segment fails. In this case select the `+` in `Attributes` and it will force create the ui segment 33 | 3. Click `Select AF Element` and select the desired AF element 34 | 4. Repeat step 3 until the desired element is reached 35 | 5. Under `Attributes` click the `+` icon to list attributes found in selected element; select attribute from dropdown 36 | * If list of attributes does not appear begin typing attribute name and attributes should appear 37 | * This method can also be used to filter through long lists of attributes 38 | 6. Repeat step 5 as many times as desired 39 | 40 | 41 | ## Querying via the PI Dataserver (PI Points) 42 | 43 | ![pi_point_query.png](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/pi_point_query.png) 44 | 45 | 1. Toggle the `Pi Point Search` on 46 | 2. Under `Data Server` click `Select Dataserver` and select desired PI Dataserver 47 | 3. Under `PI Points` click the `+` icon to open a text entry field 48 | 4. Type the exact name of the desired PI Point; it is NOT case sensitive (`sinusoid` === `SINUSOID` === `sInUsOiD`) 49 | 5. Repeat steps 3 - 4 for as many PI Points as desired 50 | 51 | 52 | # Template Variables 53 | 54 | Child elements are the only supported template variables. 55 | Currently, the query interface requires a json query. 56 | 57 | An example config is shown below. 58 | `{"path": "PISERVER\\DatabaseName\\ElementNameWithChildren"}` 59 | 60 | ![template_setup_1.png](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/template_setup_1.png) 61 | 62 | 63 | # Event Frames and Annotations 64 | 65 | This datasource can use **AF Event Frames** as annotations. 66 | 67 | ![event-frame](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/event_frame.png) 68 | 69 | Creating an annotation query and use the Event Frame category as the query string. 70 | Color and regex replacement strings for the name are supported. 71 | 72 | For example: 73 | ![annotations](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/annotations.png) 74 | 75 | 76 | # Installation 77 | 78 | Install using the grafana-cli or clone the repository directly 79 | into your Grafana plugin directory. 80 | 81 | ``` 82 | grafana-cli plugins install gridprotectionalliance-osisoftpi-datasource 83 | ``` 84 | 85 | 86 | # Trademarks 87 | 88 | All product names, logos, and brands are property of their respective owners. 89 | All company, product and service names used in this website are for identification purposes only. 90 | Use of these names, logos, and brands does not imply endorsement. 91 | 92 | OSIsoft, the OSIsoft logo and logotype, and PI Web API are all trademarks of [AVEVA Group plc](https://www.aveva.com/en/legal/osisoft-terms-and-conditions/). 93 | -------------------------------------------------------------------------------- /dist/README.md: -------------------------------------------------------------------------------- 1 | # PI Web API Datasource for Grafana 2 | 3 | This data source provides access to OSIsoft PI and PI-AF data through PI Web API. 4 | 5 | ![display](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/system_overview.png) 6 | 7 | # Usage 8 | 9 | ## Datasource Configuration 10 | 11 | Create a new instance of the data source from the Grafana Data Sources 12 | administration page. 13 | 14 | It is recommended to use "proxy" access settings. 15 | You may need to add "Basic" authentication to your PIWebAPI 16 | server configuration and add credentials to the data source settings. 17 | 18 | NOTE: If you are using PI-Coresight, it is recommended to create a new 19 | instance of PI Web API for use with this plugin. 20 | 21 | See [PI Web API Documentation](https://docs.osisoft.com/bundle/pi-web-api) 22 | for more information on configuring PI Web API. 23 | 24 | 25 | ## Querying via the PI Asset Framework 26 | 27 | ![elements_and_attributes.png](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/elements_and_attributes.png) 28 | 29 | 1. Verify that the `PI Point Search` toggle is greyed off 30 | 2. In `Element` click `Select AF Database` and choose desired database in list 31 | * A new ui segment should appear: `Select AF Element` 32 | * A known bug currently exists where this new ui segment fails. In this case select the `+` in `Attributes` and it will force create the ui segment 33 | 3. Click `Select AF Element` and select the desired AF element 34 | 4. Repeat step 3 until the desired element is reached 35 | 5. Under `Attributes` click the `+` icon to list attributes found in selected element; select attribute from dropdown 36 | * If list of attributes does not appear begin typing attribute name and attributes should appear 37 | * This method can also be used to filter through long lists of attributes 38 | 6. Repeat step 5 as many times as desired 39 | 40 | 41 | ## Querying via the PI Dataserver (PI Points) 42 | 43 | ![pi_point_query.png](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/pi_point_query.png) 44 | 45 | 1. Toggle the `Pi Point Search` on 46 | 2. Under `Data Server` click `Select Dataserver` and select desired PI Dataserver 47 | 3. Under `PI Points` click the `+` icon to open a text entry field 48 | 4. Type the exact name of the desired PI Point; it is NOT case sensitive (`sinusoid` === `SINUSOID` === `sInUsOiD`) 49 | 5. Repeat steps 3 - 4 for as many PI Points as desired 50 | 51 | 52 | # Template Variables 53 | 54 | Child elements are the only supported template variables. 55 | Currently, the query interface requires a json query. 56 | 57 | An example config is shown below. 58 | `{"path": "PISERVER\\DatabaseName\\ElementNameWithChildren"}` 59 | 60 | ![template_setup_1.png](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/template_setup_1.png) 61 | 62 | 63 | # Event Frames and Annotations 64 | 65 | This datasource can use **AF Event Frames** as annotations. 66 | 67 | ![event-frame](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/event_frame.png) 68 | 69 | Creating an annotation query and use the Event Frame category as the query string. 70 | Color and regex replacement strings for the name are supported. 71 | 72 | For example: 73 | ![annotations](https://github.com/GridProtectionAlliance/osisoftpi-grafana/raw/master/docs/img/annotations.png) 74 | 75 | 76 | # Installation 77 | 78 | Install using the grafana-cli or clone the repository directly 79 | into your Grafana plugin directory. 80 | 81 | ``` 82 | grafana-cli plugins install gridprotectionalliance-osisoftpi-datasource 83 | ``` 84 | 85 | 86 | # Trademarks 87 | 88 | All product names, logos, and brands are property of their respective owners. 89 | All company, product and service names used in this website are for identification purposes only. 90 | Use of these names, logos, and brands does not imply endorsement. 91 | 92 | OSIsoft, the OSIsoft logo and logotype, and PI Web API are all trademarks of [AVEVA Group plc](https://www.aveva.com/en/legal/osisoft-terms-and-conditions/). 93 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DataQuery } from '@grafana/schema'; 2 | import { DataSourceJsonData, SelectableValue } from '@grafana/data'; 3 | import internal from 'stream'; 4 | 5 | export interface PiwebapiElementPath { 6 | path: string; 7 | variable: string; 8 | } 9 | 10 | export interface PiwebapiInternalRsp { 11 | data: PiwebapiRsp; 12 | status: number; 13 | url: string; 14 | } 15 | 16 | export interface PiwebapiRsp { 17 | Name?: string; 18 | InstanceType?: string; 19 | Items?: PiwebapiRsp[]; 20 | WebId?: string; 21 | HasChildren?: boolean; 22 | Type?: string; 23 | DefaultUnitsName?: string; 24 | Description?: string; 25 | Path?: string; 26 | } 27 | 28 | export interface PiDataServer { 29 | name: string | undefined; 30 | webid: string | undefined; 31 | } 32 | 33 | export interface PIWebAPISelectableValue { 34 | webId?: string; 35 | value?: string; 36 | type?: string; 37 | expandable?: boolean; 38 | } 39 | 40 | export interface PiWebAPIEnable { 41 | enable: boolean; 42 | } 43 | 44 | export interface PiWebAPIRegex extends PiWebAPIEnable { 45 | search?: string; 46 | replace?: string 47 | } 48 | 49 | export interface PiWebAPIRecordedValue extends PiWebAPIEnable { 50 | maxNumber?: number; 51 | boundaryType?: string; 52 | } 53 | 54 | export interface PiWebAPIInterpolate extends PiWebAPIEnable { 55 | interval?: string; 56 | } 57 | 58 | export interface PiWebAPISummary extends PiWebAPIEnable { 59 | types?: Array>; 60 | basis?: string, 61 | duration?: string, 62 | sampleTypeInterval?: boolean, 63 | sampleInterval?: string 64 | } 65 | 66 | export interface PIWebAPIAnnotationsQuery extends DataQuery { 67 | target: string; 68 | } 69 | 70 | export interface PIWebAPIQuery extends DataQuery { 71 | target: string; 72 | attributes: Array>; 73 | segments: Array>; 74 | useUnit: PiWebAPIEnable; 75 | regex: PiWebAPIRegex; 76 | interpolate: PiWebAPIInterpolate; 77 | recordedValues: PiWebAPIRecordedValue; 78 | useLastValue: PiWebAPIEnable; 79 | summary: PiWebAPISummary; 80 | digitalStates: PiWebAPIEnable; 81 | isPiPoint: boolean; 82 | elementPath?: string; 83 | hideError?: boolean; 84 | isAnnotation?: boolean; 85 | webid?: string; 86 | display?: any; 87 | nodata?: string, 88 | enableStreaming?: any; 89 | expression?: string; 90 | rawQuery?: boolean; 91 | query?: string; 92 | // annotations items 93 | database?: PiwebapiRsp; 94 | template?: PiwebapiRsp; 95 | showEndTime?: boolean; 96 | attribute?: any; 97 | nameFilter?: string; 98 | categoryName?: string; 99 | hashCode?: string; 100 | } 101 | 102 | export const defaultQuery: Partial = { 103 | target: ';', 104 | attributes: [], 105 | segments: [], 106 | regex: { enable: false }, 107 | nodata: 'Null', 108 | summary: { 109 | enable: false, 110 | types: [], 111 | basis: 'EventWeighted', 112 | duration: '', 113 | sampleTypeInterval: false, 114 | sampleInterval: '' 115 | }, 116 | expression: '', 117 | interpolate: { enable: false }, 118 | useLastValue: { enable: false }, 119 | recordedValues: { enable: false, boundaryType: 'Inside' }, 120 | digitalStates: { enable: false }, 121 | enableStreaming: { enable: false }, 122 | useUnit: { enable: false }, 123 | isPiPoint: false, 124 | }; 125 | 126 | /** 127 | * These are options configured for each DataSource instance 128 | */ 129 | export interface PIWebAPIDataSourceJsonData extends DataSourceJsonData { 130 | url?: string; 131 | access?: string; 132 | piserver?: string; 133 | afserver?: string; 134 | afdatabase?: string; 135 | pipoint?: boolean; 136 | newFormat?: boolean; 137 | maxCacheTime?: number; 138 | useUnit?: boolean; 139 | useExperimental?: boolean; 140 | useStreaming?: boolean; 141 | useResponseCache?: boolean; 142 | } 143 | 144 | /** 145 | * Value that is used in the backend, but never sent over HTTP to the frontend 146 | */ 147 | export interface PIWebAPISecureJsonData { 148 | apiKey?: string; 149 | } 150 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/GridProtectionAlliance/osisoftpi-grafana 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/go-co-op/gocron v1.37.0 7 | github.com/google/uuid v1.6.0 8 | github.com/gorilla/websocket v1.5.2 9 | github.com/grafana/grafana-plugin-sdk-go v0.250.0 10 | go.opentelemetry.io/otel v1.29.0 11 | go.opentelemetry.io/otel/trace v1.29.0 12 | ) 13 | 14 | require ( 15 | github.com/BurntSushi/toml v1.3.2 // indirect 16 | github.com/apache/arrow/go/v15 v15.0.2 // indirect 17 | github.com/beorn7/perks v1.0.1 // indirect 18 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 19 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 20 | github.com/cheekybits/genny v1.0.0 // indirect 21 | github.com/chromedp/cdproto v0.0.0-20220208224320-6efb837e6bc2 // indirect 22 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 23 | github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 // indirect 24 | github.com/fatih/color v1.15.0 // indirect 25 | github.com/getkin/kin-openapi v0.124.0 // indirect 26 | github.com/go-logr/logr v1.4.2 // indirect 27 | github.com/go-logr/stdr v1.2.2 // indirect 28 | github.com/go-openapi/jsonpointer v0.20.2 // indirect 29 | github.com/go-openapi/swag v0.22.8 // indirect 30 | github.com/goccy/go-json v0.10.2 // indirect 31 | github.com/gogo/protobuf v1.3.2 // indirect 32 | github.com/golang/protobuf v1.5.4 // indirect 33 | github.com/google/flatbuffers v23.5.26+incompatible // indirect 34 | github.com/google/go-cmp v0.6.0 // indirect 35 | github.com/gorilla/mux v1.8.1 // indirect 36 | github.com/grafana/otel-profiling-go v0.5.1 // indirect 37 | github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect 38 | github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 // indirect 39 | github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect 40 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect 41 | github.com/hashicorp/go-hclog v1.6.3 // indirect 42 | github.com/hashicorp/go-plugin v1.6.1 // indirect 43 | github.com/hashicorp/yamux v0.1.1 // indirect 44 | github.com/invopop/yaml v0.2.0 // indirect 45 | github.com/josharian/intern v1.0.0 // indirect 46 | github.com/json-iterator/go v1.1.12 // indirect 47 | github.com/klauspost/compress v1.17.9 // indirect 48 | github.com/klauspost/cpuid/v2 v2.2.5 // indirect 49 | github.com/magefile/mage v1.15.0 // indirect 50 | github.com/mailru/easyjson v0.7.7 // indirect 51 | github.com/mattetti/filebuffer v1.0.1 // indirect 52 | github.com/mattn/go-colorable v0.1.13 // indirect 53 | github.com/mattn/go-isatty v0.0.19 // indirect 54 | github.com/mattn/go-runewidth v0.0.9 // indirect 55 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 56 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 57 | github.com/modern-go/reflect2 v1.0.2 // indirect 58 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 59 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 60 | github.com/oklog/run v1.1.0 // indirect 61 | github.com/olekukonko/tablewriter v0.0.5 // indirect 62 | github.com/perimeterx/marshmallow v1.1.5 // indirect 63 | github.com/pierrec/lz4/v4 v4.1.18 // indirect 64 | github.com/prometheus/client_golang v1.20.3 // indirect 65 | github.com/prometheus/client_model v0.6.1 // indirect 66 | github.com/prometheus/common v0.55.0 // indirect 67 | github.com/prometheus/procfs v0.15.1 // indirect 68 | github.com/robfig/cron/v3 v3.0.1 // indirect 69 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 70 | github.com/unknwon/bra v0.0.0-20200517080246-1e3013ecaff8 // indirect 71 | github.com/unknwon/com v1.0.1 // indirect 72 | github.com/unknwon/log v0.0.0-20150304194804-e617c87089d3 // indirect 73 | github.com/urfave/cli v1.22.15 // indirect 74 | github.com/zeebo/xxh3 v1.0.2 // indirect 75 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 // indirect 76 | go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.53.0 // indirect 77 | go.opentelemetry.io/contrib/propagators/jaeger v1.29.0 // indirect 78 | go.opentelemetry.io/contrib/samplers/jaegerremote v0.23.0 // indirect 79 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect 80 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 // indirect 81 | go.opentelemetry.io/otel/metric v1.29.0 // indirect 82 | go.opentelemetry.io/otel/sdk v1.29.0 // indirect 83 | go.opentelemetry.io/proto/otlp v1.3.1 // indirect 84 | go.uber.org/atomic v1.9.0 // indirect 85 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 86 | golang.org/x/mod v0.17.0 // indirect 87 | golang.org/x/net v0.29.0 // indirect 88 | golang.org/x/sync v0.8.0 // indirect 89 | golang.org/x/sys v0.25.0 // indirect 90 | golang.org/x/text v0.18.0 // indirect 91 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 92 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 93 | google.golang.org/genproto/googleapis/api v0.0.0-20240822170219-fc7c04adadcd // indirect 94 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect 95 | google.golang.org/grpc v1.66.0 // indirect 96 | google.golang.org/protobuf v1.34.2 // indirect 97 | gopkg.in/fsnotify/fsnotify.v1 v1.4.7 // indirect 98 | gopkg.in/yaml.v3 v3.0.1 // indirect 99 | ) 100 | -------------------------------------------------------------------------------- /.config/README.md: -------------------------------------------------------------------------------- 1 | # Default build configuration by Grafana 2 | 3 | **This is an auto-generated directory and is not intended to be changed! ⚠️** 4 | 5 | The `.config/` directory holds basic configuration for the different tools 6 | that are used to develop, test and build the project. In order to make it updates easier we ask you to 7 | not edit files in this folder to extend configuration. 8 | 9 | ## How to extend the basic configs? 10 | 11 | Bear in mind that you are doing it at your own risk, and that extending any of the basic configuration can lead 12 | to issues around working with the project. 13 | 14 | ### Extending the ESLint config 15 | 16 | Edit the `.eslintrc` file in the project root in order to extend the ESLint configuration. 17 | 18 | **Example:** 19 | 20 | ```json 21 | { 22 | "extends": "./.config/.eslintrc", 23 | "rules": { 24 | "react/prop-types": "off" 25 | } 26 | } 27 | ``` 28 | 29 | --- 30 | 31 | ### Extending the Prettier config 32 | 33 | Edit the `.prettierrc.js` file in the project root in order to extend the Prettier configuration. 34 | 35 | **Example:** 36 | 37 | ```javascript 38 | module.exports = { 39 | // Prettier configuration provided by Grafana scaffolding 40 | ...require('./.config/.prettierrc.js'), 41 | 42 | semi: false, 43 | }; 44 | ``` 45 | 46 | --- 47 | 48 | ### Extending the Jest config 49 | 50 | There are two configuration in the project root that belong to Jest: `jest-setup.js` and `jest.config.js`. 51 | 52 | **`jest-setup.js`:** A file that is run before each test file in the suite is executed. We are using it to 53 | set up the Jest DOM for the testing library and to apply some polyfills. ([link to Jest docs](https://jestjs.io/docs/configuration#setupfilesafterenv-array)) 54 | 55 | **`jest.config.js`:** The main Jest configuration file that extends the Grafana recommended setup. ([link to Jest docs](https://jestjs.io/docs/configuration)) 56 | 57 | #### ESM errors with Jest 58 | 59 | A common issue with the current jest config involves importing an npm package that only offers an ESM build. These packages cause jest to error with `SyntaxError: Cannot use import statement outside a module`. To work around this, we provide a list of known packages to pass to the `[transformIgnorePatterns](https://jestjs.io/docs/configuration#transformignorepatterns-arraystring)` jest configuration property. If need be, this can be extended in the following way: 60 | 61 | ```javascript 62 | process.env.TZ = 'UTC'; 63 | const { grafanaESModules, nodeModulesToTransform } = require('./config/jest/utils'); 64 | 65 | module.exports = { 66 | // Jest configuration provided by Grafana 67 | ...require('./.config/jest.config'), 68 | // Inform jest to only transform specific node_module packages. 69 | transformIgnorePatterns: [nodeModulesToTransform([...grafanaESModules, 'packageName'])], 70 | }; 71 | ``` 72 | 73 | --- 74 | 75 | ### Extending the TypeScript config 76 | 77 | Edit the `tsconfig.json` file in the project root in order to extend the TypeScript configuration. 78 | 79 | **Example:** 80 | 81 | ```json 82 | { 83 | "extends": "./.config/tsconfig.json", 84 | "compilerOptions": { 85 | "preserveConstEnums": true 86 | } 87 | } 88 | ``` 89 | 90 | --- 91 | 92 | ### Extending the Webpack config 93 | 94 | Follow these steps to extend the basic Webpack configuration that lives under `.config/`: 95 | 96 | #### 1. Create a new Webpack configuration file 97 | 98 | Create a new config file that is going to extend the basic one provided by Grafana. 99 | It can live in the project root, e.g. `webpack.config.ts`. 100 | 101 | #### 2. Merge the basic config provided by Grafana and your custom setup 102 | 103 | We are going to use [`webpack-merge`](https://github.com/survivejs/webpack-merge) for this. 104 | 105 | ```typescript 106 | // webpack.config.ts 107 | import type { Configuration } from 'webpack'; 108 | import { merge } from 'webpack-merge'; 109 | import grafanaConfig from './.config/webpack/webpack.config'; 110 | 111 | const config = async (env): Promise => { 112 | const baseConfig = await grafanaConfig(env); 113 | 114 | return merge(baseConfig, { 115 | // Add custom config here... 116 | output: { 117 | asyncChunks: true, 118 | }, 119 | }); 120 | }; 121 | 122 | export default config; 123 | ``` 124 | 125 | #### 3. Update the `package.json` to use the new Webpack config 126 | 127 | We need to update the `scripts` in the `package.json` to use the extended Webpack configuration. 128 | 129 | **Update for `build`:** 130 | 131 | ```diff 132 | -"build": "webpack -c ./.config/webpack/webpack.config.ts --env production", 133 | +"build": "webpack -c ./webpack.config.ts --env production", 134 | ``` 135 | 136 | **Update for `dev`:** 137 | 138 | ```diff 139 | -"dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", 140 | +"dev": "webpack -w -c ./webpack.config.ts --env development", 141 | ``` 142 | 143 | ### Configure grafana image to use when running docker 144 | 145 | By default, `grafana-enterprise` will be used as the docker image for all docker related commands. If you want to override this behavior, simply alter the `docker-compose.yaml` by adding the following build arg `grafana_image`. 146 | 147 | **Example:** 148 | 149 | ```yaml 150 | version: '3.7' 151 | 152 | services: 153 | grafana: 154 | container_name: 'myorg-basic-app' 155 | build: 156 | context: ./.config 157 | args: 158 | grafana_version: ${GRAFANA_VERSION:-9.1.2} 159 | grafana_image: ${GRAFANA_IMAGE:-grafana} 160 | ``` 161 | 162 | In this example, we assign the environment variable `GRAFANA_IMAGE` to the build arg `grafana_image` with a default value of `grafana`. This will allow you to set the value while running the docker-compose commands, which might be convenient in some scenarios. 163 | 164 | --- 165 | -------------------------------------------------------------------------------- /src/query/AnnotationsQueryEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useState } from 'react'; 2 | 3 | import { AnnotationQuery, QueryEditorProps, SelectableValue } from '@grafana/data'; 4 | import { AsyncSelect, InlineField, InlineFieldRow, InlineSwitch, Input } from '@grafana/ui'; 5 | 6 | import { PiWebAPIDatasource } from 'datasource'; 7 | import { PIWebAPIDataSourceJsonData, PIWebAPIQuery, PiwebapiRsp } from 'types'; 8 | 9 | const SMALL_LABEL_WIDTH = 20; 10 | const LABEL_WIDTH = 30; 11 | const MIN_INPUT_WIDTH = 50; 12 | 13 | type PiWebAPIQueryEditorProps = QueryEditorProps; 14 | 15 | type Props = PiWebAPIQueryEditorProps & { 16 | annotation?: AnnotationQuery; 17 | onAnnotationChange?: (annotation: AnnotationQuery) => void; 18 | }; 19 | 20 | export const PiWebAPIAnnotationsQueryEditor = memo(function PiWebAPIAnnotationQueryEditor(props: Props) { 21 | const { query, datasource, annotation, onChange, onRunQuery } = props; 22 | 23 | const [afWebId, setAfWebId] = useState(''); 24 | const [database, setDatabase] = useState(annotation?.target?.database ?? {}); 25 | 26 | // this should never happen, but we want to keep typescript happy 27 | if (annotation === undefined) { 28 | return null; 29 | } 30 | 31 | const getEventFrames = (): Promise>> => { 32 | return datasource.getEventFrameTemplates(database?.WebId!).then((templ: PiwebapiRsp[]) => { 33 | return templ.map((d) => ({ label: d.Name, value: d })); 34 | }); 35 | }; 36 | 37 | const getDatabases = (): Promise>> => { 38 | return datasource.getDatabases(afWebId).then((dbs: PiwebapiRsp[]) => { 39 | return dbs.map((d) => ({ label: d.Name, value: d })); 40 | }); 41 | }; 42 | 43 | const getValue = (key: string) => { 44 | const query: any = annotation.target as any; 45 | if (!query || !query[key]) { 46 | return; 47 | } 48 | return { label: query[key].Name, value: query[key] }; 49 | }; 50 | 51 | datasource.getAssetServer(datasource.afserver.name).then((result) => { 52 | setAfWebId(result.WebId!); 53 | }); 54 | 55 | return ( 56 | <> 57 |
58 | 59 | 60 | { 66 | setDatabase(e.value); 67 | onChange({ ...query, database: e.value, template: undefined }); 68 | }} 69 | defaultOptions 70 | /> 71 | 72 | 73 | onChange({ ...query, template: e.value })} 79 | defaultOptions 80 | /> 81 | 82 | 83 | onChange({ ...query, showEndTime: e.currentTarget.checked })} 86 | /> 87 | 88 | 89 | 90 | 91 | onRunQuery()} 95 | onChange={(e) => onChange({ ...query, categoryName: e.currentTarget.value })} 96 | placeholder="Enter category name" 97 | /> 98 | 99 | 100 | onRunQuery()} 104 | onChange={(e) => onChange({ ...query, nameFilter: e.currentTarget.value })} 105 | placeholder="Enter name filter" 106 | /> 107 | 108 | 109 | 110 | 111 | 114 | onChange({ 115 | ...query, 116 | regex: { ...query.regex, enable: e.currentTarget.checked }, 117 | }) 118 | } 119 | /> 120 | 121 | 122 | onRunQuery()} 126 | onChange={(e) => 127 | onChange({ 128 | ...query, 129 | regex: { ...query.regex, search: e.currentTarget.value }, 130 | }) 131 | } 132 | placeholder="(.*)" 133 | width={MIN_INPUT_WIDTH} 134 | /> 135 | 136 | 137 | onRunQuery()} 141 | onChange={(e) => 142 | onChange({ 143 | ...query, 144 | regex: { ...query.regex, replace: e.currentTarget.value }, 145 | }) 146 | } 147 | placeholder="$1" 148 | /> 149 | 150 | 151 | 152 | 153 | 156 | onChange({ 157 | ...query!, 158 | attribute: { ...query.attribute, enable: e.currentTarget.checked }, 159 | }) 160 | } 161 | /> 162 | 163 | 164 | onRunQuery()} 168 | onChange={(e) => 169 | onChange({ 170 | ...query!, 171 | attribute: { ...query.attribute, name: e.currentTarget.value }, 172 | }) 173 | } 174 | placeholder="Enter name" 175 | /> 176 | 177 | 178 |
179 | 180 | ); 181 | }); 182 | -------------------------------------------------------------------------------- /pkg/plugin/timeseries_query_models.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "time" 7 | ) 8 | 9 | type Query struct { 10 | RefID string `json:"RefID"` 11 | QueryType string `json:"QueryType"` 12 | MaxDataPoints int `json:"MaxDataPoints"` 13 | Interval int64 `json:"Interval"` 14 | TimeRange struct { 15 | From time.Time `json:"From"` 16 | To time.Time `json:"To"` 17 | } `json:"TimeRange"` 18 | Pi PIWebAPIQuery `json:"JSON"` 19 | } 20 | 21 | // isValidQuery checks if the query is valid. 22 | // This function is called before the query is executed to handle 23 | // edge cases where the front end sends invalid queries. 24 | func (q *Query) isValidQuery() error { 25 | if !q.Pi.checkValidTargets() { 26 | return fmt.Errorf("no targets found in query") 27 | } 28 | 29 | if q.Pi.checkNilSegments() { 30 | return fmt.Errorf("no segments found in query") 31 | } 32 | 33 | return nil 34 | } 35 | 36 | func (q *Query) getIntervalTime() string { 37 | if q.Pi.Interpolate.Enable && q.Pi.Interpolate.Interval != "" { 38 | return q.Pi.Interpolate.Interval 39 | } 40 | return fmt.Sprintf("%dms", q.Interval/1e6) 41 | } 42 | 43 | func (q *Query) getWindowedTimeStampURI() string { 44 | // Potential Improvement: Make windowWidth a user input 45 | windowWidth := q.getMaxDataPoints() 46 | fromTime := q.TimeRange.From.Truncate(time.Second) 47 | toTime := q.TimeRange.To.Truncate(time.Second) 48 | 49 | diff := toTime.Sub(fromTime).Nanoseconds() / int64(windowWidth) 50 | timeQuery := "time=" + fromTime.Format(time.RFC3339) 51 | 52 | for i := 1; i < windowWidth; i++ { 53 | newTime := fromTime.Add(time.Duration(i * int(diff))) 54 | timeQuery += "&time=" + newTime.Format(time.RFC3339) 55 | } 56 | 57 | timeQuery += "&time=" + toTime.Format(time.RFC3339) 58 | 59 | return "/times?" + timeQuery 60 | } 61 | 62 | func (q *Query) getTimeRangeURIComponent() string { 63 | return "?startTime=" + q.TimeRange.From.UTC().Truncate(time.Second).Format(time.RFC3339) + 64 | "&endTime=" + q.TimeRange.To.UTC().Truncate(time.Second).Format(time.RFC3339) 65 | } 66 | 67 | func (q *Query) getTimeRangeURIToComponent() string { 68 | return q.TimeRange.To.UTC().Truncate(time.Second).Format(time.RFC3339) 69 | } 70 | 71 | func (q *Query) isstreamingEnabled() bool { 72 | if q.Pi.EnableStreaming == nil || q.Pi.EnableStreaming.Enable == nil { 73 | return false 74 | } 75 | var streamingEnabled = *q.Pi.EnableStreaming.Enable 76 | return streamingEnabled 77 | } 78 | 79 | func (q *Query) isStreamable() bool { 80 | return !q.Pi.isExpression() && q.isstreamingEnabled() 81 | } 82 | 83 | // func (q *PiProcessedQuery) isSummary() bool { 84 | // if q.Summary == nil { 85 | // return false 86 | // } 87 | // if q.Summary.Types == nil { 88 | // return false 89 | // } 90 | // return *q.Summary.Basis != "" && len(*q.Summary.Types) > 0 91 | // } 92 | 93 | func (q *PiProcessedQuery) getNoDataReplace() string { 94 | if q.Nodata == nil { 95 | return "" 96 | } 97 | return *q.Nodata 98 | } 99 | 100 | type PIWebAPIQuery struct { 101 | Attributes []QueryProperties `json:"attributes"` 102 | Datasource struct { 103 | Type string `json:"type"` 104 | UID string `json:"uid"` 105 | } `json:"datasource"` 106 | DatasourceID int `json:"datasourceId"` 107 | DigitalStates *struct { 108 | Enable *bool `json:"enable"` 109 | } `json:"digitalStates"` 110 | UseLastValue *struct { 111 | Enable *bool `json:"enable"` 112 | } `json:"useLastValue"` 113 | EnableStreaming *struct { 114 | Enable *bool `json:"enable"` 115 | } `json:"EnableStreaming"` 116 | ElementPath string `json:"elementPath"` 117 | Expression string `json:"expression"` 118 | Hide bool `json:"hide"` 119 | Interpolate struct { 120 | Enable bool `json:"enable"` 121 | Interval string `json:"interval"` 122 | } `json:"interpolate"` 123 | IsPiPoint bool `json:"isPiPoint"` 124 | HideError bool `json:"hideError"` 125 | MaxDataPoints *int `json:"maxDataPoints"` 126 | RecordedValues *struct { 127 | Enable *bool `json:"enable"` 128 | MaxNumber *int `json:"maxNumber"` 129 | BoundaryType *string `json:"boundaryType"` 130 | } `json:"recordedValues"` 131 | RefID *string `json:"refId"` 132 | Regex *Regex `json:"regex"` 133 | Nodata *string `json:"nodata"` 134 | // Segments *[]string `json:"segments"` 135 | Summary *QuerySummary `json:"summary"` 136 | Target *string `json:"target"` 137 | Display *string `json:"display"` 138 | UseUnit *struct { 139 | Enable *bool `json:"enable"` 140 | } `json:"useUnit"` 141 | HashCode string `json:"hashCode"` 142 | } 143 | 144 | type QuerySummary struct { 145 | Enable *bool `json:"enable"` 146 | Basis *string `json:"basis"` 147 | Duration *string `json:"duration"` 148 | Types *[]SummaryType `json:"types"` 149 | SampleTypeInterval *bool `json:"sampleTypeInterval"` 150 | SampleInterval *string `json:"sampleInterval"` 151 | } 152 | 153 | type QueryPropertiesValue struct { 154 | Value string `json:"value"` 155 | } 156 | 157 | type QueryProperties struct { 158 | Label string `json:"label"` 159 | Value QueryPropertiesValue `json:"value"` 160 | } 161 | 162 | type SummaryType struct { 163 | Label string `json:"label"` 164 | Value SummaryTypeValue `json:"value"` 165 | } 166 | 167 | type SummaryTypeValue struct { 168 | Expandable bool `json:"expandable"` 169 | Value string `json:"value"` 170 | } 171 | 172 | type Regex struct { 173 | Enable *bool `json:"enable"` 174 | Search *string `json:"search"` 175 | Replace *string `json:"replace"` 176 | } 177 | 178 | type FrameProcessed struct { 179 | val reflect.Value 180 | prevVal reflect.Value 181 | values any 182 | timestamps []time.Time 183 | badValues []int 184 | sliceType reflect.Type 185 | } 186 | 187 | type PiProcessedQuery struct { 188 | Label string `json:"Label"` 189 | WebID string `json:"WebID"` 190 | UID string `json:"-"` 191 | IntervalNanoSeconds int64 `json:"IntervalNanoSeconds"` 192 | IsPIPoint bool `json:"IsPiPoint"` 193 | HideError bool `json:"HideError"` 194 | Streamable bool `json:"isStreamable"` 195 | FullTargetPath string `json:"FullTargetPath"` 196 | ResponseUnits string `json:"ResponseUnits"` 197 | BatchRequest BatchSubRequestMap `json:"BatchRequest"` 198 | Response PiBatchData `json:"ResponseData"` 199 | UseUnit bool `json:"UseUnit"` 200 | DigitalStates bool `json:"DigitalStates"` 201 | Display *string `json:"Display"` 202 | Nodata *string `json:"Nodata"` 203 | Regex *Regex `json:"Regex"` 204 | Summary *QuerySummary `json:"Summary"` 205 | HashCode string `json:"HashCode"` 206 | StartTime time.Time `json:"StartTime"` 207 | EndTime time.Time `json:"EndTime"` 208 | Resource string 209 | TargetPath string 210 | Variable string 211 | RefID string 212 | Error error 213 | Status int 214 | Cached bool 215 | Index int 216 | } 217 | 218 | type Links struct { 219 | First string `json:"First"` 220 | Last string `json:"Last"` 221 | } 222 | -------------------------------------------------------------------------------- /.config/webpack/webpack.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️ 3 | * 4 | * In order to extend the configuration follow the steps in 5 | * https://grafana.com/developers/plugin-tools/create-a-plugin/extend-a-plugin/extend-configurations#extend-the-webpack-config 6 | */ 7 | 8 | import CopyWebpackPlugin from 'copy-webpack-plugin'; 9 | import ESLintPlugin from 'eslint-webpack-plugin'; 10 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'; 11 | import LiveReloadPlugin from 'webpack-livereload-plugin'; 12 | import path from 'path'; 13 | import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin'; 14 | import { 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: path.resolve(__dirname, '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]' : '[file]', 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: `public/plugins/${pluginJson.id}/`, 139 | uniqueName: pluginJson.id, 140 | }, 141 | 142 | plugins: [ 143 | new CopyWebpackPlugin({ 144 | patterns: [ 145 | // If src/README.md exists use it; otherwise the root README 146 | // To `compiler.options.output` 147 | { from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true }, 148 | { from: 'plugin.json', to: '.' }, 149 | { from: '../LICENSE', to: '.' }, 150 | { from: '../CHANGELOG.md', to: '.', force: true }, 151 | { from: '**/*.json', to: '.' }, // TODO 152 | { from: '**/*.svg', to: '.', noErrorOnMissing: true }, // Optional 153 | { from: '**/*.png', to: '.', noErrorOnMissing: true }, // Optional 154 | { from: '**/*.html', to: '.', noErrorOnMissing: true }, // Optional 155 | { from: 'img/**/*', to: '.', noErrorOnMissing: true }, // Optional 156 | { from: 'libs/**/*', to: '.', noErrorOnMissing: true }, // Optional 157 | { from: 'static/**/*', to: '.', noErrorOnMissing: true }, // Optional 158 | { from: '**/query_help.md', to: '.', noErrorOnMissing: true }, // Optional 159 | ], 160 | }), 161 | // Replace certain template-variables in the README and plugin.json 162 | new ReplaceInFileWebpackPlugin([ 163 | { 164 | dir: DIST_DIR, 165 | files: ['plugin.json', 'README.md'], 166 | rules: [ 167 | { 168 | search: /\%VERSION\%/g, 169 | replace: getPackageJson().version, 170 | }, 171 | { 172 | search: /\%TODAY\%/g, 173 | replace: new Date().toISOString().substring(0, 10), 174 | }, 175 | { 176 | search: /\%PLUGIN_ID\%/g, 177 | replace: pluginJson.id, 178 | }, 179 | ], 180 | }, 181 | ]), 182 | ...(env.development ? [ 183 | new LiveReloadPlugin(), 184 | new ForkTsCheckerWebpackPlugin({ 185 | async: Boolean(env.development), 186 | issue: { 187 | include: [{ file: '**/*.{ts,tsx}' }], 188 | }, 189 | typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') }, 190 | }), 191 | new ESLintPlugin({ 192 | extensions: ['.ts', '.tsx'], 193 | lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files 194 | }), 195 | ] : []), 196 | ], 197 | 198 | resolve: { 199 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 200 | // handle resolving "rootDir" paths 201 | modules: [path.resolve(process.cwd(), 'src'), 'node_modules'], 202 | unsafeCache: true, 203 | }, 204 | }; 205 | 206 | if (isWSL()) { 207 | baseConfig.watchOptions = { 208 | poll: 3000, 209 | ignored: /node_modules/, 210 | }; 211 | } 212 | 213 | return baseConfig; 214 | }; 215 | 216 | export default config; 217 | -------------------------------------------------------------------------------- /pkg/plugin/annotation_models.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "encoding/json" 5 | "time" 6 | ) 7 | 8 | type PIAnnotationQuery struct { 9 | RefID string `json:"RefID"` 10 | QueryType string `json:"QueryType"` 11 | MaxDataPoints int `json:"MaxDataPoints"` 12 | Interval int64 `json:"Interval"` 13 | TimeRange TimeRange `json:"TimeRange"` 14 | JSON PIWebAPIAnnotationQuery `json:"JSON"` 15 | } 16 | 17 | type TimeRange struct { 18 | From time.Time `json:"From"` 19 | To time.Time `json:"To"` 20 | } 21 | 22 | type PIWebAPIAnnotationQuery struct { 23 | CategoryName string `json:"categoryName"` 24 | NameFilter string `json:"nameFilter"` 25 | Attribute AnnotationAttribute `json:"attribute"` 26 | Database AFDatabase `json:"database"` 27 | Datasource Datasource `json:"datasource"` 28 | DatasourceID int `json:"datasourceId"` 29 | IsAnnotation bool `json:"isAnnotation"` 30 | MaxDataPoints int `json:"maxDataPoints"` 31 | QueryType string `json:"queryType"` 32 | RefID string `json:"refId"` 33 | Template EventFrameTemplate `json:"template"` 34 | } 35 | 36 | type AnnotationAttribute struct { 37 | Enable bool `json:"enable"` 38 | Name string `json:"name"` 39 | } 40 | 41 | type AFDatabase struct { 42 | Description string `json:"Description"` 43 | ExtendedProperties ExtendedProperties `json:"ExtendedProperties"` 44 | Id string `json:"Id"` 45 | Links Links `json:"Links"` 46 | Name string `json:"Name"` 47 | Path string `json:"Path"` 48 | WebId string `json:"WebId"` 49 | } 50 | 51 | type ExtendedProperties struct { 52 | DefaultPIServer ValueContainer `json:"DefaultPIServer"` 53 | DefaultPIServerID ValueContainer `json:"DefaultPIServerID"` 54 | } 55 | 56 | type ValueContainer struct { 57 | Value string `json:"Value"` 58 | } 59 | 60 | type AssetDatabaseLinks struct { 61 | AnalysisCategories string `json:"AnalysisCategories"` 62 | AnalysisTemplates string `json:"AnalysisTemplates"` 63 | AssetServer string `json:"AssetServer"` 64 | AttributeCategories string `json:"AttributeCategories"` 65 | ElementCategories string `json:"ElementCategories"` 66 | ElementTemplates string `json:"ElementTemplates"` 67 | Elements string `json:"Elements"` 68 | EnumerationSets string `json:"EnumerationSets"` 69 | EventFrames string `json:"EventFrames"` 70 | Security string `json:"Security"` 71 | SecurityEntries string `json:"SecurityEntries"` 72 | Self string `json:"Self"` 73 | TableCategories string `json:"TableCategories"` 74 | Tables string `json:"Tables"` 75 | } 76 | 77 | type GrafanaDatasource struct { 78 | Type string `json:"type"` 79 | Uid string `json:"uid"` 80 | } 81 | 82 | type EventFrameTemplate struct { 83 | InstanceType string `json:"InstanceType"` 84 | Name string `json:"Name"` 85 | WebId string `json:"WebId"` 86 | } 87 | 88 | type PiProcessedAnnotationQuery struct { 89 | RefID string `json:"RefID"` 90 | TimeRange TimeRange `json:"TimeRange"` 91 | Database AFDatabase `json:"Database"` 92 | Template EventFrameTemplate `json:"Template"` 93 | CategoryName string `json:"categoryName"` 94 | NameFilter string `json:"nameFilter"` 95 | Attributes []QueryProperties `json:"attributes"` 96 | AttributesEnabled bool `json:"attributesEnabled"` 97 | Error error `json:"Error"` 98 | } 99 | 100 | type AnnotationBatchRequest map[string]AnnotationRequest 101 | 102 | type AnnotationRequest struct { 103 | Method string `json:"Method"` 104 | Resource string `json:"Resource,omitempty"` 105 | RequestTemplate *AnnotationRequestTemplate `json:"RequestTemplate,omitempty"` 106 | Parameters []string `json:"Parameters,omitempty"` 107 | ParentIds []string `json:"ParentIds,omitempty"` 108 | } 109 | 110 | type AnnotationRequestTemplate struct { 111 | Resource string `json:"Resource"` 112 | } 113 | 114 | type AnnotationBatchResponse struct { 115 | Status int `json:"Status"` 116 | Headers map[string]string `json:"Headers"` 117 | Content json.RawMessage `json:"Content"` 118 | } 119 | 120 | type EventFrameResponse struct { 121 | Links struct { 122 | First string `json:"First"` 123 | Last string `json:"Last"` 124 | } `json:"Links"` 125 | Items []struct { 126 | WebID string `json:"WebId"` 127 | ID string `json:"Id"` 128 | Name string `json:"Name"` 129 | Description string `json:"Description"` 130 | Path string `json:"Path"` 131 | TemplateName string `json:"TemplateName"` 132 | HasChildren bool `json:"HasChildren"` 133 | CategoryNames []any `json:"CategoryNames"` 134 | ExtendedProperties struct { 135 | } `json:"ExtendedProperties"` 136 | StartTime time.Time `json:"StartTime"` 137 | EndTime time.Time `json:"EndTime"` 138 | Severity string `json:"Severity"` 139 | AcknowledgedBy string `json:"AcknowledgedBy"` 140 | AcknowledgedDate time.Time `json:"AcknowledgedDate"` 141 | CanBeAcknowledged bool `json:"CanBeAcknowledged"` 142 | IsAcknowledged bool `json:"IsAcknowledged"` 143 | IsAnnotated bool `json:"IsAnnotated"` 144 | IsLocked bool `json:"IsLocked"` 145 | AreValuesCaptured bool `json:"AreValuesCaptured"` 146 | RefElementWebIds []any `json:"RefElementWebIds"` 147 | Security struct { 148 | CanAnnotate bool `json:"CanAnnotate"` 149 | CanDelete bool `json:"CanDelete"` 150 | CanExecute bool `json:"CanExecute"` 151 | CanRead bool `json:"CanRead"` 152 | CanReadData bool `json:"CanReadData"` 153 | CanSubscribe bool `json:"CanSubscribe"` 154 | CanSubscribeOthers bool `json:"CanSubscribeOthers"` 155 | CanWrite bool `json:"CanWrite"` 156 | CanWriteData bool `json:"CanWriteData"` 157 | HasAdmin bool `json:"HasAdmin"` 158 | Rights []string `json:"Rights"` 159 | } `json:"Security"` 160 | Links struct { 161 | Self string `json:"Self"` 162 | Attributes string `json:"Attributes"` 163 | EventFrames string `json:"EventFrames"` 164 | Database string `json:"Database"` 165 | ReferencedElements string `json:"ReferencedElements"` 166 | Template string `json:"Template"` 167 | Categories string `json:"Categories"` 168 | InterpolatedData string `json:"InterpolatedData"` 169 | RecordedData string `json:"RecordedData"` 170 | PlotData string `json:"PlotData"` 171 | SummaryData string `json:"SummaryData"` 172 | Value string `json:"Value"` 173 | EndValue string `json:"EndValue"` 174 | Security string `json:"Security"` 175 | SecurityEntries string `json:"SecurityEntries"` 176 | } `json:"Links"` 177 | } `json:"Items"` 178 | } 179 | 180 | type EventFrameAttribute struct { 181 | Total int `json:"Total"` 182 | Items []struct { 183 | Status int `json:"Status"` 184 | Headers struct { 185 | ContentType string `json:"Content-Type"` 186 | } `json:"Headers"` 187 | Content struct { 188 | Items []struct { 189 | WebID string `json:"WebId"` 190 | Name string `json:"Name"` 191 | Value struct { 192 | Timestamp time.Time `json:"Timestamp"` 193 | Value interface{} `json:"Value"` 194 | UnitsAbbreviation string `json:"UnitsAbbreviation"` 195 | Good bool `json:"Good"` 196 | Questionable bool `json:"Questionable"` 197 | Substituted bool `json:"Substituted"` 198 | Annotated bool `json:"Annotated"` 199 | } `json:"Value"` 200 | } `json:"Items"` 201 | } `json:"Content"` 202 | } `json:"Items"` 203 | } 204 | -------------------------------------------------------------------------------- /pkg/plugin/timeseries_response_models.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/grafana/grafana-plugin-sdk-go/backend" 10 | ) 11 | 12 | type PIBatchResponse struct { 13 | Status int `json:"Status"` 14 | Headers map[string]string `json:"Headers"` 15 | Content interface{} `json:"Content"` 16 | } 17 | 18 | type PIBatchResponseBase struct { 19 | Status int `json:"Status"` 20 | Headers map[string]string `json:"Headers"` 21 | } 22 | 23 | type PiBatchContentLinks struct { 24 | Source string `json:"Source"` 25 | } 26 | 27 | type PiBatchContentItem struct { 28 | Timestamp time.Time `json:"Timestamp"` 29 | Value interface{} `json:"Value"` 30 | UnitsAbbreviation string `json:"UnitsAbbreviation"` 31 | Good bool `json:"Good"` 32 | Questionable bool `json:"Questionable"` 33 | Substituted bool `json:"Substituted"` 34 | Annotated bool `json:"Annotated"` 35 | } 36 | 37 | type PiBatchData interface { 38 | getUnits(typeFilter string) string 39 | getSummaryTypes() *[]string 40 | getItems(typeFilter string) *[]PiBatchContentItem 41 | } 42 | 43 | // Custom unmarshaler to unmarshal PIBatchResponse to the correct struct type. 44 | // If the first item in the Items array has a WebId, then we have a PiBatchDataWithSubItems 45 | // If the first item in the Items array does not have a WebId, then we have a PiBatchDataWithoutSubItems 46 | // If the first item is a Value then we have a PiBatchDataWithSingleItem 47 | // If the first item in the Items array has a Type property, then we have a PiBatchDataSummaryItems 48 | // All other formations will return an PiBatchDataError 49 | func (p *PIBatchResponse) UnmarshalJSON(data []byte) error { 50 | var PIBatchResponseBase PIBatchResponseBase 51 | json.Unmarshal(data, &PIBatchResponseBase) 52 | p.Status = PIBatchResponseBase.Status 53 | p.Headers = PIBatchResponseBase.Headers 54 | 55 | // // Unmarshal into a generic map to get the "Items" key 56 | // // Determine if Items[0].WebId is valid. If it is, 57 | // // then we have a PiBatchDataWithSubItems 58 | var rawData map[string]interface{} 59 | err := json.Unmarshal(data, &rawData) 60 | if err != nil { 61 | backend.Logger.Error("Error unmarshalling raw data", "error", err.Error()) 62 | return err 63 | } 64 | 65 | Content, ok := rawData["Content"].(map[string]interface{}) 66 | 67 | if p.Status != http.StatusOK { 68 | var errors *[]string 69 | _, ok := rawData["Content"].(map[string]string) 70 | if ok { // error is a string inside content 71 | errors = &[]string{rawData["Content"].(string)} 72 | } else { 73 | _, ok := Content["Message"].(string) 74 | if ok { 75 | // error is a string inside Message object 76 | errors = &[]string{Content["Message"].(string)} 77 | } else { 78 | // error is a string inside Error object 79 | errors, err = convertError(Content) 80 | if err != nil { 81 | return err 82 | } 83 | } 84 | } 85 | p.Content = createPiBatchDataError(errors) 86 | return nil 87 | } 88 | 89 | if !ok { 90 | backend.Logger.Error("key 'Content' not found in raw JSON", "rawData", rawData) 91 | return fmt.Errorf("key 'Content' not found in raw JSON") 92 | } 93 | 94 | rawContent, _ := json.Marshal(Content) 95 | 96 | _, ok = Content["WebId"] 97 | if ok { 98 | p.Content = Content 99 | return nil 100 | } 101 | 102 | parentItems, ok := Content["Items"].([]interface{}) 103 | if !ok { 104 | backend.Logger.Error("key 'Items' not found in 'Content'", "Content", Content) 105 | //Return an error Batch Data Response to the user is notified 106 | errMessages := &[]string{"Could not process response from PI Web API"} 107 | p.Content = createPiBatchDataError(errMessages) 108 | return nil 109 | } 110 | 111 | parentItem, ok := parentItems[0].(map[string]interface{}) 112 | if !ok { 113 | backend.Logger.Error("key '0' not found in 'Items'", "Items", parentItems) 114 | //Return an error Batch Data Response to the user is notified 115 | errMessages := &[]string{"Could not process response from PI Web API"} 116 | p.Content = createPiBatchDataError(errMessages) 117 | return nil 118 | } 119 | 120 | // Check if the response contained a value or a subitems array of values 121 | value, exists := parentItem["Value"] 122 | if exists { 123 | _, isFloat := value.(float64) 124 | if isFloat { 125 | ResContent := PiBatchDataWithFloatItem{} 126 | err = json.Unmarshal(rawContent, &ResContent) 127 | if err != nil { 128 | backend.Logger.Error("Error unmarshalling batch response 3", "error", err.Error(), "data", parentItem["Value"]) 129 | //Return an error Batch Data Response to the user is notified 130 | errMessages := &[]string{"Could not process response from PI Web API"} 131 | p.Content = createPiBatchDataError(errMessages) 132 | return nil 133 | } 134 | p.Content = ResContent 135 | } else { 136 | ResContent := PiBatchDataWithSingleItem{} 137 | err = json.Unmarshal(rawContent, &ResContent) 138 | if err != nil { 139 | backend.Logger.Error("Error unmarshalling batch response 3", "error", err.Error(), "data", parentItem["Value"]) 140 | //Return an error Batch Data Response to the user is notified 141 | errMessages := &[]string{"Could not process response from PI Web API"} 142 | p.Content = createPiBatchDataError(errMessages) 143 | return nil 144 | } 145 | p.Content = ResContent 146 | } 147 | return nil 148 | } 149 | 150 | // Check if the 'Items' key exists and is a slice. 151 | itemsSlice, ok := parentItem["Items"].([]interface{}) 152 | if !ok { 153 | backend.Logger.Error("key 'Items' not found in 'Items'", "Items", parentItem) 154 | backend.Logger.Error("Error unmarshalling batch response 4") 155 | //Return an error Batch Data Response to the user is notified 156 | errMessages := &[]string{"Could not process response from PI Web API"} 157 | p.Content = createPiBatchDataError(errMessages) 158 | return nil 159 | } 160 | 161 | // If there's at least one item in the slice, check its type. 162 | if len(itemsSlice) > 0 { 163 | firstItem, ok := itemsSlice[0].(map[string]interface{}) 164 | if !ok { 165 | backend.Logger.Error("First item in 'Items' is not a map[string]interface{}", "FirstItem", itemsSlice[0]) 166 | //Return an error Batch Data Response to the user is notified 167 | errMessages := &[]string{"First item in 'Items' is not a map[string]interface{}"} 168 | p.Content = createPiBatchDataError(errMessages) 169 | return nil 170 | } 171 | 172 | // Now check for the "Type" key. 173 | if _, ok := firstItem["Type"]; ok { 174 | // This is a summary response 175 | ResContent := PiBatchDataSummaryItems{} 176 | err = json.Unmarshal(rawContent, &ResContent) 177 | if err != nil { 178 | backend.Logger.Error("Error unmarshalling batch response 5", "error", err.Error()) 179 | //Return an error Batch Data Response to the user is notified 180 | errMessages := &[]string{"Could not process response from PI Web API"} 181 | p.Content = createPiBatchDataError(errMessages) 182 | return nil 183 | } 184 | p.Content = ResContent 185 | return nil 186 | } 187 | } 188 | 189 | // Check if the response contained a WebId, if the response did contain a WebID 190 | // then it is a PiBatchDataWithSubItems, otherwise it is a PiBatchDataWithoutSubItems 191 | _, ok = parentItem["WebId"].(string) 192 | 193 | if !ok { 194 | ResContent := PiBatchDataWithoutSubItems{} 195 | err = json.Unmarshal(rawContent, &ResContent) 196 | if err != nil { 197 | backend.Logger.Error("Error unmarshalling batch response 6", "error", err.Error()) 198 | //Return an error Batch Data Response so the user is notified 199 | errMessages := &[]string{"Could not process response from PI Web API"} 200 | p.Content = createPiBatchDataError(errMessages) 201 | return nil 202 | } 203 | p.Content = ResContent 204 | return nil 205 | } 206 | 207 | // The default response is a PiBatchDataWithSubItems, this works 208 | ResContent := PiBatchDataWithSubItems{} 209 | err = json.Unmarshal(rawContent, &ResContent) 210 | if err != nil { 211 | backend.Logger.Error("Error unmarshalling batch response 7", "error", err.Error()) 212 | //Return an error Batch Data Response to the user is notified 213 | errMessages := &[]string{"Could not process response from PI Web API"} 214 | p.Content = createPiBatchDataError(errMessages) 215 | return nil 216 | } 217 | p.Content = ResContent 218 | return nil 219 | } 220 | -------------------------------------------------------------------------------- /pkg/plugin/annotation_query.go: -------------------------------------------------------------------------------- 1 | package plugin 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/grafana/grafana-plugin-sdk-go/backend" 13 | "github.com/grafana/grafana-plugin-sdk-go/backend/log" 14 | "github.com/grafana/grafana-plugin-sdk-go/data" 15 | ) 16 | 17 | func (d *Datasource) processAnnotationQuery(ctx context.Context, query backend.DataQuery) PiProcessedAnnotationQuery { 18 | var ProcessedQuery PiProcessedAnnotationQuery 19 | var PiAnnotationQuery PIAnnotationQuery 20 | 21 | // Unmarshal the query into a PiQuery struct, and then unmarshal the PiQuery into a PiProcessedQuery 22 | // if there are errors we'll set the error and return the PiProcessedQuery with an error set. 23 | tempJson, err := json.Marshal(query) 24 | if err != nil { 25 | log.DefaultLogger.Error("Process annotation - Error marshalling", "error", err) 26 | 27 | // create a processed query with the error set 28 | ProcessedQuery = PiProcessedAnnotationQuery{ 29 | Error: err, 30 | } 31 | return ProcessedQuery 32 | } 33 | 34 | err = json.Unmarshal(tempJson, &PiAnnotationQuery) 35 | if err != nil { 36 | log.DefaultLogger.Error("Process annotation - Error unmarshalling", "error", err) 37 | 38 | // create a processed query with the error set 39 | ProcessedQuery = PiProcessedAnnotationQuery{ 40 | Error: err, 41 | } 42 | return ProcessedQuery 43 | } 44 | 45 | var attributes []QueryProperties 46 | 47 | if PiAnnotationQuery.JSON.Attribute.Name != "" && PiAnnotationQuery.JSON.Attribute.Enable { 48 | // Splitting by comma 49 | rawAttributes := strings.Split(PiAnnotationQuery.JSON.Attribute.Name, ",") 50 | 51 | // Iterating through each name, trimming the space, and then appending it to the slice 52 | for _, name := range rawAttributes { 53 | // strip out empty attribute names 54 | if name == "" { 55 | continue 56 | } 57 | attribute := QueryProperties{ 58 | Label: strings.TrimSpace(name), 59 | Value: QueryPropertiesValue{ 60 | Value: strings.TrimSpace(name), 61 | }, 62 | } 63 | attributes = append(attributes, attribute) 64 | } 65 | } 66 | 67 | //create a processed query for the annotation query 68 | ProcessedQuery = PiProcessedAnnotationQuery{ 69 | RefID: PiAnnotationQuery.RefID, 70 | TimeRange: PiAnnotationQuery.TimeRange, 71 | Database: PiAnnotationQuery.JSON.Database, 72 | Template: PiAnnotationQuery.JSON.Template, 73 | CategoryName: PiAnnotationQuery.JSON.CategoryName, 74 | NameFilter: PiAnnotationQuery.JSON.NameFilter, 75 | Attributes: attributes, 76 | AttributesEnabled: PiAnnotationQuery.JSON.Attribute.Enable, 77 | } 78 | 79 | return ProcessedQuery 80 | } 81 | 82 | func (q PiProcessedAnnotationQuery) getTimeRangeURIComponent() string { 83 | return "&startTime=" + q.TimeRange.From.UTC().Truncate(time.Second).Format(time.RFC3339) + 84 | "&endTime=" + q.TimeRange.To.UTC().Truncate(time.Second).Format(time.RFC3339) 85 | } 86 | 87 | // getEventFrameQueryURL returns the URI for the event frame query 88 | func (q PiProcessedAnnotationQuery) getEventFrameQueryURL() string { 89 | //example uri: 90 | //http(s):////assetdatabases//eventframes?templateName=