The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/features/trails/TrailStore/TrailStore.ts

299 lines
9.3 KiB

import { debounce, isEqual } from 'lodash';
import { urlUtil } from '@grafana/data';
import { SceneObject, SceneObjectRef, SceneObjectUrlValues, sceneUtils } from '@grafana/scenes';
import { dispatch } from 'app/store/store';
import { notifyApp } from '../../../core/reducers/appNotification';
import { DataTrail } from '../DataTrail';
import { TrailStepType } from '../DataTrailsHistory';
import { TRAIL_BOOKMARKS_KEY, RECENT_TRAILS_KEY } from '../shared';
import { newMetricsTrail } from '../utils';
import { createBookmarkSavedNotification } from './utils';
const MAX_RECENT_TRAILS = 20;
export interface SerializedTrailHistory {
urlValues: SceneObjectUrlValues;
type: TrailStepType;
description: string;
parentIndex: number;
}
export interface SerializedTrail {
history: SerializedTrailHistory[];
currentStep?: number; // Assume last step in history if not specified
createdAt?: number;
}
export interface DataTrailBookmark {
urlValues: SceneObjectUrlValues;
createdAt: number;
}
export class TrailStore {
private _recent: Array<SceneObjectRef<DataTrail>> = [];
private _bookmarks: DataTrailBookmark[] = [];
private _save: () => void;
private _lastModified: number;
constructor() {
this.load();
this._lastModified = Date.now();
const doSave = () => {
const serializedRecent = this._recent
.slice(0, MAX_RECENT_TRAILS)
.map((trail) => this._serializeTrail(trail.resolve()));
localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify(serializedRecent));
localStorage.setItem(TRAIL_BOOKMARKS_KEY, JSON.stringify(this._bookmarks));
this._lastModified = Date.now();
};
this._save = debounce(doSave, 1000);
window.addEventListener('beforeunload', (ev) => {
// Before closing or reloading the page, we want to remove the debounce from `_save` so that
// any calls to is on event `unload` are actualized. Debouncing would cause a delay until after the page has been unloaded.
this._save = doSave;
});
}
private _loadRecentTrailsFromStorage() {
const list: Array<SceneObjectRef<DataTrail>> = [];
const storageItem = localStorage.getItem(RECENT_TRAILS_KEY);
if (storageItem) {
const serializedTrails: SerializedTrail[] = JSON.parse(storageItem);
for (const t of serializedTrails) {
const trail = this._deserializeTrail(t);
list.push(trail.getRef());
}
}
return list;
}
private _loadBookmarksFromStorage() {
const storageItem = localStorage.getItem(TRAIL_BOOKMARKS_KEY);
const list: Array<DataTrailBookmark | SerializedTrail> = storageItem ? JSON.parse(storageItem) : [];
return list.map((item) => {
if (isSerializedTrail(item)) {
// Take the legacy SerializedTrail implementation of bookmark storage, and extract a DataTrailBookmark
const step = item.currentStep != null ? item.currentStep : item.history.length - 1;
const bookmark: DataTrailBookmark = {
urlValues: item.history[step].urlValues,
createdAt: item.createdAt || Date.now(),
};
return bookmark;
}
return item;
});
}
private _deserializeTrail(t: SerializedTrail): DataTrail {
// reconstruct the trail based on the serialized history
const trail = new DataTrail({ createdAt: t.createdAt });
t.history.map((step) => {
this._loadFromUrl(trail, step.urlValues);
const parentIndex = step.parentIndex ?? trail.state.history.state.steps.length - 1;
// Set the parent of the next trail step by setting the current step in history.
trail.state.history.setState({ currentStep: parentIndex });
trail.state.history.addTrailStepFromStorage(trail, step);
});
const currentStep = t.currentStep ?? trail.state.history.state.steps.length - 1;
trail.state.history.setState({ currentStep });
trail.setState(
sceneUtils.cloneSceneObjectState(trail.state.history.state.steps[currentStep].trailState, {
history: trail.state.history,
})
);
return trail;
}
private _serializeTrail(trail: DataTrail): SerializedTrail {
const history = trail.state.history.state.steps.map((step) => {
const stepTrail = new DataTrail(sceneUtils.cloneSceneObjectState(step.trailState));
return {
urlValues: sceneUtils.getUrlState(stepTrail),
type: step.type,
description: step.description,
parentIndex: step.parentIndex,
};
});
return {
history,
currentStep: trail.state.history.state.currentStep,
createdAt: trail.state.createdAt,
};
}
public getTrailForBookmarkIndex(index: number) {
const bookmark = this._bookmarks[index];
if (!bookmark) {
// Create a blank trail
return newMetricsTrail();
}
return this.getTrailForBookmark(bookmark);
}
public getTrailForBookmark(bookmark: DataTrailBookmark) {
const key = getBookmarkKey(bookmark);
// Match for recent trails that have the exact same state as the current step
for (const recent of this._recent) {
const trail = recent.resolve();
if (getBookmarkKey(trail) === key) {
return trail;
}
}
// Just create a new trail with that state
const trail = new DataTrail({});
this._loadFromUrl(trail, bookmark.urlValues);
return trail;
}
private _loadFromUrl(node: SceneObject, urlValues: SceneObjectUrlValues) {
const urlState = urlUtil.renderUrl('', urlValues);
sceneUtils.syncStateFromSearchParams(node, new URLSearchParams(urlState));
}
// Recent Trails
get recent() {
return this._recent;
}
// Last updated metric
get lastModified() {
return this._lastModified;
}
load() {
this._recent = this._loadRecentTrailsFromStorage();
this._bookmarks = this._loadBookmarksFromStorage();
this._refreshBookmarkIndexMap();
this._lastModified = Date.now();
}
setRecentTrail(recentTrail: DataTrail) {
const { steps } = recentTrail.state.history.state;
if (steps.length === 0 || (steps.length === 1 && steps[0].type === 'start')) {
// We do not set an uninitialized trail, or a single node "start" trail as recent
return;
}
// Remove the `recentTrail` from the list if it already exists there
this._recent = this._recent.filter((t) => t !== recentTrail.getRef());
// Check if any existing "recent" entries have equivalent urlState to the new recentTrail
const recentUrlState = getUrlStateForComparison(recentTrail); //
this._recent = this._recent.filter((t) => {
// Use the current step urlValues to filter out equivalent states
const urlState = getUrlStateForComparison(t.resolve());
// Only keep trails with sufficiently unique urlValues on their current step
return !isEqual(recentUrlState, urlState);
});
this._recent.unshift(recentTrail.getRef());
this._save();
}
// Bookmarked Trails
get bookmarks() {
return this._bookmarks;
}
addBookmark(trail: DataTrail) {
const urlState = sceneUtils.getUrlState(trail);
const bookmarkState: DataTrailBookmark = {
urlValues: urlState,
createdAt: Date.now(),
};
this._bookmarks.unshift(bookmarkState);
this._refreshBookmarkIndexMap();
this._save();
dispatch(notifyApp(createBookmarkSavedNotification()));
}
removeBookmark(index: number) {
if (index < this._bookmarks.length) {
this._bookmarks.splice(index, 1);
this._refreshBookmarkIndexMap();
this._save();
}
}
getBookmarkIndex(trail: DataTrail) {
const bookmarkKey = getBookmarkKey(trail);
const bookmarkIndex = this._bookmarkIndexMap.get(bookmarkKey);
return bookmarkIndex;
}
private _bookmarkIndexMap = new Map<string, number>();
private _refreshBookmarkIndexMap() {
this._bookmarkIndexMap.clear();
this._bookmarks.forEach((bookmarked, index) => {
const key = getBookmarkKey(bookmarked);
// If there are duplicate bookmarks, the latest index will be kept
this._bookmarkIndexMap.set(key, index);
});
}
}
function getUrlStateForComparison(trail: DataTrail) {
const urlState = sceneUtils.getUrlState(trail);
// Make a few corrections
correctUrlStateForComparison(urlState);
return urlState;
}
function correctUrlStateForComparison(urlState: SceneObjectUrlValues) {
// Omit some URL parameters that are not useful for state comparison,
// as they can change in the URL without creating new steps
delete urlState.actionView;
delete urlState.layout;
delete urlState.metricSearch;
delete urlState.refresh;
// Populate defaults
if (urlState['var-groupby'] === '' || urlState['var-groupby'] === undefined) {
urlState['var-groupby'] = '$__all';
}
if (typeof urlState['var-filters'] !== 'string') {
urlState['var-filters'] = urlState['var-filters']?.filter((filter) => filter !== '');
}
return urlState;
}
export function getBookmarkKey(trail: DataTrail | DataTrailBookmark) {
if (trail instanceof DataTrail) {
return JSON.stringify(getUrlStateForComparison(trail));
}
return JSON.stringify(correctUrlStateForComparison({ ...trail.urlValues }));
}
let store: TrailStore | undefined;
export function getTrailStore(): TrailStore {
if (!store) {
store = new TrailStore();
}
return store;
}
function isSerializedTrail(serialized: unknown): serialized is SerializedTrail {
return serialized != null && typeof serialized === 'object' && 'history' in serialized;
}