From fb411e6a4dbb7842ef5f09fbdfc1e4e64a0482a1 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Fri, 24 Apr 2026 13:42:45 +0100 Subject: [PATCH] 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> --- src/components/ChargeVizPanel.tsx | 130 ++++++++++++++++++++++-------- src/module.ts | 84 +++++++++++++++---- 2 files changed, 167 insertions(+), 47 deletions(-) diff --git a/src/components/ChargeVizPanel.tsx b/src/components/ChargeVizPanel.tsx index 4631f00..1c6e21c 100644 --- a/src/components/ChargeVizPanel.tsx +++ b/src/components/ChargeVizPanel.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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(); diff --git a/src/module.ts b/src/module.ts index 2dfd88b..8a84195 100644 --- a/src/module.ts +++ b/src/module.ts @@ -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> { + const opts: Array> = [ + { label: '— Auto (first number field)', value: '' }, + ]; + const seen = new Set(); + + 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(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(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(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(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, });