mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Scene: POC for a future dashboard model and runtime (#50980)
* Playing around * This is getting interesting * Updates * Updated * Observable experiments * This is tricky * VizPanel panel renderer * New model progress * Maybe this could be something * Updated * Rename * updates * Updated * Query runners? not sure * Updated * updates * flex box layout starting to work * Testing * Tested an action * Parent context sort of working * Progress * Progress * Updated * Starting to work * Things are working * Scene list, nested scene demo * Progress on repeats * Moving things * Pretty big progress * More things working * Great progress * Progress * Name changing * Minor tweaks * Simplified sizing * Move toggleDirection to SceneFlexLayout * add feature flag (#50990) * removed new useObservable hook * Rename folder and feature toggle to scenes * Caching scenes so you can go back to another scene without having to re-query data * Fix issue with subs on re-mount * Fixing test * Added SceneCanvasText to play around with layout elements with size based on content * Scene: Edit mode and component edit wrapper that handles selection (#51078) * First step for scene variables * Started playing around with a scene edit mode * Better way to set component * Progress on edit mode * Update * Progress on edit mode * Progress on editor * Progress on editor * Updates * More working * Progress * Minor update * removed unnessary file * Moving things around * Updated * Making time range separate from time picker * minor rename of methods * The most basic variable start * Minor renames * Fixed interpolate issue if not found at closest level * An embryo of event model and url sync handling * Update url sync types * Removed unnessary any type arg Co-authored-by: Ryan McKinley <ryantxu@gmail.com> Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
@@ -5726,6 +5726,56 @@ exports[`better eslint`] = {
|
|||||||
"public/app/features/sandbox/TestStuffPage.tsx:5381": [
|
"public/app/features/sandbox/TestStuffPage.tsx:5381": [
|
||||||
[0, 0, 0, "Do not use any type assertions.", "0"]
|
[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": [
|
"public/app/features/search/components/SearchCard.tsx:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
|
[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.", "1"],
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface FeatureToggles {
|
|||||||
autoMigrateGraphPanels?: boolean;
|
autoMigrateGraphPanels?: boolean;
|
||||||
prometheusWideSeries?: boolean;
|
prometheusWideSeries?: boolean;
|
||||||
canvasPanelNesting?: boolean;
|
canvasPanelNesting?: boolean;
|
||||||
|
scenes?: boolean;
|
||||||
useLegacyHeatmapPanel?: boolean;
|
useLegacyHeatmapPanel?: boolean;
|
||||||
cloudMonitoringExperimentalUI?: boolean;
|
cloudMonitoringExperimentalUI?: boolean;
|
||||||
logRequestsInstrumentedAsUnknown?: boolean;
|
logRequestsInstrumentedAsUnknown?: boolean;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { AbsoluteTimeRange, FieldConfigSource, PanelData } from '@grafana/data';
|
|||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export interface PanelRendererProps<P extends object = any, F extends object = any> {
|
export interface PanelRendererProps<P extends object = any, F extends object = any> {
|
||||||
data: PanelData;
|
data?: PanelData;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
title: string;
|
title: string;
|
||||||
options?: Partial<P>;
|
options?: Partial<P>;
|
||||||
|
|||||||
@@ -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 {
|
if hasEditPerm {
|
||||||
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
|
dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{
|
||||||
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
|
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ var (
|
|||||||
State: FeatureStateAlpha,
|
State: FeatureStateAlpha,
|
||||||
FrontendOnly: true,
|
FrontendOnly: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "scenes",
|
||||||
|
Description: "Experimental framework to build interactive dashboards",
|
||||||
|
State: FeatureStateAlpha,
|
||||||
|
FrontendOnly: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "useLegacyHeatmapPanel",
|
Name: "useLegacyHeatmapPanel",
|
||||||
Description: "Continue to use the angular/flot based heatmap panel",
|
Description: "Continue to use the angular/flot based heatmap panel",
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ const (
|
|||||||
// Allow elements nesting
|
// Allow elements nesting
|
||||||
FlagCanvasPanelNesting = "canvasPanelNesting"
|
FlagCanvasPanelNesting = "canvasPanelNesting"
|
||||||
|
|
||||||
|
// FlagScenes
|
||||||
|
// Experimental framework to build interactive dashboards
|
||||||
|
FlagScenes = "scenes"
|
||||||
|
|
||||||
// FlagUseLegacyHeatmapPanel
|
// FlagUseLegacyHeatmapPanel
|
||||||
// Continue to use the angular/flot based heatmap panel
|
// Continue to use the angular/flot based heatmap panel
|
||||||
FlagUseLegacyHeatmapPanel = "useLegacyHeatmapPanel"
|
FlagUseLegacyHeatmapPanel = "useLegacyHeatmapPanel"
|
||||||
|
|||||||
@@ -57,9 +57,12 @@ export const OptionsPaneCategory: FC<OptionsPaneCategoryProps> = React.memo(
|
|||||||
|
|
||||||
const onToggle = useCallback(() => {
|
const onToggle = useCallback(() => {
|
||||||
manualClickTime.current = Date.now();
|
manualClickTime.current = Date.now();
|
||||||
updateQueryParams({
|
updateQueryParams(
|
||||||
[CATEGORY_PARAM_NAME]: isExpanded ? undefined : id,
|
{
|
||||||
});
|
[CATEGORY_PARAM_NAME]: isExpanded ? undefined : id,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
setSavedState({ isExpanded: !isExpanded });
|
setSavedState({ isExpanded: !isExpanded });
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
}, [setSavedState, setIsExpanded, updateQueryParams, isExpanded, id]);
|
}, [setSavedState, setIsExpanded, updateQueryParams, isExpanded, id]);
|
||||||
|
|||||||
31
public/app/features/scenes/SceneListPage.tsx
Normal file
31
public/app/features/scenes/SceneListPage.tsx
Normal file
@@ -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<Props> = ({}) => {
|
||||||
|
const scenes = getScenes();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<Page.Contents>
|
||||||
|
<Stack direction="column">
|
||||||
|
{scenes.map((scene) => (
|
||||||
|
<Card href={`/scenes/${scene.state.title}`} key={scene.state.title}>
|
||||||
|
<Card.Heading>{scene.state.title}</Card.Heading>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Page.Contents>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SceneListPage;
|
||||||
24
public/app/features/scenes/ScenePage.tsx
Normal file
24
public/app/features/scenes/ScenePage.tsx
Normal file
@@ -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> = (props) => {
|
||||||
|
const scene = getSceneByTitle(props.match.params.name);
|
||||||
|
|
||||||
|
if (!scene) {
|
||||||
|
return <h2>Scene not found</h2>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', width: '100%' }}>
|
||||||
|
<scene.Component model={scene} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScenePage;
|
||||||
15
public/app/features/scenes/components/Scene.test.tsx
Normal file
15
public/app/features/scenes/components/Scene.test.tsx
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
56
public/app/features/scenes/components/Scene.tsx
Normal file
56
public/app/features/scenes/components/Scene.tsx
Normal file
@@ -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<SceneState> {
|
||||||
|
static Component = SceneRenderer;
|
||||||
|
urlSyncManager?: UrlSyncManager;
|
||||||
|
|
||||||
|
onMount() {
|
||||||
|
super.onMount();
|
||||||
|
this.urlSyncManager = new UrlSyncManager(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmount() {
|
||||||
|
super.onUnmount();
|
||||||
|
this.urlSyncManager!.cleanUp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SceneRenderer({ model }: SceneComponentProps<Scene>) {
|
||||||
|
const { title, layout, actions = [], isEditing, $editor } = model.useState();
|
||||||
|
|
||||||
|
console.log('render scene');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', flex: '1 1 0', minHeight: 0 }}>
|
||||||
|
<PageToolbar title={title}>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<action.Component key={action.state.key} model={action} />
|
||||||
|
))}
|
||||||
|
{$editor && (
|
||||||
|
<ToolbarButton
|
||||||
|
icon="cog"
|
||||||
|
variant={isEditing ? 'primary' : 'default'}
|
||||||
|
onClick={() => model.setState({ isEditing: !model.state.isEditing })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PageToolbar>
|
||||||
|
<div style={{ flexGrow: 1, display: 'flex', padding: '16px', gap: '8px', paddingTop: 0, overflow: 'auto' }}>
|
||||||
|
<layout.Component model={layout} isEditing={isEditing} />
|
||||||
|
{$editor && <$editor.Component model={$editor} isEditing={isEditing} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
public/app/features/scenes/components/SceneCanvasText.tsx
Normal file
44
public/app/features/scenes/components/SceneCanvasText.tsx
Normal file
@@ -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<SceneCanvasTextState> {
|
||||||
|
static Editor = Editor;
|
||||||
|
static Component = ({ model }: SceneComponentProps<SceneCanvasText>) => {
|
||||||
|
const { text, fontSize = 20, align = 'left' } = model.useState();
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
fontSize: fontSize,
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
justifyContent: align,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <div style={style}>{text}</div>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Editor({ model }: SceneComponentProps<SceneCanvasText>) {
|
||||||
|
const { fontSize } = model.useState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field label="Font size">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
defaultValue={fontSize}
|
||||||
|
onBlur={(evt) => model.setState({ fontSize: parseInt(evt.currentTarget.value, 10) })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
public/app/features/scenes/components/SceneFlexLayout.tsx
Normal file
110
public/app/features/scenes/components/SceneFlexLayout.tsx
Normal file
@@ -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<SceneFlexLayoutState> {
|
||||||
|
static Component = FlexLayoutRenderer;
|
||||||
|
static Editor = FlexLayoutEditor;
|
||||||
|
|
||||||
|
toggleDirection() {
|
||||||
|
this.setState({
|
||||||
|
direction: this.state.direction === 'row' ? 'column' : 'row',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlexLayoutRenderer({ model, isEditing }: SceneComponentProps<SceneFlexLayout>) {
|
||||||
|
const { direction = 'row', children } = model.useState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flexGrow: 1, flexDirection: direction, display: 'flex', gap: '8px' }}>
|
||||||
|
{children.map((item) => (
|
||||||
|
<FlexLayoutChildComponent key={item.state.key} item={item} direction={direction} isEditing={isEditing} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlexLayoutChildComponent({
|
||||||
|
item,
|
||||||
|
direction,
|
||||||
|
isEditing,
|
||||||
|
}: {
|
||||||
|
item: SceneObject<SceneObjectState>;
|
||||||
|
direction: FlexLayoutDirection;
|
||||||
|
isEditing?: boolean;
|
||||||
|
}) {
|
||||||
|
const { size } = item.useState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={getItemStyles(direction, size)}>
|
||||||
|
<item.Component model={item} isEditing={isEditing} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SceneFlexLayout>) {
|
||||||
|
const { direction = 'row' } = model.useState();
|
||||||
|
const options = [
|
||||||
|
{ icon: 'arrow-right', value: 'row' },
|
||||||
|
{ icon: 'arrow-down', value: 'column' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field label="Direction">
|
||||||
|
<RadioButtonGroup
|
||||||
|
options={options}
|
||||||
|
value={direction}
|
||||||
|
onChange={(value) => model.setState({ direction: value as FlexLayoutDirection })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
public/app/features/scenes/components/ScenePanelRepeater.tsx
Normal file
54
public/app/features/scenes/components/ScenePanelRepeater.tsx
Normal file
@@ -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<SceneLayoutState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ScenePanelRepeater extends SceneObjectBase<RepeatOptions> {
|
||||||
|
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<ScenePanelRepeater>) => {
|
||||||
|
const { layout } = model.useState();
|
||||||
|
return <layout.Component model={layout} isEditing={isEditing} />;
|
||||||
|
};
|
||||||
|
}
|
||||||
43
public/app/features/scenes/components/SceneTimePicker.tsx
Normal file
43
public/app/features/scenes/components/SceneTimePicker.tsx
Normal file
@@ -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<SceneTimePickerState> {
|
||||||
|
static Component = SceneTimePickerRenderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SceneTimePickerRenderer({ model }: SceneComponentProps<SceneTimePicker>) {
|
||||||
|
const { hidePicker } = model.useState();
|
||||||
|
const timeRange = model.getTimeRange();
|
||||||
|
const timeRangeState = timeRange.useState();
|
||||||
|
|
||||||
|
if (hidePicker) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolbarButtonRow>
|
||||||
|
<TimePickerWithHistory
|
||||||
|
value={timeRangeState}
|
||||||
|
onChange={timeRange.onTimeRangeChange}
|
||||||
|
timeZone={'browser'}
|
||||||
|
fiscalYearStartMonth={0}
|
||||||
|
onMoveBackward={() => {}}
|
||||||
|
onMoveForward={() => {}}
|
||||||
|
onZoom={() => {}}
|
||||||
|
onChangeTimeZone={() => {}}
|
||||||
|
onChangeFiscalYearStartMonth={() => {}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RefreshPicker onRefresh={timeRange.onRefresh} onIntervalChanged={timeRange.onIntervalChanged} />
|
||||||
|
</ToolbarButtonRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
public/app/features/scenes/components/SceneToolbarButton.tsx
Normal file
39
public/app/features/scenes/components/SceneToolbarButton.tsx
Normal file
@@ -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<ToolbarButtonState> {
|
||||||
|
static Component = ({ model }: SceneComponentProps<SceneToolbarButton>) => {
|
||||||
|
const state = model.useState();
|
||||||
|
|
||||||
|
return <ToolbarButton onClick={state.onClick} icon={state.icon} />;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneToolbarInputState extends SceneObjectState {
|
||||||
|
value?: string;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SceneToolbarInput extends SceneObjectBase<SceneToolbarInputState> {
|
||||||
|
static Component = ({ model }: SceneComponentProps<SceneToolbarInput>) => {
|
||||||
|
const state = model.useState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
defaultValue={state.value}
|
||||||
|
onBlur={(evt) => {
|
||||||
|
model.state.onChange(parseInt(evt.currentTarget.value, 10));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
80
public/app/features/scenes/components/VizPanel.tsx
Normal file
80
public/app/features/scenes/components/VizPanel.tsx
Normal file
@@ -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<VizPanelState> {
|
||||||
|
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<VizPanel>) {
|
||||||
|
const { title, pluginId, options, fieldConfig } = model.useState();
|
||||||
|
const { data } = model.getData().useState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AutoSizer>
|
||||||
|
{({ width, height }) => {
|
||||||
|
if (width < 3 || height < 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelChrome title={title} width={width} height={height}>
|
||||||
|
{(innerWidth, innerHeight) => (
|
||||||
|
<>
|
||||||
|
<PanelRenderer
|
||||||
|
title="Raw data"
|
||||||
|
pluginId={pluginId}
|
||||||
|
width={innerWidth}
|
||||||
|
height={innerHeight}
|
||||||
|
data={data}
|
||||||
|
options={options}
|
||||||
|
fieldConfig={fieldConfig}
|
||||||
|
onOptionsChange={() => {}}
|
||||||
|
onChangeTimeRange={model.onSetTimeRange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PanelChrome>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</AutoSizer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ScenePanelRenderer.displayName = 'ScenePanelRenderer';
|
||||||
|
|
||||||
|
function VizPanelEditor({ model }: SceneComponentProps<VizPanel>) {
|
||||||
|
const { title } = model.useState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field label="Title">
|
||||||
|
<Input defaultValue={title} onBlur={(evt) => model.setState({ title: evt.currentTarget.value })} />
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<T extends SceneObjectBase<any>>({
|
||||||
|
model,
|
||||||
|
isEditing,
|
||||||
|
}: SceneComponentProps<T>) {
|
||||||
|
const Component = (model as any).constructor['Component'] ?? EmptyRenderer;
|
||||||
|
const inner = <Component model={model} isEditing={isEditing} />;
|
||||||
|
|
||||||
|
model.useMount();
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SceneComponentEditingWrapper model={model}>{inner}</SceneComponentEditingWrapper>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SceneComponentEditingWrapper<T extends SceneObjectBase<any>>({
|
||||||
|
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 (
|
||||||
|
<div style={style} className={className} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T>(_: SceneComponentProps<T>): React.ReactElement | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
10
public/app/features/scenes/core/SceneDataNode.ts
Normal file
10
public/app/features/scenes/core/SceneDataNode.ts
Normal file
@@ -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<SceneDataNodeState> {}
|
||||||
68
public/app/features/scenes/core/SceneObjectBase.test.ts
Normal file
68
public/app/features/scenes/core/SceneObjectBase.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { SceneObjectBase } from './SceneObjectBase';
|
||||||
|
import { SceneObject, SceneObjectList, SceneObjectState } from './types';
|
||||||
|
|
||||||
|
interface TestSceneState extends SceneObjectState {
|
||||||
|
name?: string;
|
||||||
|
nested?: SceneObject<TestSceneState>;
|
||||||
|
children?: SceneObjectList;
|
||||||
|
actions?: SceneObjectList;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestScene extends SceneObjectBase<TestSceneState> {}
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
220
public/app/features/scenes/core/SceneObjectBase.tsx
Normal file
220
public/app/features/scenes/core/SceneObjectBase.tsx
Normal file
@@ -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<TState extends SceneObjectState = {}> implements SceneObject<TState> {
|
||||||
|
subject = new Subject<TState>();
|
||||||
|
state: TState;
|
||||||
|
parent?: SceneObjectBase<any>;
|
||||||
|
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<this> {
|
||||||
|
return SceneComponentEditWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary solution, should be replaced by declarative options
|
||||||
|
*/
|
||||||
|
get Editor(): SceneComponent<this> {
|
||||||
|
return ((this as any).constructor['Editor'] ?? (() => null)) as SceneComponent<this>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TState> interface */
|
||||||
|
subscribe(observer: Partial<Observer<TState>>) {
|
||||||
|
return this.subject.subscribe(observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(update: Partial<TState>) {
|
||||||
|
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<SceneDataState> {
|
||||||
|
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<TState>): 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
public/app/features/scenes/core/SceneTimeRange.tsx
Normal file
29
public/app/features/scenes/core/SceneTimeRange.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { TimeRange, UrlQueryMap } from '@grafana/data';
|
||||||
|
|
||||||
|
import { SceneObjectBase } from './SceneObjectBase';
|
||||||
|
import { SceneObjectWithUrlSync, SceneTimeRangeState } from './types';
|
||||||
|
|
||||||
|
export class SceneTimeRange extends SceneObjectBase<SceneTimeRangeState> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
14
public/app/features/scenes/core/events.ts
Normal file
14
public/app/features/scenes/core/events.ts
Normal file
@@ -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<SceneObjectStateChangedPayload> {
|
||||||
|
static type = 'scene-object-state-change';
|
||||||
|
}
|
||||||
122
public/app/features/scenes/core/types.ts
Normal file
122
public/app/features/scenes/core/types.ts
Normal file
@@ -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<SceneDataState>;
|
||||||
|
$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<T> {
|
||||||
|
model: T;
|
||||||
|
isEditing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SceneComponent<TModel> = React.FunctionComponent<SceneComponentProps<TModel>>;
|
||||||
|
|
||||||
|
export interface SceneDataState extends SceneObjectState {
|
||||||
|
data?: PanelData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneObject<TState extends SceneObjectState = SceneObjectState> extends Subscribable<TState> {
|
||||||
|
/** 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<TState>): 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<TState>): this;
|
||||||
|
|
||||||
|
/** A React component to use for rendering the object */
|
||||||
|
Component(props: SceneComponentProps<SceneObject<TState>>): React.ReactElement | null;
|
||||||
|
|
||||||
|
/** To be replaced by declarative method */
|
||||||
|
Editor(props: SceneComponentProps<SceneObject<TState>>): React.ReactElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SceneObjectList<T = SceneObjectState> = Array<SceneObject<T>>;
|
||||||
|
|
||||||
|
export interface SceneLayoutState extends SceneObjectState {
|
||||||
|
children: SceneObjectList;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SceneLayout<T extends SceneLayoutState = SceneLayoutState> = SceneObject<T>;
|
||||||
|
|
||||||
|
export interface SceneEditorState extends SceneObjectState {
|
||||||
|
hoverObject?: SceneObjectRef;
|
||||||
|
selectedObject?: SceneObjectRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneEditor extends SceneObject<SceneEditorState> {
|
||||||
|
onMouseEnterObject(model: SceneObject): void;
|
||||||
|
onMouseLeaveObject(model: SceneObject): void;
|
||||||
|
onSelectObject(model: SceneObject): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneTimeRangeState extends SceneObjectState, TimeRange {}
|
||||||
|
export interface SceneTimeRange extends SceneObject<SceneTimeRangeState> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
70
public/app/features/scenes/editor/SceneEditManager.tsx
Normal file
70
public/app/features/scenes/editor/SceneEditManager.tsx
Normal file
@@ -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<SceneEditorState> implements SceneEditor {
|
||||||
|
static Component = SceneEditorRenderer;
|
||||||
|
|
||||||
|
get Component(): SceneComponent<this> {
|
||||||
|
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<SceneEditManager>) {
|
||||||
|
const { selectedObject } = model.useState();
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.tree}>
|
||||||
|
<SceneObjectTree node={model.parent!} selectedObject={selectedObject?.ref} />
|
||||||
|
</div>
|
||||||
|
{selectedObject && <SceneObjectEditor model={selectedObject.ref} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
17
public/app/features/scenes/editor/SceneObjectEditor.tsx
Normal file
17
public/app/features/scenes/editor/SceneObjectEditor.tsx
Normal file
@@ -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 (
|
||||||
|
<OptionsPaneCategory id="props" title="Properties" forceOpen={1}>
|
||||||
|
<model.Editor model={model} key={model.state.key} />
|
||||||
|
</OptionsPaneCategory>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
public/app/features/scenes/editor/SceneObjectTree.tsx
Normal file
81
public/app/features/scenes/editor/SceneObjectTree.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles.node}>
|
||||||
|
<div className={styles.header} onClick={onSelectNode}>
|
||||||
|
<div className={styles.icon}>{children.length > 0 && <Icon name="angle-down" size="sm" />}</div>
|
||||||
|
<div className={cx(styles.name, isSelected && styles.selected)}>{name}</div>
|
||||||
|
</div>
|
||||||
|
{children.length > 0 && (
|
||||||
|
<div className={styles.children}>
|
||||||
|
{children.map((child) => (
|
||||||
|
<SceneObjectTree node={child} selectedObject={selectedObject} key={child.state.key} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
133
public/app/features/scenes/querying/SceneQueryRunner.ts
Normal file
133
public/app/features/scenes/querying/SceneQueryRunner.ts
Normal file
@@ -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<QueryRunnerState> {
|
||||||
|
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<DataSourceApi> {
|
||||||
|
if (datasource && (datasource as any).query) {
|
||||||
|
return datasource as DataSourceApi;
|
||||||
|
}
|
||||||
|
return await getDatasourceSrv().get(datasource as string, scopedVars);
|
||||||
|
}
|
||||||
140
public/app/features/scenes/scenes/demo.tsx
Normal file
140
public/app/features/scenes/scenes/demo.tsx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
23
public/app/features/scenes/scenes/index.tsx
Normal file
23
public/app/features/scenes/scenes/index.tsx
Normal file
@@ -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<string, Scene> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
73
public/app/features/scenes/scenes/nested.tsx
Normal file
73
public/app/features/scenes/scenes/nested.tsx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
36
public/app/features/scenes/services/UrlSyncManager.ts
Normal file
36
public/app/features/scenes/services/UrlSyncManager.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TestSceneState> {}
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
50
public/app/features/scenes/variables/SceneVariableSet.ts
Normal file
50
public/app/features/scenes/variables/SceneVariableSet.ts
Normal file
@@ -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<SceneVariableState> implements SceneVariable {}
|
||||||
|
|
||||||
|
export class SceneVariableManager extends SceneObjectBase<SceneVariableSetState> 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;
|
||||||
|
}
|
||||||
24
public/app/features/scenes/variables/types.ts
Normal file
24
public/app/features/scenes/variables/types.ts
Normal file
@@ -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<SceneVariableState> {}
|
||||||
|
|
||||||
|
export interface SceneVariableSetState extends SceneObjectState {
|
||||||
|
variables: SceneVariable[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneVariableSet extends SceneObject<SceneVariableSetState> {
|
||||||
|
getVariableByName(name: string): SceneVariable | undefined;
|
||||||
|
}
|
||||||
@@ -439,6 +439,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
() => import(/* webpackChunkName: "NotificationsPage"*/ 'app/features/notifications/NotificationsPage')
|
() => import(/* webpackChunkName: "NotificationsPage"*/ 'app/features/notifications/NotificationsPage')
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
...getDynamicDashboardRoutes(),
|
||||||
...getPluginCatalogRoutes(),
|
...getPluginCatalogRoutes(),
|
||||||
...getLiveRoutes(),
|
...getLiveRoutes(),
|
||||||
...getAlertingRoutes(),
|
...getAlertingRoutes(),
|
||||||
@@ -454,3 +455,19 @@ export function getAppRoutes(): RouteDescriptor[] {
|
|||||||
// ...playlistRoutes,
|
// ...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')),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user