diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9e0e0bdaa49..a6128d87b15 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -403,6 +403,7 @@ 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 @@ -414,6 +415,7 @@ playwright.config.ts @grafana/plugins-platform-frontend /public/app/features/library-panels/ @grafana/dashboards-squad /public/app/features/logs/ @grafana/observability-logs /public/app/features/live/ @grafana/grafana-app-platform-squad +/public/app/features/apiserver/ @grafana/grafana-app-platform-squad /public/app/features/manage-dashboards/ @grafana/dashboards-squad /public/app/features/notifications/ @grafana/grafana-frontend-platform /public/app/features/org/ @grafana/grafana-frontend-platform diff --git a/public/app/features/apiserver/server.ts b/public/app/features/apiserver/server.ts new file mode 100644 index 00000000000..6b75ef795c8 --- /dev/null +++ b/public/app/features/apiserver/server.ts @@ -0,0 +1,81 @@ +import { config, getBackendSrv } from '@grafana/runtime'; + +import { + ListOptions, + ListOptionsLabelSelector, + MetaStatus, + Resource, + ResourceForCreate, + ResourceList, + ResourceServer, +} from './types'; + +export interface GroupVersionResource { + group: string; + version: string; + resource: string; +} + +export class ScopedResourceServer implements ResourceServer { + readonly url: string; + + constructor(gvr: GroupVersionResource, namespaced = true) { + const ns = namespaced ? `namespaces/${config.namespace}/` : ''; + + this.url = `/apis/${gvr.group}/${gvr.version}/${ns}${gvr.resource}`; + } + + public async create(obj: ResourceForCreate): Promise { + return getBackendSrv().post(this.url, obj); + } + + public async get(name: string): Promise> { + return getBackendSrv().get>(`${this.url}/${name}`); + } + + public async list(opts?: ListOptions | undefined): Promise> { + const finalOpts = opts || {}; + finalOpts.labelSelector = this.parseLabelSelector(finalOpts?.labelSelector); + + return getBackendSrv().get>(this.url, opts); + } + + public async update(obj: Resource): Promise> { + return getBackendSrv().put>(`${this.url}/${obj.metadata.name}`, obj); + } + + public async delete(name: string): Promise { + return getBackendSrv().delete(`${this.url}/${name}`); + } + + private parseLabelSelector(labelSelector: ListOptionsLabelSelector | undefined): string | undefined { + if (!Array.isArray(labelSelector)) { + return labelSelector; + } + + return labelSelector + .map((label) => { + const key = String(label.key); + const operator = label.operator; + + switch (operator) { + case '=': + case '!=': + return `${key}${operator}${label.value}`; + + case 'in': + case 'notin': + return `${key}${operator}(${label.value.join(',')})`; + + case '': + case '!': + return `${operator}${key}`; + + default: + return null; + } + }) + .filter(Boolean) + .join(','); + } +} diff --git a/public/app/features/apiserver/types.test.ts b/public/app/features/apiserver/types.test.ts new file mode 100644 index 00000000000..fb2a81680dd --- /dev/null +++ b/public/app/features/apiserver/types.test.ts @@ -0,0 +1,30 @@ +import { AnnoKeyCreatedBy, Resource } from './types'; + +interface MyObjSpec { + value: string; + count: number; +} + +describe('simple typescript tests', () => { + const val: Resource = { + apiVersion: 'xxx', + kind: 'MyObject', + metadata: { + name: 'A', + resourceVersion: '1', + creationTimestamp: '123', + }, + spec: { + value: 'a', + count: 2, + }, + }; + + describe('typescript helper', () => { + it('read and write annotations', () => { + expect(val.metadata.annotations?.[AnnoKeyCreatedBy]).toBeUndefined(); + val.metadata.annotations = { 'grafana.app/createdBy': 'me' }; + expect(val.metadata.annotations?.[AnnoKeyCreatedBy]).toBe('me'); + }); + }); +}); diff --git a/public/app/features/apiserver/types.ts b/public/app/features/apiserver/types.ts new file mode 100644 index 00000000000..41a8cbba08a --- /dev/null +++ b/public/app/features/apiserver/types.ts @@ -0,0 +1,135 @@ +/** + * This file holds generic kubernetes compatible types. + * + * This is very much a work in progress aiming to simplify common access patterns for k8s resource + * Please update and improve types/utilities while we find a good pattern here! + * + * Once this is more stable and represents a more general pattern, it should be moved to @grafana/data + * + */ + +/** The object type and version */ +export interface TypeMeta { + apiVersion: string; + kind: K; +} + +export interface ObjectMeta { + // Name is the unique identifier in k8s -- it maps to the "uid" value in most existing grafana objects + name: string; + // Namespace maps the owner group -- it is typically the org or stackId for most grafana resources + namespace?: string; + // Resource version will increase (not sequentially!) with any change to the saved value + resourceVersion: string; + // The first time this was saved + creationTimestamp: string; + // General resource annotations -- including the common grafana.app values + annotations?: GrafanaAnnotations; + // General application level key+value pairs + labels?: Record; +} + +export const AnnoKeyCreatedBy = 'grafana.app/createdBy'; +export const AnnoKeyUpdatedTimestamp = 'grafana.app/updatedTimestamp'; +export const AnnoKeyUpdatedBy = 'grafana.app/updatedBy'; +export const AnnoKeyFolder = 'grafana.app/folder'; +export const AnnoKeySlug = 'grafana.app/slug'; + +// Identify where values came from +const AnnoKeyOriginName = 'grafana.app/originName'; +const AnnoKeyOriginPath = 'grafana.app/originPath'; +const AnnoKeyOriginKey = 'grafana.app/originKey'; +const AnnoKeyOriginTimestamp = 'grafana.app/originTimestamp'; + +type GrafanaAnnotations = { + [AnnoKeyCreatedBy]?: string; + [AnnoKeyUpdatedTimestamp]?: string; + [AnnoKeyUpdatedBy]?: string; + [AnnoKeyFolder]?: string; + [AnnoKeySlug]?: string; + + [AnnoKeyOriginName]?: string; + [AnnoKeyOriginPath]?: string; + [AnnoKeyOriginKey]?: string; + [AnnoKeyOriginTimestamp]?: string; + + // Any key value + [key: string]: string | undefined; +}; + +export interface Resource extends TypeMeta { + metadata: ObjectMeta; + spec: T; +} + +export interface ResourceForCreate extends Partial> { + metadata: Partial; + spec: T; +} + +export interface ListMeta { + resourceVersion: string; + continue?: string; + remainingItemCount?: number; +} + +export interface ResourceList extends TypeMeta { + metadata: ListMeta; + items: Array>; +} + +export type ListOptionsLabelSelector = + | string + | Array< + | { + key: keyof T; + operator: '=' | '!='; + value: string; + } + | { + key: keyof T; + operator: 'in' | 'notin'; + value: string[]; + } + | { + key: keyof T; + operator: '' | '!'; + } + >; + +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; + + // Limit the response count + limit?: number; +} + +export interface MetaStatus { + // Status of the operation. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + status: 'Success' | 'Failure'; + + // A human-readable description of the status of this operation. + message: string; + + // Suggested HTTP return code for this status, 0 if not set. + code: number; + + // A machine-readable description of why this operation is in the "Failure" status. + reason?: string; + + // Extended data associated with the reason + details?: object; +} + +export interface ResourceServer { + create(obj: ResourceForCreate): Promise; + get(name: string): Promise>; + list(opts?: ListOptions): Promise>; + update(obj: ResourceForCreate): Promise>; + delete(name: string): Promise; +} diff --git a/public/app/features/playlist/api.ts b/public/app/features/playlist/api.ts index d6c390d3eb6..48b83403b3a 100644 --- a/public/app/features/playlist/api.ts +++ b/public/app/features/playlist/api.ts @@ -8,6 +8,8 @@ import { getGrafanaDatasource } from 'app/plugins/datasource/grafana/datasource' import { GrafanaQuery, GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { dispatch } from 'app/store/store'; +import { ScopedResourceServer } from '../apiserver/server'; +import { Resource, ResourceForCreate, ResourceServer } from '../apiserver/types'; import { DashboardQueryResult, getGrafanaSearcher, SearchQuery } from '../search/service'; import { Playlist, PlaylistItem, PlaylistAPI } from './types'; @@ -36,38 +38,32 @@ class LegacyAPI implements PlaylistAPI { } } -interface K8sPlaylistList { - items: K8sPlaylist[]; +interface PlaylistSpec { + title: string; + interval: string; + items: PlaylistItem[]; } -interface K8sPlaylist { - apiVersion: string; - kind: 'Playlist'; - metadata: { - name: string; - }; - spec: { - title: string; - interval: string; - items: PlaylistItem[]; - }; -} +type K8sPlaylist = Resource; class K8sAPI implements PlaylistAPI { - readonly apiVersion = 'playlist.grafana.app/v0alpha1'; - readonly url: string; + readonly server: ResourceServer; constructor() { - this.url = `/apis/${this.apiVersion}/namespaces/${config.namespace}/playlists`; + this.server = new ScopedResourceServer({ + group: 'playlist.grafana.app', + version: 'v0alpha1', + resource: 'playlists', + }); } async getAllPlaylist(): Promise { - const result = await getBackendSrv().get(this.url); + const result = await this.server.list(); return result.items.map(k8sResourceAsPlaylist); } async getPlaylist(uid: string): Promise { - const r = await getBackendSrv().get(this.url + '/' + uid); + const r = await this.server.get(uid); const p = k8sResourceAsPlaylist(r); await migrateInternalIDs(p); return p; @@ -75,22 +71,20 @@ class K8sAPI implements PlaylistAPI { async createPlaylist(playlist: Playlist): Promise { const body = this.playlistAsK8sResource(playlist); - await withErrorHandling(() => getBackendSrv().post(this.url, body)); + await withErrorHandling(() => this.server.create(body)); } async updatePlaylist(playlist: Playlist): Promise { const body = this.playlistAsK8sResource(playlist); - await withErrorHandling(() => getBackendSrv().put(`${this.url}/${playlist.uid}`, body)); + await withErrorHandling(() => this.server.update(body).then(() => {})); } async deletePlaylist(uid: string): Promise { - await withErrorHandling(() => getBackendSrv().delete(`${this.url}/${uid}`), 'Playlist deleted'); + await withErrorHandling(() => this.server.delete(uid).then(() => {}), 'Playlist deleted'); } - playlistAsK8sResource = (playlist: Playlist): K8sPlaylist => { + playlistAsK8sResource = (playlist: Playlist): ResourceForCreate => { return { - apiVersion: this.apiVersion, - kind: 'Playlist', metadata: { name: playlist.uid, // uid as k8s name }, @@ -104,7 +98,7 @@ class K8sAPI implements PlaylistAPI { } // This converts a saved k8s resource into a playlist object -// the main difference is that k8s uses metdata.name as the uid +// the main difference is that k8s uses metadata.name as the uid // to avoid future confusion, the display name is now called "title" function k8sResourceAsPlaylist(r: K8sPlaylist): Playlist { const { spec, metadata } = r; diff --git a/public/app/features/scopes/server.ts b/public/app/features/scopes/server.ts new file mode 100644 index 00000000000..c9ee48b346e --- /dev/null +++ b/public/app/features/scopes/server.ts @@ -0,0 +1,32 @@ +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; +}