From 548b7c89b68b4ca5fe365f657006039f2c715ec3 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sat, 11 Oct 2025 18:36:06 +0200 Subject: [PATCH] Initial Version --- .gitignore | 12 ++ README.md | 11 ++ package.json | 78 +++++++++ src/README.md | 50 ++++++ src/components/ChargeVizPanel.tsx | 254 ++++++++++++++++++++++++++++++ src/img/logo.svg | 1 + src/module.ts | 87 ++++++++++ src/plugin.json | 25 +++ src/types.ts | 7 + 9 files changed, 525 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 src/README.md create mode 100644 src/components/ChargeVizPanel.tsx create mode 100644 src/img/logo.svg create mode 100644 src/module.ts create mode 100644 src/plugin.json create mode 100644 src/types.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f04297 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +CHANGELOG.md +dist +docker-compose.yaml +eslint.config.mjs +jest.config.js +jest-setup.js +node_modules +package-lock.json +playwright.config.ts +provisioning +tests +tsconfig.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..426e7e5 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Grafana Battery Visualisation + +I hate writing documentation, and since I will have ADHD brain, I will forget how to do this. + +``` +npm install +npm run build +cp -r dist /var/lib/grafana/plugins/andydixon-battery-panel +``` + +in `/etc/grafana/grsfana.ini` add `andydixon-battery-panel` to `allow_loading_unsigned_plugins` diff --git a/package.json b/package.json new file mode 100644 index 0000000..b71dc35 --- /dev/null +++ b/package.json @@ -0,0 +1,78 @@ +{ + "name": "battery", + "version": "1.0.0", + "scripts": { + "build": "webpack -c ./.config/webpack/webpack.config.ts --env production", + "dev": "webpack -w -c ./.config/webpack/webpack.config.ts --env development", + "test": "jest --watch --onlyChanged", + "test:ci": "jest --passWithNoTests --maxWorkers 4", + "typecheck": "tsc --noEmit", + "lint": "eslint --cache .", + "lint:fix": "npm run lint -- --fix && prettier --write --list-different .", + "e2e": "playwright test", + "server": "docker compose up --build", + "sign": "npx --yes @grafana/sign-plugin@latest" + }, + "author": "Andy dixon", + "license": "Apache-2.0", + "devDependencies": { + "@grafana/eslint-config": "^8.2.0", + "@grafana/plugin-e2e": "^2.2.0", + "@grafana/tsconfig": "^2.0.0", + "@playwright/test": "^1.52.0", + "@stylistic/eslint-plugin-ts": "^2.9.0", + "@swc/core": "^1.3.90", + "@swc/helpers": "^0.5.0", + "@swc/jest": "^0.2.26", + "@testing-library/jest-dom": "6.1.4", + "@testing-library/react": "14.0.0", + "@types/jest": "^29.5.0", + "@types/node": "^20.8.7", + "@types/testing-library__jest-dom": "5.14.8", + "@typescript-eslint/eslint-plugin": "^8.3.0", + "@typescript-eslint/parser": "^8.3.0", + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^6.7.3", + "eslint": "^9.0.0", + "eslint-config-prettier": "^8.8.0", + "eslint-plugin-jsdoc": "^51.2.3", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-webpack-plugin": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^8.0.0", + "glob": "^10.2.7", + "identity-obj-proxy": "3.0.0", + "imports-loader": "^5.0.0", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "prettier": "^2.8.7", + "replace-in-file-webpack-plugin": "^1.0.6", + "sass": "1.63.2", + "sass-loader": "13.3.1", + "semver": "^7.6.3", + "style-loader": "3.3.3", + "swc-loader": "^0.2.3", + "terser-webpack-plugin": "^5.3.10", + "ts-node": "^10.9.2", + "typescript": "5.5.4", + "webpack": "^5.94.0", + "webpack-cli": "^5.1.4", + "webpack-livereload-plugin": "^3.0.2", + "webpack-subresource-integrity": "^5.1.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=22" + }, + "dependencies": { + "@emotion/css": "11.10.6", + "@grafana/data": "^12.2.0", + "@grafana/i18n": "^12.2.0", + "@grafana/runtime": "^12.2.0", + "@grafana/ui": "^12.2.0", + "@grafana/schema": "^12.2.0", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "packageManager": "npm@10.8.2" +} diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..d9dc2e5 --- /dev/null +++ b/src/README.md @@ -0,0 +1,50 @@ + + +# Battery + + diff --git a/src/components/ChargeVizPanel.tsx b/src/components/ChargeVizPanel.tsx new file mode 100644 index 0000000..be35cc7 --- /dev/null +++ b/src/components/ChargeVizPanel.tsx @@ -0,0 +1,254 @@ +// --------------------------------------------------------------------------- +// ChargeVizPanel.tsx +// Responsive Grafana battery visual with fine-tuned spacing and optional text hiding. +// --------------------------------------------------------------------------- + +import React from 'react'; +import { PanelProps } from '@grafana/data'; +import { useTheme2 } from '@grafana/ui'; + +interface ChargeVizOptions { + lowColour: string; + midColour: string; + highColour: string; + gradientFill: boolean; + decimalPlaces: number; + useRegressionForRate: boolean; + hideText: boolean; +} + +interface Props extends PanelProps {} + +const toFinite = (v: any): number | undefined => { + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +}; + +// Computes a least-squares slope (% per minute) to smooth noisy data. +function slopePercentPerMinute(times: number[], values: number[]): number { + const pts: Array<[number, number]> = []; + for (let i = 0; i < times.length; i++) { + const t = toFinite(times[i]); + const v = toFinite(values[i]); + if (t !== undefined && v !== undefined) pts.push([t, v]); + } + if (pts.length < 2) return 0; + + const xs = pts.map(p => (p[0] - pts[0][0]) / 60000); + const ys = pts.map(p => p[1]); + const xMean = xs.reduce((a, b) => a + b, 0) / xs.length; + const yMean = ys.reduce((a, b) => a + b, 0) / ys.length; + + let num = 0; + let den = 0; + for (let i = 0; i < xs.length; i++) { + const dx = xs[i] - xMean; + const dy = ys[i] - yMean; + num += dx * dy; + den += dx * dx; + } + return den === 0 ? 0 : num / den; +} + +export const ChargeVizPanel: React.FC = ({ data, width, height, options }) => { + const theme = useTheme2(); + + const lowColour = options.lowColour || '#F44336'; + const midColour = options.midColour || '#FF9800'; + const highColour = options.highColour || '#4CAF50'; + const gradientFill = options.gradientFill ?? true; + const dp = Number.isFinite(options.decimalPlaces) ? options.decimalPlaces : 2; + const useRegression = options.useRegressionForRate ?? false; + const hideText = options.hideText ?? false; + + let currentValue = 0; + let rate = 0; + let low = 0; + let high = 0; + + // Extract and process first numeric series + if (data.series.length > 0) { + const series = data.series[0]; + const valueField = series.fields.find(f => f.type === 'number'); + const timeField = series.fields.find(f => f.type === 'time'); + + if (valueField && timeField) { + const rawValues = valueField.values.toArray(); + const rawTimes = timeField.values.toArray(); + const values: number[] = []; + const times: number[] = []; + + for (let i = 0; i < rawValues.length; i++) { + const v = toFinite(rawValues[i]); + const t = toFinite(rawTimes[i]); + if (v !== undefined && t !== undefined) { + values.push(v); + times.push(t); + } + } + + if (values.length > 0) { + currentValue = values[values.length - 1]; + low = Math.min(...values); + high = Math.max(...values); + + if (useRegression && values.length >= 2) { + rate = slopePercentPerMinute(times, values); + } else if (values.length >= 2) { + const deltaPercent = values[values.length - 1] - values[0]; + const deltaMinutes = (times[times.length - 1] - times[0]) / 60000; + rate = deltaMinutes !== 0 ? deltaPercent / deltaMinutes : 0; + } + } + } + } + + const fillLevel = Math.max(0, Math.min(100, currentValue)); + let fillColour = highColour; + if (fillLevel < 30) fillColour = lowColour; + else if (fillLevel < 70) fillColour = midColour; + + // Gradient fill logic + const gradientId = 'batteryGradient'; + let gradientDef: React.ReactNode = null; + if (gradientFill) { + let startColour = fillColour; + let endColour = fillColour; + if (rate > 0) { + startColour = fillColour; + endColour = theme.colors.success.text; + } else if (rate < 0) { + startColour = theme.colors.error.text; + endColour = fillColour; + } + + gradientDef = ( + + + + + + + ); + } + + // -------------------- Dynamic scaling (Option B) ------------------------ + const aspectRatio = 0.5; + const panelRatio = height / width; + let batteryWidth: number; + let batteryHeight: number; + + if (panelRatio < aspectRatio) { + batteryHeight = height * 0.9; + batteryWidth = batteryHeight / aspectRatio; + } else { + // Use slightly less width when text visible to reduce crowding. + batteryWidth = width * (hideText ? 0.95 : 0.6); + batteryHeight = batteryWidth * aspectRatio; + } + + const fillHeightPx = (fillLevel / 100) * 220; + + // -------------------- Render ------------------------ + return ( +
+ {/* Battery SVG */} + + {gradientDef} + + {/* Outline */} + + + {/* Terminal */} + + + {/* Fill */} + + + {/* Text inside battery */} + + {fillLevel.toFixed(0)}% + + + + {/* Stats only if enabled */} + {!hideText && ( +
+
+ Rate: {rate.toFixed(dp)} %/min +
+
Low: {low.toFixed(dp)}%
+
High: {high.toFixed(dp)}%
+
+ {rate > 0 ? 'Charging ↑' : rate < 0 ? 'Discharging ↓' : 'Stable'} +
+
+ )} +
+ ); +}; diff --git a/src/img/logo.svg b/src/img/logo.svg new file mode 100644 index 0000000..3d284de --- /dev/null +++ b/src/img/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/module.ts b/src/module.ts new file mode 100644 index 0000000..e673e6b --- /dev/null +++ b/src/module.ts @@ -0,0 +1,87 @@ +// Import PanelPlugin to register our React component as a Grafana panel. +import { PanelPlugin } from '@grafana/data'; +// Import the component itself. +import { ChargeVizPanel } from './components/ChargeVizPanel'; + +/** + * Re-declare the options interface here to keep this file self-contained for the editor. + * These must match the props used in ChargeVizPanel. + */ +interface ChargeVizOptions { + lowColour: string; + midColour: string; + highColour: string; + gradientFill: boolean; + decimalPlaces: number; + useRegressionForRate: boolean; +} + +/** + * Export the plugin instance that Grafana will discover. + * We also define the panel editor options so users can configure colours and behaviour. + */ +export const plugin = new PanelPlugin(ChargeVizPanel).setPanelOptions( + // The builder lets us declare editor controls (colour pickers, switches, number inputs). + (builder) => { + // Colour: low battery + builder.addColorPicker({ + path: 'lowColour', // option field name + name: 'Low battery colour', // label in the editor + defaultValue: '#F44336', // sensible default (red) + description: 'Colour when the level is under 30%.', + }); + + // Colour: medium battery + builder.addColorPicker({ + path: 'midColour', + name: 'Medium battery colour', + defaultValue: '#FF9800', // amber + description: 'Colour when the level is between 30% and 70%.', + }); + + // Colour: high battery + builder.addColorPicker({ + path: 'highColour', + name: 'High battery colour', + defaultValue: '#4CAF50', // green + description: 'Colour when the level is above 70%.', + }); + + // Toggle for gradient fill. + builder.addBooleanSwitch({ + path: 'gradientFill', + name: 'Enable gradient fill', + defaultValue: true, + description: 'Show a subtle gradient that hints charging/discharging.', + }); + + // Numeric input: decimal places for numbers shown in the stats. + builder.addNumberInput({ + path: 'decimalPlaces', + name: 'Decimal places', + defaultValue: 2, + settings: { min: 0, max: 6, integer: true }, + description: 'How many decimal places to use for rate/low/high.', + }); + + builder.addBooleanSwitch({ + path: 'hideText', + name: 'Hide stats text (battery only)', + defaultValue: false, + description: 'If enabled, hides the rate and high/low text, showing only the battery graphic.', + }); + + + // Toggle: use regression for rate instead of simple first/last. + builder.addBooleanSwitch({ + path: 'useRegressionForRate', + name: 'Use regression for rate', + defaultValue: false, + description: + 'Compute %/min using more complicated maths.', + }); + + // Return the builder so Grafana applies the options. + return builder; + } +); diff --git a/src/plugin.json b/src/plugin.json new file mode 100644 index 0000000..d2bd004 --- /dev/null +++ b/src/plugin.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/grafana/grafana/main/docs/sources/developers/plugins/plugin.schema.json", + "type": "panel", + "name": "Battery", + "id": "andydixon-battery-panel", + "info": { + "keywords": ["panel"], + "description": "", + "author": { + "name": "Andy dixon" + }, + "logos": { + "small": "img/logo.svg", + "large": "img/logo.svg" + }, + "links": [], + "screenshots": [], + "version": "%VERSION%", + "updated": "%TODAY%" + }, + "dependencies": { + "grafanaDependency": ">=10.4.0", + "plugins": [] + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..47b3fbd --- /dev/null +++ b/src/types.ts @@ -0,0 +1,7 @@ +type SeriesSize = 'sm' | 'md' | 'lg'; + +export interface SimpleOptions { + text: string; + showSeriesCount: boolean; + seriesCountSize: SeriesSize; +}