Files
Alex Khomenko f9fb9d268f Restore dashboards: Add API client endpoints (#106435)
* Dashboards: Add restore endpoints to the API

* Fix unified api

* Fix resource version

* Add tests

* Update api

* Update type guards

* Update comments

* Add missing type

* Cleanup

* Move spec checking logic to v1 client

* Handle mixed versions in deleted dbs list

* Update tests

* comment

* type
2025-06-12 15:49:55 +03:00

189 lines
6.3 KiB
TypeScript

import { locationUtil } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Dashboard } from '@grafana/schema';
import { Status } from '@grafana/schema/src/schema/dashboard/v2alpha1/types.status.gen';
import { backendSrv } from 'app/core/services/backend_srv';
import { getMessageFromError, getStatusFromError } from 'app/core/utils/errors';
import kbn from 'app/core/utils/kbn';
import { ScopedResourceClient } from 'app/features/apiserver/client';
import {
ResourceClient,
ResourceForCreate,
AnnoKeyMessage,
AnnoKeyFolder,
AnnoKeyGrantPermissions,
Resource,
DeprecatedInternalId,
AnnoKeyManagerKind,
AnnoKeySourcePath,
AnnoKeyManagerAllowsEdits,
ManagerKind,
} from 'app/features/apiserver/types';
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
import { DashboardDataDTO, DashboardDTO, SaveDashboardResponseDTO } from 'app/types';
import { SaveDashboardCommand } from '../components/SaveDashboard/types';
import { DashboardAPI, DashboardVersionError, DashboardWithAccessInfo, ListDeletedDashboardsOptions } from './types';
export const K8S_V1_DASHBOARD_API_CONFIG = {
group: 'dashboard.grafana.app',
version: 'v1beta1',
resource: 'dashboards',
};
export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
private client: ResourceClient<DashboardDataDTO, Status>;
constructor() {
this.client = new ScopedResourceClient<DashboardDataDTO>(K8S_V1_DASHBOARD_API_CONFIG);
}
saveDashboard(options: SaveDashboardCommand<Dashboard>): Promise<SaveDashboardResponseDTO> {
const dashboard = options.dashboard;
const obj: ResourceForCreate<DashboardDataDTO> = {
metadata: {
...options?.k8s,
},
spec: {
...dashboard,
title: dashboard.title ?? '',
uid: dashboard.uid ?? '',
},
};
if (options.message) {
obj.metadata.annotations = {
...obj.metadata.annotations,
[AnnoKeyMessage]: options.message,
};
} else if (obj.metadata.annotations) {
delete obj.metadata.annotations[AnnoKeyMessage];
}
if (options.folderUid) {
obj.metadata.annotations = {
...obj.metadata.annotations,
[AnnoKeyFolder]: options.folderUid,
};
}
// for v1 in g12, we will ignore the schema version validation from all default clients,
// as we implement the necessary backend conversions, we will drop this query param
if (dashboard.uid) {
obj.metadata.name = dashboard.uid;
// remove resource version when updating
delete obj.metadata.resourceVersion;
return this.client.update(obj, { fieldValidation: 'Ignore' }).then((v) => this.asSaveDashboardResponseDTO(v));
}
obj.metadata.annotations = {
...obj.metadata.annotations,
[AnnoKeyGrantPermissions]: 'default',
};
return this.client.create(obj, { fieldValidation: 'Ignore' }).then((v) => this.asSaveDashboardResponseDTO(v));
}
asSaveDashboardResponseDTO(v: Resource<DashboardDataDTO>): SaveDashboardResponseDTO {
const url = locationUtil.assureBaseUrl(
getDashboardUrl({
uid: v.metadata.name,
currentQueryParams: '',
slug: kbn.slugifyForUrl(v.spec.title.trim()),
})
);
return {
uid: v.metadata.name,
version: v.metadata.generation ?? 0,
id: v.spec.id ?? 0,
status: 'success',
url,
slug: '',
};
}
deleteDashboard(uid: string, showSuccessAlert: boolean): Promise<DeleteDashboardResponse> {
return this.client.delete(uid, showSuccessAlert).then((v) => ({
id: 0,
message: v.message,
title: t('dashboard.k8s-dashboard-api.title.deleted', 'deleted'),
}));
}
async getDashboardDTO(uid: string) {
try {
const dash = await this.client.subresource<DashboardWithAccessInfo<DashboardDataDTO>>(uid, 'dto');
// This could come as conversion error from v0 or v2 to V1.
if (dash.status?.conversion?.failed && dash.status.conversion.storedVersion === 'v2alpha1') {
throw new DashboardVersionError(dash.status.conversion.storedVersion, dash.status.conversion.error);
}
const result: DashboardDTO = {
meta: {
...dash.access,
isNew: false,
isFolder: false,
uid: dash.metadata.name,
k8s: dash.metadata,
version: dash.metadata.generation,
},
dashboard: {
...dash.spec,
version: dash.metadata.generation,
uid: dash.metadata.name,
},
};
const annotations = dash.metadata.annotations ?? {};
const managerKind = annotations[AnnoKeyManagerKind];
if (managerKind) {
result.meta.provisioned = annotations[AnnoKeyManagerAllowsEdits] === 'true' || managerKind === ManagerKind.Repo;
result.meta.provisionedExternalId = annotations[AnnoKeySourcePath];
}
if (dash.metadata.labels?.[DeprecatedInternalId]) {
result.dashboard.id = parseInt(dash.metadata.labels[DeprecatedInternalId], 10);
}
if (dash.metadata.annotations?.[AnnoKeyFolder]) {
try {
const folder = await backendSrv.getFolderByUid(dash.metadata.annotations[AnnoKeyFolder]);
result.meta.folderTitle = folder.title;
result.meta.folderUrl = folder.url;
result.meta.folderUid = folder.uid;
result.meta.folderId = folder.id;
} catch (e) {
throw new Error('Failed to load folder');
}
}
return result;
} catch (e) {
const status = getStatusFromError(e);
const message = getMessageFromError(e);
// Hacking around a bug in k8s api server that returns 500 for not found resources
if (message.includes('not found') && status !== 404) {
// @ts-expect-error
e.status = 404;
// @ts-expect-error
e.data.message = 'Dashboard not found';
}
throw e;
}
}
async listDeletedDashboards(options: ListDeletedDashboardsOptions) {
return await this.client.list({ ...options, labelSelector: 'grafana.app/get-trash=true' });
}
restoreDashboard(dashboard: Resource<DashboardDataDTO>) {
// reset the resource version to create a new resource
dashboard.metadata.resourceVersion = '';
return this.client.create(dashboard);
}
}