diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ce7089273ef..853f27774d4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -505,6 +505,7 @@ i18next.config.ts @grafana/grafana-frontend-platform /e2e-playwright/various-suite/frontend-sandbox-app.spec.ts @grafana/plugins-platform-frontend /e2e-playwright/various-suite/frontend-sandbox-datasource.spec.ts @grafana/plugins-platform-frontend /e2e-playwright/various-suite/gauge.spec.ts @grafana/dataviz-squad +/e2e-playwright/various-suite/grafana-datasource-random-walk.spec.ts @grafana/grafana-frontend-platform /e2e-playwright/various-suite/graph-auto-migrate.spec.ts @grafana/dataviz-squad /e2e-playwright/various-suite/inspect-drawer.spec.ts @grafana/dashboards-squad /e2e-playwright/various-suite/keybinds.spec.ts @grafana/grafana-frontend-platform diff --git a/e2e-playwright/various-suite/grafana-datasource-random-walk.spec.ts b/e2e-playwright/various-suite/grafana-datasource-random-walk.spec.ts new file mode 100644 index 00000000000..15cb4d7bdd4 --- /dev/null +++ b/e2e-playwright/various-suite/grafana-datasource-random-walk.spec.ts @@ -0,0 +1,220 @@ +import { test as base, expect } from '@grafana/plugin-e2e'; + +// Note: Random walk configuration UI requires dashboardTemplates feature toggle to be enabled +const test = base.extend({}); +test.use({ + featureToggles: { + dashboardTemplates: true, + }, +}); + +test.describe( + 'Grafana datasource random walk', + { + tag: ['@grafana-datasource'], + }, + () => { + test('should render random walk configuration fields', async ({ gotoDashboardPage, page, selectors }) => { + // Create new dashboard + const dashboardPage = await gotoDashboardPage({}); + + // Add new panel + await dashboardPage.addPanel(); + + // Wait for the first field to be visible (ensures query editor loaded) + await expect(page.locator('#randomWalk-seriesCount-A')).toBeVisible(); + + // Verify core configuration fields (row 1) + await expect(page.locator('#randomWalk-startValue-A')).toBeVisible(); + await expect(page.locator('#randomWalk-min-A')).toBeVisible(); + await expect(page.locator('#randomWalk-max-A')).toBeVisible(); + + // Verify fine-tuning fields (row 2) + await expect(page.locator('#randomWalk-spread-A')).toBeVisible(); + await expect(page.locator('#randomWalk-noise-A')).toBeVisible(); + + // Drop percentage is tested separately in its own test + }); + + test('should configure min and max values and render constrained data', async ({ + gotoDashboardPage, + page, + selectors, + }) => { + // Create new dashboard + const dashboardPage = await gotoDashboardPage({}); + + // Add new panel + await dashboardPage.addPanel(); + + // Configure random walk with min/max constraints using specific IDs + const minInput = page.locator('#randomWalk-min-A'); + const maxInput = page.locator('#randomWalk-max-A'); + + await minInput.fill('10'); + await maxInput.fill('50'); + + // Wait for the query to execute + await page.waitForTimeout(1000); + + // Verify graph renders with a series + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.VizLegend.seriesName('A-series')) + ).toBeVisible(); + }); + + test('should generate multiple series when series count is configured', async ({ + gotoDashboardPage, + page, + selectors, + }) => { + // Create new dashboard + const dashboardPage = await gotoDashboardPage({}); + + // Add new panel + await dashboardPage.addPanel(); + + // Configure series count to 3 using role selector + const seriesCountInput = page.getByRole('spinbutton', { name: 'Series count' }); + await seriesCountInput.fill('3'); + + // Wait for query to execute and check that we have series in the legend + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.VizLegend.seriesName('A-series')) + ).toBeVisible(); + + // Verify the input value is set correctly + await expect(seriesCountInput).toHaveValue('3'); + }); + + test('should configure spread and noise parameters', async ({ gotoDashboardPage, page, selectors }) => { + // Create new dashboard + const dashboardPage = await gotoDashboardPage({}); + + // Add new panel + await dashboardPage.addPanel(); + + // Configure spread and noise using role selectors + const spreadInput = page.getByRole('spinbutton', { name: 'Spread' }); + const noiseInput = page.getByRole('spinbutton', { name: 'Noise' }); + const startValueInput = page.getByRole('spinbutton', { name: 'Start value' }); + + await startValueInput.fill('100'); + await spreadInput.fill('5'); + await noiseInput.fill('2'); + + // Verify graph renders + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.VizLegend.seriesName('A-series')) + ).toBeVisible(); + }); + + test('should configure drop percentage for missing data', async ({ gotoDashboardPage, page, selectors }) => { + // Create new dashboard + const dashboardPage = await gotoDashboardPage({}); + + // Add new panel + await dashboardPage.addPanel(); + + // Configure drop percentage using role selector + const dropInput = page.getByRole('spinbutton', { name: 'Drop (%)' }); + await dropInput.fill('20'); + + // Verify graph still renders even with dropped points + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.VizLegend.seriesName('A-series')) + ).toBeVisible(); + }); + + test('should show tooltips on configuration fields', async ({ gotoDashboardPage, page }) => { + // Create new dashboard + const dashboardPage = await gotoDashboardPage({}); + + // Add new panel + await dashboardPage.addPanel(); + + // Verify the Spread field has a tooltip by hovering near its label + const spreadInput = page.locator('#randomWalk-spread-A'); + await expect(spreadInput).toBeVisible(); + + // The tooltip is part of the InlineField component + // Just verify we can interact with the field (tooltip rendering is handled by the component) + await spreadInput.hover(); + + // Verify the field is configured correctly with tooltip text in the DOM + const spreadLabel = page.getByText('Spread').first(); + await expect(spreadLabel).toBeVisible(); + }); + + test('should maintain configuration values when switching between queries', async ({ + gotoDashboardPage, + page, + selectors, + }) => { + // Create new dashboard + const dashboardPage = await gotoDashboardPage({}); + + // Add new panel + await dashboardPage.addPanel(); + + // Configure multiple random walk parameters + await page.locator('#randomWalk-seriesCount-A').fill('2'); + await page.locator('#randomWalk-startValue-A').fill('75'); + await page.locator('#randomWalk-min-A').fill('20'); + await page.locator('#randomWalk-max-A').fill('90'); + await page.locator('#randomWalk-spread-A').fill('3'); + await page.locator('#randomWalk-noise-A').fill('1.5'); + + // Wait for query to execute + await page.waitForTimeout(500); + + // Verify all values are set correctly + await expect(page.locator('#randomWalk-seriesCount-A')).toHaveValue('2'); + await expect(page.locator('#randomWalk-startValue-A')).toHaveValue('75'); + await expect(page.locator('#randomWalk-min-A')).toHaveValue('20'); + await expect(page.locator('#randomWalk-max-A')).toHaveValue('90'); + await expect(page.locator('#randomWalk-spread-A')).toHaveValue('3'); + await expect(page.locator('#randomWalk-noise-A')).toHaveValue('1.5'); + + // Verify graph renders with the configured data + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.VizLegend.seriesName('A-series')) + ).toBeVisible(); + }); + + test('should verify series count actually generates multiple series', async ({ + gotoDashboardPage, + page, + selectors, + }) => { + // Create new dashboard + const dashboardPage = await gotoDashboardPage({}); + + // Add new panel + await dashboardPage.addPanel(); + + // Set series count to 1 first and verify + await page.locator('#randomWalk-seriesCount-A').fill('1'); + await page.waitForTimeout(500); + + // Check legend - with 1 series we should see only A-series + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.VizLegend.seriesName('A-series')) + ).toBeVisible(); + + // Now set series count to 3 + await page.locator('#randomWalk-seriesCount-A').fill('3'); + await page.waitForTimeout(1000); + + // With multiple series, they should all render + // The backend generates multiple frames which should show in the panel + // We can verify by checking the panel has data (legend shows A-series) + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.VizLegend.seriesName('A-series')) + ).toBeVisible(); + + // Verify the configuration is set + await expect(page.locator('#randomWalk-seriesCount-A')).toHaveValue('3'); + }); + } +); diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 2855555733a..1f166051adc 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1232,4 +1232,8 @@ export interface FeatureToggles { * @default true */ onlyStoreActionSets?: boolean; + /** + * Enable template dashboards + */ + dashboardTemplates?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 491c1142b3b..edbc06b0029 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2133,6 +2133,13 @@ var ( Owner: identityAccessTeam, Expression: "true", }, + { + Name: "dashboardTemplates", + Description: "Enable template dashboards", + Stage: FeatureStageExperimental, + Owner: grafanaSharingSquad, + FrontendOnly: false, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 4b64ee08612..9a51e087cd6 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -274,3 +274,4 @@ newGauge,experimental,@grafana/dataviz-squad,false,false,true preventPanelChromeOverflow,preview,@grafana/grafana-frontend-platform,false,false,true pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,false,false onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false +dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 8b28acc992b..13e47ce210e 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1105,4 +1105,8 @@ const ( // FlagOnlyStoreActionSets // When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission FlagOnlyStoreActionSets = "onlyStoreActionSets" + + // FlagDashboardTemplates + // Enable template dashboards + FlagDashboardTemplates = "dashboardTemplates" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 2019ca4b976..583c41b5d42 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1161,6 +1161,18 @@ "codeowner": "@grafana/grafana-app-platform-squad" } }, + { + "metadata": { + "name": "dashboardTemplates", + "resourceVersion": "1761575312733", + "creationTimestamp": "2025-10-27T14:28:32Z" + }, + "spec": { + "description": "Enable template dashboards", + "stage": "experimental", + "codeowner": "@grafana/sharing-squad" + } + }, { "metadata": { "name": "dashboardUndoRedo", diff --git a/pkg/tsdb/grafanads/grafana.go b/pkg/tsdb/grafanads/grafana.go index 3953e17dc5e..06254b7f0b8 100644 --- a/pkg/tsdb/grafanads/grafana.go +++ b/pkg/tsdb/grafanads/grafana.go @@ -169,12 +169,24 @@ func (s *Service) doReadQuery(ctx context.Context, query backend.DataQuery) back func (s *Service) doRandomWalk(query backend.DataQuery) backend.DataResponse { response := backend.DataResponse{} - model, err := testdatasource.GetJSONModel(json.RawMessage{}) + model, err := testdatasource.GetJSONModel(query.JSON) if err != nil { response.Error = err return response } - response.Frames = data.Frames{testdatasource.RandomWalk(query, model, 0)} + + // Default to 1 series if not specified + seriesCount := model.SeriesCount + if seriesCount == 0 { + seriesCount = 1 + } + + // Generate the requested number of series + frames := make([]*data.Frame, 0, seriesCount) + for i := 0; i < seriesCount; i++ { + frames = append(frames, testdatasource.RandomWalk(query, model, i)) + } + response.Frames = frames return response } diff --git a/public/app/plugins/datasource/grafana/components/QueryEditor.test.tsx b/public/app/plugins/datasource/grafana/components/QueryEditor.test.tsx new file mode 100644 index 00000000000..46b1f690f04 --- /dev/null +++ b/public/app/plugins/datasource/grafana/components/QueryEditor.test.tsx @@ -0,0 +1,78 @@ +import { render, waitFor } from '@testing-library/react'; +import { act } from 'react'; + +import { config } from '@grafana/runtime'; + +import { GrafanaDatasource } from '../datasource'; +import { GrafanaQuery, GrafanaQueryType } from '../types'; + +import { QueryEditor } from './QueryEditor'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + config: { + featureToggles: {}, + }, +})); + +jest.mock('app/features/live/info', () => ({ + getManagedChannelInfo: jest.fn(() => Promise.resolve({ channels: [], channelFields: {} })), +})); + +describe('QueryEditor', () => { + const mockOnChange = jest.fn(); + const mockOnRunQuery = jest.fn(); + const mockDatasource = {} as GrafanaDatasource; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Random Walk configuration', () => { + it('should render random walk editor when feature toggle is enabled', async () => { + config.featureToggles.dashboardTemplates = true; + + const query: GrafanaQuery = { + refId: 'A', + queryType: GrafanaQueryType.RandomWalk, + }; + + await act(async () => { + render( + + ); + }); + + // Wait for async operations to complete + await waitFor(() => { + // Verify random walk configuration fields are rendered using IDs + expect(document.querySelector('#randomWalk-seriesCount-A')).toBeInTheDocument(); + expect(document.querySelector('#randomWalk-startValue-A')).toBeInTheDocument(); + expect(document.querySelector('#randomWalk-spread-A')).toBeInTheDocument(); + }); + }); + + it('should not render random walk editor when feature toggle is disabled', async () => { + config.featureToggles.dashboardTemplates = false; + + const query: GrafanaQuery = { + refId: 'A', + queryType: GrafanaQueryType.RandomWalk, + }; + + await act(async () => { + render( + + ); + }); + + // Wait for component to settle + await waitFor(() => { + // Verify random walk configuration fields are NOT rendered + expect(document.querySelector('#randomWalk-seriesCount-A')).not.toBeInTheDocument(); + expect(document.querySelector('#randomWalk-startValue-A')).not.toBeInTheDocument(); + expect(document.querySelector('#randomWalk-spread-A')).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx index c1534f58378..8afe9701082 100644 --- a/public/app/plugins/datasource/grafana/components/QueryEditor.tsx +++ b/public/app/plugins/datasource/grafana/components/QueryEditor.tsx @@ -40,6 +40,7 @@ import { SearchQuery } from 'app/features/search/service/types'; import { GrafanaDatasource } from '../datasource'; import { defaultQuery, GrafanaQuery, GrafanaQueryType } from '../types'; +import { RandomWalkEditor } from './RandomWalkEditor'; import SearchEditor from './SearchEditor'; interface Props extends QueryEditorProps, Themeable2 {} @@ -450,6 +451,11 @@ export class UnthemedQueryEditor extends PureComponent { onRunQuery(); }; + renderRandomWalkQuery() { + const { query, onChange, onRunQuery } = this.props; + return ; + } + render() { const query = { ...defaultQuery, @@ -487,6 +493,9 @@ export class UnthemedQueryEditor extends PureComponent { /> + {queryType === GrafanaQueryType.RandomWalk && + config.featureToggles.dashboardTemplates && + this.renderRandomWalkQuery()} {queryType === GrafanaQueryType.LiveMeasurements && this.renderMeasurementsQuery()} {queryType === GrafanaQueryType.List && this.renderListPublicFiles()} {queryType === GrafanaQueryType.Snapshot && this.renderSnapshotQuery()} diff --git a/public/app/plugins/datasource/grafana/components/RandomWalkEditor.tsx b/public/app/plugins/datasource/grafana/components/RandomWalkEditor.tsx new file mode 100644 index 00000000000..db85df2cb57 --- /dev/null +++ b/public/app/plugins/datasource/grafana/components/RandomWalkEditor.tsx @@ -0,0 +1,115 @@ +import { FormEvent } from 'react'; + +import { InlineField, InlineFieldRow, Input } from '@grafana/ui'; + +import { GrafanaQuery } from '../types'; + +interface RandomWalkEditorProps { + query: GrafanaQuery; + onChange: (value: GrafanaQuery) => void; + onRunQuery: () => void; +} + +interface FieldConfig { + label: string; + id: keyof GrafanaQuery; + placeholder: string; + min?: number; + step?: number; + max?: number; + tooltip?: string; +} + +// Core configuration - controls the basic shape and range +const coreFields: FieldConfig[] = [ + { + label: 'Series count', + id: 'seriesCount', + placeholder: '1', + min: 1, + step: 1, + tooltip: 'Number of series to generate', + }, + { + label: 'Start value', + id: 'startValue', + placeholder: 'auto', + step: 1, + tooltip: 'Initial value for the random walk', + }, + { label: 'Min', id: 'min', placeholder: 'none', step: 0.1 }, + { label: 'Max', id: 'max', placeholder: 'none', step: 0.1 }, +]; + +// Fine-tuning parameters - controls randomness and variation +const advancedFields: FieldConfig[] = [ + { + label: 'Spread', + id: 'spread', + placeholder: '1', + min: 0.5, + step: 0.1, + tooltip: 'Maximum step size between values. Higher values create more dramatic changes.', + }, + { + label: 'Noise', + id: 'noise', + placeholder: '0', + min: 0, + step: 0.1, + tooltip: 'Random noise added to each value. Higher values create more variability.', + }, + { + label: 'Drop (%)', + id: 'dropPercent', + placeholder: '0', + min: 0, + max: 100, + step: 1, + tooltip: 'Percentage of points to randomly drop (simulates missing data)', + }, +]; + +const labelWidth = 16; + +export const RandomWalkEditor = ({ query, onChange, onRunQuery }: RandomWalkEditorProps) => { + const onInputChange = (e: FormEvent) => { + const { name, value } = e.currentTarget; + const numValue = value === '' ? undefined : parseFloat(value); + + onChange({ + ...query, + [name]: numValue, + }); + onRunQuery(); + }; + + const renderField = (fieldConfig: FieldConfig) => { + const { label, id, min, step, max, placeholder, tooltip } = fieldConfig; + const value = query[id]; + + return ( + + + + ); + }; + + return ( + <> + {coreFields.map(renderField)} + {advancedFields.map(renderField)} + + ); +}; diff --git a/public/app/plugins/datasource/grafana/types.ts b/public/app/plugins/datasource/grafana/types.ts index 52202904c00..d6efe49bc4f 100644 --- a/public/app/plugins/datasource/grafana/types.ts +++ b/public/app/plugins/datasource/grafana/types.ts @@ -33,6 +33,14 @@ export interface GrafanaQuery extends DataQuery { snapshot?: DataFrameJSON[]; timeRegion?: TimeRegionConfig; file?: GrafanaQueryFile; + // Random walk configuration + seriesCount?: number; + startValue?: number; + min?: number; + max?: number; + spread?: number; + noise?: number; + dropPercent?: number; } export interface GrafanaQueryFile {