mirror of
https://github.com/grafana/grafana.git
synced 2025-08-02 05:12:29 +08:00
Implement API server client in Scopes (#85266)
This commit is contained in:
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -403,7 +403,6 @@ playwright.config.ts @grafana/plugins-platform-frontend
|
||||
/public/app/features/dashboard/ @grafana/dashboards-squad
|
||||
/public/app/features/dashboard/components/TransformationsEditor/ @grafana/dataviz-squad
|
||||
/public/app/features/dashboard-scene/ @grafana/dashboards-squad
|
||||
/public/app/features/scopes/ @grafana/dashboards-squad
|
||||
/public/app/features/datasources/ @grafana/plugins-platform-frontend @mikkancso
|
||||
/public/app/features/dimensions/ @grafana/dataviz-squad
|
||||
/public/app/features/dataframe-import/ @grafana/dataviz-squad
|
||||
|
@ -1,20 +1,26 @@
|
||||
export interface ScopeDashboard {
|
||||
uid: string;
|
||||
title: string;
|
||||
url: string;
|
||||
export interface ScopeDashboardBindingSpec {
|
||||
dashboard: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface ScopeFilter {
|
||||
export interface ScopeSpecFilter {
|
||||
key: string;
|
||||
value: string;
|
||||
operator: string;
|
||||
}
|
||||
|
||||
export interface Scope {
|
||||
uid: string;
|
||||
export interface ScopeSpec {
|
||||
title: string;
|
||||
type: string;
|
||||
description: string;
|
||||
category: string;
|
||||
filters: ScopeFilter[];
|
||||
filters: ScopeSpecFilter[];
|
||||
}
|
||||
|
||||
// TODO: Use Resource from apiserver when we export the types
|
||||
export interface Scope {
|
||||
metadata: {
|
||||
name: string;
|
||||
};
|
||||
spec: ScopeSpec;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Scope } from '@grafana/data';
|
||||
import { ScopeSpec } from '@grafana/data';
|
||||
import * as common from '@grafana/schema';
|
||||
|
||||
export enum QueryEditorMode {
|
||||
@ -42,5 +42,5 @@ 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?: Scope;
|
||||
scope?: ScopeSpec;
|
||||
}
|
||||
|
@ -369,7 +369,7 @@ export class PrometheusDatasource
|
||||
};
|
||||
|
||||
if (config.featureToggles.promQLScope) {
|
||||
processedTarget.scope = request.scope;
|
||||
processedTarget.scope = request.scope?.spec;
|
||||
}
|
||||
|
||||
if (target.instant && target.range) {
|
||||
|
@ -2,6 +2,7 @@ import { config, getBackendSrv } from '@grafana/runtime';
|
||||
|
||||
import {
|
||||
ListOptions,
|
||||
ListOptionsFieldSelector,
|
||||
ListOptionsLabelSelector,
|
||||
MetaStatus,
|
||||
Resource,
|
||||
@ -33,9 +34,10 @@ export class ScopedResourceServer<T = object, K = string> implements ResourceSer
|
||||
return getBackendSrv().get<Resource<T, K>>(`${this.url}/${name}`);
|
||||
}
|
||||
|
||||
public async list(opts?: ListOptions<T> | undefined): Promise<ResourceList<T, K>> {
|
||||
public async list(opts?: ListOptions | undefined): Promise<ResourceList<T, K>> {
|
||||
const finalOpts = opts || {};
|
||||
finalOpts.labelSelector = this.parseLabelSelector(finalOpts?.labelSelector);
|
||||
finalOpts.labelSelector = this.parseListOptionsSelector(finalOpts?.labelSelector);
|
||||
finalOpts.fieldSelector = this.parseListOptionsSelector(finalOpts?.fieldSelector);
|
||||
|
||||
return getBackendSrv().get<ResourceList<T, K>>(this.url, opts);
|
||||
}
|
||||
@ -48,12 +50,14 @@ export class ScopedResourceServer<T = object, K = string> implements ResourceSer
|
||||
return getBackendSrv().delete<MetaStatus>(`${this.url}/${name}`);
|
||||
}
|
||||
|
||||
private parseLabelSelector<T>(labelSelector: ListOptionsLabelSelector<T> | undefined): string | undefined {
|
||||
if (!Array.isArray(labelSelector)) {
|
||||
return labelSelector;
|
||||
private parseListOptionsSelector(
|
||||
selector: ListOptionsLabelSelector | ListOptionsFieldSelector | undefined
|
||||
): string | undefined {
|
||||
if (!Array.isArray(selector)) {
|
||||
return selector;
|
||||
}
|
||||
|
||||
return labelSelector
|
||||
return selector
|
||||
.map((label) => {
|
||||
const key = String(label.key);
|
||||
const operator = label.operator;
|
||||
|
@ -78,32 +78,44 @@ export interface ResourceList<T, K = string> extends TypeMeta {
|
||||
items: Array<Resource<T, K>>;
|
||||
}
|
||||
|
||||
export type ListOptionsLabelSelector<T = {}> =
|
||||
export type ListOptionsLabelSelector =
|
||||
| string
|
||||
| Array<
|
||||
| {
|
||||
key: keyof T;
|
||||
key: string;
|
||||
operator: '=' | '!=';
|
||||
value: string;
|
||||
}
|
||||
| {
|
||||
key: keyof T;
|
||||
key: string;
|
||||
operator: 'in' | 'notin';
|
||||
value: string[];
|
||||
}
|
||||
| {
|
||||
key: keyof T;
|
||||
key: string;
|
||||
operator: '' | '!';
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ListOptions<T = {}> {
|
||||
export type ListOptionsFieldSelector =
|
||||
| string
|
||||
| Array<{
|
||||
key: string;
|
||||
operator: '=' | '!=';
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
export interface ListOptions {
|
||||
// continue the list at a given batch
|
||||
continue?: string;
|
||||
|
||||
// Query by labels
|
||||
// https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors
|
||||
labelSelector?: ListOptionsLabelSelector<T>;
|
||||
labelSelector?: ListOptionsLabelSelector;
|
||||
|
||||
// Query by fields
|
||||
// https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/
|
||||
fieldSelector?: ListOptionsFieldSelector;
|
||||
|
||||
// Limit the response count
|
||||
limit?: number;
|
||||
@ -129,7 +141,7 @@ export interface MetaStatus {
|
||||
export interface ResourceServer<T = object, K = string> {
|
||||
create(obj: ResourceForCreate<T, K>): Promise<void>;
|
||||
get(name: string): Promise<Resource<T, K>>;
|
||||
list(opts?: ListOptions<T>): Promise<ResourceList<T, K>>;
|
||||
list(opts?: ListOptions): Promise<ResourceList<T, K>>;
|
||||
update(obj: ResourceForCreate<T, K>): Promise<Resource<T, K>>;
|
||||
delete(name: string): Promise<MetaStatus>;
|
||||
}
|
||||
|
@ -2,11 +2,19 @@ import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { AppEvents, GrafanaTheme2, ScopeDashboard } from '@grafana/data';
|
||||
import { config, getAppEvents, getBackendSrv, locationService } from '@grafana/runtime';
|
||||
import { AppEvents, GrafanaTheme2, ScopeDashboardBindingSpec } from '@grafana/data';
|
||||
import { getAppEvents, getBackendSrv, locationService } from '@grafana/runtime';
|
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
||||
import { CustomScrollbar, Icon, Input, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { ScopedResourceServer } from '../../apiserver/server';
|
||||
|
||||
export interface ScopeDashboard {
|
||||
uid: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ScopesDashboardsSceneState extends SceneObjectState {
|
||||
dashboards: ScopeDashboard[];
|
||||
filteredDashboards: ScopeDashboard[];
|
||||
@ -17,8 +25,11 @@ export interface ScopesDashboardsSceneState extends SceneObjectState {
|
||||
export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsSceneState> {
|
||||
static Component = ScopesDashboardsSceneRenderer;
|
||||
|
||||
private _url =
|
||||
config.bootData.settings.listDashboardScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopedashboards';
|
||||
private server = new ScopedResourceServer<ScopeDashboardBindingSpec, 'ScopeDashboardBinding'>({
|
||||
group: 'scope.grafana.app',
|
||||
version: 'v0alpha1',
|
||||
resource: 'scopedashboardbindings',
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
@ -57,11 +68,17 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
|
||||
|
||||
private async fetchDashboardsUids(scope: string): Promise<string[]> {
|
||||
try {
|
||||
const response = await getBackendSrv().get<{
|
||||
items: Array<{ spec: { dashboards: null | string[]; scope: string } }>;
|
||||
}>(this._url, { scope });
|
||||
const response = await this.server.list({
|
||||
fieldSelector: [
|
||||
{
|
||||
key: 'spec.scope',
|
||||
operator: '=',
|
||||
value: scope,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return response.items.find((item) => !!item.spec.dashboards && item.spec.scope === scope)?.spec.dashboards ?? [];
|
||||
return response.items.map((item) => item.spec.dashboard).filter((dashboardUid) => !!dashboardUid) ?? [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { AppEvents, Scope, SelectableValue } from '@grafana/data';
|
||||
import { config, getAppEvents, getBackendSrv } from '@grafana/runtime';
|
||||
import { AppEvents, Scope, ScopeSpec, SelectableValue } from '@grafana/data';
|
||||
import { getAppEvents } from '@grafana/runtime';
|
||||
import {
|
||||
SceneComponentProps,
|
||||
SceneObjectBase,
|
||||
@ -11,6 +11,8 @@ import {
|
||||
} from '@grafana/scenes';
|
||||
import { Select } from '@grafana/ui';
|
||||
|
||||
import { ScopedResourceServer } from '../../apiserver/server';
|
||||
|
||||
export interface ScopesFiltersSceneState extends SceneObjectState {
|
||||
isLoading: boolean;
|
||||
pendingValue: string | undefined;
|
||||
@ -23,7 +25,11 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
|
||||
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scope'] });
|
||||
|
||||
private _url = config.bootData.settings.listScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopes';
|
||||
private server = new ScopedResourceServer<ScopeSpec, 'Scope'>({
|
||||
group: 'scope.grafana.app',
|
||||
version: 'v0alpha1',
|
||||
resource: 'scopes',
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
@ -44,7 +50,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
}
|
||||
|
||||
public getSelectedScope(): Scope | undefined {
|
||||
return this.state.scopes.find((scope) => scope.uid === this.state.value);
|
||||
return this.state.scopes.find((scope) => scope.metadata.name === this.state.value);
|
||||
}
|
||||
|
||||
public setScope(newScope: string | undefined) {
|
||||
@ -52,7 +58,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
return this.setState({ pendingValue: newScope });
|
||||
}
|
||||
|
||||
if (!this.state.scopes.find((scope) => scope.uid === newScope)) {
|
||||
if (!this.state.scopes.find((scope) => scope.metadata.name === newScope)) {
|
||||
newScope = undefined;
|
||||
}
|
||||
|
||||
@ -63,16 +69,9 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
try {
|
||||
const response = await getBackendSrv().get<{
|
||||
items: Array<{ metadata: { uid: string }; spec: Omit<Scope, 'uid'> }>;
|
||||
}>(this._url);
|
||||
const response = await this.server.list();
|
||||
|
||||
this.setScopesAfterFetch(
|
||||
response.items.map(({ metadata: { uid }, spec }) => ({
|
||||
uid,
|
||||
...spec,
|
||||
}))
|
||||
);
|
||||
this.setScopesAfterFetch(response.items);
|
||||
} catch (err) {
|
||||
getAppEvents().publish({
|
||||
type: AppEvents.alertError.name,
|
||||
@ -88,7 +87,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
|
||||
private setScopesAfterFetch(scopes: Scope[]) {
|
||||
let value = this.state.pendingValue ?? this.state.value;
|
||||
|
||||
if (!scopes.find((scope) => scope.uid === value)) {
|
||||
if (!scopes.find((scope) => scope.metadata.name === value)) {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
@ -101,9 +100,9 @@ export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<Scopes
|
||||
const parentState = model.parent!.useState();
|
||||
const isViewing = 'isViewing' in parentState ? !!parentState.isViewing : false;
|
||||
|
||||
const options: Array<SelectableValue<string>> = scopes.map(({ uid, title, category }) => ({
|
||||
const options: Array<SelectableValue<string>> = scopes.map(({ metadata: { name }, spec: { title, category } }) => ({
|
||||
label: title,
|
||||
value: uid,
|
||||
value: name,
|
||||
description: category,
|
||||
}));
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import { Scope } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import {
|
||||
behaviors,
|
||||
@ -13,7 +14,7 @@ import {
|
||||
|
||||
import { DashboardControls } from './DashboardControls';
|
||||
import { DashboardScene } from './DashboardScene';
|
||||
import { ScopesDashboardsScene } from './ScopesDashboardsScene';
|
||||
import { ScopeDashboard, ScopesDashboardsScene } from './ScopesDashboardsScene';
|
||||
import { ScopesFiltersScene } from './ScopesFiltersScene';
|
||||
import { ScopesScene } from './ScopesScene';
|
||||
|
||||
@ -35,35 +36,52 @@ const dashboardsMocks = {
|
||||
},
|
||||
};
|
||||
|
||||
const scopesMocks = {
|
||||
const scopesMocks: Record<
|
||||
string,
|
||||
Scope & {
|
||||
dashboards: ScopeDashboard[];
|
||||
}
|
||||
> = {
|
||||
scope1: {
|
||||
uid: 'scope1',
|
||||
title: 'Scope 1',
|
||||
type: 'Type 1',
|
||||
description: 'Description 1',
|
||||
category: 'Category 1',
|
||||
filters: [
|
||||
{ key: 'a-key', operator: '=', value: 'a-value' },
|
||||
{ key: 'b-key', operator: '!=', value: 'b-value' },
|
||||
],
|
||||
metadata: {
|
||||
name: 'scope1',
|
||||
},
|
||||
spec: {
|
||||
title: 'Scope 1',
|
||||
type: 'Type 1',
|
||||
description: 'Description 1',
|
||||
category: 'Category 1',
|
||||
filters: [
|
||||
{ key: 'a-key', operator: '=', value: 'a-value' },
|
||||
{ key: 'b-key', operator: '!=', value: 'b-value' },
|
||||
],
|
||||
},
|
||||
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2, dashboardsMocks.dashboard3],
|
||||
},
|
||||
scope2: {
|
||||
uid: 'scope2',
|
||||
title: 'Scope 2',
|
||||
type: 'Type 2',
|
||||
description: 'Description 2',
|
||||
category: 'Category 2',
|
||||
filters: [{ key: 'c-key', operator: '!=', value: 'c-value' }],
|
||||
metadata: {
|
||||
name: 'scope2',
|
||||
},
|
||||
spec: {
|
||||
title: 'Scope 2',
|
||||
type: 'Type 2',
|
||||
description: 'Description 2',
|
||||
category: 'Category 2',
|
||||
filters: [{ key: 'c-key', operator: '!=', value: 'c-value' }],
|
||||
},
|
||||
dashboards: [dashboardsMocks.dashboard3],
|
||||
},
|
||||
scope3: {
|
||||
uid: 'scope3',
|
||||
title: 'Scope 3',
|
||||
type: 'Type 1',
|
||||
description: 'Description 3',
|
||||
category: 'Category 1',
|
||||
filters: [{ key: 'd-key', operator: '=', value: 'd-value' }],
|
||||
metadata: {
|
||||
name: 'scope3',
|
||||
},
|
||||
spec: {
|
||||
title: 'Scope 3',
|
||||
type: 'Type 1',
|
||||
description: 'Description 3',
|
||||
category: 'Category 1',
|
||||
filters: [{ key: 'd-key', operator: '=', value: 'd-value' }],
|
||||
},
|
||||
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2],
|
||||
},
|
||||
};
|
||||
@ -73,29 +91,27 @@ jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getBackendSrv: () => ({
|
||||
get: jest.fn().mockImplementation((url: string) => {
|
||||
if (url === '/apis/scope.grafana.app/v0alpha1/scopes') {
|
||||
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes')) {
|
||||
return {
|
||||
items: Object.values(scopesMocks).map((scope) => ({
|
||||
metadata: { uid: scope.uid },
|
||||
spec: {
|
||||
title: scope.title,
|
||||
type: scope.type,
|
||||
description: scope.description,
|
||||
category: scope.category,
|
||||
filters: scope.filters,
|
||||
},
|
||||
})),
|
||||
items: Object.values(scopesMocks).map(({ dashboards: _dashboards, ...scope }) => scope),
|
||||
};
|
||||
}
|
||||
|
||||
if (url === '/apis/scope.grafana.app/v0alpha1/scopedashboards') {
|
||||
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: Object.values(scopesMocks).map((scope) => ({
|
||||
spec: {
|
||||
dashboards: scope.dashboards.map((dashboard) => dashboard.uid),
|
||||
scope: scope.uid,
|
||||
},
|
||||
})),
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
@ -179,14 +195,14 @@ describe('ScopesScene', () => {
|
||||
});
|
||||
|
||||
it('Fetches dashboards list', () => {
|
||||
filtersScene.setScope(scopesMocks.scope1.uid);
|
||||
filtersScene.setScope(scopesMocks.scope1.metadata.name);
|
||||
|
||||
waitFor(() => {
|
||||
expect(fetchDashboardsSpy).toHaveBeenCalled();
|
||||
expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope1.dashboards);
|
||||
});
|
||||
|
||||
filtersScene.setScope(scopesMocks.scope2.uid);
|
||||
filtersScene.setScope(scopesMocks.scope2.metadata.name);
|
||||
|
||||
waitFor(() => {
|
||||
expect(fetchDashboardsSpy).toHaveBeenCalled();
|
||||
@ -197,7 +213,7 @@ describe('ScopesScene', () => {
|
||||
it('Enriches data requests', () => {
|
||||
const { dashboards: _dashboards, ...scope1 } = scopesMocks.scope1;
|
||||
|
||||
filtersScene.setScope(scope1.uid);
|
||||
filtersScene.setScope(scope1.metadata.name);
|
||||
|
||||
const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;
|
||||
|
||||
|
@ -1,32 +0,0 @@
|
||||
import { Scope, ScopeDashboard } from '@grafana/data';
|
||||
|
||||
import { ScopedResourceServer } from '../apiserver/server';
|
||||
import { ResourceServer } from '../apiserver/types';
|
||||
|
||||
// config.bootData.settings.listDashboardScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopedashboards';
|
||||
// config.bootData.settings.listScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopes';
|
||||
|
||||
interface ScopeServers {
|
||||
scopes: ResourceServer<Scope>;
|
||||
dashboards: ResourceServer<ScopeDashboard>;
|
||||
}
|
||||
|
||||
let instance: ScopeServers | undefined = undefined;
|
||||
|
||||
export function getScopeServers() {
|
||||
if (!instance) {
|
||||
instance = {
|
||||
scopes: new ScopedResourceServer<Scope>({
|
||||
group: 'scope.grafana.app',
|
||||
version: 'v0alpha1',
|
||||
resource: 'scopes',
|
||||
}),
|
||||
dashboards: new ScopedResourceServer<ScopeDashboard>({
|
||||
group: 'scope.grafana.app',
|
||||
version: 'v0alpha1',
|
||||
resource: 'scopedashboards',
|
||||
}),
|
||||
};
|
||||
}
|
||||
return instance;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { Scope } from '@grafana/data';
|
||||
import { ScopeSpec } from '@grafana/data';
|
||||
import * as common from '@grafana/schema';
|
||||
|
||||
export enum QueryEditorMode {
|
||||
@ -45,5 +45,5 @@ export interface Prometheus extends common.DataQuery {
|
||||
/**
|
||||
* A scope object that will be used by Prometheus
|
||||
*/
|
||||
scope?: Scope;
|
||||
scope?: ScopeSpec;
|
||||
}
|
||||
|
@ -369,7 +369,7 @@ export class PrometheusDatasource
|
||||
};
|
||||
|
||||
if (config.featureToggles.promQLScope) {
|
||||
processedTarget.scope = request.scope;
|
||||
processedTarget.scope = request.scope?.spec;
|
||||
}
|
||||
|
||||
if (target.instant && target.range) {
|
||||
|
Reference in New Issue
Block a user