Files
grafana/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts
Haris Rozajac 6b03adbbb0 Dashboard V2: Snapshot variables and read snapshot (#98463)
* Introduce DashboardScenePageStateManagerLike interface

* Implement dash loader for handling v2 api

* Transformation improvements

* Update response transformer test

* v2 schema: Remove defaultOptionEnabled from ds variable schema

* v2 schema: Make annotations filter optional

* WIP render dashboard from v2 schema

* Force dashbaord scene for v2 api

* V2 schema -> scene meta transformation

* v2 api: Handle home dashboard

* Use correct api client in DashboardScenePage

* Correctly use v2 dashboard scene serializer

* Remove unnecesary type assertions

* Handle v2 dashboard not found

* Fix type

* Fix test

* Some more tests fix

* snapshot

* Add dashboard id annotation

* Nits

* Nits

* Rename v2 api

* Enable snapshot variables

* Support getSnapshotUrl() for v2 serializer

* fix

* Make metadata available in the serializer

* Test

* Decouple meta info

* State Manager: Extract snapshot loading into loadSnapshot

* Fix tests

* Add test for snapshot url

* Don't expose dashboardLoaderSrvV2

* Remove TODO

* Bubble up loading snapshot error to error boundary

* Fix test

---------

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
2025-01-09 08:14:41 -07:00

536 lines
17 KiB
TypeScript

import { isEqual } from 'lodash';
import { locationUtil, UrlQueryMap } from '@grafana/data';
import { config, getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
import { DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2alpha0/dashboard.gen';
import { StateManagerBase } from 'app/core/services/StateManagerBase';
import { getMessageFromError } from 'app/core/utils/errors';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { AnnoKeyFolder } from 'app/features/apiserver/types';
import { ResponseTransformers } from 'app/features/dashboard/api/ResponseTransformers';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { dashboardLoaderSrv, DashboardLoaderSrvV2 } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { emitDashboardViewEvent } from 'app/features/dashboard/state/analyticsProcessor';
import { trackDashboardSceneLoaded } from 'app/features/dashboard/utils/tracking';
import { DashboardDTO, DashboardRoutes } from 'app/types';
import { PanelEditor } from '../panel-edit/PanelEditor';
import { DashboardScene } from '../scene/DashboardScene';
import { buildNewDashboardSaveModel, buildNewDashboardSaveModelV2 } from '../serialization/buildNewDashboardSaveModel';
import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { restoreDashboardStateFromLocalStorage } from '../utils/dashboardSessionState';
import { updateNavModel } from './utils';
export interface DashboardScenePageState {
dashboard?: DashboardScene;
options?: LoadDashboardOptions;
panelEditor?: PanelEditor;
isLoading?: boolean;
loadError?: string;
}
export const DASHBOARD_CACHE_TTL = 500;
const LOAD_SCENE_MEASUREMENT = 'loadDashboardScene';
/** Only used by cache in loading home in DashboardPageProxy and initDashboard (Old arch), can remove this after old dashboard arch is gone */
export const HOME_DASHBOARD_CACHE_KEY = '__grafana_home_uid__';
interface DashboardCacheEntry<T> {
dashboard: T;
ts: number;
cacheKey: string;
}
export interface LoadDashboardOptions {
uid: string;
route: DashboardRoutes;
urlFolderUid?: string;
params?: {
version: number;
scopes: string[];
timeRange: {
from: string;
to: string;
};
variables: UrlQueryMap;
};
}
interface DashboardScenePageStateManagerLike<T> {
fetchDashboard(options: LoadDashboardOptions): Promise<T | null>;
getDashboardFromCache(cacheKey: string): T | null;
loadDashboard(options: LoadDashboardOptions): Promise<void>;
transformResponseToScene(rsp: T | null, options: LoadDashboardOptions): DashboardScene | null;
reloadDashboard(params: LoadDashboardOptions['params']): Promise<void>;
loadSnapshot(slug: string): Promise<void>;
setDashboardCache(cacheKey: string, dashboard: T): void;
clearSceneCache(): void;
clearDashboardCache(): void;
clearState(): void;
getCache(): Record<string, DashboardScene>;
useState: () => DashboardScenePageState;
}
abstract class DashboardScenePageStateManagerBase<T>
extends StateManagerBase<DashboardScenePageState>
implements DashboardScenePageStateManagerLike<T>
{
abstract fetchDashboard(options: LoadDashboardOptions): Promise<T | null>;
abstract reloadDashboard(params: LoadDashboardOptions['params']): Promise<void>;
abstract transformResponseToScene(rsp: T | null, options: LoadDashboardOptions): DashboardScene | null;
abstract loadSnapshotScene(slug: string): Promise<DashboardScene>;
protected cache: Record<string, DashboardScene> = {};
// This is a simplistic, short-term cache for DashboardDTOs to avoid fetching the same dashboard multiple times across a short time span.
protected dashboardCache?: DashboardCacheEntry<T>;
getCache(): Record<string, DashboardScene> {
return this.cache;
}
public async loadSnapshot(slug: string) {
try {
const dashboard = await this.loadSnapshotScene(slug);
this.setState({ dashboard: dashboard, isLoading: false });
} catch (err) {
this.setState({ isLoading: false, loadError: String(err) });
}
}
public async loadDashboard(options: LoadDashboardOptions) {
try {
startMeasure(LOAD_SCENE_MEASUREMENT);
const dashboard = await this.loadScene(options);
if (!dashboard) {
return;
}
if (config.featureToggles.preserveDashboardStateWhenNavigating && Boolean(options.uid)) {
restoreDashboardStateFromLocalStorage(dashboard);
}
this.setState({ dashboard: dashboard, isLoading: false, options });
const measure = stopMeasure(LOAD_SCENE_MEASUREMENT);
trackDashboardSceneLoaded(dashboard, measure?.duration);
if (options.route !== DashboardRoutes.New) {
emitDashboardViewEvent({
meta: dashboard.state.meta,
uid: dashboard.state.uid,
title: dashboard.state.title,
id: dashboard.state.id,
});
}
} catch (err) {
const msg = getMessageFromError(err);
this.setState({ isLoading: false, loadError: msg });
}
}
private async loadScene(options: LoadDashboardOptions): Promise<DashboardScene | null> {
this.setState({ dashboard: undefined, isLoading: true });
const rsp = await this.fetchDashboard(options);
return this.transformResponseToScene(rsp, options);
}
public getDashboardFromCache(cacheKey: string): T | null {
const cachedDashboard = this.dashboardCache;
if (
cachedDashboard &&
cachedDashboard.cacheKey === cacheKey &&
Date.now() - cachedDashboard?.ts < DASHBOARD_CACHE_TTL
) {
return cachedDashboard.dashboard;
}
return null;
}
public clearState() {
getDashboardSrv().setCurrent(undefined);
this.setState({
dashboard: undefined,
loadError: undefined,
isLoading: false,
panelEditor: undefined,
});
}
public setDashboardCache(cacheKey: string, dashboard: T) {
this.dashboardCache = { dashboard, ts: Date.now(), cacheKey };
}
public clearDashboardCache() {
this.dashboardCache = undefined;
}
public getSceneFromCache(cacheKey: string) {
return this.cache[cacheKey];
}
public setSceneCache(cacheKey: string, scene: DashboardScene) {
this.cache[cacheKey] = scene;
}
public clearSceneCache() {
this.cache = {};
}
}
export class DashboardScenePageStateManager extends DashboardScenePageStateManagerBase<DashboardDTO> {
transformResponseToScene(rsp: DashboardDTO | null, options: LoadDashboardOptions): DashboardScene | null {
const fromCache = this.getSceneFromCache(options.uid);
if (fromCache && fromCache.state.version === rsp?.dashboard.version) {
return fromCache;
}
if (rsp?.dashboard) {
const scene = transformSaveModelToScene(rsp);
// Cache scene only if not coming from Explore, we don't want to cache temporary dashboard
if (options.uid) {
this.setSceneCache(options.uid, scene);
}
return scene;
}
if (rsp?.redirectUri) {
const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri);
locationService.replace(newUrl);
return null;
}
throw new Error('Dashboard not found');
}
public async loadSnapshotScene(slug: string): Promise<DashboardScene> {
const rsp = await dashboardLoaderSrv.loadSnapshot(slug);
if (rsp?.dashboard) {
const scene = transformSaveModelToScene(rsp);
return scene;
}
throw new Error('Snapshot not found');
}
public async fetchDashboard({
uid,
route,
urlFolderUid,
params,
}: LoadDashboardOptions): Promise<DashboardDTO | null> {
const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
if (!params) {
const cachedDashboard = this.getDashboardFromCache(cacheKey);
if (cachedDashboard) {
return cachedDashboard;
}
}
let rsp: DashboardDTO;
try {
switch (route) {
case DashboardRoutes.New:
rsp = await buildNewDashboardSaveModel(urlFolderUid);
break;
case DashboardRoutes.Home:
rsp = await getBackendSrv().get('/api/dashboards/home');
if (rsp.redirectUri) {
return rsp;
}
if (rsp?.meta) {
rsp.meta.canSave = false;
rsp.meta.canShare = false;
rsp.meta.canStar = false;
}
break;
case DashboardRoutes.Public: {
return await dashboardLoaderSrv.loadDashboard('public', '', uid);
}
default:
const queryParams = params
? {
version: params.version,
scopes: params.scopes,
from: params.timeRange.from,
to: params.timeRange.to,
...params.variables,
}
: undefined;
rsp = await dashboardLoaderSrv.loadDashboard('db', '', uid, queryParams);
if (route === DashboardRoutes.Embedded) {
rsp.meta.isEmbedded = true;
}
}
if (rsp.meta.url && route === DashboardRoutes.Normal) {
const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.meta.url);
const currentPath = locationService.getLocation().pathname;
if (dashboardUrl !== currentPath) {
// Spread current location to persist search params used for navigation
locationService.replace({
...locationService.getLocation(),
pathname: dashboardUrl,
});
console.log('not correct url correcting', dashboardUrl, currentPath);
}
}
// Populate nav model in global store according to the folder
if (rsp.meta.folderUid) {
await updateNavModel(rsp.meta.folderUid);
}
// Do not cache new dashboards
this.setDashboardCache(cacheKey, rsp);
} catch (e) {
// Ignore cancelled errors
if (isFetchError(e) && e.cancelled) {
return null;
}
throw e;
}
return rsp;
}
public async reloadDashboard(params: LoadDashboardOptions['params']) {
const stateOptions = this.state.options;
if (!stateOptions) {
return;
}
const options = {
...stateOptions,
params,
};
// We shouldn't check all params since:
// - version doesn't impact the new dashboard, and it's there for increased compatibility
// - time range is almost always different for relative time ranges and absolute time ranges do not trigger subsequent reloads
// - other params don't affect the dashboard content
if (
isEqual(options.params?.variables, stateOptions.params?.variables) &&
isEqual(options.params?.scopes, stateOptions.params?.scopes)
) {
return;
}
try {
this.setState({ isLoading: true });
const rsp = await this.fetchDashboard(options);
const fromCache = this.getSceneFromCache(options.uid);
if (fromCache && fromCache.state.version === rsp?.dashboard.version) {
this.setState({ isLoading: false });
return;
}
if (!rsp?.dashboard) {
this.setState({ isLoading: false, loadError: 'Dashboard not found' });
return;
}
const scene = transformSaveModelToScene(rsp);
this.setSceneCache(options.uid, scene);
this.setState({ dashboard: scene, isLoading: false, options });
} catch (err) {
const msg = getMessageFromError(err);
this.setState({ isLoading: false, loadError: msg });
}
}
}
export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateManagerBase<
DashboardWithAccessInfo<DashboardV2Spec>
> {
private dashboardLoader = new DashboardLoaderSrvV2();
public async loadSnapshotScene(slug: string): Promise<DashboardScene> {
const rsp = await this.dashboardLoader.loadSnapshot(slug);
if (rsp?.spec) {
const scene = transformSaveModelSchemaV2ToScene(rsp);
return scene;
}
throw new Error('Snapshot not found');
}
transformResponseToScene(
rsp: DashboardWithAccessInfo<DashboardV2Spec> | null,
options: LoadDashboardOptions
): DashboardScene | null {
const fromCache = this.getSceneFromCache(options.uid);
// TODO[schema v2]: Dashboard scene state is incorrectly save, it must use the resourceVersion
if (
fromCache &&
rsp?.metadata.resourceVersion &&
fromCache.state.version === parseInt(rsp?.metadata.resourceVersion, 10)
) {
return fromCache;
}
if (rsp) {
const scene = transformSaveModelSchemaV2ToScene(rsp);
// Cache scene only if not coming from Explore, we don't want to cache temporary dashboard
if (options.uid) {
this.setSceneCache(options.uid, scene);
}
return scene;
}
// TOD)[schema v2]: Figure out redirect utl
// if (rsp?.redirectUri) {
// const newUrl = locationUtil.stripBaseFromUrl(rsp.redirectUri);
// locationService.replace(newUrl);
// return null;
// }
throw new Error('Dashboard not found');
}
reloadDashboard(params: LoadDashboardOptions['params']): Promise<void> {
throw new Error('Method not implemented.');
}
public async fetchDashboard({
uid,
route,
urlFolderUid,
params,
}: LoadDashboardOptions): Promise<DashboardWithAccessInfo<DashboardV2Spec> | null> {
// throw new Error('Method not implemented.');
const cacheKey = route === DashboardRoutes.Home ? HOME_DASHBOARD_CACHE_KEY : uid;
if (!params) {
const cachedDashboard = this.getDashboardFromCache(cacheKey);
if (cachedDashboard) {
return cachedDashboard;
}
}
let rsp: DashboardWithAccessInfo<DashboardV2Spec>;
try {
switch (route) {
case DashboardRoutes.New:
rsp = await buildNewDashboardSaveModelV2(urlFolderUid);
break;
case DashboardRoutes.Home:
// throw new Error('Method not implemented.');
const dto = await getBackendSrv().get<DashboardDTO>('/api/dashboards/home');
rsp = ResponseTransformers.ensureV2Response(dto);
rsp.access.canSave = false;
rsp.access.canShare = false;
rsp.access.canStar = false;
// if (rsp.redirectUri) {
// return rsp;
// }
break;
case DashboardRoutes.Public: {
return await this.dashboardLoader.loadDashboard('public', '', uid);
}
default:
const queryParams = params
? {
version: params.version,
scopes: params.scopes,
from: params.timeRange.from,
to: params.timeRange.to,
...params.variables,
}
: undefined;
rsp = await this.dashboardLoader.loadDashboard('db', '', uid, queryParams);
if (route === DashboardRoutes.Embedded) {
throw new Error('Method not implemented.');
// rsp.meta.isEmbedded = true;
}
}
if (rsp.access.url && route === DashboardRoutes.Normal) {
const dashboardUrl = locationUtil.stripBaseFromUrl(rsp.access.url);
const currentPath = locationService.getLocation().pathname;
if (dashboardUrl !== currentPath) {
// Spread current location to persist search params used for navigation
locationService.replace({
...locationService.getLocation(),
pathname: dashboardUrl,
});
console.log('not correct url correcting', dashboardUrl, currentPath);
}
}
// Populate nav model in global store according to the folder
if (rsp.metadata.annotations?.[AnnoKeyFolder]) {
await updateNavModel(rsp.metadata.annotations?.[AnnoKeyFolder]);
}
// Do not cache new dashboards
this.setDashboardCache(cacheKey, rsp);
} catch (e) {
// Ignore cancelled errors
if (isFetchError(e) && e.cancelled) {
return null;
}
throw e;
}
return rsp;
}
}
const managers: {
v1?: DashboardScenePageStateManager;
v2?: DashboardScenePageStateManagerV2;
} = {
v1: undefined,
v2: undefined,
};
export function getDashboardScenePageStateManager(
v: 'v2'
): DashboardScenePageStateManagerLike<DashboardWithAccessInfo<DashboardV2Spec>>;
export function getDashboardScenePageStateManager(): DashboardScenePageStateManagerLike<DashboardDTO>;
export function getDashboardScenePageStateManager(
v?: 'v2'
): DashboardScenePageStateManagerLike<DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>> {
if (v === 'v2') {
if (!managers.v2) {
managers.v2 = new DashboardScenePageStateManagerV2({});
}
return managers.v2;
} else {
if (!managers.v1) {
managers.v1 = new DashboardScenePageStateManager({});
}
return managers.v1;
}
}