Initial Version
This commit is contained in:
+12
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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: 
|
||||||
|
|
||||||
|
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!
|
||||||
|
-->
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type SeriesSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
export interface SimpleOptions {
|
||||||
|
text: string;
|
||||||
|
showSeriesCount: boolean;
|
||||||
|
seriesCountSize: SeriesSize;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user