Implement Scopes UI (#82920)
This commit is contained in:
@@ -228,6 +228,8 @@ export interface GrafanaConfig {
|
||||
rootFolderUID?: string;
|
||||
localFileSystemAvailable?: boolean;
|
||||
cloudMigrationIsTarget?: boolean;
|
||||
listDashboardScopesEndpoint?: string;
|
||||
listScopesEndpoint?: string;
|
||||
|
||||
// The namespace to use for kubernetes apiserver requests
|
||||
namespace: string;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DataQuery } from './query';
|
||||
import { RawTimeRange, TimeRange } from './time';
|
||||
import { CustomVariableSupport, DataSourceVariableSupport, StandardVariableSupport } from './variables';
|
||||
|
||||
import { AdHocVariableFilter, DataSourceRef, WithAccessControlMetadata } from '.';
|
||||
import { AdHocVariableFilter, DataSourceRef, Scope, WithAccessControlMetadata } from '.';
|
||||
|
||||
export interface DataSourcePluginOptionsEditorProps<
|
||||
JSONData extends DataSourceJsonData = DataSourceJsonData,
|
||||
@@ -561,6 +561,8 @@ export interface DataQueryRequest<TQuery extends DataQuery = DataQuery> {
|
||||
|
||||
// Used to correlate multiple related requests
|
||||
queryGroupId?: string;
|
||||
|
||||
scope?: Scope | undefined;
|
||||
}
|
||||
|
||||
export interface DataQueryTimings {
|
||||
|
||||
@@ -66,3 +66,4 @@ export {
|
||||
type PluginExtensionCommandPaletteContext,
|
||||
type PluginExtensionOpenModalOptions,
|
||||
} from './pluginExtensions';
|
||||
export * from './scopes';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface ScopeDashboard {
|
||||
uid: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ScopeFilter {
|
||||
key: string;
|
||||
value: string;
|
||||
operator: string;
|
||||
}
|
||||
|
||||
export interface Scope {
|
||||
uid: string;
|
||||
title: string;
|
||||
type: string;
|
||||
description: string;
|
||||
category: string;
|
||||
filters: ScopeFilter[];
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
||||
@@ -42,7 +42,7 @@ interface RendererProps extends EmbeddedDashboardProps {
|
||||
|
||||
function EmbeddedDashboardRenderer({ model, initialState, onStateChange }: RendererProps) {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const { controls, body } = model.useState();
|
||||
const { controls, body, scopes } = model.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -64,8 +64,15 @@ function EmbeddedDashboardRenderer({ model, initialState, onStateChange }: Rende
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.canvas}>
|
||||
{controls && <controls.Component model={controls} />}
|
||||
<div
|
||||
className={cx(styles.canvas, controls && !scopes && styles.canvasWithControls, scopes && styles.canvasWithScopes)}
|
||||
>
|
||||
{scopes && <scopes.Component model={scopes} />}
|
||||
{controls && (
|
||||
<div className={cx(styles.controlsWrapper, scopes && styles.controlsWrapperWithScopes)}>
|
||||
<controls.Component model={controls} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
<body.Component model={body} />
|
||||
</div>
|
||||
@@ -100,26 +107,44 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
canvas: css({
|
||||
label: 'canvas-content',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
display: 'grid',
|
||||
gridTemplateAreas: `
|
||||
"panels"`,
|
||||
gridTemplateColumns: `1fr`,
|
||||
gridTemplateRows: '1fr',
|
||||
flexBasis: '100%',
|
||||
flexGrow: 1,
|
||||
}),
|
||||
canvasWithControls: css({
|
||||
gridTemplateAreas: `
|
||||
"controls"
|
||||
"panels"`,
|
||||
gridTemplateRows: 'auto 1fr',
|
||||
}),
|
||||
canvasWithScopes: css({
|
||||
gridTemplateAreas: `
|
||||
"scopes controls"
|
||||
"panels panels"`,
|
||||
gridTemplateColumns: `${theme.spacing(32)} 1fr`,
|
||||
gridTemplateRows: 'auto 1fr',
|
||||
}),
|
||||
body: css({
|
||||
label: 'body',
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
gridArea: 'panels',
|
||||
marginBottom: theme.spacing(2),
|
||||
}),
|
||||
controls: css({
|
||||
controlsWrapper: css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(1),
|
||||
top: 0,
|
||||
zIndex: theme.zIndex.navbarFixed,
|
||||
padding: theme.spacing(0, 0, 2, 0),
|
||||
flexDirection: 'column',
|
||||
flexGrow: 0,
|
||||
gridArea: 'controls',
|
||||
padding: theme.spacing(2, 0, 2, 2),
|
||||
}),
|
||||
controlsWrapperWithScopes: css({
|
||||
padding: theme.spacing(2, 0),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
|
||||
const { vizManager, dataPane, showLibraryPanelSaveModal, showLibraryPanelUnlinkModal } = model.useState();
|
||||
const { sourcePanel } = vizManager.useState();
|
||||
const libraryPanel = getLibraryPanel(sourcePanel.resolve());
|
||||
const { controls } = dashboard.useState();
|
||||
const { controls, scopes } = dashboard.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } =
|
||||
@@ -75,13 +75,26 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
|
||||
},
|
||||
});
|
||||
|
||||
containerProps.className = cx(containerProps.className, styles.container);
|
||||
|
||||
if (!dataPane) {
|
||||
primaryProps.style.flexGrow = 1;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.controlsWrapper}>{controls && <controls.Component model={controls} />}</div>
|
||||
<div
|
||||
className={cx(
|
||||
styles.pageContainer,
|
||||
controls && !scopes && styles.pageContainerWithControls,
|
||||
scopes && styles.pageContainerWithScopes
|
||||
)}
|
||||
>
|
||||
{scopes && <scopes.Component model={scopes} />}
|
||||
{controls && (
|
||||
<div className={cx(styles.controlsWrapper, scopes && styles.controlsWrapperWithScopes)}>
|
||||
<controls.Component model={controls} />
|
||||
</div>
|
||||
)}
|
||||
<div {...containerProps}>
|
||||
<div {...primaryProps}>
|
||||
<vizManager.Component model={vizManager} />
|
||||
@@ -123,12 +136,37 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
pageContainer: css({
|
||||
display: 'grid',
|
||||
gridTemplateAreas: `
|
||||
"panels"`,
|
||||
gridTemplateColumns: `1fr`,
|
||||
gridTemplateRows: '1fr',
|
||||
height: '100%',
|
||||
}),
|
||||
pageContainerWithControls: css({
|
||||
gridTemplateAreas: `
|
||||
"controls"
|
||||
"panels"`,
|
||||
gridTemplateRows: 'auto 1fr',
|
||||
}),
|
||||
pageContainerWithScopes: css({
|
||||
gridTemplateAreas: `
|
||||
"scopes controls"
|
||||
"panels panels"`,
|
||||
gridTemplateColumns: `${theme.spacing(32)} 1fr`,
|
||||
gridTemplateRows: 'auto 1fr',
|
||||
}),
|
||||
container: css({
|
||||
gridArea: 'panels',
|
||||
height: '100%',
|
||||
}),
|
||||
canvasContent: css({
|
||||
label: 'canvas-content',
|
||||
display: 'flex',
|
||||
@@ -172,7 +210,11 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 0,
|
||||
paddingLeft: theme.spacing(2),
|
||||
gridArea: 'controls',
|
||||
padding: theme.spacing(2, 0, 2, 2),
|
||||
}),
|
||||
controlsWrapperWithScopes: css({
|
||||
padding: theme.spacing(2, 0),
|
||||
}),
|
||||
openDataPaneButton: css({
|
||||
width: theme.spacing(8),
|
||||
|
||||
@@ -70,12 +70,14 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
controls: css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
flex: '100%',
|
||||
gap: theme.spacing(1),
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
background: theme.colors.background.canvas,
|
||||
zIndex: theme.zIndex.navbarFixed,
|
||||
padding: theme.spacing(2, 0),
|
||||
width: '100%',
|
||||
marginLeft: 'auto',
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as H from 'history';
|
||||
|
||||
import { AppEvents, CoreApp, DataQueryRequest, NavIndex, NavModelItem, locationUtil } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { config, locationService } from '@grafana/runtime';
|
||||
import {
|
||||
getUrlSyncManager,
|
||||
SceneFlexLayout,
|
||||
@@ -32,7 +32,6 @@ import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
import { PanelEditor } from '../panel-edit/PanelEditor';
|
||||
import { DashboardSceneChangeTracker } from '../saving/DashboardSceneChangeTracker';
|
||||
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
|
||||
import { DashboardSceneRenderer } from '../scene/DashboardSceneRenderer';
|
||||
import {
|
||||
buildGridItemForLibPanel,
|
||||
buildGridItemForPanel,
|
||||
@@ -60,9 +59,11 @@ import {
|
||||
|
||||
import { AddLibraryPanelWidget } from './AddLibraryPanelWidget';
|
||||
import { DashboardControls } from './DashboardControls';
|
||||
import { DashboardSceneRenderer } from './DashboardSceneRenderer';
|
||||
import { DashboardSceneUrlSync } from './DashboardSceneUrlSync';
|
||||
import { LibraryVizPanel } from './LibraryVizPanel';
|
||||
import { PanelRepeaterGridItem } from './PanelRepeaterGridItem';
|
||||
import { ScopesScene } from './ScopesScene';
|
||||
import { ViewPanelScene } from './ViewPanelScene';
|
||||
import { setupKeyboardShortcuts } from './keyboardShortcuts';
|
||||
|
||||
@@ -111,6 +112,8 @@ export interface DashboardSceneState extends SceneObjectState {
|
||||
hasCopiedPanel?: boolean;
|
||||
/** The dashboard doesn't have panels */
|
||||
isEmpty?: boolean;
|
||||
/** Scene object that handles the scopes selector */
|
||||
scopes?: ScopesScene;
|
||||
}
|
||||
|
||||
export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
@@ -151,6 +154,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
body: state.body ?? new SceneFlexLayout({ children: [] }),
|
||||
links: state.links ?? [],
|
||||
hasCopiedPanel: store.exists(LS_PANEL_COPY_KEY),
|
||||
scopes: state.uid && config.featureToggles.scopeFilters ? new ScopesScene() : undefined,
|
||||
...state,
|
||||
});
|
||||
|
||||
@@ -783,6 +787,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
|
||||
dashboardUID: this.state.uid,
|
||||
panelId,
|
||||
panelPluginId: panel?.state.pluginId,
|
||||
scope: this.state.scopes?.state.filters.getSelectedScope(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ import { DashboardScene } from './DashboardScene';
|
||||
import { NavToolbarActions } from './NavToolbarActions';
|
||||
|
||||
export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardScene>) {
|
||||
const { controls, overlay, editview, editPanel, isEmpty } = model.useState();
|
||||
const { controls, overlay, editview, editPanel, isEmpty, scopes } = model.useState();
|
||||
const { isExpanded: isScopesExpanded } = scopes?.useState() ?? {};
|
||||
const styles = useStyles2(getStyles);
|
||||
const location = useLocation();
|
||||
const navIndex = useSelector((state) => state.navIndex);
|
||||
@@ -43,13 +44,27 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
||||
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Custom}>
|
||||
{editPanel && <editPanel.Component model={editPanel} />}
|
||||
{!editPanel && (
|
||||
<CustomScrollbar autoHeightMin={'100%'}>
|
||||
<div className={styles.canvasContent}>
|
||||
<NavToolbarActions dashboard={model} />
|
||||
{controls && <controls.Component model={controls} />}
|
||||
{isEmpty ? emptyState : withPanels}
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
<div
|
||||
className={cx(
|
||||
styles.pageContainer,
|
||||
controls && !scopes && styles.pageContainerWithControls,
|
||||
scopes && styles.pageContainerWithScopes,
|
||||
scopes && isScopesExpanded && styles.pageContainerWithScopesExpanded
|
||||
)}
|
||||
>
|
||||
{scopes && <scopes.Component model={scopes} />}
|
||||
<NavToolbarActions dashboard={model} />
|
||||
{controls && (
|
||||
<div
|
||||
className={cx(styles.controlsWrapper, scopes && !isScopesExpanded && styles.controlsWrapperWithScopes)}
|
||||
>
|
||||
<controls.Component model={controls} />
|
||||
</div>
|
||||
)}
|
||||
<CustomScrollbar autoHeightMin={'100%'} className={styles.scrollbarContainer}>
|
||||
<div className={styles.canvasContent}>{isEmpty ? emptyState : withPanels}</div>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
)}
|
||||
{overlay && <overlay.Component model={overlay} />}
|
||||
</Page>
|
||||
@@ -58,6 +73,45 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
pageContainer: css({
|
||||
display: 'grid',
|
||||
gridTemplateAreas: `
|
||||
"panels"`,
|
||||
gridTemplateColumns: `1fr`,
|
||||
gridTemplateRows: '1fr',
|
||||
height: '100%',
|
||||
}),
|
||||
pageContainerWithControls: css({
|
||||
gridTemplateAreas: `
|
||||
"controls"
|
||||
"panels"`,
|
||||
gridTemplateRows: 'auto 1fr',
|
||||
}),
|
||||
pageContainerWithScopes: css({
|
||||
gridTemplateAreas: `
|
||||
"scopes controls"
|
||||
"panels panels"`,
|
||||
gridTemplateColumns: `${theme.spacing(32)} 1fr`,
|
||||
gridTemplateRows: 'auto 1fr',
|
||||
}),
|
||||
pageContainerWithScopesExpanded: css({
|
||||
gridTemplateAreas: `
|
||||
"scopes controls"
|
||||
"scopes panels"`,
|
||||
}),
|
||||
scrollbarContainer: css({
|
||||
gridArea: 'panels',
|
||||
}),
|
||||
controlsWrapper: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexGrow: 0,
|
||||
gridArea: 'controls',
|
||||
padding: theme.spacing(2),
|
||||
}),
|
||||
controlsWrapperWithScopes: css({
|
||||
padding: theme.spacing(2, 2, 2, 0),
|
||||
}),
|
||||
canvasContent: css({
|
||||
label: 'canvas-content',
|
||||
display: 'flex',
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { AppEvents, GrafanaTheme2, ScopeDashboard } from '@grafana/data';
|
||||
import { config, getAppEvents, getBackendSrv, locationService } from '@grafana/runtime';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
||||
import { CustomScrollbar, Icon, Input, useStyles2 } from '@grafana/ui';
|
||||
|
||||
export interface ScopesDashboardsSceneState extends SceneObjectState {
|
||||
dashboards: ScopeDashboard[];
|
||||
filteredDashboards: ScopeDashboard[];
|
||||
isLoading: boolean;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsSceneState> {
|
||||
static Component = ScopesDashboardsSceneRenderer;
|
||||
|
||||
private _url =
|
||||
config.bootData.settings.listDashboardScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopedashboards';
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
dashboards: [],
|
||||
filteredDashboards: [],
|
||||
isLoading: false,
|
||||
searchQuery: '',
|
||||
});
|
||||
}
|
||||
|
||||
public async fetchDashboards(scope: string | undefined) {
|
||||
if (!scope) {
|
||||
return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false });
|
||||
}
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
const dashboardUids = await this.fetchDashboardsUids(scope);
|
||||
const dashboards = await this.fetchDashboardsDetails(dashboardUids);
|
||||
|
||||
this.setState({
|
||||
dashboards,
|
||||
filteredDashboards: this.filterDashboards(dashboards, this.state.searchQuery),
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
public changeSearchQuery(searchQuery: string) {
|
||||
this.setState({
|
||||
filteredDashboards: searchQuery
|
||||
? this.filterDashboards(this.state.dashboards, searchQuery)
|
||||
: this.state.dashboards,
|
||||
searchQuery: searchQuery ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchDashboardsUids(scope: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await getBackendSrv().get<{
|
||||
items: Array<{ spec: { dashboardUids: null | string[]; scopeUid: string } }>;
|
||||
}>(this._url, { scope });
|
||||
|
||||
return (
|
||||
response.items.find((item) => !!item.spec.dashboardUids && item.spec.scopeUid === scope)?.spec.dashboardUids ??
|
||||
[]
|
||||
);
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchDashboardsDetails(dashboardUids: string[]): Promise<ScopeDashboard[]> {
|
||||
try {
|
||||
const dashboards = await Promise.all(
|
||||
dashboardUids.map((dashboardUid) => this.fetchDashboardDetails(dashboardUid))
|
||||
);
|
||||
|
||||
return dashboards.filter((dashboard): dashboard is ScopeDashboard => !!dashboard);
|
||||
} catch (err) {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: ['Failed to fetch suggested dashboards'],
|
||||
});
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchDashboardDetails(dashboardUid: string): Promise<ScopeDashboard | undefined> {
|
||||
try {
|
||||
const dashboard = await getBackendSrv().get(`/api/dashboards/uid/${dashboardUid}`);
|
||||
|
||||
return {
|
||||
uid: dashboard.dashboard.uid,
|
||||
title: dashboard.dashboard.title,
|
||||
url: dashboard.meta.url,
|
||||
};
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private filterDashboards(dashboards: ScopeDashboard[], searchQuery: string) {
|
||||
const lowerCasedSearchQuery = searchQuery.toLowerCase();
|
||||
return dashboards.filter((dashboard) => dashboard.title.toLowerCase().includes(lowerCasedSearchQuery));
|
||||
}
|
||||
}
|
||||
|
||||
export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<ScopesDashboardsScene>) {
|
||||
const { filteredDashboards, isLoading } = model.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.searchInputContainer}>
|
||||
<Input
|
||||
prefix={<Icon name="search" />}
|
||||
disabled={isLoading}
|
||||
onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomScrollbar>
|
||||
{filteredDashboards.map((dashboard, idx) => (
|
||||
<div key={idx} className={styles.dashboardItem}>
|
||||
<Link to={{ pathname: dashboard.url, search: locationService.getLocation().search }}>
|
||||
{dashboard.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</CustomScrollbar>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
searchInputContainer: css({
|
||||
flex: '0 1 auto',
|
||||
}),
|
||||
dashboardItem: css({
|
||||
padding: theme.spacing(1, 0),
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
|
||||
':first-child': {
|
||||
paddingTop: 0,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AppEvents, Scope, SelectableValue } from '@grafana/data';
|
||||
import { config, getAppEvents, getBackendSrv } from '@grafana/runtime';
|
||||
import {
|
||||
SceneComponentProps,
|
||||
SceneObjectBase,
|
||||
SceneObjectState,
|
||||
SceneObjectUrlSyncConfig,
|
||||
SceneObjectUrlValues,
|
||||
} from '@grafana/scenes';
|
||||
import { Select } from '@grafana/ui';
|
||||
|
||||
export interface ScopesFiltersSceneState extends SceneObjectState {
|
||||
isLoading: boolean;
|
||||
pendingValue: string | undefined;
|
||||
scopes: Scope[];
|
||||
value: string | undefined;
|
||||
}
|
||||
|
||||
export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState> {
|
||||
static Component = ScopesFiltersSceneRenderer;
|
||||
|
||||
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scope'] });
|
||||
|
||||
private _url = config.bootData.settings.listScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopes';
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
isLoading: true,
|
||||
pendingValue: undefined,
|
||||
scopes: [],
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
getUrlState() {
|
||||
return { scope: this.state.value };
|
||||
}
|
||||
|
||||
updateFromUrl(values: SceneObjectUrlValues) {
|
||||
const scope = values.scope ?? undefined;
|
||||
this.setScope(Array.isArray(scope) ? scope[0] : scope);
|
||||
}
|
||||
|
||||
public getSelectedScope(): Scope | undefined {
|
||||
return this.state.scopes.find((scope) => scope.uid === this.state.value);
|
||||
}
|
||||
|
||||
public setScope(newScope: string | undefined) {
|
||||
if (this.state.isLoading) {
|
||||
return this.setState({ pendingValue: newScope });
|
||||
}
|
||||
|
||||
if (!this.state.scopes.find((scope) => scope.uid === newScope)) {
|
||||
newScope = undefined;
|
||||
}
|
||||
|
||||
this.setState({ value: newScope });
|
||||
}
|
||||
|
||||
public async fetchScopes() {
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
try {
|
||||
const response = await getBackendSrv().get<{
|
||||
items: Array<{ metadata: { uid: string }; spec: Omit<Scope, 'uid'> }>;
|
||||
}>(this._url);
|
||||
|
||||
this.setScopesAfterFetch(
|
||||
response.items.map(({ metadata: { uid }, spec }) => ({
|
||||
uid,
|
||||
...spec,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: ['Failed to fetch scopes'],
|
||||
});
|
||||
|
||||
this.setScopesAfterFetch([]);
|
||||
} finally {
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
private setScopesAfterFetch(scopes: Scope[]) {
|
||||
let value = this.state.pendingValue ?? this.state.value;
|
||||
|
||||
if (!scopes.find((scope) => scope.uid === value)) {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
this.setState({ scopes, pendingValue: undefined, value });
|
||||
}
|
||||
}
|
||||
|
||||
export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<ScopesFiltersScene>) {
|
||||
const { scopes, isLoading, value } = model.useState();
|
||||
const parentState = model.parent!.useState();
|
||||
const isViewing = 'isViewing' in parentState ? !!parentState.isViewing : false;
|
||||
|
||||
const options: Array<SelectableValue<string>> = scopes.map(({ uid, title, category }) => ({
|
||||
label: title,
|
||||
value: uid,
|
||||
description: category,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Select
|
||||
isClearable
|
||||
isLoading={isLoading}
|
||||
disabled={isViewing}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(selectableValue) => model.setScope(selectableValue?.value ?? undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
behaviors,
|
||||
sceneGraph,
|
||||
SceneGridItem,
|
||||
SceneGridLayout,
|
||||
SceneQueryRunner,
|
||||
SceneTimeRange,
|
||||
VizPanel,
|
||||
} from '@grafana/scenes';
|
||||
|
||||
import { DashboardControls } from './DashboardControls';
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { ScopesDashboardsScene } from './ScopesDashboardsScene';
|
||||
import { ScopesFiltersScene } from './ScopesFiltersScene';
|
||||
import { ScopesScene } from './ScopesScene';
|
||||
|
||||
const dashboardsMocks = {
|
||||
dashboard1: {
|
||||
uid: 'dashboard1',
|
||||
title: 'Dashboard 1',
|
||||
url: '/d/dashboard1',
|
||||
},
|
||||
dashboard2: {
|
||||
uid: 'dashboard2',
|
||||
title: 'Dashboard 2',
|
||||
url: '/d/dashboard2',
|
||||
},
|
||||
dashboard3: {
|
||||
uid: 'dashboard3',
|
||||
title: 'Dashboard 3',
|
||||
url: '/d/dashboard3',
|
||||
},
|
||||
};
|
||||
|
||||
const scopesMocks = {
|
||||
scope1: {
|
||||
uid: 'scope1',
|
||||
title: 'Scope 1',
|
||||
type: 'Type 1',
|
||||
description: 'Description 1',
|
||||
category: 'Category 1',
|
||||
filters: [
|
||||
{ key: 'a-key', operator: '=', value: 'a-value' },
|
||||
{ key: 'b-key', operator: '!=', value: 'b-value' },
|
||||
],
|
||||
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2, dashboardsMocks.dashboard3],
|
||||
},
|
||||
scope2: {
|
||||
uid: 'scope2',
|
||||
title: 'Scope 2',
|
||||
type: 'Type 2',
|
||||
description: 'Description 2',
|
||||
category: 'Category 2',
|
||||
filters: [{ key: 'c-key', operator: '!=', value: 'c-value' }],
|
||||
dashboards: [dashboardsMocks.dashboard3],
|
||||
},
|
||||
scope3: {
|
||||
uid: 'scope3',
|
||||
title: 'Scope 3',
|
||||
type: 'Type 1',
|
||||
description: 'Description 3',
|
||||
category: 'Category 1',
|
||||
filters: [{ key: 'd-key', operator: '=', value: 'd-value' }],
|
||||
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2],
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
__esModule: true,
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getBackendSrv: () => ({
|
||||
get: jest.fn().mockImplementation((url: string) => {
|
||||
if (url === '/apis/scope.grafana.app/v0alpha1/scopes') {
|
||||
return {
|
||||
items: Object.values(scopesMocks).map((scope) => ({
|
||||
metadata: { uid: scope.uid },
|
||||
spec: {
|
||||
title: scope.title,
|
||||
type: scope.type,
|
||||
description: scope.description,
|
||||
category: scope.category,
|
||||
filters: scope.filters,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (url === '/apis/scope.grafana.app/v0alpha1/scopedashboards') {
|
||||
return {
|
||||
items: Object.values(scopesMocks).map((scope) => ({
|
||||
spec: {
|
||||
dashboardUids: scope.dashboards.map((dashboard) => dashboard.uid),
|
||||
scopeUid: scope.uid,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/dashboards/uid/')) {
|
||||
const uid = url.split('/').pop();
|
||||
|
||||
if (!uid) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const dashboard = Object.values(dashboardsMocks).find((dashboard) => dashboard.uid === uid);
|
||||
|
||||
if (!dashboard) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
dashboard: {
|
||||
title: dashboard.title,
|
||||
uid,
|
||||
},
|
||||
meta: {
|
||||
url: dashboard.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ScopesScene', () => {
|
||||
describe('Feature flag off', () => {
|
||||
beforeAll(() => {
|
||||
config.featureToggles.scopeFilters = false;
|
||||
});
|
||||
|
||||
it('Does not initialize', () => {
|
||||
const dashboardScene = buildTestScene();
|
||||
dashboardScene.activate();
|
||||
const scopesScene = dashboardScene.state.scopes;
|
||||
|
||||
expect(scopesScene).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature flag on', () => {
|
||||
let dashboardScene: DashboardScene;
|
||||
let scopesScene: ScopesScene;
|
||||
let filtersScene: ScopesFiltersScene;
|
||||
let dashboardsScene: ScopesDashboardsScene;
|
||||
let fetchScopesSpy: jest.SpyInstance;
|
||||
let fetchDashboardsSpy: jest.SpyInstance;
|
||||
|
||||
beforeAll(() => {
|
||||
config.featureToggles.scopeFilters = true;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
dashboardScene = buildTestScene();
|
||||
scopesScene = dashboardScene.state.scopes!;
|
||||
filtersScene = scopesScene.state.filters;
|
||||
dashboardsScene = scopesScene.state.dashboards;
|
||||
fetchScopesSpy = jest.spyOn(filtersScene!, 'fetchScopes');
|
||||
fetchDashboardsSpy = jest.spyOn(dashboardsScene!, 'fetchDashboards');
|
||||
dashboardScene.activate();
|
||||
scopesScene.activate();
|
||||
filtersScene.activate();
|
||||
dashboardsScene.activate();
|
||||
});
|
||||
|
||||
it('Initializes', () => {
|
||||
expect(scopesScene).toBeInstanceOf(ScopesScene);
|
||||
expect(filtersScene).toBeInstanceOf(ScopesFiltersScene);
|
||||
expect(dashboardsScene).toBeInstanceOf(ScopesDashboardsScene);
|
||||
});
|
||||
|
||||
it('Fetches scopes list', async () => {
|
||||
expect(fetchScopesSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Fetches dashboards list', () => {
|
||||
filtersScene.setScope(scopesMocks.scope1.uid);
|
||||
|
||||
waitFor(() => {
|
||||
expect(fetchDashboardsSpy).toHaveBeenCalled();
|
||||
expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope1.dashboards);
|
||||
});
|
||||
|
||||
filtersScene.setScope(scopesMocks.scope2.uid);
|
||||
|
||||
waitFor(() => {
|
||||
expect(fetchDashboardsSpy).toHaveBeenCalled();
|
||||
expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope2.dashboards);
|
||||
});
|
||||
});
|
||||
|
||||
it('Enriches data requests', () => {
|
||||
const { dashboards: _dashboards, ...scope1 } = scopesMocks.scope1;
|
||||
|
||||
filtersScene.setScope(scope1.uid);
|
||||
|
||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
||||
|
||||
expect(dashboardScene.enrichDataRequest(queryRunner).scope).toEqual(scope1);
|
||||
});
|
||||
|
||||
it('Toggles expanded state', async () => {
|
||||
scopesScene.toggleIsExpanded();
|
||||
|
||||
expect(scopesScene.state.isExpanded).toEqual(true);
|
||||
});
|
||||
|
||||
it('Enters view mode', async () => {
|
||||
dashboardScene.onEnterEditMode();
|
||||
|
||||
expect(scopesScene.state.isViewing).toEqual(true);
|
||||
expect(scopesScene.state.isExpanded).toEqual(false);
|
||||
});
|
||||
|
||||
it('Exits view mode', async () => {
|
||||
dashboardScene.onEnterEditMode();
|
||||
dashboardScene.exitEditMode({ skipConfirm: true });
|
||||
|
||||
expect(scopesScene.state.isViewing).toEqual(false);
|
||||
expect(scopesScene.state.isExpanded).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function buildTestScene(overrides: Partial<DashboardScene> = {}) {
|
||||
return new DashboardScene({
|
||||
title: 'hello',
|
||||
uid: 'dash-1',
|
||||
description: 'hello description',
|
||||
tags: ['tag1', 'tag2'],
|
||||
editable: true,
|
||||
$timeRange: new SceneTimeRange({
|
||||
timeZone: 'browser',
|
||||
}),
|
||||
controls: new DashboardControls({}),
|
||||
$behaviors: [new behaviors.CursorSync({})],
|
||||
body: new SceneGridLayout({
|
||||
children: [
|
||||
new SceneGridItem({
|
||||
key: 'griditem-1',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 300,
|
||||
height: 300,
|
||||
body: new VizPanel({
|
||||
title: 'Panel A',
|
||||
key: 'panel-1',
|
||||
pluginId: 'table',
|
||||
$data: new SceneQueryRunner({ key: 'data-query-runner', queries: [{ refId: 'A' }] }),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
||||
import { IconButton, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { ScopesDashboardsScene } from './ScopesDashboardsScene';
|
||||
import { ScopesFiltersScene } from './ScopesFiltersScene';
|
||||
|
||||
export interface ScopesSceneState extends SceneObjectState {
|
||||
dashboards: ScopesDashboardsScene;
|
||||
filters: ScopesFiltersScene;
|
||||
isExpanded: boolean;
|
||||
isViewing: boolean;
|
||||
}
|
||||
|
||||
export class ScopesScene extends SceneObjectBase<ScopesSceneState> {
|
||||
static Component = ScopesSceneRenderer;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
dashboards: new ScopesDashboardsScene(),
|
||||
filters: new ScopesFiltersScene(),
|
||||
isExpanded: false,
|
||||
isViewing: false,
|
||||
});
|
||||
|
||||
this.addActivationHandler(() => {
|
||||
this.state.filters.fetchScopes();
|
||||
|
||||
const filtersValueSubscription = this.state.filters.subscribeToState((newState, prevState) => {
|
||||
if (newState.value !== prevState.value) {
|
||||
this.state.dashboards.fetchDashboards(newState.value);
|
||||
sceneGraph.getTimeRange(this.parent!).onRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
const dashboardEditModeSubscription = this.parent?.subscribeToState((newState) => {
|
||||
const isEditing = 'isEditing' in newState ? !!newState.isEditing : false;
|
||||
|
||||
if (isEditing !== this.state.isViewing) {
|
||||
if (isEditing) {
|
||||
this.enterViewMode();
|
||||
} else {
|
||||
this.exitViewMode();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
filtersValueSubscription.unsubscribe();
|
||||
dashboardEditModeSubscription?.unsubscribe();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public toggleIsExpanded() {
|
||||
this.setState({ isExpanded: !this.state.isExpanded });
|
||||
}
|
||||
|
||||
private enterViewMode() {
|
||||
this.setState({ isExpanded: false, isViewing: true });
|
||||
}
|
||||
|
||||
private exitViewMode() {
|
||||
this.setState({ isViewing: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function ScopesSceneRenderer({ model }: SceneComponentProps<ScopesScene>) {
|
||||
const { filters, dashboards, isExpanded, isViewing } = model.useState();
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, isExpanded && styles.containerExpanded)}>
|
||||
<div className={cx(styles.filtersContainer, isExpanded && styles.filtersContainerExpanded)}>
|
||||
{!isViewing && (
|
||||
<IconButton
|
||||
name="arrow-to-right"
|
||||
aria-label={isExpanded ? 'Collapse scope filters' : 'Expand scope filters'}
|
||||
className={cx(!isExpanded && styles.iconNotExpanded)}
|
||||
data-testid="scopes-scene-toggle-expand-button"
|
||||
onClick={() => model.toggleIsExpanded()}
|
||||
/>
|
||||
)}
|
||||
<filters.Component model={filters} />
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={styles.dashboardsContainer} data-testid="scopes-scene-dashboards-container">
|
||||
<dashboards.Component model={dashboards} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
container: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gridArea: 'scopes',
|
||||
}),
|
||||
containerExpanded: css({
|
||||
backgroundColor: theme.colors.background.primary,
|
||||
height: '100%',
|
||||
}),
|
||||
filtersContainer: css({
|
||||
display: 'flex',
|
||||
flex: '0 1 auto',
|
||||
flexDirection: 'row',
|
||||
padding: theme.spacing(2, 2, 2, 2),
|
||||
}),
|
||||
filtersContainerExpanded: css({
|
||||
borderBottom: `1px solid ${theme.colors.border.weak}`,
|
||||
padding: theme.spacing(2),
|
||||
}),
|
||||
iconNotExpanded: css({
|
||||
transform: 'scaleX(-1)',
|
||||
}),
|
||||
dashboardsContainer: css({
|
||||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(3),
|
||||
overflow: 'hidden',
|
||||
padding: theme.spacing(2),
|
||||
}),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user