Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c4ade9514 | ||
|
|
72f7bd3900 | ||
|
|
e60fb6b1d3 | ||
|
|
2992964f32 | ||
|
|
31a806281e | ||
|
|
d0178cc95d | ||
|
|
8aa4f518d7 |
Generated
+1
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 user’s 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 {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
return ctx, recorder
|
||||
}
|
||||
|
||||
CreateDashboardSnapshot(ctx, cfg, cmd, mockService)
|
||||
// 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)
|
||||
|
||||
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"])
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user