Implement API server client in Scopes (#85266)

This commit is contained in:
Bogdan Matei
2024-04-15 13:43:42 +03:00
committed by GitHub
parent fc6cad797d
commit d379e319d6
12 changed files with 150 additions and 129 deletions

1
.github/CODEOWNERS vendored
View File

@ -403,7 +403,6 @@ playwright.config.ts @grafana/plugins-platform-frontend
/public/app/features/dashboard/ @grafana/dashboards-squad /public/app/features/dashboard/ @grafana/dashboards-squad
/public/app/features/dashboard/components/TransformationsEditor/ @grafana/dataviz-squad /public/app/features/dashboard/components/TransformationsEditor/ @grafana/dataviz-squad
/public/app/features/dashboard-scene/ @grafana/dashboards-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/datasources/ @grafana/plugins-platform-frontend @mikkancso
/public/app/features/dimensions/ @grafana/dataviz-squad /public/app/features/dimensions/ @grafana/dataviz-squad
/public/app/features/dataframe-import/ @grafana/dataviz-squad /public/app/features/dataframe-import/ @grafana/dataviz-squad

View File

@ -1,20 +1,26 @@
export interface ScopeDashboard { export interface ScopeDashboardBindingSpec {
uid: string; dashboard: string;
title: string; scope: string;
url: string;
} }
export interface ScopeFilter { export interface ScopeSpecFilter {
key: string; key: string;
value: string; value: string;
operator: string; operator: string;
} }
export interface Scope { export interface ScopeSpec {
uid: string;
title: string; title: string;
type: string; type: string;
description: string; description: string;
category: 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;
} }

View File

@ -1,4 +1,4 @@
import { Scope } from '@grafana/data'; import { ScopeSpec } from '@grafana/data';
import * as common from '@grafana/schema'; import * as common from '@grafana/schema';
export enum QueryEditorMode { 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 * 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?: Scope; scope?: ScopeSpec;
} }

View File

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

View File

