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>
This commit is contained in:
@@ -76,6 +76,22 @@ function inferStateFromTrend(
|
|||||||
return 'stable';
|
return 'stable';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Find a field by configured name, falling back to first field of given type. */
|
/** Find a field by configured name, falling back to first field of given type. */
|
||||||
function resolveField(
|
function resolveField(
|
||||||
fields: Field[],
|
fields: Field[],
|
||||||
@@ -335,7 +351,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 +360,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,6 +374,7 @@ 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';
|
||||||
|
|
||||||
@@ -414,6 +431,18 @@ export const ChargeVizPanel: React.FC<Props> = ({ data, width, height, options,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Time remaining field
|
||||||
|
if (enableTimeLeft && options.timeLeftField) {
|
||||||
|
const tf = fields.find((f) => f.name === options.timeLeftField);
|
||||||
|
if (tf) {
|
||||||
|
const arr = Array.from(tf.values);
|
||||||
|
const last = toFinite(arr[arr.length - 1]);
|
||||||
|
if (last !== undefined) {
|
||||||
|
timeLeftValue = last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// State field
|
// State field
|
||||||
if (enableStateField && options.stateField) {
|
if (enableStateField && options.stateField) {
|
||||||
const sf = fields.find((f) => f.name === options.stateField);
|
const sf = fields.find((f) => f.name === options.stateField);
|
||||||
@@ -434,14 +463,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 +503,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 +665,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 +674,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 +687,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 +697,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 |
+31
-22
@@ -5,15 +5,12 @@ import { ChargeVizOptions } from './types';
|
|||||||
export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions(
|
export const plugin = new PanelPlugin<ChargeVizOptions>(ChargeVizPanel).setPanelOptions(
|
||||||
(builder) => {
|
(builder) => {
|
||||||
// -- Field Mapping category (top) --
|
// -- Field Mapping category (top) --
|
||||||
builder.addFieldNamePicker({
|
builder.addTextInput({
|
||||||
path: 'chargeField',
|
path: 'chargeField',
|
||||||
name: 'Charge field',
|
name: 'Charge field name',
|
||||||
|
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: 'Name of the numeric field for battery charge (0–100%). Leave blank to use the first number field.',
|
||||||
settings: {
|
|
||||||
filter: (f) => f.type === 'number',
|
|
||||||
noFieldsMessage: 'No numeric fields found',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.addBooleanSwitch({
|
builder.addBooleanSwitch({
|
||||||
@@ -24,34 +21,46 @@ 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.addTextInput({
|
||||||
path: 'loadField',
|
path: 'loadField',
|
||||||
name: 'Load field',
|
name: 'Load field name',
|
||||||
|
defaultValue: '',
|
||||||
category: ['Field Mapping'],
|
category: ['Field Mapping'],
|
||||||
description: 'Numeric field for load percentage (0–100%).',
|
description: 'Name of the numeric field for load percentage (0–100%).',
|
||||||
settings: {
|
|
||||||
filter: (f) => f.type === 'number',
|
|
||||||
noFieldsMessage: 'No numeric fields found',
|
|
||||||
},
|
|
||||||
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.addTextInput({
|
||||||
|
path: 'timeLeftField',
|
||||||
|
name: 'Time remaining field name',
|
||||||
|
defaultValue: '',
|
||||||
|
category: ['Field Mapping'],
|
||||||
|
description: 'Name of the numeric field for estimated time remaining (in minutes).',
|
||||||
|
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.addTextInput({
|
||||||
path: 'stateField',
|
path: 'stateField',
|
||||||
name: 'State field',
|
name: 'State field name',
|
||||||
|
defaultValue: '',
|
||||||
category: ['Field Mapping'],
|
category: ['Field Mapping'],
|
||||||
description: 'String or numeric field whose value indicates the battery state.',
|
description: 'Name of the field whose value indicates battery state.',
|
||||||
settings: {
|
|
||||||
noFieldsMessage: 'No fields found',
|
|
||||||
},
|
|
||||||
showIf: (opts) => opts.enableStateField === true,
|
showIf: (opts) => opts.enableStateField === true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,7 +146,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