Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb411e6a4d | ||
|
|
947d9075b0 |
@@ -1,5 +1,5 @@
|
|||||||
import React, { useId, useMemo } from 'react';
|
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 { 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,19 +76,99 @@ function inferStateFromTrend(
|
|||||||
return 'stable';
|
return 'stable';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find a field by configured name, falling back to first field of given type. */
|
/** Format minutes into a human-readable duration string. */
|
||||||
function resolveField(
|
function formatMinutes(mins: number): string {
|
||||||
fields: Field[],
|
if (!Number.isFinite(mins) || mins < 0) {
|
||||||
configuredName: string | undefined,
|
return '—';
|
||||||
fallbackType: string
|
}
|
||||||
): Field | undefined {
|
if (mins < 1) {
|
||||||
if (configuredName) {
|
return '< 1 min';
|
||||||
const match = fields.find((f) => f.name === configuredName);
|
}
|
||||||
if (match) {
|
const h = Math.floor(mins / 60);
|
||||||
return match;
|
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 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;
|
||||||
@@ -345,6 +424,7 @@ 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();
|
||||||
@@ -358,18 +438,20 @@ 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';
|
||||||
|
|
||||||
if (data.series.length > 0) {
|
const allSeries = data.series;
|
||||||
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 (chargeField && timeField) {
|
if (allSeries.length > 0) {
|
||||||
const rawValues = Array.from(chargeField.values);
|
// Resolve the charge field: user selection → first numeric field
|
||||||
const rawTimes = Array.from(timeField.values);
|
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 values: number[] = [];
|
||||||
const times: 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);
|
inferredState = inferStateFromTrend(values);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load field
|
// Load field — search across all series
|
||||||
if (enableLoad && options.loadField) {
|
if (enableLoad && options.loadField) {
|
||||||
const lf = fields.find((f) => f.name === options.loadField);
|
const loadResolved = resolveFieldByKey(allSeries, options.loadField);
|
||||||
if (lf) {
|
if (loadResolved) {
|
||||||
const arr = Array.from(lf.values);
|
const arr = Array.from(loadResolved.field.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;
|
||||||
@@ -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) {
|
if (enableStateField && options.stateField) {
|
||||||
const sf = fields.find((f) => f.name === options.stateField);
|
const stateResolved = resolveFieldByKey(allSeries, options.stateField);
|
||||||
if (sf) {
|
if (stateResolved) {
|
||||||
const arr = Array.from(sf.values);
|
const arr = Array.from(stateResolved.field.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();
|
||||||
@@ -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,
|
data.series, useRegression, enableLoad, enableTimeLeft, enableStateField,
|
||||||
options.chargeField, options.loadField, options.stateField,
|
options.chargeField, options.loadField, options.timeLeftField, options.stateField,
|
||||||
stateChargingValue, stateDischargingValue,
|
stateChargingValue, stateDischargingValue,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { currentValue, rate, low, high, loadValue, stateFromField, inferredState } = extracted;
|
const { currentValue, rate, low, high, loadValue, timeLeftValue, stateFromField, inferredState } = extracted;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Derived visual values
|
// Derived visual values
|
||||||
@@ -474,21 +567,31 @@ 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 hasExtras = (enableLoad && loadValue !== undefined);
|
const hasLoadExtra = 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 = orientation === 'vertical'
|
const useColumnLayout = effectiveOrientation === 'vertical'
|
||||||
? width < 240
|
? width < 240
|
||||||
: height < 200;
|
: height < 200;
|
||||||
|
|
||||||
const statAreaSize = useColumnLayout
|
const statAreaSize = useColumnLayout
|
||||||
? (orientation === 'vertical' ? height * 0.3 : width * 0.3)
|
? (effectiveOrientation === 'vertical' ? height * 0.3 : width * 0.3)
|
||||||
: (orientation === 'vertical' ? width * 0.5 : height * 0.5);
|
: (effectiveOrientation === '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
|
||||||
@@ -626,7 +729,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}>
|
||||||
{orientation === 'vertical'
|
{effectiveOrientation === 'vertical'
|
||||||
? <VerticalBattery {...batteryProps} />
|
? <VerticalBattery {...batteryProps} />
|
||||||
: <HorizontalBattery {...batteryProps} />
|
: <HorizontalBattery {...batteryProps} />
|
||||||
}
|
}
|
||||||
@@ -635,7 +738,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 */}
|
||||||
{hasExtras && !isCompact && (
|
{hasLoadExtra && !isCompact && (
|
||||||
<div className={loadCardStyle}>
|
<div className={loadCardStyle}>
|
||||||
<LoadGauge
|
<LoadGauge
|
||||||
value={loadClamped}
|
value={loadClamped}
|
||||||
@@ -648,7 +751,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 */}
|
||||||
{hasExtras && isCompact && (
|
{hasLoadExtra && isCompact && (
|
||||||
<div className={css`
|
<div className={css`
|
||||||
font-size: ${baseFontSize}px;
|
font-size: ${baseFontSize}px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -658,6 +761,31 @@ 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
@@ -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
@@ -1,18 +1,56 @@
|
|||||||
import { PanelPlugin } from '@grafana/data';
|
import { PanelPlugin, SelectableValue } 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) => {
|
(builder, context) => {
|
||||||
|
const fieldOptions = buildFieldOptions(context.data ?? []);
|
||||||
|
const fieldOptionsNoAuto = fieldOptions.filter((o) => o.value !== '');
|
||||||
|
|
||||||
// -- Field Mapping category (top) --
|
// -- Field Mapping category (top) --
|
||||||
builder.addFieldNamePicker({
|
builder.addSelect({
|
||||||
path: 'chargeField',
|
path: 'chargeField',
|
||||||
name: 'Charge field',
|
name: 'Charge field',
|
||||||
|
defaultValue: '',
|
||||||
category: ['Field Mapping'],
|
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: {
|
settings: {
|
||||||
filter: (f) => f.type === 'number',
|
allowCustomValue: true,
|
||||||
noFieldsMessage: 'No numeric fields found',
|
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.',
|
description: 'Show a second metric representing current load / power draw.',
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.addFieldNamePicker({
|
builder.addSelect({
|
||||||
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: {
|
||||||
filter: (f) => f.type === 'number',
|
allowCustomValue: true,
|
||||||
noFieldsMessage: 'No numeric fields found',
|
isClearable: true,
|
||||||
|
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 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',
|
path: 'stateField',
|
||||||
name: 'State field',
|
name: 'State field',
|
||||||
|
defaultValue: '',
|
||||||
category: ['Field Mapping'],
|
category: ['Field Mapping'],
|
||||||
description: 'String or numeric field whose value indicates the battery state.',
|
description: 'Field whose value indicates battery state.',
|
||||||
settings: {
|
settings: {
|
||||||
noFieldsMessage: 'No fields found',
|
allowCustomValue: true,
|
||||||
|
isClearable: true,
|
||||||
|
options: fieldOptionsNoAuto,
|
||||||
},
|
},
|
||||||
showIf: (opts) => opts.enableStateField === true,
|
showIf: (opts) => opts.enableStateField === true,
|
||||||
});
|
});
|
||||||
@@ -137,7 +202,7 @@ export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanel
|
|||||||
{ value: 'horizontal', label: 'Horizontal' },
|
{ 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({
|
builder.addBooleanSwitch({
|
||||||
|
|||||||
+1
-1
@@ -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": "Battery Panel",
|
"name": "UPS Visualisation",
|
||||||
"id": "andydixon-chargevizbattery-panel",
|
"id": "andydixon-chargevizbattery-panel",
|
||||||
"info": {
|
"info": {
|
||||||
"keywords": ["panel"],
|
"keywords": ["panel"],
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user