Compare commits

...
Author SHA1 Message Date
dependabot[bot] 626a50ecd0 Bump fast-uri from 3.1.0 to 3.1.2
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-09 03:49:27 +00:00
Andy DixonandCopilot fb411e6a4d Replace text inputs with dynamic dropdowns, fix cross-series field resolution
Field selectors are now dropdowns populated from the panel's actual
data frames. Each option shows 'fieldName (refId)' so users can
distinguish fields from different queries (A, B, C, etc.).

The selected value uses a 'refId:fieldName' composite key so the
panel can unambiguously resolve the correct field even when multiple
queries return fields with the same name.

Data extraction now searches across ALL series (not just series[0]),
fixing the core bug where selecting a field from query B/C had no
effect because the code only looked at the first data frame.

Field resolution priority:
1. Exact 'refId:fieldName' match (from dropdown selection)
2. Fuzzy match by field name, display name, frame name, or refId
3. For charge field only: fallback to first numeric field ('Auto')

Dropdowns also support allowCustomValue for manual entry and
isClearable to reset.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 13:42:45 +01:00
Andy DixonandCopilot 947d9075b0 Add time remaining field, fix field pickers, auto-revert orientation, battery icon
Field pickers:
- Replaced addFieldNamePicker with addTextInput for all field
  selectors (charge, load, state, time remaining). The FieldNamePicker
  component was silently failing to render in some Grafana versions.
  Text inputs are universally supported and let users type the exact
  field name from their data source.

Time remaining:
- New 'Enable time remaining' toggle + field name in Field Mapping
- Reads the last value from the configured field (expects minutes)
- Displays as a styled card with clock icon and human-readable
  duration (e.g. '2h 15m', '45 min', '< 1 min')

Auto-revert horizontal orientation:
- When orientation is set to horizontal but the panel width is < 300px,
  automatically switches to vertical so the battery + side content
  still fit. The preference is stored, so it reverts back when the
  panel is resized larger.

Icon:
- Replaced generic Grafana scaffold logo with a battery SVG icon
  that shows in the visualisation picker and panel header.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-24 13:32:53 +01:00
