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/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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

@ -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')!;

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

View File

@ -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) {