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 {