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
|
// Used to correlate multiple related requests
|
||||||
queryGroupId?: string;
|
queryGroupId?: string;
|
||||||
|
|
||||||
scope?: Scope | undefined;
|
scopes?: Scope[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataQueryTimings {
|
export interface DataQueryTimings {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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 '!':
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user