Compare commits

...
16 changed files with 603 additions and 126 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');
@@ -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),
})
}
+329 -33
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(),
}
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
+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",