mirror of
https://github.com/grafana/grafana.git
synced 2025-09-26 04:24:13 +08:00
Data trails: Store recent and bookmarked trails in local storage (#78508)
* WIP * Restore trail using history and updateFromUrl() * Limit stored recent trails to 20 * Rename and refactor * Bookmark and store trails * No export * Remove unused event * Organise * Address feedback * Added button to remove bookmark. Added trail to home card * Added tests for trail store * Update * remove import * Fix home not updating after removing bookmark. Remove trail for home card * Remove button no longer absolute --------- Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
@ -26,6 +26,7 @@ import { DataTrailSettings } from './DataTrailSettings';
|
|||||||
import { DataTrailHistory, DataTrailHistoryStep } from './DataTrailsHistory';
|
import { DataTrailHistory, DataTrailHistoryStep } from './DataTrailsHistory';
|
||||||
import { MetricScene } from './MetricScene';
|
import { MetricScene } from './MetricScene';
|
||||||
import { MetricSelectScene } from './MetricSelectScene';
|
import { MetricSelectScene } from './MetricSelectScene';
|
||||||
|
import { getTrailStore } from './TrailStore/TrailStore';
|
||||||
import { MetricSelectedEvent, trailDS, LOGS_METRIC, VAR_DATASOURCE } from './shared';
|
import { MetricSelectedEvent, trailDS, LOGS_METRIC, VAR_DATASOURCE } from './shared';
|
||||||
import { getUrlForTrail } from './utils';
|
import { getUrlForTrail } from './utils';
|
||||||
|
|
||||||
@ -80,6 +81,7 @@ export class DataTrail extends SceneObjectBase<DataTrailState> {
|
|||||||
return () => {
|
return () => {
|
||||||
if (!this.state.embedded) {
|
if (!this.state.embedded) {
|
||||||
getUrlSyncManager().cleanUp(this);
|
getUrlSyncManager().cleanUp(this);
|
||||||
|
getTrailStore().setRecentTrail(this);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes';
|
import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes';
|
||||||
import { useStyles2, Stack } from '@grafana/ui';
|
import { useStyles2, Stack, Tooltip, Button } from '@grafana/ui';
|
||||||
|
|
||||||
import { DataTrail } from './DataTrail';
|
import { DataTrail } from './DataTrail';
|
||||||
import { LOGS_METRIC, VAR_DATASOURCE_EXPR, VAR_FILTERS } from './shared';
|
import { LOGS_METRIC, VAR_DATASOURCE_EXPR, VAR_FILTERS } from './shared';
|
||||||
@ -11,9 +11,10 @@ import { LOGS_METRIC, VAR_DATASOURCE_EXPR, VAR_FILTERS } from './shared';
|
|||||||
export interface Props {
|
export interface Props {
|
||||||
trail: DataTrail;
|
trail: DataTrail;
|
||||||
onSelect: (trail: DataTrail) => void;
|
onSelect: (trail: DataTrail) => void;
|
||||||
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTrailCard({ trail, onSelect }: Props) {
|
export function DataTrailCard({ trail, onSelect, onDelete }: Props) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail)!;
|
const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail)!;
|
||||||
@ -26,7 +27,15 @@ export function DataTrailCard({ trail, onSelect }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<button className={styles.container} onClick={() => onSelect(trail)}>
|
<button className={styles.container} onClick={() => onSelect(trail)}>
|
||||||
<div className={styles.heading}>{getMetricName(trail.state.metric)}</div>
|
<div className={styles.wrapper}>
|
||||||
|
<div className={styles.heading}>{getMetricName(trail.state.metric)}</div>
|
||||||
|
{onDelete && (
|
||||||
|
<Tooltip content={'Remove bookmark'}>
|
||||||
|
<Button size="sm" icon="trash-alt" variant="destructive" fill="text" onClick={onDelete} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Stack gap={1.5}>
|
<Stack gap={1.5}>
|
||||||
{dsValue && (
|
{dsValue && (
|
||||||
<Stack direction="column" gap={0.5}>
|
<Stack direction="column" gap={0.5}>
|
||||||
@ -69,6 +78,7 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: theme.spacing(2),
|
gap: theme.spacing(2),
|
||||||
|
width: '100%',
|
||||||
border: `1px solid ${theme.colors.border.weak}`,
|
border: `1px solid ${theme.colors.border.weak}`,
|
||||||
borderRadius: theme.shape.radius.default,
|
borderRadius: theme.shape.radius.default,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@ -90,9 +100,17 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
padding: theme.spacing(0),
|
padding: theme.spacing(0),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
fontWeight: theme.typography.fontWeightMedium,
|
fontWeight: theme.typography.fontWeightMedium,
|
||||||
|
overflowX: 'hidden',
|
||||||
}),
|
}),
|
||||||
body: css({
|
body: css({
|
||||||
padding: theme.spacing(0),
|
padding: theme.spacing(0),
|
||||||
}),
|
}),
|
||||||
|
wrapper: css({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
gap: theme.spacing.x1,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
width: '100%',
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import { Page } from 'app/core/components/Page/Page';
|
|||||||
|
|
||||||
import { DataTrail } from './DataTrail';
|
import { DataTrail } from './DataTrail';
|
||||||
import { DataTrailsHome } from './DataTrailsHome';
|
import { DataTrailsHome } from './DataTrailsHome';
|
||||||
|
import { getTrailStore } from './TrailStore/TrailStore';
|
||||||
import { getUrlForTrail, newMetricsTrail } from './utils';
|
import { getUrlForTrail, newMetricsTrail } from './utils';
|
||||||
|
|
||||||
export interface DataTrailsAppState extends SceneObjectState {
|
export interface DataTrailsAppState extends SceneObjectState {
|
||||||
@ -66,6 +67,7 @@ function DataTrailView({ trail }: { trail: DataTrail }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isInitialized) {
|
if (!isInitialized) {
|
||||||
getUrlSyncManager().initSync(trail);
|
getUrlSyncManager().initSync(trail);
|
||||||
|
getTrailStore().setRecentTrail(trail);
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
}
|
}
|
||||||
}, [trail, isInitialized]);
|
}, [trail, isInitialized]);
|
||||||
@ -83,10 +85,7 @@ export function getDataTrailsApp() {
|
|||||||
if (!dataTrailsApp) {
|
if (!dataTrailsApp) {
|
||||||
dataTrailsApp = new DataTrailsApp({
|
dataTrailsApp = new DataTrailsApp({
|
||||||
trail: newMetricsTrail(),
|
trail: newMetricsTrail(),
|
||||||
home: new DataTrailsHome({
|
home: new DataTrailsHome({}),
|
||||||
recent: [],
|
|
||||||
bookmarks: [],
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,10 +6,10 @@ import {
|
|||||||
SceneObjectState,
|
SceneObjectState,
|
||||||
SceneObjectBase,
|
SceneObjectBase,
|
||||||
SceneComponentProps,
|
SceneComponentProps,
|
||||||
sceneUtils,
|
|
||||||
SceneVariableValueChangedEvent,
|
SceneVariableValueChangedEvent,
|
||||||
SceneObjectStateChangedEvent,
|
SceneObjectStateChangedEvent,
|
||||||
SceneTimeRange,
|
SceneTimeRange,
|
||||||
|
sceneUtils,
|
||||||
} from '@grafana/scenes';
|
} from '@grafana/scenes';
|
||||||
import { useStyles2, Tooltip, Stack } from '@grafana/ui';
|
import { useStyles2, Tooltip, Stack } from '@grafana/ui';
|
||||||
|
|
||||||
|
@ -1,27 +1,18 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import {
|
import { SceneComponentProps, sceneGraph, SceneObject, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
|
||||||
SceneComponentProps,
|
|
||||||
sceneGraph,
|
|
||||||
SceneObject,
|
|
||||||
SceneObjectBase,
|
|
||||||
SceneObjectRef,
|
|
||||||
SceneObjectState,
|
|
||||||
} from '@grafana/scenes';
|
|
||||||
import { Button, useStyles2, Stack } from '@grafana/ui';
|
import { Button, useStyles2, Stack } from '@grafana/ui';
|
||||||
import { Text } from '@grafana/ui/src/components/Text/Text';
|
import { Text } from '@grafana/ui/src/components/Text/Text';
|
||||||
|
|
||||||
import { DataTrail } from './DataTrail';
|
import { DataTrail } from './DataTrail';
|
||||||
import { DataTrailCard } from './DataTrailCard';
|
import { DataTrailCard } from './DataTrailCard';
|
||||||
import { DataTrailsApp } from './DataTrailsApp';
|
import { DataTrailsApp } from './DataTrailsApp';
|
||||||
|
import { getTrailStore } from './TrailStore/TrailStore';
|
||||||
import { newMetricsTrail } from './utils';
|
import { newMetricsTrail } from './utils';
|
||||||
|
|
||||||
export interface DataTrailsHomeState extends SceneObjectState {
|
export interface DataTrailsHomeState extends SceneObjectState {}
|
||||||
recent: Array<SceneObjectRef<DataTrail>>;
|
|
||||||
bookmarks: Array<SceneObjectRef<DataTrail>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
|
export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
|
||||||
public constructor(state: DataTrailsHomeState) {
|
public constructor(state: DataTrailsHomeState) {
|
||||||
@ -32,28 +23,26 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
|
|||||||
const app = getAppFor(this);
|
const app = getAppFor(this);
|
||||||
const trail = newMetricsTrail();
|
const trail = newMetricsTrail();
|
||||||
|
|
||||||
this.setState({ recent: [app.state.trail.getRef(), ...this.state.recent] });
|
getTrailStore().setRecentTrail(trail);
|
||||||
app.goToUrlForTrail(trail);
|
app.goToUrlForTrail(trail);
|
||||||
};
|
};
|
||||||
|
|
||||||
public onSelectTrail = (trail: DataTrail) => {
|
public onSelectTrail = (trail: DataTrail) => {
|
||||||
const app = getAppFor(this);
|
const app = getAppFor(this);
|
||||||
|
|
||||||
const currentTrail = app.state.trail;
|
getTrailStore().setRecentTrail(trail);
|
||||||
const existsInRecent = this.state.recent.find((t) => t.resolve() === currentTrail);
|
|
||||||
|
|
||||||
if (!existsInRecent) {
|
|
||||||
this.setState({ recent: [currentTrail.getRef(), ...this.state.recent] });
|
|
||||||
}
|
|
||||||
|
|
||||||
app.goToUrlForTrail(trail);
|
app.goToUrlForTrail(trail);
|
||||||
};
|
};
|
||||||
|
|
||||||
static Component = ({ model }: SceneComponentProps<DataTrailsHome>) => {
|
static Component = ({ model }: SceneComponentProps<DataTrailsHome>) => {
|
||||||
const { recent, bookmarks } = model.useState();
|
const [_, setLastDelete] = useState(Date.now());
|
||||||
const app = getAppFor(model);
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const onDelete = (index: number) => {
|
||||||
|
getTrailStore().removeBookmark(index);
|
||||||
|
setLastDelete(Date.now()); // trigger re-render
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Stack direction="column" gap={1}>
|
<Stack direction="column" gap={1}>
|
||||||
@ -69,18 +58,32 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
|
|||||||
<div className={styles.column}>
|
<div className={styles.column}>
|
||||||
<Text variant="h4">Recent trails</Text>
|
<Text variant="h4">Recent trails</Text>
|
||||||
<div className={styles.trailList}>
|
<div className={styles.trailList}>
|
||||||
{app.state.trail.state.metric && <DataTrailCard trail={app.state.trail} onSelect={model.onSelectTrail} />}
|
{getTrailStore().recent.map((trail, index) => {
|
||||||
{recent.map((trail, index) => (
|
const resolvedTrail = trail.resolve();
|
||||||
<DataTrailCard key={index} trail={trail.resolve()} onSelect={model.onSelectTrail} />
|
return (
|
||||||
))}
|
<DataTrailCard
|
||||||
|
key={(resolvedTrail.state.key || '') + index}
|
||||||
|
trail={resolvedTrail}
|
||||||
|
onSelect={model.onSelectTrail}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.column}>
|
<div className={styles.column}>
|
||||||
<Text variant="h4">Bookmarks</Text>
|
<Text variant="h4">Bookmarks</Text>
|
||||||
<div className={styles.trailList}>
|
<div className={styles.trailList}>
|
||||||
{bookmarks.map((trail, index) => (
|
{getTrailStore().bookmarks.map((trail, index) => {
|
||||||
<DataTrailCard key={index} trail={trail.resolve()} onSelect={model.onSelectTrail} />
|
const resolvedTrail = trail.resolve();
|
||||||
))}
|
return (
|
||||||
|
<DataTrailCard
|
||||||
|
key={(resolvedTrail.state.key || '') + index}
|
||||||
|
trail={resolvedTrail}
|
||||||
|
onSelect={model.onSelectTrail}
|
||||||
|
onDelete={() => onDelete(index)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SceneObjectState,
|
SceneObjectState,
|
||||||
@ -12,13 +12,14 @@ import {
|
|||||||
PanelBuilders,
|
PanelBuilders,
|
||||||
sceneGraph,
|
sceneGraph,
|
||||||
} from '@grafana/scenes';
|
} from '@grafana/scenes';
|
||||||
import { ToolbarButton, Box, Stack } from '@grafana/ui';
|
import { ToolbarButton, Box, Stack, Icon } from '@grafana/ui';
|
||||||
|
|
||||||
import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine';
|
import { getAutoQueriesForMetric } from './AutomaticMetricQueries/AutoQueryEngine';
|
||||||
import { AutoVizPanel } from './AutomaticMetricQueries/AutoVizPanel';
|
import { AutoVizPanel } from './AutomaticMetricQueries/AutoVizPanel';
|
||||||
import { buildBreakdownActionScene } from './BreakdownScene';
|
import { buildBreakdownActionScene } from './BreakdownScene';
|
||||||
import { MetricSelectScene } from './MetricSelectScene';
|
import { MetricSelectScene } from './MetricSelectScene';
|
||||||
import { SelectMetricAction } from './SelectMetricAction';
|
import { SelectMetricAction } from './SelectMetricAction';
|
||||||
|
import { getTrailStore } from './TrailStore/TrailStore';
|
||||||
import {
|
import {
|
||||||
ActionViewDefinition,
|
ActionViewDefinition,
|
||||||
getVariablesWithMetricConstant,
|
getVariablesWithMetricConstant,
|
||||||
@ -104,8 +105,14 @@ export class MetricActionBar extends SceneObjectBase<MetricActionBarState> {
|
|||||||
public static Component = ({ model }: SceneComponentProps<MetricActionBar>) => {
|
public static Component = ({ model }: SceneComponentProps<MetricActionBar>) => {
|
||||||
const metricScene = sceneGraph.getAncestor(model, MetricScene);
|
const metricScene = sceneGraph.getAncestor(model, MetricScene);
|
||||||
const trail = getTrailFor(model);
|
const trail = getTrailFor(model);
|
||||||
|
const [isBookmarked, setBookmarked] = useState(false);
|
||||||
const { actionView } = metricScene.useState();
|
const { actionView } = metricScene.useState();
|
||||||
|
|
||||||
|
const onBookmarkTrail = () => {
|
||||||
|
getTrailStore().addBookmark(trail);
|
||||||
|
setBookmarked(!isBookmarked);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box paddingY={1}>
|
<Box paddingY={1}>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
@ -120,7 +127,18 @@ export class MetricActionBar extends SceneObjectBase<MetricActionBarState> {
|
|||||||
))}
|
))}
|
||||||
<ToolbarButton variant={'canvas'}>Add to dashboard</ToolbarButton>
|
<ToolbarButton variant={'canvas'}>Add to dashboard</ToolbarButton>
|
||||||
<ToolbarButton variant={'canvas'} icon="compass" tooltip="Open in explore (todo)" disabled />
|
<ToolbarButton variant={'canvas'} icon="compass" tooltip="Open in explore (todo)" disabled />
|
||||||
<ToolbarButton variant={'canvas'} icon="star" tooltip="Bookmark (todo)" disabled />
|
<ToolbarButton
|
||||||
|
variant={'canvas'}
|
||||||
|
icon={
|
||||||
|
isBookmarked ? (
|
||||||
|
<Icon name={'favorite'} type={'mono'} size={'lg'} />
|
||||||
|
) : (
|
||||||
|
<Icon name={'star'} type={'default'} size={'lg'} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tooltip={'Bookmark'}
|
||||||
|
onClick={onBookmarkTrail}
|
||||||
|
/>
|
||||||
<ToolbarButton variant={'canvas'} icon="share-alt" tooltip="Copy url (todo)" disabled />
|
<ToolbarButton variant={'canvas'} icon="share-alt" tooltip="Copy url (todo)" disabled />
|
||||||
{trail.state.embedded && (
|
{trail.state.embedded && (
|
||||||
<ToolbarButton variant={'canvas'} onClick={model.onOpenTrail}>
|
<ToolbarButton variant={'canvas'} onClick={model.onOpenTrail}>
|
||||||
|
152
public/app/features/trails/TrailStore/TrailStore.test.ts
Normal file
152
public/app/features/trails/TrailStore/TrailStore.test.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { BOOKMARKED_TRAILS_KEY, RECENT_TRAILS_KEY } from '../shared';
|
||||||
|
|
||||||
|
import { getTrailStore } from './TrailStore';
|
||||||
|
|
||||||
|
describe('TrailStore', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
let localStore: Record<string, string> = {};
|
||||||
|
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: jest.fn((key) => (key in localStore ? localStore[key] : null)),
|
||||||
|
setItem: jest.fn(jest.fn((key, value) => (localStore[key] = value + ''))),
|
||||||
|
clear: jest.fn(() => (localStore = {})),
|
||||||
|
};
|
||||||
|
global.localStorage = localStorageMock as unknown as Storage;
|
||||||
|
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty store', () => {
|
||||||
|
const store = getTrailStore();
|
||||||
|
|
||||||
|
it('should have no recent trails', () => {
|
||||||
|
expect(store.recent.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have no bookmarked trails', () => {
|
||||||
|
expect(store.bookmarks.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialize store with one recent trail', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem(
|
||||||
|
RECENT_TRAILS_KEY,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
urlValues: {
|
||||||
|
from: 'now-1h',
|
||||||
|
to: 'now',
|
||||||
|
'var-ds': 'cb3a3391-700f-4cc6-81be-a122488e93e6',
|
||||||
|
'var-filters': [],
|
||||||
|
refresh: '',
|
||||||
|
},
|
||||||
|
type: 'start',
|
||||||
|
description: 'Test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlValues: {
|
||||||
|
metric: 'access_permissions_duration_count',
|
||||||
|
from: 'now-1h',
|
||||||
|
to: 'now',
|
||||||
|
'var-ds': 'cb3a3391-700f-4cc6-81be-a122488e93e6',
|
||||||
|
'var-filters': [],
|
||||||
|
refresh: '',
|
||||||
|
},
|
||||||
|
type: 'metric',
|
||||||
|
description: 'Test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
getTrailStore().load();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accurately load recent trails', () => {
|
||||||
|
const store = getTrailStore();
|
||||||
|
expect(store.recent.length).toBe(1);
|
||||||
|
const trail = store.recent[0].resolve();
|
||||||
|
expect(trail.state.history.state.steps.length).toBe(2);
|
||||||
|
expect(trail.state.history.state.steps[0].type).toBe('start');
|
||||||
|
expect(trail.state.history.state.steps[1].type).toBe('metric');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have no bookmarked trails', () => {
|
||||||
|
const store = getTrailStore();
|
||||||
|
expect(store.bookmarks.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('Initialize store with one bookmark trail', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
localStorage.setItem(
|
||||||
|
BOOKMARKED_TRAILS_KEY,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
urlValues: {
|
||||||
|
from: 'now-1h',
|
||||||
|
to: 'now',
|
||||||
|
'var-ds': 'cb3a3391-700f-4cc6-81be-a122488e93e6',
|
||||||
|
'var-filters': [],
|
||||||
|
refresh: '',
|
||||||
|
},
|
||||||
|
type: 'start',
|
||||||
|
description: 'Test',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlValues: {
|
||||||
|
metric: 'access_permissions_duration_count',
|
||||||
|
from: 'now-1h',
|
||||||
|
to: 'now',
|
||||||
|
'var-ds': 'cb3a3391-700f-4cc6-81be-a122488e93e6',
|
||||||
|
'var-filters': [],
|
||||||
|
refresh: '',
|
||||||
|
},
|
||||||
|
type: 'time',
|
||||||
|
description: 'Test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
getTrailStore().load();
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = getTrailStore();
|
||||||
|
|
||||||
|
it('should have no recent trails', () => {
|
||||||
|
expect(store.recent.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accurately load bookmarked trails', () => {
|
||||||
|
expect(store.bookmarks.length).toBe(1);
|
||||||
|
const trail = store.bookmarks[0].resolve();
|
||||||
|
expect(trail.state.history.state.steps.length).toBe(2);
|
||||||
|
expect(trail.state.history.state.steps[0].type).toBe('start');
|
||||||
|
expect(trail.state.history.state.steps[1].type).toBe('time');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save a new recent trail based on the bookmark', () => {
|
||||||
|
expect(store.recent.length).toBe(0);
|
||||||
|
const trail = store.bookmarks[0].resolve();
|
||||||
|
store.setRecentTrail(trail);
|
||||||
|
expect(store.recent.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove a bookmark', () => {
|
||||||
|
expect(store.bookmarks.length).toBe(1);
|
||||||
|
store.removeBookmark(0);
|
||||||
|
expect(store.bookmarks.length).toBe(0);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(2000);
|
||||||
|
|
||||||
|
expect(localStorage.getItem(BOOKMARKED_TRAILS_KEY)).toBe('[]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
124
public/app/features/trails/TrailStore/TrailStore.ts
Normal file
124
public/app/features/trails/TrailStore/TrailStore.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
|
import { SceneObject, SceneObjectRef, SceneObjectUrlValues, getUrlSyncManager, sceneUtils } from '@grafana/scenes';
|
||||||
|
|
||||||
|
import { DataTrail } from '../DataTrail';
|
||||||
|
import { TrailStepType } from '../DataTrailsHistory';
|
||||||
|
import { BOOKMARKED_TRAILS_KEY, RECENT_TRAILS_KEY } from '../shared';
|
||||||
|
|
||||||
|
const MAX_RECENT_TRAILS = 20;
|
||||||
|
|
||||||
|
export interface SerializedTrail {
|
||||||
|
history: Array<{
|
||||||
|
urlValues: SceneObjectUrlValues;
|
||||||
|
type: TrailStepType;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TrailStore {
|
||||||
|
private _recent: Array<SceneObjectRef<DataTrail>> = [];
|
||||||
|
private _bookmarks: Array<SceneObjectRef<DataTrail>> = [];
|
||||||
|
private _save;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.load();
|
||||||
|
|
||||||
|
this._save = debounce(() => {
|
||||||
|
const serializedRecent = this._recent
|
||||||
|
.slice(0, MAX_RECENT_TRAILS)
|
||||||
|
.map((trail) => this._serializeTrail(trail.resolve()));
|
||||||
|
localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify(serializedRecent));
|
||||||
|
|
||||||
|
const serializedBookmarks = this._bookmarks.map((trail) => this._serializeTrail(trail.resolve()));
|
||||||
|
localStorage.setItem(BOOKMARKED_TRAILS_KEY, JSON.stringify(serializedBookmarks));
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _loadFromStorage(key: string) {
|
||||||
|
const list: Array<SceneObjectRef<DataTrail>> = [];
|
||||||
|
const storageItem = localStorage.getItem(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 _deserializeTrail(t: SerializedTrail): DataTrail {
|
||||||
|
// reconstruct the trail based on the the serialized history
|
||||||
|
const trail = new DataTrail({});
|
||||||
|
|
||||||
|
t.history.map((step) => {
|
||||||
|
this._loadFromUrl(trail, step.urlValues);
|
||||||
|
trail.state.history.addTrailStep(trail, step.type);
|
||||||
|
});
|
||||||
|
|
||||||
|
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: getUrlSyncManager().getUrlState(stepTrail),
|
||||||
|
type: step.type,
|
||||||
|
description: step.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
history,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _loadFromUrl(node: SceneObject, urlValues: SceneObjectUrlValues) {
|
||||||
|
node.urlSync?.updateFromUrl(urlValues);
|
||||||
|
node.forEachChild((child) => this._loadFromUrl(child, urlValues));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Trails
|
||||||
|
get recent() {
|
||||||
|
return this._recent;
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
this._recent = this._loadFromStorage(RECENT_TRAILS_KEY);
|
||||||
|
this._bookmarks = this._loadFromStorage(BOOKMARKED_TRAILS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecentTrail(trail: DataTrail) {
|
||||||
|
this._recent = this._recent.filter((t) => t !== trail.getRef());
|
||||||
|
this._recent.unshift(trail.getRef());
|
||||||
|
this._save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmarked Trails
|
||||||
|
get bookmarks() {
|
||||||
|
return this._bookmarks;
|
||||||
|
}
|
||||||
|
|
||||||
|
addBookmark(trail: DataTrail) {
|
||||||
|
this._bookmarks.unshift(trail.getRef());
|
||||||
|
this._save();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBookmark(index: number) {
|
||||||
|
if (index < this._bookmarks.length) {
|
||||||
|
this._bookmarks.splice(index, 1);
|
||||||
|
this._save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: TrailStore | undefined;
|
||||||
|
export function getTrailStore(): TrailStore {
|
||||||
|
if (!store) {
|
||||||
|
store = new TrailStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
@ -8,6 +8,8 @@ export interface ActionViewDefinition {
|
|||||||
getScene: () => SceneObject;
|
getScene: () => SceneObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TRAILS_ROUTE = '/data-trails/trail';
|
||||||
|
|
||||||
export const VAR_METRIC_NAMES = 'metricNames';
|
export const VAR_METRIC_NAMES = 'metricNames';
|
||||||
export const VAR_FILTERS = 'filters';
|
export const VAR_FILTERS = 'filters';
|
||||||
export const VAR_FILTERS_EXPR = '{${filters}}';
|
export const VAR_FILTERS_EXPR = '{${filters}}';
|
||||||
@ -23,6 +25,10 @@ export const KEY_SQR_METRIC_VIZ_QUERY = 'sqr-metric-viz-query';
|
|||||||
|
|
||||||
export const trailDS = { uid: VAR_DATASOURCE_EXPR };
|
export const trailDS = { uid: VAR_DATASOURCE_EXPR };
|
||||||
|
|
||||||
|
// Local storage keys
|
||||||
|
export const RECENT_TRAILS_KEY = 'grafana.trails.recent';
|
||||||
|
export const BOOKMARKED_TRAILS_KEY = 'grafana.trails.bookmarks';
|
||||||
|
|
||||||
export type MakeOptional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
|
export type MakeOptional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
|
||||||
|
|
||||||
export function getVariablesWithMetricConstant(metric: string) {
|
export function getVariablesWithMetricConstant(metric: string) {
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { urlUtil } from '@grafana/data';
|
import { urlUtil } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
import { getUrlSyncManager, sceneGraph, SceneObject, SceneTimeRange } from '@grafana/scenes';
|
import { getUrlSyncManager, sceneGraph, SceneObject, SceneObjectUrlValues, SceneTimeRange } from '@grafana/scenes';
|
||||||
|
|
||||||
import { DataTrail } from './DataTrail';
|
import { DataTrail } from './DataTrail';
|
||||||
import { DataTrailSettings } from './DataTrailSettings';
|
import { DataTrailSettings } from './DataTrailSettings';
|
||||||
import { MetricScene } from './MetricScene';
|
import { MetricScene } from './MetricScene';
|
||||||
|
import { TRAILS_ROUTE } from './shared';
|
||||||
|
|
||||||
export function getTrailFor(model: SceneObject): DataTrail {
|
export function getTrailFor(model: SceneObject): DataTrail {
|
||||||
return sceneGraph.getAncestor(model, DataTrail);
|
return sceneGraph.getAncestor(model, DataTrail);
|
||||||
@ -25,7 +26,11 @@ export function newMetricsTrail(): DataTrail {
|
|||||||
|
|
||||||
export function getUrlForTrail(trail: DataTrail) {
|
export function getUrlForTrail(trail: DataTrail) {
|
||||||
const params = getUrlSyncManager().getUrlState(trail);
|
const params = getUrlSyncManager().getUrlState(trail);
|
||||||
return urlUtil.renderUrl('/data-trails/trail', params);
|
return getUrlForValues(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUrlForValues(values: SceneObjectUrlValues) {
|
||||||
|
return urlUtil.renderUrl(TRAILS_ROUTE, values);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMetricSceneFor(model: SceneObject): MetricScene {
|
export function getMetricSceneFor(model: SceneObject): MetricScene {
|
||||||
|
Reference in New Issue
Block a user