Compare commits

..

10 Commits

Author SHA1 Message Date
Paul Marbach 0c4ade9514 Merge branch 'main' into fastfrwrd/gauge-neutral 2026-01-14 13:32:57 -05:00
Ezequiel Victorero 72f7bd3900 Snapshots: Support public snapshot instance in latest version (#116086) 2026-01-14 15:28:16 -03:00
Will Assis ba416eab4e unified-storage: dont use polling notifier with sqlite in sqlkv (#116283)
* unified-storage: dont use polling notifier with sqlite in sqlkv
2026-01-14 18:22:39 +00:00
Alan Martin 189d50d815 UI: Use react-table column header types in InteractiveTable with story and tests (#116091)
* feat(InteractiveTable): allow custom header rendering

* docs(InteractiveTable): add story for custom header rendering

* test(InteractiveTable): add tests for custom header rendering

* docs(InteractiveTable): add custom header rendering documentation

* fix: test failure from non-a11y code
2026-01-14 17:59:03 +00:00
Mariell Hoversholm 450eaba447 test: skip integration test in short mode (#116280) 2026-01-14 18:33:55 +01:00
Paul Marbach e60fb6b1d3 Merge remote-tracking branch 'origin/main' into fastfrwrd/gauge-neutral 2026-01-08 11:14:58 -05:00
Paul Marbach 2992964f32 storybook updates 2026-01-08 11:09:50 -05:00
Paul Marbach 31a806281e fix off-by-one, update tests 2026-01-08 11:00:24 -05:00
Paul Marbach d0178cc95d fix off-by-one in segmented gauge 2026-01-08 10:24:53 -05:00
Paul Marbach 8aa4f518d7 Gauge: Neutral option 2026-01-07 17:34:57 -05:00
28 changed files with 865 additions and 308 deletions
@@ -31,6 +31,7 @@ export interface Options extends common.SingleStatBaseOptions {
endpointMarker?: ('point' | 'glow' | 'none');
minVizHeight: number;
minVizWidth: number;
neutral?: number;
segmentCount: number;
segmentSpacing: number;
shape: ('circle' | 'gauge');
@@ -117,6 +117,44 @@ export const MyComponent = () => {
};
```
### Custom Header Rendering
Column headers can be customized using strings, React elements, or renderer functions. The `header` property accepts any value that matches React Table's `Renderer` type.
**Important:** When using custom header content, prefer inline elements (like `<span>`) over block elements (like `<div>`) to avoid layout issues. Block-level elements can cause extra spacing and alignment problems in table headers because they disrupt the table's inline flow. Use `display: inline-flex` or `display: inline-block` when you need flexbox or block-like behavior.
```tsx
const columns: Array<Column<TableData>> = [
// React element header
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" />
</>
),
cell: () => <Checkbox aria-label="Select row" />,
},
// Function renderer header
{
id: 'firstName',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" />
<span>First Name</span>
</span>
),
},
// String header
{ id: 'lastName', header: 'Last name' },
];
```
### Custom Cell Rendering
Individual cells can be rendered using custom content dy defining a `cell` property on the column definition.
@@ -3,8 +3,11 @@ import { useCallback, useMemo, useState } from 'react';
import { CellProps } from 'react-table';
import { LinkButton } from '../Button/Button';
import { Checkbox } from '../Forms/Checkbox';
import { Field } from '../Forms/Field';
import { Icon } from '../Icon/Icon';
import { Input } from '../Input/Input';
import { Text } from '../Text/Text';
import { FetchDataArgs, InteractiveTable, InteractiveTableHeaderTooltip } from './InteractiveTable';
import mdx from './InteractiveTable.mdx';
@@ -297,4 +300,40 @@ export const WithControlledSort: StoryFn<typeof InteractiveTable> = (args) => {
return <InteractiveTable {...args} data={data} pageSize={15} fetchData={fetchData} />;
};
export const WithCustomHeader: TableStoryObj = {
args: {
columns: [
// React element header
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" />
</>
),
cell: () => <Checkbox aria-label="Select row" />,
},
// Function renderer header
{
id: 'firstName',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" />
<Text element="span">First Name</Text>
</span>
),
sortType: 'string',
},
// String header
{ id: 'lastName', header: 'Last name', sortType: 'string' },
{ id: 'car', header: 'Car', sortType: 'string' },
{ id: 'age', header: 'Age', sortType: 'number' },
],
data: pageableData.slice(0, 10),
getRowId: (r) => r.id,
},
};
export default meta;
@@ -2,6 +2,9 @@ import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { Checkbox } from '../Forms/Checkbox';
import { Icon } from '../Icon/Icon';
import { InteractiveTable } from './InteractiveTable';
import { Column } from './types';
@@ -247,4 +250,104 @@ describe('InteractiveTable', () => {
expect(fetchData).toHaveBeenCalledWith({ sortBy: [{ id: 'id', desc: false }] });
});
});
describe('custom header rendering', () => {
it('should render string headers', () => {
const columns: Array<Column<TableData>> = [{ id: 'id', header: 'ID' }];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByRole('columnheader', { name: 'ID' })).toBeInTheDocument();
});
it('should render React element headers', () => {
const columns: Array<Column<TableData>> = [
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" data-testid="header-checkbox" />
</>
),
cell: () => <Checkbox data-testid="cell-checkbox" aria-label="Select row" />,
},
];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByTestId('header-checkbox')).toBeInTheDocument();
expect(screen.getByTestId('cell-checkbox')).toBeInTheDocument();
expect(screen.getByLabelText('Select all rows')).toBeInTheDocument();
expect(screen.getByLabelText('Select row')).toBeInTheDocument();
expect(screen.getByText('Select all rows')).toBeInTheDocument();
});
it('should render function renderer headers', () => {
const columns: Array<Column<TableData>> = [
{
id: 'firstName',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" data-testid="header-icon" />
<span>First Name</span>
</span>
),
sortType: 'string',
},
];
const data: TableData[] = [{ id: '1', value: '1', country: 'Sweden' }];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByTestId('header-icon')).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /first name/i })).toBeInTheDocument();
});
it('should render all header types together', () => {
const columns: Array<Column<TableData>> = [
{
id: 'checkbox',
header: (
<>
<label htmlFor="select-all" className="sr-only">
Select all rows
</label>
<Checkbox id="select-all" data-testid="header-checkbox" />
</>
),
cell: () => <Checkbox aria-label="Select row" />,
},
{
id: 'id',
header: () => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px' }}>
<Icon name="user" size="sm" data-testid="header-icon" />
<span>ID</span>
</span>
),
sortType: 'string',
},
{ id: 'country', header: 'Country', sortType: 'string' },
{ id: 'value', header: 'Value' },
];
const data: TableData[] = [
{ id: '1', value: 'Value 1', country: 'Sweden' },
{ id: '2', value: 'Value 2', country: 'Norway' },
];
render(<InteractiveTable columns={columns} data={data} getRowId={getRowId} />);
expect(screen.getByTestId('header-checkbox')).toBeInTheDocument();
expect(screen.getByTestId('header-icon')).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Country' })).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: 'Value' })).toBeInTheDocument();
// Verify data is rendered
expect(screen.getByText('Sweden')).toBeInTheDocument();
expect(screen.getByText('Norway')).toBeInTheDocument();
expect(screen.getByText('Value 1')).toBeInTheDocument();
expect(screen.getByText('Value 2')).toBeInTheDocument();
});
});
});
@@ -1,5 +1,5 @@
import { ReactNode } from 'react';
import { CellProps, DefaultSortTypes, IdType, SortByFn } from 'react-table';
import { CellProps, DefaultSortTypes, HeaderProps, IdType, Renderer, SortByFn } from 'react-table';
export interface Column<TableData extends object> {
/**
@@ -11,9 +11,9 @@ export interface Column<TableData extends object> {
*/
cell?: (props: CellProps<TableData>) => ReactNode;
/**
* Header name. if `undefined` the header will be empty. Useful for action columns.
* Header name. Can be a string, renderer function, or undefined. If `undefined` the header will be empty. Useful for action columns.
*/
header?: string;
header?: Renderer<HeaderProps<TableData>>;
/**
* Column sort type. If `undefined` the column will not be sortable.
* */
@@ -1,4 +1,6 @@
import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
import { useMemo } from 'react';
import { colorManipulator, FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
@@ -6,7 +8,6 @@ import { RadialArcPath } from './RadialArcPath';
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
export interface RadialBarProps {
angle: number;
angleRange: number;
dimensions: RadialGaugeDimensions;
fieldDisplay: FieldDisplay;
@@ -15,11 +16,12 @@ export interface RadialBarProps {
endpointMarker?: 'point' | 'glow';
shape: RadialShape;
startAngle: number;
startValueAngle: number;
endValueAngle: number;
glowFilter?: string;
endpointMarkerGlowFilter?: string;
}
export function RadialBar({
angle,
angleRange,
dimensions,
fieldDisplay,
@@ -28,26 +30,45 @@ export function RadialBar({
endpointMarker,
shape,
startAngle,
startValueAngle,
endValueAngle,
glowFilter,
endpointMarkerGlowFilter,
}: RadialBarProps) {
const theme = useTheme2();
const colorProps = gradient ? { gradient } : { color: fieldDisplay.display.color ?? FALLBACK_COLOR };
const trackColor = useMemo(
() => colorManipulator.onBackground(theme.colors.action.hover, theme.colors.background.primary).toHexString(),
[theme]
);
return (
<>
{/** Track */}
{/** Track before value */}
{startValueAngle !== 0 && (
<RadialArcPath
arcLengthDeg={startValueAngle}
fieldDisplay={fieldDisplay}
color={trackColor}
dimensions={dimensions}
roundedBars={roundedBars}
shape={shape}
startAngle={startAngle}
/>
)}
{/** Track after value */}
<RadialArcPath
arcLengthDeg={angleRange - angle}
arcLengthDeg={angleRange - endValueAngle - startValueAngle}
fieldDisplay={fieldDisplay}
color={theme.colors.action.hover}
color={trackColor}
dimensions={dimensions}
roundedBars={roundedBars}
shape={shape}
startAngle={startAngle + angle}
startAngle={startAngle + startValueAngle + endValueAngle}
/>
{/** The colored bar */}
<RadialArcPath
arcLengthDeg={angle}
arcLengthDeg={endValueAngle}
barEndcaps={shape === 'circle' && roundedBars}
dimensions={dimensions}
endpointMarker={roundedBars ? endpointMarker : undefined}
@@ -56,7 +77,7 @@ export function RadialBar({
glowFilter={glowFilter}
roundedBars={roundedBars}
shape={shape}
startAngle={startAngle}
startAngle={startAngle + startValueAngle}
{...colorProps}
/>
</>
@@ -11,6 +11,7 @@ import {
getFieldConfigMinMax,
getFieldDisplayProcessor,
getOptimalSegmentCount,
getValuePercentageForValue,
} from './utils';
export interface RadialBarSegmentedProps {
@@ -18,6 +19,8 @@ export interface RadialBarSegmentedProps {
dimensions: RadialGaugeDimensions;
angleRange: number;
startAngle: number;
startValueAngle: number;
endValueAngle: number;
glowFilter?: string;
segmentCount: number;
segmentSpacing: number;
@@ -36,22 +39,24 @@ export const RadialBarSegmented = memo(
segmentCount,
segmentSpacing,
shape,
startValueAngle,
endValueAngle,
}: RadialBarSegmentedProps) => {
const theme = useTheme2();
const segments: React.ReactNode[] = [];
const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange);
const [min, max] = getFieldConfigMinMax(fieldDisplay);
const value = fieldDisplay.display.numeric;
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange);
const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments;
const displayProcessor = getFieldDisplayProcessor(fieldDisplay);
for (let i = 0; i < segmentCountAdjusted; i++) {
const angleValue = min + ((max - min) / segmentCountAdjusted) * i;
const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01;
const segmentColor =
angleValue >= value ? theme.colors.border.medium : (displayProcessor(angleValue).color ?? FALLBACK_COLOR);
const colorProps = angleValue < value && gradient ? { gradient } : { color: segmentColor };
const value = min + ((max - min) / segmentCountAdjusted) * i;
const segmentAngle = getValuePercentageForValue(fieldDisplay, value) * angleRange;
const isTrack = segmentAngle < startValueAngle || segmentAngle >= startValueAngle + endValueAngle;
const segmentStartAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01;
const segmentColor = isTrack ? theme.colors.border.medium : (displayProcessor(value).color ?? FALLBACK_COLOR);
const colorProps = !isTrack && gradient ? { gradient } : { color: segmentColor };
segments.push(
<RadialArcPath
@@ -61,7 +66,7 @@ export const RadialBarSegmented = memo(
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
shape={shape}
startAngle={segmentAngle}
startAngle={segmentStartAngle}
{...colorProps}
/>
);
@@ -50,6 +50,7 @@ const meta: Meta<StoryProps> = {
thresholdsBar: false,
colorScheme: FieldColorModeId.Thresholds,
decimals: 0,
neutral: undefined,
},
argTypes: {
barWidthFactor: { control: { type: 'range', min: 0.1, max: 1, step: 0.01 } },
@@ -75,6 +76,7 @@ const meta: Meta<StoryProps> = {
],
},
decimals: { control: { type: 'range', min: 0, max: 7 } },
neutral: { control: { type: 'number' } },
},
};
@@ -270,6 +272,23 @@ export const Examples: StoryFn<StoryProps> = (args) => {
barWidthFactor={0.7}
/>
</Stack>
<div>
Neutral <em>(range -50 to 50, neutral = 0)</em>
</div>
<Stack direction={'row'} gap={3}>
<RadialGaugeExample
min={-50}
max={50}
value={-20}
colorScheme={FieldColorModeId.Thresholds}
gradient
shape="gauge"
glowCenter={true}
roundedBars={false}
barWidthFactor={0.7}
neutral={0}
/>
</Stack>
</Stack>
);
};
@@ -330,6 +349,7 @@ interface ExampleProps {
endpointMarker?: RadialGaugeProps['endpointMarker'];
decimals?: number;
showScaleLabels?: boolean;
neutral?: number;
}
export function RadialGaugeExample({
@@ -357,6 +377,7 @@ export function RadialGaugeExample({
endpointMarker = 'glow',
decimals = 0,
showScaleLabels,
neutral,
}: ExampleProps) {
const theme = useTheme2();
@@ -442,6 +463,7 @@ export function RadialGaugeExample({
thresholdsBar={thresholdsBar}
showScaleLabels={showScaleLabels}
endpointMarker={endpointMarker}
neutral={neutral}
/>
);
}
@@ -16,6 +16,7 @@ describe('RadialGauge', () => {
{ description: 'with endpoint marker point', props: { roundedBars: true, endpointMarker: 'point' } },
{ description: 'with thresholds bar', props: { thresholdsBar: true } },
{ description: 'with sparkline', props: { sparkline: true } },
{ description: 'with neutral value', props: { neutral: 50 } },
] satisfies Array<{ description: string; props?: ComponentProps<typeof RadialGaugeExample> }>)(
'should render $description without throwing',
({ props }) => {
@@ -67,6 +67,11 @@ export interface RadialGaugeProps {
/** Specify which text should be visible */
textMode?: RadialTextMode;
showScaleLabels?: boolean;
/**
* If set, the gauge will use the neutral value instead of the min value as the starting point for a gauge.
* this is most useful when you need to show positive and negative values on a gauge.
*/
neutral?: number;
/** For data links */
onClick?: React.MouseEventHandler<HTMLElement>;
timeRange?: TimeRange;
@@ -91,6 +96,7 @@ export function RadialGauge(props: RadialGaugeProps) {
roundedBars = true,
thresholdsBar = false,
showScaleLabels = false,
neutral,
endpointMarker,
onClick,
values,
@@ -113,7 +119,13 @@ export function RadialGauge(props: RadialGaugeProps) {
for (let barIndex = 0; barIndex < values.length; barIndex++) {
const displayValue = values[barIndex];
const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle);
const { startValueAngle, endValueAngle, angleRange } = getValueAngleForValue(
displayValue,
startAngle,
endAngle,
neutral
);
const gradientStops = gradient ? buildGradientColors(theme, displayValue) : undefined;
const color = displayValue.display.color ?? FALLBACK_COLOR;
const dimensions = calculateDimensions(
@@ -140,7 +152,7 @@ export function RadialGauge(props: RadialGaugeProps) {
<SpotlightGradient
key={spotlightGradientId}
id={spotlightGradientId}
angle={angle + startAngle}
angle={endValueAngle + startAngle}
dimensions={dimensions}
roundedBars={roundedBars}
theme={theme}
@@ -156,6 +168,8 @@ export function RadialGauge(props: RadialGaugeProps) {
fieldDisplay={displayValue}
angleRange={angleRange}
startAngle={startAngle}
startValueAngle={startValueAngle}
endValueAngle={endValueAngle}
glowFilter={glowFilterRef}
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
@@ -168,9 +182,10 @@ export function RadialGauge(props: RadialGaugeProps) {
<RadialBar
key={`radial-bar-${barIndex}-${gaugeId}`}
dimensions={dimensions}
angle={angle}
angleRange={angleRange}
startAngle={startAngle}
startValueAngle={startValueAngle}
endValueAngle={endValueAngle}
roundedBars={roundedBars}
glowFilter={glowFilterRef}
endpointMarkerGlowFilter={spotlightGradientRef}
@@ -233,7 +233,8 @@ describe('RadialGauge utils', () => {
const fieldDisplay = createFieldDisplay(50, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360);
expect(result.angle).toBe(180); // 50% of 360°
expect(result.startValueAngle).toBe(0);
expect(result.endValueAngle).toBe(180); // 50% of 360°
expect(result.angleRange).toBe(360);
});
@@ -241,7 +242,8 @@ describe('RadialGauge utils', () => {
const fieldDisplay = createFieldDisplay(50, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 90, 270);
expect(result.angle).toBe(135); // 50% of 360° range
expect(result.startValueAngle).toBe(0);
expect(result.endValueAngle).toBe(135); // 50% of 360° range
expect(result.angleRange).toBe(270);
});
@@ -249,28 +251,28 @@ describe('RadialGauge utils', () => {
const fieldDisplay = createFieldDisplay(150, 0, 100); // value exceeds max
const result = getValueAngleForValue(fieldDisplay, 0, 360);
expect(result.angle).toBe(360); // clamped to angleRange
expect(result.endValueAngle).toBe(360); // clamped to angleRange
});
it('should handle minimum values', () => {
const fieldDisplay = createFieldDisplay(0, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360);
expect(result.angle).toBe(0);
expect(result.endValueAngle).toBe(0);
});
it('should handle maximum values', () => {
const fieldDisplay = createFieldDisplay(100, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360);
expect(result.angle).toBe(360);
expect(result.endValueAngle).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);
expect(result.endValueAngle).toBe(0);
});
it('should handle values higher than max', () => {
@@ -278,7 +280,39 @@ describe('RadialGauge utils', () => {
const result = getValueAngleForValue(fieldDisplay, 240, 120);
// Expect the angle to be clamped to the maximum range
expect(result.angle).toBe(240);
expect(result.endValueAngle).toBe(240);
});
it('should handle neutral values', () => {
const fieldDisplay = createFieldDisplay(75, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360, 50);
expect(result.startValueAngle).toBe(180); // Neutral at 50% of 360°
expect(result.endValueAngle).toBe(90); // 75% - 50% = 25% of 360°
});
it('should handle neutral values equal to value', () => {
const fieldDisplay = createFieldDisplay(50, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360, 50);
expect(result.startValueAngle).toBe(180); // Neutral at 50% of 360°
expect(result.endValueAngle).toBe(0); // No difference
});
it('should handle neutral values greater than value', () => {
const fieldDisplay = createFieldDisplay(25, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360, 150);
expect(result.startValueAngle).toBe(90);
expect(result.endValueAngle).toBe(270); // remaining angle to 360
});
it('should handle neutral values below range', () => {
const fieldDisplay = createFieldDisplay(25, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360, -50);
expect(result.startValueAngle).toBe(0);
expect(result.endValueAngle).toBe(90);
});
});
@@ -28,19 +28,32 @@ export function getValueAngleForValue(
fieldDisplay: FieldDisplay,
startAngle: number,
endAngle: number,
value = fieldDisplay.display.numeric
neutral?: number
) {
const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle;
const value = fieldDisplay.display.numeric;
let angle = getValuePercentageForValue(fieldDisplay, value) * angleRange;
const valueAngle = getValuePercentageForValue(fieldDisplay, value) * angleRange;
if (angle > angleRange) {
angle = angleRange;
} else if (angle < 0) {
angle = 0;
let endValueAngle = valueAngle;
let startValueAngle = 0;
if (typeof neutral === 'number') {
const [min, max] = getFieldConfigMinMax(fieldDisplay);
const clampedNeutral = Math.min(Math.max(min, neutral), max);
const neutralAngle = getValuePercentageForValue(fieldDisplay, clampedNeutral) * angleRange;
if (neutralAngle <= valueAngle) {
startValueAngle = neutralAngle;
endValueAngle = valueAngle - neutralAngle;
} else {
startValueAngle = valueAngle;
endValueAngle = neutralAngle - valueAngle;
}
}
return { angleRange, angle };
const clampedEndValueAngle = Math.min(Math.max(endValueAngle, 0), angleRange);
return { angleRange, startValueAngle, endValueAngle: clampedEndValueAngle };
}
/**
+24 -18
View File
@@ -76,21 +76,27 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) {
return
}
// Do not check permissions when the instance snapshot public mode is enabled
if !hs.Cfg.SnapshotPublicMode {
evaluator := ac.EvalAll(ac.EvalPermission(dashboards.ActionSnapshotsCreate), ac.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(cmd.Dashboard.GetNestedString("uid"))))
if canSave, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canSave {
c.JsonApiErr(http.StatusForbidden, "forbidden", err)
return
}
}
dashboardsnapshots.CreateDashboardSnapshot(c, snapshot.SnapshotSharingOptions{
cfg := snapshot.SnapshotSharingOptions{
SnapshotsEnabled: hs.Cfg.SnapshotEnabled,
ExternalEnabled: hs.Cfg.ExternalEnabled,
ExternalSnapshotName: hs.Cfg.ExternalSnapshotName,
ExternalSnapshotURL: hs.Cfg.ExternalSnapshotUrl,
}, cmd, hs.dashboardsnapshotsService)
}
if hs.Cfg.SnapshotPublicMode {
// Public mode: no user or dashboard validation needed
dashboardsnapshots.CreateDashboardSnapshotPublic(c, cfg, cmd, hs.dashboardsnapshotsService)
return
}
// Regular mode: check permissions
evaluator := ac.EvalAll(ac.EvalPermission(dashboards.ActionSnapshotsCreate), ac.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(cmd.Dashboard.GetNestedString("uid"))))
if canSave, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canSave {
c.JsonApiErr(http.StatusForbidden, "forbidden", err)
return
}
dashboardsnapshots.CreateDashboardSnapshot(c, cfg, cmd, hs.dashboardsnapshotsService)
}
// GET /api/snapshots/:key
@@ -213,13 +219,6 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon
return response.Error(http.StatusUnauthorized, "OrgID mismatch", nil)
}
if queryResult.External {
err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err)
}
}
// Dashboard can be empty (creation error or external snapshot). This means that the mustInt here returns a 0,
// which before RBAC would result in a dashboard which has no ACL. A dashboard without an ACL would fallback
// to the users org role, which for editors and admins would essentially always be allowed here. With RBAC,
@@ -239,6 +238,13 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon
}
}
if queryResult.External {
err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err)
}
}
cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey}
if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil {
+73 -29
View File
@@ -36,6 +36,9 @@ var client = &http.Client{
Transport: &http.Transport{Proxy: http.ProxyFromEnvironment},
}
// CreateDashboardSnapshot creates a snapshot when running Grafana in regular mode.
// It validates the user and dashboard exist before creating the snapshot.
// This mode supports both local and external snapshots.
func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) {
if !cfg.SnapshotsEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
@@ -43,6 +46,7 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh
}
uid := cmd.Dashboard.GetNestedString("uid")
user, err := identity.GetRequester(c.Req.Context())
if err != nil {
c.JsonApiErr(http.StatusBadRequest, "missing user in context", nil)
@@ -59,21 +63,18 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh
return
}
cmd.ExternalURL = ""
cmd.OrgID = user.GetOrgID()
cmd.UserID, _ = identity.UserIdentifier(user.GetID())
if cmd.Name == "" {
cmd.Name = "Unnamed snapshot"
}
var snapshotUrl string
cmd.ExternalURL = ""
cmd.OrgID = user.GetOrgID()
cmd.UserID, _ = identity.UserIdentifier(user.GetID())
originalDashboardURL, err := createOriginalDashboardURL(&cmd)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err)
return
}
var snapshotURL string
if cmd.External {
// Handle external snapshot creation
if !cfg.ExternalEnabled {
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
return
@@ -85,40 +86,83 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh
return
}
snapshotUrl = resp.Url
cmd.Key = resp.Key
cmd.DeleteKey = resp.DeleteKey
cmd.ExternalURL = resp.Url
cmd.ExternalDeleteURL = resp.DeleteUrl
cmd.Dashboard = &common.Unstructured{}
snapshotURL = resp.Url
metrics.MApiDashboardSnapshotExternal.Inc()
} else {
cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl")
if cmd.Key == "" {
var err error
cmd.Key, err = util.GetRandomString(32)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
return
}
// Handle local snapshot creation
originalDashboardURL, err := createOriginalDashboardURL(&cmd)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err)
return
}
if cmd.DeleteKey == "" {
var err error
cmd.DeleteKey, err = util.GetRandomString(32)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
return
}
snapshotURL, err = prepareLocalSnapshot(&cmd, originalDashboardURL)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
return
}
snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key)
metrics.MApiDashboardSnapshotCreate.Inc()
}
saveAndRespond(c, svc, cmd, snapshotURL)
}
// CreateDashboardSnapshotPublic creates a snapshot when running Grafana in public mode.
// In public mode, there is no user or dashboard information to validate.
// Only local snapshots are supported (external snapshots are not available).
func CreateDashboardSnapshotPublic(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) {
if !cfg.SnapshotsEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return
}
if cmd.Name == "" {
cmd.Name = "Unnamed snapshot"
}
snapshotURL, err := prepareLocalSnapshot(&cmd, "")
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err)
return
}
metrics.MApiDashboardSnapshotCreate.Inc()
saveAndRespond(c, svc, cmd, snapshotURL)
}
// prepareLocalSnapshot prepares the command for a local snapshot and returns the snapshot URL.
func prepareLocalSnapshot(cmd *CreateDashboardSnapshotCommand, originalDashboardURL string) (string, error) {
cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl")
if cmd.Key == "" {
key, err := util.GetRandomString(32)
if err != nil {
return "", err
}
cmd.Key = key
}
if cmd.DeleteKey == "" {
deleteKey, err := util.GetRandomString(32)
if err != nil {
return "", err
}
cmd.DeleteKey = deleteKey
}
return setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key), nil
}
// saveAndRespond saves the snapshot and sends the response.
func saveAndRespond(c *contextmodel.ReqContext, svc Service, cmd CreateDashboardSnapshotCommand, snapshotURL string) {
result, err := svc.CreateDashboardSnapshot(c.Req.Context(), &cmd)
if err != nil {
c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err)
@@ -128,7 +172,7 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh
c.JSON(http.StatusOK, snapshot.DashboardCreateResponse{
Key: result.Key,
DeleteKey: result.DeleteKey,
URL: snapshotUrl,
URL: snapshotURL,
DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + result.DeleteKey),
})
}
+331 -35
View File
@@ -20,40 +20,30 @@ import (
"github.com/grafana/grafana/pkg/web"
)
func TestCreateDashboardSnapshot_DashboardNotFound(t *testing.T) {
mockService := &MockService{}
cfg := snapshot.SnapshotSharingOptions{
SnapshotsEnabled: true,
ExternalEnabled: false,
func createTestDashboard(t *testing.T) *common.Unstructured {
t.Helper()
dashboard := &common.Unstructured{}
dashboardData := map[string]any{
"uid": "test-dashboard-uid",
"id": 123,
}
testUser := &user.SignedInUser{
dashboardBytes, _ := json.Marshal(dashboardData)
_ = json.Unmarshal(dashboardBytes, dashboard)
return dashboard
}
func createTestUser() *user.SignedInUser {
return &user.SignedInUser{
UserID: 1,
OrgID: 1,
Login: "testuser",
Name: "Test User",
Email: "test@example.com",
}
dashboard := &common.Unstructured{}
dashboardData := map[string]interface{}{
"uid": "test-dashboard-uid",
"id": 123,
}
dashboardBytes, _ := json.Marshal(dashboardData)
_ = json.Unmarshal(dashboardBytes, dashboard)
cmd := CreateDashboardSnapshotCommand{
DashboardCreateCommand: snapshot.DashboardCreateCommand{
Dashboard: dashboard,
Name: "Test Snapshot",
},
}
mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid").
Return(dashboards.ErrDashboardNotFound)
req, _ := http.NewRequest("POST", "/api/snapshots", nil)
req = req.WithContext(identity.WithRequester(req.Context(), testUser))
}
func createReqContext(t *testing.T, req *http.Request, testUser *user.SignedInUser) (*contextmodel.ReqContext, *httptest.ResponseRecorder) {
t.Helper()
recorder := httptest.NewRecorder()
ctx := &contextmodel.ReqContext{
Context: &web.Context{
@@ -63,13 +53,319 @@ func TestCreateDashboardSnapshot_DashboardNotFound(t *testing.T) {
SignedInUser: testUser,
Logger: log.NewNopLogger(),
}
CreateDashboardSnapshot(ctx, cfg, cmd, mockService)
mockService.AssertExpectations(t)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]interface{}
err := json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "Dashboard not found", response["message"])
return ctx, recorder
}
// TestCreateDashboardSnapshot tests snapshot creation in regular mode (non-public instance).
// These tests cover scenarios when Grafana is running as a regular server with user authentication.
func TestCreateDashboardSnapshot(t *testing.T) {
t.Run("should return error when dashboard not found", func(t *testing.T) {
mockService := &MockService{}
cfg := snapshot.SnapshotSharingOptions{
SnapshotsEnabled: true,
ExternalEnabled: false,
}
testUser := createTestUser()
dashboard := createTestDashboard(t)
cmd := CreateDashboardSnapshotCommand{
DashboardCreateCommand: snapshot.DashboardCreateCommand{
Dashboard: dashboard,
Name: "Test Snapshot",
},
}
mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid").
Return(dashboards.ErrDashboardNotFound)
req, _ := http.NewRequest("POST", "/api/snapshots", nil)
req = req.WithContext(identity.WithRequester(req.Context(), testUser))
ctx, recorder := createReqContext(t, req, testUser)
CreateDashboardSnapshot(ctx, cfg, cmd, mockService)
mockService.AssertExpectations(t)
assert.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "Dashboard not found", response["message"])
})
t.Run("should create external snapshot when external is enabled", func(t *testing.T) {
externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/snapshots", r.URL.Path)
assert.Equal(t, "POST", r.Method)
response := map[string]any{
"key": "external-key",
"deleteKey": "external-delete-key",
"url": "https://external.example.com/dashboard/snapshot/external-key",
"deleteUrl": "https://external.example.com/api/snapshots-delete/external-delete-key",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(response)
}))
defer externalServer.Close()
mockService := NewMockService(t)
cfg := snapshot.SnapshotSharingOptions{
SnapshotsEnabled: true,
ExternalEnabled: true,
ExternalSnapshotURL: externalServer.URL,
}
testUser := createTestUser()
dashboard := createTestDashboard(t)
cmd := CreateDashboardSnapshotCommand{
DashboardCreateCommand: snapshot.DashboardCreateCommand{
Dashboard: dashboard,
Name: "Test External Snapshot",
External: true,
},
}
mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid").
Return(nil)
mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything).
Return(&DashboardSnapshot{
Key: "external-key",
DeleteKey: "external-delete-key",
}, nil)
req, _ := http.NewRequest("POST", "/api/snapshots", nil)
req = req.WithContext(identity.WithRequester(req.Context(), testUser))
ctx, recorder := createReqContext(t, req, testUser)
CreateDashboardSnapshot(ctx, cfg, cmd, mockService)
mockService.AssertExpectations(t)
assert.Equal(t, http.StatusOK, recorder.Code)
var response map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "external-key", response["key"])
assert.Equal(t, "external-delete-key", response["deleteKey"])
assert.Equal(t, "https://external.example.com/dashboard/snapshot/external-key", response["url"])
})
t.Run("should return forbidden when external is disabled", func(t *testing.T) {
mockService := NewMockService(t)
cfg := snapshot.SnapshotSharingOptions{
SnapshotsEnabled: true,
ExternalEnabled: false,
}
testUser := createTestUser()
dashboard := createTestDashboard(t)
cmd := CreateDashboardSnapshotCommand{
DashboardCreateCommand: snapshot.DashboardCreateCommand{
Dashboard: dashboard,
Name: "Test External Snapshot",
External: true,
},
}
mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid").
Return(nil)
req, _ := http.NewRequest("POST", "/api/snapshots", nil)
req = req.WithContext(identity.WithRequester(req.Context(), testUser))
ctx, recorder := createReqContext(t, req, testUser)
CreateDashboardSnapshot(ctx, cfg, cmd, mockService)
mockService.AssertExpectations(t)
assert.Equal(t, http.StatusForbidden, recorder.Code)
var response map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "External dashboard creation is disabled", response["message"])
})
t.Run("should create local snapshot", func(t *testing.T) {
mockService := NewMockService(t)
cfg := snapshot.SnapshotSharingOptions{
SnapshotsEnabled: true,
}
testUser := createTestUser()
dashboard := createTestDashboard(t)
cmd := CreateDashboardSnapshotCommand{
DashboardCreateCommand: snapshot.DashboardCreateCommand{
Dashboard: dashboard,
Name: "Test Local Snapshot",
},
Key: "local-key",
DeleteKey: "local-delete-key",
}
mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid").
Return(nil)
mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything).
Return(&DashboardSnapshot{
Key: "local-key",
DeleteKey: "local-delete-key",
}, nil)
req, _ := http.NewRequest("POST", "/api/snapshots", nil)
req = req.WithContext(identity.WithRequester(req.Context(), testUser))
ctx, recorder := createReqContext(t, req, testUser)
CreateDashboardSnapshot(ctx, cfg, cmd, mockService)
mockService.AssertExpectations(t)
assert.Equal(t, http.StatusOK, recorder.Code)
var response map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "local-key", response["key"])
assert.Equal(t, "local-delete-key", response["deleteKey"])
assert.Contains(t, response["url"], "dashboard/snapshot/local-key")
assert.Contains(t, response["deleteUrl"], "api/snapshots-delete/local-delete-key")
})
}
// TestCreateDashboardSnapshotPublic tests snapshot creation in public mode.
// These tests cover scenarios when Grafana is running as a public snapshot server
// where no user authentication or dashboard validation is required.
func TestCreateDashboardSnapshotPublic(t *testing.T) {
t.Run("should create local snapshot without user context", func(t *testing.T) {
mockService := NewMockService(t)
cfg := snapshot.SnapshotSharingOptions{
SnapshotsEnabled: true,
}
dashboard := createTestDashboard(t)
cmd := CreateDashboardSnapshotCommand{
DashboardCreateCommand: snapshot.DashboardCreateCommand{
Dashboard: dashboard,
Name: "Test Snapshot",
},
Key: "test-key",
DeleteKey: "test-delete-key",
}
mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything).
Return(&DashboardSnapshot{
Key: "test-key",
DeleteKey: "test-delete-key",
}, nil)
req, _ := http.NewRequest("POST", "/api/snapshots", nil)
recorder := httptest.NewRecorder()
ctx := &contextmodel.ReqContext{
Context: &web.Context{
Req: req,
Resp: web.NewResponseWriter("POST", recorder),
},
Logger: log.NewNopLogger(),
}
CreateDashboardSnapshotPublic(ctx, cfg, cmd, mockService)
mockService.AssertExpectations(t)
assert.Equal(t, http.StatusOK, recorder.Code)
var response map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "test-key", response["key"])
assert.Equal(t, "test-delete-key", response["deleteKey"])
assert.Contains(t, response["url"], "dashboard/snapshot/test-key")
assert.Contains(t, response["deleteUrl"], "api/snapshots-delete/test-delete-key")
})
t.Run("should return forbidden when snapshots are disabled", func(t *testing.T) {
mockService := NewMockService(t)
cfg := snapshot.SnapshotSharingOptions{
SnapshotsEnabled: false,
}
dashboard := createTestDashboard(t)
cmd := CreateDashboardSnapshotCommand{
DashboardCreateCommand: snapshot.DashboardCreateCommand{
Dashboard: dashboard,
Name: "Test Snapshot",
},
}
req, _ := http.NewRequest("POST", "/api/snapshots", nil)
recorder := httptest.NewRecorder()
ctx := &contextmodel.ReqContext{
Context: &web.Context{
Req: req,
Resp: web.NewResponseWriter("POST", recorder),
},
Logger: log.NewNopLogger(),
}
CreateDashboardSnapshotPublic(ctx, cfg, cmd, mockService)
assert.Equal(t, http.StatusForbidden, recorder.Code)
var response map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &response)
require.NoError(t, err)
assert.Equal(t, "Dashboard Snapshots are disabled", response["message"])
})
}
// TestDeleteExternalDashboardSnapshot tests deletion of external snapshots.
// This function is called in public mode and doesn't require user context.
func TestDeleteExternalDashboardSnapshot(t *testing.T) {
t.Run("should return nil on successful deletion", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "GET", r.Method)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
err := DeleteExternalDashboardSnapshot(server.URL)
assert.NoError(t, err)
})
t.Run("should gracefully handle already deleted snapshot", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
response := map[string]any{
"message": "Failed to get dashboard snapshot",
}
_ = json.NewEncoder(w).Encode(response)
}))
defer server.Close()
err := DeleteExternalDashboardSnapshot(server.URL)
assert.NoError(t, err)
})
t.Run("should return error on unexpected status code", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
err := DeleteExternalDashboardSnapshot(server.URL)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unexpected response when deleting external snapshot")
assert.Contains(t, err.Error(), "404")
})
t.Run("should return error on 500 with different message", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
response := map[string]any{
"message": "Some other error",
}
_ = json.NewEncoder(w).Encode(response)
}))
defer server.Close()
err := DeleteExternalDashboardSnapshot(server.URL)
assert.Error(t, err)
assert.Contains(t, err.Error(), "500")
})
}
+23 -7
View File
@@ -19,13 +19,18 @@ const (
defaultBufferSize = 10000
)
type notifier struct {
type notifier interface {
Watch(context.Context, watchOptions) <-chan Event
}
type pollingNotifier struct {
eventStore *eventStore
log logging.Logger
}
type notifierOptions struct {
log logging.Logger
log logging.Logger
useChannelNotifier bool
}
type watchOptions struct {
@@ -44,15 +49,26 @@ func defaultWatchOptions() watchOptions {
}
}
func newNotifier(eventStore *eventStore, opts notifierOptions) *notifier {
func newNotifier(eventStore *eventStore, opts notifierOptions) notifier {
if opts.log == nil {
opts.log = &logging.NoOpLogger{}
}
return &notifier{eventStore: eventStore, log: opts.log}
if opts.useChannelNotifier {
return &channelNotifier{}
}
return &pollingNotifier{eventStore: eventStore, log: opts.log}
}
type channelNotifier struct{}
func (cn *channelNotifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
return nil
}
// Return the last resource version from the event store
func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error) {
func (n *pollingNotifier) lastEventResourceVersion(ctx context.Context) (int64, error) {
e, err := n.eventStore.LastEventKey(ctx)
if err != nil {
return 0, err
@@ -60,11 +76,11 @@ func (n *notifier) lastEventResourceVersion(ctx context.Context) (int64, error)
return e.ResourceVersion, nil
}
func (n *notifier) cacheKey(evt Event) string {
func (n *pollingNotifier) cacheKey(evt Event) string {
return fmt.Sprintf("%s~%s~%s~%s~%d", evt.Namespace, evt.Group, evt.Resource, evt.Name, evt.ResourceVersion)
}
func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
func (n *pollingNotifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
if opts.MinBackoff <= 0 {
opts.MinBackoff = defaultMinBackoff
}
+12 -12
View File
@@ -13,7 +13,7 @@ import (
"github.com/stretchr/testify/require"
)
func setupTestNotifier(t *testing.T) (*notifier, *eventStore) {
func setupTestNotifier(t *testing.T) (*pollingNotifier, *eventStore) {
db := setupTestBadgerDB(t)
t.Cleanup(func() {
err := db.Close()
@@ -22,10 +22,10 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) {
kv := NewBadgerKV(db)
eventStore := newEventStore(kv)
notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}})
return notifier, eventStore
return notifier.(*pollingNotifier), eventStore
}
func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) {
func setupTestNotifierSqlKv(t *testing.T) (*pollingNotifier, *eventStore) {
dbstore := db.InitTestDB(t)
eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil)
require.NoError(t, err)
@@ -33,7 +33,7 @@ func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) {
require.NoError(t, err)
eventStore := newEventStore(kv)
notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}})
return notifier, eventStore
return notifier.(*pollingNotifier), eventStore
}
func TestNewNotifier(t *testing.T) {
@@ -49,7 +49,7 @@ func TestDefaultWatchOptions(t *testing.T) {
assert.Equal(t, defaultBufferSize, opts.BufferSize)
}
func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*notifier, *eventStore), testFn func(*testing.T, context.Context, *notifier, *eventStore)) {
func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*pollingNotifier, *eventStore), testFn func(*testing.T, context.Context, *pollingNotifier, *eventStore)) {
t.Run(storeName, func(t *testing.T) {
ctx := context.Background()
notifier, eventStore := newStoreFn(t)
@@ -62,7 +62,7 @@ func TestNotifier_lastEventResourceVersion(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
}
func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
// Test with no events
rv, err := notifier.lastEventResourceVersion(ctx)
assert.Error(t, err)
@@ -113,7 +113,7 @@ func TestNotifier_cachekey(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
}
func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
tests := []struct {
name string
event Event
@@ -167,7 +167,7 @@ func TestNotifier_Watch_NoEvents(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
}
func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
@@ -208,7 +208,7 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
}
func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
@@ -282,7 +282,7 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
}
func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
@@ -348,7 +348,7 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
}
func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithCancel(ctx)
// Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound
@@ -394,7 +394,7 @@ func TestNotifier_Watch_MultipleEvents(t *testing.T) {
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
}
func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *pollingNotifier, eventStore *eventStore) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
rv := time.Now().UnixNano()
@@ -61,7 +61,7 @@ type kvStorageBackend struct {
bulkLock *BulkLock
dataStore *dataStore
eventStore *eventStore
notifier *notifier
notifier notifier
builder DocumentBuilder
log logging.Logger
withPruner bool
@@ -91,6 +91,7 @@ type KVBackendOptions struct {
Tracer trace.Tracer // TODO add tracing
Reg prometheus.Registerer // TODO add metrics
UseChannelNotifier bool
// Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with
// unified/sql
RvManager *rvmanager.ResourceVersionManager
@@ -121,7 +122,7 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) {
bulkLock: NewBulkLock(),
dataStore: newDataStore(kv),
eventStore: eventStore,
notifier: newNotifier(eventStore, notifierOptions{}),
notifier: newNotifier(eventStore, notifierOptions{useChannelNotifier: opts.UseChannelNotifier}),
snowflake: s,
builder: StandardDocumentBuilder(), // For now we use the standard document builder.
log: &logging.NoOpLogger{}, // Make this configurable
+7 -6
View File
@@ -99,6 +99,9 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
return nil, err
}
isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"),
opts.Cfg.SectionWithEnvOverrides("resource_api"))
if opts.Cfg.EnableSQLKVBackend {
sqlkv, err := resource.NewSQLKV(eDB)
if err != nil {
@@ -106,9 +109,10 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
}
kvBackendOpts := resource.KVBackendOptions{
KvStore: sqlkv,
Tracer: opts.Tracer,
Reg: opts.Reg,
KvStore: sqlkv,
Tracer: opts.Tracer,
Reg: opts.Reg,
UseChannelNotifier: !isHA,
}
ctx := context.Background()
@@ -140,9 +144,6 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) {
serverOptions.Backend = kvBackend
serverOptions.Diagnostics = kvBackend
} else {
isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"),
opts.Cfg.SectionWithEnvOverrides("resource_api"))
backend, err := NewBackend(BackendOptions{
DBProvider: eDB,
Reg: opts.Reg,
@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestBadgerKVStorageBackend(t *testing.T) {
@@ -36,7 +37,9 @@ func TestBadgerKVStorageBackend(t *testing.T) {
})
}
func TestSQLKVStorageBackend(t *testing.T) {
func TestIntegrationSQLKVStorageBackend(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
skipTests := map[string]bool{
TestWatchWriteEvents: true,
TestList: true,
+2 -2
View File
@@ -39,7 +39,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
_, span := tracing.DefaultTracer().Start(ctx, "graphite healthcheck")
defer span.End()
graphiteReq, formData, _, _, err := s.createGraphiteRequest(ctx, healthCheckQuery, dsInfo)
graphiteReq, formData, _, err := s.createGraphiteRequest(ctx, healthCheckQuery, dsInfo)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
@@ -81,7 +81,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
}
}()
_, err = s.toDataFrames(res, healthCheckQuery.RefID, false, targetStr)
_, err = s.toDataFrames(res, healthCheckQuery.RefID, false)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
+15 -23
View File
@@ -24,16 +24,15 @@ import (
func (s *Service) RunQuery(ctx context.Context, req *backend.QueryDataRequest, dsInfo *datasourceInfo) (*backend.QueryDataResponse, error) {
emptyQueries := []string{}
graphiteQueries := map[string]struct {
req *http.Request
formData url.Values
rawTarget string
req *http.Request
formData url.Values
}{}
// FromAlert header is defined in pkg/services/ngalert/models/constants.go
fromAlert := req.Headers["FromAlert"] == "true"
result := backend.NewQueryDataResponse()
for _, query := range req.Queries {
graphiteReq, formData, emptyQuery, target, err := s.createGraphiteRequest(ctx, query, dsInfo)
graphiteReq, formData, emptyQuery, err := s.createGraphiteRequest(ctx, query, dsInfo)
if err != nil {
result.Responses[query.RefID] = backend.ErrorResponseWithErrorSource(err)
return result, nil
@@ -45,13 +44,11 @@ func (s *Service) RunQuery(ctx context.Context, req *backend.QueryDataRequest, d
}
graphiteQueries[query.RefID] = struct {
req *http.Request
formData url.Values
rawTarget string
req *http.Request
formData url.Values
}{
req: graphiteReq,
formData: formData,
rawTarget: target,
req: graphiteReq,
formData: formData,
}
}
@@ -102,7 +99,7 @@ func (s *Service) RunQuery(ctx context.Context, req *backend.QueryDataRequest, d
}
}()
queryFrames, err := s.toDataFrames(res, refId, fromAlert, graphiteReq.rawTarget)
queryFrames, err := s.toDataFrames(res, refId, fromAlert)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
@@ -150,7 +147,7 @@ func (s *Service) processQuery(query backend.DataQuery) (string, *GraphiteQuery,
return target, nil, queryJSON.IsMetricTank, nil
}
func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQuery, dsInfo *datasourceInfo) (*http.Request, url.Values, *GraphiteQuery, string, error) {
func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQuery, dsInfo *datasourceInfo) (*http.Request, url.Values, *GraphiteQuery, error) {
/*
graphite doc about from and until, with sdk we are getting absolute instead of relative time
https://graphite-api.readthedocs.io/en/latest/api.html#from-until
@@ -166,12 +163,12 @@ func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQ
target, emptyQuery, isMetricTank, err := s.processQuery(query)
if err != nil {
return nil, formData, nil, "", err
return nil, formData, nil, err
}
if emptyQuery != nil {
s.logger.Debug("Graphite", "empty query target", emptyQuery)
return nil, formData, emptyQuery, "", nil
return nil, formData, emptyQuery, nil
}
formData["target"] = []string{target}
@@ -191,23 +188,20 @@ func (s *Service) createGraphiteRequest(ctx context.Context, query backend.DataQ
QueryParams: params,
})
if err != nil {
return nil, formData, nil, "", err
return nil, formData, nil, err
}
return graphiteReq, formData, emptyQuery, target, nil
return graphiteReq, formData, emptyQuery, nil
}
func (s *Service) toDataFrames(response *http.Response, refId string, fromAlert bool, rawTarget string) (frames data.Frames, error error) {
func (s *Service) toDataFrames(response *http.Response, refId string, fromAlert bool) (frames data.Frames, error error) {
responseData, err := s.parseResponse(response)
if err != nil {
return nil, err
}
aliasRegex := regexp.MustCompile(`(alias\(|aliasByMetric|aliasByNode|aliasByTags|aliasQuery|aliasSub)`)
frames = data.Frames{}
for _, series := range responseData {
aliasMatch := aliasRegex.MatchString(rawTarget)
timeVector := make([]time.Time, 0, len(series.DataPoints))
values := make([]*float64, 0, len(series.DataPoints))
@@ -223,9 +217,7 @@ func (s *Service) toDataFrames(response *http.Response, refId string, fromAlert
tags := make(map[string]string)
for name, value := range series.Tags {
if name == "name" {
// Queries with aliases should use the target as the name
// to ensure multi-dimensional queries are distinguishable from each other
if fromAlert || aliasMatch {
if fromAlert {
value = series.Target
}
}
+11 -124
View File
@@ -182,7 +182,7 @@ func TestConvertResponses(t *testing.T) {
expectedFrames := data.Frames{expectedFrame}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, refId, false, "target")
dataFrames, err := service.toDataFrames(httpResponse, refId, false)
require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -196,7 +196,7 @@ func TestConvertResponses(t *testing.T) {
body := `
[
{
"target": "alias(target)",
"target": "aliasedTarget(target)",
"tags": { "name": "target", "fooTag": "fooValue", "barTag": "barValue", "int": 100, "float": 3.14 },
"datapoints": [[50, 1], [null, 2], [100, 3]]
}
@@ -211,13 +211,13 @@ func TestConvertResponses(t *testing.T) {
"barTag": "barValue",
"int": "100",
"float": "3.14",
"name": "alias(target)",
}, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "alias(target)"}),
"name": "target",
}, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "aliasedTarget(target)"}),
).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti})
expectedFrames := data.Frames{expectedFrame}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, refId, false, "alias(target)")
dataFrames, err := service.toDataFrames(httpResponse, refId, false)
require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -240,7 +240,7 @@ func TestConvertResponses(t *testing.T) {
expectedFrames := data.Frames{}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, refId, false, "")
dataFrames, err := service.toDataFrames(httpResponse, refId, false)
require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -281,7 +281,7 @@ func TestConvertResponses(t *testing.T) {
expectedFrames := data.Frames{expectedFrame}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, refId, false, "target")
dataFrames, err := service.toDataFrames(httpResponse, refId, false)
require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -295,7 +295,7 @@ func TestConvertResponses(t *testing.T) {
body := `
[
{
"target": "alias(target)",
"target": "aliasedTarget(target)",
"tags": { "name": "target", "fooTag": "fooValue", "barTag": "barValue", "int": 100, "float": 3.14 },
"datapoints": [[50, 1], [null, 2], [100, 3]]
}
@@ -310,13 +310,13 @@ func TestConvertResponses(t *testing.T) {
"barTag": "barValue",
"int": "100",
"float": "3.14",
"name": "alias(target)",
}, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "alias(target)"}),
"name": "aliasedTarget(target)",
}, []*float64{&a, nil, &b}).SetConfig(&data.FieldConfig{DisplayNameFromDS: "aliasedTarget(target)"}),
).SetMeta(&data.FrameMeta{Type: data.FrameTypeTimeSeriesMulti})
expectedFrames := data.Frames{expectedFrame}
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, refId, true, "alias(target)")
dataFrames, err := service.toDataFrames(httpResponse, refId, true)
require.NoError(t, err)
if !reflect.DeepEqual(expectedFrames, dataFrames) {
@@ -738,119 +738,6 @@ Error: Target not found
}
}
func TestAliasMatching(t *testing.T) {
service := &Service{
logger: backend.Logger,
}
testCases := []struct {
name string
target string
tagsName string
fromAlert bool
expectedLabelName string
}{
{
name: "alias() function sets name tag to target",
target: "alias(stats.counters.web.hits, 'Web Hits')",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "alias(stats.counters.web.hits, 'Web Hits')",
},
{
name: "aliasByNode() function sets name tag to target",
target: "aliasByNode(stats.counters.web.hits, 2)",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "aliasByNode(stats.counters.web.hits, 2)",
},
{
name: "aliasByMetric() function sets name tag to target",
target: "aliasByMetric(stats.counters.web.hits)",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "aliasByMetric(stats.counters.web.hits)",
},
{
name: "aliasByTags() function sets name tag to target",
target: "aliasByTags(stats.counters.web.hits, 'host')",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "aliasByTags(stats.counters.web.hits, 'host')",
},
{
name: "aliasSub() function sets name tag to target",
target: "aliasSub(stats.counters.web.hits, 'stats', 'metrics')",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "aliasSub(stats.counters.web.hits, 'stats', 'metrics')",
},
{
name: "aliasQuery() function sets name tag to target",
target: "aliasQuery(stats.counters.web.hits, 'SELECT name FROM hosts')",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "aliasQuery(stats.counters.web.hits, 'SELECT name FROM hosts')",
},
{
name: "no alias function keeps original name tag",
target: "stats.counters.web.hits",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "stats.counters.web.hits",
},
{
name: "fromAlert overrides name tag even without alias",
target: "stats.counters.web.hits",
tagsName: "original.name",
fromAlert: true,
expectedLabelName: "stats.counters.web.hits",
},
{
name: "nested alias function matches",
target: "sumSeries(alias(stats.counters.*.hits, 'Hits'))",
tagsName: "stats.counters.web.hits",
fromAlert: false,
expectedLabelName: "sumSeries(alias(stats.counters.*.hits, 'Hits'))",
},
{
name: "alias in metric path should not match",
target: "stats.alias.web.hits",
tagsName: "stats.alias.web.hits",
fromAlert: false,
expectedLabelName: "stats.alias.web.hits",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
body := fmt.Sprintf(`[
{
"target": %q,
"tags": { "name": %q, "host": "server1" },
"datapoints": [[100, 1609459200]]
}
]`, tc.target, tc.tagsName)
httpResponse := &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}
dataFrames, err := service.toDataFrames(httpResponse, "A", tc.fromAlert, tc.target)
require.NoError(t, err)
require.Len(t, dataFrames, 1)
frame := dataFrames[0]
require.GreaterOrEqual(t, len(frame.Fields), 2)
valueField := frame.Fields[1]
require.NotNil(t, valueField.Labels)
actualName, ok := valueField.Labels["name"]
require.True(t, ok, "name label should exist")
assert.Equal(t, tc.expectedLabelName, actualName, "name label should match expected value")
})
}
}
func TestParseGraphiteError(t *testing.T) {
tests := []struct {
name string
@@ -32,26 +32,27 @@ export function RadialBarPanel({
return (
<RadialGauge
values={[value]}
width={width}
height={height}
alignmentFactors={valueProps.alignmentFactors}
barWidthFactor={options.barWidthFactor}
gradient={options.effects?.gradient}
endpointMarker={options.endpointMarker !== 'none' ? options.endpointMarker : undefined}
glowBar={options.effects?.barGlow}
glowCenter={options.effects?.centerGlow}
gradient={options.effects?.gradient}
height={height}
nameManualFontSize={options.text?.titleSize}
neutral={options.neutral}
onClick={menuProps.openMenu}
roundedBars={options.barShape === 'rounded'}
vizCount={valueProps.count}
shape={options.shape}
segmentCount={options.segmentCount}
segmentSpacing={options.segmentSpacing}
thresholdsBar={options.showThresholdMarkers}
shape={options.shape}
showScaleLabels={options.showThresholdLabels}
alignmentFactors={valueProps.alignmentFactors}
valueManualFontSize={options.text?.valueSize}
nameManualFontSize={options.text?.titleSize}
endpointMarker={options.endpointMarker !== 'none' ? options.endpointMarker : undefined}
onClick={menuProps.openMenu}
textMode={options.textMode}
thresholdsBar={options.showThresholdMarkers}
valueManualFontSize={options.text?.valueSize}
values={[value]}
vizCount={valueProps.count}
width={width}
/>
);
}
@@ -161,6 +161,17 @@ export const plugin = new PanelPlugin<Options>(RadialBarPanel)
defaultValue: defaultOptions.textMode,
});
builder.addNumberInput({
path: 'neutral',
name: t('radialbar.config.neutral.title', 'Neutral value'),
description: t('radialbar.config.neutral.description', 'Leave empty to use Min as neutral point'),
category,
settings: {
placeholder: t('radialbar.config.neutral.placeholder', 'none'),
step: 1,
},
});
builder.addBooleanSwitch({
path: 'sparkline',
name: t('radialbar.config.sparkline', 'Show sparkline'),
@@ -42,7 +42,8 @@ composableKinds: PanelCfg: {
barWidthFactor: number | *0.5
barShape: "flat" | "rounded" | *"flat"
endpointMarker?: "point" | "glow" | "none" | *"point"
textMode?: "auto" | "value_and_name" | "value" | "name" | "none" | *"auto"
textMode?: "auto" | "value_and_name" | "value" | "name" | "none" | *"auto"
neutral?: number
effects: GaugePanelEffects | *{}
sizing: common.BarGaugeSizing & (*"auto" | _)
minVizWidth: uint32 | *75
+1
View File
@@ -29,6 +29,7 @@ export interface Options extends common.SingleStatBaseOptions {
endpointMarker?: ('point' | 'glow' | 'none');
minVizHeight: number;
minVizWidth: number;
neutral?: number;
segmentCount: number;
segmentSpacing: number;
shape: ('circle' | 'gauge');
+5
View File
@@ -12572,6 +12572,11 @@
"endpoint-marker-glow": "Glow",
"endpoint-marker-none": "None",
"endpoint-marker-point": "Point",
"neutral": {
"description": "Leave empty to use Min as neutral point",
"placeholder": "none",
"title": "Neutral value"
},
"segment-count": "Segments",
"segment-spacing": "Segment spacing",
"shape": "Style",