@ -2,6 +2,7 @@ import { config, getBackendSrv } from '@grafana/runtime';
import { import {
ListOptions, ListOptions,
ListOptionsFieldSelector,
ListOptionsLabelSelector, ListOptionsLabelSelector,
MetaStatus, MetaStatus,
Resource, Resource,
@ -33,9 +34,10 @@ export class ScopedResourceServer<T = object, K = string> implements ResourceSer
return getBackendSrv().get<Resource<T, K>>(`${this.url}/${name}`); 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 || {}; 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); 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}`); return getBackendSrv().delete<MetaStatus>(`${this.url}/${name}`);
} }
private parseLabelSelector<T>(labelSelector: ListOptionsLabelSelector<T> | undefined): string | undefined { private parseListOptionsSelector(
if (!Array.isArray(labelSelector)) { selector: ListOptionsLabelSelector | ListOptionsFieldSelector | undefined
return labelSelector; ): string | undefined {
if (!Array.isArray(selector)) {
return selector;
} }
return labelSelector return selector
.map((label) => { .map((label) => {
const key = String(label.key); const key = String(label.key);
const operator = label.operator; const operator = label.operator;

View File

@ -78,32 +78,44 @@ export interface ResourceList<T, K = string> extends TypeMeta {
items: Array<Resource<T, K>>; items: Array<Resource<T, K>>;
} }
export type ListOptionsLabelSelector<T = {}> = export type ListOptionsLabelSelector =
| string | string
| Array< | Array<
| { | {
key: keyof T; key: string;
operator: '=' | '!='; operator: '=' | '!=';
value: string; value: string;
} }
| { | {
key: keyof T; key: string;
operator: 'in' | 'notin'; operator: 'in' | 'notin';
value: string[]; value: string[];
} }
| { | {
key: keyof T; key: string;
operator: '' | '!'; 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 the list at a given batch
continue?: string; continue?: string;
// Query by labels // Query by labels
// https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors // 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 the response count
limit?: number; limit?: number;
@ -129,7 +141,7 @@ export interface MetaStatus {
export interface ResourceServer<T = object, K = string> { export interface ResourceServer<T = object, K = string> {
create(obj: ResourceForCreate<T, K>): Promise<void>; create(obj: ResourceForCreate<T, K>): Promise<void>;
get(name: string): Promise<Resource<T, K>>; 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>>; update(obj: ResourceForCreate<T, K>): Promise<Resource<T, K>>;
delete(name: string): Promise<MetaStatus>; delete(name: string): Promise<MetaStatus>;
} }

View File

@ -2,11 +2,19 @@ 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, ScopeDashboard } from '@grafana/data'; import { AppEvents, GrafanaTheme2, ScopeDashboardBindingSpec } from '@grafana/data';
import { config, getAppEvents, getBackendSrv, locationService } from '@grafana/runtime'; import { getAppEvents, getBackendSrv, locationService } 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 { ScopedResourceServer } from '../../apiserver/server';
export interface ScopeDashboard {
uid: string;
title: string;
url: string;
}
export interface ScopesDashboardsSceneState extends SceneObjectState { export interface ScopesDashboardsSceneState extends SceneObjectState {
dashboards: ScopeDashboard[]; dashboards: ScopeDashboard[];
filteredDashboards: ScopeDashboard[]; filteredDashboards: ScopeDashboard[];
@ -17,8 +25,11 @@ export interface ScopesDashboardsSceneState extends SceneObjectState {
export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsSceneState> { export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsSceneState> {
static Component = ScopesDashboardsSceneRenderer; static Component = ScopesDashboardsSceneRenderer;
private _url = private server = new ScopedResourceServer<ScopeDashboardBindingSpec, 'ScopeDashboardBinding'>({
config.bootData.settings.listDashboardScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopedashboards'; group: 'scope.grafana.app',
version: 'v0alpha1',
resource: 'scopedashboardbindings',
});
constructor() { constructor() {
super({ super({
@ -57,11 +68,17 @@ export class ScopesDashboardsScene extends SceneObjectBase<ScopesDashboardsScene
private async fetchDashboardsUids(scope: string): Promise<string[]> { private async fetchDashboardsUids(scope: string): Promise<string[]> {
try { try {
const response = await getBackendSrv().get<{ const response = await this.server.list({
items: Array<{ spec: { dashboards: null | string[]; scope: string } }>; fieldSelector: [
}>(this._url, { scope }); {
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) { } catch (err) {
return []; return [];
} }

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { AppEvents, Scope, SelectableValue } from '@grafana/data'; import { AppEvents, Scope, ScopeSpec, SelectableValue } from '@grafana/data';
import { config, getAppEvents, getBackendSrv } from '@grafana/runtime'; import { getAppEvents } from '@grafana/runtime';
import { import {
SceneComponentProps, SceneComponentProps,
SceneObjectBase, SceneObjectBase,
@ -11,6 +11,8 @@ import {
} from '@grafana/scenes'; } from '@grafana/scenes';
import { Select } from '@grafana/ui'; import { Select } from '@grafana/ui';
import { ScopedResourceServer } from '../../apiserver/server';
export interface ScopesFiltersSceneState extends SceneObjectState { export interface ScopesFiltersSceneState extends SceneObjectState {
isLoading: boolean; isLoading: boolean;
pendingValue: string | undefined; pendingValue: string | undefined;
@ -23,7 +25,11 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scope'] }); 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() { constructor() {
super({ super({
@ -44,7 +50,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
} }
public getSelectedScope(): Scope | undefined { 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) { public setScope(newScope: string | undefined) {
@ -52,7 +58,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
return this.setState({ pendingValue: newScope }); 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; newScope = undefined;
} }
@ -63,16 +69,9 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
this.setState({ isLoading: true }); this.setState({ isLoading: true });
try { try {
const response = await getBackendSrv().get<{ const response = await this.server.list();
items: Array<{ metadata: { uid: string }; spec: Omit<Scope, 'uid'> }>;
}>(this._url);
this.setScopesAfterFetch( this.setScopesAfterFetch(response.items);
response.items.map(({ metadata: { uid }, spec }) => ({
uid,
...spec,
}))
);
} catch (err) { } catch (err) {
getAppEvents().publish({ getAppEvents().publish({
type: AppEvents.alertError.name, type: AppEvents.alertError.name,
@ -88,7 +87,7 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
private setScopesAfterFetch(scopes: Scope[]) { private setScopesAfterFetch(scopes: Scope[]) {
let value = this.state.pendingValue ?? this.state.value; 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; value = undefined;
} }
@ -101,9 +100,9 @@ export function ScopesFiltersSceneRenderer({ model }: SceneComponentProps<Scopes
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(({ uid, title, category }) => ({ const options: Array<SelectableValue<string>> = scopes.map(({ metadata: { name }, spec: { title, category } }) => ({
label: title, label: title,
value: uid, value: name,
description: category, description: category,
})); }));

View File

@ -1,5 +1,6 @@
import { waitFor } from '@testing-library/react'; import { waitFor } from '@testing-library/react';
import { Scope } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { import {
behaviors, behaviors,
@ -13,7 +14,7 @@ import {
import { DashboardControls } from './DashboardControls'; import { DashboardControls } from './DashboardControls';
import { DashboardScene } from './DashboardScene'; import { DashboardScene } from './DashboardScene';
import { ScopesDashboardsScene } from './ScopesDashboardsScene'; import { ScopeDashboard, ScopesDashboardsScene } from './ScopesDashboardsScene';
import { ScopesFiltersScene } from './ScopesFiltersScene'; import { ScopesFiltersScene } from './ScopesFiltersScene';
import { ScopesScene } from './ScopesScene'; import { ScopesScene } from './ScopesScene';
@ -35,9 +36,17 @@ const dashboardsMocks = {
}, },
}; };
const scopesMocks = { const scopesMocks: Record<
string,
Scope & {
dashboards: ScopeDashboard[];
}
> = {
scope1: { scope1: {
uid: 'scope1', metadata: {
name: 'scope1',
},
spec: {
title: 'Scope 1', title: 'Scope 1',
type: 'Type 1', type: 'Type 1',
description: 'Description 1', description: 'Description 1',
@ -46,24 +55,33 @@ const scopesMocks = {
{ key: 'a-key', operator: '=', value: 'a-value' }, { key: 'a-key', operator: '=', value: 'a-value' },
{ key: 'b-key', operator: '!=', value: 'b-value' }, { key: 'b-key', operator: '!=', value: 'b-value' },
], ],
},
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2, dashboardsMocks.dashboard3], dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2, dashboardsMocks.dashboard3],
}, },
scope2: { scope2: {
uid: 'scope2', metadata: {
name: 'scope2',
},
spec: {
title: 'Scope 2', title: 'Scope 2',
type: 'Type 2', type: 'Type 2',
description: 'Description 2', description: 'Description 2',
category: 'Category 2', category: 'Category 2',
filters: [{ key: 'c-key', operator: '!=', value: 'c-value' }], filters: [{ key: 'c-key', operator: '!=', value: 'c-value' }],
},
dashboards: [dashboardsMocks.dashboard3], dashboards: [dashboardsMocks.dashboard3],
}, },
scope3: { scope3: {
uid: 'scope3', metadata: {
name: 'scope3',
},
spec: {
title: 'Scope 3', title: 'Scope 3',
type: 'Type 1', type: 'Type 1',
description: 'Description 3', description: 'Description 3',
category: 'Category 1', category: 'Category 1',
filters: [{ key: 'd-key', operator: '=', value: 'd-value' }], filters: [{ key: 'd-key', operator: '=', value: 'd-value' }],
},
dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2], dashboards: [dashboardsMocks.dashboard1, dashboardsMocks.dashboard2],
}, },
}; };
@ -73,29 +91,27 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({ getBackendSrv: () => ({
get: jest.fn().mockImplementation((url: string) => { 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 { return {
items: Object.values(scopesMocks).map((scope) => ({ items: Object.values(scopesMocks).map(({ dashboards: _dashboards, ...scope }) => scope),
metadata: { uid: scope.uid }, };
spec: { }
title: scope.title,
type: scope.type, if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopedashboardbindings')) {
description: scope.description, const search = new URLSearchParams(url.split('?').pop() || '');
category: scope.category, const scope = search.get('fieldSelector')?.replace('spec.scope=', '') ?? '';
filters: scope.filters,
}, if (scope in scopesMocks) {
return {
items: scopesMocks[scope].dashboards.map(({ uid }) => ({
scope,
dashboard: uid,
})), })),
}; };
} }
if (url === '/apis/scope.grafana.app/v0alpha1/scopedashboards') {
return { return {
items: Object.values(scopesMocks).map((scope) => ({ items: [],
spec: {
dashboards: scope.dashboards.map((dashboard) => dashboard.uid),
scope: scope.uid,
},
})),
}; };
} }
@ -179,14 +195,14 @@ describe('ScopesScene', () => {
}); });
it('Fetches dashboards list', () => { it('Fetches dashboards list', () => {
filtersScene.setScope(scopesMocks.scope1.uid); filtersScene.setScope(scopesMocks.scope1.metadata.name);
waitFor(() => { waitFor(() => {
expect(fetchDashboardsSpy).toHaveBeenCalled(); expect(fetchDashboardsSpy).toHaveBeenCalled();
expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope1.dashboards); expect(dashboardsScene.state.dashboards).toEqual(scopesMocks.scope1.dashboards);
}); });
filtersScene.setScope(scopesMocks.scope2.uid); filtersScene.setScope(scopesMocks.scope2.metadata.name);
waitFor(() => { waitFor(() => {
expect(fetchDashboardsSpy).toHaveBeenCalled(); expect(fetchDashboardsSpy).toHaveBeenCalled();
@ -197,7 +213,7 @@ describe('ScopesScene', () => {
it('Enriches data requests', () => { it('Enriches data requests', () => {
const { dashboards: _dashboards, ...scope1 } = scopesMocks.scope1; 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')!; const queryRunner = sceneGraph.findObject(dashboardScene, (o) => o.state.key === 'data-query-runner')!;

View File

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

View File

@ -1,4 +1,4 @@
import { Scope } from '@grafana/data'; import { ScopeSpec } from '@grafana/data';
import * as common from '@grafana/schema'; import * as common from '@grafana/schema';
export enum QueryEditorMode { export enum QueryEditorMode {
@ -45,5 +45,5 @@ export interface Prometheus extends common.DataQuery {
/** /**
* A scope object that will be used by Prometheus * A scope object that will be used by Prometheus
*/ */
scope?: Scope; scope?: ScopeSpec;
} }

View File

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