Readding missing files, fix SVG spacing

This commit is contained in:
Andy Dixon
2025-10-11 18:48:08 +02:00
parent 548b7c89b6
commit ff4b00401f
46 changed files with 1731 additions and 40 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"version": "6.1.0"
}
+16
View File
@@ -0,0 +1,16 @@
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in .config/README.md
*/
module.exports = {
endOfLine: 'auto',
printWidth: 120,
trailingComma: 'es5',
semi: true,
jsxSingleQuote: false,
singleQuote: true,
useTabs: false,
tabWidth: 2,
};
+54
View File
@@ -0,0 +1,54 @@
ARG grafana_version=latest
ARG grafana_image=grafana-enterprise
FROM grafana/${grafana_image}:${grafana_version}
ARG anonymous_auth_enabled=true
ARG development=false
ARG TARGETARCH
ENV DEV "${development}"
# Make it as simple as possible to access the grafana instance for development purposes
# Do NOT enable these settings in a public facing / production grafana instance
ENV GF_AUTH_ANONYMOUS_ORG_ROLE "Admin"
ENV GF_AUTH_ANONYMOUS_ENABLED "${anonymous_auth_enabled}"
ENV GF_AUTH_BASIC_ENABLED "false"
# Set development mode so plugins can be loaded without the need to sign
ENV GF_DEFAULT_APP_MODE "development"
LABEL maintainer="Grafana Labs <hello@grafana.com>"
ENV GF_PATHS_HOME="/usr/share/grafana"
WORKDIR $GF_PATHS_HOME
USER root
# Installing supervisor and inotify-tools
RUN if [ "${development}" = "true" ]; then \
if grep -i -q alpine /etc/issue; then \
apk add supervisor inotify-tools git; \
elif grep -i -q ubuntu /etc/issue; then \
DEBIAN_FRONTEND=noninteractive && \
apt-get update && \
apt-get install -y supervisor inotify-tools git && \
rm -rf /var/lib/apt/lists/*; \
else \
echo 'ERROR: Unsupported base image' && /bin/false; \
fi \
fi
COPY supervisord/supervisord.conf /etc/supervisor.d/supervisord.ini
COPY supervisord/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Inject livereload script into grafana index.html
RUN sed -i 's|</body>|<script src="http://localhost:35729/livereload.js"></script></body>|g' /usr/share/grafana/public/views/index.html
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+176
View File
@@ -0,0 +1,176 @@
# Default build configuration by Grafana
**This is an auto-generated directory and is not intended to be changed! ⚠️**
The `.config/` directory holds basic configuration for the different tools
that are used to develop, test and build the project. In order to make it updates easier we ask you to
not edit files in this folder to extend configuration.
## How to extend the basic configs?
Bear in mind that you are doing it at your own risk, and that extending any of the basic configuration can lead
to issues around working with the project.
### Extending the ESLint config
Edit the `eslint.config.mjs` file in the project root to extend the ESLint configuration. The following example disables deprecation notices for source files.
**Example:**
```javascript
import { defineConfig } from 'eslint/config';
import baseConfig from './.config/eslint.config.mjs';
export default defineConfig([
{
ignores: [
//...
],
},
...baseConfig,
{
files: ['src/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-deprecated': 'off',
},
},
]);
```
---
### Extending the Prettier config
Edit the `.prettierrc.js` file in the project root in order to extend the Prettier configuration.
**Example:**
```javascript
module.exports = {
// Prettier configuration provided by Grafana scaffolding
...require('./.config/.prettierrc.js'),
semi: false,
};
```
---
### Extending the Jest config
There are two configuration in the project root that belong to Jest: `jest-setup.js` and `jest.config.js`.
**`jest-setup.js`:** A file that is run before each test file in the suite is executed. We are using it to
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))
**`jest.config.js`:** The main Jest configuration file that extends the Grafana recommended setup. ([link to Jest docs](https://jestjs.io/docs/configuration))
#### ESM errors with Jest
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:
```javascript
process.env.TZ = 'UTC';
const { grafanaESModules, nodeModulesToTransform } = require('./config/jest/utils');
module.exports = {
// Jest configuration provided by Grafana
...require('./.config/jest.config'),
// Inform jest to only transform specific node_module packages.
transformIgnorePatterns: [nodeModulesToTransform([...grafanaESModules, 'packageName'])],
};
```
---
### Extending the TypeScript config
Edit the `tsconfig.json` file in the project root in order to extend the TypeScript configuration.
**Example:**
```json
{
"extends": "./.config/tsconfig.json",
"compilerOptions": {
"preserveConstEnums": true
}
}
```
---
### Extending the Webpack config
Follow these steps to extend the basic Webpack configuration that lives under `.config/`:
#### 1. Create a new Webpack configuration file
Create a new config file that is going to extend the basic one provided by Grafana.
It can live in the project root, e.g. `webpack.config.ts`.
#### 2. Merge the basic config provided by Grafana and your custom setup
We are going to use [`webpack-merge`](https://github.com/survivejs/webpack-merge) for this.
```typescript
// webpack.config.ts
import type { Configuration } from 'webpack';
import { merge } from 'webpack-merge';
import grafanaConfig, { type Env } from './.config/webpack/webpack.config';
const config = async (env: Env): Promise<Configuration> => {
const baseConfig = await grafanaConfig(env);
return merge(baseConfig, {
// Add custom config here...
output: {
asyncChunks: true,
},
});
};
export default config;
```
#### 3. Update the `package.json` to use the new Webpack config
We need to update the `scripts` in the `package.json` to use the extended Webpack configuration.
**Update for `build`:**
```diff
-"build": "webpack -c ./.config/webpack/webpack.config.ts --env production",
+"build": "webpack -c ./webpack.config.ts --env production",
```
**Update for `dev`:**
```diff
-"dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development",
+"dev": "webpack -w -c ./webpack.config.ts --env development",
```
### Configure grafana image to use when running docker
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`.
**Example:**
```yaml
version: '3.7'
services:
grafana:
extends:
file: .config/docker-compose-base.yaml
service: grafana
build:
args:
grafana_version: ${GRAFANA_VERSION:-9.1.2}
grafana_image: ${GRAFANA_IMAGE:-grafana}
```
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.
---
+43
View File
@@ -0,0 +1,43 @@
import type { Configuration, ExternalItemFunctionData } from 'webpack';
type ExternalsType = Configuration['externals'];
export const externals: ExternalsType = [
// Required for dynamic publicPath resolution
{ 'amd-module': 'module' },
'lodash',
'jquery',
'moment',
'slate',
'emotion',
'@emotion/react',
'@emotion/css',
'prismjs',
'slate-plain-serializer',
'@grafana/slate-react',
'react',
'react-dom',
'react-redux',
'redux',
'rxjs',
'i18next',
'react-router',
'd3',
'angular',
/^@grafana\/ui/i,
/^@grafana\/runtime/i,
/^@grafana\/data/i,
// Mark legacy SDK imports as external if their name starts with the "grafana/" prefix
({ request }: ExternalItemFunctionData, callback: (error?: Error, result?: string) => void) => {
const prefix = 'grafana/';
const hasPrefix = (request: string) => request.indexOf(prefix) === 0;
const stripPrefix = (request: string) => request.slice(prefix.length);
if (request && hasPrefix(request)) {
return callback(undefined, stripPrefix(request));
}
callback();
},
];
+25
View File
@@ -0,0 +1,25 @@
services:
grafana:
user: root
container_name: 'andydixon-chargevizbattery-panel'
build:
context: .
args:
grafana_image: ${GRAFANA_IMAGE:-grafana-enterprise}
grafana_version: ${GRAFANA_VERSION:-12.2.0}
development: ${DEVELOPMENT:-false}
anonymous_auth_enabled: ${ANONYMOUS_AUTH_ENABLED:-true}
ports:
- 3000:3000/tcp
volumes:
- ../dist:/var/lib/grafana/plugins/andydixon-chargevizbattery-panel
- ../provisioning:/etc/grafana/provisioning
- ..:/root/andydixon-chargevizbattery-panel
environment:
NODE_ENV: development
GF_LOG_FILTERS: plugin.andydixon-chargevizbattery-panel:debug
GF_LOG_LEVEL: debug
GF_DATAPROXY_LOGGING: 1
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: andydixon-chargevizbattery-panel
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
if [ "${DEV}" = "false" ]; then
echo "Starting test mode"
exec /run.sh
fi
echo "Starting development mode"
if grep -i -q alpine /etc/issue; then
exec /usr/bin/supervisord -c /etc/supervisord.conf
elif grep -i -q ubuntu /etc/issue; then
exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
else
echo 'ERROR: Unsupported base image'
exit 1
fi
+38
View File
@@ -0,0 +1,38 @@
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in
* https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations#extend-the-eslint-config
*/
import { defineConfig } from 'eslint/config';
import grafanaConfig from '@grafana/eslint-config/flat.js';
export default defineConfig([
...grafanaConfig,
{
rules: {
'react/prop-types': 'off',
},
},
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
parserOptions: {
project: './tsconfig.json',
},
},
rules: {
'@typescript-eslint/no-deprecated': 'warn',
},
},
{
files: ['./tests/**/*'],
rules: {
'react-hooks/rules-of-hooks': 'off',
},
},
]);
+28
View File
@@ -0,0 +1,28 @@
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in
* https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations#extend-the-jest-config
*/
import '@testing-library/jest-dom';
import { TextEncoder, TextDecoder } from 'util';
Object.assign(global, { TextDecoder, TextEncoder });
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
Object.defineProperty(global, 'matchMedia', {
writable: true,
value: (query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}),
});
HTMLCanvasElement.prototype.getContext = () => {};
+44
View File
@@ -0,0 +1,44 @@
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in
* https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations#extend-the-jest-config
*/
const path = require('path');
const { grafanaESModules, nodeModulesToTransform } = require('./jest/utils');
module.exports = {
moduleNameMapper: {
'\\.(css|scss|sass)$': 'identity-obj-proxy',
'react-inlinesvg': path.resolve(__dirname, 'jest', 'mocks', 'react-inlinesvg.tsx'),
},
modulePaths: ['<rootDir>/src'],
setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],
testEnvironment: 'jest-environment-jsdom',
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{spec,test,jest}.{js,jsx,ts,tsx}',
],
transform: {
'^.+\\.(t|j)sx?$': [
'@swc/jest',
{
sourceMaps: 'inline',
jsc: {
parser: {
syntax: 'typescript',
tsx: true,
decorators: false,
dynamicImport: true,
},
},
},
],
},
// Jest will throw `Cannot use import statement outside module` if it tries to load an
// ES module without it being transformed first. ./config/README.md#esm-errors-with-jest
transformIgnorePatterns: [nodeModulesToTransform(grafanaESModules)],
watchPathIgnorePatterns: ['<rootDir>/node_modules', '<rootDir>/dist'],
};
+25
View File
@@ -0,0 +1,25 @@
// Due to the grafana/ui Icon component making fetch requests to
// `/public/img/icon/<icon_name>.svg` we need to mock react-inlinesvg to prevent
// the failed fetch requests from displaying errors in console.
import React from 'react';
type Callback = (...args: any[]) => void;
export interface StorageItem {
content: string;
queue: Callback[];
status: string;
}
export const cacheStore: { [key: string]: StorageItem } = Object.create(null);
const SVG_FILE_NAME_REGEX = /(.+)\/(.+)\.svg$/;
const InlineSVG = ({ src }: { src: string }) => {
// testId will be the file name without extension (e.g. `public/img/icons/angle-double-down.svg` -> `angle-double-down`)
const testId = src.replace(SVG_FILE_NAME_REGEX, '$2');
return <svg xmlns="http://www.w3.org/2000/svg" data-testid={testId} viewBox="0 0 24 24" />;
};
export default InlineSVG;
+37
View File
@@ -0,0 +1,37 @@
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in .config/README.md
*/
/*
* This utility function is useful in combination with jest `transformIgnorePatterns` config
* to transform specific packages (e.g.ES modules) in a projects node_modules folder.
*/
const nodeModulesToTransform = (moduleNames) => `node_modules\/(?!.*(${moduleNames.join('|')})\/.*)`;
// Array of known nested grafana package dependencies that only bundle an ESM version
const grafanaESModules = [
'.pnpm', // Support using pnpm symlinked packages
'@grafana/schema',
'@wojtekmaj/date-utils',
'd3',
'd3-color',
'd3-force',
'd3-interpolate',
'd3-scale-chromatic',
'get-user-locale',
'marked',
'memoize',
'mimic-function',
'ol',
'react-calendar',
'react-colorful',
'rxjs',
'uuid',
];
module.exports = {
nodeModulesToTransform,
grafanaESModules,
};
+15
View File
@@ -0,0 +1,15 @@
[supervisord]
nodaemon=true
user=root
[program:grafana]
user=root
directory=/var/lib/grafana
command=/run.sh
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
redirect_stderr=true
killasgroup=true
stopasgroup=true
autostart=true
+26
View File
@@ -0,0 +1,26 @@
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in
* https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations#extend-the-typescript-config
*/
{
"compilerOptions": {
"alwaysStrict": true,
"declaration": false,
"rootDir": "../src",
"baseUrl": "../src",
"typeRoots": ["../node_modules/@types"],
"resolveJsonModule": true
},
"ts-node": {
"compilerOptions": {
"module": "commonjs",
"target": "es5",
"esModuleInterop": true
},
"transpileOnly": true
},
"include": ["../src", "./types"],
"extends": "@grafana/tsconfig"
}
+37
View File
@@ -0,0 +1,37 @@
// Image declarations
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
// Font declarations
declare module '*.woff';
declare module '*.woff2';
declare module '*.eot';
declare module '*.ttf';
declare module '*.otf';
+83
View File
@@ -0,0 +1,83 @@
declare module 'replace-in-file-webpack-plugin' {
import { Compiler, Plugin } from 'webpack';
interface ReplaceRule {
search: string | RegExp;
replace: string | ((match: string) => string);
}
interface ReplaceOption {
dir?: string;
files?: string[];
test?: RegExp | RegExp[];
rules: ReplaceRule[];
}
class ReplaceInFilePlugin extends Plugin {
constructor(options?: ReplaceOption[]);
options: ReplaceOption[];
apply(compiler: Compiler): void;
}
export = ReplaceInFilePlugin;
}
declare module 'webpack-livereload-plugin' {
import { ServerOptions } from 'https';
import { Compiler, Plugin, Stats, Compilation } from 'webpack';
interface Options extends Pick<ServerOptions, 'cert' | 'key' | 'pfx'> {
/**
* protocol for livereload `<script>` src attribute value
* @default protocol of the page, either `http` or `https`
*/
protocol?: string | undefined;
/**
* The desired port for the livereload server.
* If you define port 0, an available port will be searched for, starting from 35729.
* @default 35729
*/
port?: number | undefined;
/**
* he desired hostname for the appended `<script>` (if present) to point to
* @default hostname of the page, like `localhost` or 10.0.2.2
*/
hostname?: string | undefined;
/**
* livereload `<script>` automatically to `<head>`.
* @default false
*/
appendScriptTag?: boolean | undefined;
/**
* RegExp of files to ignore. Null value means ignore nothing.
* It is also possible to define an array and use multiple anymatch patterns
*/
ignore?: RegExp | RegExp[] | null | undefined;
/**
* amount of milliseconds by which to delay the live reload (in case build takes longer)
* @default 0
*/
delay?: number | undefined;
/**
* create hash for each file source and only notify livereload if hash has changed
* @default false
*/
useSourceHash?: boolean | undefined;
}
class LiveReloadPlugin extends Plugin {
readonly isRunning: boolean;
constructor(options?: Options);
apply(compiler: Compiler): void;
start(watching: any, cb: () => void): void;
done(stats: Stats): void;
failed(): void;
autoloadJs(): string;
scriptTag(source: string): string;
applyCompilation(compilation: Compilation): void;
}
export = LiveReloadPlugin;
}
+33
View File
@@ -0,0 +1,33 @@
import webpack, { type Compiler } from 'webpack';
const PLUGIN_NAME = 'BuildModeWebpack';
export class BuildModeWebpackPlugin {
apply(compiler: webpack.Compiler) {
compiler.hooks.compilation.tap(PLUGIN_NAME, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
stage: webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
},
async () => {
const assets = compilation.getAssets();
for (const asset of assets) {
if (asset.name.endsWith('plugin.json')) {
const pluginJsonString = asset.source.source().toString();
const pluginJsonWithBuildMode = JSON.stringify(
{
...JSON.parse(pluginJsonString),
buildMode: compilation.options.mode,
},
null,
4
);
compilation.updateAsset(asset.name, new webpack.sources.RawSource(pluginJsonWithBuildMode));
}
}
}
);
});
}
}
+2
View File
@@ -0,0 +1,2 @@
export const SOURCE_DIR = 'src';
export const DIST_DIR = 'dist';
+68
View File
@@ -0,0 +1,68 @@
import fs from 'fs';
import process from 'process';
import os from 'os';
import path from 'path';
import { glob } from 'glob';
import { SOURCE_DIR } from './constants.ts';
export function isWSL() {
if (process.platform !== 'linux') {
return false;
}
if (os.release().toLowerCase().includes('microsoft')) {
return true;
}
try {
return fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
} catch {
return false;
}
}
function loadJson(path: string) {
const rawJson = fs.readFileSync(path, 'utf8');
return JSON.parse(rawJson);
}
export function getPackageJson() {
return loadJson(path.resolve(process.cwd(), 'package.json'));
}
export function getPluginJson() {
return loadJson(path.resolve(process.cwd(), `${SOURCE_DIR}/plugin.json`));
}
export function getCPConfigVersion() {
const cprcJson = path.resolve(process.cwd(), './.config', '.cprc.json');
return fs.existsSync(cprcJson) ? loadJson(cprcJson).version : { version: 'unknown' };
}
export function hasReadme() {
return fs.existsSync(path.resolve(process.cwd(), SOURCE_DIR, 'README.md'));
}
// Support bundling nested plugins by finding all plugin.json files in src directory
// then checking for a sibling module.[jt]sx? file.
export async function getEntries() {
const pluginsJson = await glob('**/src/**/plugin.json', { absolute: true });
const plugins = await Promise.all(
pluginsJson.map((pluginJson) => {
const folder = path.dirname(pluginJson);
return glob(`${folder}/module.{ts,tsx,js,jsx}`, { absolute: true });
})
);
return plugins.reduce<Record<string, string>>((result, modules) => {
return modules.reduce((innerResult, module) => {
const pluginPath = path.dirname(module);
const pluginName = path.relative(process.cwd(), pluginPath).replace(/src\/?/i, '');
const entryName = pluginName === '' ? 'module' : `${pluginName}/module`;
innerResult[entryName] = module;
return innerResult;
}, result);
}, {});
}
+247
View File
@@ -0,0 +1,247 @@
/*
* ⚠️⚠️⚠️ THIS FILE WAS SCAFFOLDED BY `@grafana/create-plugin`. DO NOT EDIT THIS FILE DIRECTLY. ⚠️⚠️⚠️
*
* In order to extend the configuration follow the steps in
* https://grafana.com/developers/plugin-tools/how-to-guides/extend-configurations#extend-the-webpack-config
*/
import CopyWebpackPlugin from 'copy-webpack-plugin';
import ESLintPlugin from 'eslint-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import path from 'path';
import ReplaceInFileWebpackPlugin from 'replace-in-file-webpack-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import { SubresourceIntegrityPlugin } from 'webpack-subresource-integrity';
import webpack, { type Configuration } from 'webpack';
import LiveReloadPlugin from 'webpack-livereload-plugin';
import VirtualModulesPlugin from 'webpack-virtual-modules';
import { BuildModeWebpackPlugin } from './BuildModeWebpackPlugin.ts';
import { DIST_DIR, SOURCE_DIR } from './constants.ts';
import { getCPConfigVersion, getEntries, getPackageJson, getPluginJson, hasReadme, isWSL } from './utils.ts';
import { externals } from '../bundler/externals.ts';
const pluginJson = getPluginJson();
const cpVersion = getCPConfigVersion();
const pluginVersion = getPackageJson().version;
const virtualPublicPath = new VirtualModulesPlugin({
'node_modules/grafana-public-path.js': `
import amdMetaModule from 'amd-module';
__webpack_public_path__ =
amdMetaModule && amdMetaModule.uri
? amdMetaModule.uri.slice(0, amdMetaModule.uri.lastIndexOf('/') + 1)
: 'public/plugins/${pluginJson.id}/';
`,
});
export type Env = {
[key: string]: true | string | Env;
};
const config = async (env: Env): Promise<Configuration> => {
const baseConfig: Configuration = {
cache: {
type: 'filesystem',
buildDependencies: {
// __filename doesn't work in Node 24
config: [path.resolve(process.cwd(), '.config', 'webpack', 'webpack.config.ts')],
},
},
context: path.join(process.cwd(), SOURCE_DIR),
devtool: env.production ? 'source-map' : 'eval-source-map',
entry: await getEntries(),
externals,
// Support WebAssembly according to latest spec - makes WebAssembly module async
experiments: {
asyncWebAssembly: true,
},
mode: env.production ? 'production' : 'development',
module: {
rules: [
// This must come first in the rules array otherwise it breaks sourcemaps.
{
test: /src\/(?:.*\/)?module\.tsx?$/,
use: [
{
loader: 'imports-loader',
options: {
imports: `side-effects grafana-public-path`,
},
},
],
},
{
exclude: /(node_modules)/,
test: /\.[tj]sx?$/,
use: {
loader: 'swc-loader',
options: {
jsc: {
baseUrl: path.resolve(process.cwd(), SOURCE_DIR),
target: 'es2015',
loose: false,
parser: {
syntax: 'typescript',
tsx: true,
decorators: false,
dynamicImport: true,
},
},
},
},
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.s[ac]ss$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.(png|jpe?g|gif|svg)$/,
type: 'asset/resource',
generator: {
filename: Boolean(env.production) ? '[hash][ext]' : '[file]',
},
},
{
test: /\.(woff|woff2|eot|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/,
type: 'asset/resource',
generator: {
filename: Boolean(env.production) ? '[hash][ext]' : '[file]',
},
},
],
},
optimization: {
minimize: Boolean(env.production),
minimizer: [
new TerserPlugin({
terserOptions: {
format: {
comments: (_, { type, value }) => type === 'comment2' && value.trim().startsWith('[create-plugin]'),
},
compress: {
drop_console: ['log', 'info'],
},
},
}),
],
},
output: {
clean: {
keep: new RegExp(`(.*?_(amd64|arm(64)?)(.exe)?|go_plugin_build_manifest)`),
},
filename: '[name].js',
chunkFilename: env.production ? '[name].js?_cache=[contenthash]' : '[name].js',
library: {
type: 'amd',
},
path: path.resolve(process.cwd(), DIST_DIR),
publicPath: `public/plugins/${pluginJson.id}/`,
uniqueName: pluginJson.id,
crossOriginLoading: 'anonymous',
},
plugins: [
new BuildModeWebpackPlugin(),
virtualPublicPath,
// Insert create plugin version information into the bundle
new webpack.BannerPlugin({
banner: `/* [create-plugin] version: ${cpVersion} */
/* [create-plugin] plugin: ${pluginJson.id}@${pluginVersion} */`,
raw: true,
entryOnly: true,
}),
new CopyWebpackPlugin({
patterns: [
// If src/README.md exists use it; otherwise the root README
// To `compiler.options.output`
{ from: hasReadme() ? 'README.md' : '../README.md', to: '.', force: true },
{ from: 'plugin.json', to: '.' },
{ from: '../LICENSE', to: '.' },
{ from: '../CHANGELOG.md', to: '.', force: true },
{ from: '**/*.json', to: '.' },
{ from: '**/*.svg', to: '.', noErrorOnMissing: true },
{ from: '**/*.png', to: '.', noErrorOnMissing: true },
{ from: '**/*.html', to: '.', noErrorOnMissing: true },
{ from: 'img/**/*', to: '.', noErrorOnMissing: true },
{ from: 'libs/**/*', to: '.', noErrorOnMissing: true },
{ from: 'static/**/*', to: '.', noErrorOnMissing: true },
{ from: '**/query_help.md', to: '.', noErrorOnMissing: true },
],
}),
// Replace certain template-variables in the README and plugin.json
new ReplaceInFileWebpackPlugin([
{
dir: DIST_DIR,
files: ['plugin.json', 'README.md'],
rules: [
{
search: /\%VERSION\%/g,
replace: pluginVersion,
},
{
search: /\%TODAY\%/g,
replace: new Date().toISOString().substring(0, 10),
},
{
search: /\%PLUGIN_ID\%/g,
replace: pluginJson.id,
},
],
},
]),
new SubresourceIntegrityPlugin({
hashFuncNames: ['sha256'],
}),
...(env.development
? [
new LiveReloadPlugin(),
new ForkTsCheckerWebpackPlugin({
async: Boolean(env.development),
issue: {
include: [{ file: '**/*.{ts,tsx}' }],
},
typescript: { configFile: path.join(process.cwd(), 'tsconfig.json') },
}),
new ESLintPlugin({
extensions: ['.ts', '.tsx'],
lintDirtyModulesOnly: Boolean(env.development), // don't lint on start, only lint changed files
failOnError: Boolean(env.production),
}),
]
: []),
],
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
// handle resolving "rootDir" paths
modules: [path.resolve(process.cwd(), 'src'), 'node_modules'],
unsafeCache: true,
},
};
if (isWSL()) {
baseConfig.watchOptions = {
poll: 3000,
ignored: /node_modules/,
};
}
return baseConfig;
};
export default config;
+7
View File
@@ -0,0 +1,7 @@
{
"features": {
"bundleGrafanaUI": false,
"useReactRouterV6": true,
"useExperimentalRspack": false
}
}
+27
View File
@@ -0,0 +1,27 @@
name: Bundle Stats
on:
workflow_dispatch:
pull_request:
branches:
- main
push:
branches:
- main
permissions:
contents: write
pull-requests: write
actions: read
jobs:
compare:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false
- uses: grafana/plugin-actions/bundle-size@main # zizmor: ignore[unpinned-uses] provided by grafana
+241
View File
@@ -0,0 +1,241 @@
name: CI
on:
push:
branches:
- master
- main
pull_request:
branches:
- master
- main
jobs:
build:
name: Build, lint and unit tests
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
plugin-id: ${{ steps.metadata.outputs.plugin-id }}
plugin-version: ${{ steps.metadata.outputs.plugin-version }}
has-e2e: ${{ steps.check-for-e2e.outputs.has-e2e }}
has-backend: ${{ steps.check-for-backend.outputs.has-backend }}
env:
GRAFANA_ACCESS_POLICY_TOKEN: ${{ secrets.GRAFANA_ACCESS_POLICY_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Check types
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Unit tests
run: npm run test:ci
- name: Build frontend
run: npm run build
- name: Check for backend
id: check-for-backend
run: |
if [ -f "Magefile.go" ]
then
echo "has-backend=true" >> $GITHUB_OUTPUT
fi
- name: Setup Go environment
if: steps.check-for-backend.outputs.has-backend == 'true'
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Test backend
if: steps.check-for-backend.outputs.has-backend == 'true'
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3.1.0
with:
version: latest
args: coverage
- name: Build backend
if: steps.check-for-backend.outputs.has-backend == 'true'
uses: magefile/mage-action@6f50bbb8ea47d56e62dee92392788acbc8192d0b # v3.1.0
with:
version: latest
args: buildAll
- name: Check for E2E
id: check-for-e2e
run: |
if [ -f "playwright.config.ts" ]
then
echo "has-e2e=true" >> $GITHUB_OUTPUT
fi
- name: Sign plugin
run: npm run sign
if: ${{ env.GRAFANA_ACCESS_POLICY_TOKEN != '' }}
- name: Get plugin metadata
id: metadata
run: |
sudo apt-get install jq
export GRAFANA_PLUGIN_ID=$(cat dist/plugin.json | jq -r .id)
export GRAFANA_PLUGIN_VERSION=$(cat dist/plugin.json | jq -r .info.version)
export GRAFANA_PLUGIN_ARTIFACT=${GRAFANA_PLUGIN_ID}-${GRAFANA_PLUGIN_VERSION}.zip
echo "plugin-id=${GRAFANA_PLUGIN_ID}" >> $GITHUB_OUTPUT
echo "plugin-version=${GRAFANA_PLUGIN_VERSION}" >> $GITHUB_OUTPUT
echo "archive=${GRAFANA_PLUGIN_ARTIFACT}" >> $GITHUB_OUTPUT
- name: Package plugin
id: package-plugin
run: |
mv dist ${PLUGIN_ID}
zip ${ARCHIVE} ${PLUGIN_ID} -r
env:
ARCHIVE: ${{ steps.metadata.outputs.archive }}
PLUGIN_ID: ${{ steps.metadata.outputs.plugin-id }}
- name: Check plugin.json
run: |
docker run --pull=always \
-v $PWD/${ARCHIVE}:/archive.zip \
grafana/plugin-validator-cli -analyzer=metadatavalid /archive.zip
env:
ARCHIVE: ${{ steps.metadata.outputs.archive }}
- name: Archive Build
uses: actions/upload-artifact@v4
with:
name: ${{ steps.metadata.outputs.plugin-id }}-${{ steps.metadata.outputs.plugin-version }}
path: ${{ steps.metadata.outputs.plugin-id }}
retention-days: 5
resolve-versions:
name: Resolve e2e images
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 3
needs: build
if: ${{ needs.build.outputs.has-e2e == 'true' }}
outputs:
matrix: ${{ steps.resolve-versions.outputs.matrix }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Resolve Grafana E2E versions
id: resolve-versions
uses: grafana/plugin-actions/e2e-version@main # zizmor: ignore[unpinned-uses] provided by grafana
playwright-tests:
needs: [resolve-versions, build]
timeout-minutes: 15
permissions:
contents: read
id-token: write
pull-requests: write
strategy:
fail-fast: false
matrix:
GRAFANA_IMAGE: ${{fromJson(needs.resolve-versions.outputs.matrix)}}
name: e2e test ${{ matrix.GRAFANA_IMAGE.name }}@${{ matrix.GRAFANA_IMAGE.VERSION }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Download plugin
uses: actions/download-artifact@v4
with:
path: dist
name: ${{ needs.build.outputs.plugin-id }}-${{ needs.build.outputs.plugin-version }}
- name: Execute permissions on binary
if: needs.build.outputs.has-backend == 'true'
run: |
chmod +x ./dist/gpx_*
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dev dependencies
run: npm ci
- name: Start Grafana
run: |
docker compose pull
ANONYMOUS_AUTH_ENABLED=false DEVELOPMENT=false GRAFANA_VERSION=${{ matrix.GRAFANA_IMAGE.VERSION }} GRAFANA_IMAGE=${{ matrix.GRAFANA_IMAGE.NAME }} docker compose up -d
- name: Wait for grafana server
uses: grafana/plugin-actions/wait-for-grafana@main # zizmor: ignore[unpinned-uses] provided by grafana
with:
url: http://localhost:3000/login
- name: Install Playwright Browsers
run: npm exec playwright install chromium --with-deps
- name: Run Playwright tests
id: run-tests
run: npm run e2e
- name: Upload e2e test summary
uses: grafana/plugin-actions/playwright-gh-pages/upload-report-artifacts@main # zizmor: ignore[unpinned-uses] provided by grafana
if: ${{ always() && !cancelled() }}
with:
upload-report: false
github-token: ${{ secrets.GITHUB_TOKEN }}
test-outcome: ${{ steps.run-tests.outcome }}
- name: Docker logs
if: ${{ always() && steps.run-tests.outcome == 'failure' }}
run: |
docker logs andydixon-chargevizbattery-panel >& grafana-server.log
- name: Stop grafana docker
run: docker compose down
# Uncomment this step to upload the server log to Github artifacts. Remember Github artifacts are public on the Internet if the repository is public.
# - name: Upload server log
# uses: actions/upload-artifact@v4
# if: ${{ always() && steps.run-tests.outcome == 'failure' }}
# with:
# name: ${{ matrix.GRAFANA_IMAGE.NAME }}-v${{ matrix.GRAFANA_IMAGE.VERSION }}-${{github.run_id}}-server-log
# path: grafana-server.log
# retention-days: 5
publish-report:
if: ${{ always() && !cancelled() }}
permissions:
contents: write
id-token: write
pull-requests: write
needs: [playwright-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# required for playwright-gh-pages
persist-credentials: true
- name: Publish report
uses: grafana/plugin-actions/playwright-gh-pages/deploy-report-pages@main # zizmor: ignore[unpinned-uses] provided by grafana
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+26
View File
@@ -0,0 +1,26 @@
name: Create Plugin Update
on:
workflow_dispatch:
schedule:
- cron: '0 0 1 * *' # run once a month on the 1st day
# To use the default github token with the following elevated permissions make sure to check:
# **Allow GitHub Actions to create and approve pull requests** in https://github.com/ORG_NAME/REPO_NAME/settings/actions.
# Alternatively create a fine-grained personal access token for your repository with
# `contents: read and write` and `pull requests: read and write` and pass it to the action.
permissions:
contents: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: grafana/plugin-actions/create-plugin-update@main # zizmor: ignore[unpinned-uses] provided by grafana
# Uncomment to use a fine-grained personal access token instead of default github token
# (For more info on how to generate the token see https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)
# with:
# Make sure to save the token in your repository secrets
# token: $
+37
View File
@@ -0,0 +1,37 @@
name: Latest Grafana API compatibility check
on: [pull_request]
jobs:
compatibilitycheck:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Node.js environment
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npm run build
- name: Find module.ts or module.tsx
id: find-module-ts
run: |
MODULETS="$(find ./src -type f \( -name "module.ts" -o -name "module.tsx" \))"
echo "modulets=${MODULETS}" >> $GITHUB_OUTPUT
- name: Compatibility check
uses: grafana/plugin-actions/is-compatible@main
with:
module: ${{ steps.find-module-ts.outputs.modulets }}
comment-pr: 'no'
fail-if-incompatible: 'yes'
+29
View File
@@ -0,0 +1,29 @@
# This GitHub Action automates the process of building Grafana plugins.
# (For more information, see https://github.com/grafana/plugin-actions/blob/main/build-plugin/README.md)
name: Release
on:
push:
tags:
- 'v*' # Run workflow on version tags, e.g. v1.0.0.
permissions: read-all
jobs:
release:
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: grafana/plugin-actions/build-plugin@main # zizmor: ignore[unpinned-uses] provided by grafana
# Uncomment to enable plugin signing
# (For more info on how to generate the access policy token see https://grafana.com/developers/plugin-tools/publish-a-plugin/sign-a-plugin#generate-an-access-policy-token)
#with:
# Make sure to save the token in your repository secrets
#policy_token: $
# Usage of GRAFANA_API_KEY is deprecated, prefer `policy_token` option above
#grafana_token: $
+46 -12
View File
@@ -1,12 +1,46 @@
CHANGELOG.md # Logs
dist logs
docker-compose.yaml *.log
eslint.config.mjs npm-debug.log*
jest.config.js yarn-debug.log*
jest-setup.js yarn-error.log*
node_modules .pnpm-debug.log*
package-lock.json
playwright.config.ts node_modules/
provisioning
tests # yarn
tsconfig.json .yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# Compiled binary addons (https://nodejs.org/api/addons.html)
dist/
artifacts/
work/
ci/
# e2e test directories
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
# Editor
.idea
.eslintcache
+1
View File
@@ -0,0 +1 @@
22
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
// Prettier configuration provided by Grafana scaffolding
...require('./.config/.prettierrc.js'),
};
+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 1.0.0 (Unreleased)
Initial release.
+5
View File
@@ -0,0 +1,5 @@
services:
grafana:
extends:
file: .config/docker-compose-base.yaml
service: grafana
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'eslint/config';
import baseConfig from './.config/eslint.config.mjs';
export default defineConfig([
{
ignores: [
'**/logs',
'**/*.log',
'**/npm-debug.log*',
'**/yarn-debug.log*',
'**/yarn-error.log*',
'**/.pnpm-debug.log*',
'**/node_modules/',
'.yarn/cache',
'.yarn/unplugged',
'.yarn/build-state.yml',
'.yarn/install-state.gz',
'**/.pnp.*',
'**/pids',
'**/*.pid',
'**/*.seed',
'**/*.pid.lock',
'**/lib-cov',
'**/coverage',
'**/dist/',
'**/artifacts/',
'**/work/',
'**/ci/',
'test-results/',
'playwright-report/',
'blob-report/',
'playwright/.cache/',
'playwright/.auth/',
'**/.idea',
'**/.eslintcache',
],
},
...baseConfig,
]);
+2
View File
@@ -0,0 +1,2 @@
// Jest setup provided by Grafana scaffolding
import './.config/jest-setup';
+8
View File
@@ -0,0 +1,8 @@
// force timezone to UTC to allow tests to work regardless of local timezone
// generally used by snapshots, but can affect specific tests
process.env.TZ = 'UTC';
module.exports = {
// Jest configuration provided by Grafana scaffolding
...require('./.config/jest.config'),
};
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "battery", "name": "charge-viz-battery-panel",
"version": "1.0.0", "version": "1.0.0",
"scripts": { "scripts": {
"build": "webpack -c ./.config/webpack/webpack.config.ts --env production", "build": "webpack -c ./.config/webpack/webpack.config.ts --env production",
+53
View File
@@ -0,0 +1,53 @@
import type { PluginOptions } from '@grafana/plugin-e2e';
import { defineConfig, devices } from '@playwright/test';
import { dirname } from 'node:path';
const pluginE2eAuth = `${dirname(require.resolve('@grafana/plugin-e2e'))}/auth`;
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig<PluginOptions>({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.GRAFANA_URL || 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
// 1. Login to Grafana and store the cookie on disk for use in other tests.
{
name: 'auth',
testDir: pluginE2eAuth,
testMatch: [/.*\.js/],
},
// 2. Run tests in Google Chrome. Every test will start authenticated as admin user.
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/admin.json',
},
dependencies: ['auth'],
},
],
});
+1
View File
@@ -0,0 +1 @@
For more information see [Provision dashboards and data sources](https://grafana.com/tutorials/provision-dashboards-and-data-sources/)
View File
+104
View File
@@ -0,0 +1,104 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 1,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "trlxrdZVk"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"seriesCountSize": "sm",
"showSeriesCount": false,
"text": "Default value of text input option"
},
"targets": [
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "db84e60d-b92a-4089-82cb-34842fb1754b"
},
"refId": "A",
"scenarioId": "raw_frame",
"rawFrameContent": "[\n {\n \"columns\": [\n {\n \"text\": \"Time\",\n \"type\": \"time\"\n },\n {\n \"text\": \"Label\",\n \"type\": \"string\"\n },\n {\n \"text\": \"Value\",\n \"type\": \"number\"\n }\n ],\n \"rows\": [\n [1609459200000, \"A\", 10],\n [1609462800000, \"B\", 20],\n [1609466400000, \"C\", 15],\n [1609470000000, \"D\", 25],\n [1609473600000, \"E\", 30]\n ]\n },\n{\n \"columns\": [\n {\n \"text\": \"Time\",\n \"type\": \"time\"\n },\n {\n \"text\": \"Label\",\n \"type\": \"string\"\n },\n {\n \"text\": \"Value\",\n \"type\": \"number\"\n }\n ],\n \"rows\": [\n [1609459200000, \"A\", 40],\n [1609462800000, \"B\", 50],\n [1609466400000, \"C\", 45],\n [1609470000000, \"D\", 55],\n [1609473600000, \"E\", 60]\n ]\n }\n]"
}
],
"title": "Sample Panel Title",
"type": "andydixon-chargevizbattery-panel"
},
{
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "trlxrdZVk"
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 0
},
"id": 2,
"options": {
"seriesCountSize": "sm",
"showSeriesCount": false,
"text": "Default value of text input option"
},
"targets": [
{
"alias": "",
"datasource": {
"type": "grafana-testdata-datasource",
"uid": "db84e60d-b92a-4089-82cb-34842fb1754b"
},
"refId": "A",
"scenarioId": "no_data_points"
}
],
"title": "Sample Panel Title",
"type": "andydixon-chargevizbattery-panel"
}
],
"refresh": "",
"schemaVersion": 39,
"tags": [],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Provisioned ChargeViz Battery Panel dashboard",
"uid": "a538aeff-5a8a-42a5-901c-938d896fdd6f",
"version": 1,
"weekStart": ""
}
+7
View File
@@ -0,0 +1,7 @@
apiVersion: 1
providers:
- name: 'ChargeViz Battery Panel'
type: file
options:
path: /etc/grafana/provisioning/dashboards
+7
View File
@@ -0,0 +1,7 @@
apiVersion: 1
datasources:
- name: TestData DB
type: testdata
uid: trlxrdZVk
isDefault: true
+1 -1
View File
@@ -2,7 +2,7 @@
Remove any remaining comments before publishing as these may be displayed on Grafana.com --> Remove any remaining comments before publishing as these may be displayed on Grafana.com -->
# Battery # ChargeViz Battery Panel
<!-- To help maximize the impact of your README and improve usability for users, we propose the following loose structure: <!-- To help maximize the impact of your README and improve usability for users, we propose the following loose structure:
+22 -24
View File
@@ -1,6 +1,6 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ChargeVizPanel.tsx // ChargeVizPanel.tsx
// Responsive Grafana battery visual with fine-tuned spacing and optional text hiding. // Responsive Grafana battery visual with trimmed SVG padding.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import React from 'react'; import React from 'react';
@@ -24,7 +24,6 @@ const toFinite = (v: any): number | undefined => {
return Number.isFinite(n) ? n : undefined; return Number.isFinite(n) ? n : undefined;
}; };
// Computes a least-squares slope (% per minute) to smooth noisy data.
function slopePercentPerMinute(times: number[], values: number[]): number { function slopePercentPerMinute(times: number[], values: number[]): number {
const pts: Array<[number, number]> = []; const pts: Array<[number, number]> = [];
for (let i = 0; i < times.length; i++) { for (let i = 0; i < times.length; i++) {
@@ -66,7 +65,6 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
let low = 0; let low = 0;
let high = 0; let high = 0;
// Extract and process first numeric series
if (data.series.length > 0) { if (data.series.length > 0) {
const series = data.series[0]; const series = data.series[0];
const valueField = series.fields.find(f => f.type === 'number'); const valueField = series.fields.find(f => f.type === 'number');
@@ -108,7 +106,6 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
if (fillLevel < 30) fillColour = lowColour; if (fillLevel < 30) fillColour = lowColour;
else if (fillLevel < 70) fillColour = midColour; else if (fillLevel < 70) fillColour = midColour;
// Gradient fill logic
const gradientId = 'batteryGradient'; const gradientId = 'batteryGradient';
let gradientDef: React.ReactNode = null; let gradientDef: React.ReactNode = null;
if (gradientFill) { if (gradientFill) {
@@ -132,7 +129,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
); );
} }
// -------------------- Dynamic scaling (Option B) ------------------------ // Dynamic scaling (Option B)
const aspectRatio = 0.5; const aspectRatio = 0.5;
const panelRatio = height / width; const panelRatio = height / width;
let batteryWidth: number; let batteryWidth: number;
@@ -142,14 +139,15 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
batteryHeight = height * 0.9; batteryHeight = height * 0.9;
batteryWidth = batteryHeight / aspectRatio; batteryWidth = batteryHeight / aspectRatio;
} else { } else {
// Use slightly less width when text visible to reduce crowding.
batteryWidth = width * (hideText ? 0.95 : 0.6); batteryWidth = width * (hideText ? 0.95 : 0.6);
batteryHeight = batteryWidth * aspectRatio; batteryHeight = batteryWidth * aspectRatio;
} }
const fillHeightPx = (fillLevel / 100) * 220; const fillHeightPx = (fillLevel / 100) * 220;
// -------------------- Render ------------------------ // -----------------------------------------------------------------------
// Render
// -----------------------------------------------------------------------
return ( return (
<div <div
style={{ style={{
@@ -160,31 +158,31 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
width: '100%', width: '100%',
fontFamily: 'Inter, sans-serif', fontFamily: 'Inter, sans-serif',
color: theme.colors.text.primary, color: theme.colors.text.primary,
padding: 2, // minimal padding all round padding: 2,
boxSizing: 'border-box', boxSizing: 'border-box',
gap: hideText ? '0px' : '6px', // half the previous gap gap: hideText ? '0px' : '6px',
}} }}
> >
{/* Battery SVG */} {/* Battery SVG with reduced horizontal padding */}
<svg <svg
style={{ style={{
height: `${batteryHeight}px`, height: `${batteryHeight}px`,
width: `${batteryWidth}px`, width: `${batteryWidth}px`,
flex: '0 0 auto', flex: '0 0 auto',
}} }}
viewBox="0 0 120 240" viewBox="0 0 112 240" // narrower overall width
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
> >
{gradientDef} {gradientDef}
{/* Outline */} {/* Outline - starts closer to edges */}
<rect <rect
x="5" x="2"
y="10" y="10"
width="110" width="108" // narrower outline width
height="220" height="220"
rx="8" rx="6"
ry="8" ry="6"
stroke={theme.colors.text.secondary} stroke={theme.colors.text.secondary}
strokeWidth="3" strokeWidth="3"
fill="none" fill="none"
@@ -192,7 +190,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
{/* Terminal */} {/* Terminal */}
<rect <rect
x="50" x="46"
y="0" y="0"
width="20" width="20"
height="8" height="8"
@@ -201,9 +199,9 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
fill={theme.colors.text.secondary} fill={theme.colors.text.secondary}
/> />
{/* Fill */} {/* Fill - adjusted for new inner width */}
<rect <rect
x="8" x="4"
y={230 - fillHeightPx} y={230 - fillHeightPx}
width="104" width="104"
height={fillHeightPx} height={fillHeightPx}
@@ -212,7 +210,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
{/* Text inside battery */} {/* Text inside battery */}
<text <text
x="60" x="56"
y="120" y="120"
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
@@ -224,7 +222,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
</text> </text>
</svg> </svg>
{/* Stats only if enabled */} {/* Optional stats text */}
{!hideText && ( {!hideText && (
<div <div
style={{ style={{
@@ -233,9 +231,9 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options }
justifyContent: 'center', justifyContent: 'center',
flex: '1 1 auto', flex: '1 1 auto',
minWidth: 120, minWidth: 120,
overflow: 'hidden', // prevent wrapping overflow overflow: 'hidden',
whiteSpace: 'nowrap', // stop premature wrapping whiteSpace: 'nowrap',
textOverflow: 'ellipsis', // if too narrow, ellipsis instead textOverflow: 'ellipsis',
lineHeight: '1.4em', lineHeight: '1.4em',
}} }}
> >
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json", "$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json",
"type": "panel", "type": "panel",
"name": "Battery", "name": "ChargeViz Battery Panel",
"id": "andydixon-battery-panel", "id": "andydixon-chargevizbattery-panel",
"info": { "info": {
"keywords": ["panel"], "keywords": ["panel"],
"description": "", "description": "",
+35
View File
@@ -0,0 +1,35 @@
import { test, expect } from '@grafana/plugin-e2e';
test('should display "No data" in case panel data is empty', async ({
gotoPanelEditPage,
readProvisionedDashboard,
}) => {
const dashboard = await readProvisionedDashboard({ fileName: 'dashboard.json' });
const panelEditPage = await gotoPanelEditPage({ dashboard, id: '2' });
await expect(panelEditPage.panel.locator).toContainText('No data');
});
test('should display circle when data is passed to the panel', async ({
panelEditPage,
readProvisionedDataSource,
page,
}) => {
const ds = await readProvisionedDataSource({ fileName: 'datasources.yml' });
await panelEditPage.datasource.set(ds.name);
await panelEditPage.setVisualization('ChargeViz Battery Panel');
await expect(page.getByTestId('simple-panel-circle')).toBeVisible();
});
test('should display series counter when "Show series counter" option is enabled', async ({
gotoPanelEditPage,
readProvisionedDashboard,
page,
}) => {
const dashboard = await readProvisionedDashboard({ fileName: 'dashboard.json' });
const panelEditPage = await gotoPanelEditPage({ dashboard, id: '1' });
const options = panelEditPage.getCustomOptions('ChargeViz Battery Panel');
const showSeriesCounter = options.getSwitch('Show series counter');
await showSeriesCounter.check();
await expect(page.getByTestId('simple-panel-series-counter')).toBeVisible();
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.config/tsconfig.json"
}