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')), + }, + ]; +}