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>
This commit is contained in:
Bogdan Matei
2024-05-15 16:28:09 +03:00
committed by GitHub
parent 6c1e9a9717
commit 6127dfd322
14 changed files with 665 additions and 331 deletions

View File

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

View File

@@ -33,3 +33,16 @@ export interface Scope {
}; };
spec: ScopeSpec; 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;
}

View File

@@ -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 * 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; range?: boolean;
scope?: ScopeSpec; scopes?: ScopeSpec[];
adhocFilters?: ScopeSpecFilter[]; adhocFilters?: ScopeSpecFilter[];
} }

View File

@@ -374,7 +374,7 @@ export class PrometheusDatasource
}; };
if (config.featureToggles.promQLScope) { if (config.featureToggles.promQLScope) {
processedTarget.scope = request.scope?.spec; processedTarget.scopes = (request.scopes ?? []).map((scope) => scope.spec);
} }
if (target.instant && target.range) { if (target.instant && target.range) {

View File

@@ -12,7 +12,6 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
sdkapi "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1" 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/attribute"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
@@ -67,7 +66,7 @@ type PrometheusQueryProperties struct {
LegendFormat string `json:"legendFormat,omitempty"` LegendFormat string `json:"legendFormat,omitempty"`
// A set of filters applied to apply to the query // 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. // Additional Ad-hoc filters that take precedence over Scope on conflict.
AdhocFilters []ScopeFilter `json:"adhocFilters,omitempty"` AdhocFilters []ScopeFilter `json:"adhocFilters,omitempty"`
@@ -167,11 +166,8 @@ type Query struct {
RangeQuery bool RangeQuery bool
ExemplarQuery bool ExemplarQuery bool
UtcOffsetSec int64 UtcOffsetSec int64
Scope *ScopeSpec
}
type Scope struct { Scopes []ScopeSpec
Matchers []*labels.Matcher
} }
// This internal query struct is just like QueryModel, except it does not include: // 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 { if enableScope {
var scopeFilters []ScopeFilter var scopeFilters []ScopeFilter
if model.Scope != nil { for _, scope := range model.Scopes {
scopeFilters = model.Scope.Filters scopeFilters = append(scopeFilters, scope.Filters...)
} }
if len(scopeFilters) > 0 { if len(scopeFilters) > 0 {

View File

@@ -162,55 +162,59 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"scope": { "scopes": {
"description": "A set of filters applied to apply to the query", "description": "A set of filters applied to apply to the query",
"type": "object", "type": "array",
"required": [ "items": {
"title", "description": "ScopeSpec is a hand copy of the ScopeSpec struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)",
"type", "type": "object",
"description", "required": [
"category", "title",
"filters" "type",
], "description",
"properties": { "category",
"category": { "filters"
"type": "string" ],
}, "properties": {
"description": { "category": {
"type": "string" "type": "string"
}, },
"filters": { "description": {
"type": "array", "type": "string"
"items": { },
"description": "ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", "filters": {
"type": "object", "type": "array",
"required": [ "items": {
"key", "description": "ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)",
"value", "type": "object",
"operator" "required": [
], "key",
"properties": { "value",
"key": { "operator"
"type": "string" ],
"properties": {
"key": {
"type": "string"
},
"operator": {
"type": "string"
},
"value": {
"type": "string"
}
}, },
"operator": { "additionalProperties": false
"type": "string" }
}, },
"value": { "title": {
"type": "string" "type": "string"
} },
}, "type": {
"additionalProperties": false "type": "string"
} }
}, },
"title": { "additionalProperties": false
"type": "string" }
},
"type": {
"type": "string"
}
},
"additionalProperties": false
}, },
"timeRange": { "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", "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",

View File

@@ -172,55 +172,59 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"scope": { "scopes": {
"description": "A set of filters applied to apply to the query", "description": "A set of filters applied to apply to the query",
"type": "object", "type": "array",
"required": [ "items": {
"title", "description": "ScopeSpec is a hand copy of the ScopeSpec struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)",
"type", "type": "object",
"description", "required": [
"category", "title",
"filters" "type",
], "description",
"properties": { "category",
"category": { "filters"
"type": "string" ],
}, "properties": {
"description": { "category": {
"type": "string" "type": "string"
}, },
"filters": { "description": {
"type": "array", "type": "string"
"items": { },
"description": "ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)", "filters": {
"type": "object", "type": "array",
"required": [ "items": {
"key", "description": "ScopeFilter is a hand copy of the ScopeFilter struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)",
"value", "type": "object",
"operator" "required": [
], "key",
"properties": { "value",
"key": { "operator"
"type": "string" ],
"properties": {
"key": {
"type": "string"
},
"operator": {
"type": "string"
},
"value": {
"type": "string"
}
}, },
"operator": { "additionalProperties": false
"type": "string" }
}, },
"value": { "title": {
"type": "string" "type": "string"
} },
}, "type": {
"additionalProperties": false "type": "string"
} }
}, },
"title": { "additionalProperties": false
"type": "string" }
},
"type": {
"type": "string"
}
},
"additionalProperties": false
}, },
"timeRange": { "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", "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",

View File

@@ -8,7 +8,7 @@
{ {
"metadata": { "metadata": {
"name": "default", "name": "default",
"resourceVersion": "1713187448137", "resourceVersion": "1715777575561",
"creationTimestamp": "2024-03-25T13:19:04Z" "creationTimestamp": "2024-03-25T13:19:04Z"
}, },
"spec": { "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", "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" "type": "boolean"
}, },
"scope": { "scopes": {
"additionalProperties": false,
"description": "A set of filters applied to apply to the query", "description": "A set of filters applied to apply to the query",
"properties": { "items": {
"category": { "additionalProperties": false,
"type": "string" "description": "ScopeSpec is a hand copy of the ScopeSpec struct from pkg/apis/scope/v0alpha1/types.go to avoid import (temp fix)",
}, "properties": {
"description": { "category": {
"type": "string" "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" "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": { "required": [
"type": "string" "title",
}, "type",
"type": { "description",
"type": "string" "category",
} "filters"
],
"type": "object"
}, },
"required": [ "type": "array"
"title",
"type",
"description",
"category",
"filters"
],
"type": "object"
} }
}, },
"required": [ "required": [

View File

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

View File

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

View File

@@ -2,12 +2,12 @@ import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { AppEvents, GrafanaTheme2, ScopeDashboardBindingSpec } from '@grafana/data'; import { AppEvents, GrafanaTheme2, Scope, ScopeDashboardBindingSpec, urlUtil } from '@grafana/data';
import { getAppEvents, getBackendSrv, locationService } from '@grafana/runtime'; import { getAppEvents, getBackendSrv } from '@grafana/runtime';
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { CustomScrollbar, Icon, Input, useStyles2 } from '@grafana/ui'; import { CustomScrollbar, Icon, Input, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { ScopedResourceServer } from '../../apiserver/server'; import { ScopedResourceServer } from 'app/features/apiserver/server';
export interface ScopeDashboard { export interface ScopeDashboard {
uid: string; uid: string;
@@ -40,15 +40,17 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
}); });
} }
public async fetchDashboards(scope: string | undefined) { public async fetchDashboards(scopes: Scope[]) {
if (!scope) { if (scopes.length === 0) {
return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false }); return this.setState({ dashboards: [], filteredDashboards: [], isLoading: false });
} }
this.setState({ isLoading: true }); this.setState({ isLoading: true });
const dashboardUids = await this.fetchDashboardsUids(scope); const dashboardUids = await Promise.all(
const dashboards = await this.fetchDashboardsDetails(dashboardUids); scopes.map((scope) => this.fetchDashboardsUids(scope.metadata.name).catch(() => []))
);
const dashboards = await this.fetchDashboardsDetails(dashboardUids.flat());
this.setState({ this.setState({
dashboards, dashboards,
@@ -125,6 +127,8 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
const { filteredDashboards, isLoading } = model.useState(); const { filteredDashboards, isLoading } = model.useState();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams();
return ( return (
<> <>
<div className={styles.searchInputContainer}> <div className={styles.searchInputContainer}>
@@ -138,9 +142,7 @@ export function ScopesDashboardsSceneRenderer({ model }: SceneComponentProps<Sco
<CustomScrollbar> <CustomScrollbar>
{filteredDashboards.map((dashboard, idx) => ( {filteredDashboards.map((dashboard, idx) => (
<div key={idx} className={styles.dashboardItem}> <div key={idx} className={styles.dashboardItem}>
<Link to={{ pathname: dashboard.url, search: locationService.getLocation().search }}> <Link to={urlUtil.renderUrl(dashboard.url, queryParams)}>{dashboard.title}</Link>
{dashboard.title}
</Link>
</div> </div>
))} ))}
</CustomScrollbar> </CustomScrollbar>

View File

@@ -1,7 +1,8 @@
import { css, cx } from '@emotion/css';
import React from 'react'; import React from 'react';
import { AppEvents, Scope, ScopeSpec, SelectableValue } from '@grafana/data'; import { AppEvents, GrafanaTheme2, Scope, ScopeSpec, ScopeTreeItemSpec } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime'; import { getAppEvents, getBackendSrv } from '@grafana/runtime';
import { import {
SceneComponentProps, SceneComponentProps,
SceneObjectBase, SceneObjectBase,
@@ -9,111 +10,280 @@ import {
SceneObjectUrlSyncConfig, SceneObjectUrlSyncConfig,
SceneObjectUrlValues, SceneObjectUrlValues,
} from '@grafana/scenes'; } 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 { export interface ScopesFiltersSceneState extends SceneObjectState {
isLoading: boolean; nodes: Record<string, Node>;
pendingValue: string | undefined; expandedNodes: string[];
scopes: Scope[]; scopes: Scope[];
value: string | undefined;
} }
export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState> { export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState> {
static Component = ScopesFiltersSceneRenderer; 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'>({ private server = new ScopedResourceServer<ScopeSpec, 'Scope'>({
group: 'scope.grafana.app', group: this.serverGroup,
version: 'v0alpha1', version: this.serverVersion,
resource: 'scopes', resource: 'scopes',
}); });
constructor() { constructor() {
super({ super({
isLoading: true, nodes: {},
pendingValue: undefined, expandedNodes: [],
scopes: [], scopes: [],
value: undefined,
}); });
} }
getUrlState() { getUrlState() {
return { scope: this.state.value }; return { scopes: this.state.scopes.map((scope) => scope.metadata.name) };
} }
updateFromUrl(values: SceneObjectUrlValues) { updateFromUrl(values: SceneObjectUrlValues) {
const scope = values.scope ?? undefined; let scopes = values.scopes ?? [];
this.setScope(Array.isArray(scope) ? scope[0] : scope); 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 { public async fetchTreeItems(nodeId: string): Promise<Record<string, Node>> {
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 });
try { 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<Record<string, Node>>((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) { } catch (err) {
getAppEvents().publish({ getAppEvents().publish({
type: AppEvents.alertError.name, type: AppEvents.alertError.name,
payload: ['Failed to fetch scopes'], payload: ['Failed to fetch scopes'],
}); });
return [];
this.setScopesAfterFetch([]);
} finally {
this.setState({ isLoading: false });
} }
} }
private setScopesAfterFetch(scopes: Scope[]) { public async expandNode(path: string[]) {
let value = this.state.pendingValue ?? this.state.value; let nodes = { ...this.state.nodes };
let currentLevel = nodes;
if (!scopes.find((scope) => scope.metadata.name === value)) { for (let idx = 0; idx < path.length; idx++) {
value = undefined; 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<ScopesFiltersScene>) { export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<ScopesFiltersScene>) {
const { scopes, isLoading, value } = model.useState(); const { nodes, expandedNodes, scopes } = model.useState();
const parentState = model.parent!.useState(); const parentState = model.parent!.useState();
const isViewing = 'isViewing' in parentState ? !!parentState.isViewing : false; const isViewing = 'isViewing' in parentState ? !!parentState.isViewing : false;
const options: Array<SelectableValue<string>> = scopes.map(({ metadata: { name }, spec: { title, category } }) => ({ const handleNodeExpand = (path: string[]) => model.expandNode(path);
label: title, const handleScopeToggle = (linkId: string) => model.toggleScope(linkId);
value: name,
description: category,
}));
return ( return (
<Select <Toggletip
isClearable content={
isLoading={isLoading} <ScopesTreeLevel
disabled={isViewing} isExpanded
options={options} path={[]}
value={value} nodes={nodes}
onChange={(selectableValue) => model.setScope(selectableValue?.value ?? undefined)} 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 (
<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),
}),
};
};

View File

@@ -1,6 +1,6 @@
import { waitFor } from '@testing-library/react'; import { waitFor } from '@testing-library/react';
import { Scope } from '@grafana/data'; import { Scope, ScopeDashboardBindingSpec, ScopeTreeItemSpec } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { import {
behaviors, behaviors,
@@ -18,125 +18,209 @@ import { ScopeDashboard, ScopesDashboardsScene } from './ScopesDashboardsScene';
import { ScopesFiltersScene } from './ScopesFiltersScene'; import { ScopesFiltersScene } from './ScopesFiltersScene';
import { ScopesScene } from './ScopesScene'; import { ScopesScene } from './ScopesScene';
const dashboardsMocks = { const mocksScopes: Scope[] = [
dashboard1: { {
uid: 'dashboard1', metadata: { name: 'indexHelperCluster' },
title: 'Dashboard 1', spec: {
url: '/d/dashboard1', 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', metadata: { name: 'slothClusterNorth' },
title: 'Dashboard 2', spec: {
url: '/d/dashboard2', title: 'slothClusterNorth',
type: 'cluster',
description: 'slothClusterNorth',
category: 'clusters',
filters: [{ key: 'cluster', value: 'slothClusterNorth', operator: 'equals' }],
},
}, },
dashboard3: { {
uid: 'dashboard3', metadata: { name: 'slothClusterSouth' },
title: 'Dashboard 3', spec: {
url: '/d/dashboard3', 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< const mocksScopeDashboardBindings: ScopeDashboardBindingSpec[] = [
string, { dashboard: '1', scope: 'slothPictureFactory' },
Scope & { { dashboard: '2', scope: 'slothPictureFactory' },
dashboards: ScopeDashboard[]; { dashboard: '3', scope: 'slothVoteTracker' },
} { dashboard: '4', scope: 'slothVoteTracker' },
> = { ] as const;
scope1: {
metadata: { const mocksNodes: ScopeTreeItemSpec[] = [
name: 'scope1', {
}, nodeId: 'applications',
spec: { nodeType: 'container',
title: 'Scope 1', title: 'Applications',
type: 'Type 1', description: 'Application Scopes',
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],
}, },
scope2: { {
metadata: { nodeId: 'clusters',
name: 'scope2', nodeType: 'container',
}, title: 'Clusters',
spec: { description: 'Cluster Scopes',
title: 'Scope 2', linkType: 'scope',
type: 'Type 2', linkId: 'indexHelperCluster',
description: 'Description 2',
category: 'Category 2',
filters: [{ key: 'c-key', operator: 'not-equals', value: 'c-value' }],
},
dashboards: [dashboardsMocks.dashboard3],
}, },
scope3: { {
metadata: { nodeId: 'applications-slothPictureFactory',
name: 'scope3', nodeType: 'leaf',
}, title: 'slothPictureFactory',
spec: { description: 'slothPictureFactory',
title: 'Scope 3', linkType: 'scope',
type: 'Type 1', linkId: 'slothPictureFactory',
description: 'Description 3',
category: 'Category 1',
filters: [{ key: 'd-key', operator: 'equals', value: 'd-value' }],
},
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2],
}, },
}; {
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', () => ({ jest.mock('@grafana/runtime', () => ({
__esModule: true, __esModule: true,
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({ getBackendSrv: () => ({
get: jest.fn().mockImplementation((url: string) => { 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 { 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')) { 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=', '') ?? ''; const scope = search.get('fieldSelector')?.replace('spec.scope=', '') ?? '';
if (scope in scopesMocks) {
return {
items: scopesMocks[scope].dashboards.map(({ uid }) => ({
scope,
dashboard: uid,
})),
};
}
return { return {
items: [], items: mocksScopeDashboardBindings.filter((binding) => binding.scope === scope),
}; };
} }
if (url.startsWith('/api/dashboards/uid/')) { if (url.startsWith('/api/dashboards/uid/')) {
const uid = url.split('/').pop(); const uid = url.split('/').pop();
if (!uid) { return uid ? getDashboardDetailsForUid(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 {}; return {};
@@ -160,10 +244,15 @@ describe('ScopesScene', () => {
}); });
describe('Feature flag on', () => { describe('Feature flag on', () => {
let scopesNames: string[];
let scopes: Scope[];
let scopeDashboardBindings: ScopeDashboardBindingSpec[][];
let dashboards: ScopeDashboard[][];
let dashboardScene: DashboardScene; let dashboardScene: DashboardScene;
let scopesScene: ScopesScene; let scopesScene: ScopesScene;
let filtersScene: ScopesFiltersScene; let filtersScene: ScopesFiltersScene;
let dashboardsScene: ScopesDashboardsScene; let dashboardsScene: ScopesDashboardsScene;
let fetchBaseNodesSpy: jest.SpyInstance;
let fetchScopesSpy: jest.SpyInstance; let fetchScopesSpy: jest.SpyInstance;
let fetchDashboardsSpy: jest.SpyInstance; let fetchDashboardsSpy: jest.SpyInstance;
@@ -172,10 +261,19 @@ describe('ScopesScene', () => {
}); });
beforeEach(() => { 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(); dashboardScene = buildTestScene();
scopesScene = dashboardScene.state.scopes!; scopesScene = dashboardScene.state.scopes!;
filtersScene = scopesScene.state.filters; filtersScene = scopesScene.state.filters;
dashboardsScene = scopesScene.state.dashboards; dashboardsScene = scopesScene.state.dashboards;
fetchBaseNodesSpy = jest.spyOn(filtersScene!, 'fetchBaseNodes');
fetchScopesSpy = jest.spyOn(filtersScene!, 'fetchScopes'); fetchScopesSpy = jest.spyOn(filtersScene!, 'fetchScopes');
fetchDashboardsSpy = jest.spyOn(dashboardsScene!, 'fetchDashboards'); fetchDashboardsSpy = jest.spyOn(dashboardsScene!, 'fetchDashboards');
dashboardScene.activate(); dashboardScene.activate();
@@ -190,50 +288,89 @@ describe('ScopesScene', () => {
expect(dashboardsScene).toBeInstanceOf(ScopesDashboardsScene); expect(dashboardsScene).toBeInstanceOf(ScopesDashboardsScene);
}); });
it('Fetches scopes list', async () => { it('Fetches nodes list', () => {
expect(fetchScopesSpy).toHaveBeenCalled(); 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', () => { it('Fetches dashboards list', () => {
filtersScene.setScope(scopesMocks.scope1.metadata.name); filtersScene.toggleScope(scopesNames[0]);
waitFor(() => { waitFor(() => {
expect(fetchDashboardsSpy).toHaveBeenCalled(); 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(() => { waitFor(() => {
expect(fetchDashboardsSpy).toHaveBeenCalled(); expect(fetchDashboardsSpy).toHaveBeenCalled();
expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope2.dashboards); expect(dashboardsScene.state.dashboards).toEqual(dashboards[1]);
}); });
}); });
it('Enriches data requests', () => { 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')!; filtersScene.toggleScope(scopesNames[0]);
waitFor(() => {
expect(dashboardScene.enrichDataRequest(queryRunner).scope).toEqual(scope1); 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(); scopesScene.toggleIsExpanded();
expect(scopesScene.state.isExpanded).toEqual(true); expect(scopesScene.state.isExpanded).toEqual(true);
}); });
it('Enters view mode', async () => { it('Enters view mode', () => {
dashboardScene.onEnterEditMode(); dashboardScene.onEnterEditMode();
expect(scopesScene.state.isViewing).toEqual(true); expect(scopesScene.state.isViewing).toEqual(true);
expect(scopesScene.state.isExpanded).toEqual(false); expect(scopesScene.state.isExpanded).toEqual(false);
}); });
it('Exits view mode', async () => { it('Exits view mode', () => {
dashboardScene.onEnterEditMode(); dashboardScene.onEnterEditMode();
dashboardScene.exitEditMode({ skipConfirm: true }); dashboardScene.exitEditMode({ skipConfirm: true });

View File

@@ -27,11 +27,11 @@ export class ScopesScene extends SceneObjectBase<ScopesSceneState> {
}); });
this.addActivationHandler(() => { this.addActivationHandler(() => {
this.state.filters.fetchScopes(); this.state.filters.fetchBaseNodes();
const filtersValueSubscription = this.state.filters.subscribeToState((newState, prevState) => { const filtersValueSubscription = this.state.filters.subscribeToState((newState, prevState) => {
if (newState.value !== prevState.value) { if (newState.scopes !== prevState.scopes) {
this.state.dashboards.fetchDashboards(newState.value); this.state.dashboards.fetchDashboards(newState.scopes);
sceneGraph.getTimeRange(this.parent!).onRefresh(); 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() { public toggleIsExpanded() {
this.setState({ isExpanded: !this.state.isExpanded }); this.setState({ isExpanded: !this.state.isExpanded });
} }