6 changed files with 256 additions and 61 deletions
+3 -3
View File
@@ -7954,9 +7954,9 @@
"integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"dev": true,
"funding": [
{
+170 -42
View File
@@ -1,5 +1,5 @@
import React, { useId, useMemo } from 'react';
import { PanelProps, Field } from '@grafana/data';
import { PanelProps, Field, DataFrame } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { css, keyframes } from '@emotion/css';
import { ChargeVizOptions } from '../types';
@@ -76,19 +76,99 @@ function inferStateFromTrend(
return 'stable';
}
/** Find a field by configured name, falling back to first field of given type. */
function resolveField(
fields: Field[],
configuredName: string | undefined,
fallbackType: string
): Field | undefined {
if (configuredName) {
const match = fields.find((f) => f.name === configuredName);
if (match) {
return match;
/** Format minutes into a human-readable duration string. */
function formatMinutes(mins: number): string {
if (!Number.isFinite(mins) || mins < 0) {
return '—';
}
if (mins < 1) {
return '< 1 min';
}
const h = Math.floor(mins / 60);
const m = Math.round(mins % 60);
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;
}
// ---------------------------------------------------------------------------
@@ -335,7 +415,6 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
const lowThreshold = options.lowThreshold ?? 30;
const highThreshold = options.highThreshold ?? 70;
const gradientFill = options.gradientFill ?? true;
const orientation = options.orientation ?? 'vertical';
const chargingAnimation = options.chargingAnimation ?? true;
const borderWidth = options.borderWidth ?? 3;
const dp = Number.isFinite(options.decimalPlaces) ? options.decimalPlaces : 2;
@@ -345,6 +424,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
const showLowHigh = options.showLowHigh ?? true;
const showStatus = options.showStatus ?? true;
const enableLoad = options.enableLoad ?? false;
const enableTimeLeft = options.enableTimeLeft ?? false;
const enableStateField = options.enableStateField ?? false;
const stateChargingValue = (options.stateChargingValue ?? 'charging').toLowerCase();
const stateDischargingValue = (options.stateDischargingValue ?? 'discharging').toLowerCase();
@@ -358,18 +438,20 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
let lo = 0;
let hi = 0;
let loadValue: number | undefined;
let timeLeftValue: number | undefined;
let stateFromField: 'charging' | 'discharging' | 'stable' | undefined;
let inferredState: 'charging' | 'discharging' | 'stable' = 'stable';
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');
const allSeries = data.series;
if (chargeField && timeField) {
const rawValues = Array.from(chargeField.values);
const rawTimes = Array.from(timeField.values);
if (allSeries.length > 0) {
// Resolve the charge field: user selection → first numeric field
const chargeResolved = resolveFieldByKey(allSeries, options.chargeField)
?? getFirstNumericField(allSeries);
if (chargeResolved) {
const rawValues = Array.from(chargeResolved.field.values);
const rawTimes = Array.from(chargeResolved.timeField.values);
const values: number[] = [];
const times: number[] = [];
@@ -397,16 +479,15 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
}
}
// Infer state from recent trend (last ≤ 50 points)
inferredState = inferStateFromTrend(values);
}
}
// Load field
// Load field — search across all series
if (enableLoad && options.loadField) {
const lf = fields.find((f) => f.name === options.loadField);
if (lf) {
const arr = Array.from(lf.values);
const loadResolved = resolveFieldByKey(allSeries, options.loadField);
if (loadResolved) {
const arr = Array.from(loadResolved.field.values);
const last = toFinite(arr[arr.length - 1]);
if (last !== undefined) {
loadValue = last;
@@ -414,11 +495,23 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
}
}
// State field
// Time remaining field — search across all series
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) {
const sf = fields.find((f) => f.name === options.stateField);
if (sf) {
const arr = Array.from(sf.values);
const stateResolved = resolveFieldByKey(allSeries, options.stateField);
if (stateResolved) {
const arr = Array.from(stateResolved.field.values);
const lastRaw = arr[arr.length - 1];
if (lastRaw !== undefined && lastRaw !== null) {
const v = String(lastRaw).toLowerCase().trim();
@@ -434,14 +527,14 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
}
}
return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, stateFromField, inferredState };
return { currentValue: cur, rate: r, low: lo, high: hi, loadValue, timeLeftValue, stateFromField, inferredState };
}, [
data.series, useRegression, enableLoad, enableStateField,
options.chargeField, options.loadField, options.stateField,
data.series, useRegression, enableLoad, enableTimeLeft, enableStateField,
options.chargeField, options.loadField, options.timeLeftField, options.stateField,
stateChargingValue, stateDischargingValue,
]);
const { currentValue, rate, low, high, loadValue, stateFromField, inferredState } = extracted;
const { currentValue, rate, low, high, loadValue, timeLeftValue, stateFromField, inferredState } = extracted;
// -----------------------------------------------------------------------
// Derived visual values
@@ -474,21 +567,31 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
const animating = chargingAnimation && isCharging;
const showAnyStats = showRate || showLowHigh || showStatus;
const hasExtras = (enableLoad && loadValue !== undefined);
const hasLoadExtra = enableLoad && loadValue !== undefined;
const hasTimeLeftExtra = enableTimeLeft && timeLeftValue !== undefined;
const hasExtras = hasLoadExtra || hasTimeLeftExtra;
// -----------------------------------------------------------------------
// 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 useColumnLayout = orientation === 'vertical'
const useColumnLayout = effectiveOrientation === 'vertical'
? width < 240
: height < 200;
const statAreaSize = useColumnLayout
? (orientation === 'vertical' ? height * 0.3 : width * 0.3)
: (orientation === 'vertical' ? width * 0.5 : height * 0.5);
? (effectiveOrientation === 'vertical' ? height * 0.3 : width * 0.3)
: (effectiveOrientation === 'vertical' ? width * 0.5 : height * 0.5);
const baseFontSize = Math.max(10, Math.min(16, statAreaSize / 8));
// Gauge size scales with available panel space
@@ -626,7 +729,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
return (
<div className={containerStyle} data-testid="charge-viz-panel">
<div className={batteryWrapStyle}>
{orientation === 'vertical'
{effectiveOrientation === 'vertical'
? <VerticalBattery {...batteryProps} />
: <HorizontalBattery {...batteryProps} />
}
@@ -635,7 +738,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
{(showAnyStats || hasExtras) && !isTiny && (
<div className={sidePanelStyle}>
{/* Load gauge card */}
{hasExtras && !isCompact && (
{hasLoadExtra && !isCompact && (
<div className={loadCardStyle}>
<LoadGauge
value={loadClamped}
@@ -648,7 +751,7 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
)}
{/* Compact load fallback when gauge doesn't fit */}
{hasExtras && isCompact && (
{hasLoadExtra && isCompact && (
<div className={css`
font-size: ${baseFontSize}px;
white-space: nowrap;
@@ -658,6 +761,31 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
</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 */}
{showStatus && !isCompact && (
<div className={statusBadgeStyle}>
+1 -1
View File
@@ -1 +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>
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 435 B

+79 -14
View File
@@ -1,18 +1,56 @@
import { PanelPlugin } from '@grafana/data';
import { PanelPlugin, SelectableValue } from '@grafana/data';
import { ChargeVizPanel } from './components/ChargeVizPanel';
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(
(builder) => {
(builder, context) => {
const fieldOptions = buildFieldOptions(context.data ?? []);
const fieldOptionsNoAuto = fieldOptions.filter((o) => o.value !== '');
// -- Field Mapping category (top) --
builder.addFieldNamePicker({
builder.addSelect({
path: 'chargeField',
name: 'Charge field',
defaultValue: '',
category: ['Field Mapping'],
description: 'Numeric field that represents battery charge (0–100%). If blank, the first number field is used.',
description: 'Numeric field for battery charge (0–100%).',
settings: {
filter: (f) => f.type === 'number',
noFieldsMessage: 'No numeric fields found',
allowCustomValue: true,
isClearable: true,
options: fieldOptions,
},
});
@@ -24,33 +62,60 @@ export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanel
description: 'Show a second metric representing current load / power draw.',
});
builder.addFieldNamePicker({
builder.addSelect({
path: 'loadField',
name: 'Load field',
defaultValue: '',
category: ['Field Mapping'],
description: 'Numeric field for load percentage (0–100%).',
settings: {
filter: (f) => f.type === 'number',
noFieldsMessage: 'No numeric fields found',
allowCustomValue: true,
isClearable: true,
options: fieldOptionsNoAuto,
},
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({
path: 'enableStateField',
name: 'Enable state field',
defaultValue: false,
category: ['Field Mapping'],
description: 'Use a field from the data to determine charging / discharging state instead of computing it from the rate.',
description: 'Use a data field to determine charging / discharging state instead of inferring from the trend.',
});
builder.addFieldNamePicker({
builder.addSelect({
path: 'stateField',
name: 'State field',
defaultValue: '',
category: ['Field Mapping'],
description: 'String or numeric field whose value indicates the battery state.',
description: 'Field whose value indicates battery state.',
settings: {
noFieldsMessage: 'No fields found',
allowCustomValue: true,
isClearable: true,
options: fieldOptionsNoAuto,
},
showIf: (opts) => opts.enableStateField === true,
});
@@ -137,7 +202,7 @@ export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanel
{ value: 'horizontal', label: 'Horizontal' },
],
},
description: 'Battery orientation. Horizontal works well in wide, short panels.',
description: 'Preferred orientation. Auto-reverts to vertical if the panel is too narrow for horizontal.',
});
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",
"type": "panel",
"name": "Battery Panel",
"name": "UPS Visualisation",
"id": "andydixon-chargevizbattery-panel",
"info": {
"keywords": ["panel"],
+2
View File
@@ -5,6 +5,8 @@ export interface ChargeVizOptions {
chargeField: string;
enableLoad: boolean;
loadField: string;
enableTimeLeft: boolean;
timeLeftField: string;
enableStateField: boolean;
stateField: string;
stateChargingValue: string;