Scopes: Adapt for new API (#87841)

* Implement changes for new Scopes API

* Update from linkID to linkId

* Fixes

* Fix tests

* prom/scopes: change query model to recieve []ScopeSpec

* Move to basic backend service

---------

Co-authored-by: Kyle Brandt <kyle@grafana.com>
pull/87870/head^2
Bogdan Matei 1 year ago committed by GitHub
parent 6c1e9a9717
commit 6127dfd322
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      packages/grafana-data/src/types/datasource.ts
  2. 13
      packages/grafana-data/src/types/scopes.ts
  3. 2
      packages/grafana-prometheus/src/dataquery.ts
  4. 2
      packages/grafana-prometheus/src/datasource.ts
  5. 12
      pkg/promlib/models/query.go
  6. 94
      pkg/promlib/models/query.panel.schema.json
  7. 94
      pkg/promlib/models/query.request.schema.json
  8. 92
      pkg/promlib/models/query.types.json
  9. 2
      public/app/features/apiserver/server.ts
  10. 2
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  11. 24
      public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx
  12. 280
      public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx
  13. 343
      public/app/features/dashboard-scene/scene/ScopesScene.test.tsx
  14. 10
      public/app/features/dashboard-scene/scene/ScopesScene.tsx

@ -561,7 +561,7 @@ export interface DataQueryRequest<TQuery extends DataQuery = DataQuery> {
// Used to correlate multiple related requests
queryGroupId?: string;
scope?: Scope | undefined;
scopes?: Scope[] | undefined;
}
export interface DataQueryTimings {

@ -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;
}

@ -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[];
}

@ -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) {

@ -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 {

@ -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"
},
"operator": {
"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"
}
},
"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",

@ -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"
},
"operator": {
"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"
}
},
"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",

@ -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"
"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"
},
"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"
}
},
"value": {
"type": "string"
}
"required": [
"key",
"value",
"operator"
],
"type": "object"
},
"required": [
"key",
"value",
"operator"
],
"type": "object"
"type": "array"
},
"type": "array"
},
"title": {
"type": "string"
"title": {
"type": "string"
},
"type": {
"type": "string"
}
},
"type": {
"type": "string"
}
"required": [
"title",
"type",
"description",
"category",
"filters"
],
"type": "object"
},
"required": [
"title",
"type",
"description",
"category",
"filters"
],
"type": "object"
"type": "array"
}
},
"required": [

@ -69,7 +69,7 @@ export class ScopedResourceServer<T = object, K = string> implements ResourceSer
case 'in':
case 'notin':
return `${key}${operator}(${label.value.join(',')})`;
return `${key} ${operator} (${label.value.join(',')})`;
case '':
case '!':

@ -818,7 +818,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
dashboardUID: this.state.uid,
panelId,
panelPluginId: panel?.state.pluginId,
scope: this.state.scopes?.state.filters.getSelectedScope(),
scopes: this.state.scopes?.getSelectedScopes(),
};
}

