Repository: grafana/grafana
Pull request #103348: Dynamic Dashboards: Add repeats for tabs
Author: login: bfmatei
Labels: name: area/dashboard
name: area/backend
name: area/frontend
name: no-backport
description: Skip backport of PR
name: no-changelog
description: Skip including change in changelog/release notes
name: team/grafana-dashboards
description: Dashboards squad
Created: 2025-04-03T10:33:56Z | Merged: 2025-04-11T10:50:45Z

Add repeat functionality for tabs.

Took some time to address some other issues as well:
- maintain repeats when changing from tabs to rows or rows to tabs
- maintain conditional rendering when changing from tabs to rows or rows to tabs
- fixed an issue where repeated rows were persisted when saving a dashboard
- fixed an issue where repeated rows were persisted when changing between layouts


Diff:
diff --git a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue
index 5b52aef5a6ef5..7ac0428493b92 100644
--- a/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue
+++ b/apps/dashboard/kinds/v2alpha1/dashboard_spec.cue
@@ -495,6 +495,11 @@ RowRepeatOptions: {
 	value: string
 }
 
+TabRepeatOptions: {
+	mode:  RepeatMode
+	value: string
+}
+
 AutoGridRepeatOptions: {
 	mode:  RepeatMode
 	value: string
@@ -523,8 +528,8 @@ GridLayoutRowSpec: {
 	y:         int
 	collapsed: bool
 	title:     string
-	elements: [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard.
-	repeat?:                          RowRepeatOptions
+	elements:  [...GridLayoutItemKind] // Grid items in the row will have their Y value be relative to the rows Y value. This means a panel positioned at Y: 0 in a row with Y: 10 will be positioned at Y: 11 (row header has a heigh of 1) in the dashboard.
+	repeat?:   RowRepeatOptions
 }
 
 GridLayoutSpec: {
@@ -604,6 +609,7 @@ TabsLayoutTabSpec: {
 	title?:                string
 	layout:                GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
 	conditionalRendering?: ConditionalRenderingGroupKind
+	repeat?:               TabRepeatOptions
 }
 
 PanelSpec: {
diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go
index 6b5511a751d61..f104c3fdf4504 100644
--- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go
+++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/dashboard_spec_gen.go
@@ -1069,6 +1069,7 @@ type DashboardTabsLayoutTabSpec struct {
 	Title                *string                                                                     `json:"title,omitempty"`
 	Layout               DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind `json:"layout"`
 	ConditionalRendering *DashboardConditionalRenderingGroupKind                                     `json:"conditionalRendering,omitempty"`
+	Repeat               *DashboardTabRepeatOptions                                                  `json:"repeat,omitempty"`
 }
 
 // NewDashboardTabsLayoutTabSpec creates a new DashboardTabsLayoutTabSpec object.
@@ -1078,6 +1079,17 @@ func NewDashboardTabsLayoutTabSpec() *DashboardTabsLayoutTabSpec {
 	}
 }
 
+// +k8s:openapi-gen=true
+type DashboardTabRepeatOptions struct {
+	Mode  string `json:"mode"`
+	Value string `json:"value"`
+}
+
+// NewDashboardTabRepeatOptions creates a new DashboardTabRepeatOptions object.
+func NewDashboardTabRepeatOptions() *DashboardTabRepeatOptions {
+	return &DashboardTabRepeatOptions{}
+}
+
 // Links with references to other dashboards or external resources
 // +k8s:openapi-gen=true
 type DashboardDashboardLink struct {
diff --git a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go
index 0bed9c70b5c51..e2f3b302f997a 100644
--- a/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go
+++ b/apps/dashboard/pkg/apis/dashboard/v2alpha1/zz_generated.openapi.go
@@ -100,6 +100,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
 		"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStatus":                                                                                                                                                              schema_pkg_apis_dashboard_v2alpha1_DashboardStatus(ref),
 		"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrArrayOfString":                                                                                                                                               schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrArrayOfString(ref),
 		"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrFloat64":                                                                                                                                                     schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref),
+		"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions":                                                                                                                                                    schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref),
 		"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutKind":                                                                                                                                                      schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref),
 		"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutSpec":                                                                                                                                                      schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutSpec(ref),
 		"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabsLayoutTabKind":                                                                                                                                                   schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabKind(ref),
@@ -3921,6 +3922,33 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardStringOrFloat64(ref common.Refe
 	}
 }
 
+func schema_pkg_apis_dashboard_v2alpha1_DashboardTabRepeatOptions(ref common.ReferenceCallback) common.OpenAPIDefinition {
+	return common.OpenAPIDefinition{
+		Schema: spec.Schema{
+			SchemaProps: spec.SchemaProps{
+				Type: []string{"object"},
+				Properties: map[string]spec.Schema{
+					"mode": {
+						SchemaProps: spec.SchemaProps{
+							Default: "",
+							Type:    []string{"string"},
+							Format:  "",
+						},
+					},
+					"value": {
+						SchemaProps: spec.SchemaProps{
+							Default: "",
+							Type:    []string{"string"},
+							Format:  "",
+						},
+					},
+				},
+				Required: []string{"mode", "value"},
+			},
+		},
+	}
+}
+
 func schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutKind(ref common.ReferenceCallback) common.OpenAPIDefinition {
 	return common.OpenAPIDefinition{
 		Schema: spec.Schema{
@@ -4027,12 +4055,17 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardTabsLayoutTabSpec(ref common.Re
 							Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind"),
 						},
 					},
+					"repeat": {
+						SchemaProps: spec.SchemaProps{
+							Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions"),
+						},
+					},
 				},
 				Required: []string{"layout"},
 			},
 		},
 		Dependencies: []string{
-			"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind"},
+			"github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardConditionalRenderingGroupKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind", "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardTabRepeatOptions"},
 	}
 }
 
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
index 41fd55839ac9d..4e15cffeb3dfc 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/dashboard.schema.cue
@@ -495,6 +495,11 @@ RowRepeatOptions: {
   value: string
 }
 
