diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c3a77ff27cd..98d6e96c183 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/packages/grafana-data/src/types/scopes.ts b/packages/grafana-data/src/types/scopes.ts index 09dd972f98c..f0128c59cb7 100644 --- a/packages/grafana-data/src/types/scopes.ts +++ b/packages/grafana-data/src/types/scopes.ts @@ -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; } diff --git a/packages/grafana-prometheus/src/dataquery.ts b/packages/grafana-prometheus/src/dataquery.ts index 545a32345b7..999a969c99e 100644 --- a/packages/grafana-prometheus/src/dataquery.ts +++ b/packages/grafana-prometheus/src/dataquery.ts @@ -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; } diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index 6c9a0c81032..7a1d6731f33 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -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) { diff --git a/public/app/features/apiserver/server.ts b/public/app/features/apiserver/server.ts index 6b75ef795c8..77fba2a006e 100644 --- a/public/app/features/apiserver/server.ts +++ b/public/app/features/apiserver/server.ts @@ -2,6 +2,7 @@ import { config, getBackendSrv } from '@grafana/runtime'; import { ListOptions, + ListOptionsFieldSelector, ListOptionsLabelSelector, MetaStatus, Resource, @@ -33,9 +34,10 @@ export class ScopedResourceServer implements ResourceSer return getBackendSrv().get>(`${this.url}/${name}`); } - public async list(opts?: ListOptions | undefined): Promise> { + public async list(opts?: ListOptions | undefined): Promise> { const finalOpts = opts || {}; - finalOpts.labelSelector = this.parseLabelSelector(finalOpts?.labelSelector); + finalOpts.labelSelector = this.parseListOptionsSelector(finalOpts?.labelSelector); + finalOpts.fieldSelector = this.parseListOptionsSelector(finalOpts?.fieldSelector); return getBackendSrv().get>(this.url, opts); } @@ -48,12 +50,14 @@ export class ScopedResourceServer implements ResourceSer return getBackendSrv().delete(`${this.url}/${name}`); } - private parseLabelSelector(labelSelector: ListOptionsLabelSelector | 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; diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts index 41a8cbba08a..56fbbf5ac7b 100644 --- a/public/app/features/apiserver/types.ts +++ b/public/app/features/apiserver/types.ts @@ -78,32 +78,44 @@ export interface ResourceList extends TypeMeta { items: Array>; } -export type ListOptionsLabelSelector = +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 { +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; + 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 { create(obj: ResourceForCreate): Promise; get(name: string): Promise>; - list(opts?: ListOptions): Promise>; + list(opts?: ListOptions): Promise>; update(obj: ResourceForCreate): Promise>; delete(name: string): Promise; } diff --git a/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx b/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx index b9c60e6fd9b..ef7a56a55c8 100644 --- a/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx +++ b/public/app/features/dashboard-scene/scene/ScopesDashboardsScene.tsx @@ -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 { static Component = ScopesDashboardsSceneRenderer; - private _url = - config.bootData.settings.listDashboardScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopedashboards'; + private server = new ScopedResourceServer({ + group: 'scope.grafana.app', + version: 'v0alpha1', + resource: 'scopedashboardbindings', + }); constructor() { super({ @@ -57,11 +68,17 @@ export class ScopesDashboardsScene extends SceneObjectBase { 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 []; } diff --git a/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx b/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx index 7f2dfe5e6ad..83ba74de49f 100644 --- a/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx +++ b/public/app/features/dashboard-scene/scene/ScopesFiltersScene.tsx @@ -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 protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['scope'] }); - private _url = config.bootData.settings.listScopesEndpoint || '/apis/scope.grafana.app/v0alpha1/scopes'; + private server = new ScopedResourceServer({ + group: 'scope.grafana.app', + version: 'v0alpha1', + resource: 'scopes', + }); constructor() { super({ @@ -44,7 +50,7 @@ export class ScopesFiltersScene extends SceneObjectBase } 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 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 this.setState({ isLoading: true }); try { - const response = await getBackendSrv().get<{ - items: Array<{ metadata: { uid: string }; spec: Omit }>; - }>(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 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.map(({ uid, title, category }) => ({ + const options: Array> = scopes.map(({ metadata: { name }, spec: { title, category } }) => ({ label: title, - value: uid, + value: name, description: category, })); diff --git a/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx b/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx index 0508c762ddc..72f9c2d9ee6 100644 --- a/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx +++ b/public/app/features/dashboard-scene/scene/ScopesScene.test.tsx @@ -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')!; diff --git a/public/app/features/scopes/server.ts b/public/app/features/scopes/server.ts deleted file mode 100644 index c9ee48b346e..00000000000 --- a/public/app/features/scopes/server.ts +++ /dev/null @@ -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; - dashboards: ResourceServer; -} - -let instance: ScopeServers | undefined = undefined; - -export function getScopeServers() { - if (!instance) { - instance = { - scopes: new ScopedResourceServer({ - group: 'scope.grafana.app', - version: 'v0alpha1', - resource: 'scopes', - }), - dashboards: new ScopedResourceServer({ - group: 'scope.grafana.app', - version: 'v0alpha1', - resource: 'scopedashboards', - }), - }; - } - return instance; -} diff --git a/public/app/plugins/datasource/prometheus/dataquery.ts b/public/app/plugins/datasource/prometheus/dataquery.ts index 54f6cd7bac9..ddb8973e949 100644 --- a/public/app/plugins/datasource/prometheus/dataquery.ts +++ b/public/app/plugins/datasource/prometheus/dataquery.ts @@ -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; } diff --git a/public/app/plugins/datasource/prometheus/datasource.ts b/public/app/plugins/datasource/prometheus/datasource.ts index 6c8b7a1a365..e2c898658e9 100644 --- a/public/app/plugins/datasource/prometheus/datasource.ts +++ b/public/app/plugins/datasource/prometheus/datasource.ts @@ -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) {