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>
This commit is contained in:
Andy Dixon
2026-04-24 13:42:45 +01:00
co-authored by Copilot
parent 947d9075b0
commit fb411e6a4d
2 changed files with 167 additions and 47 deletions
+97 -33
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';
@@ -92,19 +92,83 @@ function formatMinutes(mins: number): string {
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
/** 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;
/**
* 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;
}
// ---------------------------------------------------------------------------
@@ -378,15 +442,16 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
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[] = [];
@@ -414,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;
@@ -431,11 +495,11 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
}
}
// Time remaining field
// Time remaining field — search across all series
if (enableTimeLeft && options.timeLeftField) {
const tf = fields.find((f) => f.name === options.timeLeftField);
if (tf) {
const arr = Array.from(tf.values);
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;
@@ -443,11 +507,11 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
}
}
// State field
// 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();
+70 -14
View File
@@ -1,16 +1,57 @@
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.addTextInput({
builder.addSelect({
path: 'chargeField',
name: 'Charge field name',
name: 'Charge field',
defaultValue: '',
category: ['Field Mapping'],
description: 'Name of the numeric field for battery charge (0–100%). Leave blank to use the first number field.',
description: 'Numeric field for battery charge (0–100%).',
settings: {
allowCustomValue: true,
isClearable: true,
options: fieldOptions,
},
});
builder.addBooleanSwitch({
@@ -21,12 +62,17 @@ export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanel
description: 'Show a second metric representing current load / power draw.',
});
builder.addTextInput({
builder.addSelect({
path: 'loadField',
name: 'Load field name',
name: 'Load field',
defaultValue: '',
category: ['Field Mapping'],
description: 'Name of the numeric field for load percentage (0–100%).',
description: 'Numeric field for load percentage (0–100%).',
settings: {
allowCustomValue: true,
isClearable: true,
options: fieldOptionsNoAuto,
},
showIf: (opts) => opts.enableLoad === true,
});
@@ -38,12 +84,17 @@ export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanel
description: 'Show estimated time remaining from a data field (value in minutes).',
});
builder.addTextInput({
builder.addSelect({
path: 'timeLeftField',
name: 'Time remaining field name',
name: 'Time remaining field',
defaultValue: '',
category: ['Field Mapping'],
description: 'Name of the numeric field for estimated time remaining (in minutes).',
description: 'Numeric field for estimated time remaining (in minutes).',
settings: {
allowCustomValue: true,
isClearable: true,
options: fieldOptionsNoAuto,
},
showIf: (opts) => opts.enableTimeLeft === true,
});
@@ -55,12 +106,17 @@ export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanel
description: 'Use a data field to determine charging / discharging state instead of inferring from the trend.',
});
builder.addTextInput({
builder.addSelect({
path: 'stateField',
name: 'State field name',
name: 'State field',
defaultValue: '',
category: ['Field Mapping'],
description: 'Name of the field whose value indicates battery state.',
description: 'Field whose value indicates battery state.',
settings: {
allowCustomValue: true,
isClearable: true,
options: fieldOptionsNoAuto,
},
showIf: (opts) => opts.enableStateField === true,
});