@ -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<ScopesDashboardsScene
});
}
public async fetchDashboards(scope: string | undefined) {
if (!scope) {
public async fetchDashboards(scopes: Scope[]) {
if (scopes.length === 0) {
return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false });
}
this.setState({ isLoading: true });
const dashboardUids = await this.fetchDashboardsUids(scope);
const dashboards = await this.fetchDashboardsDetails(dashboardUids);
const dashboardUids = await Promise.all(
scopes.map((scope) => 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<Sco
const { filteredDashboards, isLoading } = model.useState();
const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams();
return (
<>
<div className={styles.searchInputContainer}>
@ -138,9 +142,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
<CustomScrollbar>
{filteredDashboards.map((dashboard, idx) => (
<div key={idx} className={styles.dashboardItem}>
<Link to={{ pathname: dashboard.url, search: locationService.getLocation().search }}>
{dashboard.title}
</Link>
<Link to={urlUtil.renderUrl(dashboard.url, queryParams)}>{dashboard.title}</Link>
</div>
))}
</CustomScrollbar>

@ -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<string, Node>;
}
export interface ScopesFiltersSceneState extends SceneObjectState {
isLoading: boolean;
pendingValue: string | undefined;
nodes: Record<string, Node>;
expandedNodes: string[];
scopes: Scope[];
value: string | undefined;
}
export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState> {
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<ScopeSpec, 'Scope'>({
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];
public getSelectedScope(): Scope | undefined {
return this.state.scopes.find((scope) => scope.metadata.name === this.state.value);
const scopesPromises = scopes.map((scopeName) => this.server.get(scopeName));
Promise.all(scopesPromises).then((scopes) => {
this.setState({ scopes });
});
}
public setScope(newScope: string | undefined) {
if (this.state.isLoading) {
return this.setState({ pendingValue: newScope });
}
public async fetchTreeItems(nodeId: string): Promise<Record<string, Node>> {
try {
return (
(
await getBackendSrv().get<{ items: ScopeTreeItemSpec[] }>(
`/apis/${this.serverGroup}/${this.serverVersion}/namespaces/${this.serverNamespace}/find`,
{ parent: nodeId }
)
)?.items ?? []
).reduce<Record<string, Node>>((acc, item) => {
acc[item.nodeId] = {
item,
isScope: item.nodeType === 'leaf',
children: {},
};
if (!this.state.scopes.find((scope) => scope.metadata.name === newScope)) {
newScope = undefined;
}
return acc;
}, {});
} catch (err) {
getAppEvents().publish({
type: AppEvents.alertError.name,
payload: ['Failed to fetch tree items'],
});
this.setState({ value: newScope });
return {};
}
}
public async fetchScopes() {
this.setState({ isLoading: true });
public async fetchScopes(parent: string) {
try {
const response = await this.server.list();
this.setScopesAfterFetch(response.items);
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'],
});
return [];
}
}
public async expandNode(path: string[]) {
let nodes = { ...this.state.nodes };
let currentLevel = nodes;
for (let idx = 0; idx < path.length; idx++) {
const nodeId = path[idx];
const isLast = idx === path.length - 1;
const currentNode = currentLevel[nodeId];
this.setScopesAfterFetch([]);
} finally {
this.setState({ isLoading: false });
currentLevel[nodeId] = {
...currentNode,
children: isLast ? await this.fetchTreeItems(nodeId) : currentLevel[nodeId].children,
};
currentLevel = currentNode.children;
}
this.setState({
nodes,
expandedNodes: path,
});
}
private setScopesAfterFetch(scopes: Scope[]) {
let value = this.state.pendingValue ?? this.state.value;
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 (!scopes.find((scope) => scope.metadata.name === value)) {
value = undefined;
if (scope) {
scopes = [...scopes, scope];
}
} else {
scopes.splice(selectedIdx, 1);
}
this.setState({ scopes, pendingValue: undefined, value });
this.setState({ scopes });
}
}
export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<ScopesFiltersScene>) {
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<SelectableValue<string>> = 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 (
<Toggletip
content={
<ScopesTreeLevel
isExpanded
path={[]}
nodes={nodes}
expandedNodes={expandedNodes}
scopes={scopes}
onNodeExpand={handleNodeExpand}
onScopeToggle={handleScopeToggle}
/>
}
footer={'Open advanced scope selector'}
closeButton={false}
>
<Input disabled={isViewing} readOnly value={scopes.map((scope) => scope.spec.title)} />
</Toggletip>
);
}
export interface ScopesTreeLevelProps {
isExpanded: boolean;
path: string[];
nodes: Record<string, Node>;
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 (
<Select
isClearable
isLoading={isLoading}
disabled={isViewing}
options={options}
value={value}
onChange={(selectableValue) => model.setScope(selectableValue?.value ?? undefined)}
/>
<div role="tree" className={path.length > 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 (
<div
key={nodeId}
role="treeitem"
aria-selected={isExpanded}
tabIndex={0}
className={cx(styles.item, isScope && styles.itemScope)}
onClick={(evt) => {
evt.stopPropagation();
onNodeExpand(nodePath);
}}
onKeyDown={(evt) => {
evt.stopPropagation();
onNodeExpand(nodePath);
}}
>
{!isScope ? (
<Icon className={styles.icon} name="folder" />
) : (
<Checkbox
className={styles.checkbox}
checked={isSelected}
onChange={(evt) => {
evt.stopPropagation();
if (linkId) {
onScopeToggle(linkId);
}
}}
/>
)}
<span>{node.item.title}</span>
<ScopesTreeLevel
isExpanded={isExpanded}
path={nodePath}
nodes={children}
expandedNodes={expandedNodes}
scopes={scopes}
onNodeExpand={onNodeExpand}
onScopeToggle={onScopeToggle}
/>
</div>
);
})}
</div>
);
}
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),
}),
};
};

