From b23541c29865d333f6e226e0a93c9e3fa1ce8162 Mon Sep 17 00:00:00 2001 From: Bogdan Matei Date: Thu, 21 Mar 2024 13:01:47 +0200 Subject: [PATCH] Implement Scopes UI (#82920) --- packages/grafana-data/src/types/config.ts | 2 + packages/grafana-data/src/types/datasource.ts | 4 +- packages/grafana-data/src/types/index.ts | 1 + packages/grafana-data/src/types/scopes.ts | 20 ++ .../embedding/EmbeddedDashboard.tsx | 51 +++- .../panel-edit/PanelEditorRenderer.tsx | 52 +++- .../scene/DashboardControls.tsx | 4 +- .../dashboard-scene/scene/DashboardScene.tsx | 9 +- .../scene/DashboardSceneRenderer.tsx | 70 ++++- .../scene/ScopesDashboardsScene.tsx | 151 ++++++++++ .../scene/ScopesFiltersScene.tsx | 120 ++++++++ .../scene/ScopesScene.test.tsx | 261 ++++++++++++++++++ .../dashboard-scene/scene/ScopesScene.tsx | 132 +++++++++ 13 files changed, 847 insertions(+), 30 deletions(-) create mode 100644 packages/grafana-data/src/types/scopes.ts create mode 100644 public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx create mode 100644 public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx create mode 100644 public/app/features/dashboard-scene/scene/ScopesScene.test.tsx create mode 100644 public/app/features/dashboard-scene/scene/ScopesScene.tsx diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 6a67ad5c1ce..72f89ea9286 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -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; diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index ddf62d0d259..992ca19d994 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -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 { // Used to correlate multiple related requests queryGroupId?: string; + + scope?: Scope | undefined; } export interface DataQueryTimings { diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 5ee1e8971a4..e467d5d8ee4 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -66,3 +66,4 @@ export { type PluginExtensionCommandPaletteContext, type PluginExtensionOpenModalOptions, } from './pluginExtensions'; +export * from './scopes'; diff --git a/packages/grafana-data/src/types/scopes.ts b/packages/grafana-data/src/types/scopes.ts new file mode 100644 index 00000000000..09dd972f98c --- /dev/null +++ b/packages/grafana-data/src/types/scopes.ts @@ -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[]; +} diff --git a/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx b/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx index a0e9854173e..14390c35a8c 100644 --- a/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx +++ b/public/app/features/dashboard-scene/embedding/EmbeddedDashboard.tsx @@ -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 ( -
- {controls && } +
+ {scopes && } + {controls && ( +
+ +
+ )}
@@ -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), }), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx index 6a1b852557e..c850073a94f 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditorRenderer.tsx @@ -62,7 +62,7 @@ function VizAndDataPane({ model }: SceneComponentProps) { 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) { }, }); + containerProps.className = cx(containerProps.className, styles.container); + if (!dataPane) { primaryProps.style.flexGrow = 1; } return ( - <> -
{controls && }
+
+ {scopes && } + {controls && ( +
+ +
+ )}
@@ -123,12 +136,37 @@ function VizAndDataPane({ model }: SceneComponentProps) { )}
- +
); } 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), diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index 0ad76b3bff1..64cf71583b9 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -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')]: { diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 6f81fe05b19..23dd39f55ed 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -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 { @@ -151,6 +154,7 @@ export class DashboardScene extends SceneObjectBase { 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 { dashboardUID: this.state.uid, panelId, panelPluginId: panel?.state.pluginId, + scope: this.state.scopes?.state.filters.getSelectedScope(), }; } diff --git a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx index 17915583be9..0d346eafa12 100644 --- a/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardSceneRenderer.tsx @@ -14,7 +14,8 @@ import { DashboardScene } from './DashboardScene'; import { NavToolbarActions } from './NavToolbarActions'; export function DashboardSceneRenderer({ model }: SceneComponentProps) { - 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 {editPanel && } {!editPanel && ( - -
- - {controls && } - {isEmpty ? emptyState : withPanels} -
-
+
+ {scopes && } + + {controls && ( +
+ +
+ )} + +
{isEmpty ? emptyState : withPanels}
+
+
)} {overlay && } @@ -58,6 +73,45 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps { + 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 { + 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 { + 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 { + 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) { + const { filteredDashboards, isLoading } = model.useState(); + const styles = useStyles2(getStyles); + + return ( + <> +
+ } + disabled={isLoading} + onChange={(evt) => model.changeSearchQuery(evt.currentTarget.value)} + /> +
+ + + {filteredDashboards.map((dashboard, idx) => ( +
+ + {dashboard.title} + +
+ ))} +
+ + ); +} + +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, + }, + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx b/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx new file mode 100644 index 00000000000..7f2dfe5e6ad --- /dev/null +++ b/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx @@ -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 { + 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 }>; + }>(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) { + const { scopes, isLoading, value } = model.useState(); + const parentState = model.parent!.useState(); + const isViewing = 'isViewing' in parentState ? !!parentState.isViewing : false; + + const options: Array> = scopes.map(({ uid, title, category }) => ({ + label: title, + value: uid, + description: category, + })); + + return ( +