import { Observable, from, retry, catchError, filter, map, mergeMap } from 'rxjs'; import { isLiveChannelMessageEvent, LiveChannelScope } from '@grafana/data'; import { config, getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/core'; import { getAPINamespace } from '../../api/utils'; import { ListOptions, ListOptionsFieldSelector, ListOptionsLabelSelector, MetaStatus, Resource, ResourceForCreate, ResourceList, ResourceClient, ObjectMeta, WatchOptions, K8sAPIGroupList, AnnoKeySavedFromUI, ResourceEvent, ResourceClientWriteParams, GroupVersionResource, } from './types'; export class ScopedResourceClient implements ResourceClient { readonly url: string; readonly gvr: GroupVersionResource; constructor(gvr: GroupVersionResource, namespaced = true) { const ns = namespaced ? `namespaces/${getAPINamespace()}/` : ''; this.gvr = gvr; this.url = `/apis/${gvr.group}/${gvr.version}/${ns}${gvr.resource}`; } public async get(name: string): Promise> { return getBackendSrv().get>(`${this.url}/${name}`); } public watch(params?: WatchOptions): Observable> { const requestParams = { watch: true, labelSelector: this.parseListOptionsSelector(params?.labelSelector), fieldSelector: this.parseListOptionsSelector(params?.fieldSelector), }; if (params?.name) { requestParams.fieldSelector = `metadata.name=${params.name}`; } // For now, watch over live only supports provisioning if (this.gvr.group === 'provisioning.grafana.app') { let query = ''; if (requestParams.fieldSelector?.startsWith('metadata.name=')) { query = requestParams.fieldSelector.substring('metadata.name'.length); } return getGrafanaLiveSrv() .getStream>({ scope: LiveChannelScope.Watch, namespace: this.gvr.group, path: `${this.gvr.version}/${this.gvr.resource}${query}/${config.bootData.user.uid}`, }) .pipe( filter((event) => isLiveChannelMessageEvent(event)), map((event) => event.message) ); } const decoder = new TextDecoder(); return getBackendSrv() .chunked({ url: this.url, params: requestParams, method: 'GET', }) .pipe( filter((response) => response.ok && response.data instanceof Uint8Array), map((response) => { const text = decoder.decode(response.data); return text.split('\n'); }), mergeMap((text) => from(text)), filter((line) => line.length > 0), map((line) => { try { return JSON.parse(line); } catch (e) { console.warn('Invalid JSON in watch stream:', e, line); return null; } }), filter((event): event is ResourceEvent => event !== null), retry({ count: 3, delay: 1000 }), catchError((error) => { console.error('Watch stream error:', error); throw error; }) ); } public async subresource(name: string, path: string, params?: Record): Promise { return getBackendSrv().get(`${this.url}/${name}/${path}`, params); } public async list(opts?: ListOptions | undefined): Promise> { const finalOpts = opts || {}; finalOpts.labelSelector = this.parseListOptionsSelector(finalOpts?.labelSelector); finalOpts.fieldSelector = this.parseListOptionsSelector(finalOpts?.fieldSelector); return getBackendSrv().get>(this.url, opts); } public async create(obj: ResourceForCreate, params?: ResourceClientWriteParams): Promise> { if (!obj.metadata.name && !obj.metadata.generateName) { const login = contextSrv.user.login; // GenerateName lets the apiserver create a new uid for the name // THe passed in value is the suggested prefix obj.metadata.generateName = login ? login.slice(0, 2) : 'g'; } setSavedFromUIAnnotation(obj.metadata); return getBackendSrv().post(this.url, obj, { params, }); } public async update(obj: Resource, params?: ResourceClientWriteParams): Promise> { setSavedFromUIAnnotation(obj.metadata); const url = `${this.url}/${obj.metadata.name}`; return getBackendSrv().put>(url, obj, { params, }); } public async delete(name: string, showSuccessAlert: boolean): Promise { return getBackendSrv().delete(`${this.url}/${name}`, undefined, { showSuccessAlert, }); } private parseListOptionsSelector = parseListOptionsSelector; } // add the origin annotations so we know what was set from the UI function setSavedFromUIAnnotation(meta: Partial) { if (!meta.annotations) { meta.annotations = {}; } meta.annotations[AnnoKeySavedFromUI] = config.buildInfo.versionString; } export class DatasourceAPIVersions { private apiVersions?: { [pluginID: string]: string }; async get(pluginID: string): Promise { if (this.apiVersions) { return this.apiVersions[pluginID]; } const apis = await getBackendSrv().get('/apis'); const apiVersions: { [pluginID: string]: string } = {}; apis.groups.forEach((group) => { if (group.name.includes('datasource.grafana.app')) { const id = group.name.split('.')[0]; apiVersions[id] = group.preferredVersion.version; // workaround for plugins that don't append '-datasource' for the group name // e.g. org-plugin-datasource uses org-plugin.datasource.grafana.app if (!id.endsWith('-datasource')) { if (!id.includes('-')) { // workaroud for Grafana plugins that don't include the org either // e.g. testdata uses testdata.datasource.grafana.app apiVersions[`grafana-${id}-datasource`] = group.preferredVersion.version; } else { apiVersions[`${id}-datasource`] = group.preferredVersion.version; } } } }); this.apiVersions = apiVersions; return apiVersions[pluginID]; } } export const parseListOptionsSelector = (selector: ListOptionsLabelSelector | ListOptionsFieldSelector | undefined) => { if (!Array.isArray(selector)) { return selector; } return selector .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(','); };