diff --git a/packages/scenes/src/components/SceneApp/SceneApp.tsx b/packages/scenes/src/components/SceneApp/SceneApp.tsx index fc4583fc4..862396ec6 100644 --- a/packages/scenes/src/components/SceneApp/SceneApp.tsx +++ b/packages/scenes/src/components/SceneApp/SceneApp.tsx @@ -10,6 +10,8 @@ import { renderSceneComponentWithRouteProps } from './utils'; * Responsible for top level pages routing */ export class SceneApp extends SceneObjectBase implements DataRequestEnricher { + protected _renderBeforeActivation = true; + public enrichDataRequest() { return { app: this.state.name || 'app', @@ -17,21 +19,24 @@ export class SceneApp extends SceneObjectBase implements DataRequ } public static Component = ({ model }: SceneComponentProps) => { - const { pages } = model.useState(); + const { pages, scopesBridge } = model.useState(); return ( - - - {pages.map((page) => ( - renderSceneComponentWithRouteProps(page, props)} - > - ))} - - + <> + {scopesBridge && } + + + {pages.map((page) => ( + renderSceneComponentWithRouteProps(page, props)} + > + ))} + + + ); }; } diff --git a/packages/scenes/src/components/SceneApp/SceneAppPage.tsx b/packages/scenes/src/components/SceneApp/SceneAppPage.tsx index 9fc3cd83b..71aef0047 100644 --- a/packages/scenes/src/components/SceneApp/SceneAppPage.tsx +++ b/packages/scenes/src/components/SceneApp/SceneAppPage.tsx @@ -8,6 +8,8 @@ import { SceneReactObject } from '../SceneReactObject'; import { SceneAppDrilldownViewRender, SceneAppPageView } from './SceneAppPageView'; import { SceneAppDrilldownView, SceneAppPageLike, SceneAppPageState, SceneRouteMatch } from './types'; import { renderSceneComponentWithRouteProps } from './utils'; +import { sceneGraph } from '../../core/sceneGraph'; +import { SceneScopesBridge } from '../../core/SceneScopesBridge'; /** * Responsible for page's drilldown & tabs routing @@ -16,11 +18,32 @@ export class SceneAppPage extends SceneObjectBase implements public static Component = SceneAppPageRenderer; private _sceneCache = new Map(); private _drilldownCache = new Map(); + private _scopesBridge: SceneScopesBridge | undefined; public constructor(state: SceneAppPageState) { super(state); + + this.addActivationHandler(this._activationHandler); } + private _activationHandler = () => { + if (!this.state.useScopes) { + return; + } + + this._scopesBridge = sceneGraph.getScopesBridge(this); + + if (!this._scopesBridge) { + throw new Error('Use of scopes is enabled but no scopes bridge found'); + } + + this._scopesBridge.setEnabled(true); + + return () => { + this._scopesBridge?.setEnabled(false); + }; + }; + public initializeScene(scene: EmbeddedScene) { this.setState({ initializedScene: scene }); } diff --git a/packages/scenes/src/components/SceneApp/types.ts b/packages/scenes/src/components/SceneApp/types.ts index 054c8c72b..13cedcba4 100644 --- a/packages/scenes/src/components/SceneApp/types.ts +++ b/packages/scenes/src/components/SceneApp/types.ts @@ -2,6 +2,7 @@ import { ComponentType } from 'react'; import { DataRequestEnricher, SceneObject, SceneObjectState, SceneUrlSyncOptions } from '../../core/types'; import { EmbeddedScene } from '../EmbeddedScene'; import { IconName, PageLayoutType } from '@grafana/data'; +import { SceneScopesBridge } from '../../core/SceneScopesBridge'; export interface SceneRouteMatch { params: Params; @@ -15,6 +16,7 @@ export interface SceneAppState extends SceneObjectState { pages: SceneAppPageLike[]; name?: string; urlSyncOptions?: SceneUrlSyncOptions; + scopesBridge?: SceneScopesBridge; } export interface SceneAppRoute { @@ -69,6 +71,9 @@ export interface SceneAppPageState extends SceneObjectState { getFallbackPage?: () => SceneAppPageLike; layout?: PageLayoutType; + + // Whether to use scopes for this page + useScopes?: boolean; } export interface SceneAppPageLike extends SceneObject, DataRequestEnricher { diff --git a/packages/scenes/src/core/SceneScopesBridge.ts b/packages/scenes/src/core/SceneScopesBridge.ts new file mode 100644 index 000000000..718898d31 --- /dev/null +++ b/packages/scenes/src/core/SceneScopesBridge.ts @@ -0,0 +1,126 @@ +import { isEqual } from 'lodash'; +import { useEffect } from 'react'; +import { BehaviorSubject, filter, map, Observable, pairwise, Unsubscribable } from 'rxjs'; + +import { Scope } from '@grafana/data'; +// @ts-expect-error: TODO: Fix this once new runtime package is released +import { ScopesContextValue, useScopes } from '@grafana/runtime'; + +import { SceneObjectBase } from './SceneObjectBase'; +import { SceneComponentProps, SceneObjectUrlValues, SceneObjectWithUrlSync } from './types'; +import { SceneObjectUrlSyncConfig } from '../services/SceneObjectUrlSyncConfig'; + +export class SceneScopesBridge extends SceneObjectBase implements SceneObjectWithUrlSync { + static Component = SceneScopesBridgeRenderer; + + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] }); + + protected _renderBeforeActivation = true; + + private _contextSubject = new BehaviorSubject(undefined); + + private _pendingScopes: string[] | null = null; + + public getUrlState(): SceneObjectUrlValues { + return { + scopes: this._pendingScopes ?? (this.context?.state.value ?? []).map((scope: Scope) => scope.metadata.name), + }; + } + + public updateFromUrl(values: SceneObjectUrlValues) { + let scopes = values['scopes'] ?? []; + scopes = (Array.isArray(scopes) ? scopes : [scopes]).map(String); + + if (!this.context) { + this._pendingScopes = scopes; + return; + } + + this.context?.changeScopes(scopes); + } + + public getValue(): Scope[] { + return this.context?.state.value ?? []; + } + + public subscribeToValue(cb: (newScopes: Scope[], prevScopes: Scope[]) => void): Unsubscribable { + return this.contextObservable + .pipe( + filter((context) => !!context && !context.state.loading), + pairwise(), + map( + ([prevContext, newContext]) => + [prevContext?.state.value ?? [], newContext?.state.value ?? []] as [Scope[], Scope[]] + ), + filter(([prevScopes, newScopes]) => !isEqual(prevScopes, newScopes)) + ) + .subscribe(([prevScopes, newScopes]) => { + cb(newScopes, prevScopes); + }); + } + + public isLoading(): boolean { + return this.context?.state.loading ?? false; + } + + public subscribeToLoading(cb: (loading: boolean) => void): Unsubscribable { + return this.contextObservable + .pipe( + filter((context) => !!context), + pairwise(), + map( + ([prevContext, newContext]) => + [prevContext?.state.loading ?? false, newContext?.state.loading ?? false] as [boolean, boolean] + ), + filter(([prevLoading, newLoading]) => prevLoading !== newLoading) + ) + .subscribe(([_prevLoading, newLoading]) => { + cb(newLoading); + }); + } + + public setEnabled(enabled: boolean) { + this.context?.setEnabled(enabled); + } + + public setReadOnly(readOnly: boolean) { + this.context?.setReadOnly(readOnly); + } + + public updateContext(newContext: ScopesContextValue | undefined) { + if (this._pendingScopes && newContext) { + setTimeout(() => { + newContext?.changeScopes(this._pendingScopes!); + this._pendingScopes = null; + }); + } + + if (this.context !== newContext || this.context?.state !== newContext?.state) { + const shouldUpdate = this.context?.state.value !== newContext?.state.value; + + this._contextSubject.next(newContext); + + if (shouldUpdate) { + this.forceRender(); + } + } + } + + private get context(): ScopesContextValue | undefined { + return this._contextSubject.getValue(); + } + + private get contextObservable(): Observable { + return this._contextSubject.asObservable(); + } +} + +function SceneScopesBridgeRenderer({ model }: SceneComponentProps) { + const context = useScopes(); + + useEffect(() => { + model.updateContext(context); + }, [context, model]); + + return null; +} diff --git a/packages/scenes/src/core/sceneGraph/index.ts b/packages/scenes/src/core/sceneGraph/index.ts index e038d45c6..b6a3de5b4 100644 --- a/packages/scenes/src/core/sceneGraph/index.ts +++ b/packages/scenes/src/core/sceneGraph/index.ts @@ -15,6 +15,7 @@ import { findDescendents, getQueryController, getUrlSyncManager, + getScopesBridge, } from './sceneGraph'; export const sceneGraph = { @@ -34,4 +35,5 @@ export const sceneGraph = { findDescendents, getQueryController, getUrlSyncManager, + getScopesBridge, }; diff --git a/packages/scenes/src/core/sceneGraph/sceneGraph.ts b/packages/scenes/src/core/sceneGraph/sceneGraph.ts index 72c5402d1..b2d6c6d7a 100644 --- a/packages/scenes/src/core/sceneGraph/sceneGraph.ts +++ b/packages/scenes/src/core/sceneGraph/sceneGraph.ts @@ -11,6 +11,7 @@ import { SceneQueryControllerLike, isQueryController } from '../../behaviors/Sce import { VariableInterpolation } from '@grafana/runtime'; import { QueryVariable } from '../../variables/variants/query/QueryVariable'; import { UrlSyncManagerLike } from '../../services/UrlSyncManager'; +import { SceneScopesBridge } from '../SceneScopesBridge'; /** * Get the closest node with variables @@ -300,3 +301,10 @@ export function getUrlSyncManager(sceneObject: SceneObject): UrlSyncManagerLike return undefined; } + +/** + * Will walk up the scene object graph to the closest $scopesBridge scene object + */ +export function getScopesBridge(sceneObject: SceneObject): SceneScopesBridge | undefined { + return (findObject(sceneObject, (s) => s instanceof SceneScopesBridge) as SceneScopesBridge) ?? undefined; +} diff --git a/packages/scenes/src/index.ts b/packages/scenes/src/index.ts index 3b2c4a371..c489145f1 100644 --- a/packages/scenes/src/index.ts +++ b/packages/scenes/src/index.ts @@ -127,6 +127,7 @@ export { renderSelectForVariable } from './variables/components/VariableValueSel export { VizConfigBuilder } from './core/PanelBuilders/VizConfigBuilder'; export { VizConfigBuilders } from './core/PanelBuilders/VizConfigBuilders'; export { type VizConfig } from './core/PanelBuilders/types'; +export { SceneScopesBridge } from './core/SceneScopesBridge'; export const sceneUtils = { getUrlWithAppState, diff --git a/packages/scenes/src/querying/SceneQueryRunner.ts b/packages/scenes/src/querying/SceneQueryRunner.ts index cfb27453c..8fa762a3c 100644 --- a/packages/scenes/src/querying/SceneQueryRunner.ts +++ b/packages/scenes/src/querying/SceneQueryRunner.ts @@ -48,6 +48,7 @@ import { SceneVariable } from '../variables/types'; import { DataLayersMerger } from './DataLayersMerger'; import { interpolate } from '../core/sceneGraph/sceneGraph'; import { wrapInSafeSerializableSceneObject } from '../utils/wrapInSafeSerializableSceneObject'; +import { SceneScopesBridge } from '../core/SceneScopesBridge'; let counter = 100; @@ -111,6 +112,8 @@ export class SceneQueryRunner extends SceneObjectBase implemen private _dataLayersMerger = new DataLayersMerger(); private _timeSub?: Unsubscribable; private _timeSubRange?: SceneTimeRangeLike; + private _scopesSub?: Unsubscribable; + private _scopesSubBridge?: SceneScopesBridge; private _containerWidth?: number; private _variableValueRecorder = new VariableValueRecorder(); private _results = new ReplaySubject(1); @@ -140,6 +143,7 @@ export class SceneQueryRunner extends SceneObjectBase implemen private _onActivate() { if (this.isQueryModeAuto()) { const timeRange = sceneGraph.getTimeRange(this); + const scopesBridge = sceneGraph.getScopesBridge(this); // Add subscriptions to any extra providers so that they rerun queries // when their state changes and they should rerun. @@ -154,6 +158,8 @@ export class SceneQueryRunner extends SceneObjectBase implemen ); } + this.subscribeToScopesChanges(scopesBridge); + this.subscribeToTimeRangeChanges(timeRange); if (this.shouldRunQueriesOnActivate()) { @@ -366,6 +372,28 @@ export class SceneQueryRunner extends SceneObjectBase implemen return Boolean(this.state._hasFetchedData); } + private subscribeToScopesChanges(scopesBridge: SceneScopesBridge | undefined) { + if (!scopesBridge) { + // Nothing to do, there's no scopes bridge + return; + } + + if (this._scopesSubBridge === scopesBridge) { + // Nothing to do, already subscribed + return; + } + + if (this._scopesSub) { + this._scopesSub.unsubscribe(); + } + + this._scopesSubBridge = scopesBridge; + + this._scopesSub = scopesBridge.subscribeToValue(() => { + this.runWithTimeRangeAndScopes(sceneGraph.getTimeRange(this), scopesBridge); + }); + } + private subscribeToTimeRangeChanges(timeRange: SceneTimeRangeLike) { if (this._timeSubRange === timeRange) { // Nothing to do, already subscribed @@ -378,17 +406,19 @@ export class SceneQueryRunner extends SceneObjectBase implemen this._timeSubRange = timeRange; this._timeSub = timeRange.subscribeToState(() => { - this.runWithTimeRange(timeRange); + this.runWithTimeRangeAndScopes(timeRange, sceneGraph.getScopesBridge(this)); }); } public runQueries() { const timeRange = sceneGraph.getTimeRange(this); + const scopesBridge = sceneGraph.getScopesBridge(this); if (this.isQueryModeAuto()) { this.subscribeToTimeRangeChanges(timeRange); + this.subscribeToScopesChanges(scopesBridge); } - this.runWithTimeRange(timeRange); + this.runWithTimeRangeAndScopes(timeRange, scopesBridge); } private getMaxDataPoints() { @@ -412,7 +442,7 @@ export class SceneQueryRunner extends SceneObjectBase implemen }); } - private async runWithTimeRange(timeRange: SceneTimeRangeLike) { + private async runWithTimeRangeAndScopes(timeRange: SceneTimeRangeLike, scopesBridge: SceneScopesBridge | undefined) { // If no maxDataPoints specified we might need to wait for container width to be set from the outside if (!this.state.maxDataPoints && this.state.maxDataPointsFromWidth && !this._containerWidth) { return; @@ -433,6 +463,13 @@ export class SceneQueryRunner extends SceneObjectBase implemen return; } + // Skip executing queries if scopes are in loading state + if (scopesBridge?.isLoading()) { + writeSceneLog('SceneQueryRunner', 'Scopes are in loading state, skipping query execution'); + this.setState({ data: { ...(this.state.data ?? emptyPanelData), state: LoadingState.Loading } }); + return; + } + const { queries } = this.state; // Simple path when no queries exist @@ -448,7 +485,7 @@ export class SceneQueryRunner extends SceneObjectBase implemen this.findAndSubscribeToAdHocFilters(datasource?.uid); const runRequest = getRunRequest(); - const { primary, secondaries, processors } = this.prepareRequests(timeRange, ds); + const { primary, secondaries, processors } = this.prepareRequests(timeRange, ds, scopesBridge); writeSceneLog('SceneQueryRunner', 'Starting runRequest', this.state.key); @@ -505,7 +542,11 @@ export class SceneQueryRunner extends SceneObjectBase implemen return clone; } - private prepareRequests(timeRange: SceneTimeRangeLike, ds: DataSourceApi): PreparedRequests { + private prepareRequests( + timeRange: SceneTimeRangeLike, + ds: DataSourceApi, + scopesBridge: SceneScopesBridge | undefined + ): PreparedRequests { const { minInterval, queries } = this.state; let request: DataQueryRequest = { @@ -526,6 +567,7 @@ export class SceneQueryRunner extends SceneObjectBase implemen }, cacheTimeout: this.state.cacheTimeout, queryCachingTTL: this.state.queryCachingTTL, + scopes: scopesBridge?.getValue(), // This asks the scene root to provide context properties like app, panel and dashboardUID ...getEnrichedDataRequest(this), }; diff --git a/packages/scenes/src/querying/__snapshots__/SceneQueryRunner.test.ts.snap b/packages/scenes/src/querying/__snapshots__/SceneQueryRunner.test.ts.snap index 43711e4aa..202d9a0be 100644 --- a/packages/scenes/src/querying/__snapshots__/SceneQueryRunner.test.ts.snap +++ b/packages/scenes/src/querying/__snapshots__/SceneQueryRunner.test.ts.snap @@ -22,6 +22,7 @@ exports[`SceneQueryRunner when running query should build DataQueryRequest objec "to": "now", }, "requestId": "SQR100", + "scopes": undefined, "startTime": 1689063488000, "targets": [ { @@ -57,6 +58,7 @@ exports[`SceneQueryRunner when running query should build DataQueryRequest objec "to": "now", }, "requestId": "SQR178", + "scopes": undefined, "startTime": 1689063488000, "targets": [ { diff --git a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx index a05cb82c5..00da5991e 100644 --- a/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx +++ b/packages/scenes/src/variables/adhoc/AdHocFiltersVariable.tsx @@ -16,6 +16,7 @@ import { css } from '@emotion/css'; import { getEnrichedFiltersRequest } from '../getEnrichedFiltersRequest'; import { AdHocFiltersComboboxRenderer } from './AdHocFiltersCombobox/AdHocFiltersComboboxRenderer'; import { wrapInSafeSerializableSceneObject } from '../../utils/wrapInSafeSerializableSceneObject'; +import { SceneScopesBridge } from '../../core/SceneScopesBridge'; export interface AdHocFilterWithLabels extends AdHocVariableFilter { keyLabel?: string; @@ -170,6 +171,7 @@ export class AdHocFiltersVariable private _scopedVars = { __sceneObject: wrapInSafeSerializableSceneObject(this) }; private _dataSourceSrv = getDataSourceSrv(); + private _scopesBridge: SceneScopesBridge | undefined; protected _urlSync = new AdHocFiltersVariableUrlSyncHandler(this); @@ -187,8 +189,14 @@ export class AdHocFiltersVariable if (this.state.applyMode === 'auto') { patchGetAdhocFilters(this); } + + this.addActivationHandler(this._activationHandler); } + private _activationHandler = () => { + this._scopesBridge = sceneGraph.getScopesBridge(this); + }; + public setState(update: Partial): void { let filterExpressionChanged = false; @@ -305,6 +313,7 @@ export class AdHocFiltersVariable filters: otherFilters, queries, timeRange, + scopes: this._scopesBridge?.getValue(), ...getEnrichedFiltersRequest(this), }); @@ -352,6 +361,7 @@ export class AdHocFiltersVariable filters: otherFilters, timeRange, queries, + scopes: this._scopesBridge?.getValue(), ...getEnrichedFiltersRequest(this), }); diff --git a/packages/scenes/src/variables/groupby/GroupByVariable.tsx b/packages/scenes/src/variables/groupby/GroupByVariable.tsx index fd78ddc31..1a9be8e19 100644 --- a/packages/scenes/src/variables/groupby/GroupByVariable.tsx +++ b/packages/scenes/src/variables/groupby/GroupByVariable.tsx @@ -16,6 +16,7 @@ import { GroupByVariableUrlSyncHandler } from './GroupByVariableUrlSyncHandler'; import { getOptionSearcher } from '../components/getOptionSearcher'; import { getEnrichedFiltersRequest } from '../getEnrichedFiltersRequest'; import { wrapInSafeSerializableSceneObject } from '../../utils/wrapInSafeSerializableSceneObject'; +import { SceneScopesBridge } from '../../core/SceneScopesBridge'; export interface GroupByVariableState extends MultiValueVariableState { /** Defaults to "Group" */ @@ -63,6 +64,8 @@ export class GroupByVariable extends MultiValueVariable { protected _urlSync: SceneObjectUrlSyncHandler = new GroupByVariableUrlSyncHandler(this); + private _scopesBridge: SceneScopesBridge | undefined; + public validateAndUpdate(): Observable { return this.getValueOptions({}).pipe( map((options) => { @@ -145,6 +148,8 @@ export class GroupByVariable extends MultiValueVariable { }); this.addActivationHandler(() => { + this._scopesBridge = sceneGraph.getScopesBridge(this); + allActiveGroupByVariables.add(this); return () => allActiveGroupByVariables.delete(this); @@ -178,6 +183,7 @@ export class GroupByVariable extends MultiValueVariable { filters: otherFilters, queries, timeRange, + scopes: this._scopesBridge?.getValue(), ...getEnrichedFiltersRequest(this), }); if (responseHasError(response)) { @@ -204,7 +210,7 @@ export class GroupByVariable extends MultiValueVariable { return { value: [], text: [] }; } } -export function GroupByVariableRenderer({ model }: SceneComponentProps) { +export function GroupByVariableRenderer({ model }: SceneComponentProps) { const { value, text,