Initial Version

This commit is contained in:
Andy Dixon
2025-10-11 18:36:06 +02:00
parent 5a689b53ff
commit 548b7c89b6
9 changed files with 525 additions and 0 deletions
+12
View File
@@ -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
+11
View File
@@ -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`
+78
View File
@@ -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"
}
+50
View File
@@ -0,0 +1,50 @@
<!-- This README file is going to be the one displayed on the Grafana.com website for your plugin. Uncomment and replace the content here before publishing.
Remove any remaining comments before publishing as these may be displayed on Grafana.com -->
# Battery
<!-- To help maximize the impact of your README and improve usability for users, we propose the following loose structure:
**BEFORE YOU BEGIN**
- Ensure all links are absolute URLs so that they will work when the README is displayed within Grafana and Grafana.com
- Be inspired ✨
- [grafana-polystat-panel](https://github.com/grafana/grafana-polystat-panel)
- [volkovlabs-variable-panel](https://github.com/volkovlabs/volkovlabs-variable-panel)
**ADD SOME BADGES**
Badges convey useful information at a glance for users whether in the Catalog or viewing the source code. You can use the generator on [Shields.io](https://shields.io/badges/dynamic-json-badge) together with the Grafana.com API
to create dynamic badges that update automatically when you publish a new version to the marketplace.
- For the URL parameter use `https://grafana.com/api/plugins/your-plugin-id`.
- Example queries:
- Downloads: `$.downloads`
- Catalog Version: `$.version`
- Grafana Dependency: `$.grafanaDependency`
- Signature Type: `$.versionSignatureType`
- Optionally, for the logo parameter use `grafana`.
Full example: ![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?logo=grafana&query=$.version&url=https://grafana.com/api/plugins/grafana-polystat-panel&label=Marketplace&prefix=v&color=F47A20)
Consider other [badges](https://shields.io/badges) as you feel appropriate for your project.
## Overview / Introduction
Provide one or more paragraphs as an introduction to your plugin to help users understand why they should use it.
Consider including screenshots:
- in [plugin.json](https://grafana.com/developers/plugin-tools/reference/plugin-json#info) include them as relative links.
- in the README ensure they are absolute URLs.
## Requirements
List any requirements or dependencies they may need to run the plugin.
## Getting Started
Provide a quick start on how to configure and use the plugin.
## Documentation
If your project has dedicated documentation available for users, provide links here. For help in following Grafana's style recommendations for technical documentation, refer to our [Writer's Toolkit](https://grafana.com/docs/writers-toolkit/).
## Contributing
Do you want folks to contribute to the plugin or provide feedback through specific means? If so, tell them how!
-->
+254
View File
@@ -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<ChargeVizOptions> {}
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<Props> = ({ 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 = (
<defs>
<linearGradient id={gradientId} x1="0" y1="1" x2="0" y2="0">
<stop offset="0%" stopColor={startColour} />
<stop offset="100%" stopColor={endColour} />
</linearGradient>
</defs>
);
}
// -------------------- 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 (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: hideText ? 'center' : 'flex-start',
height: '100%',
width: '100%',
fontFamily: 'Inter, sans-serif',
color: theme.colors.text.primary,
padding: 2, // minimal padding all round
boxSizing: 'border-box',
gap: hideText ? '0px' : '6px', // half the previous gap
}}
>
{/* Battery SVG */}
<svg
style={{
height: `${batteryHeight}px`,
width: `${batteryWidth}px`,
flex: '0 0 auto',
}}
viewBox="0 0 120 240"
preserveAspectRatio="xMidYMid meet"
>
{gradientDef}
{/* Outline */}
<rect
x="5"
y="10"
width="110"
height="220"
rx="8"
ry="8"
stroke={theme.colors.text.secondary}
strokeWidth="3"
fill="none"
/>
{/* Terminal */}
<rect
x="50"
y="0"
width="20"
height="8"
rx="2"
ry="2"
fill={theme.colors.text.secondary}
/>
{/* Fill */}
<rect
x="8"
y={230 - fillHeightPx}
width="104"
height={fillHeightPx}
fill={gradientFill ? `url(#${gradientId})` : fillColour}
/>
{/* Text inside battery */}
<text
x="60"
y="120"
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
fontSize="28"
fontWeight="bold"
>
{fillLevel.toFixed(0)}%
</text>
</svg>
{/* Stats only if enabled */}
{!hideText && (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
flex: '1 1 auto',
minWidth: 120,
overflow: 'hidden', // prevent wrapping overflow
whiteSpace: 'nowrap', // stop premature wrapping
textOverflow: 'ellipsis', // if too narrow, ellipsis instead
lineHeight: '1.4em',
}}
>
<div style={{ fontSize: '1.05em', fontWeight: 600 }}>
Rate: {rate.toFixed(dp)} %/min
</div>
<div>Low: {low.toFixed(dp)}%</div>
<div>High: {high.toFixed(dp)}%</div>
<div style={{ opacity: 0.8 }}>
{rate > 0 ? 'Charging ↑' : rate < 0 ? 'Discharging ↓' : 'Stable'}
</div>
</div>
)}
</div>
);
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 81.9 71.52"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="42.95" y1="16.88" x2="81.9" y2="16.88" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M55.46,62.43A2,2,0,0,1,54.07,59l4.72-4.54a2,2,0,0,1,2.2-.39l3.65,1.63,3.68-3.64a2,2,0,1,1,2.81,2.84l-4.64,4.6a2,2,0,0,1-2.22.41L60.6,58.26l-3.76,3.61A2,2,0,0,1,55.46,62.43Z"/><path class="cls-2" d="M37,0H2A2,2,0,0,0,0,2V31.76a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V2A2,2,0,0,0,37,0ZM4,29.76V8.84H35V29.76Z"/><path class="cls-3" d="M79.9,0H45a2,2,0,0,0-2,2V31.76a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V2A2,2,0,0,0,79.9,0ZM47,29.76V8.84h31V29.76Z"/><path class="cls-2" d="M37,37.76H2a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2H37a2,2,0,0,0,2-2V39.76A2,2,0,0,0,37,37.76ZM4,67.52V46.6H35V67.52Z"/><path class="cls-2" d="M79.9,37.76H45a2,2,0,0,0-2,2V69.52a2,2,0,0,0,2,2h35a2,2,0,0,0,2-2V39.76A2,2,0,0,0,79.9,37.76ZM47,67.52V46.6h31V67.52Z"/><rect class="cls-1" x="10.48" y="56.95" width="4" height="5.79"/><rect class="cls-1" x="17.43" y="53.95" width="4" height="8.79"/><rect class="cls-1" x="24.47" y="50.95" width="4" height="11.79"/><path class="cls-1" d="M19.47,25.8a6.93,6.93,0,1,1,6.93-6.92A6.93,6.93,0,0,1,19.47,25.8Zm0-9.85a2.93,2.93,0,1,0,2.93,2.93A2.93,2.93,0,0,0,19.47,16Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+87
View File
@@ -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<ChargeVizOptions>(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;
}
);
+25
View File
@@ -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": []
}
}
+7
View File
@@ -0,0 +1,7 @@
type SeriesSize = 'sm' | 'md' | 'lg';
export interface SimpleOptions {
text: string;
showSeriesCount: boolean;
seriesCountSize: SeriesSize;
}