diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 1ed466bec02..5d630b47fda 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -561,7 +561,7 @@ export interface DataQueryRequest { // Used to correlate multiple related requests queryGroupId?: string; - scope?: Scope | undefined; + scopes?: Scope[] | undefined; } export interface DataQueryTimings { diff --git a/packages/grafana-data/src/types/scopes.ts b/packages/grafana-data/src/types/scopes.ts index 2f9b785ca27..b84f1872067 100644 --- a/packages/grafana-data/src/types/scopes.ts +++ b/packages/grafana-data/src/types/scopes.ts @@ -33,3 +33,16 @@ export interface Scope { }; spec: ScopeSpec; } + +export type ScopeTreeItemNodeType = 'container' | 'leaf'; +export type ScopeTreeItemLinkType = 'scope'; + +export interface ScopeTreeItemSpec { + nodeId: string; + nodeType: ScopeTreeItemNodeType; + title: string; + + description?: string; + linkId?: string; + linkType?: ScopeTreeItemLinkType; +} diff --git a/packages/grafana-prometheus/src/dataquery.ts b/packages/grafana-prometheus/src/dataquery.ts index c56a5f91307..839f7c56999 100644 --- a/packages/grafana-prometheus/src/dataquery.ts +++ b/packages/grafana-prometheus/src/dataquery.ts @@ -43,6 +43,6 @@ export interface Prometheus extends common.DataQuery { * Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series */ range?: boolean; - scope?: ScopeSpec; + scopes?: ScopeSpec[]; adhocFilters?: ScopeSpecFilter[]; } diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index 0403d209ad8..69010cad7e7 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -374,7 +374,7 @@ export class PrometheusDatasource }; if (config.featureToggles.promQLScope) { - processedTarget.scope = request.scope?.spec; + processedTarget.scopes = (request.scopes ?? []).map((scope) => scope.spec); } if (target.instant && target.range) { diff --git a/pkg/promlib/models/query.go b/pkg/promlib/models/query.go index 08fb240b9f1..a081df17f4d 100644 --- a/pkg/promlib/models/query.go +++ b/pkg/promlib/models/query.go @@ -12,7 +12,6 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" sdkapi "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" - "github.com/prometheus/prometheus/model/labels" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -67,7 +66,7 @@ type PrometheusQueryProperties struct { LegendFormat string `json:"legendFormat,omitempty"` // A set of filters applied to apply to the query - Scope *ScopeSpec `json:"scope,omitempty"` + Scopes []ScopeSpec `json:"scopes,omitempty"` // Additional Ad-hoc filters that take precedence over Scope on conflict. AdhocFilters []ScopeFilter `json:"adhocFilters,omitempty"` @@ -167,11 +166,8 @@ type Query struct { RangeQuery bool ExemplarQuery bool UtcOffsetSec int64 - Scope *ScopeSpec -} -type Scope struct { - Matchers []*labels.Matcher + Scopes []ScopeSpec } // This internal query struct is just like QueryModel, except it does not include: @@ -214,8 +210,8 @@ func Parse(span trace.Span, query backend.DataQuery, dsScrapeInterval string, in if enableScope { var scopeFilters []ScopeFilter - if model.Scope != nil { - scopeFilters = model.Scope.Filters + for _, scope := range model.Scopes { + scopeFilters = append(scopeFilters, scope.Filters...) } if len(scopeFilters) > 0 { diff --git a/pkg/promlib/models/query.panel.schema.json b/pkg/promlib/models/query.panel.schema.json index f52bb24a968..307f91f3817 100644 --- a/pkg/promlib/models/query.panel.schema.json +++ b/pkg/promlib/models/query.panel.schema.json @@ -162,55 +162,59 @@ }, "additionalProperties": false }, - "scope": { + "scopes": { "description": "A set of filters applied to apply to the query", - "type": "object", - "required": [ - "title", - "type", - "description", - "category", - "filters" - ], - "properties": { - "category": { - "type": "string" - }, - "description": { - "type": "string" - }, - "filters": { - "type": "array", - "items": { - "description": "ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", - "type": "object", - "required": [ - "key", - "value", - "operator" - ], - "properties": { - "key": { - "type": "string" + "type": "array", + "items": { + "description": "ScopeSpec is a hand copy of the ScopeSpec struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", + "type": "object", + "required": [ + "title", + "type", + "description", + "category", + "filters" + ], + "properties": { + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "filters": { + "type": "array", + "items": { + "description": "ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", + "type": "object", + "required": [ + "key", + "value", + "operator" + ], + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "value": { + "type": "string" + } }, - "operator": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "additionalProperties": false + "additionalProperties": false + } + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" } }, - "title": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "additionalProperties": false + "additionalProperties": false + } }, "timeRange": { "description": "TimeRange represents the query range\nNOTE: unlike generic /ds/query, we can now send explicit time values in each query\nNOTE: the values for timeRange are not saved in a dashboard, they are constructed on the fly", diff --git a/pkg/promlib/models/query.request.schema.json b/pkg/promlib/models/query.request.schema.json index 979f69a6cb0..19efc109c2c 100644 --- a/pkg/promlib/models/query.request.schema.json +++ b/pkg/promlib/models/query.request.schema.json @@ -172,55 +172,59 @@ }, "additionalProperties": false }, - "scope": { + "scopes": { "description": "A set of filters applied to apply to the query", - "type": "object", - "required": [ - "title", - "type", - "description", - "category", - "filters" - ], - "properties": { - "category": { - "type": "string" - }, - "description": { - "type": "string" - }, - "filters": { - "type": "array", - "items": { - "description": "ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", - "type": "object", - "required": [ - "key", - "value", - "operator" - ], - "properties": { - "key": { - "type": "string" + "type": "array", + "items": { + "description": "ScopeSpec is a hand copy of the ScopeSpec struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", + "type": "object", + "required": [ + "title", + "type", + "description", + "category", + "filters" + ], + "properties": { + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "filters": { + "type": "array", + "items": { + "description": "ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", + "type": "object", + "required": [ + "key", + "value", + "operator" + ], + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "value": { + "type": "string" + } }, - "operator": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "additionalProperties": false + "additionalProperties": false + } + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" } }, - "title": { - "type": "string" - }, - "type": { - "type": "string" - } - }, - "additionalProperties": false + "additionalProperties": false + } }, "timeRange": { "description": "TimeRange represents the query range\nNOTE: unlike generic /ds/query, we can now send explicit time values in each query\nNOTE: the values for timeRange are not saved in a dashboard, they are constructed on the fly", diff --git a/pkg/promlib/models/query.types.json b/pkg/promlib/models/query.types.json index 95f7ab499d8..1e843c92c23 100644 --- a/pkg/promlib/models/query.types.json +++ b/pkg/promlib/models/query.types.json @@ -8,7 +8,7 @@ { "metadata": { "name": "default", - "resourceVersion": "1713187448137", + "resourceVersion": "1715777575561", "creationTimestamp": "2024-03-25T13:19:04Z" }, "spec": { @@ -85,55 +85,59 @@ "description": "Returns a Range vector, comprised of a set of time series containing a range of data points over time for each time series", "type": "boolean" }, - "scope": { - "additionalProperties": false, + "scopes": { "description": "A set of filters applied to apply to the query", - "properties": { - "category": { - "type": "string" - }, - "description": { - "type": "string" - }, - "filters": { - "items": { - "additionalProperties": false, - "description": "ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", - "properties": { - "key": { - "type": "string" - }, - "operator": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "key", - "value", - "operator" - ], - "type": "object" + "items": { + "additionalProperties": false, + "description": "ScopeSpec is a hand copy of the ScopeSpec struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", + "properties": { + "category": { + "type": "string" }, - "type": "array" + "description": { + "type": "string" + }, + "filters": { + "items": { + "additionalProperties": false, + "description": "ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", + "properties": { + "key": { + "type": "string" + }, + "operator": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value", + "operator" + ], + "type": "object" + }, + "type": "array" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } }, - "title": { - "type": "string" - }, - "type": { - "type": "string" - } + "required": [ + "title", + "type", + "description", + "category", + "filters" + ], + "type": "object" }, - "required": [ - "title", - "type", - "description", - "category", - "filters" - ], - "type": "object" + "type": "array" } }, "required": [ diff --git a/public/app/features/apiserver/server.ts b/public/app/features/apiserver/server.ts index 77fba2a006e..c7aca0a4fd5 100644 --- a/public/app/features/apiserver/server.ts +++ b/public/app/features/apiserver/server.ts @@ -69,7 +69,7 @@ export class ScopedResourceServer implements ResourceSer case 'in': case 'notin': - return `${key}${operator}(${label.value.join(',')})`; + return `${key} ${operator} (${label.value.join(',')})`; case '': case '!': diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 6acd2fef7c3..b16cdf0962e 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -818,7 +818,7 @@ export class DashboardScene extends SceneObjectBase { dashboardUID: this.state.uid, panelId, panelPluginId: panel?.state.pluginId, - scope: this.state.scopes?.state.filters.getSelectedScope(), + scopes: this.state.scopes?.getSelectedScopes(), }; } diff --git a/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx b/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx index ef7a56a55c8..651085d943b 100644 --- a/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx +++ b/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx @@ -2,12 +2,12 @@ import { css } from '@emotion/css'; import React from 'react'; import { Link } from 'react-router-dom'; -import { AppEvents, GrafanaTheme2, ScopeDashboardBindingSpec } from '@grafana/data'; -import { getAppEvents, getBackendSrv, locationService } from '@grafana/runtime'; +import { AppEvents, GrafanaTheme2, Scope, ScopeDashboardBindingSpec, urlUtil } from '@grafana/data'; +import { getAppEvents, getBackendSrv } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { CustomScrollbar, Icon, Input, useStyles2 } from '@grafana/ui'; - -import { ScopedResourceServer } from '../../apiserver/server'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; +import { ScopedResourceServer } from 'app/features/apiserver/server'; export interface ScopeDashboard { uid: string; @@ -40,15 +40,17 @@ export class ScopesDashboardsScene extends SceneObjectBase this.fetchDashboardsUids(scope.metadata.name).catch(() => [])) + ); + const dashboards = await this.fetchDashboardsDetails(dashboardUids.flat()); this.setState({ dashboards, @@ -125,6 +127,8 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps
@@ -138,9 +142,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps {filteredDashboards.map((dashboard, idx) => (
- - {dashboard.title} - + {dashboard.title}
))} diff --git a/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx b/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx index 83ba74de49f..3600da65c7b 100644 --- a/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx +++ b/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx @@ -1,7 +1,8 @@ +import { css, cx } from '@emotion/css'; import React from 'react'; -import { AppEvents, Scope, ScopeSpec, SelectableValue } from '@grafana/data'; -import { getAppEvents } from '@grafana/runtime'; +import { AppEvents, GrafanaTheme2, Scope, ScopeSpec, ScopeTreeItemSpec } from '@grafana/data'; +import { getAppEvents, getBackendSrv } from '@grafana/runtime'; import { SceneComponentProps, SceneObjectBase, @@ -9,111 +10,280 @@ import { SceneObjectUrlSyncConfig, SceneObjectUrlValues, } from '@grafana/scenes'; -import { Select } from '@grafana/ui'; +import { Checkbox, Icon, Input, Toggletip, useStyles2 } from '@grafana/ui'; +import { ScopedResourceServer } from 'app/features/apiserver/server'; -import { ScopedResourceServer } from '../../apiserver/server'; +export interface Node { + item: ScopeTreeItemSpec; + isScope: boolean; + children: Record; +} export interface ScopesFiltersSceneState extends SceneObjectState { - isLoading: boolean; - pendingValue: string | undefined; + nodes: Record; + expandedNodes: string[]; scopes: Scope[]; - value: string | undefined; } export class ScopesFiltersScene extends SceneObjectBase { static Component = ScopesFiltersSceneRenderer; - protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scope'] }); + protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scopes'] }); + + private serverGroup = 'scope.grafana.app'; + private serverVersion = 'v0alpha1'; + private serverNamespace = 'default'; private server = new ScopedResourceServer({ - group: 'scope.grafana.app', - version: 'v0alpha1', + group: this.serverGroup, + version: this.serverVersion, resource: 'scopes', }); constructor() { super({ - isLoading: true, - pendingValue: undefined, + nodes: {}, + expandedNodes: [], scopes: [], - value: undefined, }); } getUrlState() { - return { scope: this.state.value }; + return { scopes: this.state.scopes.map((scope) => scope.metadata.name) }; } updateFromUrl(values: SceneObjectUrlValues) { - const scope = values.scope ?? undefined; - this.setScope(Array.isArray(scope) ? scope[0] : scope); + let scopes = values.scopes ?? []; + scopes = Array.isArray(scopes) ? scopes : [scopes]; + + const scopesPromises = scopes.map((scopeName) => this.server.get(scopeName)); + + Promise.all(scopesPromises).then((scopes) => { + this.setState({ scopes }); + }); } - public getSelectedScope(): Scope | undefined { - return this.state.scopes.find((scope) => scope.metadata.name === this.state.value); - } - - public setScope(newScope: string | undefined) { - if (this.state.isLoading) { - return this.setState({ pendingValue: newScope }); - } - - if (!this.state.scopes.find((scope) => scope.metadata.name === newScope)) { - newScope = undefined; - } - - this.setState({ value: newScope }); - } - - public async fetchScopes() { - this.setState({ isLoading: true }); - + public async fetchTreeItems(nodeId: string): Promise> { try { - const response = await this.server.list(); + return ( + ( + await getBackendSrv().get<{ items: ScopeTreeItemSpec[] }>( + `/apis/${this.serverGroup}/${this.serverVersion}/namespaces/${this.serverNamespace}/find`, + { parent: nodeId } + ) + )?.items ?? [] + ).reduce>((acc, item) => { + acc[item.nodeId] = { + item, + isScope: item.nodeType === 'leaf', + children: {}, + }; - this.setScopesAfterFetch(response.items); + return acc; + }, {}); + } catch (err) { + getAppEvents().publish({ + type: AppEvents.alertError.name, + payload: ['Failed to fetch tree items'], + }); + + return {}; + } + } + + public async fetchScopes(parent: string) { + try { + return (await this.server.list({ labelSelector: [{ key: 'category', operator: '=', value: parent }] }))?.items; } catch (err) { getAppEvents().publish({ type: AppEvents.alertError.name, payload: ['Failed to fetch scopes'], }); - - this.setScopesAfterFetch([]); - } finally { - this.setState({ isLoading: false }); + return []; } } - private setScopesAfterFetch(scopes: Scope[]) { - let value = this.state.pendingValue ?? this.state.value; + public async expandNode(path: string[]) { + let nodes = { ...this.state.nodes }; + let currentLevel = nodes; - if (!scopes.find((scope) => scope.metadata.name === value)) { - value = undefined; + for (let idx = 0; idx < path.length; idx++) { + const nodeId = path[idx]; + const isLast = idx === path.length - 1; + const currentNode = currentLevel[nodeId]; + + currentLevel[nodeId] = { + ...currentNode, + children: isLast ? await this.fetchTreeItems(nodeId) : currentLevel[nodeId].children, + }; + + currentLevel = currentNode.children; } - this.setState({ scopes, pendingValue: undefined, value }); + this.setState({ + nodes, + expandedNodes: path, + }); + } + + public async fetchBaseNodes() { + this.setState({ + nodes: await this.fetchTreeItems(''), + expandedNodes: [], + }); + } + + public async toggleScope(linkId: string) { + let scopes = this.state.scopes; + const selectedIdx = scopes.findIndex((scope) => scope.metadata.name === linkId); + + if (selectedIdx === -1) { + const scope = await this.server.get(linkId); + + if (scope) { + scopes = [...scopes, scope]; + } + } else { + scopes.splice(selectedIdx, 1); + } + + this.setState({ scopes }); } } export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps) { - const { scopes, isLoading, value } = model.useState(); + const { nodes, expandedNodes, scopes } = model.useState(); const parentState = model.parent!.useState(); const isViewing = 'isViewing' in parentState ? !!parentState.isViewing : false; - const options: Array> = scopes.map(({ metadata: { name }, spec: { title, category } }) => ({ - label: title, - value: name, - description: category, - })); + const handleNodeExpand = (path: string[]) => model.expandNode(path); + const handleScopeToggle = (linkId: string) => model.toggleScope(linkId); return ( - scope.spec.title)} /> + ); } + +export interface ScopesTreeLevelProps { + isExpanded: boolean; + path: string[]; + nodes: Record; + expandedNodes: string[]; + scopes: Scope[]; + onNodeExpand: (path: string[]) => void; + onScopeToggle: (linkId: string) => void; +} + +export function ScopesTreeLevel({ + isExpanded, + path, + nodes, + expandedNodes, + scopes, + onNodeExpand, + onScopeToggle, +}: ScopesTreeLevelProps) { + const styles = useStyles2(getStyles); + + if (!isExpanded) { + return null; + } + + return ( +
0 ? styles.innerLevelContainer : undefined}> + {Object.values(nodes).map((node) => { + const { + item: { nodeId, linkId }, + isScope, + children, + } = node; + const nodePath = [...path, nodeId]; + const isExpanded = expandedNodes.includes(nodeId); + const isSelected = isScope && !!scopes.find((scope) => scope.metadata.name === linkId); + + return ( +
{ + evt.stopPropagation(); + onNodeExpand(nodePath); + }} + onKeyDown={(evt) => { + evt.stopPropagation(); + onNodeExpand(nodePath); + }} + > + {!isScope ? ( + + ) : ( + { + evt.stopPropagation(); + + if (linkId) { + onScopeToggle(linkId); + } + }} + /> + )} + + {node.item.title} + + +
+ ); + })} +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + innerLevelContainer: css({ + marginLeft: theme.spacing(2), + }), + item: css({ + cursor: 'pointer', + margin: theme.spacing(1, 0), + }), + itemScope: css({ + cursor: 'default', + }), + icon: css({ + marginRight: theme.spacing(1), + }), + checkbox: css({ + marginRight: theme.spacing(1), + }), + }; +}; diff --git a/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx b/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx index 1a056ae88d7..6efe679a873 100644 --- a/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx @@ -1,6 +1,6 @@ import { waitFor } from '@testing-library/react'; -import { Scope } from '@grafana/data'; +import { Scope, ScopeDashboardBindingSpec, ScopeTreeItemSpec } from '@grafana/data'; import { config } from '@grafana/runtime'; import { behaviors, @@ -18,125 +18,209 @@ import { ScopeDashboard, ScopesDashboardsScene } from './ScopesDashboardsScene'; import { ScopesFiltersScene } from './ScopesFiltersScene'; import { ScopesScene } from './ScopesScene'; -const dashboardsMocks = { - dashboard1: { - uid: 'dashboard1', - title: 'Dashboard 1', - url: '/d/dashboard1', +const mocksScopes: Scope[] = [ + { + metadata: { name: 'indexHelperCluster' }, + spec: { + title: 'Cluster Index Helper', + type: 'indexHelper', + description: 'redundant label filter but makes queries faster', + category: 'indexHelpers', + filters: [{ key: 'indexHelper', value: 'cluster', operator: 'equals' }], + }, }, - dashboard2: { - uid: 'dashboard2', - title: 'Dashboard 2', - url: '/d/dashboard2', + { + metadata: { name: 'slothClusterNorth' }, + spec: { + title: 'slothClusterNorth', + type: 'cluster', + description: 'slothClusterNorth', + category: 'clusters', + filters: [{ key: 'cluster', value: 'slothClusterNorth', operator: 'equals' }], + }, }, - dashboard3: { - uid: 'dashboard3', - title: 'Dashboard 3', - url: '/d/dashboard3', + { + metadata: { name: 'slothClusterSouth' }, + spec: { + title: 'slothClusterSouth', + type: 'cluster', + description: 'slothClusterSouth', + category: 'clusters', + filters: [{ key: 'cluster', value: 'slothClusterSouth', operator: 'equals' }], + }, }, -}; + { + metadata: { name: 'slothPictureFactory' }, + spec: { + title: 'slothPictureFactory', + type: 'app', + description: 'slothPictureFactory', + category: 'apps', + filters: [{ key: 'app', value: 'slothPictureFactory', operator: 'equals' }], + }, + }, + { + metadata: { name: 'slothVoteTracker' }, + spec: { + title: 'slothVoteTracker', + type: 'app', + description: 'slothVoteTracker', + category: 'apps', + filters: [{ key: 'app', value: 'slothVoteTracker', operator: 'equals' }], + }, + }, +] as const; -const scopesMocks: Record< - string, - Scope & { - dashboards: ScopeDashboard[]; - } -> = { - scope1: { - metadata: { - name: 'scope1', - }, - spec: { - title: 'Scope 1', - type: 'Type 1', - description: 'Description 1', - category: 'Category 1', - filters: [ - { key: 'a-key', operator: 'equals', value: 'a-value' }, - { key: 'b-key', operator: 'not-equals', value: 'b-value' }, - ], - }, - dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2, dashboardsMocks.dashboard3], +const mocksScopeDashboardBindings: ScopeDashboardBindingSpec[] = [ + { dashboard: '1', scope: 'slothPictureFactory' }, + { dashboard: '2', scope: 'slothPictureFactory' }, + { dashboard: '3', scope: 'slothVoteTracker' }, + { dashboard: '4', scope: 'slothVoteTracker' }, +] as const; + +const mocksNodes: ScopeTreeItemSpec[] = [ + { + nodeId: 'applications', + nodeType: 'container', + title: 'Applications', + description: 'Application Scopes', }, - scope2: { - metadata: { - name: 'scope2', - }, - spec: { - title: 'Scope 2', - type: 'Type 2', - description: 'Description 2', - category: 'Category 2', - filters: [{ key: 'c-key', operator: 'not-equals', value: 'c-value' }], - }, - dashboards: [dashboardsMocks.dashboard3], + { + nodeId: 'clusters', + nodeType: 'container', + title: 'Clusters', + description: 'Cluster Scopes', + linkType: 'scope', + linkId: 'indexHelperCluster', }, - scope3: { - metadata: { - name: 'scope3', - }, - spec: { - title: 'Scope 3', - type: 'Type 1', - description: 'Description 3', - category: 'Category 1', - filters: [{ key: 'd-key', operator: 'equals', value: 'd-value' }], - }, - dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2], + { + nodeId: 'applications-slothPictureFactory', + nodeType: 'leaf', + title: 'slothPictureFactory', + description: 'slothPictureFactory', + linkType: 'scope', + linkId: 'slothPictureFactory', }, -}; + { + nodeId: 'applications-slothVoteTracker', + nodeType: 'leaf', + title: 'slothVoteTracker', + description: 'slothVoteTracker', + linkType: 'scope', + linkId: 'slothVoteTracker', + }, + { + nodeId: 'applications.clusters', + nodeType: 'container', + title: 'Clusters', + description: 'Application/Clusters Scopes', + linkType: 'scope', + linkId: 'indexHelperCluster', + }, + { + nodeId: 'applications.clusters-slothClusterNorth', + nodeType: 'leaf', + title: 'slothClusterNorth', + description: 'slothClusterNorth', + linkType: 'scope', + linkId: 'slothClusterNorth', + }, + { + nodeId: 'applications.clusters-slothClusterSouth', + nodeType: 'leaf', + title: 'slothClusterSouth', + description: 'slothClusterSouth', + linkType: 'scope', + linkId: 'slothClusterSouth', + }, + { + nodeId: 'clusters-slothClusterNorth', + nodeType: 'leaf', + title: 'slothClusterNorth', + description: 'slothClusterNorth', + linkType: 'scope', + linkId: 'slothClusterNorth', + }, + { + nodeId: 'clusters-slothClusterSouth', + nodeType: 'leaf', + title: 'slothClusterSouth', + description: 'slothClusterSouth', + linkType: 'scope', + linkId: 'slothClusterSouth', + }, + { + nodeId: 'clusters.applications', + nodeType: 'container', + title: 'Applications', + description: 'Clusters/Application Scopes', + }, + { + nodeId: 'clusters.applications-slothPictureFactory', + nodeType: 'leaf', + title: 'slothPictureFactory', + description: 'slothPictureFactory', + linkType: 'scope', + linkId: 'slothPictureFactory', + }, + { + nodeId: 'clusters.applications-slothVoteTracker', + nodeType: 'leaf', + title: 'slothVoteTracker', + description: 'slothVoteTracker', + linkType: 'scope', + linkId: 'slothVoteTracker', + }, +] as const; + +const getDashboardDetailsForUid = (uid: string) => ({ + dashboard: { + title: `Dashboard ${uid}`, + uid, + }, + meta: { + url: `/d/dashboard${uid}`, + }, +}); +const getDashboardScopeForUid = (uid: string) => ({ + title: `Dashboard ${uid}`, + uid, + url: `/d/dashboard${uid}`, +}); jest.mock('@grafana/runtime', () => ({ __esModule: true, ...jest.requireActual('@grafana/runtime'), getBackendSrv: () => ({ get: jest.fn().mockImplementation((url: string) => { - if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes')) { + const search = new URLSearchParams(url.split('?').pop() || ''); + + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find')) { + const parent = search.get('parent')?.replace('parent=', ''); + return { - items: Object.values(scopesMocks).map(({ dashboards: _dashboards, ...scope }) => scope), + items: mocksNodes.filter((node) => (parent ? node.nodeId.startsWith(parent) : !node.nodeId.includes('-'))), }; } + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) { + const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', ''); + + return mocksScopes.find((scope) => scope.metadata.name === name) ?? {}; + } + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopedashboardbindings')) { - const search = new URLSearchParams(url.split('?').pop() || ''); const scope = search.get('fieldSelector')?.replace('spec.scope=', '') ?? ''; - if (scope in scopesMocks) { - return { - items: scopesMocks[scope].dashboards.map(({ uid }) => ({ - scope, - dashboard: uid, - })), - }; - } - return { - items: [], + items: mocksScopeDashboardBindings.filter((binding) => binding.scope === scope), }; } 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 uid ? getDashboardDetailsForUid(uid) : {}; } return {}; @@ -160,10 +244,15 @@ describe('ScopesScene', () => { }); describe('Feature flag on', () => { + let scopesNames: string[]; + let scopes: Scope[]; + let scopeDashboardBindings: ScopeDashboardBindingSpec[][]; + let dashboards: ScopeDashboard[][]; let dashboardScene: DashboardScene; let scopesScene: ScopesScene; let filtersScene: ScopesFiltersScene; let dashboardsScene: ScopesDashboardsScene; + let fetchBaseNodesSpy: jest.SpyInstance; let fetchScopesSpy: jest.SpyInstance; let fetchDashboardsSpy: jest.SpyInstance; @@ -172,10 +261,19 @@ describe('ScopesScene', () => { }); beforeEach(() => { + scopesNames = ['slothClusterNorth', 'slothClusterSouth']; + scopes = scopesNames.map((scopeName) => mocksScopes.find((scope) => scope.metadata.name === scopeName)!); + scopeDashboardBindings = scopesNames.map( + (scopeName) => mocksScopeDashboardBindings.filter((binding) => binding.scope === scopeName)! + ); + dashboards = scopeDashboardBindings.map((bindings) => + bindings.map((binding) => getDashboardScopeForUid(binding.dashboard)) + ); dashboardScene = buildTestScene(); scopesScene = dashboardScene.state.scopes!; filtersScene = scopesScene.state.filters; dashboardsScene = scopesScene.state.dashboards; + fetchBaseNodesSpy = jest.spyOn(filtersScene!, 'fetchBaseNodes'); fetchScopesSpy = jest.spyOn(filtersScene!, 'fetchScopes'); fetchDashboardsSpy = jest.spyOn(dashboardsScene!, 'fetchDashboards'); dashboardScene.activate(); @@ -190,50 +288,89 @@ describe('ScopesScene', () => { expect(dashboardsScene).toBeInstanceOf(ScopesDashboardsScene); }); - it('Fetches scopes list', async () => { - expect(fetchScopesSpy).toHaveBeenCalled(); + it('Fetches nodes list', () => { + expect(fetchBaseNodesSpy).toHaveBeenCalled(); + }); + + it('Fetches scope details', () => { + filtersScene.toggleScope(scopesNames[0]); + waitFor(() => { + expect(fetchScopesSpy).toHaveBeenCalled(); + expect(filtersScene.state.scopes).toEqual(scopes.filter((scope) => scope.metadata.name === scopesNames[0])); + }); + + filtersScene.toggleScope(scopesNames[1]); + waitFor(() => { + expect(fetchScopesSpy).toHaveBeenCalled(); + expect(filtersScene.state.scopes).toEqual(scopes); + }); + + filtersScene.toggleScope(scopesNames[0]); + waitFor(() => { + expect(fetchScopesSpy).toHaveBeenCalled(); + expect(filtersScene.state.scopes).toEqual(scopes.filter((scope) => scope.metadata.name === scopesNames[1])); + }); }); it('Fetches dashboards list', () => { - filtersScene.setScope(scopesMocks.scope1.metadata.name); - + filtersScene.toggleScope(scopesNames[0]); waitFor(() => { expect(fetchDashboardsSpy).toHaveBeenCalled(); - expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope1.dashboards); + expect(dashboardsScene.state.dashboards).toEqual(dashboards[0]); }); - filtersScene.setScope(scopesMocks.scope2.metadata.name); + filtersScene.toggleScope(scopesNames[1]); + waitFor(() => { + expect(fetchDashboardsSpy).toHaveBeenCalled(); + expect(dashboardsScene.state.dashboards).toEqual(dashboards.flat()); + }); + + filtersScene.toggleScope(scopesNames[0]); waitFor(() => { expect(fetchDashboardsSpy).toHaveBeenCalled(); - expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope2.dashboards); + expect(dashboardsScene.state.dashboards).toEqual(dashboards[1]); }); }); it('Enriches data requests', () => { - const { dashboards: _dashboards, ...scope1 } = scopesMocks.scope1; + filtersScene.toggleScope(scopesNames[0]); + waitFor(() => { + const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; + expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( + scopes.filter((scope) => scope.metadata.name === scopesNames[0]) + ); + }); - filtersScene.setScope(scope1.metadata.name); + filtersScene.toggleScope(scopesNames[1]); + waitFor(() => { + const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; + expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes); + }); - const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; - - expect(dashboardScene.enrichDataRequest(queryRunner).scope).toEqual(scope1); + filtersScene.toggleScope(scopesNames[0]); + waitFor(() => { + const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!; + expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual( + scopes.filter((scope) => scope.metadata.name === scopesNames[1]) + ); + }); }); - it('Toggles expanded state', async () => { + it('Toggles expanded state', () => { scopesScene.toggleIsExpanded(); expect(scopesScene.state.isExpanded).toEqual(true); }); - it('Enters view mode', async () => { + it('Enters view mode', () => { dashboardScene.onEnterEditMode(); expect(scopesScene.state.isViewing).toEqual(true); expect(scopesScene.state.isExpanded).toEqual(false); }); - it('Exits view mode', async () => { + it('Exits view mode', () => { dashboardScene.onEnterEditMode(); dashboardScene.exitEditMode({ skipConfirm: true }); diff --git a/public/app/features/dashboard-scene/scene/ScopesScene.tsx b/public/app/features/dashboard-scene/scene/ScopesScene.tsx index e2581452c55..dd4bda3c888 100644 --- a/public/app/features/dashboard-scene/scene/ScopesScene.tsx +++ b/public/app/features/dashboard-scene/scene/ScopesScene.tsx @@ -27,11 +27,11 @@ export class ScopesScene extends SceneObjectBase { }); this.addActivationHandler(() => { - this.state.filters.fetchScopes(); + this.state.filters.fetchBaseNodes(); const filtersValueSubscription = this.state.filters.subscribeToState((newState, prevState) => { - if (newState.value !== prevState.value) { - this.state.dashboards.fetchDashboards(newState.value); + if (newState.scopes !== prevState.scopes) { + this.state.dashboards.fetchDashboards(newState.scopes); sceneGraph.getTimeRange(this.parent!).onRefresh(); } }); @@ -55,6 +55,10 @@ export class ScopesScene extends SceneObjectBase { }); } + public getSelectedScopes() { + return this.state.filters.state.scopes; + } + public toggleIsExpanded() { this.setState({ isExpanded: !this.state.isExpanded }); }