@ -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',
},
dashboard2: {
uid: 'dashboard2',
title: 'Dashboard 2',
url: '/d/dashboard2',
},
dashboard3: {
uid: 'dashboard3',
title: 'Dashboard 3',
url: '/d/dashboard3',
},
};
const scopesMocks: Record<
string,
Scope & {
dashboards: ScopeDashboard[];
}
> = {
scope1: {
metadata: {
name: 'scope1',
},
const mocksScopes: Scope[] = [
{
metadata: { name: 'indexHelperCluster' },
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' },
],
title: 'Cluster Index Helper',
type: 'indexHelper',
description: 'redundant label filter but makes queries faster',
category: 'indexHelpers',
filters: [{ key: 'indexHelper', value: 'cluster', operator: 'equals' }],
},
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2, dashboardsMocks.dashboard3],
},
scope2: {
metadata: {
name: 'scope2',
{
metadata: { name: 'slothClusterNorth' },
spec: {
title: 'slothClusterNorth',
type: 'cluster',
description: 'slothClusterNorth',
category: 'clusters',
filters: [{ key: 'cluster', value: 'slothClusterNorth', operator: 'equals' }],
},
},
{
metadata: { name: 'slothClusterSouth' },
spec: {
title: 'Scope 2',
type: 'Type 2',
description: 'Description 2',
category: 'Category 2',
filters: [{ key: 'c-key', operator: 'not-equals', value: 'c-value' }],
title: 'slothClusterSouth',
type: 'cluster',
description: 'slothClusterSouth',
category: 'clusters',
filters: [{ key: 'cluster', value: 'slothClusterSouth', operator: 'equals' }],
},
dashboards: [dashboardsMocks.dashboard3],
},
scope3: {
metadata: {
name: 'scope3',
{
metadata: { name: 'slothPictureFactory' },
spec: {
title: 'slothPictureFactory',
type: 'app',
description: 'slothPictureFactory',
category: 'apps',
filters: [{ key: 'app', value: 'slothPictureFactory', operator: 'equals' }],
},
},
{
metadata: { name: 'slothVoteTracker' },
spec: {
title: 'Scope 3',
type: 'Type 1',
description: 'Description 3',
category: 'Category 1',
filters: [{ key: 'd-key', operator: 'equals', value: 'd-value' }],
title: 'slothVoteTracker',
type: 'app',
description: 'slothVoteTracker',
category: 'apps',
filters: [{ key: 'app', value: 'slothVoteTracker', operator: 'equals' }],
},
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2],
},
};
] as const;
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',
},
{
nodeId: 'clusters',
nodeType: 'container',
title: 'Clusters',
description: 'Cluster Scopes',
linkType: 'scope',
linkId: 'indexHelperCluster',
},
{
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(dashboards[0]);
});
filtersScene.toggleScope(scopesNames[1]);
waitFor(() => {
expect(fetchDashboardsSpy).toHaveBeenCalled();
expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope1.dashboards);
expect(dashboardsScene.state.dashboards).toEqual(dashboards.flat());
});
filtersScene.setScope(scopesMocks.scope2.metadata.name);
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.setScope(scope1.metadata.name);
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])
);
});
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
filtersScene.toggleScope(scopesNames[1]);
waitFor(() => {
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes);
});
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 });

@ -27,11 +27,11 @@ export class ScopesScene extends SceneObjectBase<ScopesSceneState> {
});
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<ScopesSceneState> {
});
}
public getSelectedScopes() {
return this.state.filters.state.scopes;
}
public toggleIsExpanded() {
this.setState({ isExpanded: !this.state.isExpanded });
}

Loading…
Cancel
Save