Merge remote-tracking branch 'origin/main' into resource-store

This commit is contained in:
Ryan McKinley
2024-06-21 07:49:24 +03:00
9 changed files with 457 additions and 113 deletions
+45 -1
View File
@@ -2921,6 +2921,40 @@ volumes:
path: /var/run/docker.sock path: /var/run/docker.sock
name: docker name: docker
--- ---
clone:
retries: 3
depends_on: []
image_pull_secrets:
- gcr
- gar
kind: pipeline
name: verify-grafanacom-artifacts
node:
type: no-parallel
platform:
arch: amd64
os: linux
services: []
steps:
- commands:
- apk add curl bash
- "\n for i in {1..5}; do\n if ./scripts/drone/verify-grafanacom.sh;
then\n exit 0\n elif [ $i -eq 5 ]; then\n exit
1\n else\n sleep 60\n fi\n done\n
\ "
depends_on: []
image: node:20.9.0-alpine
name: verify-grafanacom
trigger:
event:
- promote
target: verify-grafanacom-artifacts
type: docker
volumes:
- host:
path: /var/run/docker.sock
name: docker
---
clone: clone:
retries: 3 retries: 3
depends_on: depends_on:
@@ -3001,6 +3035,16 @@ steps:
from_secret: grafana_api_key from_secret: grafana_api_key
image: grafana/grafana-ci-deploy:1.3.3 image: grafana/grafana-ci-deploy:1.3.3
name: publish-grafanacom name: publish-grafanacom
- commands:
- apk add curl bash
- "\n for i in {1..5}; do\n if ./scripts/drone/verify-grafanacom.sh;
then\n exit 0\n elif [ $i -eq 5 ]; then\n exit
1\n else\n sleep 60\n fi\n done\n
\ "
depends_on:
- publish-grafanacom
image: node:20.9.0-alpine
name: verify-grafanacom
trigger: trigger:
event: event:
- promote - promote
@@ -4952,6 +4996,6 @@ kind: secret
name: gcr_credentials name: gcr_credentials
--- ---
kind: signature kind: signature
hmac: ba86e9c1fb16bb20bff8d56f158ea31f32c3e44f6d517a04ae774fc28f9101e7 hmac: 06f574902baa67d8885abb48e48987f675d7637e30d4b783b3bb84e51b46cdaf
... ...
+37 -16
View File
@@ -1,36 +1,57 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React from 'react'; import React, { useMemo } from 'react';
import { dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; import { dateTimeFormat, GrafanaTheme2 } from '@grafana/data';
import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes'; import { AdHocFiltersVariable, sceneGraph } from '@grafana/scenes';
import { Card, IconButton, Stack, Tag, useStyles2 } from '@grafana/ui'; import { Card, IconButton, Stack, Tag, useStyles2 } from '@grafana/ui';
import { DataTrail } from './DataTrail'; import { DataTrail } from './DataTrail';
import { getTrailStore, DataTrailBookmark } from './TrailStore/TrailStore';
import { VAR_FILTERS } from './shared'; import { VAR_FILTERS } from './shared';
import { getDataSource, getDataSourceName, getMetricName } from './utils'; import { getDataSource, getDataSourceName, getMetricName } from './utils';
export interface Props { export type Props = {
trail: DataTrail; trail?: DataTrail;
onSelect: (trail: DataTrail) => void; bookmark?: DataTrailBookmark;
onSelect: () => void;
onDelete?: () => void; onDelete?: () => void;
} };
export function DataTrailCard({ trail, onSelect, onDelete }: Props) { export function DataTrailCard(props: Props) {
const { onSelect, onDelete, bookmark } = props;
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail)!; const values = useMemo(() => {
if (!(filtersVariable instanceof AdHocFiltersVariable)) { let trail = props.trail || (bookmark && getTrailStore().getTrailForBookmark(bookmark));
if (!trail) {
return null;
}
const filtersVariable = sceneGraph.lookupVariable(VAR_FILTERS, trail)!;
if (!(filtersVariable instanceof AdHocFiltersVariable)) {
return null;
}
const createdAt = bookmark?.createdAt || trail.state.createdAt;
return {
dsValue: getDataSource(trail),
filters: filtersVariable.state.filters,
metric: trail.state.metric,
createdAt,
};
}, [props.trail, bookmark]);
if (!values) {
return null; return null;
} }
const filters = filtersVariable.state.filters; const { dsValue, filters, metric, createdAt } = values;
const dsValue = getDataSource(trail);
const onClick = () => onSelect(trail);
return ( return (
<Card onClick={onClick} className={styles.card}> <Card onClick={onSelect} className={styles.card}>
<Card.Heading>{getMetricName(trail.state.metric)}</Card.Heading> <Card.Heading>{getMetricName(metric)}</Card.Heading>
<div className={styles.description}> <div className={styles.description}>
<Stack gap={1.5} wrap="wrap"> <Stack gap={1.5} wrap="wrap">
{filters.map((f) => ( {filters.map((f) => (
@@ -43,9 +64,9 @@ export function DataTrailCard({ trail, onSelect, onDelete }: Props) {
<div className={styles.secondary}> <div className={styles.secondary}>
<b>Datasource:</b> {getDataSourceName(dsValue)} <b>Datasource:</b> {getDataSourceName(dsValue)}
</div> </div>
{trail.state.createdAt && ( {createdAt && (
<i className={styles.secondary}> <i className={styles.secondary}>
<b>Created:</b> {dateTimeFormat(trail.state.createdAt, { format: 'LL' })} <b>Created:</b> {dateTimeFormat(createdAt, { format: 'LL' })}
</i> </i>
)} )}
</Stack> </Stack>
+16 -12
View File
@@ -10,7 +10,7 @@ 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 { getBookmarkKey, getTrailStore } from './TrailStore/TrailStore';
import { reportExploreMetrics } from './interactions'; import { reportExploreMetrics } from './interactions';
import { getDatasourceForNewTrail, getUrlForTrail, newMetricsTrail } from './utils'; import { getDatasourceForNewTrail, getUrlForTrail, newMetricsTrail } from './utils';
@@ -29,9 +29,17 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
app.goToUrlForTrail(trail); app.goToUrlForTrail(trail);
}; };
public onSelectTrail = (trail: DataTrail, isBookmark: boolean) => { public onSelectRecentTrail = (trail: DataTrail) => {
const app = getAppFor(this); const app = getAppFor(this);
reportExploreMetrics('exploration_started', { cause: isBookmark ? 'bookmark_clicked' : 'recent_clicked' }); reportExploreMetrics('exploration_started', { cause: 'recent_clicked' });
getTrailStore().setRecentTrail(trail);
app.goToUrlForTrail(trail);
};
public onSelectBookmark = (bookmarkIndex: number) => {
const app = getAppFor(this);
reportExploreMetrics('exploration_started', { cause: 'bookmark_clicked' });
const trail = getTrailStore().getTrailForBookmarkIndex(bookmarkIndex);
getTrailStore().setRecentTrail(trail); getTrailStore().setRecentTrail(trail);
app.goToUrlForTrail(trail); app.goToUrlForTrail(trail);
}; };
@@ -52,9 +60,6 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
return <Redirect to={getUrlForTrail(trail)} />; return <Redirect to={getUrlForTrail(trail)} />;
} }
const onSelectRecent = (trail: DataTrail) => model.onSelectTrail(trail, false);
const onSelectBookmark = (trail: DataTrail) => model.onSelectTrail(trail, true);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Stack direction={'column'} gap={1} alignItems={'start'}> <Stack direction={'column'} gap={1} alignItems={'start'}>
@@ -73,7 +78,7 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
<DataTrailCard <DataTrailCard
key={(resolvedTrail.state.key || '') + index} key={(resolvedTrail.state.key || '') + index}
trail={resolvedTrail} trail={resolvedTrail}
onSelect={onSelectRecent} onSelect={() => model.onSelectRecentTrail(resolvedTrail)}
/> />
); );
})} })}
@@ -83,13 +88,12 @@ export class DataTrailsHome extends SceneObjectBase<DataTrailsHomeState> {
<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}>
{getTrailStore().bookmarks.map((trail, index) => { {getTrailStore().bookmarks.map((bookmark, index) => {
const resolvedTrail = trail.resolve();
return ( return (
<DataTrailCard <DataTrailCard
key={(resolvedTrail.state.key || '') + index} key={getBookmarkKey(bookmark)}
trail={resolvedTrail} bookmark={bookmark}
onSelect={onSelectBookmark} onSelect={() => model.onSelectBookmark(index)}
onDelete={() => onDelete(index)} onDelete={() => onDelete(index)}
/> />
); );
@@ -4,7 +4,7 @@ import { DataSourceType } from 'app/features/alerting/unified/utils/datasource';
import { MockDataSourceSrv, mockDataSource } from '../../alerting/unified/mocks'; import { MockDataSourceSrv, mockDataSource } from '../../alerting/unified/mocks';
import { DataTrail } from '../DataTrail'; import { DataTrail } from '../DataTrail';
import { BOOKMARKED_TRAILS_KEY, RECENT_TRAILS_KEY, VAR_FILTERS } from '../shared'; import { TRAIL_BOOKMARKS_KEY, RECENT_TRAILS_KEY, VAR_FILTERS } from '../shared';
import { SerializedTrail, getTrailStore } from './TrailStore'; import { SerializedTrail, getTrailStore } from './TrailStore';
@@ -482,11 +482,95 @@ describe('TrailStore', () => {
}); });
}); });
describe('Initialize store with one bookmark trail', () => { describe('Initialize store with one bookmark trail but no recent trails', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
localStorage.setItem( localStorage.setItem(
BOOKMARKED_TRAILS_KEY, TRAIL_BOOKMARKS_KEY,
JSON.stringify([
{
urlValues: {
metric: 'bookmarked_metric',
from: 'now-1h',
to: 'now',
'var-ds': 'prom-mock',
'var-filters': [],
refresh: '',
},
type: 'time',
},
])
);
getTrailStore().load();
});
const store = getTrailStore();
it('should have no recent trails', () => {
expect(store.recent.length).toBe(0);
});
it('should accurately load bookmarked trails xx', () => {
expect(store.bookmarks.length).toBe(1);
const trail = store.getTrailForBookmarkIndex(0);
expect(trail.state.metric).toBe('bookmarked_metric');
});
it('should save a new recent trail based on the bookmark', () => {
expect(store.recent.length).toBe(0);
const trail = store.getTrailForBookmarkIndex(0);
// Trail and history must be activated first
trail.activate();
trail.state.history.activate();
store.setRecentTrail(trail);
expect(store.recent.length).toBe(1);
});
it('should be able to obtain index of bookmark', () => {
const trail = store.getTrailForBookmarkIndex(0);
const index = store.getBookmarkIndex(trail);
expect(index).toBe(0);
});
it('index should be undefined for removed bookmarks', () => {
const trail = store.getTrailForBookmarkIndex(0);
store.removeBookmark(0);
const index = store.getBookmarkIndex(trail);
expect(index).toBe(undefined);
});
it('index should be undefined for a trail that has changed since it was bookmarked', () => {
const trail = store.getTrailForBookmarkIndex(0);
trail.setState({ metric: 'something_completely_different' });
const index = store.getBookmarkIndex(trail);
expect(index).toBe(undefined);
});
it('should be able to obtain index of a bookmark for a trail that changed back to bookmarked state', () => {
const trail = store.getTrailForBookmarkIndex(0);
const bookmarkedMetric = trail.state.metric;
trail.setState({ metric: 'something_completely_different' });
trail.setState({ metric: bookmarkedMetric });
const index = store.getBookmarkIndex(trail);
expect(index).toBe(0);
});
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(TRAIL_BOOKMARKS_KEY)).toBe('[]');
});
});
describe('Initialize store with one legacy bookmark trail', () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem(
TRAIL_BOOKMARKS_KEY,
JSON.stringify([ JSON.stringify([
{ {
history: [ history: [
@@ -526,66 +610,18 @@ describe('TrailStore', () => {
expect(store.recent.length).toBe(0); expect(store.recent.length).toBe(0);
}); });
it('should accurately load bookmarked trails', () => { it('should accurately load legacy bookmark', () => {
expect(store.bookmarks.length).toBe(1); expect(store.bookmarks.length).toBe(1);
const trail = store.bookmarks[0].resolve(); const trail = store.getTrailForBookmarkIndex(0);
expect(trail.state.history.state.steps.length).toBe(2); expect(trail.state.metric).toBe('access_permissions_duration_count');
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 be able to obtain index of bookmark', () => {
const trail = store.bookmarks[0].resolve();
const index = store.getBookmarkIndex(trail);
expect(index).toBe(0);
});
it('index should be undefined for removed bookmarks', () => {
const trail = store.bookmarks[0].resolve();
store.removeBookmark(0);
const index = store.getBookmarkIndex(trail);
expect(index).toBe(undefined);
});
it('index should be undefined for a trail that has changed since it was bookmarked', () => {
const trail = store.bookmarks[0].resolve();
trail.setState({ metric: 'something_completely_different' });
const index = store.getBookmarkIndex(trail);
expect(index).toBe(undefined);
});
it('should be able to obtain index of a bookmark for a trail that changed back to bookmarked state', () => {
const trail = store.bookmarks[0].resolve();
const bookmarkedMetric = trail.state.metric;
trail.setState({ metric: 'something_completely_different' });
trail.setState({ metric: bookmarkedMetric });
const index = store.getBookmarkIndex(trail);
expect(index).toBe(0);
});
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('[]');
}); });
}); });
describe('Initialize store with one bookmark trail not on final step', () => { describe('Initialize store with one legacy bookmark trail not bookmarked on final step', () => {
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
localStorage.setItem( localStorage.setItem(
BOOKMARKED_TRAILS_KEY, TRAIL_BOOKMARKS_KEY,
JSON.stringify([ JSON.stringify([
{ {
history: [ history: [
@@ -635,9 +671,87 @@ describe('TrailStore', () => {
expect(store.recent.length).toBe(0); expect(store.recent.length).toBe(0);
}); });
it('should accurately load bookmarked trails', () => { it('should accurately load legacy bookmark', () => {
expect(store.bookmarks.length).toBe(1); expect(store.bookmarks.length).toBe(1);
const trail = store.bookmarks[0].resolve(); const trail = store.getTrailForBookmarkIndex(0);
expect(trail.state.metric).toBe('bookmarked_metric');
});
});
describe('Initialize store with one bookmark matching recent trail not on final step', () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem(
RECENT_TRAILS_KEY,
JSON.stringify([
{
history: [
{
urlValues: {
from: 'now-1h',
to: 'now',
'var-ds': 'prom-mock',
'var-filters': [],
refresh: '',
},
type: 'start',
},
{
urlValues: {
metric: 'bookmarked_metric',
from: 'now-1h',
to: 'now',
'var-ds': 'prom-mock',
'var-filters': [],
refresh: '',
},
type: 'time',
},
{
urlValues: {
metric: 'some_other_metric',
from: 'now-1h',
to: 'now',
'var-ds': 'prom-mock',
'var-filters': [],
refresh: '',
},
type: 'metric',
},
],
currentStep: 1,
},
])
);
localStorage.setItem(
TRAIL_BOOKMARKS_KEY,
JSON.stringify([
{
urlValues: {
metric: 'bookmarked_metric',
from: 'now-1h',
to: 'now',
'var-ds': 'prom-mock',
'var-filters': [],
refresh: '',
},
type: 'time',
},
])
);
getTrailStore().load();
});
const store = getTrailStore();
it('should have 1 recent trail', () => {
expect(store.recent.length).toBe(1);
});
it('should accurately load bookmarked trail from matching recent', () => {
expect(store.bookmarks.length).toBe(1);
expect(store.recent.length).toBe(1);
const trail = store.getTrailForBookmarkIndex(0);
expect(trail.state.history.state.steps.length).toBe(3); expect(trail.state.history.state.steps.length).toBe(3);
expect(trail.state.history.state.steps[0].type).toBe('start'); expect(trail.state.history.state.steps[0].type).toBe('start');
expect(trail.state.history.state.steps[1].type).toBe('time'); expect(trail.state.history.state.steps[1].type).toBe('time');
@@ -645,34 +759,34 @@ describe('TrailStore', () => {
}); });
it('should save a new recent trail based on the bookmark', () => { it('should save a new recent trail based on the bookmark', () => {
expect(store.recent.length).toBe(0); expect(store.recent.length).toBe(1);
const trail = store.bookmarks[0].resolve(); const trail = store.getTrailForBookmarkIndex(0);
store.setRecentTrail(trail); store.setRecentTrail(trail);
expect(store.recent.length).toBe(1); expect(store.recent.length).toBe(1);
}); });
it('should be able to obtain index of bookmark', () => { it('should be able to obtain index of bookmark', () => {
const trail = store.bookmarks[0].resolve(); const trail = store.getTrailForBookmarkIndex(0);
const index = store.getBookmarkIndex(trail); const index = store.getBookmarkIndex(trail);
expect(index).toBe(0); expect(index).toBe(0);
}); });
it('index should be undefined for removed bookmarks', () => { it('index should be undefined for removed bookmarks', () => {
const trail = store.bookmarks[0].resolve(); const trail = store.getTrailForBookmarkIndex(0);
store.removeBookmark(0); store.removeBookmark(0);
const index = store.getBookmarkIndex(trail); const index = store.getBookmarkIndex(trail);
expect(index).toBe(undefined); expect(index).toBe(undefined);
}); });
it('index should be undefined for a trail that has changed since it was bookmarked', () => { it('index should be undefined for a trail that has changed since it was bookmarked', () => {
const trail = store.bookmarks[0].resolve(); const trail = store.getTrailForBookmarkIndex(0);
trail.setState({ metric: 'something_completely_different' }); trail.setState({ metric: 'something_completely_different' });
const index = store.getBookmarkIndex(trail); const index = store.getBookmarkIndex(trail);
expect(index).toBe(undefined); expect(index).toBe(undefined);
}); });
it('should be able to obtain index of a bookmark for a trail that changed back to bookmarked state', () => { it('should be able to obtain index of a bookmark for a trail that changed back to bookmarked state', () => {
const trail = store.bookmarks[0].resolve(); const trail = store.getTrailForBookmarkIndex(0);
trail.setState({ metric: 'something_completely_different' }); trail.setState({ metric: 'something_completely_different' });
expect(store.getBookmarkIndex(trail)).toBe(undefined); expect(store.getBookmarkIndex(trail)).toBe(undefined);
trail.setState({ metric: 'bookmarked_metric' }); trail.setState({ metric: 'bookmarked_metric' });
@@ -684,7 +798,7 @@ describe('TrailStore', () => {
store.removeBookmark(0); store.removeBookmark(0);
expect(store.bookmarks.length).toBe(0); expect(store.bookmarks.length).toBe(0);
jest.advanceTimersByTime(2000); jest.advanceTimersByTime(2000);
expect(localStorage.getItem(BOOKMARKED_TRAILS_KEY)).toBe('[]'); expect(localStorage.getItem(TRAIL_BOOKMARKS_KEY)).toBe('[]');
}); });
}); });
}); });
@@ -7,7 +7,8 @@ import { dispatch } from 'app/store/store';
import { notifyApp } from '../../../core/reducers/appNotification'; import { notifyApp } from '../../../core/reducers/appNotification';
import { DataTrail } from '../DataTrail'; import { DataTrail } from '../DataTrail';
import { TrailStepType } from '../DataTrailsHistory'; import { TrailStepType } from '../DataTrailsHistory';
import { BOOKMARKED_TRAILS_KEY, RECENT_TRAILS_KEY } from '../shared'; import { TRAIL_BOOKMARKS_KEY, RECENT_TRAILS_KEY } from '../shared';
import { newMetricsTrail } from '../utils';
import { createBookmarkSavedNotification } from './utils'; import { createBookmarkSavedNotification } from './utils';
@@ -20,13 +21,18 @@ export interface SerializedTrail {
description: string; description: string;
parentIndex: number; parentIndex: number;
}>; }>;
currentStep: number; currentStep?: number; // Assume last step in history if not specified
createdAt?: number; createdAt?: number;
} }
export interface DataTrailBookmark {
urlValues: SceneObjectUrlValues;
createdAt: number;
}
export class TrailStore { export class TrailStore {
private _recent: Array<SceneObjectRef<DataTrail>> = []; private _recent: Array<SceneObjectRef<DataTrail>> = [];
private _bookmarks: Array<SceneObjectRef<DataTrail>> = []; private _bookmarks: DataTrailBookmark[] = [];
private _save: () => void; private _save: () => void;
constructor() { constructor() {
@@ -38,8 +44,7 @@ export class TrailStore {
.map((trail) => this._serializeTrail(trail.resolve())); .map((trail) => this._serializeTrail(trail.resolve()));
localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify(serializedRecent)); localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify(serializedRecent));
const serializedBookmarks = this._bookmarks.map((trail) => this._serializeTrail(trail.resolve())); localStorage.setItem(TRAIL_BOOKMARKS_KEY, JSON.stringify(this._bookmarks));
localStorage.setItem(BOOKMARKED_TRAILS_KEY, JSON.stringify(serializedBookmarks));
}; };
this._save = debounce(doSave, 1000); this._save = debounce(doSave, 1000);
@@ -51,9 +56,9 @@ export class TrailStore {
}); });
} }
private _loadFromStorage(key: string) { private _loadRecentTrailsFromStorage() {
const list: Array<SceneObjectRef<DataTrail>> = []; const list: Array<SceneObjectRef<DataTrail>> = [];
const storageItem = localStorage.getItem(key); const storageItem = localStorage.getItem(RECENT_TRAILS_KEY);
if (storageItem) { if (storageItem) {
const serializedTrails: SerializedTrail[] = JSON.parse(storageItem); const serializedTrails: SerializedTrail[] = JSON.parse(storageItem);
@@ -65,6 +70,25 @@ export class TrailStore {
return list; 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 { private _deserializeTrail(t: SerializedTrail): DataTrail {
// reconstruct the trail based on the serialized history // reconstruct the trail based on the serialized history
const trail = new DataTrail({ createdAt: t.createdAt }); const trail = new DataTrail({ createdAt: t.createdAt });
@@ -107,6 +131,31 @@ export class TrailStore {
}; };
} }
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) { private _loadFromUrl(node: SceneObject, urlValues: SceneObjectUrlValues) {
const urlState = urlUtil.renderUrl('', urlValues); const urlState = urlUtil.renderUrl('', urlValues);
sceneUtils.syncStateFromSearchParams(node, new URLSearchParams(urlState)); sceneUtils.syncStateFromSearchParams(node, new URLSearchParams(urlState));
@@ -118,8 +167,8 @@ export class TrailStore {
} }
load() { load() {
this._recent = this._loadFromStorage(RECENT_TRAILS_KEY); this._recent = this._loadRecentTrailsFromStorage();
this._bookmarks = this._loadFromStorage(BOOKMARKED_TRAILS_KEY); this._bookmarks = this._loadBookmarksFromStorage();
this._refreshBookmarkIndexMap(); this._refreshBookmarkIndexMap();
} }
@@ -152,8 +201,14 @@ export class TrailStore {
} }
addBookmark(trail: DataTrail) { addBookmark(trail: DataTrail) {
const bookmark = new DataTrail(sceneUtils.cloneSceneObjectState(trail.state)); const urlState = getUrlSyncManager().getUrlState(trail);
this._bookmarks.unshift(bookmark.getRef());
const bookmarkState: DataTrailBookmark = {
urlValues: urlState,
createdAt: Date.now(),
};
this._bookmarks.unshift(bookmarkState);
this._refreshBookmarkIndexMap(); this._refreshBookmarkIndexMap();
this._save(); this._save();
dispatch(notifyApp(createBookmarkSavedNotification())); dispatch(notifyApp(createBookmarkSavedNotification()));
@@ -178,9 +233,7 @@ export class TrailStore {
private _refreshBookmarkIndexMap() { private _refreshBookmarkIndexMap() {
this._bookmarkIndexMap.clear(); this._bookmarkIndexMap.clear();
this._bookmarks.forEach((bookmarked, index) => { this._bookmarks.forEach((bookmarked, index) => {
const trail = bookmarked.resolve(); const key = getBookmarkKey(bookmarked);
const key = getBookmarkKey(trail);
// If there are duplicate bookmarks, the latest index will be kept // If there are duplicate bookmarks, the latest index will be kept
this._bookmarkIndexMap.set(key, index); this._bookmarkIndexMap.set(key, index);
}); });
@@ -190,24 +243,36 @@ export class TrailStore {
function getUrlStateForComparison(trail: DataTrail) { function getUrlStateForComparison(trail: DataTrail) {
const urlState = getUrlSyncManager().getUrlState(trail); const urlState = getUrlSyncManager().getUrlState(trail);
// Make a few corrections // Make a few corrections
correctUrlStateForComparison(urlState);
return urlState;
}
function correctUrlStateForComparison(urlState: SceneObjectUrlValues) {
// Omit some URL parameters that are not useful for state comparison, // Omit some URL parameters that are not useful for state comparison,
// as they can change in the URL without creating new steps // as they can change in the URL without creating new steps
delete urlState.actionView; delete urlState.actionView;
delete urlState.layout; delete urlState.layout;
delete urlState.metricSearch; delete urlState.metricSearch;
delete urlState.refresh;
// Populate defaults // Populate defaults
if (urlState['var-groupby'] === '') { if (urlState['var-groupby'] === '' || urlState['var-groupby'] === undefined) {
urlState['var-groupby'] = '$__all'; urlState['var-groupby'] = '$__all';
} }
if (typeof urlState['var-filters'] !== 'string') {
urlState['var-filters'] = urlState['var-filters']?.filter((filter) => filter !== '');
}
return urlState; return urlState;
} }
function getBookmarkKey(trail: DataTrail) { export function getBookmarkKey(trail: DataTrail | DataTrailBookmark) {
const key = JSON.stringify(getUrlStateForComparison(trail)); if (trail instanceof DataTrail) {
return key; return JSON.stringify(getUrlStateForComparison(trail));
}
return JSON.stringify(correctUrlStateForComparison({ ...trail.urlValues }));
} }
let store: TrailStore | undefined; let store: TrailStore | undefined;
@@ -218,3 +283,7 @@ export function getTrailStore(): TrailStore {
return store; return store;
} }
function isSerializedTrail(serialized: unknown): serialized is SerializedTrail {
return serialized != null && typeof serialized === 'object' && 'history' in serialized;
}
+2 -1
View File
@@ -31,7 +31,8 @@ export const trailDS = { uid: VAR_DATASOURCE_EXPR };
// Local storage keys // Local storage keys
export const RECENT_TRAILS_KEY = 'grafana.trails.recent'; export const RECENT_TRAILS_KEY = 'grafana.trails.recent';
export const BOOKMARKED_TRAILS_KEY = 'grafana.trails.bookmarks';
export const TRAIL_BOOKMARKS_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>;
+12
View File
@@ -21,6 +21,7 @@ load(
"remote_alertmanager_integration_tests_steps", "remote_alertmanager_integration_tests_steps",
"verify_gen_cue_step", "verify_gen_cue_step",
"verify_gen_jsonnet_step", "verify_gen_jsonnet_step",
"verify_grafanacom_step",
"wire_install_step", "wire_install_step",
"yarn_install_step", "yarn_install_step",
) )
@@ -203,6 +204,7 @@ def publish_packages_pipeline():
publish_linux_packages_step(package_manager = "deb"), publish_linux_packages_step(package_manager = "deb"),
publish_linux_packages_step(package_manager = "rpm"), publish_linux_packages_step(package_manager = "rpm"),
publish_grafanacom_step(ver_mode = "release"), publish_grafanacom_step(ver_mode = "release"),
verify_grafanacom_step(),
] ]
deps = [ deps = [
@@ -211,6 +213,16 @@ def publish_packages_pipeline():
] ]
return [ return [
pipeline(
name = "verify-grafanacom-artifacts",
trigger = {
"event": ["promote"],
"target": "verify-grafanacom-artifacts",
},
steps = [
verify_grafanacom_step(depends_on = []),
],
),
pipeline( pipeline(
name = "publish-packages", name = "publish-packages",
trigger = trigger, trigger = trigger,
+28
View File
@@ -1152,6 +1152,34 @@ def publish_grafanacom_step(ver_mode):
], ],
} }
def verify_grafanacom_step(depends_on = ["publish-grafanacom"]):
return {
"name": "verify-grafanacom",
"image": images["node"],
"commands": [
# Download and install `curl` and `bash` - both of which aren't available inside of the `node:{version}-alpine` docker image.
"apk add curl bash",
# There may be a slight lag between when artifacts are uploaded to Google Storage,
# and when they become available on the website. This `for` loop sould account for that discrepancy.
# We attempt the verification up to 5 times. If successful, exit the loop with a success (0) status.
# If any attempt fails, but it's not the final attempt, wait 60 seconds before the next attempt.
# If the 5th (final) attempt fails, exit with error (1) status.
"""
for i in {1..5}; do
if ./scripts/drone/verify-grafanacom.sh; then
exit 0
elif [ $i -eq 5 ]; then
exit 1
else
sleep 60
fi
done
""",
],
"depends_on": depends_on,
}
def publish_linux_packages_step(package_manager = "deb"): def publish_linux_packages_step(package_manager = "deb"):
return { return {
"name": "publish-linux-packages-{}".format(package_manager), "name": "publish-linux-packages-{}".format(package_manager),
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
version=${1:-$TAG}
# Construct the URL based on the provided version and edition
if [ "$EDITION" = "enterprise" ]; then
url="https://grafana.com/api/downloads/grafana-enterprise/versions/$version"
else
url="https://grafana.com/api/downloads/grafana/versions/$version"
fi
# Make a request to the GCOM API to retrieve the artifacts for the specified version. Exit if the request fails.
if ! artifacts=$(curl "$url"); then
echo "Failed to retrieve artifact URLs from Grafana.com API. Please check the API key, authentication, edition, and version."
exit 1
fi
# Use Node.js to parse the JSON response and extract the download URLs
url_string=$(node -e "
const artifacts = JSON.parse(JSON.stringify($artifacts));
const downloadUrls = artifacts.packages.map((package) => package.links.find((link) => link.rel === 'download').href);
console.log(downloadUrls.join(' '));
")
# Convert the url_string to a Bash array
read -r -a urls <<< "$url_string"
# If empty, no artifact URLs were found for the specified version. Exit with an error.
if [ ${#urls[@]} -eq 0 ]; then
echo "No artifact URLs found for version $version. Please check the provided version."
exit 1
fi
# Iterate over the URLs and check the status code of each. If any URL does not return a 200 status code, add it to the failed_urls string.
failed_urls=""
for url in "${urls[@]}"; do
status_code=$(curl -L -s -o /dev/null -w "%{http_code}" "$url")
if [ "$status_code" -ne 200 ]; then
failed_urls+="$url\n"
fi
done
# If any URLs failed, print them and exit with an error.
if [ -n "$failed_urls" ]; then
echo "The following URLs did not return a 200 status code:"
echo "$failed_urls"
exit 1
else
echo "All URLs returned a 200 status code. Download links are valid for version $version."
exit 0
fi