K8s/Frontend: Add generic resource server and use it for playlists (#83339)
Co-authored-by: Bogdan Matei <bogdan.matei@grafana.com>
This commit is contained in:
co-authored by
Bogdan Matei
parent
85a646b4dc
commit
45d1766524
@@ -403,6 +403,7 @@ 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
|
||||||
@@ -414,6 +415,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
|
|||||||
/public/app/features/library-panels/ @grafana/dashboards-squad
|
/public/app/features/library-panels/ @grafana/dashboards-squad
|
||||||
/public/app/features/logs/ @grafana/observability-logs
|
/public/app/features/logs/ @grafana/observability-logs
|
||||||
/public/app/features/live/ @grafana/grafana-app-platform-squad
|
/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/manage-dashboards/ @grafana/dashboards-squad
|
||||||
/public/app/features/notifications/ @grafana/grafana-frontend-platform
|
/public/app/features/notifications/ @grafana/grafana-frontend-platform
|
||||||
/public/app/features/org/ @grafana/grafana-frontend-platform
|
/public/app/features/org/ @grafana/grafana-frontend-platform
|
||||||
|
|||||||
@@ -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<T = object, K = string> implements ResourceServer<T, K> {
|
||||||
|
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<T, K>): Promise<void> {
|
||||||
|
return getBackendSrv().post(this.url, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(name: string): Promise<Resource<T, K>> {
|
||||||
|
return getBackendSrv().get<Resource<T, K>>(`${this.url}/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async list(opts?: ListOptions<T> | undefined): Promise<ResourceList<T, K>> {
|
||||||
|
const finalOpts = opts || {};
|
||||||
|
finalOpts.labelSelector = this.parseLabelSelector(finalOpts?.labelSelector);
|
||||||
|
|
||||||
|
return getBackendSrv().get<ResourceList<T, K>>(this.url, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async update(obj: Resource<T, K>): Promise<Resource<T, K>> {
|
||||||
|
return getBackendSrv().put<Resource<T, K>>(`${this.url}/${obj.metadata.name}`, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async delete(name: string): Promise<MetaStatus> {
|
||||||
|
return getBackendSrv().delete<MetaStatus>(`${this.url}/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseLabelSelector<T>(labelSelector: ListOptionsLabelSelector<T> | 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(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { AnnoKeyCreatedBy, Resource } from './types';
|
||||||
|
|
||||||
|
interface MyObjSpec {
|
||||||
|
value: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('simple typescript tests', () => {
|
||||||
|
const val: Resource<MyObjSpec, 'MyObject'> = {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<K = string> {
|
||||||
|
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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T = object, K = string> extends TypeMeta<K> {
|
||||||
|
metadata: ObjectMeta;
|
||||||
|
spec: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceForCreate<T = object, K = string> extends Partial<TypeMeta<K>> {
|
||||||
|
metadata: Partial<ObjectMeta>;
|
||||||
|
spec: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListMeta {
|
||||||
|
resourceVersion: string;
|
||||||
|
continue?: string;
|
||||||
|
remainingItemCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceList<T, K = string> extends TypeMeta {
|
||||||
|
metadata: ListMeta;
|
||||||
|
items: Array<Resource<T, K>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListOptionsLabelSelector<T = {}> =
|
||||||
|
| string
|
||||||
|
| Array<
|
||||||
|
| {
|
||||||
|
key: keyof T;
|
||||||
|
operator: '=' | '!=';
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: keyof T;
|
||||||
|
operator: 'in' | 'notin';
|
||||||
|
value: string[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
key: keyof T;
|
||||||
|
operator: '' | '!';
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ListOptions<T = {}> {
|
||||||
|
// 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>;
|
||||||
|
|
||||||
|
// 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<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>>;
|
||||||
|
update(obj: ResourceForCreate<T, K>): Promise<Resource<T, K>>;
|
||||||
|
delete(name: string): Promise<MetaStatus>;
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import { getGrafanaDatasource } from 'app/plugins/datasource/grafana/datasource'
|
|||||||
import { GrafanaQuery, GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
|
import { GrafanaQuery, GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
|
||||||
import { dispatch } from 'app/store/store';
|
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 { DashboardQueryResult, getGrafanaSearcher, SearchQuery } from '../search/service';
|
||||||
|
|
||||||
import { Playlist, PlaylistItem, PlaylistAPI } from './types';
|
import { Playlist, PlaylistItem, PlaylistAPI } from './types';
|
||||||
@@ -36,38 +38,32 @@ class LegacyAPI implements PlaylistAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface K8sPlaylistList {
|
interface PlaylistSpec {
|
||||||
items: K8sPlaylist[];
|
title: string;
|
||||||
|
interval: string;
|
||||||
|
items: PlaylistItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface K8sPlaylist {
|
type K8sPlaylist = Resource<PlaylistSpec>;
|
||||||
apiVersion: string;
|
|
||||||
kind: 'Playlist';
|
|
||||||
metadata: {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
spec: {
|
|
||||||
title: string;
|
|
||||||
interval: string;
|
|
||||||
items: PlaylistItem[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class K8sAPI implements PlaylistAPI {
|
class K8sAPI implements PlaylistAPI {
|
||||||
readonly apiVersion = 'playlist.grafana.app/v0alpha1';
|
readonly server: ResourceServer<PlaylistSpec>;
|
||||||
readonly url: string;
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.url = `/apis/${this.apiVersion}/namespaces/${config.namespace}/playlists`;
|
this.server = new ScopedResourceServer<PlaylistSpec>({
|
||||||
|
group: 'playlist.grafana.app',
|
||||||
|
version: 'v0alpha1',
|
||||||
|
resource: 'playlists',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllPlaylist(): Promise<Playlist[]> {
|
async getAllPlaylist(): Promise<Playlist[]> {
|
||||||
const result = await getBackendSrv().get<K8sPlaylistList>(this.url);
|
const result = await this.server.list();
|
||||||
return result.items.map(k8sResourceAsPlaylist);
|
return result.items.map(k8sResourceAsPlaylist);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPlaylist(uid: string): Promise<Playlist> {
|
async getPlaylist(uid: string): Promise<Playlist> {
|
||||||
const r = await getBackendSrv().get<K8sPlaylist>(this.url + '/' + uid);
|
const r = await this.server.get(uid);
|
||||||
const p = k8sResourceAsPlaylist(r);
|
const p = k8sResourceAsPlaylist(r);
|
||||||
await migrateInternalIDs(p);
|
await migrateInternalIDs(p);
|
||||||
return p;
|
return p;
|
||||||
@@ -75,22 +71,20 @@ class K8sAPI implements PlaylistAPI {
|
|||||||
|
|
||||||
async createPlaylist(playlist: Playlist): Promise<void> {
|
async createPlaylist(playlist: Playlist): Promise<void> {
|
||||||
const body = this.playlistAsK8sResource(playlist);
|
const body = this.playlistAsK8sResource(playlist);
|
||||||
await withErrorHandling(() => getBackendSrv().post(this.url, body));
|
await withErrorHandling(() => this.server.create(body));
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePlaylist(playlist: Playlist): Promise<void> {
|
async updatePlaylist(playlist: Playlist): Promise<void> {
|
||||||
const body = this.playlistAsK8sResource(playlist);
|
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<void> {
|
async deletePlaylist(uid: string): Promise<void> {
|
||||||
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<PlaylistSpec> => {
|
||||||
return {
|
return {
|
||||||
apiVersion: this.apiVersion,
|
|
||||||
kind: 'Playlist',
|
|
||||||
metadata: {
|
metadata: {
|
||||||
name: playlist.uid, // uid as k8s name
|
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
|
// 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"
|
// to avoid future confusion, the display name is now called "title"
|
||||||
function k8sResourceAsPlaylist(r: K8sPlaylist): Playlist {
|
function k8sResourceAsPlaylist(r: K8sPlaylist): Playlist {
|
||||||
const { spec, metadata } = r;
|
const { spec, metadata } = r;
|
||||||
|
|||||||
@@ -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<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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user