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, });