mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
85a646b4dc
commit
45d1766524
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@ -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
|
||||
|
81
public/app/features/apiserver/server.ts
Normal file
81
public/app/features/apiserver/server.ts
Normal file
@ -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(',');
|
||||
}
|
||||
}
|
30
public/app/features/apiserver/types.test.ts
Normal file
30
public/app/features/apiserver/types.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
135
public/app/features/apiserver/types.ts
Normal file
135
public/app/features/apiserver/types.ts
Normal file
@ -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 { 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<PlaylistSpec>;
|
||||
|
||||
class K8sAPI implements PlaylistAPI {
|
||||
readonly apiVersion = 'playlist.grafana.app/v0alpha1';
|
||||
readonly url: string;
|
||||
readonly server: ResourceServer<PlaylistSpec>;
|
||||
|
||||
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[]> {
|
||||
const result = await getBackendSrv().get<K8sPlaylistList>(this.url);
|
||||
const result = await this.server.list();
|
||||
return result.items.map(k8sResourceAsPlaylist);
|
||||
}
|
||||
|
||||
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);
|
||||
await migrateInternalIDs(p);
|
||||
return p;
|
||||
@ -75,22 +71,20 @@ class K8sAPI implements PlaylistAPI {
|
||||
|
||||
async createPlaylist(playlist: Playlist): Promise<void> {
|
||||
const body = this.playlistAsK8sResource(playlist);
|
||||
await withErrorHandling(() => getBackendSrv().post(this.url, body));
|
||||
await withErrorHandling(() => this.server.create(body));
|
||||
}
|
||||
|
||||
async updatePlaylist(playlist: Playlist): Promise<void> {
|
||||
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> {
|
||||
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 {
|
||||
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;
|
||||
|
32
public/app/features/scopes/server.ts
Normal file
32
public/app/features/scopes/server.ts
Normal file
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user