NewGauge: Adds new feature toggle named newGauge (#112593)

* NewGauge: Feature toggle and fixes

* Gauge: New migration test dashboard

* Update

* Updates

* Tweaked default barWidth

* Fix multi data links

* remove sizing options

* merge fix

* Update

* Restore

* Update

* Tweaked name font size logic

* use file snapshots instead of inline

---------

Co-authored-by: Paul Marbach <paul.marbach@grafana.com>
This commit is contained in:
Torkel Ödegaard 2025-10-20 18:33:19 +02:00 committed by GitHub
parent 17771e0e1d
commit 7df537c9fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 3157 additions and 573 deletions

File diff suppressed because it is too large Load Diff

View File

@ -38,6 +38,7 @@
"gauge-multi-series": (import '../dev-dashboards/panel-gauge/gauge-multi-series.json'),
"gauge_tests": (import '../dev-dashboards/panel-gauge/gauge_tests.json'),
"gauge_tests_new": (import '../dev-dashboards/panel-gauge/gauge_tests_new.json'),
"gauge_tests_old_to_new": (import '../dev-dashboards/panel-gauge/gauge_tests_old_to_new.json'),
"geomap-color-field": (import '../dev-dashboards/panel-geomap/geomap-color-field.json'),
"geomap-photo-layer": (import '../dev-dashboards/panel-geomap/geomap-photo-layer.json'),
"geomap-route-layer": (import '../dev-dashboards/panel-geomap/geomap-route-layer.json'),

View File

@ -1218,6 +1218,11 @@ export interface FeatureToggles {
*/
cdnPluginsUrls?: boolean;
/**
* Enable new gauge visualization
* @default false
*/
newGauge?: boolean;
/**
* Restrict PanelChrome contents with overflow: hidden;
* @default true
*/

View File

@ -21,7 +21,7 @@ export interface GaugePanelEffects {
export const defaultGaugePanelEffects: Partial<GaugePanelEffects> = {
barGlow: false,
centerGlow: true,
centerGlow: false,
rounded: false,
spotlight: false,
};
@ -39,13 +39,13 @@ export interface Options extends common.SingleStatBaseOptions {
}
export const defaultOptions: Partial<Options> = {
barWidthFactor: 0.4,
barWidthFactor: 0.5,
effects: {},
gradient: 'none',
gradient: 'auto',
segmentCount: 1,
segmentSpacing: 0.3,
shape: 'gauge',
showThresholdLabels: false,
showThresholdMarkers: true,
sparkline: false,
sparkline: true,
};

View File

@ -73,11 +73,12 @@ export class RadialColorDefs {
this.defs.push(
<radialGradient
key={id}
id={id}
cx={dimensions.centerX}
cy={dimensions.centerY}
r={dimensions.radius + dimensions.barWidth / 2}
fr={dimensions.radius - dimensions.barWidth / 2}
id={id}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor={tinycolor(baseColor).spin(20).lighten(10).toString()} stopOpacity={1} />
@ -85,6 +86,8 @@ export class RadialColorDefs {
<stop offset="100%" stopColor={color1.toString()} stopOpacity={1} />
</radialGradient>
);
return returnColor;
}
// For fixed / palette based color scales we can create a more fun
@ -103,11 +106,12 @@ export class RadialColorDefs {
this.defs.push(
<linearGradient
key={id}
id={id}
x1="0"
y1="0"
x2={x2}
y2={y2}
id={id}
gradientUnits="userSpaceOnUse"
gradientTransform={transform}
>

View File

@ -1,4 +1,4 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { isNumber } from 'lodash';
import { useId } from 'react';
@ -64,6 +64,8 @@ export interface RadialGaugeProps {
/** Specify which text should be visible */
textMode?: RadialTextMode;
showScaleLabels?: boolean;
/** For data links */
onClick?: React.MouseEventHandler<HTMLElement>;
}
export type RadialGradientMode = 'none' | 'auto';
@ -87,6 +89,7 @@ export function RadialGauge(props: RadialGaugeProps) {
roundedBars = true,
thresholdsBar = false,
showScaleLabels = false,
onClick,
values,
} = props;
const theme = useTheme2();
@ -247,13 +250,27 @@ export function RadialGauge(props: RadialGaugeProps) {
}
}
return (
<div className={styles.vizWrapper} style={{ width, height }}>
const body = (
<>
<svg width={width} height={height} role="img" aria-label={t('gauge.category-gauge', 'Gauge')}>
<defs>{defs}</defs>
{graphics}
</svg>
{sparklineElement}
</>
);
if (onClick) {
return (
<button onClick={onClick} className={cx(styles.clearButton, styles.vizWrapper)} style={{ width, height }}>
{body}
</button>
);
}
return (
<div className={styles.vizWrapper} style={{ width, height }}>
{body}
</div>
);
}
@ -281,5 +298,12 @@ function getStyles(theme: GrafanaTheme2) {
filter: theme.isLight ? `drop-shadow(0px 0px 1px #888);` : '',
},
}),
clearButton: css({
background: 'transparent',
color: theme.colors.text.primary,
border: 'none',
padding: 0,
cursor: 'context-menu',
}),
};
}

View File

@ -25,7 +25,7 @@ export function RadialSparkline({ sparkline, dimensions, theme, color, shape }:
const height = radius / 4;
const widthFactor = shape === 'gauge' ? 1.6 : 1.4;
const width = radius * widthFactor - barWidth;
const topPos = shape === 'gauge' ? `calc(50% + ${radius / 1.75}px)` : `calc(50% + ${radius / 2.8}px)`;
const topPos = shape === 'gauge' ? `${dimensions.gaugeBottomY - height}px` : `calc(50% + ${radius / 2.8}px)`;
const styles = css({
position: 'absolute',

View File

@ -59,9 +59,9 @@ export function RadialText({
// Not sure where this comes from but svg text is not using body line-height
const lineHeight = 1.21;
const valueWidthToRadiusFactor = 0.6;
const nameToHeightFactor = 0.3;
const largeRadiusScalingDecay = 0.92;
const valueWidthToRadiusFactor = 0.85;
const nameToHeightFactor = 0.45;
const largeRadiusScalingDecay = 0.86;
// This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels
let maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay);
@ -99,7 +99,8 @@ export function RadialText({
const nameHeight = nameFontSize * lineHeight;
const valueY = showName ? centerY - nameHeight / 2 : centerY;
const nameY = showValue ? valueY + valueHeight * 0.7 : centerY;
const valueNameSpacing = valueHeight / 3.5;
const nameY = showValue ? valueY + valueHeight / 2 + valueNameSpacing : centerY;
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2;

View File

@ -40,8 +40,15 @@ export function ThresholdsBar({
for (let i = 1; i < thresholds.length; i++) {
const threshold = thresholds[i];
const valueDeg = ((threshold.value - min) / (max - min)) * angleRange;
const lengthDeg = valueDeg - currentStart + startAngle;
let valueDeg = ((threshold.value - min) / (max - min)) * angleRange;
if (valueDeg > angleRange) {
valueDeg = angleRange;
} else if (valueDeg < 0) {
valueDeg = 0;
}
let lengthDeg = valueDeg - currentStart + startAngle;
paths.push(
<RadialArcPath

View File

@ -178,5 +178,20 @@ describe('RadialGauge utils', () => {
expect(result.angle).toBe(360);
});
it('should handle values lower than min', () => {
const fieldDisplay = createFieldDisplay(-50, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 240, 120);
expect(result.angle).toBe(0);
});
it('should handle values higher than max', () => {
const fieldDisplay = createFieldDisplay(200, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 240, 120);
// Expect the angle to be clamped to the maximum range
expect(result.angle).toBe(240);
});
});
});

View File

@ -9,6 +9,8 @@ export function getValueAngleForValue(fieldDisplay: FieldDisplay, startAngle: nu
if (angle > angleRange) {
angle = angleRange;
} else if (angle < 0) {
angle = 0;
}
return { angleRange, angle };
@ -39,6 +41,7 @@ export interface GaugeDimensions {
scaleLabelsFontSize: number;
scaleLabelsSpacing: number;
scaleLabelsRadius: number;
gaugeBottomY: number;
}
export function calculateDimensions(
@ -119,8 +122,8 @@ export function calculateDimensions(
let innerRadius = outerRadius - barWidth / 2;
const maxY = maxRadius * Math.sin(toRad(yMaxAngle)) + maxRadius;
const rest = height - maxY - margin * 2;
const belowCenterY = maxRadius * Math.sin(toRad(yMaxAngle));
const rest = height - belowCenterY - margin * 2 - maxRadius;
const centerX = width / 2;
const centerY = maxRadius + margin + rest / 2;
@ -130,6 +133,7 @@ export function calculateDimensions(
return {
margin,
gaugeBottomY: centerY + belowCenterY,
radius: innerRadius,
centerX,
centerY,

View File

@ -2110,6 +2110,14 @@ var (
Owner: grafanaPluginsPlatformSquad,
Expression: "false",
},
{
Name: "newGauge",
Description: "Enable new gauge visualization",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaDatavizSquad,
Expression: "false",
},
{
Name: "preventPanelChromeOverflow",
Description: "Restrict PanelChrome contents with overflow: hidden;",

View File

@ -271,6 +271,7 @@ pluginContainers,privatePreview,@grafana/plugins-platform-backend,false,true,fal
tempoSearchBackendMigration,GA,@grafana/oss-big-tent,false,true,false
cdnPluginsLoadFirst,experimental,@grafana/plugins-platform-backend,false,false,false
cdnPluginsUrls,experimental,@grafana/plugins-platform-backend,false,false,false
newGauge,experimental,@grafana/dataviz-squad,false,false,true
preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true
pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false
onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
271 tempoSearchBackendMigration GA @grafana/oss-big-tent false true false
272 cdnPluginsLoadFirst experimental @grafana/plugins-platform-backend false false false
273 cdnPluginsUrls experimental @grafana/plugins-platform-backend false false false
274 newGauge experimental @grafana/dataviz-squad false false true
275 preventPanelChromeOverflow preview @grafana/grafana-frontend-platform false false true
276 pluginStoreServiceLoading experimental @grafana/plugins-platform-backend false false false
277 onlyStoreActionSets GA @grafana/identity-access-team false false false

View File

@ -1094,6 +1094,10 @@ const (
// Enable loading plugins via declarative URLs
FlagCdnPluginsUrls = "cdnPluginsUrls"
// FlagNewGauge
// Enable new gauge visualization
FlagNewGauge = "newGauge"
// FlagPreventPanelChromeOverflow
// Restrict PanelChrome contents with overflow: hidden;
FlagPreventPanelChromeOverflow = "preventPanelChromeOverflow"

View File

@ -2694,6 +2694,20 @@
"expression": "true"
}
},
{
"metadata": {
"name": "newGauge",
"resourceVersion": "1760700645318",
"creationTimestamp": "2025-10-17T11:30:45Z"
},
"spec": {
"description": "Enable new gauge visualization",
"stage": "experimental",
"codeowner": "@grafana/dataviz-squad",
"frontend": true,
"expression": "false"
}
},
{
"metadata": {
"name": "newInfluxDSConfigPageDesign",

View File

@ -1,3 +1,5 @@
import { config } from '@grafana/runtime';
const cloudwatchPlugin = async () =>
await import(/* webpackChunkName: "cloudwatchPlugin" */ 'app/plugins/datasource/cloudwatch/module');
const dashboardDSPlugin = async () =>
@ -104,7 +106,7 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul
'core:plugin/debug': debugPanel,
'core:plugin/flamegraph': flamegraphPanel,
'core:plugin/gettingstarted': gettingStartedPanel,
'core:plugin/gauge': gaugePanel,
'core:plugin/gauge': config.featureToggles.newGauge ? radialBar : gaugePanel,
'core:plugin/piechart': pieChartPanel,
'core:plugin/bargauge': barGaugePanel,
'core:plugin/barchart': barChartPanel,

File diff suppressed because it is too large Load Diff

View File

@ -88,56 +88,7 @@ describe('Gauge Panel Migrations', () => {
//@ts-ignore
expect(result.reduceOptions.overrides).toBeUndefined();
expect((panel as PanelModel).fieldConfig).toMatchInlineSnapshot(`
{
"defaults": {
"color": {
"mode": "thresholds",
},
"decimals": 3,
"mappings": [
{
"from": "50",
"id": 1,
"operator": "",
"text": "BIG",
"to": "1000",
"type": 2,
"value": "",
},
],
"max": "50",
"min": "-50",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"index": 0,
"value": -Infinity,
},
{
"color": "#EAB839",
"index": 1,
"value": -25,
},
{
"color": "#6ED0E0",
"index": 2,
"value": 0,
},
{
"color": "red",
"index": 3,
"value": 25,
},
],
},
"unit": "accMS2",
},
"overrides": [],
}
`);
expect((panel as PanelModel).fieldConfig).toMatchSnapshot();
});
it('change from angular singlestat to gauge', () => {

View File

@ -14,3 +14,54 @@ exports[`Gauge Panel Migrations from 6.1.1 1`] = `
"showThresholdMarkers": true,
}
`;
exports[`Gauge Panel Migrations from 6.1.1 2`] = `
{
"defaults": {
"color": {
"mode": "thresholds",
},
"decimals": 3,
"mappings": [
{
"from": "50",
"id": 1,
"operator": "",
"text": "BIG",
"to": "1000",
"type": 2,
"value": "",
},
],
"max": "50",
"min": "-50",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"index": 0,
"value": -Infinity,
},
{
"color": "#EAB839",
"index": 1,
"value": -25,
},
{
"color": "#6ED0E0",
"index": 2,
"value": 0,
},
{
"color": "red",
"index": 3,
"value": 25,
},
],
},
"unit": "accMS2",
},
"overrides": [],
}
`;

View File

@ -0,0 +1,146 @@
import { PanelModel } from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema/dist/esm/index.gen';
import { gaugePanelMigrationHandler, gaugePanelChangedHandler } from './GaugeMigrations';
describe('Gauge Panel Migrations', () => {
it('from old gauge', () => {
const panel = {
id: 2,
options: {
reduceOptions: {
calcs: ['lastNotNull'],
},
showThresholdLabels: false,
showThresholdMarkers: true,
},
fieldConfig: {
defaults: {
color: {
mode: FieldColorModeId.Fixed,
fixedColor: 'blue',
},
},
overrides: [],
},
pluginVersion: '12.3.0',
type: 'gauge',
} as Omit<PanelModel, 'fieldConfig'>;
const result = gaugePanelMigrationHandler(panel as PanelModel);
expect(result.showThresholdMarkers).toBe(false);
expect(result.sparkline).toBe(false);
});
it('from 6.1.1', () => {
const panel = {
datasource: '-- Grafana --',
gridPos: {
h: 9,
w: 12,
x: 0,
y: 0,
},
id: 2,
options: {
maxValue: '50',
minValue: '-50',
orientation: 'auto',
showThresholdLabels: true,
showThresholdMarkers: true,
thresholds: [
{
color: 'green',
index: 0,
value: -Infinity,
},
{
color: '#EAB839',
index: 1,
value: -25,
},
{
color: '#6ED0E0',
index: 2,
value: 0,
},
{
color: 'red',
index: 3,
value: 25,
},
],
valueMappings: [
{
id: 1,
operator: '',
value: '',
text: 'BIG',
type: 2,
from: '50',
to: '1000',
},
],
valueOptions: {
decimals: 3,
prefix: 'XX',
stat: 'last',
suffix: 'YY',
unit: 'accMS2',
},
},
pluginVersion: '6.1.6',
targets: [
{
refId: 'A',
},
{
refId: 'B',
},
{
refId: 'C',
},
],
timeFrom: null,
timeShift: null,
title: 'Panel Title',
type: 'gauge',
} as Omit<PanelModel, 'fieldConfig'>;
const result = gaugePanelMigrationHandler(panel as PanelModel);
// Ignored due to the API change
//@ts-ignore
expect(result.reduceOptions.defaults).toBeUndefined();
// Ignored due to the API change
//@ts-ignore
expect(result.reduceOptions.overrides).toBeUndefined();
expect((panel as PanelModel).fieldConfig).toMatchSnapshot();
});
it('change from angular singlestat to gauge', () => {
const old = {
angular: {
format: 'ms',
decimals: 7,
gauge: {
maxValue: 150,
minValue: -10,
show: true,
thresholdLabels: true,
thresholdMarkers: true,
},
},
};
const panel = {} as PanelModel;
const newOptions = gaugePanelChangedHandler(panel, 'singlestat', old, { defaults: {}, overrides: [] });
expect(panel.fieldConfig.defaults.unit).toBe('ms');
expect(panel.fieldConfig.defaults.min).toBe(-10);
expect(panel.fieldConfig.defaults.max).toBe(150);
expect(panel.fieldConfig.defaults.decimals).toBe(7);
expect(newOptions.showThresholdMarkers).toBe(true);
expect(newOptions.showThresholdLabels).toBe(true);
});
});

View File

@ -0,0 +1,64 @@
import { PanelModel, PanelTypeChangedHandler } from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema/dist/esm/index.gen';
import { sharedSingleStatPanelChangedHandler, sharedSingleStatMigrationHandler } from '@grafana/ui';
import { Options } from './panelcfg.gen';
// This is called when the panel first loads
export function gaugePanelMigrationHandler(panel: PanelModel<Options>): Partial<Options> {
const sharedOptions = sharedSingleStatMigrationHandler(panel);
const newOptions: Partial<Options> = { ...sharedOptions };
const previousVersion = parseFloat(panel.pluginVersion || '8');
const fieldConfig = panel.fieldConfig;
if (previousVersion <= 12.3) {
// This option had no effect in old gauge unless color mode was 'From thresholds'
if (newOptions.showThresholdMarkers && fieldConfig?.defaults?.color?.mode !== FieldColorModeId.Thresholds) {
newOptions.showThresholdMarkers = false;
}
// This option is enabled by default in new gauge but does not exist in old gauge
newOptions.sparkline = false;
// Remove deprecated sizing options
if ('sizing' in newOptions) {
delete newOptions.sizing;
}
if ('minVizHeight' in newOptions) {
delete newOptions.minVizHeight;
}
if ('minVizWidth' in newOptions) {
delete newOptions.minVizWidth;
}
}
return newOptions;
}
export function shouldMigrateGauge(panel: PanelModel): boolean {
const previousVersion = parseFloat(panel.pluginVersion ?? '8');
return previousVersion <= 12.3;
}
// This is called when the panel changes from another panel
export const gaugePanelChangedHandler: PanelTypeChangedHandler<Options> = (
panel,
prevPluginId: string,
prevOptions
) => {
// This handles most config changes
const opts: Options = sharedSingleStatPanelChangedHandler(panel, prevPluginId, prevOptions);
// Changing from angular singlestat
if (prevPluginId === 'singlestat' && prevOptions.angular) {
const gauge = prevOptions.angular.gauge;
if (gauge) {
opts.showThresholdMarkers = gauge.thresholdMarkers;
opts.showThresholdLabels = gauge.thresholdLabels;
}
}
return opts;
};

View File

@ -47,6 +47,7 @@ export function RadialBarPanel({
alignmentFactors={valueProps.alignmentFactors}
valueManualFontSize={options.text?.valueSize}
nameManualFontSize={options.text?.titleSize}
onClick={menuProps.openMenu}
/>
);
}

View File

@ -0,0 +1,52 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Gauge Panel Migrations from 6.1.1 1`] = `
{
"defaults": {
"color": {
"mode": "thresholds",
},
"decimals": 3,
"mappings": [
{
"from": "50",
"id": 1,
"operator": "",
"text": "BIG",
"to": "1000",
"type": 2,
"value": "",
},
],
"max": "50",
"min": "-50",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"index": 0,
"value": -Infinity,
},
{
"color": "#EAB839",
"index": 1,
"value": -25,
},
{
"color": "#6ED0E0",
"index": 2,
"value": 0,
},
{
"color": "red",
"index": 3,
"value": 25,
},
],
},
"unit": "accMS2",
},
"overrides": [],
}
`;

View File

@ -5,6 +5,7 @@ import { commonOptionsBuilder } from '@grafana/ui';
import { addOrientationOption, addStandardDataReduceOptions } from '../stat/common';
import { EffectsEditor } from './EffectsEditor';
import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations';
import { RadialBarPanel } from './RadialBarPanel';
import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen';
import { GaugeSuggestionsSupplier } from './suggestions';
@ -111,4 +112,6 @@ export const plugin = new PanelPlugin<Options>(RadialBarPanel)
defaultValue: defaultGaugePanelEffects,
});
})
.setSuggestionsSupplier(new GaugeSuggestionsSupplier());
.setSuggestionsSupplier(new GaugeSuggestionsSupplier())
.setMigrationHandler(gaugePanelMigrationHandler, shouldMigrateGauge)
.setPanelChangeHandler(gaugePanelChangedHandler);

View File

@ -10,3 +10,9 @@
Gauge => new gauge migration notes
Old gauge "Show threshold markers" does nothing when color scheme != From thresholds
Decide what to do with
sizing: manual & minVizHeight & minVizWidth (do not think this panel should scroll, and minWidth is broken/does not scroll)
neutral value

View File

@ -29,7 +29,7 @@ composableKinds: PanelCfg: {
barGlow?: bool | *false
spotlight?: bool | *false
rounded?: bool | *false
centerGlow?: bool | *true
centerGlow?: bool | *false
} @cuetsy(kind="interface")
Options: {
@ -38,10 +38,10 @@ composableKinds: PanelCfg: {
showThresholdLabels: bool | *false
segmentCount: number | *1
segmentSpacing: number | *0.3
sparkline?: bool | *false
sparkline?: bool | *true
shape: "circle" | *"gauge"
barWidthFactor: number | *0.4
gradient: *"none" | "auto"
barWidthFactor: number | *0.5
gradient: "none" | *"auto"
effects: GaugePanelEffects | *{}
} @cuetsy(kind="interface")
}

View File

@ -19,7 +19,7 @@ export interface GaugePanelEffects {
export const defaultGaugePanelEffects: Partial<GaugePanelEffects> = {
barGlow: false,
centerGlow: true,
centerGlow: false,
rounded: false,
spotlight: false,
};
@ -37,13 +37,13 @@ export interface Options extends common.SingleStatBaseOptions {
}
export const defaultOptions: Partial<Options> = {
barWidthFactor: 0.4,
barWidthFactor: 0.5,
effects: {},
gradient: 'none',
gradient: 'auto',
segmentCount: 1,
segmentSpacing: 0.3,
shape: 'gauge',
showThresholdLabels: false,
showThresholdMarkers: true,
sparkline: false,
sparkline: true,
};