+TabRepeatOptions: {
+  mode: RepeatMode,
+  value: string
+}
+
 AutoGridRepeatOptions: {
   mode: RepeatMode
   value: string
@@ -603,6 +608,7 @@ TabsLayoutTabKind: {
 TabsLayoutTabSpec: {
   title?: string
   layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind
+  repeat?: TabRepeatOptions
   conditionalRendering?: ConditionalRenderingGroupKind
 }
 
@@ -962,4 +968,4 @@ ConditionalRenderingTimeRangeSizeKind: {
 
 ConditionalRenderingTimeRangeSizeSpec: {
   value: string
-}
\ No newline at end of file
+}
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
index a53da1e3aadd4..3106f1b6f8f0f 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha0/types.gen.ts
@@ -327,7 +327,7 @@ export interface FieldConfig {
 	description?: string;
 	// An explicit path to the field in the datasource.  When the frame meta includes a path,
 	// This will default to `${frame.meta.path}/${field.name}
-	// 
+	//
 	// When defined, this value can be used as an identifier within the datasource scope, and
 	// may be used to update the results
 	path?: string;
@@ -916,6 +916,7 @@ export const defaultTabsLayoutTabKind = (): TabsLayoutTabKind => ({
 export interface TabsLayoutTabSpec {
 	title?: string;
 	layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
+	repeat?: TabRepeatOptions;
 	conditionalRendering?: ConditionalRenderingGroupKind;
 }
 
@@ -923,6 +924,16 @@ export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
 	layout: defaultGridLayoutKind(),
 });
 
+export interface TabRepeatOptions {
+	mode: "variable";
+	value: string;
+}
+
+export const defaultTabRepeatOptions = (): TabRepeatOptions => ({
+	mode: RepeatMode,
+	value: "",
+});
+
 // Links with references to other dashboards or external resources
 export interface DashboardLink {
 	// Title to display with the link
@@ -1492,4 +1503,3 @@ export const defaultVariableValueOption = (): VariableValueOption => ({
 	label: "",
 	value: defaultVariableValueSingle(),
 });
-
diff --git a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
index fb3b3b17e3347..1a4fad82b968d 100644
--- a/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
+++ b/packages/grafana-schema/src/schema/dashboard/v2alpha1/types.spec.gen.ts
@@ -282,7 +282,7 @@ export interface FieldConfig {
 	description?: string;
 	// An explicit path to the field in the datasource.  When the frame meta includes a path,
 	// This will default to `${frame.meta.path}/${field.name}
-	// 
+	//
 	// When defined, this value can be used as an identifier within the datasource scope, and
 	// may be used to update the results
 	path?: string;
@@ -872,12 +872,23 @@ export interface TabsLayoutTabSpec {
 	title?: string;
 	layout: GridLayoutKind | RowsLayoutKind | AutoGridLayoutKind | TabsLayoutKind;
 	conditionalRendering?: ConditionalRenderingGroupKind;
+	repeat?: TabRepeatOptions;
 }
 
 export const defaultTabsLayoutTabSpec = (): TabsLayoutTabSpec => ({
 	layout: defaultGridLayoutKind(),
 });
 
+export interface TabRepeatOptions {
+	mode: "variable";
+	value: string;
+}
+
+export const defaultTabRepeatOptions = (): TabRepeatOptions => ({
+	mode: RepeatMode,
+	value: "",
+});
+
 // Links with references to other dashboards or external resources
 export interface DashboardLink {
 	// Title to display with the link
@@ -1402,4 +1413,3 @@ export const defaultSpec = (): Spec => ({
 	title: "",
 	variables: [],
 });
-
diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json
index a179f013d4daa..556cc08992854 100644
--- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json
+++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v2alpha1.json
@@ -3445,6 +3445,23 @@
           }
         }
       },
+      "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTabRepeatOptions": {
+        "type": "object",
+        "required": [
+          "mode",
+          "value"
+        ],
+        "properties": {
+          "mode": {
+            "type": "string",
+            "default": ""
+          },
+          "value": {
+            "type": "string",
+            "default": ""
+          }
+        }
+      },
       "com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTabsLayoutKind": {
         "type": "object",
         "required": [
@@ -3518,6 +3535,9 @@
           "layout": {
             "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardGridLayoutKindOrRowsLayoutKindOrAutoGridLayoutKindOrTabsLayoutKind"
           },
+          "repeat": {
+            "$ref": "#/components/schemas/com.github.grafana.grafana.apps.dashboard.pkg.apis.dashboard.v2alpha1.DashboardTabRepeatOptions"
+          },
           "title": {
             "type": "string"
           }
diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
index ad82737cb81cf..5371a126ff536 100644
--- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
@@ -1,4 +1,11 @@
-import { SceneGridItemLike, SceneGridRow, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes';
+import {
+  sceneGraph,
+  SceneGridItemLike,
+  SceneGridRow,
+  SceneObjectBase,
+  SceneObjectState,
+  VizPanel,
+} from '@grafana/scenes';
 import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
 import { t } from 'app/core/internationalization';
 
@@ -8,12 +15,13 @@ import {
   ObjectsReorderedOnCanvasEvent,
 } from '../../edit-pane/shared';
 import { serializeRowsLayout } from '../../serialization/layoutSerializers/RowsLayoutSerializer';
-import { isClonedKey } from '../../utils/clone';
+import { isClonedKey, joinCloneKeys } from '../../utils/clone';
 import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
 import { getDashboardSceneFor } from '../../utils/utils';
 import { DashboardGridItem } from '../layout-default/DashboardGridItem';
 import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
 import { RowRepeaterBehavior } from '../layout-default/RowRepeaterBehavior';
+import { TabItemRepeaterBehavior } from '../layout-tabs/TabItemRepeaterBehavior';
 import { TabsLayoutManager } from '../layout-tabs/TabsLayoutManager';
 import { getRowFromClipboard } from '../layouts-shared/paste';
 import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils';
@@ -81,7 +89,16 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
   }
 
   public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
-    throw new Error('Method not implemented.');
+    return this.clone({
+      rows: this.state.rows.map((row) => {
+        const key = joinCloneKeys(ancestorKey, row.state.key!);
+
+        return row.clone({
+          key,
+          layout: row.state.layout.cloneLayout(key, isSource),
+        });
+      }),
+    });
   }
 
   public duplicate(): DashboardLayoutManager {
@@ -179,7 +196,21 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
 
     if (layout instanceof TabsLayoutManager) {
       for (const tab of layout.state.tabs) {
-        rows.push(new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title }));
+        if (isClonedKey(tab.state.key!)) {
+          continue;
+        }
+
+        const conditionalRendering = tab.state.conditionalRendering;
+        conditionalRendering?.clearParent();
+
+        const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior);
+        const $behaviors = !behavior
+          ? undefined
+          : [new RowItemRepeaterBehavior({ variableName: behavior.state.variableName })];
+
+        rows.push(
+          new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title, conditionalRendering, $behaviors })
+        );
       }
     } else if (layout instanceof DefaultGridLayoutManager) {
       const config: Array<{
@@ -253,7 +284,7 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
     const duplicateTitles = new Set<string | undefined>();
 
     this.state.rows.forEach((row) => {
-      const title = row.state.title;
+      const title = sceneGraph.interpolate(row, row.state.title);
       const count = (titleCounts.get(title) ?? 0) + 1;
       titleCounts.set(title, count);
       if (count > 1 && title) {
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
index b42705614e639..931790b944350 100644
--- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx
@@ -33,6 +33,7 @@ import { LayoutParent } from '../types/LayoutParent';
 
 import { getEditOptions } from './TabItemEditor';
 import { TabItemRenderer } from './TabItemRenderer';
+import { TabItemRepeaterBehavior } from './TabItemRepeaterBehavior';
 import { TabItems } from './TabItems';
 import { TabsLayoutManager } from './TabsLayoutManager';
 
@@ -175,6 +176,23 @@ export class TabItem
     this.setState({ title, isNew: false });
   }
 
+  public onChangeRepeat(repeat: string | undefined) {
+    let repeatBehavior = this._getRepeatBehavior();
+
+    if (repeat) {
+      // Remove repeat behavior if it exists to trigger repeat when adding new one
+      if (repeatBehavior) {
+        repeatBehavior.removeBehavior();
+      }
+
+      repeatBehavior = new TabItemRepeaterBehavior({ variableName: repeat });
+      this.setState({ $behaviors: [...(this.state.$behaviors ?? []), repeatBehavior] });
+      repeatBehavior.activate();
+    } else {
+      repeatBehavior?.removeBehavior();
+    }
+  }
+
   public setIsDropTarget(isDropTarget: boolean) {
     if (!!this.state.isDropTarget !== isDropTarget) {
       this.setState({ isDropTarget });
@@ -198,6 +216,10 @@ export class TabItem
     }
   }
 
+  public getRepeatVariable(): string | undefined {
+    return this._getRepeatBehavior()?.state.variableName;
+  }
+
   public getParentLayout(): TabsLayoutManager {
     return sceneGraph.getAncestor(this, TabsLayoutManager);
   }
@@ -216,4 +238,8 @@ export class TabItem
     const duplicateTitles = parentLayout.duplicateTitles();
     return !duplicateTitles.has(this.state.title);
   }
+
+  private _getRepeatBehavior(): TabItemRepeaterBehavior | undefined {
+    return this.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior);
+  }
 }
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx
index 35d864a3b3805..31d7401ace457 100644
--- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx
@@ -1,11 +1,16 @@
 import { useMemo } from 'react';
 
-import { Input, Field } from '@grafana/ui';
-import { t } from 'app/core/internationalization';
+import { selectors } from '@grafana/e2e-selectors';
+import { Alert, Input, Field, TextLink } from '@grafana/ui';
+import { t, Trans } from 'app/core/internationalization';
 import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
 import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
+import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
+import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
+import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
 
 import { useConditionalRenderingEditor } from '../../conditional-rendering/ConditionalRenderingEditor';
+import { getQueryRunnerFor, useDashboard } from '../../utils/utils';
 import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
 import { useEditPaneInputAutoFocus } from '../layouts-shared/utils';
 
@@ -25,9 +30,28 @@ export function getEditOptions(model: TabItem): OptionsPaneCategoryDescriptor[]
     [model]
   );
 
