Compare commits

..
1 Commits
Author SHA1 Message Date
dependabot[bot] 79ef69286f Bump serialize-javascript and copy-webpack-plugin
Bumps [serialize-javascript](https://github.com/yahoo/serialize-javascript) to 7.0.5 and updates ancestor dependency [copy-webpack-plugin](https://github.com/webpack/copy-webpack-plugin). These dependencies need to be updated together.


Updates `serialize-javascript` from 6.0.2 to 7.0.5
- [Release notes](https://github.com/yahoo/serialize-javascript/releases)
- [Commits](https://github.com/yahoo/serialize-javascript/compare/v6.0.2...v7.0.5)

Updates `copy-webpack-plugin` from 11.0.0 to 14.0.0
- [Release notes](https://github.com/webpack/copy-webpack-plugin/releases)
- [Changelog](https://github.com/webpack/copy-webpack-plugin/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/copy-webpack-plugin/compare/v11.0.0...v14.0.0)

---
updated-dependencies:
- dependency-name: serialize-javascript
  dependency-version: 7.0.5
  dependency-type: indirect
- dependency-name: copy-webpack-plugin
  dependency-version: 14.0.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 12:22:55 +00:00
7 changed files with 72 additions and 478 deletions
+13 -224
View File
@@ -34,7 +34,7 @@
"@types/testing-library__jest-dom": "5.14.8", "@types/testing-library__jest-dom": "5.14.8",
"@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0", "@typescript-eslint/parser": "^8.3.0",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^14.0.0",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
@@ -2613,44 +2613,6 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/@openfeature/core": { "node_modules/@openfeature/core": {
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.10.0.tgz", "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.10.0.tgz",
@@ -6026,21 +5988,20 @@
} }
}, },
"node_modules/copy-webpack-plugin": { "node_modules/copy-webpack-plugin": {
"version": "11.0.0", "version": "14.0.0",
"resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz",
"integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-glob": "^3.2.11",
"glob-parent": "^6.0.1", "glob-parent": "^6.0.1",
"globby": "^13.1.1",
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"schema-utils": "^4.0.0", "schema-utils": "^4.2.0",
"serialize-javascript": "^6.0.0" "serialize-javascript": "^7.0.3",
"tinyglobby": "^0.2.12"
}, },
"engines": { "engines": {
"node": ">= 14.15.0" "node": ">= 20.9.0"
}, },
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -6950,19 +6911,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-type": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/direction": { "node_modules/direction": {
"version": "0.1.5", "version": "0.1.5",
"resolved": "https://registry.npmjs.org/direction/-/direction-0.1.5.tgz", "resolved": "https://registry.npmjs.org/direction/-/direction-0.1.5.tgz",
@@ -7906,36 +7854,6 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
"glob-parent": "^5.1.2",
"merge2": "^1.3.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -7986,16 +7904,6 @@
"integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==", "integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/faye-websocket": { "node_modules/faye-websocket": {
"version": "0.10.0", "version": "0.10.0",
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz",
@@ -8578,36 +8486,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/globby": {
"version": "13.2.2",
"resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz",
"integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"dir-glob": "^3.0.1",
"fast-glob": "^3.3.0",
"ignore": "^5.2.4",
"merge2": "^1.4.1",
"slash": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globby/node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -11926,16 +11804,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/micro-memoize": { "node_modules/micro-memoize": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/micro-memoize/-/micro-memoize-4.2.0.tgz", "resolved": "https://registry.npmjs.org/micro-memoize/-/micro-memoize-4.2.0.tgz",
@@ -13108,27 +12976,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/quick-lru": { "node_modules/quick-lru": {
"version": "6.1.2", "version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
@@ -13162,16 +13009,6 @@
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/raw-body": { "node_modules/raw-body": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-1.1.7.tgz", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-1.1.7.tgz",
@@ -13932,17 +13769,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/robust-predicates": { "node_modules/robust-predicates": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
@@ -13958,30 +13784,6 @@
"@babel/runtime": "^7.1.2" "@babel/runtime": "^7.1.2"
} }
}, },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": { "node_modules/rw": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
@@ -14273,13 +14075,13 @@
} }
}, },
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "6.0.2", "version": "7.0.5",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "engines": {
"randombytes": "^2.1.0" "node": ">=20.0.0"
} }
}, },
"node_modules/set-function-length": { "node_modules/set-function-length": {
@@ -14470,19 +14272,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/slash": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
"integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/slate": { "node_modules/slate": {
"version": "0.47.9", "version": "0.47.9",
"resolved": "https://registry.npmjs.org/slate/-/slate-0.47.9.tgz", "resolved": "https://registry.npmjs.org/slate/-/slate-0.47.9.tgz",
+1 -1
View File
@@ -31,7 +31,7 @@
"@types/testing-library__jest-dom": "5.14.8", "@types/testing-library__jest-dom": "5.14.8",
"@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0", "@typescript-eslint/parser": "^8.3.0",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^14.0.0",
"css-loader": "^6.7.3", "css-loader": "^6.7.3",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
+42 -170
View File
@@ -1,5 +1,5 @@
import React, { useId, useMemo } from 'react'; import React, { useId, useMemo } from 'react';
import { PanelProps, Field, DataFrame } from '@grafana/data'; import { PanelProps, Field } from '@grafana/data';
import { useTheme2 } from '@grafana/ui'; import { useTheme2 } from '@grafana/ui';
import { css, keyframes } from '@emotion/css'; import { css, keyframes } from '@emotion/css';
import { ChargeVizOptions } from '../types'; import { ChargeVizOptions } from '../types';
@@ -76,99 +76,19 @@ function inferStateFromTrend(
return 'stable'; return 'stable';
} }
/** Format minutes into a human-readable duration string. */ /** Find a field by configured name, falling back to first field of given type. */
function formatMinutes(mins: number): string { function resolveField(
if (!Number.isFinite(mins) || mins < 0) { fields: Field[],
return '—'; configuredName: string | undefined,
} fallbackType: string
if (mins < 1) { ): Field | undefined {
return '< 1 min'; if (configuredName) {
} const match = fields.find((f) => f.name === configuredName);
const h = Math.floor(mins / 60); if (match) {
const m = Math.round(mins % 60); return match;
if (h === 0) {
return `${m} min`;
}
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
/**
* Resolve a field across all data frames using the "refId:fieldName" key
* format produced by the dropdown, or a plain field name for manual entry.
* Returns the matching field and its companion time field, or undefined.
*/
function resolveFieldByKey(
series: DataFrame[],
key: string | undefined,
): { field: Field; timeField: Field } | undefined {
if (!key) {
return undefined;
}
// Try "refId:fieldName" format first
const colonIdx = key.indexOf(':');
if (colonIdx > 0) {
const refId = key.substring(0, colonIdx);
const fieldName = key.substring(colonIdx + 1);
for (const frame of series) {
if (frame.refId !== refId && frame.name !== refId) {
continue;
}
const timeField = frame.fields.find((f) => f.type === 'time');
const match = frame.fields.find((f) => f.name === fieldName && f.type !== 'time');
if (match && timeField) {
return { field: match, timeField };
} }
} }
} return fields.find((f) => f.type === fallbackType);
// Fallback: search all frames by field name, display name, frame name, or refId
const needle = key.toLowerCase().trim();
for (const frame of series) {
const timeField = frame.fields.find((f) => f.type === 'time');
if (!timeField) {
continue;
}
for (const field of frame.fields) {
if (field.type === 'time') {
continue;
}
const candidates = [
field.name,
field.config?.displayName,
field.config?.displayNameFromDS,
frame.name,
frame.refId,
];
for (const c of candidates) {
if (c && c.toLowerCase().trim() === needle) {
return { field, timeField };
}
}
}
}
return undefined;
}
/**
* Get the first numeric field from the first series (default fallback).
*/
function getFirstNumericField(
series: DataFrame[],
): { field: Field; timeField: Field } | undefined {
for (const frame of series) {
const timeField = frame.fields.find((f) => f.type === 'time');
const numField = frame.fields.find((f) => f.type === 'number');
if (numField && timeField) {
return { field: numField, timeField };
}
}
return undefined;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -415,6 +335,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
const lowThreshold = options.lowThreshold ?? 30; const lowThreshold = options.lowThreshold ?? 30;
const highThreshold = options.highThreshold ?? 70; const highThreshold = options.highThreshold ?? 70;
const gradientFill = options.gradientFill ?? true; const gradientFill = options.gradientFill ?? true;
const orientation = options.orientation ?? 'vertical';
const chargingAnimation = options.chargingAnimation ?? true; const chargingAnimation = options.chargingAnimation ?? true;
const borderWidth = options.borderWidth ?? 3; const borderWidth = options.borderWidth ?? 3;
const dp = Number.isFinite(options.decimalPlaces) ? options.decimalPlaces : 2; const dp = Number.isFinite(options.decimalPlaces) ? options.decimalPlaces : 2;
@@ -424,7 +345,6 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
const showLowHigh = options.showLowHigh ?? true; const showLowHigh = options.showLowHigh ?? true;
const showStatus = options.showStatus ?? true; const showStatus = options.showStatus ?? true;
const enableLoad = options.enableLoad ?? false; const enableLoad = options.enableLoad ?? false;
const enableTimeLeft = options.enableTimeLeft ?? false;
const enableStateField = options.enableStateField ?? false; const enableStateField = options.enableStateField ?? false;
const stateChargingValue = (options.stateChargingValue ?? 'charging').toLowerCase(); const stateChargingValue = (options.stateChargingValue ?? 'charging').toLowerCase();
const stateDischargingValue = (options.stateDischargingValue ?? 'discharging').toLowerCase(); const stateDischargingValue = (options.stateDischargingValue ?? 'discharging').toLowerCase();
@@ -438,20 +358,18 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
let lo = 0; let lo = 0;
let hi = 0; let hi = 0;
let loadValue: number | undefined; let loadValue: number | undefined;
let timeLeftValue: number | undefined;
let stateFromField: 'charging' | 'discharging' | 'stable' | undefined; let stateFromField: 'charging' | 'discharging' | 'stable' | undefined;
let inferredState: 'charging' | 'discharging' | 'stable' = 'stable'; let inferredState: 'charging' | 'discharging' | 'stable' = 'stable';
const allSeries = data.series; if (data.series.length > 0) {
const series = data.series[0];
const fields = series.fields;
const timeField = fields.find((f) => f.type === 'time');
const chargeField = resolveField(fields, options.chargeField, 'number');
if (allSeries.length > 0) { if (chargeField && timeField) {
// Resolve the charge field: user selection → first numeric field const rawValues = Array.from(chargeField.values);
const chargeResolved = resolveFieldByKey(allSeries, options.chargeField) const rawTimes = Array.from(timeField.values);
?? getFirstNumericField(allSeries);
if (chargeResolved) {
const rawValues = Array.from(chargeResolved.field.values);
const rawTimes = Array.from(chargeResolved.timeField.values);
const values: number[] = []; const values: number[] = [];
const times: number[] = []; const times: number[] = [];
@@ -479,15 +397,16 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
} }
} }
// Infer state from recent trend (last ≤ 50 points)
inferredState = inferStateFromTrend(values); inferredState = inferStateFromTrend(values);
} }
} }
// Load field — search across all series // Load field
if (enableLoad && options.loadField) { if (enableLoad && options.loadField) {
const loadResolved = resolveFieldByKey(allSeries, options.loadField); const lf = fields.find((f) => f.name === options.loadField);
if (loadResolved) { if (lf) {
const arr = Array.from(loadResolved.field.values); const arr = Array.from(lf.values);
const last = toFinite(arr[arr.length - 1]); const last = toFinite(arr[arr.length - 1]);
if (last !== undefined) { if (last !== undefined) {
loadValue = last; loadValue = last;
@@ -495,23 +414,11 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
} }
} }
// Time remaining field — search across all series // State field
if (enableTimeLeft && options.timeLeftField) {
const tlResolved = resolveFieldByKey(allSeries, options.timeLeftField);
if (tlResolved) {
const arr = Array.from(tlResolved.field.values);
const last = toFinite(arr[arr.length - 1]);
if (last !== undefined) {
timeLeftValue = last;
}
}
}
// State field — search across all series
if (enableStateField && options.stateField) { if (enableStateField && options.stateField) {
const stateResolved = resolveFieldByKey(allSeries, options.stateField); const sf = fields.find((f) => f.name === options.stateField);
if (stateResolved) { if (sf) {
const arr = Array.from(stateResolved.field.values); const arr = Array.from(sf.values);
const lastRaw = arr[arr.length - 1]; const lastRaw = arr[arr.length - 1];
if (lastRaw !== undefined && lastRaw !== null) { if (lastRaw !== undefined && lastRaw !== null) {
const v = String(lastRaw).toLowerCase().trim(); const v = String(lastRaw).toLowerCase().trim();
@@ -527,14 +434,14 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
} }
} }
return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, timeLeftValue, stateFromField, inferredState }; return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, stateFromField, inferredState };
}, [ }, [
data.series, useRegression, enableLoad, enableTimeLeft, enableStateField, data.series, useRegression, enableLoad, enableStateField,
options.chargeField, options.loadField, options.timeLeftField, options.stateField, options.chargeField, options.loadField, options.stateField,
stateChargingValue, stateDischargingValue, stateChargingValue, stateDischargingValue,
]); ]);
const { currentValue, rate, low, high, loadValue, timeLeftValue, stateFromField, inferredState } = extracted; const { currentValue, rate, low, high, loadValue, stateFromField, inferredState } = extracted;
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Derived visual values // Derived visual values
@@ -567,31 +474,21 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
const animating = chargingAnimation && isCharging; const animating = chargingAnimation && isCharging;
const showAnyStats = showRate || showLowHigh || showStatus; const showAnyStats = showRate || showLowHigh || showStatus;
const hasLoadExtra = enableLoad && loadValue !== undefined; const hasExtras = (enableLoad && loadValue !== undefined);
const hasTimeLeftExtra = enableTimeLeft && timeLeftValue !== undefined;
const hasExtras = hasLoadExtra || hasTimeLeftExtra;
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Responsive layout // Responsive layout
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
const isCompact = orientation === 'vertical' ? width < 140 : height < 100;
// Auto-revert horizontal to vertical when the panel is too narrow
// for horizontal battery + side content to fit comfortably.
const preferredOrientation = options.orientation ?? 'vertical';
const effectiveOrientation = preferredOrientation === 'horizontal' && width < 300
? 'vertical'
: preferredOrientation;
const isCompact = effectiveOrientation === 'vertical' ? width < 140 : height < 100;
const isTiny = width < 80 || height < 80; const isTiny = width < 80 || height < 80;
const useColumnLayout = effectiveOrientation === 'vertical' const useColumnLayout = orientation === 'vertical'
? width < 240 ? width < 240
: height < 200; : height < 200;
const statAreaSize = useColumnLayout const statAreaSize = useColumnLayout
? (effectiveOrientation === 'vertical' ? height * 0.3 : width * 0.3) ? (orientation === 'vertical' ? height * 0.3 : width * 0.3)
: (effectiveOrientation === 'vertical' ? width * 0.5 : height * 0.5); : (orientation === 'vertical' ? width * 0.5 : height * 0.5);
const baseFontSize = Math.max(10, Math.min(16, statAreaSize / 8)); const baseFontSize = Math.max(10, Math.min(16, statAreaSize / 8));
// Gauge size scales with available panel space // Gauge size scales with available panel space
@@ -729,7 +626,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
return ( return (
<div className={containerStyle} data-testid="charge-viz-panel"> <div className={containerStyle} data-testid="charge-viz-panel">
<div className={batteryWrapStyle}> <div className={batteryWrapStyle}>
{effectiveOrientation === 'vertical' {orientation === 'vertical'
? <VerticalBattery {...batteryProps} /> ? <VerticalBattery {...batteryProps} />
: <HorizontalBattery {...batteryProps} /> : <HorizontalBattery {...batteryProps} />
} }
@@ -738,7 +635,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
{(showAnyStats || hasExtras) && !isTiny && ( {(showAnyStats || hasExtras) && !isTiny && (
<div className={sidePanelStyle}> <div className={sidePanelStyle}>
{/* Load gauge card */} {/* Load gauge card */}
{hasLoadExtra && !isCompact && ( {hasExtras && !isCompact && (
<div className={loadCardStyle}> <div className={loadCardStyle}>
<LoadGauge <LoadGauge
value={loadClamped} value={loadClamped}
@@ -751,7 +648,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
)} )}
{/* Compact load fallback when gauge doesn't fit */} {/* Compact load fallback when gauge doesn't fit */}
{hasLoadExtra && isCompact && ( {hasExtras && isCompact && (
<div className={css` <div className={css`
font-size: ${baseFontSize}px; font-size: ${baseFontSize}px;
white-space: nowrap; white-space: nowrap;
@@ -761,31 +658,6 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
</div> </div>
)} )}
{/* Time remaining */}
{hasTimeLeftExtra && (
<div className={css`
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 8px;
background: ${theme.colors.background.secondary};
border: 1px solid ${theme.colors.border.weak};
white-space: nowrap;
font-size: ${baseFontSize}px;
`}>
<span style={{ fontSize: baseFontSize * 1.1, lineHeight: 1 }}>⏱</span>
<div>
<div style={{ opacity: 0.7, fontSize: baseFontSize * 0.8, lineHeight: 1.2 }}>
Time remaining
</div>
<strong style={{ fontSize: baseFontSize * 1.15 }}>
{formatMinutes(timeLeftValue!)}
</strong>
</div>
</div>
)}
{/* Status badge */} {/* Status badge */}
{showStatus && !isCompact && ( {showStatus && !isCompact && (
<div className={statusBadgeStyle}> <div className={statusBadgeStyle}>
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#4CAF50" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="6" width="18" height="12" rx="2" ry="2"/><line x1="23" y1="10" x2="23" y2="14"/><rect x="3" y="8" width="6" height="8" rx="1" ry="1" fill="#4CAF50" stroke="none"/><rect x="10" y="8" width="6" height="8" rx="1" ry="1" fill="#4CAF50" stroke="none" opacity="0.4"/></svg> <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>

Before

Width:  |  Height:  |  Size: 435 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+14 -79
View File
@@ -1,56 +1,18 @@
import { PanelPlugin, SelectableValue } from '@grafana/data'; import { PanelPlugin } from '@grafana/data';
import { ChargeVizPanel } from './components/ChargeVizPanel'; import { ChargeVizPanel } from './components/ChargeVizPanel';
import { ChargeVizOptions } from './types'; import { ChargeVizOptions } from './types';
/**
* Build a list of selectable field identifiers from the panel's data frames.
* Each option label shows "fieldName (refId)" so users can distinguish
* fields from different queries.
*/
function buildFieldOptions(data: Array<{ fields: Array<{ name: string; type: string }>; refId?: string; name?: string }>): Array<SelectableValue<string>> {
const opts: Array<SelectableValue<string>> = [
{ label: '— Auto (first number field)', value: '' },
];
const seen = new Set<string>();
for (const frame of data) {
const label = frame.refId || frame.name || '';
for (const field of frame.fields) {
if (field.type === 'time') {
continue;
}
// Use "refId:fieldName" as a unique key to handle multiple series
// with identically-named fields.
const key = label ? `${label}:${field.name}` : field.name;
if (seen.has(key)) {
continue;
}
seen.add(key);
const display = label ? `${field.name} (${label})` : field.name;
opts.push({ label: display, value: key });
}
}
return opts;
}
export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions( export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions(
(builder, context) => { (builder) => {
const fieldOptions = buildFieldOptions(context.data ?? []);
const fieldOptionsNoAuto = fieldOptions.filter((o) => o.value !== '');
// -- Field Mapping category (top) -- // -- Field Mapping category (top) --
builder.addSelect({ builder.addFieldNamePicker({
path: 'chargeField', path: 'chargeField',
name: 'Charge field', name: 'Charge field',
defaultValue: '',
category: ['Field Mapping'], category: ['Field Mapping'],
description: 'Numeric field for battery charge (0–100%).', description: 'Numeric field that represents battery charge (0–100%). If blank, the first number field is used.',
settings: { settings: {
allowCustomValue: true, filter: (f) => f.type === 'number',
isClearable: true, noFieldsMessage: 'No numeric fields found',
options: fieldOptions,
}, },
}); });
@@ -62,60 +24,33 @@ export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanel
description: 'Show a second metric representing current load / power draw.', description: 'Show a second metric representing current load / power draw.',
}); });
builder.addSelect({ builder.addFieldNamePicker({
path: 'loadField', path: 'loadField',
name: 'Load field', name: 'Load field',
defaultValue: '',
category: ['Field Mapping'], category: ['Field Mapping'],
description: 'Numeric field for load percentage (0–100%).', description: 'Numeric field for load percentage (0–100%).',
settings: { settings: {
allowCustomValue: true, filter: (f) => f.type === 'number',
isClearable: true, noFieldsMessage: 'No numeric fields found',
options: fieldOptionsNoAuto,
}, },
showIf: (opts) => opts.enableLoad === true, showIf: (opts) => opts.enableLoad === true,
}); });
builder.addBooleanSwitch({
path: 'enableTimeLeft',
name: 'Enable time remaining',
defaultValue: false,
category: ['Field Mapping'],
description: 'Show estimated time remaining from a data field (value in minutes).',
});
builder.addSelect({
path: 'timeLeftField',
name: 'Time remaining field',
defaultValue: '',
category: ['Field Mapping'],
description: 'Numeric field for estimated time remaining (in minutes).',
settings: {
allowCustomValue: true,
isClearable: true,
options: fieldOptionsNoAuto,
},
showIf: (opts) => opts.enableTimeLeft === true,
});
builder.addBooleanSwitch({ builder.addBooleanSwitch({
path: 'enableStateField', path: 'enableStateField',
name: 'Enable state field', name: 'Enable state field',
defaultValue: false, defaultValue: false,
category: ['Field Mapping'], category: ['Field Mapping'],
description: 'Use a data field to determine charging / discharging state instead of inferring from the trend.', description: 'Use a field from the data to determine charging / discharging state instead of computing it from the rate.',
}); });
builder.addSelect({ builder.addFieldNamePicker({
path: 'stateField', path: 'stateField',
name: 'State field', name: 'State field',
defaultValue: '',
category: ['Field Mapping'], category: ['Field Mapping'],
description: 'Field whose value indicates battery state.', description: 'String or numeric field whose value indicates the battery state.',
settings: { settings: {
allowCustomValue: true, noFieldsMessage: 'No fields found',
isClearable: true,
options: fieldOptionsNoAuto,
}, },
showIf: (opts) => opts.enableStateField === true, showIf: (opts) => opts.enableStateField === true,
}); });
@@ -202,7 +137,7 @@ export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanel
{ value: 'horizontal', label: 'Horizontal' }, { value: 'horizontal', label: 'Horizontal' },
], ],
}, },
description: 'Preferred orientation. Auto-reverts to vertical if the panel is too narrow for horizontal.', description: 'Battery orientation. Horizontal works well in wide, short panels.',
}); });
builder.addBooleanSwitch({ builder.addBooleanSwitch({
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$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": "UPS Visualisation", "name": "Battery Panel",
"id": "andydixon-chargevizbattery-panel", "id": "andydixon-chargevizbattery-panel",
"info": { "info": {
"keywords": ["panel"], "keywords": ["panel"],
-2
View File
@@ -5,8 +5,6 @@ export interface ChargeVizOptions {
chargeField: string; chargeField: string;
enableLoad: boolean; enableLoad: boolean;
loadField: string; loadField: string;
enableTimeLeft: boolean;
timeLeftField: string;
enableStateField: boolean; enableStateField: boolean;
stateField: string; stateField: string;
stateChargingValue: string; stateChargingValue: string;