diff --git a/.betterer.results b/.betterer.results
index f8a8a7e54df..f9d18a97a72 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -5726,6 +5726,56 @@ exports[`better eslint`] = {
"public/app/features/sandbox/TestStuffPage.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
+ "public/app/features/scenes/components/SceneFlexLayout.tsx:5381": [
+ [0, 0, 0, "Do not use any type assertions.", "0"]
+ ],
+ "public/app/features/scenes/core/SceneComponentEditWrapper.tsx:5381": [
+ [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
+ [0, 0, 0, "Do not use any type assertions.", "1"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "2"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "3"]
+ ],
+ "public/app/features/scenes/core/SceneObjectBase.tsx:5381": [
+ [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
+ [0, 0, 0, "Do not use any type assertions.", "1"],
+ [0, 0, 0, "Do not use any type assertions.", "2"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "3"],
+ [0, 0, 0, "Do not use any type assertions.", "4"],
+ [0, 0, 0, "Do not use any type assertions.", "5"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "6"],
+ [0, 0, 0, "Do not use any type assertions.", "7"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "8"],
+ [0, 0, 0, "Do not use any type assertions.", "9"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "10"]
+ ],
+ "public/app/features/scenes/core/SceneTimeRange.tsx:5381": [
+ [0, 0, 0, "Do not use any type assertions.", "0"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "1"]
+ ],
+ "public/app/features/scenes/core/events.ts:5381": [
+ [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "1"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "2"]
+ ],
+ "public/app/features/scenes/core/types.ts:5381": [
+ [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "1"]
+ ],
+ "public/app/features/scenes/editor/SceneObjectTree.tsx:5381": [
+ [0, 0, 0, "Do not use any type assertions.", "0"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "1"],
+ [0, 0, 0, "Do not use any type assertions.", "2"]
+ ],
+ "public/app/features/scenes/querying/SceneQueryRunner.ts:5381": [
+ [0, 0, 0, "Unexpected any. Specify a different type.", "0"],
+ [0, 0, 0, "Do not use any type assertions.", "1"],
+ [0, 0, 0, "Unexpected any. Specify a different type.", "2"],
+ [0, 0, 0, "Do not use any type assertions.", "3"],
+ [0, 0, 0, "Do not use any type assertions.", "4"]
+ ],
+ "public/app/features/scenes/variables/types.ts:5381": [
+ [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
+ ],
"public/app/features/search/components/SearchCard.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index ccf3e461d71..d28102166c2 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -56,6 +56,7 @@ export interface FeatureToggles {
autoMigrateGraphPanels?: boolean;
prometheusWideSeries?: boolean;
canvasPanelNesting?: boolean;
+ scenes?: boolean;
useLegacyHeatmapPanel?: boolean;
cloudMonitoringExperimentalUI?: boolean;
logRequestsInstrumentedAsUnknown?: boolean;
diff --git a/packages/grafana-runtime/src/components/PanelRenderer.tsx b/packages/grafana-runtime/src/components/PanelRenderer.tsx
index 8c0619ba3ba..a16377052c8 100644
--- a/packages/grafana-runtime/src/components/PanelRenderer.tsx
+++ b/packages/grafana-runtime/src/components/PanelRenderer.tsx
@@ -11,7 +11,7 @@ import { AbsoluteTimeRange, FieldConfigSource, PanelData } from '@grafana/data';
* @internal
*/
export interface PanelRendererProps
{
- data: PanelData;
+ data?: PanelData;
pluginId: string;
title: string;
options?: Partial
;
diff --git a/pkg/api/index.go b/pkg/api/index.go
index a6417214748..00c375931c6 100644
--- a/pkg/api/index.go
+++ b/pkg/api/index.go
@@ -459,6 +459,15 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
})
}
+ if hs.Features.IsEnabled(featuremgmt.FlagScenes) {
+ dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
+ Text: "Scenes",
+ Id: "scenes",
+ Url: hs.Cfg.AppSubURL + "/scenes",
+ Icon: "apps",
+ })
+ }
+
if hasEditPerm {
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index 75e64a2ebca..81c39c2bace 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -227,6 +227,12 @@ var (
State: FeatureStateAlpha,
FrontendOnly: true,
},
+ {
+ Name: "scenes",
+ Description: "Experimental framework to build interactive dashboards",
+ State: FeatureStateAlpha,
+ FrontendOnly: true,
+ },
{
Name: "useLegacyHeatmapPanel",
Description: "Continue to use the angular/flot based heatmap panel",
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index d9ff3c122ae..d95e7241994 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -167,6 +167,10 @@ const (
// Allow elements nesting
FlagCanvasPanelNesting = "canvasPanelNesting"
+ // FlagScenes
+ // Experimental framework to build interactive dashboards
+ FlagScenes = "scenes"
+
// FlagUseLegacyHeatmapPanel
// Continue to use the angular/flot based heatmap panel
FlagUseLegacyHeatmapPanel = "useLegacyHeatmapPanel"
diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx
index 8cfa6969013..baa2878f749 100644
--- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx
+++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx
@@ -57,9 +57,12 @@ export const OptionsPaneCategory: FC = React.memo(
const onToggle = useCallback(() => {
manualClickTime.current = Date.now();
- updateQueryParams({
- [CATEGORY_PARAM_NAME]: isExpanded ? undefined : id,
- });
+ updateQueryParams(
+ {
+ [CATEGORY_PARAM_NAME]: isExpanded ? undefined : id,
+ },
+ true
+ );
setSavedState({ isExpanded: !isExpanded });
setIsExpanded(!isExpanded);
}, [setSavedState, setIsExpanded, updateQueryParams, isExpanded, id]);
diff --git a/public/app/features/scenes/SceneListPage.tsx b/public/app/features/scenes/SceneListPage.tsx
new file mode 100644
index 00000000000..dbd7b138941
--- /dev/null
+++ b/public/app/features/scenes/SceneListPage.tsx
@@ -0,0 +1,31 @@
+// Libraries
+import React, { FC } from 'react';
+
+import { Stack } from '@grafana/experimental';
+import { Card } from '@grafana/ui';
+import Page from 'app/core/components/Page/Page';
+
+// Types
+import { getScenes } from './scenes';
+
+export interface Props {}
+
+export const SceneListPage: FC = ({}) => {
+ const scenes = getScenes();
+
+ return (
+
+
+
+ {scenes.map((scene) => (
+
+ {scene.state.title}
+
+ ))}
+
+
+
+ );
+};
+
+export default SceneListPage;
diff --git a/public/app/features/scenes/ScenePage.tsx b/public/app/features/scenes/ScenePage.tsx
new file mode 100644
index 00000000000..14cf1133635
--- /dev/null
+++ b/public/app/features/scenes/ScenePage.tsx
@@ -0,0 +1,24 @@
+// Libraries
+import React, { FC } from 'react';
+
+import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
+
+import { getSceneByTitle } from './scenes';
+
+export interface Props extends GrafanaRouteComponentProps<{ name: string }> {}
+
+export const ScenePage: FC = (props) => {
+ const scene = getSceneByTitle(props.match.params.name);
+
+ if (!scene) {
+ return Scene not found ;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default ScenePage;
diff --git a/public/app/features/scenes/components/Scene.test.tsx b/public/app/features/scenes/components/Scene.test.tsx
new file mode 100644
index 00000000000..6c616ff70f6
--- /dev/null
+++ b/public/app/features/scenes/components/Scene.test.tsx
@@ -0,0 +1,15 @@
+import { Scene } from './Scene';
+import { SceneFlexLayout } from './SceneFlexLayout';
+
+describe('Scene', () => {
+ it('Simple scene', () => {
+ const scene = new Scene({
+ title: 'Hello',
+ layout: new SceneFlexLayout({
+ children: [],
+ }),
+ });
+
+ expect(scene.state.title).toBe('Hello');
+ });
+});
diff --git a/public/app/features/scenes/components/Scene.tsx b/public/app/features/scenes/components/Scene.tsx
new file mode 100644
index 00000000000..7bf37c758b5
--- /dev/null
+++ b/public/app/features/scenes/components/Scene.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+
+import { PageToolbar, ToolbarButton } from '@grafana/ui';
+
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneComponentProps, SceneObjectState, SceneObject } from '../core/types';
+import { UrlSyncManager } from '../services/UrlSyncManager';
+
+interface SceneState extends SceneObjectState {
+ title: string;
+ layout: SceneObject;
+ actions?: SceneObject[];
+ isEditing?: boolean;
+}
+
+export class Scene extends SceneObjectBase {
+ static Component = SceneRenderer;
+ urlSyncManager?: UrlSyncManager;
+
+ onMount() {
+ super.onMount();
+ this.urlSyncManager = new UrlSyncManager(this);
+ }
+
+ onUnmount() {
+ super.onUnmount();
+ this.urlSyncManager!.cleanUp();
+ }
+}
+
+function SceneRenderer({ model }: SceneComponentProps) {
+ const { title, layout, actions = [], isEditing, $editor } = model.useState();
+
+ console.log('render scene');
+
+ return (
+
+
+ {actions.map((action) => (
+
+ ))}
+ {$editor && (
+ model.setState({ isEditing: !model.state.isEditing })}
+ />
+ )}
+
+
+
+ {$editor && <$editor.Component model={$editor} isEditing={isEditing} />}
+
+
+ );
+}
diff --git a/public/app/features/scenes/components/SceneCanvasText.tsx b/public/app/features/scenes/components/SceneCanvasText.tsx
new file mode 100644
index 00000000000..042c86312e7
--- /dev/null
+++ b/public/app/features/scenes/components/SceneCanvasText.tsx
@@ -0,0 +1,44 @@
+import React, { CSSProperties } from 'react';
+
+import { Field, Input } from '@grafana/ui';
+
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneComponentProps, SceneObjectState } from '../core/types';
+
+export interface SceneCanvasTextState extends SceneObjectState {
+ text: string;
+ fontSize?: number;
+ align?: 'left' | 'center' | 'right';
+}
+
+export class SceneCanvasText extends SceneObjectBase {
+ static Editor = Editor;
+ static Component = ({ model }: SceneComponentProps) => {
+ const { text, fontSize = 20, align = 'left' } = model.useState();
+
+ const style: CSSProperties = {
+ fontSize: fontSize,
+ display: 'flex',
+ flexGrow: 1,
+ alignItems: 'center',
+ padding: 16,
+ justifyContent: align,
+ };
+
+ return {text}
;
+ };
+}
+
+function Editor({ model }: SceneComponentProps) {
+ const { fontSize } = model.useState();
+
+ return (
+
+ model.setState({ fontSize: parseInt(evt.currentTarget.value, 10) })}
+ />
+
+ );
+}
diff --git a/public/app/features/scenes/components/SceneFlexLayout.tsx b/public/app/features/scenes/components/SceneFlexLayout.tsx
new file mode 100644
index 00000000000..24691000c33
--- /dev/null
+++ b/public/app/features/scenes/components/SceneFlexLayout.tsx
@@ -0,0 +1,110 @@
+import React, { CSSProperties } from 'react';
+
+import { Field, RadioButtonGroup } from '@grafana/ui';
+
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneObject, SceneObjectSize, SceneObjectState, SceneLayoutState, SceneComponentProps } from '../core/types';
+
+export type FlexLayoutDirection = 'column' | 'row';
+
+interface SceneFlexLayoutState extends SceneObjectState, SceneLayoutState {
+ direction?: FlexLayoutDirection;
+}
+
+export class SceneFlexLayout extends SceneObjectBase {
+ static Component = FlexLayoutRenderer;
+ static Editor = FlexLayoutEditor;
+
+ toggleDirection() {
+ this.setState({
+ direction: this.state.direction === 'row' ? 'column' : 'row',
+ });
+ }
+}
+
+function FlexLayoutRenderer({ model, isEditing }: SceneComponentProps) {
+ const { direction = 'row', children } = model.useState();
+
+ return (
+
+ {children.map((item) => (
+
+ ))}
+
+ );
+}
+
+function FlexLayoutChildComponent({
+ item,
+ direction,
+ isEditing,
+}: {
+ item: SceneObject;
+ direction: FlexLayoutDirection;
+ isEditing?: boolean;
+}) {
+ const { size } = item.useState();
+
+ return (
+
+
+
+ );
+}
+
+function getItemStyles(direction: FlexLayoutDirection, sizing: SceneObjectSize = {}) {
+ const { xSizing = 'fill', ySizing = 'fill' } = sizing;
+
+ const style: CSSProperties = {
+ display: 'flex',
+ flexDirection: direction,
+ minWidth: sizing.minWidth,
+ minHeight: sizing.minHeight,
+ };
+
+ if (direction === 'column') {
+ if (sizing.height) {
+ style.height = sizing.height;
+ } else {
+ style.flexGrow = ySizing === 'fill' ? 1 : 0;
+ }
+
+ if (sizing.width) {
+ style.width = sizing.width;
+ } else {
+ style.alignSelf = xSizing === 'fill' ? 'stretch' : 'flex-start';
+ }
+ } else {
+ if (sizing.height) {
+ style.height = sizing.height;
+ } else {
+ style.alignSelf = ySizing === 'fill' ? 'stretch' : 'flex-start';
+ }
+
+ if (sizing.width) {
+ style.width = sizing.width;
+ } else {
+ style.flexGrow = xSizing === 'fill' ? 1 : 0;
+ }
+ }
+
+ return style;
+}
+
+function FlexLayoutEditor({ model }: SceneComponentProps) {
+ const { direction = 'row' } = model.useState();
+ const options = [
+ { icon: 'arrow-right', value: 'row' },
+ { icon: 'arrow-down', value: 'column' },
+ ];
+
+ return (
+
+ model.setState({ direction: value as FlexLayoutDirection })}
+ />
+
+ );
+}
diff --git a/public/app/features/scenes/components/ScenePanelRepeater.tsx b/public/app/features/scenes/components/ScenePanelRepeater.tsx
new file mode 100644
index 00000000000..6d2ba7569e6
--- /dev/null
+++ b/public/app/features/scenes/components/ScenePanelRepeater.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+
+import { LoadingState, PanelData } from '@grafana/data';
+
+import { SceneDataNode } from '../core/SceneDataNode';
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneComponentProps, SceneObject, SceneObjectList, SceneObjectState, SceneLayoutState } from '../core/types';
+
+interface RepeatOptions extends SceneObjectState {
+ layout: SceneObject;
+}
+
+export class ScenePanelRepeater extends SceneObjectBase {
+ onMount() {
+ super.onMount();
+
+ this.subs.add(
+ this.getData().subscribe({
+ next: (data) => {
+ if (data.data?.state === LoadingState.Done) {
+ this.performRepeat(data.data);
+ }
+ },
+ })
+ );
+ }
+
+ performRepeat(data: PanelData) {
+ // assume parent is a layout
+ const firstChild = this.state.layout.state.children[0]!;
+ const newChildren: SceneObjectList = [];
+
+ for (const series of data.series) {
+ const clone = firstChild.clone({
+ key: `${newChildren.length}`,
+ $data: new SceneDataNode({
+ data: {
+ ...data,
+ series: [series],
+ },
+ }),
+ });
+
+ newChildren.push(clone);
+ }
+
+ this.state.layout.setState({ children: newChildren });
+ }
+
+ static Component = ({ model, isEditing }: SceneComponentProps) => {
+ const { layout } = model.useState();
+ return ;
+ };
+}
diff --git a/public/app/features/scenes/components/SceneTimePicker.tsx b/public/app/features/scenes/components/SceneTimePicker.tsx
new file mode 100644
index 00000000000..5ef53b264c5
--- /dev/null
+++ b/public/app/features/scenes/components/SceneTimePicker.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+
+import { RefreshPicker, ToolbarButtonRow } from '@grafana/ui';
+import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory';
+
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneComponentProps, SceneObjectState } from '../core/types';
+
+export interface SceneTimePickerState extends SceneObjectState {
+ hidePicker?: boolean;
+}
+
+export class SceneTimePicker extends SceneObjectBase {
+ static Component = SceneTimePickerRenderer;
+}
+
+function SceneTimePickerRenderer({ model }: SceneComponentProps) {
+ const { hidePicker } = model.useState();
+ const timeRange = model.getTimeRange();
+ const timeRangeState = timeRange.useState();
+
+ if (hidePicker) {
+ return null;
+ }
+
+ return (
+
+ {}}
+ onMoveForward={() => {}}
+ onZoom={() => {}}
+ onChangeTimeZone={() => {}}
+ onChangeFiscalYearStartMonth={() => {}}
+ />
+
+
+
+ );
+}
diff --git a/public/app/features/scenes/components/SceneToolbarButton.tsx b/public/app/features/scenes/components/SceneToolbarButton.tsx
new file mode 100644
index 00000000000..a7c588a0e0e
--- /dev/null
+++ b/public/app/features/scenes/components/SceneToolbarButton.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+
+import { IconName, Input, ToolbarButton } from '@grafana/ui';
+
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneComponentProps, SceneObjectState } from '../core/types';
+
+export interface ToolbarButtonState extends SceneObjectState {
+ icon: IconName;
+ onClick: () => void;
+}
+
+export class SceneToolbarButton extends SceneObjectBase {
+ static Component = ({ model }: SceneComponentProps) => {
+ const state = model.useState();
+
+ return ;
+ };
+}
+
+export interface SceneToolbarInputState extends SceneObjectState {
+ value?: string;
+ onChange: (value: number) => void;
+}
+
+export class SceneToolbarInput extends SceneObjectBase {
+ static Component = ({ model }: SceneComponentProps) => {
+ const state = model.useState();
+
+ return (
+ {
+ model.state.onChange(parseInt(evt.currentTarget.value, 10));
+ }}
+ />
+ );
+ };
+}
diff --git a/public/app/features/scenes/components/VizPanel.tsx b/public/app/features/scenes/components/VizPanel.tsx
new file mode 100644
index 00000000000..dee9ef6865d
--- /dev/null
+++ b/public/app/features/scenes/components/VizPanel.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import AutoSizer from 'react-virtualized-auto-sizer';
+
+import { AbsoluteTimeRange, FieldConfigSource, toUtc } from '@grafana/data';
+import { PanelRenderer } from '@grafana/runtime';
+import { Field, PanelChrome, Input } from '@grafana/ui';
+
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneComponentProps, SceneObjectState } from '../core/types';
+
+export interface VizPanelState extends SceneObjectState {
+ title?: string;
+ pluginId: string;
+ options?: object;
+ fieldConfig?: FieldConfigSource;
+}
+
+export class VizPanel extends SceneObjectBase {
+ static Component = ScenePanelRenderer;
+ static Editor = VizPanelEditor;
+
+ onSetTimeRange = (timeRange: AbsoluteTimeRange) => {
+ const sceneTimeRange = this.getTimeRange();
+ sceneTimeRange.setState({
+ raw: {
+ from: toUtc(timeRange.from),
+ to: toUtc(timeRange.to),
+ },
+ from: toUtc(timeRange.from),
+ to: toUtc(timeRange.to),
+ });
+ };
+}
+
+function ScenePanelRenderer({ model }: SceneComponentProps) {
+ const { title, pluginId, options, fieldConfig } = model.useState();
+ const { data } = model.getData().useState();
+
+ return (
+
+ {({ width, height }) => {
+ if (width < 3 || height < 3) {
+ return null;
+ }
+
+ return (
+
+ {(innerWidth, innerHeight) => (
+ <>
+ {}}
+ onChangeTimeRange={model.onSetTimeRange}
+ />
+ >
+ )}
+
+ );
+ }}
+
+ );
+}
+
+ScenePanelRenderer.displayName = 'ScenePanelRenderer';
+
+function VizPanelEditor({ model }: SceneComponentProps) {
+ const { title } = model.useState();
+
+ return (
+
+ model.setState({ title: evt.currentTarget.value })} />
+
+ );
+}
diff --git a/public/app/features/scenes/core/SceneComponentEditWrapper.tsx b/public/app/features/scenes/core/SceneComponentEditWrapper.tsx
new file mode 100644
index 00000000000..9bbde1c2511
--- /dev/null
+++ b/public/app/features/scenes/core/SceneComponentEditWrapper.tsx
@@ -0,0 +1,82 @@
+import { css } from '@emotion/css';
+import React, { CSSProperties } from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2 } from '@grafana/ui';
+
+import { SceneObjectBase } from './SceneObjectBase';
+import { SceneComponentProps } from './types';
+
+export function SceneComponentEditWrapper>({
+ model,
+ isEditing,
+}: SceneComponentProps) {
+ const Component = (model as any).constructor['Component'] ?? EmptyRenderer;
+ const inner = ;
+
+ model.useMount();
+
+ if (!isEditing) {
+ return inner;
+ }
+
+ return {inner} ;
+}
+
+export function SceneComponentEditingWrapper>({
+ model,
+ children,
+}: {
+ model: T;
+ children: React.ReactNode;
+}) {
+ const styles = useStyles2(getStyles);
+ const editor = model.getSceneEditor();
+ const { hoverObject, selectedObject } = editor.useState();
+
+ const onMouseEnter = () => editor.onMouseEnterObject(model);
+ const onMouseLeave = () => editor.onMouseLeaveObject(model);
+
+ const onClick = (evt: React.MouseEvent) => {
+ evt.stopPropagation();
+ editor.onSelectObject(model);
+ };
+
+ const style: CSSProperties = {};
+ let className = styles.wrapper;
+
+ if (hoverObject?.ref === model) {
+ className += ' ' + styles.hover;
+ }
+ if (selectedObject?.ref === model) {
+ className += ' ' + styles.selected;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ wrapper: css({
+ display: 'flex',
+ flexGrow: 1,
+ padding: 8,
+ border: `1px dashed ${theme.colors.primary.main}`,
+ cursor: 'pointer',
+ }),
+ hover: css({
+ border: `1px solid ${theme.colors.primary.border}`,
+ }),
+ selected: css({
+ border: `1px solid ${theme.colors.error.border}`,
+ }),
+ };
+};
+
+function EmptyRenderer(_: SceneComponentProps): React.ReactElement | null {
+ return null;
+}
diff --git a/public/app/features/scenes/core/SceneDataNode.ts b/public/app/features/scenes/core/SceneDataNode.ts
new file mode 100644
index 00000000000..adaf6b256e4
--- /dev/null
+++ b/public/app/features/scenes/core/SceneDataNode.ts
@@ -0,0 +1,10 @@
+import { PanelData } from '@grafana/data';
+
+import { SceneObjectBase } from './SceneObjectBase';
+import { SceneObjectState } from './types';
+
+export interface SceneDataNodeState extends SceneObjectState {
+ data?: PanelData;
+}
+
+export class SceneDataNode extends SceneObjectBase {}
diff --git a/public/app/features/scenes/core/SceneObjectBase.test.ts b/public/app/features/scenes/core/SceneObjectBase.test.ts
new file mode 100644
index 00000000000..2ac12132c81
--- /dev/null
+++ b/public/app/features/scenes/core/SceneObjectBase.test.ts
@@ -0,0 +1,68 @@
+import { SceneObjectBase } from './SceneObjectBase';
+import { SceneObject, SceneObjectList, SceneObjectState } from './types';
+
+interface TestSceneState extends SceneObjectState {
+ name?: string;
+ nested?: SceneObject;
+ children?: SceneObjectList;
+ actions?: SceneObjectList;
+}
+
+class TestScene extends SceneObjectBase {}
+
+describe('SceneObject', () => {
+ it('Can clone', () => {
+ const scene = new TestScene({
+ nested: new TestScene({
+ name: 'nested',
+ }),
+ children: [
+ new TestScene({
+ name: 'layout child',
+ }),
+ ],
+ });
+
+ scene.state.nested?.onMount();
+
+ const clone = scene.clone();
+ expect(clone).not.toBe(scene);
+ expect(clone.state.nested).not.toBe(scene.state.nested);
+ expect(clone.state.nested?.isMounted).toBe(undefined);
+ expect(clone.state.children![0]).not.toBe(scene.state.children![0]);
+ });
+
+ it('SceneObject should have parent when added to container', () => {
+ const scene = new TestScene({
+ nested: new TestScene({
+ name: 'nested',
+ }),
+ children: [
+ new TestScene({
+ name: 'layout child',
+ }),
+ ],
+ actions: [
+ new TestScene({
+ name: 'layout child',
+ }),
+ ],
+ });
+
+ expect(scene.parent).toBe(undefined);
+ expect(scene.state.nested?.parent).toBe(scene);
+ expect(scene.state.children![0].parent).toBe(scene);
+ expect(scene.state.actions![0].parent).toBe(scene);
+ });
+
+ it('Can clone with state change', () => {
+ const scene = new TestScene({
+ nested: new TestScene({
+ name: 'nested',
+ }),
+ });
+
+ const clone = scene.clone({ name: 'new name' });
+ expect(clone.state.name).toBe('new name');
+ });
+});
diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx
new file mode 100644
index 00000000000..89c08b11bdd
--- /dev/null
+++ b/public/app/features/scenes/core/SceneObjectBase.tsx
@@ -0,0 +1,220 @@
+import { useEffect } from 'react';
+import { useObservable } from 'react-use';
+import { Observer, Subject, Subscription } from 'rxjs';
+import { v4 as uuidv4 } from 'uuid';
+
+import { EventBusSrv } from '@grafana/data';
+
+import { SceneComponentEditWrapper } from './SceneComponentEditWrapper';
+import { SceneObjectStateChangedEvent } from './events';
+import {
+ SceneDataState,
+ SceneObject,
+ SceneLayoutState,
+ SceneObjectState,
+ SceneComponent,
+ SceneEditor,
+ SceneObjectList,
+ SceneTimeRange,
+} from './types';
+
+export abstract class SceneObjectBase implements SceneObject {
+ subject = new Subject();
+ state: TState;
+ parent?: SceneObjectBase;
+ subs = new Subscription();
+ isMounted?: boolean;
+ events = new EventBusSrv();
+
+ constructor(state: TState) {
+ if (!state.key) {
+ state.key = uuidv4();
+ }
+
+ this.state = state;
+ this.subject.next(state);
+ this.setParent();
+ }
+
+ /**
+ * Used in render functions when rendering a SceneObject.
+ * Wraps the component in an EditWrapper that handles edit mode
+ */
+ get Component(): SceneComponent {
+ return SceneComponentEditWrapper;
+ }
+
+ /**
+ * Temporary solution, should be replaced by declarative options
+ */
+ get Editor(): SceneComponent {
+ return ((this as any).constructor['Editor'] ?? (() => null)) as SceneComponent;
+ }
+
+ private setParent() {
+ for (const propValue of Object.values(this.state)) {
+ if (propValue instanceof SceneObjectBase) {
+ propValue.parent = this;
+ }
+
+ if (Array.isArray(propValue)) {
+ for (const child of propValue) {
+ if (child instanceof SceneObjectBase) {
+ child.parent = this;
+ }
+ }
+ }
+ }
+ }
+
+ /** This function implements the Subscribable interface */
+ subscribe(observer: Partial>) {
+ return this.subject.subscribe(observer);
+ }
+
+ setState(update: Partial) {
+ const prevState = this.state;
+ this.state = {
+ ...this.state,
+ ...update,
+ };
+ this.setParent();
+ this.subject.next(this.state);
+
+ // broadcast state change. This is event is subscribed to by UrlSyncManager and UndoManager
+ this.getRoot().events.publish(
+ new SceneObjectStateChangedEvent({
+ prevState,
+ newState: this.state,
+ partialUpdate: update,
+ changedObject: this,
+ })
+ );
+ }
+
+ private getRoot(): SceneObject {
+ return !this.parent ? this : this.parent.getRoot();
+ }
+
+ onMount() {
+ this.isMounted = true;
+
+ const { $data } = this.state;
+ if ($data && !$data.isMounted) {
+ $data.onMount();
+ }
+ }
+
+ onUnmount() {
+ this.isMounted = false;
+
+ const { $data } = this.state;
+ if ($data && $data.isMounted) {
+ $data.onUnmount();
+ }
+
+ this.subs.unsubscribe();
+ this.subs = new Subscription();
+ }
+
+ /**
+ * The scene object needs to know when the react component is mounted to trigger query and other lazy actions
+ */
+ useMount() {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useEffect(() => {
+ if (!this.isMounted) {
+ this.onMount();
+ }
+ return () => {
+ if (this.isMounted) {
+ this.onUnmount();
+ }
+ };
+ }, []);
+
+ return this;
+ }
+
+ useState() {
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ return useObservable(this.subject, this.state);
+ }
+
+ /**
+ * Will walk up the scene object graph to the closest $timeRange scene object
+ */
+ getTimeRange(): SceneTimeRange {
+ const { $timeRange } = this.state;
+ if ($timeRange) {
+ return $timeRange;
+ }
+
+ if (this.parent) {
+ return this.parent.getTimeRange();
+ }
+
+ throw new Error('No time range found in scene tree');
+ }
+
+ /**
+ * Will walk up the scene object graph to the closest $data scene object
+ */
+ getData(): SceneObject {
+ const { $data } = this.state;
+ if ($data) {
+ return $data;
+ }
+
+ if (this.parent) {
+ return this.parent.getData();
+ }
+
+ throw new Error('No data found in scene tree');
+ }
+
+ /**
+ * Will walk up the scene object graph to the closest $editor scene object
+ */
+ getSceneEditor(): SceneEditor {
+ const { $editor } = this.state;
+ if ($editor) {
+ return $editor;
+ }
+
+ if (this.parent) {
+ return this.parent.getSceneEditor();
+ }
+
+ throw new Error('No editor found in scene tree');
+ }
+
+ /**
+ * Will create new SceneItem with shalled cloned state, but all states items of type SceneItem are deep cloned
+ */
+ clone(withState?: Partial): this {
+ const clonedState = { ...this.state };
+
+ // Clone any SceneItems in state
+ for (const key in clonedState) {
+ const propValue = clonedState[key];
+ if (propValue instanceof SceneObjectBase) {
+ clonedState[key] = propValue.clone();
+ }
+ }
+
+ // Clone layout children
+ const layout = this.state as any as SceneLayoutState;
+ if (layout.children) {
+ const newChildren: SceneObjectList = [];
+ for (const child of layout.children) {
+ newChildren.push(child.clone());
+ }
+ (clonedState as any).children = newChildren;
+ }
+
+ Object.assign(clonedState, withState);
+
+ return new (this.constructor as any)(clonedState);
+ }
+}
diff --git a/public/app/features/scenes/core/SceneTimeRange.tsx b/public/app/features/scenes/core/SceneTimeRange.tsx
new file mode 100644
index 00000000000..595db0d864b
--- /dev/null
+++ b/public/app/features/scenes/core/SceneTimeRange.tsx
@@ -0,0 +1,29 @@
+import { TimeRange, UrlQueryMap } from '@grafana/data';
+
+import { SceneObjectBase } from './SceneObjectBase';
+import { SceneObjectWithUrlSync, SceneTimeRangeState } from './types';
+
+export class SceneTimeRange extends SceneObjectBase implements SceneObjectWithUrlSync {
+ onTimeRangeChange = (timeRange: TimeRange) => {
+ this.setState(timeRange);
+ };
+
+ onRefresh = () => {
+ // TODO re-eval time range
+ this.setState({ ...this.state });
+ };
+
+ onIntervalChanged = (_: string) => {};
+
+ /** These url sync functions are only placeholders for something more sophisticated */
+ getUrlState() {
+ return {
+ from: this.state.raw.from,
+ to: this.state.raw.to,
+ } as any;
+ }
+
+ updateFromUrl(values: UrlQueryMap) {
+ // TODO
+ }
+}
diff --git a/public/app/features/scenes/core/events.ts b/public/app/features/scenes/core/events.ts
new file mode 100644
index 00000000000..a0113c03311
--- /dev/null
+++ b/public/app/features/scenes/core/events.ts
@@ -0,0 +1,14 @@
+import { BusEventWithPayload } from '@grafana/data';
+
+import { SceneObject } from './types';
+
+export interface SceneObjectStateChangedPayload {
+ prevState: any;
+ newState: any;
+ partialUpdate: any;
+ changedObject: SceneObject;
+}
+
+export class SceneObjectStateChangedEvent extends BusEventWithPayload {
+ static type = 'scene-object-state-change';
+}
diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts
new file mode 100644
index 00000000000..f44bfee7b21
--- /dev/null
+++ b/public/app/features/scenes/core/types.ts
@@ -0,0 +1,122 @@
+import React from 'react';
+import { Subscribable } from 'rxjs';
+
+import { EventBus, PanelData, TimeRange, UrlQueryMap } from '@grafana/data';
+
+import { SceneVariableSet } from '../variables/types';
+
+export interface SceneObjectState {
+ key?: string;
+ size?: SceneObjectSize;
+ $timeRange?: SceneTimeRange;
+ $data?: SceneObject;
+ $editor?: SceneEditor;
+ $variables?: SceneVariableSet;
+}
+
+export interface SceneObjectSize {
+ width?: number | string;
+ height?: number | string;
+ xSizing?: 'fill' | 'content';
+ ySizing?: 'fill' | 'content';
+ x?: number;
+ y?: number;
+ minWidth?: number | string;
+ minHeight?: number | string;
+}
+
+export interface SceneComponentProps {
+ model: T;
+ isEditing?: boolean;
+}
+
+export type SceneComponent = React.FunctionComponent>;
+
+export interface SceneDataState extends SceneObjectState {
+ data?: PanelData;
+}
+
+export interface SceneObject extends Subscribable {
+ /** The current state */
+ state: TState;
+
+ /** True when there is a React component mounted for this Object */
+ isMounted?: boolean;
+
+ /** SceneObject parent */
+ parent?: SceneObject;
+
+ /** Currently only used from root to broadcast events */
+ events: EventBus;
+
+ /** Utility hook that wraps useObservable. Used by React components to subscribes to state changes */
+ useState(): TState;
+
+ /** How to modify state */
+ setState(state: Partial): void;
+
+ /** Utility hook for main component so that object knows when it's mounted */
+ useMount(): this;
+
+ /** Called when component mounts. A place to register event listeners add subscribe to state changes */
+ onMount(): void;
+
+ /** Called when component unmounts. Unsubscribe to events */
+ onUnmount(): void;
+
+ /** Get the scene editor */
+ getSceneEditor(): SceneEditor;
+
+ /** Returns a deep clone this object and all it's children */
+ clone(state?: Partial): this;
+
+ /** A React component to use for rendering the object */
+ Component(props: SceneComponentProps>): React.ReactElement | null;
+
+ /** To be replaced by declarative method */
+ Editor(props: SceneComponentProps>): React.ReactElement | null;
+}
+
+export type SceneObjectList = Array>;
+
+export interface SceneLayoutState extends SceneObjectState {
+ children: SceneObjectList;
+}
+
+export type SceneLayout = SceneObject;
+
+export interface SceneEditorState extends SceneObjectState {
+ hoverObject?: SceneObjectRef;
+ selectedObject?: SceneObjectRef;
+}
+
+export interface SceneEditor extends SceneObject {
+ onMouseEnterObject(model: SceneObject): void;
+ onMouseLeaveObject(model: SceneObject): void;
+ onSelectObject(model: SceneObject): void;
+}
+
+export interface SceneTimeRangeState extends SceneObjectState, TimeRange {}
+export interface SceneTimeRange extends SceneObject {
+ onTimeRangeChange(timeRange: TimeRange): void;
+ onIntervalChanged(interval: string): void;
+ onRefresh(): void;
+}
+
+export interface SceneObjectRef {
+ ref: SceneObject;
+}
+
+export function isSceneObject(obj: any): obj is SceneObject {
+ return obj.useState !== undefined;
+}
+
+/** These functions are still just temporary until this get's refined */
+export interface SceneObjectWithUrlSync extends SceneObject {
+ getUrlState(): UrlQueryMap;
+ updateFromUrl(values: UrlQueryMap): void;
+}
+
+export function isSceneObjectWithUrlSync(obj: any): obj is SceneObjectWithUrlSync {
+ return obj.getUrlState !== undefined;
+}
diff --git a/public/app/features/scenes/editor/SceneEditManager.tsx b/public/app/features/scenes/editor/SceneEditManager.tsx
new file mode 100644
index 00000000000..bfa22349e71
--- /dev/null
+++ b/public/app/features/scenes/editor/SceneEditManager.tsx
@@ -0,0 +1,70 @@
+import { css } from '@emotion/css';
+import React from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { useStyles2 } from '@grafana/ui';
+
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneEditorState, SceneEditor, SceneObject, SceneComponentProps, SceneComponent } from '../core/types';
+
+import { SceneObjectEditor } from './SceneObjectEditor';
+import { SceneObjectTree } from './SceneObjectTree';
+
+export class SceneEditManager extends SceneObjectBase implements SceneEditor {
+ static Component = SceneEditorRenderer;
+
+ get Component(): SceneComponent {
+ return SceneEditorRenderer;
+ }
+
+ onMouseEnterObject(model: SceneObject) {
+ this.setState({ hoverObject: { ref: model } });
+ }
+
+ onMouseLeaveObject(model: SceneObject) {
+ if (model.parent) {
+ this.setState({ hoverObject: { ref: model.parent } });
+ } else {
+ this.setState({ hoverObject: undefined });
+ }
+ }
+
+ onSelectObject(model: SceneObject) {
+ this.setState({ selectedObject: { ref: model } });
+ }
+}
+
+function SceneEditorRenderer({ model, isEditing }: SceneComponentProps) {
+ const { selectedObject } = model.useState();
+ const styles = useStyles2(getStyles);
+
+ if (!isEditing) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ {selectedObject &&
}
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ container: css({
+ display: 'flex',
+ flexGrow: 0,
+ border: `1px solid ${theme.colors.border.weak}`,
+ background: theme.colors.background.primary,
+ width: theme.spacing(40),
+ cursor: 'pointer',
+ flexDirection: 'column',
+ }),
+ tree: css({
+ padding: theme.spacing(0.25, 1),
+ }),
+ };
+};
diff --git a/public/app/features/scenes/editor/SceneObjectEditor.tsx b/public/app/features/scenes/editor/SceneObjectEditor.tsx
new file mode 100644
index 00000000000..269303bd9a7
--- /dev/null
+++ b/public/app/features/scenes/editor/SceneObjectEditor.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory';
+
+import { SceneObject } from '../core/types';
+
+export interface Props {
+ model: SceneObject;
+}
+
+export function SceneObjectEditor({ model }: Props) {
+ return (
+
+
+
+ );
+}
diff --git a/public/app/features/scenes/editor/SceneObjectTree.tsx b/public/app/features/scenes/editor/SceneObjectTree.tsx
new file mode 100644
index 00000000000..13c33b42e80
--- /dev/null
+++ b/public/app/features/scenes/editor/SceneObjectTree.tsx
@@ -0,0 +1,81 @@
+import { css, cx } from '@emotion/css';
+import React from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { Icon, useStyles2 } from '@grafana/ui';
+
+import { SceneObject, SceneLayoutState, SceneObjectList, isSceneObject } from '../core/types';
+
+export interface Props {
+ node: SceneObject;
+ selectedObject?: SceneObject;
+}
+
+export function SceneObjectTree({ node, selectedObject }: Props) {
+ const styles = useStyles2(getStyles);
+ const state = node.useState();
+ let children: SceneObjectList = [];
+
+ for (const propKey of Object.keys(state)) {
+ const propValue = (state as any)[propKey];
+ if (isSceneObject(propValue)) {
+ children.push(propValue);
+ }
+ }
+
+ let layoutChildren = (state as SceneLayoutState).children;
+ if (layoutChildren) {
+ for (const child of layoutChildren) {
+ children.push(child);
+ }
+ }
+
+ const name = node.constructor.name;
+ const isSelected = selectedObject === node;
+ const onSelectNode = () => node.getSceneEditor().onSelectObject(node);
+
+ return (
+
+
+
{children.length > 0 && }
+
{name}
+
+ {children.length > 0 && (
+
+ {children.map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => {
+ return {
+ node: css({
+ display: 'flex',
+ flexGrow: 0,
+ cursor: 'pointer',
+ flexDirection: 'column',
+ padding: '2px 4px',
+ }),
+ header: css({
+ display: 'flex',
+ fontWeight: 500,
+ }),
+ name: css({}),
+ selected: css({
+ color: theme.colors.error.text,
+ }),
+ icon: css({
+ width: theme.spacing(3),
+ color: theme.colors.text.secondary,
+ }),
+ children: css({
+ display: 'flex',
+ flexDirection: 'column',
+ paddingLeft: 8,
+ }),
+ };
+};
diff --git a/public/app/features/scenes/querying/SceneQueryRunner.ts b/public/app/features/scenes/querying/SceneQueryRunner.ts
new file mode 100644
index 00000000000..fb76b1c5088
--- /dev/null
+++ b/public/app/features/scenes/querying/SceneQueryRunner.ts
@@ -0,0 +1,133 @@
+import { cloneDeep } from 'lodash';
+import { Unsubscribable } from 'rxjs';
+
+import {
+ CoreApp,
+ DataQuery,
+ DataQueryRequest,
+ DataSourceApi,
+ DataSourceRef,
+ PanelData,
+ rangeUtil,
+ ScopedVars,
+ TimeRange,
+} from '@grafana/data';
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { getNextRequestId } from 'app/features/query/state/PanelQueryRunner';
+import { runRequest } from 'app/features/query/state/runRequest';
+
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneObjectState } from '../core/types';
+
+export interface QueryRunnerState extends SceneObjectState {
+ data?: PanelData;
+ queries: DataQueryExtended[];
+}
+
+export interface DataQueryExtended extends DataQuery {
+ [key: string]: any;
+}
+
+export class SceneQueryRunner extends SceneObjectBase {
+ private querySub?: Unsubscribable;
+
+ onMount() {
+ super.onMount();
+
+ const timeRange = this.getTimeRange();
+
+ this.subs.add(
+ timeRange.subscribe({
+ next: (timeRange) => {
+ this.runWithTimeRange(timeRange);
+ },
+ })
+ );
+
+ if (!this.state.data) {
+ this.runQueries();
+ }
+ }
+
+ onUnmount() {
+ super.onUnmount();
+ this.cleanUp();
+ }
+
+ cleanUp() {
+ if (this.querySub) {
+ this.querySub.unsubscribe();
+ this.querySub = undefined;
+ }
+ }
+
+ runQueries() {
+ const timeRange = this.getTimeRange();
+ this.runWithTimeRange(timeRange.state);
+ }
+
+ private async runWithTimeRange(timeRange: TimeRange) {
+ const queries = cloneDeep(this.state.queries);
+
+ const request: DataQueryRequest = {
+ app: CoreApp.Dashboard,
+ requestId: getNextRequestId(),
+ timezone: 'browser',
+ panelId: 1,
+ dashboardId: 1,
+ range: timeRange,
+ interval: '1s',
+ intervalMs: 1000,
+ targets: cloneDeep(this.state.queries),
+ maxDataPoints: 500,
+ scopedVars: {},
+ startTime: Date.now(),
+ };
+
+ try {
+ const ds = await getDataSource(queries[0].datasource!, request.scopedVars);
+
+ // Attach the data source name to each query
+ request.targets = request.targets.map((query) => {
+ if (!query.datasource) {
+ query.datasource = ds.getRef();
+ }
+ return query;
+ });
+
+ const lowerIntervalLimit = ds.interval;
+ const norm = rangeUtil.calculateInterval(timeRange, request.maxDataPoints ?? 1000, lowerIntervalLimit);
+
+ // make shallow copy of scoped vars,
+ // and add built in variables interval and interval_ms
+ request.scopedVars = Object.assign({}, request.scopedVars, {
+ __interval: { text: norm.interval, value: norm.interval },
+ __interval_ms: { text: norm.intervalMs.toString(), value: norm.intervalMs },
+ });
+
+ request.interval = norm.interval;
+ request.intervalMs = norm.intervalMs;
+
+ console.log('Query runner run');
+
+ this.querySub = runRequest(ds, request).subscribe({
+ next: (data) => {
+ console.log('set data', data, data.state);
+ this.setState({ data });
+ },
+ });
+ } catch (err) {
+ console.error('PanelQueryRunner Error', err);
+ }
+ }
+}
+
+async function getDataSource(
+ datasource: DataSourceRef | string | DataSourceApi | null,
+ scopedVars: ScopedVars
+): Promise {
+ if (datasource && (datasource as any).query) {
+ return datasource as DataSourceApi;
+ }
+ return await getDatasourceSrv().get(datasource as string, scopedVars);
+}
diff --git a/public/app/features/scenes/scenes/demo.tsx b/public/app/features/scenes/scenes/demo.tsx
new file mode 100644
index 00000000000..fd46030217d
--- /dev/null
+++ b/public/app/features/scenes/scenes/demo.tsx
@@ -0,0 +1,140 @@
+import { getDefaultTimeRange } from '@grafana/data';
+
+import { Scene } from '../components/Scene';
+import { SceneCanvasText } from '../components/SceneCanvasText';
+import { SceneFlexLayout } from '../components/SceneFlexLayout';
+import { ScenePanelRepeater } from '../components/ScenePanelRepeater';
+import { SceneTimePicker } from '../components/SceneTimePicker';
+import { SceneToolbarInput } from '../components/SceneToolbarButton';
+import { VizPanel } from '../components/VizPanel';
+import { SceneTimeRange } from '../core/SceneTimeRange';
+import { SceneEditManager } from '../editor/SceneEditManager';
+import { SceneQueryRunner } from '../querying/SceneQueryRunner';
+
+export function getFlexLayoutTest(): Scene {
+ const scene = new Scene({
+ title: 'Flex layout test',
+ layout: new SceneFlexLayout({
+ direction: 'row',
+ children: [
+ new VizPanel({
+ pluginId: 'timeseries',
+ title: 'Dynamic height and width',
+ size: { minWidth: '70%' },
+ }),
+ new SceneFlexLayout({
+ // size: { width: 450 },
+ direction: 'column',
+ children: [
+ new VizPanel({
+ pluginId: 'timeseries',
+ title: 'Fill height',
+ }),
+ new VizPanel({
+ pluginId: 'timeseries',
+ title: 'Fill height',
+ }),
+ new SceneCanvasText({
+ text: 'Size to content',
+ fontSize: 20,
+ size: { ySizing: 'content' },
+ align: 'center',
+ }),
+ new VizPanel({
+ pluginId: 'timeseries',
+ title: 'Fixed height',
+ size: { height: 300 },
+ }),
+ ],
+ }),
+ ],
+ }),
+ $editor: new SceneEditManager({}),
+ $timeRange: new SceneTimeRange(getDefaultTimeRange()),
+ $data: new SceneQueryRunner({
+ queries: [
+ {
+ refId: 'A',
+ datasource: {
+ uid: 'gdev-testdata',
+ type: 'testdata',
+ },
+ scenarioId: 'random_walk',
+ },
+ ],
+ }),
+ actions: [new SceneTimePicker({})],
+ });
+
+ return scene;
+}
+
+export function getScenePanelRepeaterTest(): Scene {
+ const queryRunner = new SceneQueryRunner({
+ queries: [
+ {
+ refId: 'A',
+ datasource: {
+ uid: 'gdev-testdata',
+ type: 'testdata',
+ },
+ seriesCount: 5,
+ alias: '__server_names',
+ scenarioId: 'random_walk',
+ },
+ ],
+ });
+
+ const scene = new Scene({
+ title: 'Panel repeater test',
+ layout: new ScenePanelRepeater({
+ layout: new SceneFlexLayout({
+ direction: 'column',
+ children: [
+ new SceneFlexLayout({
+ size: { minHeight: 200 },
+ children: [
+ new VizPanel({
+ pluginId: 'timeseries',
+ title: 'Title',
+ options: {
+ legend: { displayMode: 'hidden' },
+ },
+ }),
+ new VizPanel({
+ size: { width: 300 },
+ pluginId: 'stat',
+ fieldConfig: { defaults: { displayName: 'Last' }, overrides: [] },
+ options: {
+ graphMode: 'none',
+ },
+ }),
+ ],
+ }),
+ ],
+ }),
+ }),
+ $editor: new SceneEditManager({}),
+ $timeRange: new SceneTimeRange(getDefaultTimeRange()),
+ $data: queryRunner,
+ actions: [
+ new SceneToolbarInput({
+ value: '5',
+ onChange: (newValue) => {
+ queryRunner.setState({
+ queries: [
+ {
+ ...queryRunner.state.queries[0],
+ seriesCount: newValue,
+ },
+ ],
+ });
+ queryRunner.runQueries();
+ },
+ }),
+ new SceneTimePicker({}),
+ ],
+ });
+
+ return scene;
+}
diff --git a/public/app/features/scenes/scenes/index.tsx b/public/app/features/scenes/scenes/index.tsx
new file mode 100644
index 00000000000..150d6b2b77b
--- /dev/null
+++ b/public/app/features/scenes/scenes/index.tsx
@@ -0,0 +1,23 @@
+import { Scene } from '../components/Scene';
+
+import { getFlexLayoutTest, getScenePanelRepeaterTest } from './demo';
+import { getNestedScene } from './nested';
+
+export function getScenes(): Scene[] {
+ return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene()];
+}
+
+const cache: Record = {};
+
+export function getSceneByTitle(title: string) {
+ if (cache[title]) {
+ return cache[title];
+ }
+
+ const scene = getScenes().find((x) => x.state.title === title);
+ if (scene) {
+ cache[title] = scene;
+ }
+
+ return scene;
+}
diff --git a/public/app/features/scenes/scenes/nested.tsx b/public/app/features/scenes/scenes/nested.tsx
new file mode 100644
index 00000000000..64076f1ca4c
--- /dev/null
+++ b/public/app/features/scenes/scenes/nested.tsx
@@ -0,0 +1,73 @@
+import { getDefaultTimeRange } from '@grafana/data';
+
+import { Scene } from '../components/Scene';
+import { SceneFlexLayout } from '../components/SceneFlexLayout';
+import { SceneTimePicker } from '../components/SceneTimePicker';
+import { VizPanel } from '../components/VizPanel';
+import { SceneTimeRange } from '../core/SceneTimeRange';
+import { SceneQueryRunner } from '../querying/SceneQueryRunner';
+
+export function getNestedScene(): Scene {
+ const scene = new Scene({
+ title: 'Nested Scene demo',
+ layout: new SceneFlexLayout({
+ direction: 'column',
+ children: [
+ new VizPanel({
+ key: '3',
+ pluginId: 'timeseries',
+ title: 'Panel 3',
+ }),
+ getInnerScene('Inner scene'),
+ ],
+ }),
+ $timeRange: new SceneTimeRange(getDefaultTimeRange()),
+ $data: new SceneQueryRunner({
+ queries: [
+ {
+ refId: 'A',
+ datasource: {
+ uid: 'gdev-testdata',
+ type: 'testdata',
+ },
+ scenarioId: 'random_walk',
+ },
+ ],
+ }),
+ actions: [new SceneTimePicker({})],
+ });
+
+ return scene;
+}
+
+export function getInnerScene(title: string): Scene {
+ const scene = new Scene({
+ title: title,
+ layout: new SceneFlexLayout({
+ direction: 'row',
+ children: [
+ new VizPanel({
+ key: '3',
+ pluginId: 'timeseries',
+ title: 'Data',
+ }),
+ ],
+ }),
+ $timeRange: new SceneTimeRange(getDefaultTimeRange()),
+ $data: new SceneQueryRunner({
+ queries: [
+ {
+ refId: 'A',
+ datasource: {
+ uid: 'gdev-testdata',
+ type: 'testdata',
+ },
+ scenarioId: 'random_walk',
+ },
+ ],
+ }),
+ actions: [new SceneTimePicker({})],
+ });
+
+ return scene;
+}
diff --git a/public/app/features/scenes/services/UrlSyncManager.ts b/public/app/features/scenes/services/UrlSyncManager.ts
new file mode 100644
index 00000000000..b065d90ef49
--- /dev/null
+++ b/public/app/features/scenes/services/UrlSyncManager.ts
@@ -0,0 +1,36 @@
+import { Location } from 'history';
+import { Unsubscribable } from 'rxjs';
+
+import { locationService } from '@grafana/runtime';
+
+import { SceneObjectStateChangedEvent } from '../core/events';
+import { isSceneObjectWithUrlSync, SceneObject } from '../core/types';
+
+export class UrlSyncManager {
+ private locationListenerUnsub: () => void;
+ private stateChangeSub: Unsubscribable;
+
+ constructor(sceneRoot: SceneObject) {
+ this.stateChangeSub = sceneRoot.events.subscribe(SceneObjectStateChangedEvent, this.onStateChanged);
+ this.locationListenerUnsub = locationService.getHistory().listen(this.onLocationUpdate);
+ }
+
+ onLocationUpdate = (location: Location) => {
+ // TODO: find any scene object whose state we need to update
+ };
+
+ onStateChanged = ({ payload }: SceneObjectStateChangedEvent) => {
+ const changedObject = payload.changedObject;
+ if (!isSceneObjectWithUrlSync(changedObject)) {
+ return;
+ }
+
+ const urlUpdate = changedObject.getUrlState();
+ locationService.partial(urlUpdate, true);
+ };
+
+ cleanUp() {
+ this.stateChangeSub.unsubscribe();
+ this.locationListenerUnsub();
+ }
+}
diff --git a/public/app/features/scenes/variables/SceneVariableSet.test.ts b/public/app/features/scenes/variables/SceneVariableSet.test.ts
new file mode 100644
index 00000000000..230b49545d4
--- /dev/null
+++ b/public/app/features/scenes/variables/SceneVariableSet.test.ts
@@ -0,0 +1,43 @@
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneObjectState } from '../core/types';
+
+import { sceneTemplateInterpolator, SceneVariableManager, TextBoxSceneVariable } from './SceneVariableSet';
+
+interface TestSceneState extends SceneObjectState {
+ nested?: TestScene;
+}
+
+class TestScene extends SceneObjectBase {}
+
+describe('SceneObject with variables', () => {
+ it('Should be interpolate and use closest variable', () => {
+ const scene = new TestScene({
+ $variables: new SceneVariableManager({
+ variables: [
+ new TextBoxSceneVariable({
+ name: 'test',
+ current: { value: 'hello' },
+ }),
+ new TextBoxSceneVariable({
+ name: 'atRootOnly',
+ current: { value: 'RootValue' },
+ }),
+ ],
+ }),
+ nested: new TestScene({
+ $variables: new SceneVariableManager({
+ variables: [
+ new TextBoxSceneVariable({
+ name: 'test',
+ current: { value: 'nestedValue' },
+ }),
+ ],
+ }),
+ }),
+ });
+
+ expect(sceneTemplateInterpolator('${test}', scene)).toBe('hello');
+ expect(sceneTemplateInterpolator('${test}', scene.state.nested!)).toBe('nestedValue');
+ expect(sceneTemplateInterpolator('${atRootOnly}', scene.state.nested!)).toBe('RootValue');
+ });
+});
diff --git a/public/app/features/scenes/variables/SceneVariableSet.ts b/public/app/features/scenes/variables/SceneVariableSet.ts
new file mode 100644
index 00000000000..13e0c21862a
--- /dev/null
+++ b/public/app/features/scenes/variables/SceneVariableSet.ts
@@ -0,0 +1,50 @@
+import { variableRegex } from 'app/features/variables/utils';
+
+import { SceneObjectBase } from '../core/SceneObjectBase';
+import { SceneObject } from '../core/types';
+
+import { SceneVariable, SceneVariableSet, SceneVariableSetState, SceneVariableState } from './types';
+
+export class TextBoxSceneVariable extends SceneObjectBase implements SceneVariable {}
+
+export class SceneVariableManager extends SceneObjectBase implements SceneVariableSet {
+ getVariableByName(name: string): SceneVariable | undefined {
+ // TODO: Replace with index
+ return this.state.variables.find((x) => x.state.name === name);
+ }
+}
+
+export function sceneTemplateInterpolator(target: string, sceneObject: SceneObject) {
+ variableRegex.lastIndex = 0;
+
+ return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => {
+ const variableName = var1 || var2 || var3;
+ const variable = lookupSceneVariable(variableName, sceneObject);
+
+ if (!variable) {
+ return match;
+ }
+
+ return variable.state.current.value;
+ });
+}
+
+function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVariable | null | undefined {
+ const variables = sceneObject.state.$variables;
+ if (!variables) {
+ if (sceneObject.parent) {
+ return lookupSceneVariable(name, sceneObject.parent);
+ } else {
+ return null;
+ }
+ }
+
+ const found = variables.getVariableByName(name);
+ if (found) {
+ return found;
+ } else if (sceneObject.parent) {
+ return lookupSceneVariable(name, sceneObject.parent);
+ }
+
+ return null;
+}
diff --git a/public/app/features/scenes/variables/types.ts b/public/app/features/scenes/variables/types.ts
new file mode 100644
index 00000000000..b3b80e3fcd5
--- /dev/null
+++ b/public/app/features/scenes/variables/types.ts
@@ -0,0 +1,24 @@
+import { LoadingState } from '@grafana/data';
+import { VariableHide } from 'app/features/variables/types';
+
+import { SceneObject, SceneObjectState } from '../core/types';
+
+export interface SceneVariableState extends SceneObjectState {
+ name: string;
+ hide?: VariableHide;
+ skipUrlSync?: boolean;
+ state?: LoadingState;
+ error?: any | null;
+ description?: string | null;
+ current: { value: string; text?: string };
+}
+
+export interface SceneVariable extends SceneObject {}
+
+export interface SceneVariableSetState extends SceneObjectState {
+ variables: SceneVariable[];
+}
+
+export interface SceneVariableSet extends SceneObject {
+ getVariableByName(name: string): SceneVariable | undefined;
+}
diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx
index 35015f93c54..b05e107eb9c 100644
--- a/public/app/routes/routes.tsx
+++ b/public/app/routes/routes.tsx
@@ -439,6 +439,7 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "NotificationsPage"*/ 'app/features/notifications/NotificationsPage')
),
},
+ ...getDynamicDashboardRoutes(),
...getPluginCatalogRoutes(),
...getLiveRoutes(),
...getAlertingRoutes(),
@@ -454,3 +455,19 @@ export function getAppRoutes(): RouteDescriptor[] {
// ...playlistRoutes,
];
}
+
+export function getDynamicDashboardRoutes(cfg = config): RouteDescriptor[] {
+ if (!cfg.featureToggles.scenes) {
+ return [];
+ }
+ return [
+ {
+ path: '/scenes',
+ component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/SceneListPage')),
+ },
+ {
+ path: '/scenes/:name',
+ component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/ScenePage')),
+ },
+ ];
+}