+  const repeatCategory = useMemo(
+    () =>
+      new OptionsPaneCategoryDescriptor({
+        title: t('dashboard.tabs-layout.tab-options.repeat.title', 'Repeat options'),
+        id: 'repeat-options',
+        isOpenDefault: false,
+      }).addItem(
+        new OptionsPaneItemDescriptor({
+          title: t('dashboard.tabs-layout.tab-options.repeat.variable.title', 'Repeat by variable'),
+          description: t(
+            'dashboard.tabs-layout.tab-options.repeat.variable.description',
+            'Repeat this tab for each value in the selected variable.'
+          ),
+          render: () => <TabRepeatSelect tab={model} />,
+        })
+      ),
+    [model]
+  );
+
   const layoutCategory = useLayoutCategory(layout);
 
-  const editOptions = [tabCategory, ...layoutCategory];
+  const editOptions = [tabCategory, ...layoutCategory, repeatCategory];
 
   const conditionalRenderingCategory = useMemo(
     () => useConditionalRenderingEditor(model.state.conditionalRendering),
@@ -62,3 +86,51 @@ function TabTitleInput({ tab }: { tab: TabItem }) {
     </Field>
   );
 }
+
+function TabRepeatSelect({ tab }: { tab: TabItem }) {
+  const { layout } = tab.useState();
+  const dashboard = useDashboard(tab);
+
+  const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
+    const runner = getQueryRunnerFor(vizPanel);
+    return (
+      runner?.state.datasource?.uid === SHARED_DASHBOARD_QUERY ||
+      (runner?.state.datasource?.uid === MIXED_DATASOURCE_NAME &&
+        runner?.state.queries.some((query) => query.datasource?.uid === SHARED_DASHBOARD_QUERY))
+    );
+  });
+
+  return (
+    <>
+      <RepeatRowSelect2
+        sceneContext={dashboard}
+        repeat={tab.getRepeatVariable()}
+        onChange={(repeat) => tab.onChangeRepeat(repeat)}
+      />
+      {isAnyPanelUsingDashboardDS ? (
+        <Alert
+          data-testid={selectors.pages.Dashboard.Rows.Repeated.ConfigSection.warningMessage}
+          severity="warning"
+          title=""
+          topSpacing={3}
+          bottomSpacing={0}
+        >
+          <p>
+            <Trans i18nKey="dashboard.tabs-layout.tab.repeat.warning">
+              Panels in this tab use the {{ SHARED_DASHBOARD_QUERY }} data source. These panels will reference the panel
+              in the original tab, not the ones in the repeated tabs.
+            </Trans>
+          </p>
+          <TextLink
+            external
+            href={
+              'https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/create-dashboard/#configure-repeating-tabs'
+            }
+          >
+            <Trans i18nKey="dashboard.tabs-layout.tab.repeat.learn-more">Learn more</Trans>
+          </TextLink>
+        </Alert>
+      ) : undefined}
+    </>
+  );
+}
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx
index 2400ce19cfbe6..178a68709674e 100644
--- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRenderer.tsx
@@ -8,6 +8,7 @@ import { Tab, useElementSelection, usePointerDistance, useStyles2 } from '@grafa
 import { t } from 'app/core/internationalization';
 
 import { useIsConditionallyHidden } from '../../conditional-rendering/useIsConditionallyHidden';
