mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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,8 +162,11 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"scope": {
|
||||
"scopes": {
|
||||
"description": "A set of filters applied to apply to the query",
|
||||
"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",
|
||||
@@ -211,6 +214,7 @@
|
||||
}
|
||||
},
|
||||
"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,8 +172,11 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"scope": {
|
||||
"scopes": {
|
||||
"description": "A set of filters applied to apply to the query",
|
||||
"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",
|
||||
@@ -221,6 +224,7 @@
|
||||
}
|
||||
},
|
||||
"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,9 +85,11 @@
|
||||
"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",
|
||||
"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"
|
||||
@@ -134,6 +136,8 @@
|
||||
"filters"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -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];
|
||||
|
||||
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<Record<string, Node>> {
|
||||
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) {
|
||||
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<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 (
|
||||
<Select
|
||||
isClearable
|
||||
isLoading={isLoading}
|
||||
disabled={isViewing}
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(selectableValue) => model.setScope(selectableValue?.value ?? undefined)}
|
||||
<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 (
|
||||
<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',
|
||||
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',
|
||||
},
|
||||
dashboard3: {
|
||||
uid: 'dashboard3',
|
||||
title: 'Dashboard 3',
|
||||
url: '/d/dashboard3',
|
||||
{
|
||||
metadata: { name: 'slothClusterNorth' },
|
||||
spec: {
|
||||
title: 'slothClusterNorth',
|
||||
type: 'cluster',
|
||||
description: 'slothClusterNorth',
|
||||
category: 'clusters',
|
||||
filters: [{ key: 'cluster', value: 'slothClusterNorth', operator: 'equals' }],
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
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',
|
||||
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',
|
||||
},
|
||||
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' },
|
||||
],
|
||||
{
|
||||
nodeId: 'clusters',
|
||||
nodeType: 'container',
|
||||
title: 'Clusters',
|
||||
description: 'Cluster Scopes',
|
||||
linkType: 'scope',
|
||||
linkId: 'indexHelperCluster',
|
||||
},
|
||||
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2, dashboardsMocks.dashboard3],
|
||||
{
|
||||
nodeId: 'applications-slothPictureFactory',
|
||||
nodeType: 'leaf',
|
||||
title: 'slothPictureFactory',
|
||||
description: 'slothPictureFactory',
|
||||
linkType: 'scope',
|
||||
linkId: 'slothPictureFactory',
|
||||
},
|
||||
scope2: {
|
||||
metadata: {
|
||||
name: 'scope2',
|
||||
{
|
||||
nodeId: 'applications-slothVoteTracker',
|
||||
nodeType: 'leaf',
|
||||
title: 'slothVoteTracker',
|
||||
description: 'slothVoteTracker',
|
||||
linkType: 'scope',
|
||||
linkId: 'slothVoteTracker',
|
||||
},
|
||||
spec: {
|
||||
title: 'Scope 2',
|
||||
type: 'Type 2',
|
||||
description: 'Description 2',
|
||||
category: 'Category 2',
|
||||
filters: [{ key: 'c-key', operator: 'not-equals', value: 'c-value' }],
|
||||
{
|
||||
nodeId: 'applications.clusters',
|
||||
nodeType: 'container',
|
||||
title: 'Clusters',
|
||||
description: 'Application/Clusters Scopes',
|
||||
linkType: 'scope',
|
||||
linkId: 'indexHelperCluster',
|
||||
},
|
||||
dashboards: [dashboardsMocks.dashboard3],
|
||||
{
|
||||
nodeId: 'applications.clusters-slothClusterNorth',
|
||||
nodeType: 'leaf',
|
||||
title: 'slothClusterNorth',
|
||||
description: 'slothClusterNorth',
|
||||
linkType: 'scope',
|
||||
linkId: 'slothClusterNorth',
|
||||
},
|
||||
scope3: {
|
||||
metadata: {
|
||||
name: 'scope3',
|
||||
{
|
||||
nodeId: 'applications.clusters-slothClusterSouth',
|
||||
nodeType: 'leaf',
|
||||
title: 'slothClusterSouth',
|
||||
description: 'slothClusterSouth',
|
||||
linkType: 'scope',
|
||||
linkId: 'slothClusterSouth',
|
||||
},
|
||||
spec: {
|
||||
title: 'Scope 3',
|
||||
type: 'Type 1',
|
||||
description: 'Description 3',
|
||||
category: 'Category 1',
|
||||
filters: [{ key: 'd-key', operator: 'equals', value: 'd-value' }],
|
||||
{
|
||||
nodeId: 'clusters-slothClusterNorth',
|
||||
nodeType: 'leaf',
|
||||
title: 'slothClusterNorth',
|
||||
description: 'slothClusterNorth',
|
||||
linkType: 'scope',
|
||||
linkId: 'slothClusterNorth',
|
||||
},
|
||||
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2],
|
||||
{
|
||||
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 () => {
|
||||
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.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).scope).toEqual(scope1);
|
||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(
|
||||
scopes.filter((scope) => scope.metadata.name === scopesNames[0])
|
||||
);
|
||||
});
|
||||
|
||||
it('Toggles expanded state', async () => {
|
||||
filtersScene.toggleScope(scopesNames[1]);
|
||||
waitFor(() => {
|
||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
||||
expect(dashboardScene.enrichDataRequest(queryRunner).scopes).toEqual(scopes);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user