+import { useIsClone } from '../../utils/clone';
 import { useDashboardState } from '../../utils/utils';
 
 import { TabItem } from './TabItem';
@@ -28,6 +29,9 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
   const styles = useStyles2(getStyles);
   const pointerDistance = usePointerDistance();
   const [isConditionallyHidden] = useIsConditionallyHidden(model);
+  const isClone = useIsClone(model);
+
+  const isDraggable = !isClone && isEditing;
 
   if (isConditionallyHidden && !isEditing && !isActive) {
     return null;
@@ -43,7 +47,7 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
   }
 
   return (
-    <Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isEditing}>
+    <Draggable key={key!} draggableId={key!} index={myIndex} isDragDisabled={!isDraggable}>
       {(dragProvided, dragSnapshot) => (
         <div
           ref={(ref) => dragProvided.innerRef(ref)}
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.test.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.test.tsx
new file mode 100644
index 0000000000000..93a0d400a717e
--- /dev/null
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.test.tsx
@@ -0,0 +1,272 @@
+import { VariableRefresh } from '@grafana/data';
+import { getPanelPlugin } from '@grafana/data/test';
+import { setPluginImportUtils } from '@grafana/runtime';
+import {
+  SceneGridRow,
+  SceneTimeRange,
+  SceneVariableSet,
+  TestVariable,
+  VariableValueOption,
+  PanelBuilders,
+} from '@grafana/scenes';
+import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/constants';
+import { TextMode } from 'app/plugins/panel/text/panelcfg.gen';
+
+import { getCloneKey, isInCloneChain, joinCloneKeys } from '../../utils/clone';
+import { activateFullSceneTree } from '../../utils/test-utils';
+import { DashboardScene } from '../DashboardScene';
+import { DashboardGridItem } from '../layout-default/DashboardGridItem';
+import { DefaultGridLayoutManager } from '../layout-default/DefaultGridLayoutManager';
+
+import { TabItem } from './TabItem';
+import { TabItemRepeaterBehavior } from './TabItemRepeaterBehavior';
+import { TabsLayoutManager } from './TabsLayoutManager';
+
+jest.mock('@grafana/runtime', () => ({
+  ...jest.requireActual('@grafana/runtime'),
+  setPluginExtensionGetter: jest.fn(),
+  getPluginLinkExtensions: jest.fn().mockReturnValue({ extensions: [] }),
+}));
+
+setPluginImportUtils({
+  importPanelPlugin: () => Promise.resolve(getPanelPlugin({})),
+  getPanelPluginFromCache: () => undefined,
+});
+
+describe('TabItemRepeaterBehavior', () => {
+  describe('Given scene with variable with 5 values', () => {
+    let scene: DashboardScene, layout: TabsLayoutManager, repeatBehavior: TabItemRepeaterBehavior;
+    let layoutStateUpdates: unknown[];
+
+    beforeEach(async () => {
+      ({ scene, layout, repeatBehavior } = buildScene({ variableQueryTime: 0 }));
+
+      layoutStateUpdates = [];
+      layout.subscribeToState((state) => layoutStateUpdates.push(state));
+
+      activateFullSceneTree(scene);
+      await new Promise((r) => setTimeout(r, 1));
+    });
+
+    it('Should repeat tab', () => {
+      // Verify that first tab still has repeat behavior
+      const tab1 = layout.state.tabs[0];
+      expect(tab1.state.key).toBe(getCloneKey('tab-1', 0));
+      expect(tab1.state.$behaviors?.[0]).toBeInstanceOf(TabItemRepeaterBehavior);
+      expect(tab1.state.$variables!.state.variables[0].getValue()).toBe('A1');
+
+      const tab1Children = getTabChildren(tab1);
+      expect(tab1Children[0].state.key!).toBe(joinCloneKeys(tab1.state.key!, 'grid-item-0'));
+      expect(tab1Children[0].state.body?.state.key).toBe(joinCloneKeys(tab1Children[0].state.key!, 'panel-0'));
+
+      const tab2 = layout.state.tabs[1];
+      expect(tab2.state.key).toBe(getCloneKey('tab-1', 1));
+      expect(tab2.state.$behaviors).toEqual([]);
+      expect(tab2.state.$variables!.state.variables[0].getValueText?.()).toBe('B');
+
+      const tab2Children = getTabChildren(tab2);
+      expect(tab2Children[0].state.key!).toBe(joinCloneKeys(tab2.state.key!, 'grid-item-0'));
+      expect(tab2Children[0].state.body?.state.key).toBe(joinCloneKeys(tab2Children[0].state.key!, 'panel-0'));
+    });
+
+    it('Repeated tabs should be read only', () => {
+      const tab1 = layout.state.tabs[0];
+      expect(isInCloneChain(tab1.state.key!)).toBe(false);
+
+      const tab2 = layout.state.tabs[1];
+      expect(isInCloneChain(tab2.state.key!)).toBe(true);
+    });
+
+    it('Should push tab at the bottom down', () => {
+      // Should push tab at the bottom down
+      const tabAtTheBottom = layout.state.tabs[5];
+      expect(tabAtTheBottom.state.title).toBe('Tab at the bottom');
+    });
+
+    it('Should handle second repeat cycle and update remove old repeats', async () => {
+      // trigger another repeat cycle by changing the variable
+      const variable = scene.state.$variables!.state.variables[0] as TestVariable;
+      variable.changeValueTo(['B1', 'C1']);
+
+      await new Promise((r) => setTimeout(r, 1));
+
+      // should now only have 2 repeated tabs (and the panel above + the tab at the bottom)
+      expect(layout.state.tabs.length).toBe(3);
+    });
+
+    it('Should ignore repeat process if variable values are the same', async () => {
+      // trigger another repeat cycle by changing the variable
+      repeatBehavior.performRepeat();
+
+      await new Promise((r) => setTimeout(r, 1));
+
+      expect(layoutStateUpdates.length).toBe(1);
+    });
+  });
+
+  describe('Given scene with variable with 15 values', () => {
+    let scene: DashboardScene, layout: TabsLayoutManager;
+    let layoutStateUpdates: unknown[];
+
+    beforeEach(async () => {
+      ({ scene, layout } = buildScene({ variableQueryTime: 0 }, [
+        { label: 'A', value: 'A1' },
+        { label: 'B', value: 'B1' },
+        { label: 'C', value: 'C1' },
+        { label: 'D', value: 'D1' },
+        { label: 'E', value: 'E1' },
+        { label: 'F', value: 'F1' },
+        { label: 'G', value: 'G1' },
+        { label: 'H', value: 'H1' },
+        { label: 'I', value: 'I1' },
+        { label: 'J', value: 'J1' },
+        { label: 'K', value: 'K1' },
+        { label: 'L', value: 'L1' },
+        { label: 'M', value: 'M1' },
+        { label: 'N', value: 'N1' },
+        { label: 'O', value: 'O1' },
+      ]));
+
+      layoutStateUpdates = [];
+      layout.subscribeToState((state) => layoutStateUpdates.push(state));
+
+      activateFullSceneTree(scene);
+      await new Promise((r) => setTimeout(r, 1));
+    });
+
+    it('Should handle second repeat cycle and update remove old repeats', async () => {
+      // should have 15 repeated tabs (and the panel above)
+      expect(layout.state.tabs.length).toBe(16);
+
+      // trigger another repeat cycle by changing the variable
+      const variable = scene.state.$variables!.state.variables[0] as TestVariable;
+      variable.changeValueTo(['B1', 'C1']);
+
+      await new Promise((r) => setTimeout(r, 1));
+
+      // should now only have 2 repeated tabs (and the panel above)
+      expect(layout.state.tabs.length).toBe(3);
+    });
+  });
+
+  describe('Given a scene with empty variable', () => {
+    it('Should preserve repeat tab', async () => {
+      const { scene, layout } = buildScene({ variableQueryTime: 0 }, []);
+      activateFullSceneTree(scene);
+      await new Promise((r) => setTimeout(r, 1));
+
+      // Should have 2 tabs, one without repeat and one with the dummy tab
+      expect(layout.state.tabs.length).toBe(2);
+      expect(layout.state.tabs[0].state.$behaviors?.[0]).toBeInstanceOf(TabItemRepeaterBehavior);
+    });
+  });
+});
+
+interface SceneOptions {
+  variableQueryTime: number;
+  variableRefresh?: VariableRefresh;
+}
+
+function buildTextPanel(key: string, content: string) {
+  const panel = PanelBuilders.text().setOption('content', content).setOption('mode', TextMode.Markdown).build();
+  panel.setState({ key });
+  return panel;
+}
+
+function buildScene(
+  options: SceneOptions,
+  variableOptions?: VariableValueOption[],
+  variableStateOverrides?: { isMulti: boolean }
+) {
+  const repeatBehavior = new TabItemRepeaterBehavior({ variableName: 'server' });
+
+  const tabs = [
+    new TabItem({
+      key: 'tab-1',
+      $behaviors: [repeatBehavior],
+      layout: DefaultGridLayoutManager.fromGridItems([
+        new DashboardGridItem({
+          key: 'grid-item-1',
+          x: 0,
+          y: 11,
+          width: 24,
+          height: 5,
+          body: buildTextPanel('text-1', 'Panel inside repeated tab, server = $server'),
+        }),
+      ]),
+    }),
+    new TabItem({
+      key: 'tab-2',
+      title: 'Tab at the bottom',
+      layout: DefaultGridLayoutManager.fromGridItems([
+        new DashboardGridItem({
+          key: 'grid-item-2',
+          x: 0,
+          y: 17,
+          body: buildTextPanel('text-2', 'Panel inside tab, server = $server'),
+        }),
+        new DashboardGridItem({
+          key: 'grid-item-3',
+          x: 0,
+          y: 25,
+          body: buildTextPanel('text-3', 'Panel inside tab, server = $server'),
+        }),
+      ]),
+    }),
+  ];
+
+  const layout = new TabsLayoutManager({ tabs });
+
+  const scene = new DashboardScene({
+    $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }),
+    $variables: new SceneVariableSet({
+      variables: [
+        new TestVariable({
+          name: 'server',
+          query: 'A.*',
+          value: ALL_VARIABLE_VALUE,
+          text: ALL_VARIABLE_TEXT,
+          isMulti: true,
+          includeAll: true,
+          delayMs: options.variableQueryTime,
+          refresh: options.variableRefresh,
+          optionsToReturn: variableOptions ?? [
+            { label: 'A', value: 'A1' },
+            { label: 'B', value: 'B1' },
+            { label: 'C', value: 'C1' },
+            { label: 'D', value: 'D1' },
+            { label: 'E', value: 'E1' },
+          ],
+          ...variableStateOverrides,
+        }),
+      ],
+    }),
+    body: layout,
+  });
+
+  const tabToRepeat = repeatBehavior.parent as SceneGridRow;
+
+  return { scene, layout, tabs, repeatBehavior, tabToRepeat };
+}
+
+function getTabLayout(tab: TabItem): DefaultGridLayoutManager {
+  const layout = tab.getLayout();
+
+  if (!(layout instanceof DefaultGridLayoutManager)) {
+    throw new Error('Invalid layout');
+  }
+
+  return layout;
+}
+
+function getTabChildren(tab: TabItem): DashboardGridItem[] {
+  const layout = getTabLayout(tab);
+
+  const filteredChildren = layout.state.grid.state.children.filter((child) => child instanceof DashboardGridItem);
+
+  if (filteredChildren.length !== layout.state.grid.state.children.length) {
+    throw new Error('Invalid layout');
+  }
+
+  return filteredChildren;
+}
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.ts b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.ts
new file mode 100644
index 0000000000000..a0e0207c7ad56
--- /dev/null
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemRepeaterBehavior.ts
@@ -0,0 +1,161 @@
+import { isEqual } from 'lodash';
+
+import {
+  LocalValueVariable,
+  MultiValueVariable,
+  sceneGraph,
+  SceneObjectBase,
+  SceneObjectState,
+  SceneVariableSet,
+  VariableDependencyConfig,
+  VariableValueSingle,
+} from '@grafana/scenes';
+
+import { isClonedKeyOf, getCloneKey } from '../../utils/clone';
+import { getMultiVariableValues } from '../../utils/utils';
+import { DashboardRepeatsProcessedEvent } from '../types/DashboardRepeatsProcessedEvent';
+
+import { TabItem } from './TabItem';
+import { TabsLayoutManager } from './TabsLayoutManager';
+
+interface TabItemRepeaterBehaviorState extends SceneObjectState {
+  variableName: string;
+}
+
+export class TabItemRepeaterBehavior extends SceneObjectBase<TabItemRepeaterBehaviorState> {
+  protected _variableDependency = new VariableDependencyConfig(this, {
+    variableNames: [this.state.variableName],
+    onVariableUpdateCompleted: () => this.performRepeat(),
+  });
+
+  private _prevRepeatValues?: VariableValueSingle[];
+  private _clonedTabs?: TabItem[];
+
+  public constructor(state: TabItemRepeaterBehaviorState) {
+    super(state);
+
+    this.addActivationHandler(() => this._activationHandler());
+  }
+
+  private _activationHandler() {
+    this.performRepeat();
+  }
+
+  private _getTab(): TabItem {
+    if (!(this.parent instanceof TabItem)) {
+      throw new Error('RepeatedTabItemBehavior: Parent is not a TabItem');
+    }
+
+    return this.parent;
+  }
+
+  private _getLayout(): TabsLayoutManager {
+    const layout = this._getTab().parent;
+
+    if (!(layout instanceof TabsLayoutManager)) {
+      throw new Error('RepeatedTabItemBehavior: Layout is not a TabsLayoutManager');
+    }
+
+    return layout;
+  }
+
+  public performRepeat(force = false) {
+    if (this._variableDependency.hasDependencyInLoadingState()) {
+      return;
+    }
+
+    const variable = sceneGraph.lookupVariable(this.state.variableName, this.parent?.parent!);
+
+    if (!variable) {
+      console.error('RepeatedTabItemBehavior: Variable not found');
+      return;
+    }
+
+    if (!(variable instanceof MultiValueVariable)) {
+      console.error('RepeatedTabItemBehavior: Variable is not a MultiValueVariable');
+      return;
+    }
+
+    const tabToRepeat = this._getTab();
+    const layout = this._getLayout();
+    const { values, texts } = getMultiVariableValues(variable);
+
+    // Do nothing if values are the same
+    if (isEqual(this._prevRepeatValues, values) && !force) {
+      return;
+    }
+
+    this._prevRepeatValues = values;
+
+    this._clonedTabs = [];
+
+    const tabContent = tabToRepeat.getLayout();
+
+    // when variable has no options (due to error or similar) it will not render any panels at all
+    // adding a placeholder in this case so that there is at least empty panel that can display error
+    const emptyVariablePlaceholderOption = {
+      values: [''],
+      texts: variable.hasAllValue() ? ['All'] : ['None'],
+    };
+
+    const variableValues = values.length ? values : emptyVariablePlaceholderOption.values;
+    const variableTexts = texts.length ? texts : emptyVariablePlaceholderOption.texts;
+
+    // Loop through variable values and create repeats
+    for (let tabIndex = 0; tabIndex < variableValues.length; tabIndex++) {
+      const isSourceTab = tabIndex === 0;
+      const tabClone = isSourceTab ? tabToRepeat : tabToRepeat.clone({ $behaviors: [] });
+
+      const tabCloneKey = getCloneKey(tabToRepeat.state.key!, tabIndex);
+
+      tabClone.setState({
+        key: tabCloneKey,
+        $variables: new SceneVariableSet({
+          variables: [
+            new LocalValueVariable({
+              name: this.state.variableName,
+              value: variableValues[tabIndex],
+              text: String(variableTexts[tabIndex]),
+              isMulti: variable.state.isMulti,
+              includeAll: variable.state.includeAll,
+            }),
+          ],
+        }),
+        layout: tabContent.cloneLayout?.(tabCloneKey, isSourceTab),
+      });
+
+      this._clonedTabs.push(tabClone);
+    }
+
+    updateLayout(layout, this._clonedTabs, tabToRepeat.state.key!);
+
+    // Used from dashboard url sync
+    this.publishEvent(new DashboardRepeatsProcessedEvent({ source: this }), true);
+  }
+
+  public removeBehavior() {
+    const tab = this._getTab();
+    const layout = this._getLayout();
+    const tabs = getTabsFilterOutRepeatClones(layout, tab.state.key!);
+
+    layout.setState({ tabs });
+
+    // Remove behavior and the scoped local variable
+    tab.setState({ $behaviors: tab.state.$behaviors!.filter((b) => b !== this), $variables: undefined });
+  }
+}
+
+function updateLayout(layout: TabsLayoutManager, tabs: TabItem[], tabKey: string) {
+  const allTabs = getTabsFilterOutRepeatClones(layout, tabKey);
+  const index = allTabs.findIndex((tab) => tab.state.key!.includes(tabKey));
+
+  if (index === -1) {
+    throw new Error('TabItemRepeaterBehavior: Tab not found in layout');
+  }
+
+  layout.setState({ tabs: [...allTabs.slice(0, index), ...tabs, ...allTabs.slice(index + 1)] });
+}
+
+function getTabsFilterOutRepeatClones(layout: TabsLayoutManager, tabKey: string) {
+  return layout.state.tabs.filter((tab) => !isClonedKeyOf(tab.state.key!, tabKey));
+}
diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx
index 194d49c1d11da..e7a0f278063f6 100644
--- a/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx
+++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabsLayoutManager.tsx
@@ -1,4 +1,5 @@
 import {
+  sceneGraph,
   SceneObjectBase,
   SceneObjectState,
   SceneObjectUrlSyncConfig,
@@ -14,8 +15,10 @@ import {
   ObjectsReorderedOnCanvasEvent,
 } from '../../edit-pane/shared';
 import { serializeTabsLayout } from '../../serialization/layoutSerializers/TabsLayoutSerializer';
+import { isClonedKey, joinCloneKeys } from '../../utils/clone';
 import { getDashboardSceneFor } from '../../utils/utils';
 import { RowItem } from '../layout-rows/RowItem';
+import { RowItemRepeaterBehavior } from '../layout-rows/RowItemRepeaterBehavior';
 import { RowsLayoutManager } from '../layout-rows/RowsLayoutManager';
 import { getTabFromClipboard } from '../layouts-shared/paste';
 import { generateUniqueTitle, ungroupLayout } from '../layouts-shared/utils';
@@ -23,6 +26,7 @@ import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
 import { LayoutRegistryItem } from '../types/LayoutRegistryItem';
 
 import { TabItem } from './TabItem';
+import { TabItemRepeaterBehavior } from './TabItemRepeaterBehavior';
 import { TabsLayoutManagerRenderer } from './TabsLayoutManagerRenderer';
 
 interface TabsLayoutManagerState extends SceneObjectState {
@@ -122,7 +126,16 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
   }
 
   public cloneLayout(ancestorKey: string, isSource: boolean): DashboardLayoutManager {
-    throw new Error('Method not implemented.');
+    return this.clone({
+      tabs: this.state.tabs.map((tab) => {
+        const key = joinCloneKeys(ancestorKey, tab.state.key!);
+
+        return tab.clone({
+          key,
+          layout: tab.state.layout.cloneLayout(key, isSource),
+        });
+      }),
+    });
   }
 
   public addNewTab(tab?: TabItem) {
@@ -149,7 +162,19 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
   }
 
   public activateRepeaters() {
-    this.state.tabs.forEach((tab) => tab.getLayout().activateRepeaters?.());
+    this.state.tabs.forEach((tab) => {
+      if (!tab.isActive) {
+        tab.activate();
+      }
+
+      const behavior = (tab.state.$behaviors ?? []).find((b) => b instanceof TabItemRepeaterBehavior);
+
+      if (!behavior?.isActive) {
+        behavior?.activate();
+      }
+
+      tab.getLayout().activateRepeaters?.();
+    });
   }
 
   public shouldUngroup(): boolean {
@@ -210,7 +235,21 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
 
     if (layout instanceof RowsLayoutManager) {
       for (const row of layout.state.rows) {
-        tabs.push(new TabItem({ layout: row.state.layout.clone(), title: row.state.title }));
+        if (isClonedKey(row.state.key!)) {
+          continue;
+        }
+
+        const conditionalRendering = row.state.conditionalRendering;
+        conditionalRendering?.clearParent();
+
+        const behavior = row.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior);
+        const $behaviors = !behavior
+          ? undefined
+          : [new TabItemRepeaterBehavior({ variableName: behavior.state.variableName })];
+
+        tabs.push(
+          new TabItem({ layout: row.state.layout.clone(), title: row.state.title, conditionalRendering, $behaviors })
+        );
       }
     } else {
       layout.clearParent();
@@ -245,7 +284,7 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
     const duplicateTitles = new Set<string | undefined>();
 
     this.state.tabs.forEach((tab) => {
-      const title = tab.state.title;
+      const title = sceneGraph.interpolate(tab, tab.state.title);
       const count = (titleCounts.get(title) ?? 0) + 1;
       titleCounts.set(title, count);
       if (count > 1) {
diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts
index b6fae8406294c..49a8cf94870f3 100644
--- a/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts
+++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/RowsLayoutSerializer.ts
@@ -1,4 +1,3 @@
-import { SceneObject } from '@grafana/scenes';
 import {
   Spec as DashboardV2Spec,
   RowsLayoutRowKind,
@@ -7,6 +6,7 @@ import {
 import { RowItem } from '../../scene/layout-rows/RowItem';
 import { RowItemRepeaterBehavior } from '../../scene/layout-rows/RowItemRepeaterBehavior';
 import { RowsLayoutManager } from '../../scene/layout-rows/RowsLayoutManager';
+import { isClonedKey } from '../../utils/clone';
 
 import { layoutDeserializerRegistry } from './layoutSerializerRegistry';
 import { getConditionalRendering } from './utils';
@@ -15,7 +15,7 @@ export function serializeRowsLayout(layoutManager: RowsLayoutManager): Dashboard
   return {
     kind: 'RowsLayout',
     spec: {
-      rows: layoutManager.state.rows.map(serializeRow),
+      rows: layoutManager.state.rows.filter((row) => !isClonedKey(row.state.key!)).map(serializeRow),
     },
   };
 }
@@ -72,17 +72,16 @@ export function deserializeRow(
   panelIdGenerator?: () => number
 ): RowItem {
   const layout = row.spec.layout;
-  const behaviors: SceneObject[] = [];
-  if (row.spec.repeat) {
-    behaviors.push(new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value }));
-  }
+  const $behaviors = !row.spec.repeat
+    ? undefined
+    : [new RowItemRepeaterBehavior({ variableName: row.spec.repeat.value })];
 
   return new RowItem({
     title: row.spec.title,
     collapse: row.spec.collapse,
     hideHeader: row.spec.hideHeader,
     fillScreen: row.spec.fillScreen,
-    $behaviors: behaviors,
+    $behaviors,
     layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator),
     conditionalRendering: getConditionalRendering(row),
   });
diff --git a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts
index 6ab820816d1df..9a8cbe50bdbbb 100644
--- a/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts
+++ b/public/app/features/dashboard-scene/serialization/layoutSerializers/TabsLayoutSerializer.ts
@@ -4,7 +4,9 @@ import {
 } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha1/types.spec.gen';
 
 import { TabItem } from '../../scene/layout-tabs/TabItem';
+import { TabItemRepeaterBehavior } from '../../scene/layout-tabs/TabItemRepeaterBehavior';
 import { TabsLayoutManager } from '../../scene/layout-tabs/TabsLayoutManager';
+import { isClonedKey } from '../../utils/clone';
 
 import { layoutDeserializerRegistry } from './layoutSerializerRegistry';
 import { getConditionalRendering } from './utils';
@@ -13,7 +15,7 @@ export function serializeTabsLayout(layoutManager: TabsLayoutManager): Dashboard
   return {
     kind: 'TabsLayout',
     spec: {
-      tabs: layoutManager.state.tabs.map(serializeTab),
+      tabs: layoutManager.state.tabs.filter((tab) => !isClonedKey(tab.state.key!)).map(serializeTab),
     },
   };
 }
@@ -34,6 +36,17 @@ export function serializeTab(tab: TabItem): TabsLayoutTabKind {
     tabKind.spec.conditionalRendering = conditionalRenderingRootGroup;
   }
 
+  if (tab.state.$behaviors) {
+    for (const behavior of tab.state.$behaviors) {
+      if (behavior instanceof TabItemRepeaterBehavior) {
+        if (tabKind.spec.repeat) {
+          throw new Error('Multiple repeaters are not supported');
+        }
+        tabKind.spec.repeat = { value: behavior.state.variableName, mode: 'variable' };
+      }
+    }
+  }
+
   return tabKind;
 }
 
@@ -61,9 +74,14 @@ export function deserializeTab(
   panelIdGenerator?: () => number
 ): TabItem {
   const layout = tab.spec.layout;
+  const $behaviors = !tab.spec.repeat
+    ? undefined
+    : [new TabItemRepeaterBehavior({ variableName: tab.spec.repeat.value })];
+
   return new TabItem({
     title: tab.spec.title,
     layout: layoutDeserializerRegistry.get(layout.kind).deserialize(layout, elements, preload, panelIdGenerator),
+    $behaviors,
     conditionalRendering: getConditionalRendering(tab),
   });
 }
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 3c17b9c6c73d3..b1fb3d21c95f1 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -3060,9 +3060,20 @@
       "description": "Organize panels into horizontal tabs",
       "name": "Tabs",
       "tab": {
-        "new": "New tab"
+        "new": "New tab",
+        "repeat": {
+          "learn-more": "Learn more",
+          "warning": "Panels in this tab use the {{SHARED_DASHBOARD_QUERY}} data source. These panels will reference the panel in the original tab, not the ones in the repeated tabs."
+        }
       },
       "tab-options": {
+        "repeat": {
+          "title": "Repeat options",
+          "variable": {
+            "description": "Repeat this tab for each value in the selected variable.",
+            "title": "Repeat by variable"
+          }
+        },
         "title-not-unique": "Title should be unique",
         "title-option": "Title"
       },


Reviews:
user: login: torkelo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T10:39:46Z
user: login: bfmatei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-03T10:42:57Z
user: login: Sergej-Vlasov
body: Initial UI review:

1. repeating tab triggers title must be unique error
<img width="1158" alt="Screenshot 2025-04-09 at 12 02 57" src="https://github.com/user-attachments/assets/b2aff867-b0b0-49ce-b4ff-e735df843b2b" />

2. repeating tab with rows inside does not work
<img width="1507" alt="Screenshot 2025-04-09 at 12 00 41" src="https://github.com/user-attachments/assets/df927b62-7627-4edb-9281-f3fa5cc48366" />

state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T11:06:19Z
user: login: Sergej-Vlasov
body: Approving as is. Addressing change detection will be in a separate PR
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T10:15:09Z

Review comments:
diff_hunk: @@ -169,7 +170,21 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
 
     if (layout instanceof TabsLayoutManager) {
       for (const tab of layout.state.tabs) {
-        rows.push(new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title }));
+        if (isClonedKey(tab.state.key!)) {
+          continue;
+        }
+
+        const conditionalRendering = tab.state.conditionalRendering;
+        conditionalRendering?.clearParent();
+
+        const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior);
+        const $behaviors = !behavior
+          ? undefined
+          : [new RowItemRepeaterBehavior({ variableName: behavior.state.variableName })];
path: public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
user: login: torkelo
body: feel a behavior is wrong here, it should just a be a normal typed object on TabItem / RowItem. but maybe something we can refactor later. 

or handled by TabItem/RowItem directly 
created_at: 2025-04-03T10:39:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -169,7 +170,21 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
 
     if (layout instanceof TabsLayoutManager) {
       for (const tab of layout.state.tabs) {
-        rows.push(new RowItem({ layout: tab.state.layout.clone(), title: tab.state.title }));
+        if (isClonedKey(tab.state.key!)) {
+          continue;
+        }
+
+        const conditionalRendering = tab.state.conditionalRendering;
+        conditionalRendering?.clearParent();
+
+        const behavior = tab.state.$behaviors?.find((b) => b instanceof TabItemRepeaterBehavior);
+        const $behaviors = !behavior
+          ? undefined
+          : [new RowItemRepeaterBehavior({ variableName: behavior.state.variableName })];
path: public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx
user: login: bfmatei
body: 🤔  I think we can refactor afterwards. We can also move the functionality out of the `AutoGridItem` to declutter that one as well. WDYT?
created_at: 2025-04-03T10:42:57Z
author_association: CONTRIBUTOR

PR comments:
user: login: bfmatei
created_at: 2025-04-10T12:22:37Z
body: Hey @Sergej-Vlasov ! Just pushed three fixes:
- avoid showing duplicate title error for repeated rows (however, the user has to use the variable name in the title - we can improve it later, good enough for now)
- repeating tabs are now saving the repeat in the db
- repeated tabs with rows and repeated rows with tabs is now also working
author_association: CONTRIBUTOR
user: login: Sergej-Vlasov
created_at: 2025-04-10T12:41:08Z
body: > Hey @Sergej-Vlasov ! Just pushed three fixes:
> 
> * avoid showing duplicate title error for repeated rows (however, the user has to use the variable name in the title - we can improve it later, good enough for now)
> * repeating tabs are now saving the repeat in the db
> * repeated tabs with rows and repeated rows with tabs is now also working

Very nice! 

I noticed change detection breaks when I change something in the first repeated Tab
author_association: CONTRIBUTOR